mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
1504 lines
54 KiB
Go
1504 lines
54 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/owncord/server/api"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/service"
|
|
"github.com/owncord/server/storage"
|
|
)
|
|
|
|
// testPermSvc wires a PermissionService around the test DB so
|
|
// MountUploadRoutes can enforce its non-nil contract. The tests don't
|
|
// exercise per-channel ACLs directly — they go through the live
|
|
// permissions.Checker, which is the production path anyway.
|
|
func testPermSvc(database *db.DB) *service.PermissionService {
|
|
return service.NewPermissionService(database, permissions.NewChecker(database))
|
|
}
|
|
|
|
// ─── schema for upload tests ─────────────────────────────────────────────────
|
|
|
|
var uploadTestSchema = []byte(`
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
color TEXT,
|
|
permissions INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
is_default INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES
|
|
(1, 'Owner', '#E74C3C', 2147483647, 100, 0),
|
|
(2, 'Admin', '#F39C12', 1073741823, 80, 0),
|
|
(3, 'Moderator', '#3498DB', 1048575, 60, 0),
|
|
(4, 'Member', NULL, 1635, 40, 1);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
password TEXT NOT NULL,
|
|
avatar TEXT,
|
|
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
|
|
totp_secret TEXT,
|
|
status TEXT NOT NULL DEFAULT 'offline',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_seen TEXT,
|
|
banned INTEGER NOT NULL DEFAULT 0,
|
|
ban_reason TEXT,
|
|
ban_expires TEXT,
|
|
identity_public_key TEXT,
|
|
display_name TEXT,
|
|
about TEXT,
|
|
custom_status TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT NOT NULL UNIQUE,
|
|
device TEXT,
|
|
ip_address TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
|
|
|
-- AuthMiddleware falls through to an API-token lookup whenever a bearer
|
|
-- token matches no session (auth.ResolveTokenHash), so this table must exist
|
|
-- even in upload-only fixtures — otherwise an ordinary "no such session"
|
|
-- lookup for a garbage/unknown token hits GetActiveAPIToken and fails with a
|
|
-- real "no such table" SQL error instead of the intended not-found sentinel.
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
label TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_used_at TEXT,
|
|
expires_at TEXT,
|
|
revoked_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS channels (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'text',
|
|
category TEXT NOT NULL DEFAULT '',
|
|
topic TEXT NOT NULL DEFAULT '',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
slow_mode INTEGER NOT NULL DEFAULT 0,
|
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
voice_max_users INTEGER NOT NULL DEFAULT 0,
|
|
is_group INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
edited_at TEXT,
|
|
deleted INTEGER NOT NULL DEFAULT 0,
|
|
mentions_everyone INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (message_id, mentioned_user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id TEXT PRIMARY KEY,
|
|
message_id INTEGER,
|
|
filename TEXT NOT NULL,
|
|
stored_as TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
width INTEGER,
|
|
height INTEGER,
|
|
uploader_id INTEGER REFERENCES users(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS dm_participants (
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
|
opened INTEGER NOT NULL DEFAULT 1,
|
|
PRIMARY KEY (user_id, channel_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS channel_overrides (
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
|
role_id INTEGER NOT NULL REFERENCES roles(id),
|
|
allow INTEGER NOT NULL DEFAULT 0,
|
|
deny INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (channel_id, role_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS channel_user_overrides (
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
allow INTEGER NOT NULL DEFAULT 0,
|
|
deny INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (channel_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
INSERT OR IGNORE INTO settings (key, value) VALUES
|
|
('server_name', 'OwnCord Server'),
|
|
('motd', 'Welcome!');
|
|
`)
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func newUploadTestDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
migrFS := fstest.MapFS{"001_schema.sql": {Data: uploadTestSchema}}
|
|
if err := db.MigrateFS(database, migrFS); err != nil {
|
|
t.Fatalf("MigrateFS: %v", err)
|
|
}
|
|
return database
|
|
}
|
|
|
|
func newUploadTestStorage(t *testing.T) *storage.Storage {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
store, err := storage.New(dir, 10) // 10 MB max
|
|
if err != nil {
|
|
t.Fatalf("storage.New: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func buildUploadRouter(database *db.DB, store *storage.Storage, allowedOrigins []string) http.Handler {
|
|
r := chi.NewRouter()
|
|
limiter := auth.NewRateLimiter()
|
|
api.MountUploadRoutes(r, database, store, limiter, allowedOrigins, testPermSvc(database))
|
|
return r
|
|
}
|
|
|
|
func buildUploadRouterWithLimiter(database *db.DB, store *storage.Storage, limiter *auth.RateLimiter, allowedOrigins []string) http.Handler {
|
|
r := chi.NewRouter()
|
|
if limiter == nil {
|
|
limiter = auth.NewRateLimiter()
|
|
}
|
|
api.MountUploadRoutes(r, database, store, limiter, allowedOrigins, testPermSvc(database))
|
|
return r
|
|
}
|
|
|
|
// uploadCreateToken creates a user+session and returns the plaintext token.
|
|
func uploadCreateToken(t *testing.T, database *db.DB, username string, roleID int) string {
|
|
t.Helper()
|
|
_, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID)
|
|
if err != nil {
|
|
t.Fatalf("CreateUser %q: %v", username, err)
|
|
}
|
|
token := "upload-test-token-" + username
|
|
hash := auth.HashToken(token)
|
|
_, err = database.ExecContext(context.Background(),
|
|
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
|
SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`,
|
|
hash, username,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert session for %q: %v", username, err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
// makeMultipartFile builds a multipart form body with a single "file" field.
|
|
func makeMultipartFile(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
|
|
t.Helper()
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile(fieldName, filename)
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile: %v", err)
|
|
}
|
|
if _, err := io.Copy(part, bytes.NewReader(content)); err != nil {
|
|
t.Fatalf("writing file part: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("closing multipart writer: %v", err)
|
|
}
|
|
return body, writer.FormDataContentType()
|
|
}
|
|
|
|
// makePNGBytes generates a small valid PNG image and returns its raw bytes.
|
|
func makePNGBytes(t *testing.T, width, height int) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
for y := range height {
|
|
for x := range width {
|
|
img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("png.Encode: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func doUpload(t *testing.T, router http.Handler, token, fieldName, filename string, content []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body, contentType := makeMultipartFile(t, fieldName, filename, content)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func doServeFile(t *testing.T, router http.Handler, fileID, token string, headers map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/"+fileID, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// ─── MountUploadRoutes ──────────────────────────────────────────────────────
|
|
|
|
func TestUpload_RoutesAreMounted(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "routeuser", 1)
|
|
|
|
// POST /api/v1/uploads should not return 404/405.
|
|
body, contentType := makeMultipartFile(t, "file", "test.txt", []byte("hello"))
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code == http.StatusNotFound || rr.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /api/v1/uploads returned %d, route not mounted", rr.Code)
|
|
}
|
|
|
|
// GET /api/v1/files/{id} should not return 405 (401 or 404 are valid).
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/files/some-id", nil)
|
|
req2.RemoteAddr = "127.0.0.1:9999"
|
|
rr2 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr2, req2)
|
|
if rr2.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("GET /api/v1/files/{id} returned 405, route not mounted")
|
|
}
|
|
}
|
|
|
|
// ─── handleUpload ───────────────────────────────────────────────────────────
|
|
|
|
func TestUpload_Success_TextFile(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "uploader1", 1)
|
|
|
|
content := []byte("hello world this is a text file with enough bytes for detection")
|
|
rr := doUpload(t, router, token, "file", "notes.txt", content)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["filename"] != "notes.txt" {
|
|
t.Errorf("filename = %v, want notes.txt", resp["filename"])
|
|
}
|
|
if resp["url"] == nil || resp["url"] == "" {
|
|
t.Error("expected non-empty url in response")
|
|
}
|
|
if resp["id"] == nil || resp["id"] == "" {
|
|
t.Error("expected non-empty id in response")
|
|
}
|
|
if resp["mime"] == nil || resp["mime"] == "" {
|
|
t.Error("expected non-empty mime in response")
|
|
}
|
|
|
|
// Verify attachment record was created in DB.
|
|
att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
}
|
|
if att == nil {
|
|
t.Fatal("expected attachment record in DB, got nil")
|
|
}
|
|
if att.Filename != "notes.txt" {
|
|
t.Errorf("DB filename = %q, want notes.txt", att.Filename)
|
|
}
|
|
}
|
|
|
|
func TestUpload_Success_PNGImage(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "imguploader", 1)
|
|
|
|
pngData := makePNGBytes(t, 16, 8)
|
|
rr := doUpload(t, router, token, "file", "image.png", pngData)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["mime"] != "image/png" {
|
|
t.Errorf("mime = %v, want image/png", resp["mime"])
|
|
}
|
|
// Image upload should include dimensions.
|
|
if resp["width"] == nil {
|
|
t.Error("expected width for image upload")
|
|
}
|
|
if resp["height"] == nil {
|
|
t.Error("expected height for image upload")
|
|
}
|
|
if int(resp["width"].(float64)) != 16 {
|
|
t.Errorf("width = %v, want 16", resp["width"])
|
|
}
|
|
if int(resp["height"].(float64)) != 8 {
|
|
t.Errorf("height = %v, want 8", resp["height"])
|
|
}
|
|
}
|
|
|
|
func TestUpload_Unauthenticated(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
|
|
rr := doUpload(t, router, "", "file", "test.txt", []byte("hello"))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpload_InvalidToken(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
|
|
rr := doUpload(t, router, "invalid-token-123", "file", "test.txt", []byte("hello"))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpload_MissingFileField(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "nofield", 1)
|
|
|
|
// Upload with wrong field name "attachment" instead of "file".
|
|
rr := doUpload(t, router, token, "attachment", "test.txt", []byte("hello world"))
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["message"] != "missing file field" {
|
|
t.Errorf("message = %v, want 'missing file field'", resp["message"])
|
|
}
|
|
}
|
|
|
|
func TestUpload_InvalidMultipartForm(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "badform", 1)
|
|
|
|
// Send a request with Content-Type claiming multipart but with a plain body.
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", bytes.NewReader([]byte("not multipart")))
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=nonexistent")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpload_BlockedFileType_Executable(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "exeuploader", 1)
|
|
|
|
// PE executable starts with "MZ".
|
|
exeContent := append([]byte("MZ"), make([]byte, 100)...)
|
|
rr := doUpload(t, router, token, "file", "malware.exe", exeContent)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
msg, _ := resp["message"].(string)
|
|
if msg == "" {
|
|
t.Error("expected non-empty error message for blocked file type")
|
|
}
|
|
}
|
|
|
|
func TestUpload_BlockedFileType_ShellScript(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "shuploader", 1)
|
|
|
|
// Shell script starts with "#!".
|
|
shContent := []byte("#!/bin/bash\necho hello\n")
|
|
rr := doUpload(t, router, token, "file", "script.sh", shContent)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpload_BlockedFileType_ELF(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "elfuploader", 1)
|
|
|
|
// ELF binary starts with \x7fELF.
|
|
elfContent := append([]byte("\x7fELF"), make([]byte, 100)...)
|
|
rr := doUpload(t, router, token, "file", "binary", elfContent)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpload_RateLimitedAfterBurst(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildUploadRouterWithLimiter(database, store, limiter, nil)
|
|
token := uploadCreateToken(t, database, "burstuser", 1)
|
|
otherToken := uploadCreateToken(t, database, "otherburstuser", 1)
|
|
content := []byte("upload payload with enough bytes for content type detection")
|
|
|
|
for range 10 {
|
|
rr := doUpload(t, router, token, "file", "burst.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("pre-limit upload status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
rr := doUpload(t, router, token, "file", "burst.txt", content)
|
|
if rr.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("rate-limited upload status = %d, want 429; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode rate-limit response: %v", err)
|
|
}
|
|
if resp["error"] != "RATE_LIMITED" {
|
|
t.Errorf("error = %v, want RATE_LIMITED", resp["error"])
|
|
}
|
|
|
|
rr = doUpload(t, router, otherToken, "file", "burst.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("other user upload status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpload_OversizedFileRejected(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
dir := t.TempDir()
|
|
store, err := storage.New(dir, 1)
|
|
if err != nil {
|
|
t.Fatalf("storage.New: %v", err)
|
|
}
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "largeupload", 1)
|
|
content := bytes.Repeat([]byte("a"), (1<<20)+1)
|
|
|
|
rr := doUpload(t, router, token, "file", "too-large.txt", content)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("oversized upload status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode oversized response: %v", err)
|
|
}
|
|
message, _ := resp["message"].(string)
|
|
if !strings.Contains(message, "file exceeds maximum size") {
|
|
t.Fatalf("message = %q, want size rejection", message)
|
|
}
|
|
if resp["error"] != "BAD_REQUEST" {
|
|
t.Errorf("error = %v, want BAD_REQUEST", resp["error"])
|
|
}
|
|
}
|
|
|
|
// OC-0137: storage.Save's error strings embed the resolved absolute
|
|
// destination path ("creating file %s", "syncing file %s", "resolved path %q
|
|
// escapes storage directory"). handleUpload must not forward that text to the
|
|
// client — only log it — or any authenticated user who triggers a storage
|
|
// failure (disk full, permission change, read-only mount) learns the
|
|
// server's absolute storage directory layout.
|
|
func TestUpload_StorageErrorDoesNotLeakPath(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
dir := t.TempDir()
|
|
store, err := storage.New(dir, 10)
|
|
if err != nil {
|
|
t.Fatalf("storage.New: %v", err)
|
|
}
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "leakuser", 1)
|
|
|
|
// Remove the storage directory out from under the already-constructed
|
|
// Storage so Save's os.Create fails — this is what a disk-full,
|
|
// permission-change, or read-only-mount failure looks like from the
|
|
// handler's point of view: a storage-layer error surfaces at Save time.
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
t.Fatalf("RemoveAll: %v", err)
|
|
}
|
|
|
|
content := []byte("content that will fail to persist because the storage dir is gone")
|
|
rr := doUpload(t, router, token, "file", "leaktest.txt", content)
|
|
// Server-side filesystem failures are 507 (storage.ErrIO) so they are
|
|
// distinguishable from bad uploads; the no-leak contract is unchanged.
|
|
if rr.Code != http.StatusInsufficientStorage {
|
|
t.Fatalf("status = %d, want 507; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
message, _ := resp["message"].(string)
|
|
if strings.Contains(message, dir) {
|
|
t.Fatalf("response message leaks the absolute storage path: %q", message)
|
|
}
|
|
if strings.ContainsAny(message, `/\`) {
|
|
t.Fatalf("response message looks like it contains a filesystem path: %q", message)
|
|
}
|
|
}
|
|
|
|
func TestUpload_DBCreateAttachmentFailureDeletesStoredFile(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
dir := t.TempDir()
|
|
store, err := storage.New(dir, 10)
|
|
if err != nil {
|
|
t.Fatalf("storage.New: %v", err)
|
|
}
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "dbfailupload", 1)
|
|
|
|
if _, err := database.ExecContext(context.Background(), `DROP TABLE attachments`); err != nil {
|
|
t.Fatalf("drop attachments table: %v", err)
|
|
}
|
|
|
|
content := []byte("content that will save to disk before attachment insert fails")
|
|
rr := doUpload(t, router, token, "file", "cleanup.txt", content)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("upload status = %d, want 500; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode db failure response: %v", err)
|
|
}
|
|
if resp["error"] != "INTERNAL_ERROR" {
|
|
t.Errorf("error = %v, want INTERNAL_ERROR", resp["error"])
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("expected stored file cleanup on DB failure, found %d entries", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestUpload_SanitizesReservedFilenameToUnnamed(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "sanitizeupload", 1)
|
|
content := []byte("content for reserved filename sanitization")
|
|
|
|
rr := doUpload(t, router, token, "file", ".", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["filename"] != "unnamed" {
|
|
t.Fatalf("filename = %v, want unnamed", resp["filename"])
|
|
}
|
|
|
|
att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
}
|
|
if att == nil {
|
|
t.Fatal("expected attachment record in DB, got nil")
|
|
}
|
|
if att.Filename != "unnamed" {
|
|
t.Fatalf("DB filename = %q, want unnamed", att.Filename)
|
|
}
|
|
}
|
|
|
|
// TestUpload_StripsBidiOverrideAndForeignSeparator locks the two gaps in
|
|
// sanitizeUploadFilename. The sanitizer filtered ASCII control bytes only, so
|
|
// U+202E RIGHT-TO-LEFT OVERRIDE survived into attachments.filename and was
|
|
// reflected to every other member of the channel — and into the native save
|
|
// dialog the client pre-fills — making a script display as though it ended in
|
|
// ".txt". Separately, filepath.Base only strips the server OS's separator, so a
|
|
// backslash survived on a Linux server and is a path separator on the victim's
|
|
// Windows client.
|
|
func TestUpload_StripsBidiOverrideAndForeignSeparator(t *testing.T) {
|
|
// Escaped rather than embedded: a literal U+202E would reorder this source
|
|
// file in every editor and terminal that renders it — which is the whole
|
|
// primitive under test.
|
|
const rtlOverride = "\u202e"
|
|
|
|
cases := []struct {
|
|
name string
|
|
upload string
|
|
wantName string
|
|
}{
|
|
{
|
|
name: "bidi override removed",
|
|
upload: "Q3_Report" + rtlOverride + "txt.bat",
|
|
wantName: "Q3_Reporttxt.bat",
|
|
},
|
|
{
|
|
name: "other invisible formatting characters removed",
|
|
upload: "in\u200bvoice\u2066.pdf", // ZERO WIDTH SPACE, LEFT-TO-RIGHT ISOLATE
|
|
wantName: "invoice.pdf",
|
|
},
|
|
{
|
|
name: "backslash path stripped regardless of server OS",
|
|
upload: `..\..\Windows\evil.bat`,
|
|
wantName: "evil.bat",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "bidi"+strings.ReplaceAll(tc.name, " ", ""), 1)
|
|
|
|
rr := doUpload(t, router, token, "file", tc.upload, []byte("@echo off\r\n"))
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
got, _ := resp["filename"].(string)
|
|
if got != tc.wantName {
|
|
t.Errorf("filename = %q, want %q", got, tc.wantName)
|
|
}
|
|
|
|
// The stored record must match — it is what every other client renders.
|
|
att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string))
|
|
if err != nil || att == nil {
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
}
|
|
if att.Filename != tc.wantName {
|
|
t.Errorf("DB filename = %q, want %q", att.Filename, tc.wantName)
|
|
}
|
|
if strings.ContainsAny(att.Filename, "\\/") {
|
|
t.Errorf("stored filename %q still contains a path separator", att.Filename)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpload_SuccessfulUploadCreatesDBRecord(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "dbcheck", 1)
|
|
|
|
content := []byte("some file content for database record verification test")
|
|
rr := doUpload(t, router, token, "file", "dbtest.txt", content)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
|
|
fileID := resp["id"].(string)
|
|
att, err := database.GetAttachmentByID(context.Background(), fileID)
|
|
if err != nil {
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
}
|
|
if att == nil {
|
|
t.Fatal("expected attachment record in DB")
|
|
}
|
|
if att.Filename != "dbtest.txt" {
|
|
t.Errorf("filename = %q, want dbtest.txt", att.Filename)
|
|
}
|
|
if att.Size != int64(len(content)) {
|
|
t.Errorf("size = %d, want %d", att.Size, len(content))
|
|
}
|
|
// message_id should be nil (unlinked upload).
|
|
if att.MessageID != nil {
|
|
t.Errorf("message_id = %v, want nil (unlinked)", att.MessageID)
|
|
}
|
|
}
|
|
|
|
func TestUpload_ResponseFields(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "respfields", 1)
|
|
|
|
content := []byte("response field validation content data")
|
|
rr := doUpload(t, router, token, "file", "fields.dat", content)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
// All required fields should be present.
|
|
requiredFields := []string{"id", "filename", "size", "mime", "url"}
|
|
for _, field := range requiredFields {
|
|
if resp[field] == nil {
|
|
t.Errorf("missing required field %q in response", field)
|
|
}
|
|
}
|
|
|
|
// URL should contain the file ID.
|
|
url, _ := resp["url"].(string)
|
|
id, _ := resp["id"].(string)
|
|
expectedURL := "/api/v1/files/" + id
|
|
if url != expectedURL {
|
|
t.Errorf("url = %q, want %q", url, expectedURL)
|
|
}
|
|
|
|
// Non-image files should not have width/height.
|
|
if resp["width"] != nil {
|
|
t.Errorf("expected nil width for non-image, got %v", resp["width"])
|
|
}
|
|
if resp["height"] != nil {
|
|
t.Errorf("expected nil height for non-image, got %v", resp["height"])
|
|
}
|
|
}
|
|
|
|
// ─── handleServeFile ────────────────────────────────────────────────────────
|
|
|
|
func TestServeFile_Success(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "serve1", 1)
|
|
|
|
// Upload a file first.
|
|
content := []byte("served file content with enough bytes for mime detection")
|
|
rr := doUpload(t, router, token, "file", "served.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
// Serve the file (uploader is also the requester — allowed for unlinked files).
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200; body: %s", rr2.Code, rr2.Body.String())
|
|
}
|
|
|
|
// Verify content type header is set.
|
|
ct := rr2.Header().Get("Content-Type")
|
|
if ct == "" {
|
|
t.Error("expected Content-Type header on served file")
|
|
}
|
|
|
|
// Verify cache control header. Access-controlled downloads must be marked
|
|
// private + no-cache so shared/proxy caches never store them (info-leak).
|
|
cc := rr2.Header().Get("Cache-Control")
|
|
if cc != "private, no-cache" {
|
|
t.Errorf("Cache-Control = %q, want 'private, no-cache'", cc)
|
|
}
|
|
|
|
// Verify Content-Disposition header.
|
|
cd := rr2.Header().Get("Content-Disposition")
|
|
if cd == "" {
|
|
t.Error("expected Content-Disposition header on served file")
|
|
}
|
|
}
|
|
|
|
func TestServeFile_Success_PNG(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "servepng", 1)
|
|
|
|
pngData := makePNGBytes(t, 4, 4)
|
|
rr := doUpload(t, router, token, "file", "icon.png", pngData)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
|
|
ct := rr2.Header().Get("Content-Type")
|
|
if ct != "image/png" {
|
|
t.Errorf("Content-Type = %q, want image/png", ct)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_NotFound(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "notfounduser", 1)
|
|
|
|
rr := doServeFile(t, router, "nonexistent-uuid-12345", token, nil)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_EmptyID(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
|
|
// Request to /api/v1/files/ with no ID should 404 (chi won't match the route).
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/", nil)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_CORS_MatchingOrigin(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, []string{"https://app.example.com"})
|
|
token := uploadCreateToken(t, database, "corsuser", 1)
|
|
|
|
// Upload a file.
|
|
content := []byte("cors test file content with sufficient length for detection")
|
|
rr := doUpload(t, router, token, "file", "cors.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
// Serve with matching origin.
|
|
rr2 := doServeFile(t, router, fileID, token, map[string]string{
|
|
"Origin": "https://app.example.com",
|
|
})
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
acao := rr2.Header().Get("Access-Control-Allow-Origin")
|
|
if acao != "https://app.example.com" {
|
|
t.Errorf("ACAO = %q, want https://app.example.com", acao)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_CORS_NonMatchingOrigin(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, []string{"https://app.example.com"})
|
|
token := uploadCreateToken(t, database, "corsmismatch", 1)
|
|
|
|
content := []byte("cors non-matching test file content with sufficient length")
|
|
rr := doUpload(t, router, token, "file", "cors2.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d", rr.Code)
|
|
}
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
rr2 := doServeFile(t, router, fileID, token, map[string]string{
|
|
"Origin": "https://evil.example.com",
|
|
})
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
acao := rr2.Header().Get("Access-Control-Allow-Origin")
|
|
if acao != "" {
|
|
t.Errorf("ACAO should be empty for non-matching origin, got %q", acao)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_CORS_WildcardOrigin(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, []string{"*"})
|
|
token := uploadCreateToken(t, database, "corswildcard", 1)
|
|
|
|
content := []byte("wildcard cors test file content with sufficient length")
|
|
rr := doUpload(t, router, token, "file", "wild.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d", rr.Code)
|
|
}
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
rr2 := doServeFile(t, router, fileID, token, map[string]string{
|
|
"Origin": "https://anything.example.com",
|
|
})
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
acao := rr2.Header().Get("Access-Control-Allow-Origin")
|
|
if acao != "https://anything.example.com" {
|
|
t.Errorf("ACAO = %q, want https://anything.example.com for wildcard", acao)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_CORS_NoOriginHeader(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, []string{"*"})
|
|
token := uploadCreateToken(t, database, "corsnoorigin", 1)
|
|
|
|
content := []byte("no origin header test file content with sufficient length")
|
|
rr := doUpload(t, router, token, "file", "noorigin.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d", rr.Code)
|
|
}
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
// No Origin header — CORS headers should not be set.
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
acao := rr2.Header().Get("Access-Control-Allow-Origin")
|
|
if acao != "" {
|
|
t.Errorf("ACAO should be empty when no Origin sent, got %q", acao)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_DBRecordMissing_ReturnsNotFound(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "dbmissing", 1)
|
|
|
|
// No file uploaded — DB has no record.
|
|
rr := doServeFile(t, router, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", token, nil)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_StorageFileMissing_ReturnsNotFound(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "storemissing", 1)
|
|
|
|
// Upload a file, then delete it from storage.
|
|
content := []byte("file that will be deleted from storage backend")
|
|
rr := doUpload(t, router, token, "file", "vanish.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
|
|
// Delete the file from storage directly.
|
|
if err := store.Delete(fileID); err != nil {
|
|
t.Fatalf("store.Delete: %v", err)
|
|
}
|
|
|
|
// Serve should return 404 because the file is missing from disk.
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404 for missing storage file", rr2.Code)
|
|
}
|
|
}
|
|
|
|
// ─── Table-driven tests for blocked file types ──────────────────────────────
|
|
|
|
func TestUpload_BlockedFileTypes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
content []byte
|
|
}{
|
|
{"PE executable", "test.exe", append([]byte("MZ"), make([]byte, 50)...)},
|
|
{"ELF binary", "test.bin", append([]byte("\x7fELF"), make([]byte, 50)...)},
|
|
{"Mach-O 64-bit", "test.macho", append([]byte("\xcf\xfa\xed\xfe"), make([]byte, 50)...)},
|
|
{"Mach-O 32-bit", "test.macho32", append([]byte("\xce\xfa\xed\xfe"), make([]byte, 50)...)},
|
|
{"shell script", "test.sh", []byte("#!/bin/bash\necho hello\n")},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, fmt.Sprintf("blocked_%s", tc.name), 1)
|
|
|
|
rr := doUpload(t, router, token, "file", tc.filename, tc.content)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for %s; body: %s", rr.Code, tc.name, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── End-to-end upload then serve round-trip ────────────────────────────────
|
|
|
|
func TestUpload_ThenServe_RoundTrip(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "roundtrip", 1)
|
|
|
|
content := []byte("round trip test content for full upload and serve cycle")
|
|
rr := doUpload(t, router, token, "file", "roundtrip.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
fileID := uploadResp["id"].(string)
|
|
url := uploadResp["url"].(string)
|
|
|
|
// Serve using the URL from the upload response.
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr2 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr2, req)
|
|
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve status = %d, want 200", rr2.Code)
|
|
}
|
|
|
|
// Verify the served content matches what was uploaded.
|
|
servedBody := rr2.Body.Bytes()
|
|
if !bytes.Equal(servedBody, content) {
|
|
t.Errorf("served content length = %d, want %d", len(servedBody), len(content))
|
|
}
|
|
|
|
_ = fileID // used above
|
|
}
|
|
|
|
func TestUpload_ThenServe_PNG_RoundTrip(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "pngrt", 1)
|
|
|
|
pngData := makePNGBytes(t, 32, 32)
|
|
rr := doUpload(t, router, token, "file", "test.png", pngData)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var uploadResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&uploadResp)
|
|
url := uploadResp["url"].(string)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr2 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr2, req)
|
|
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("serve: %d", rr2.Code)
|
|
}
|
|
if rr2.Header().Get("Content-Type") != "image/png" {
|
|
t.Errorf("Content-Type = %q, want image/png", rr2.Header().Get("Content-Type"))
|
|
}
|
|
|
|
// Verify served bytes match original.
|
|
if !bytes.Equal(rr2.Body.Bytes(), pngData) {
|
|
t.Error("served PNG bytes differ from uploaded bytes")
|
|
}
|
|
}
|
|
|
|
// ─── Access Control Tests (BUG-092) ────────────────────────────────────────
|
|
|
|
func TestServeFile_Unauthenticated_Returns401(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "acl401uploader", 1)
|
|
|
|
// Upload a file.
|
|
content := []byte("private file content for unauthenticated access test")
|
|
rr := doUpload(t, router, token, "file", "private.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Request without auth token.
|
|
rr2 := doServeFile(t, router, fileID, "", nil)
|
|
if rr2.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401 for unauthenticated file request", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_UnlinkedFile_UploaderCanAccess(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "acluploader", 1)
|
|
|
|
content := []byte("file owned by uploader for ownership access test")
|
|
rr := doUpload(t, router, token, "file", "mine.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Uploader can access their own unlinked file.
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 for uploader accessing own file", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_UnlinkedFile_OtherUserForbidden(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
uploaderToken := uploadCreateToken(t, database, "aclowner", 4) // Member role
|
|
otherToken := uploadCreateToken(t, database, "aclother", 4) // Member role
|
|
|
|
content := []byte("private file content for other-user forbidden test")
|
|
rr := doUpload(t, router, uploaderToken, "file", "secret.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Other user cannot access unlinked file.
|
|
rr2 := doServeFile(t, router, fileID, otherToken, nil)
|
|
if rr2.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for other user accessing unlinked file", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_AdminBypassesAllChecks(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
uploaderToken := uploadCreateToken(t, database, "acluploaderadmin", 4) // Member
|
|
adminToken := uploadCreateToken(t, database, "acladmin", 1) // Owner (admin)
|
|
|
|
content := []byte("file for admin bypass test content with sufficient bytes")
|
|
rr := doUpload(t, router, uploaderToken, "file", "restricted.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Admin can access any file regardless of ownership.
|
|
rr2 := doServeFile(t, router, fileID, adminToken, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 for admin bypass", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_LinkedToGuildChannel_MemberWithPerm(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token := uploadCreateToken(t, database, "guildmember", 4) // Member role (perms=1635, includes ReadMessages=0x0002)
|
|
|
|
// Upload a file.
|
|
content := []byte("guild channel attachment content for permission test")
|
|
rr := doUpload(t, router, token, "file", "guild.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Create a guild channel and link the attachment via a message.
|
|
_, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel: %v", err)
|
|
}
|
|
// Get the uploader's user ID.
|
|
var userID int64
|
|
if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guildmember'`).Scan(&userID); err != nil {
|
|
t.Fatalf("get user id: %v", err)
|
|
}
|
|
_, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID)
|
|
if err != nil {
|
|
t.Fatalf("insert message: %v", err)
|
|
}
|
|
_, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID)
|
|
if err != nil {
|
|
t.Fatalf("link attachment: %v", err)
|
|
}
|
|
|
|
// Member with ReadMessages should be able to access.
|
|
rr2 := doServeFile(t, router, fileID, token, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 for guild member with READ_MESSAGES", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_LinkedToGuildChannel_MemberWithoutPerm(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
uploaderToken := uploadCreateToken(t, database, "guilduploader2", 1) // Owner (to upload)
|
|
memberToken := uploadCreateToken(t, database, "guildnoperm", 4) // Member
|
|
|
|
// Upload a file.
|
|
content := []byte("guild channel attachment content for denied permission test")
|
|
rr := doUpload(t, router, uploaderToken, "file", "restricted.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Create channel and link.
|
|
_, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'secret', 'text')`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel: %v", err)
|
|
}
|
|
var uploaderID int64
|
|
if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guilduploader2'`).Scan(&uploaderID); err != nil {
|
|
t.Fatalf("get user id: %v", err)
|
|
}
|
|
_, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, uploaderID)
|
|
if err != nil {
|
|
t.Fatalf("insert message: %v", err)
|
|
}
|
|
_, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID)
|
|
if err != nil {
|
|
t.Fatalf("link attachment: %v", err)
|
|
}
|
|
// Deny ReadMessages (0x0002) for role 4 (Member) on channel 1.
|
|
_, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, 0, 2)`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel_override: %v", err)
|
|
}
|
|
|
|
// Member without ReadMessages should get 403.
|
|
rr2 := doServeFile(t, router, fileID, memberToken, nil)
|
|
if rr2.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for guild member without READ_MESSAGES", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_LinkedToDM_ParticipantAllowed(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token1 := uploadCreateToken(t, database, "dmalice", 4)
|
|
_ = uploadCreateToken(t, database, "dmbob", 4)
|
|
|
|
// Upload a file.
|
|
content := []byte("dm attachment content for participant access test")
|
|
rr := doUpload(t, router, token1, "file", "dm.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Create DM channel, add participants, link attachment.
|
|
_, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel: %v", err)
|
|
}
|
|
var aliceID, bobID int64
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmalice'`).Scan(&aliceID)
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmbob'`).Scan(&bobID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, aliceID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, bobID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, aliceID)
|
|
_, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID)
|
|
|
|
// DM participant can access.
|
|
rr2 := doServeFile(t, router, fileID, token1, nil)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 for DM participant", rr2.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeFile_LinkedToDM_NonParticipantForbidden(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token1 := uploadCreateToken(t, database, "dmowner", 4)
|
|
_ = uploadCreateToken(t, database, "dmpartner", 4)
|
|
outsiderToken := uploadCreateToken(t, database, "dmoutsider", 4)
|
|
|
|
// Upload a file.
|
|
content := []byte("dm attachment content for non-participant forbidden test")
|
|
rr := doUpload(t, router, token1, "file", "dmsecret.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Create DM channel with two participants (not the outsider).
|
|
_, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel: %v", err)
|
|
}
|
|
var ownerID, partnerID int64
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmowner'`).Scan(&ownerID)
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmpartner'`).Scan(&partnerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID)
|
|
_, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID)
|
|
|
|
// Non-participant gets 403.
|
|
rr2 := doServeFile(t, router, fileID, outsiderToken, nil)
|
|
if rr2.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for DM non-participant", rr2.Code)
|
|
}
|
|
}
|
|
|
|
// OC-0112: the admin bypass in handleServeFile must not cover the DM
|
|
// participant check. Every sibling DM read gate (requireChannelRead,
|
|
// PermissionService.RequireChannelAccess, checkSendPermission) denies a
|
|
// non-participant Administrator just like anyone else — the file route must
|
|
// match, not open every private DM to anyone holding the admin bit.
|
|
func TestServeFile_LinkedToDM_AdminNonParticipantForbidden(t *testing.T) {
|
|
database := newUploadTestDB(t)
|
|
store := newUploadTestStorage(t)
|
|
router := buildUploadRouter(database, store, nil)
|
|
token1 := uploadCreateToken(t, database, "dmadminowner", 4)
|
|
_ = uploadCreateToken(t, database, "dmadminpartner", 4)
|
|
adminToken := uploadCreateToken(t, database, "dmadminoutsider", 1) // Owner (admin), not a participant
|
|
|
|
// Upload a file.
|
|
content := []byte("dm attachment content for admin non-participant forbidden test")
|
|
rr := doUpload(t, router, token1, "file", "dmadminsecret.txt", content)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("upload: %d; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
fileID := resp["id"].(string)
|
|
|
|
// Create DM channel with two participants (not the admin).
|
|
_, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`)
|
|
if err != nil {
|
|
t.Fatalf("insert channel: %v", err)
|
|
}
|
|
var ownerID, partnerID int64
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmadminowner'`).Scan(&ownerID)
|
|
_ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmadminpartner'`).Scan(&partnerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID)
|
|
_, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID)
|
|
_, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID)
|
|
|
|
// Admin who is not a DM participant must still be denied.
|
|
rr2 := doServeFile(t, router, fileID, adminToken, nil)
|
|
if rr2.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for admin who is not a DM participant", rr2.Code)
|
|
}
|
|
}
|