Files
OwnCord/Server/api/upload_handler_test.go
T
J3vbandClaude Fable 5 f5faf82a60 infra: observability, backups, guardrails, and deployment hardening (#1376)
* docs: add infrastructure roadmap plan

Records the verified recommendations from an infrastructure review in three
tracks: raising the single-instance ceiling, cheap seams for a possible
multi-instance future, and ops hygiene. Includes explicit anti-recommendations
and sequencing. Security-sensitive detail is intentionally excluded per
docs/security.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* feat(server): real health checks and saturation metrics

/api/v1/metrics now exposes signals that were already computed in memory but
never surfaced: reconnect replay tier hits, event-persister counters, SQLite
writer-pool wait stats, aggregate per-client backpressure counters (including
previously invisible low-priority drops), and permission-cache hit/miss.

/health now returns a real verdict: hub dispatch-loop liveness, a bounded
database ping, and a free-disk check, returning 503 with a subsystem reason
when degraded. Checks are cached so the unauthenticated endpoint cannot
amplify load. The hub's panic breaker now exits the process so a supervisor
can restart it, instead of leaving broadcast delivery silently dead while
clients still appear online.

OTel instruments that were declared but never recorded are now wired
(ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total,
ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds).
Also corrects the docs/api.md description of broadcast_drops, which counts
hub-queue overflow, not client send-queue overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* feat(server): implement scheduled backups, retention, and backup verification

The backup_schedule and backup_retention settings have existed in the admin
panel and API since the initial schema but were never read by any code. The
15-minute maintenance loop now enforces them: a scheduled backup is taken
when the newest backup on disk is older than the schedule interval (manual
backups reset the clock), and retention prunes backups older than the
configured days while always keeping the newest one.

Backups are now verified with PRAGMA integrity_check immediately after
VACUUM INTO (a failed backup is removed rather than listed as restorable)
and again before a restore may overwrite the live database. A failed VACUUM
INTO also cleans up its partial output file — but never a pre-existing one.

The backup directory is configurable via a new backup.dir key (default
data/backups) so operators can point backups at another disk or an off-host
mount, mirroring the SetDatabasePath plumb.

Restore-handler tests now use real SQLite fixtures (the integrity gate
correctly refuses text files) with the mid-copy failure injected through a
test-only copy hook. Also adds audited gosec suppressions to the Windows
disk-free syscall added in the previous commit, which the Windows lint leg
flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* feat(server): capacity and failure-mode guardrails

- server.max_ws_connections: optional cap on concurrent WebSocket clients,
  checked before the upgrade with a 503 + Retry-After; rejections are counted
  and exposed as ws_conn_rejects in /api/v1/metrics.
- Single-process database lock: an OS-level advisory lock (flock / exclusive
  handle) beside the SQLite file makes a second server process fail fast with
  a clear message instead of silently fighting the first over process-local
  state. A bounded retry covers the self-update/restore restart handoff, and
  the lock mechanism failing (e.g. network filesystems) only warns.
- Disk-space awareness: boot-time warnings for the data and backup volumes,
  plus a disk_free_mb metrics field, via a small cross-platform diskutil
  package (already used by /health).
- Upload storage failures: storage.Save now marks server-side filesystem
  failures with a sentinel (storage.ErrIO); handlers return 507 for those
  instead of blaming the client with a 400, and the emoji route stops echoing
  raw storage errors (which embed absolute paths) into responses.
- Unknown config keys now warn at startup — a typo like admin_alowed_cidrs
  previously kept the default silently while the operator believed the
  setting changed. Never fatal: newer servers tolerate older configs.
- Admin settings honesty: the three stored-but-inert settings (server_icon,
  max_upload_bytes, voice_quality) are shown read-only with a note pointing
  at the real config.yaml keys, instead of pretending to apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* perf(db): write-path efficiency and capacity knobs

- channel_focus/mark_read now skip the read-state UPSERT when the stored row
  already matches (same last_message_id, no mentions) — refocus events fire
  at up to 10/s/user and every no-op write still occupied the single SQLite
  writer connection. The extra existence check runs on the reader pool, which
  doesn't serialize. Same shape as the session-touch throttle.
- DeleteExpiredSessions is now sargable: migration 031 normalizes legacy
  expiry formats to the RFC3339-Z layout the server writes and indexes
  expires_at, replacing the strftime full-table scan that ran on the writer
  every 15 minutes.
- Boot-time ANALYZE runs only when a migration actually applied; unchanged
  schemas get the cheap PRAGMA optimize instead (which also covers
  crash-restarts that never reached the shutdown optimize).
- The read/write SQL router gets a table-driven test with explicit expected
  values (INSERT ... RETURNING must hit the writer despite being :one).
- New knobs, all defaulting to current behavior: database.max_readers,
  security.auth_rate_limit_multiplier (for shared-NAT communities),
  event_persistence.replay_ring_size and replay_cold_limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* fix(server): shutdown lifecycle ordering

- The event pruner and maintenance loop are now joined (bounded) before the
  database closes: bgCtx cancellation used to run AFTER database.Close via
  LIFO defers, contradicting its own comment, and neither goroutine was ever
  waited on — a mid-tick scheduled backup or prune could still hold the
  writer while the pool tore down. StartEventPruner returns a done channel
  with the same join contract EventPersister.Stop already had.
- srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers'
  broadcasts still reach a live hub and the event persister instead of
  vanishing from the replay/event store across a restart. Shutdown does not
  wait on hijacked WebSocket connections, so the swap adds no delay.
- GracefulStopContext threads the 30s shutdown budget into the hub: the 5s
  client-notice window (matching the countdown clients are shown) ends early
  when the budget expires, and is skipped entirely when nobody is connected —
  early-return startup paths and idle servers no longer sleep 5s for an
  audience of zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish

- deploy/owncord.service: hardened systemd unit template with the two
  verified caveats encoded (install dir stays writable for self-update under
  ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a
  'Linux (systemd)' deployment docs section — the Linux service story was
  previously 'Docker or nothing'.
- New 'Reverse Proxy Topology' docs section with a working nginx snippet and
  the correct signaling-vs-media distinction: /livekit/* is already proxied
  by the server, only WebRTC media ports must be directly reachable.
- docker-compose: log rotation, commented resource limits, and a healthcheck
  backed by a new 'chatserver healthcheck' subcommand (the distroless image
  has no shell) that probes /health without config side effects.
- release.yml: a concurrency group (queue, never cancel), and boot-smoke
  gates — the freshly built server binaries and the Docker image are cold
  booted and probed healthy BEFORE anything is signed or pushed. The release
  feed drives signed self-updates, so a binary that compiles but dies on
  boot previously would have shipped itself to every auto-updating instance.
- ci.yml: client-check/client-tests move to ubuntu with the reasoning
  recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a
  written graduation criterion instead of an open-ended non-blocking status.
- docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs;
  architecture overview records presence/voice state as the fifth
  single-instance blocker and the macOS client scope decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams

- Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped
  frames, correct message types (typing_start, presence_update), the correct
  /api/v1/ws path, and thresholds that fail a run where nobody authenticated
  or went ready — the script had drifted to pre-envelope framing and reported
  100% green while every auth failed on the first frame. A new
  workflow_dispatch-only load-baseline workflow boots a real server, seeds
  users through the setup/invite APIs, runs the script, and uploads the k6
  summary plus a metrics snapshot for before/after comparison.
- Role-scoped channel-override changes now evict only the affected role's
  members from the permission cache (fail-safe: unreadable member list still
  flushes everything). InvalidateAll here repopulated every connected user —
  two reads each — synchronously inside the admin request via
  RefreshChannelVisibility, a stampede that scaled with total population
  rather than the role's size. Same pattern the per-user override endpoints
  already used.
- Connect/disconnect presence broadcasts now pass through a 300ms latest-wins
  coalescer (QueuePresence): each un-coalesced presence change is a sequenced
  global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm
  fired O(users) of them from the connect critical path. A flap inside the
  window collapses to its final state; the wire format, seq ordering, and
  replay behaviour are unchanged, and the delivery path (BroadcastPresence)
  is untouched.
- Storage seam: api handlers now consume a FileStore interface (consumer-side,
  same pattern as service.Store) with Open returning a seekable storage.File —
  writing down the contract (range-request seeks included) an alternative
  backend would have to meet, without building one.
- The metrics surfaces and the LiveKit webhook/health endpoints get their own
  allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both
  defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an
  externally-hosted LiveKit no longer requires widening the admin panel's
  perimeter. Startup now also warns when admin_allowed_cidrs is customized
  while trusted_proxies is empty — behind a proxy or container network the
  check would otherwise compare the proxy's private address, not the client's.
- The container healthcheck probe now PINS the server's own certificate from
  disk (VerifyConnection, exact-match) instead of skipping TLS verification,
  addressing the CodeQL finding on the previous commit; WebPKI verification
  is used when no local cert exists (ACME).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* fix(server): address self-review findings on the hardening branch

Seven fixes from a high-effort review of the full branch diff:

- healthcheck CLI now works under tls.mode acme: it overrides ServerName
  with the configured domain for WebPKI verification instead of pinning a
  cert that doesn't exist (or is stale) in that mode. Previously an ACME
  deployment's container healthcheck failed forever.
- /health pings the READER pool (new db.PingRead): the writer ping queued
  behind a scheduled backup's VACUUM INTO and reported the server degraded
  for the whole backup — which an autoheal watchdog would turn into a
  nightly mid-backup restart.
- /health runs its cached checks under context.WithoutCancel so a probe
  that disconnects mid-request cannot poison the shared cache with a false
  degraded verdict for the next 5 seconds.
- The token CLI uses a new db.OpenShared that skips the single-process
  lock: minting a token against a running server is safe under WAL and was
  a documented workflow the lock had broken.
- The per-user TOTP failure cap is no longer scaled by
  security.auth_rate_limit_multiplier — that knob exists for per-IP limits;
  scaling the only cross-IP brute-force defence multiplied an attacker's
  distributed guess budget. Mirrors the unscaled per-user login threshold.
- A direct presence_update now drops the user's queued entry in the
  connect/disconnect coalescer, so a stale connect-time presence can no
  longer flush 300ms later over the user's fresher chosen status.
- The scheduled-backup filename collision loop breaks on any stat error
  and bounds its suffix probing, instead of spinning the maintenance
  goroutine forever on a persistent EACCES.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

* test(admin): real SQLite fixture for the merged Close-failure restore test

TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375)
with a plain-text backup fixture; this branch's restore handler verifies
backups with integrity_check before touching the live database, so the text
fixture was (correctly) refused with 400 before the Close-failure branch
under test was reached. Use a real backup via BackupToSafe, matching the
other restore tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 20:50:47 +02:00

1488 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);
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)
}
}