mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* refactor: move the protocol schema to protocol/schema.json (RL-09)
The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.
The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.
Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
`protocol/` is the cross-component boundary, with a `README.md` naming the
two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
conventional home for an executable. This also empties `Server/scripts/` of
Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
both already excluded `Server/scripts/`. Without this the move would have
silently widened the Docker build context and the air watch set.
27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.
Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.
Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.
Refs RL-09, L-09
* refactor: move the seed tool under Server/cmd/seed (RL-10)
`Server/scripts/seed.go` was a `package main` sitting directly in
`Server/scripts/`, which made `Server/scripts` itself one of the module's
three main packages — a developer tool in the module's build graph under a
directory name that says "loose scripts". It also did filesystem work in
`func init()`: `os.MkdirAll("data", 0o750)` ran before `flag.Parse()`, so the
directory appeared even when the tool immediately refused to run.
The audit row (RL-10) claims that `init()` fires "during test discovery". It
does not, and the obvious fix aimed at that claim would be aimed at nothing:
`Server/scripts/` contains zero `_test.go` files, so Go never builds a test
binary there and `go test ./...` never runs the `init()`. The residual defect
is narrower and real — an untagged `package main` in the build graph, plus a
side effect on a path (`go run ./cmd/seed -h`) that has nothing to do with
tests.
Done:
- `Server/scripts/seed.go` -> `Server/cmd/seed/main.go`, joining
`cmd/genprotocol/` from RL-09. `Server/scripts/` now holds shell and JS
tooling only (docker-smoke.sh, k6/, toxiproxy/, voice-test.sh) and no Go
entry point at all.
- The `os.MkdirAll` moved out of `init()` to immediately before `db.Open` in
`main()` — the one call that needs the directory, since `db.Open` ->
`OpenWithMaxReaders` -> `openFile` creates no intermediate directories.
- The package doc comment's usage lines were wrong in two ways, not one: they
named `go run scripts/seed.go`, which no longer exists, and they omitted
the mandatory `-confirm-dev`, so neither documented command could ever have
run. Both corrected, and `seed.go is a standalone tool` became the
conventional `Command seed populates ...`.
- `Server/CLAUDE.md`'s Layout list now names `cmd/` and states that no Go
entry point lives in `scripts/`.
Two files, 20 insertions, 17 deletions. `go list` main packages go from
`{server, server/cmd/genprotocol, server/scripts}` to `{server,
server/cmd/genprotocol, server/cmd/seed}` — the count is unchanged at three,
which is the honest framing: this relocates a main package to a conventional
path, it does not remove one from the build graph.
Verified: both directions, by building the pre-change file and the
post-change file and running each in a fresh empty directory. Before, `seed`
with no flags exits 1 *and leaves a `data/` directory behind*; `seed -h`
exits 0 and also leaves `data/` behind. After, both exit the same way and
create nothing — `data/ exists=NO` in each case. The happy path is unchanged:
`seed -confirm-dev` in an empty directory creates `data/` at mode 0750,
writes `data/chatserver.db`, and reports 4 users / 5 channels / 31 messages;
a second run reports 0 new rows, so idempotence survives. The old documented
invocation now fails loudly (`go run scripts/seed.go` -> `stat
scripts/seed.go: no such file or directory`) and the new one is what the
comment says. All four build-tag variants compile, `go vet ./...` passes,
`gofmt -l` is clean outside `db/dbgen`, and `npx prettier --check .` passes.
Behaviour delta, called out rather than left silent: the two cases above
(`-h`, and a missing `-confirm-dev`) no longer create `./data`. That is a
change, not a pure relocation. It is the change RL-10 asks for — the remedy
text is "remove import/test-time filesystem side effects" — and the
alternative that preserves the old behaviour exactly, making the `MkdirAll`
the first statement of `main()` before `flag.Parse()`, would keep precisely
the side effect the item exists to remove.
Not included: `Server/scripts/genprotocol` was moved to `Server/cmd/` by the
RL-09 commit rather than here, so the "executable tooling under conventional
command ownership" class is closed across the two commits, not this one
alone. `filepath.Dir(*dbPath)` was evaluated for the `MkdirAll` and rejected:
it would fix a real gap (`-db /elsewhere/x.db` still creates a useless
`./data` and does not create `/elsewhere`) but it means creating an arbitrary
directory from CLI input, and that is a behaviour change past "shift it out
of `init()`" — worth its own item. No `make seed` target was added, and the
dated `docs/audit-*.md` rows naming `Server/scripts/seed.go` keep the old
path. The findings ledger has zero references to this file, so no re-render
was needed.
Refs RL-10, L-10
* test: give the cross-stack contracts a named tier (RL-11)
`Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes
`Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its
location or name said it locks a server-owned artifact, so a Go developer
editing the admin SPA got a red check called "Client Unit Tests" with no clue
why.
The register describes this as one file. It is not, and the measured set does
not match the description in either direction:
- Client -> Server: exactly ONE test crosses by filesystem read, not two.
`main-page.test.ts` was named in the plan but only carries a prose comment
citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no
import, nothing to move.
- Server -> Client: the four tests the plan named do not cross.
`waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0`
literal that appears nowhere under `Client/`; `ws_integration_test.go:289`
and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is
one the register never named: `Server/updater/updater_test.go:630` does
`os.ReadFile` on `Client/src-tauri/tauri.conf.json`.
The obvious fixes are both wrong. Moving the invariant "to the owning server
test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja,
otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the
text level like `admin/perm_grid_test.go` does — and that is not a
substitute. Flipping the guard at `admin/static/index.html:1182` to
`targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every
greppable identifier intact, so a text-level test passes on a broken file.
Relocating it to the e2e admin journey is worse: that job is
`continue-on-error: true` and deliberately unpinned ("requiring it is
theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a
blocking, pinned gate into one that is green regardless. And the journey does
not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"`
over its 142 lines returns 0, so the "if e2e already covers it, delete"
branch never fires.
Done — one tier, applied to the whole set, defined by artifact coupling and
placed by runtime capability:
- New `Client/tests/contract/`, holding
`server-admin-static-channel-perms.test.ts`. Same directory depth, so
`../../../Server/...` still resolves; the body is byte-identical apart from
a header naming the owner and the runner.
- `Server/updater/tauri_key_contract_test.go` splits the one cross-component
Go test out of `updater_test.go` verbatim, same `package updater`. It stays
in Go — placement follows capability, and Go parses JSON fine — so only the
file name has to declare the crossing. Without this the item would have
been "moved one file and declared the class closed".
- `npm run test:contract`, and the tier, the membership rule and a
blocking/non-blocking table in `docs/contributing.md#testing`, which
previously described no tiers at all.
- `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and
`tests/e2e/native` before this; it now lists all seven and states the rule.
`Server/CLAUDE.md` records why the SPA's execution-level invariant is
locked from the client tree, so nobody "fixes" it into a regex.
- Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered;
`.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no
longer describes the TS test surface as `tests/unit/*.test.ts` only.
- Three stale cross-stack pointers of exactly the class this item is about:
`tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named
`docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a
gitignored path); all now name `docs/protocol.md`.
15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest,
tsconfig, eslint, knip or stryker change, and no new pinned check —
`ci.yml`'s `npx vitest run --coverage` has no path filter and
`vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the
move is bit-identical to enforcement before it. That is deliberate: `dev`
pins 11 contexts and a 12th is a branch-protection API write, not something a
PR can do, so any new job would be advisory until someone separately changed
repository settings — strictly less protection than today.
Verified: both directions, and the assertion was not weakened. Flipping
`admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes
the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`);
`git checkout` of that file makes it pass again — so the invariant survived
the move intact rather than becoming a test that passes anywhere. The split
Go test's cross-boundary read is live too: with
`Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails
with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or
directory` from `tauri_key_contract_test.go:20`, and passes once restored.
The full client suite is 192 files / 5257 tests passing, identical to the
count before the move; `npm run typecheck` passes, which proves
`tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts`
still resolves the moved test's `import { JSDOM }`. `npm run lint`,
`npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass.
`git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside
the B1 plan itself.
Not included: nothing was deleted, because no e2e sibling covers OC-0154.
`Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still
the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts`
and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in
`tests/unit`: `src-tauri` is inside the `Client` component, so they are not
contract tests, and the rule earns that rather than hand-waving it — moving
them would have forced repoints of ledger entry OC-0089 and
`docs/security.md:64` for no gain. Each gained a one-line header saying why.
`Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own
package's embedded asset and are unchanged; they are the text-level
complement to the execution-level test, not duplicates. No JS engine was
added to `go.mod`, no npm root was created under `Server/`, and no root-level
`tests/` tier was created — there is no runner for one and no way to make it
blocking from a PR. Separately noticed and NOT fixed here:
`docs/contributing.md:221` still says "All ten required checks" while
`docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added
`Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is
stale the same way — that is the branch-protection item's to fix, not this
one's, and one register item per commit.
Refs RL-11, L-11
* refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)
`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.
The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.
Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.
350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).
The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.
Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.
Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.
Refs RL-13, L-12
---------
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/J3vb/OwnCord/Server/api"
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
"github.com/J3vb/OwnCord/Server/storage"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|