Files
OwnCord/Server/api/auth_handler_test.go
T
J3vbandClaude 9eba6969d2 B1-5: ownership moves (RL-09 / L-09, RL-10 / L-10, RL-11 / L-11, RL-13 / L-12) (#1417)
* 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>
2026-08-27 07:11:18 +02:00

1833 lines
66 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/go-chi/chi/v5"
)
// newAuthTestDB builds an in-memory DB with the full schema needed for auth tests.
func newAuthTestDB(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: apiTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// buildAuthRouter returns a chi router with auth routes mounted on /api/v1/auth.
func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
return buildAuthRouterWithProxies(database, limiter, nil)
}
func buildAuthRouterWithProxies(database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) http.Handler {
r := chi.NewRouter()
api.MountAuthRoutes(r, database, limiter, trustedProxies, testTOTPKey)
return r
}
// testTOTPKey is a fixed 32-byte AES-256 key used in tests.
var testTOTPKey = make([]byte, 32)
// postJSON is a test helper that POSTs JSON to the given router.
func postJSON(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// postJSONWithToken posts with an Authorization header.
func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func postJSONFromIP(t *testing.T, router http.Handler, path string, body any, ip string) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = ip + ":9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// getWithToken performs a GET with an Authorization header.
func getWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// ─── Register tests ───────────────────────────────────────────────────────────
func TestRegister_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Create an invite first.
ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "newuser",
"password": "securePass1",
"invite_code": code,
})
if rr.Code != http.StatusCreated {
t.Errorf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["token"] == nil {
t.Error("Register response missing token")
}
if resp["user"] == nil {
t.Error("Register response missing user")
}
}
func TestRegister_RegistrationClosed(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = '0' WHERE key = 'registration_open'`); err != nil {
t.Fatalf("close registration: %v", err)
}
ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "closeduser",
"password": "securePass1",
"invite_code": code,
})
if rr.Code != http.StatusForbidden {
t.Fatalf("Register status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
func TestRegister_InvalidInvite(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "newuser",
"password": "securePass1",
"invite_code": "bogus",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("Register invalid invite status = %d, want 400", rr.Code)
}
}
func TestRegister_WeakPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "owner2", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "newuser",
"password": "short",
"invite_code": code,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("Register weak password status = %d, want 400", rr.Code)
}
}
func TestRegister_InviteUsedUp(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "owner3", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) // max 1 use
// First registration should succeed.
postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "user1",
"password": "securePass1",
"invite_code": code,
})
// Second should fail — invite exhausted.
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "user2",
"password": "securePass2",
"invite_code": code,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("Register exhausted invite status = %d, want 400", rr.Code)
}
}
func TestRegister_DuplicateUsername_DoesNotConsumeInvite(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "owner4", "hash", 1)
_, _ = database.CreateUser(context.Background(), "takenuser", "hash", 4)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
duplicate := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "takenuser",
"password": "securePass1",
"invite_code": code,
})
if duplicate.Code != http.StatusBadRequest {
t.Fatalf("duplicate username status = %d, want 400; body = %s", duplicate.Code, duplicate.Body.String())
}
success := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "freshuser",
"password": "securePass2",
"invite_code": code,
})
if success.Code != http.StatusCreated {
t.Fatalf("invite should remain usable after failed registration, status = %d, want 201; body = %s", success.Code, success.Body.String())
}
}
func TestRegister_MissingFields(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{})
if rr.Code != http.StatusBadRequest {
t.Errorf("Register missing fields status = %d, want 400", rr.Code)
}
}
func TestRegister_ErrorNeverRevealUsername(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "someone",
"password": "securePass1",
"invite_code": "bogus",
})
body := rr.Body.String()
// Must not hint that the username doesn't exist or the invite is invalid specifically
if contains(body, "username") && contains(body, "taken") {
t.Error("Register error message reveals username status")
}
}
// ─── Login tests ──────────────────────────────────────────────────────────────
func TestLogin_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "loginuser", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "loginuser",
"password": "correctPass1",
})
if rr.Code != http.StatusOK {
t.Errorf("Login status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["token"] == nil {
t.Error("Login response missing token")
}
}
func TestLogin_WrongPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "loginuser2", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "loginuser2",
"password": "wrongpassword",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("Login wrong password status = %d, want 401", rr.Code)
}
}
func TestLogin_UnknownUser(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "nobody",
"password": "anypass123",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("Login unknown user status = %d, want 401", rr.Code)
}
}
func TestLogin_LockoutUsesTrustedForwardedIP(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouterWithProxies(database, limiter, []string{"127.0.0.0/8"})
for range 10 {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(`{"username":"nobody","password":"wrongpass123"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "198.51.100.10")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
}
// Use a different username AND IP to verify per-IP lockout isolation.
// (Same username would be locked by per-username lockout — BUG-110 fix.)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(`{"username":"other","password":"wrongpass123"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "198.51.100.11")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("different forwarded client+user should not inherit another client's lockout, got %d", rr.Code)
}
}
func TestLogin_UsernameLockoutAcrossDifferentIPs(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "lockoutuser", hash, 4)
for i := range 10 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "lockoutuser",
"password": "wrongpassword",
}, fmt.Sprintf("198.51.100.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "lockoutuser",
"password": "wrongpassword",
}, "198.51.100.250")
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("username lockout status = %d, want 429; body = %s", rr.Code, rr.Body.String())
}
}
func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "lockoutcorrect", hash, 4)
for i := range 10 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "lockoutcorrect",
"password": "wrongpassword",
}, fmt.Sprintf("203.0.113.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "lockoutcorrect",
"password": "correctPass1",
}, "203.0.113.250")
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("locked correct-password status = %d, want 429; body = %s", rr.Code, rr.Body.String())
}
}
// TestLogin_UsernameLockoutIgnoresCasing locks F1: the per-username lockout key
// must be case-folded so it matches the DB's COLLATE NOCASE username lookup.
// Otherwise an attacker splits the 9-attempt lockout budget across case variants
// of one account (admin, Admin, ADMIN, …), all of which authenticate the same row.
func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "casehunt", hash, 4)
// Trip the per-username lockout using the lowercase spelling, from many IPs
// so the per-IP limiter is never the binding cap.
for i := range 10 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "casehunt",
"password": "wrongpassword",
}, fmt.Sprintf("198.51.100.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
// A different casing of the SAME account must land in the same lockout bucket.
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "CASEHUNT",
"password": "wrongpassword",
}, "198.51.100.250")
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("case-variant username bypassed the per-username lockout: status = %d, want 429; body = %s", rr.Code, rr.Body.String())
}
}
// TestLogin_ConcurrentBurstCannotExceedUsernameBudget locks F3: the
// per-username failure cap (the only cross-IP brute-force defence) must bind
// concurrent attackers, not just sequential ones. Before the fix the lockout
// was a read-only check followed by a post-bcrypt record, so N concurrent
// requests all passed the stale check and landed N guesses; a distributed
// burst must now land at most the same 10-attempt budget a sequential
// attacker gets.
func TestLogin_ConcurrentBurstCannotExceedUsernameBudget(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "bursttarget", hash, 4)
const burst = 40
start := make(chan struct{})
codes := make([]int, burst)
var wg sync.WaitGroup
for i := range burst {
wg.Go(func() {
raw, _ := json.Marshal(map[string]string{
"username": "bursttarget",
"password": "wrongpassword",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
// Unique IP per request so only the per-username limiter binds.
req.RemoteAddr = fmt.Sprintf("203.0.113.%d:9999", i+1)
rr := httptest.NewRecorder()
<-start
router.ServeHTTP(rr, req)
codes[i] = rr.Code
})
}
close(start)
wg.Wait()
landed, limited := 0, 0
for i, code := range codes {
switch code {
case http.StatusUnauthorized:
landed++
case http.StatusTooManyRequests:
limited++
default:
t.Fatalf("request %d: unexpected status %d", i, code)
}
}
// Sequential budget is 10 landed guesses (9 recorded + the one that trips
// the lockout); a concurrent burst must not exceed it.
if landed > 10 {
t.Fatalf("concurrent burst landed %d password guesses (%d rate-limited), want at most 10", landed, limited)
}
}
// TestLogin_NineFailuresThenCorrectPasswordSucceeds pins the sequential
// accepted-input boundary that the F3 fix must not move: after 9 wrong
// guesses the account owner's correct password still logs in (attempt 10 is
// inside the budget). A fix that reserves attempts pre-compare at the
// original threshold would return 429 here and hand attackers a 9-request
// victim lockout.
func TestLogin_NineFailuresThenCorrectPasswordSucceeds(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "boundaryuser", hash, 4)
for i := range 9 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "boundaryuser",
"password": "wrongpassword",
}, fmt.Sprintf("198.51.100.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "boundaryuser",
"password": "correctPass1",
}, "198.51.100.250")
if rr.Code != http.StatusOK {
t.Fatalf("correct password on attempt 10 status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "resetuser", hash, 4)
for i := range 8 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "resetuser",
"password": "wrongpassword",
}, fmt.Sprintf("192.0.2.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "resetuser",
"password": "correctPass1",
}, "192.0.2.200")
if rr.Code != http.StatusOK {
t.Fatalf("success status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
for i := range 3 {
rr = postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "resetuser",
"password": "wrongpassword",
}, fmt.Sprintf("192.0.2.%d", 201+i))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("post-reset attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
}
func TestLogin_GenericErrorOnBadCredentials(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "nobody",
"password": "anypass123",
})
body := rr.Body.String()
// The response must never reveal whether the user exists
if contains(body, "user not found") || contains(body, "does not exist") {
t.Errorf("Login error reveals user existence: %s", body)
}
}
func TestLogin_RequiresTOTPChallenge(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "totpuser", hash, 4)
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil {
t.Fatalf("set totp secret: %v", err)
}
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser",
"password": "correctPass1",
})
if rr.Code != http.StatusOK {
t.Fatalf("Login status = %d, want 200; 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["requires_2fa"] != true {
t.Fatalf("requires_2fa = %v, want true", resp["requires_2fa"])
}
if resp["partial_token"] == nil || resp["partial_token"] == "" {
t.Fatal("expected partial_token in TOTP challenge response")
}
if token := resp["token"]; token != nil && token != "" {
t.Fatalf("expected no full session token before TOTP verification, got %v", token)
}
}
func TestLogin_UsernameLockoutBlocksTOTPChallenge(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "totplocked", hash, 4)
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil {
t.Fatalf("set totp secret: %v", err)
}
for i := range 10 {
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "totplocked",
"password": "wrongpassword",
}, fmt.Sprintf("198.18.0.%d", i+1))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String())
}
}
rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{
"username": "totplocked",
"password": "correctPass1",
}, "198.18.0.250")
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("locked TOTP login 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 response: %v", err)
}
if resp["partial_token"] != nil {
t.Fatalf("partial_token = %v, want nil when username is locked out", resp["partial_token"])
}
}
func TestVerifyTotp_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "totpverify", hash, 4)
secret := "JBSWY3DPEHPK3PXP"
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil {
t.Fatalf("set totp secret: %v", err)
}
login := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpverify",
"password": "correctPass1",
})
if login.Code != http.StatusOK {
t.Fatalf("Login status = %d, want 200; body = %s", login.Code, login.Body.String())
}
var loginResp map[string]any
if err := json.NewDecoder(login.Body).Decode(&loginResp); err != nil {
t.Fatalf("decode login response: %v", err)
}
partialToken, _ := loginResp["partial_token"].(string)
if partialToken == "" {
t.Fatal("expected partial_token from login")
}
code, err := auth.GenerateTOTPCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
verify := postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken, map[string]string{"code": code})
if verify.Code != http.StatusOK {
t.Fatalf("verify status = %d, want 200; body = %s", verify.Code, verify.Body.String())
}
var verifyResp map[string]any
if err := json.NewDecoder(verify.Body).Decode(&verifyResp); err != nil {
t.Fatalf("decode verify response: %v", err)
}
if verifyResp["token"] == nil || verifyResp["token"] == "" {
t.Fatal("expected full session token after successful TOTP verification")
}
if verifyResp["requires_2fa"] != false {
t.Fatalf("requires_2fa after verify = %v, want false", verifyResp["requires_2fa"])
}
}
func TestEnableConfirmDisableTotp(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "enrolltotp", hash, 4)
token, _ := auth.GenerateToken()
if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
enable := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"})
if enable.Code != http.StatusOK {
t.Fatalf("enable status = %d, want 200; body = %s", enable.Code, enable.Body.String())
}
var enableResp map[string]any
if err := json.NewDecoder(enable.Body).Decode(&enableResp); err != nil {
t.Fatalf("decode enable response: %v", err)
}
qrURI, _ := enableResp["qr_uri"].(string)
if qrURI == "" {
t.Fatal("expected qr_uri from enable response")
}
userBeforeConfirm, err := database.GetUserByID(context.Background(), userID)
if err != nil {
t.Fatalf("GetUserByID before confirm: %v", err)
}
if userBeforeConfirm.TOTPSecret != nil {
t.Fatal("TOTP secret should not be persisted before confirmation")
}
parsed, err := url.Parse(qrURI)
if err != nil {
t.Fatalf("parse qr uri: %v", err)
}
secret := parsed.Query().Get("secret")
if secret == "" {
t.Fatal("expected secret query param in qr_uri")
}
code, err := auth.GenerateTOTPCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{"password": "correctPass1", "code": code})
if confirm.Code != http.StatusNoContent {
t.Fatalf("confirm status = %d, want 204; body = %s", confirm.Code, confirm.Body.String())
}
userAfterConfirm, err := database.GetUserByID(context.Background(), userID)
if err != nil {
t.Fatalf("GetUserByID after confirm: %v", err)
}
if userAfterConfirm.TOTPSecret == nil || *userAfterConfirm.TOTPSecret == "" {
t.Fatal("TOTP secret should be persisted after confirmation")
}
deleteBody, err := json.Marshal(map[string]string{"password": "correctPass1"})
if err != nil {
t.Fatalf("marshal delete body: %v", err)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp", bytes.NewReader(deleteBody))
deleteReq.Header.Set("Authorization", "Bearer "+token)
deleteReq.Header.Set("Content-Type", "application/json")
deleteReq.RemoteAddr = "127.0.0.1:9999"
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusNoContent {
t.Fatalf("disable status = %d, want 204; body = %s", deleteRec.Code, deleteRec.Body.String())
}
userAfterDelete, err := database.GetUserByID(context.Background(), userID)
if err != nil {
t.Fatalf("GetUserByID after delete: %v", err)
}
if userAfterDelete.TOTPSecret != nil {
t.Fatal("TOTP secret should be cleared after disable")
}
}
func TestTOTPManagement_RequiresPasswordConfirmation(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "totppassword", hash, 4)
token, _ := auth.GenerateToken()
if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
enable := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "wrongPass"})
if enable.Code != http.StatusBadRequest {
t.Fatalf("enable status = %d, want 400; body = %s", enable.Code, enable.Body.String())
}
userAfterEnable, err := database.GetUserByID(context.Background(), userID)
if err != nil {
t.Fatalf("GetUserByID after failed enable: %v", err)
}
if userAfterEnable.TOTPSecret != nil {
t.Fatal("TOTP secret should remain unset after failed password confirmation")
}
deleteBody, err := json.Marshal(map[string]string{"password": "wrongPass"})
if err != nil {
t.Fatalf("marshal delete body: %v", err)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp", bytes.NewReader(deleteBody))
deleteReq.Header.Set("Authorization", "Bearer "+token)
deleteReq.Header.Set("Content-Type", "application/json")
deleteReq.RemoteAddr = "127.0.0.1:9999"
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusBadRequest {
t.Fatalf("disable status = %d, want 400; body = %s", deleteRec.Code, deleteRec.Body.String())
}
}
func TestVerifyTotp_ConsumesChallengeAfterRepeatedFailures(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
userID, _ := database.CreateUser(context.Background(), "totplockout", hash, 4)
secret := "JBSWY3DPEHPK3PXP"
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil {
t.Fatalf("set totp secret: %v", err)
}
login := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totplockout",
"password": "correctPass1",
})
if login.Code != http.StatusOK {
t.Fatalf("Login status = %d, want 200; body = %s", login.Code, login.Body.String())
}
var loginResp map[string]any
if err := json.NewDecoder(login.Body).Decode(&loginResp); err != nil {
t.Fatalf("decode login response: %v", err)
}
partialToken, _ := loginResp["partial_token"].(string)
if partialToken == "" {
t.Fatal("expected partial_token from login")
}
for i := range 5 {
verify := postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken, map[string]string{"code": "000000"})
if verify.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401; body = %s", i+1, verify.Code, verify.Body.String())
}
}
code, err := auth.GenerateTOTPCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
verify := postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken, map[string]string{"code": code})
if verify.Code != http.StatusUnauthorized {
t.Fatalf("verify after lockout status = %d, want 401; body = %s", verify.Code, verify.Body.String())
}
}
func TestLogin_Require2FASettingRejectsUsersWithoutEnrollment(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil {
t.Fatalf("enable require_2fa: %v", err)
}
if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'false' WHERE key = 'registration_open'`); err != nil {
t.Fatalf("disable registration_open: %v", err)
}
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "needsenrollment", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "needsenrollment",
"password": "correctPass1",
})
if rr.Code != http.StatusForbidden {
t.Fatalf("Login status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
func TestLogin_BannedUser(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
id, _ := database.CreateUser(context.Background(), "banned", hash, 4)
_ = database.BanUser(context.Background(), id, "violated rules", nil)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "banned",
"password": "correctPass1",
})
if rr.Code != http.StatusForbidden {
t.Errorf("Login banned user status = %d, want 403", rr.Code)
}
}
func TestLogin_MissingFields(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{})
if rr.Code != http.StatusBadRequest {
t.Errorf("Login missing fields status = %d, want 400", rr.Code)
}
}
// ─── Logout tests ─────────────────────────────────────────────────────────────
func TestLogout_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "logoutuser", hash, 4)
token, _ := auth.GenerateToken()
tokenHash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1")
rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil)
if rr.Code != http.StatusNoContent {
t.Errorf("Logout status = %d, want 204", rr.Code)
}
// Session should be gone.
sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash)
if sess != nil {
t.Error("Session still exists after logout")
}
}
func TestLogout_NoAuth(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Logout no auth status = %d, want 401", rr.Code)
}
}
// ─── Me tests ─────────────────────────────────────────────────────────────────
func TestMe_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "meuser", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rr := getWithToken(t, router, "/api/v1/auth/me", token)
if rr.Code != http.StatusOK {
t.Errorf("Me status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["id"] == nil {
t.Error("Me response missing id")
}
if resp["username"] != "meuser" {
t.Errorf("Me username = %v, want meuser", resp["username"])
}
}
func TestMe_NoAuth(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Me no auth status = %d, want 401", rr.Code)
}
}
// ─── Fix 2.5: Password trim fix ───────────────────────────────────────────────
// TestLogin_PasswordWithLeadingSpaceIsPreserved verifies that a password with
// leading whitespace is NOT trimmed, so a user who set " securePass1" can log
// in with " securePass1" and NOT with "securePass1".
func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Hash the password WITH the leading space — this is what was registered.
hash, _ := auth.HashPassword(" securePass1")
_, _ = database.CreateUser(context.Background(), "spacepassuser", hash, 4)
// Login with the exact same password (including space) must succeed.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "spacepassuser",
"password": " securePass1",
})
if rr.Code != http.StatusOK {
t.Errorf("Login space-prefixed password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// TestLogin_PasswordWithLeadingSpaceTrimmedFails verifies that logging in with
// the trimmed version of a space-prefixed password correctly fails.
func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Register with password that has a leading space.
hash, _ := auth.HashPassword(" securePass1")
_, _ = database.CreateUser(context.Background(), "spacepassuser2", hash, 4)
// Login without the leading space must fail.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "spacepassuser2",
"password": "securePass1",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("Login trimmed space password status = %d, want 401; body = %s", rr.Code, rr.Body.String())
}
}
// TestLogin_PasswordWithTrailingSpaceIsPreserved verifies that a password with
// trailing whitespace is NOT trimmed.
func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("securePass1 ")
_, _ = database.CreateUser(context.Background(), "trailingspaceuser", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "trailingspaceuser",
"password": "securePass1 ",
})
if rr.Code != http.StatusOK {
t.Errorf("Login trailing-space password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// TestLogin_UsernameIsStillTrimmed verifies that the username IS still trimmed
// (only the password trim was removed).
func TestLogin_UsernameIsStillTrimmed(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
_, _ = database.CreateUser(context.Background(), "trimuser", hash, 4)
// Username with surrounding spaces should resolve to "trimuser".
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": " trimuser ",
"password": "correctPass1",
})
if rr.Code != http.StatusOK {
t.Errorf("Login space-padded username status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// TestRegister_UsernameNotHTMLEscaped pins OC-0099: handleRegister must not
// persist an HTML-escaped username. A bare bluemonday sanitizer.Sanitize call
// HTML-escapes survivors (' -> &#39;, & -> &amp;, " -> &#34;), so a name like
// "O'Brien" would be stored as "O&#39;Brien" — different from what the user
// typed and from what handleLogin looks up (which only trims).
func TestRegister_UsernameNotHTMLEscaped(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "owner2", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "O'Brien",
"password": "securePass1",
"invite_code": code,
})
if rr.Code != http.StatusCreated {
t.Fatalf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
user, _ := resp["user"].(map[string]any)
if got, want := user["username"], "O'Brien"; got != want {
t.Errorf("registered username = %q, want %q (must not be HTML-escaped)", got, want)
}
stored, err := database.GetUserByUsername(context.Background(), "O'Brien")
if err != nil || stored == nil {
t.Fatalf("GetUserByUsername(%q) = (%v, %v), want a match", "O'Brien", stored, err)
}
}
// TestLogin_UsernameWithApostropheSucceeds pins OC-0099 end-to-end: a user
// who registers with an apostrophe/quote/ampersand in their name must be able
// to log back in with the exact same name. Before the fix, handleRegister
// stored the HTML-escaped form while handleLogin looked up the raw form, so
// this login permanently 401s for any such account.
func TestLogin_UsernameWithApostropheSucceeds(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "owner3", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
regRR := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "O'Brien",
"password": "securePass1",
"invite_code": code,
})
if regRR.Code != http.StatusCreated {
t.Fatalf("Register status = %d, want 201; body = %s", regRR.Code, regRR.Body.String())
}
loginRR := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "O'Brien",
"password": "securePass1",
})
if loginRR.Code != http.StatusOK {
t.Fatalf("Login with registered username status = %d, want 200; body = %s", loginRR.Code, loginRR.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(loginRR.Body).Decode(&resp)
if resp["token"] == nil {
t.Error("Login response missing token")
}
}
// TestLogin_OversizedUsernameRejectedBeforeRateLimiterKey pins OC-0021:
// handleLogin never length-checks req.Username before using it to build
// RateLimiter map keys ("login_user_fail:"+username, "login_user_lock:"+...).
// An unauthenticated caller could otherwise pin an arbitrarily large key in
// the limiter's maps (retained for hours by Cleanup's window) on every
// attempt. The oversized username must be rejected with 400 before any such
// key is ever recorded, mirroring the same 32-rune cap register enforces via
// auth.ValidateUsername before touching anything.
func TestLogin_OversizedUsernameRejectedBeforeRateLimiterKey(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hugeUsername := strings.Repeat("a", 1<<20) // 1 MiB, as in the repro
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": hugeUsername,
"password": "anypass123",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("Login oversized username status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// The route's own per-IP RateLimitMiddleware ("login:"+ip) legitimately
// records one small, IP-bounded window entry regardless of this fix.
// What must NOT happen is handleLogin additionally recording a second
// entry keyed on the 1 MiB username itself (failKey/userFailKey) — that
// unbounded entry is the actual leak, so anything beyond the single
// expected IP entry means the oversized username reached key-building
// code before being rejected.
if windows, lockouts := limiter.Len(); windows > 1 || lockouts != 0 {
t.Errorf("RateLimiter retained state after oversized-username login: windows=%d lockouts=%d, want at most 1/0", windows, lockouts)
}
}
// OC-0151: registerReadRequest ran the fixpoint sanitizer
// (service.SanitizeText) over the raw username *before* auth.ValidateUsername
// applies its 32-rune cap. The sanitizer loops sanitizePass to a fixpoint,
// and nested HTML entities force roughly one extra pass per two nesting
// levels, so the cost is quadratic in the attacker-controlled field length.
// A 16 KB adversarial username measurably takes ~200ms to sanitize on this
// tree (measured up to ~3.4s at 64 KB) — all of it spent before any bound on
// the field is applied, and unauthenticated. The fix must reject an
// oversized username on a cheap byte-length check *before* sanitizing, so
// the rejection is near-instant regardless of payload size.
func TestRegister_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
// doc comment for why this shape is quadratic to sanitize.
hugeUsername := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": hugeUsername,
"password": "securePass1",
"invite_code": "whatever",
})
elapsed := time.Since(start)
if rr.Code != http.StatusBadRequest {
t.Errorf("Register oversized username status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// A guard that runs before sanitizing rejects in well under a
// millisecond; the pre-fix code spends ~200ms in sanitizeToFixpoint on
// this payload before it ever reaches auth.ValidateUsername's length
// check. 150ms gives generous margin over noise while still being far
// below the unguarded cost.
if elapsed > 150*time.Millisecond {
t.Errorf("Register oversized username took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
}
}
// ─── Rate limiting integration test ──────────────────────────────────────────
func TestRegister_RateLimit(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
ownerID, _ := database.CreateUser(context.Background(), "rl_owner", "hash", 1)
// Attempt register 4 times (limit=3) — 4th should be rate-limited.
var lastCode int
for i := range 4 {
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "rl_user" + string(rune('0'+i)),
"password": "securePass1",
"invite_code": code,
})
lastCode = rr.Code
}
if lastCode != http.StatusTooManyRequests {
t.Errorf("Register rate limit: last attempt status = %d, want 429", lastCode)
}
}
// ─── DeleteAccount tests ─────────────────────────────────────────────────────
// deleteJSONWithToken sends a DELETE request with an Authorization header and JSON body.
func deleteJSONWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodDelete, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestDeleteAccount_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
// Create as Member (role_id=4) so the last-admin check does not block deletion.
uid, _ := database.CreateUser(context.Background(), "deleteuser", hash, 4)
token, _ := auth.GenerateToken()
tokenHash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1")
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{
"password": "correctPass1",
})
if rr.Code != http.StatusNoContent {
t.Fatalf("DeleteAccount status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
// User should be anonymised (banned, username changed).
user, err := database.GetUserByID(context.Background(), uid)
if err != nil {
t.Fatalf("GetUserByID after delete: %v", err)
}
if user == nil {
t.Fatal("user row should still exist (soft-delete), got nil")
}
if !user.Banned {
t.Error("expected user to be banned after deletion")
}
if user.Username != "[deleted-1]" && user.Username != "[deleted-"+fmt.Sprintf("%d", uid)+"]" {
t.Errorf("expected anonymised username, got %q", user.Username)
}
// Session should be gone.
sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash)
if sess != nil {
t.Error("session should be deleted after account deletion")
}
}
func TestDeleteAccount_MissingPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "delnopass", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{})
if rr.Code != http.StatusBadRequest {
t.Errorf("DeleteAccount missing password status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestDeleteAccount_WrongPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "delwrong", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{
"password": "wrongPassword1",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("DeleteAccount wrong password status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// Verify user is NOT deleted.
user, _ := database.GetUserByID(context.Background(), uid)
if user == nil || user.Banned {
t.Error("user should not be deleted after wrong password")
}
}
func TestDeleteAccount_LastAdmin(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
// Create as Owner (role_id=1) — the only admin-class user.
uid, _ := database.CreateUser(context.Background(), "lastadmin", hash, 1)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{
"password": "correctPass1",
})
if rr.Code != http.StatusForbidden {
t.Errorf("DeleteAccount last admin status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
// User should still be intact.
user, _ := database.GetUserByID(context.Background(), uid)
if user == nil || user.Banned {
t.Error("last admin should not be deleted")
}
}
func TestDeleteAccount_NoAuth(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/account", bytes.NewReader([]byte(`{"password":"x"}`)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("DeleteAccount no auth status = %d, want 401", rr.Code)
}
}
func TestDeleteAccount_LockoutAfterRepeatedFailures(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "dellockout", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// 3 failures should trigger lockout on the 4th attempt.
for range 4 {
deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{
"password": "wrongPassword1",
})
}
// Even with correct password, should now be locked out.
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{
"password": "correctPass1",
})
if rr.Code != http.StatusTooManyRequests {
t.Errorf("DeleteAccount lockout status = %d, want 429; body = %s", rr.Code, rr.Body.String())
}
}
// ─── ConfirmTOTP additional tests ────────────────────────────────────────────
func TestConfirmTOTP_InvalidCode(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "totpbadcode", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Enable TOTP first to get a pending secret.
enable := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"})
if enable.Code != http.StatusOK {
t.Fatalf("enable status = %d, want 200; body = %s", enable.Code, enable.Body.String())
}
// Confirm with an invalid code.
confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{
"password": "correctPass1",
"code": "000000",
})
if confirm.Code != http.StatusUnauthorized {
t.Errorf("ConfirmTOTP invalid code status = %d, want 401; body = %s", confirm.Code, confirm.Body.String())
}
// Secret should NOT be persisted.
user, _ := database.GetUserByID(context.Background(), uid)
if user.TOTPSecret != nil {
t.Error("TOTP secret should not be persisted after invalid code")
}
}
func TestConfirmTOTP_NoPendingSecret(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "totpnopending", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Confirm without enabling first — no pending secret.
confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{
"password": "correctPass1",
"code": "123456",
})
if confirm.Code != http.StatusBadRequest {
t.Errorf("ConfirmTOTP no pending status = %d, want 400; body = %s", confirm.Code, confirm.Body.String())
}
}
func TestConfirmTOTP_MissingPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "totpnoconfirmpass", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Enable TOTP first.
postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"})
// Confirm without password.
confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{
"code": "123456",
})
if confirm.Code != http.StatusBadRequest {
t.Errorf("ConfirmTOTP missing password status = %d, want 400; body = %s", confirm.Code, confirm.Body.String())
}
}
func TestConfirmTOTP_WrongPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "totpwrongconfirm", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Enable TOTP first.
postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"})
// Confirm with wrong password.
confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{
"password": "wrongPass",
"code": "123456",
})
if confirm.Code != http.StatusBadRequest {
t.Errorf("ConfirmTOTP wrong password status = %d, want 400; body = %s", confirm.Code, confirm.Body.String())
}
}
func TestConfirmTOTP_NoAuth(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/totp/confirm", bytes.NewReader([]byte(`{"password":"x","code":"123456"}`)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ConfirmTOTP no auth status = %d, want 401", rr.Code)
}
}
// ─── DisableTOTP additional tests ────────────────────────────────────────────
func TestDisableTOTP_WrongPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "disabletotpwrong", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Set TOTP secret directly.
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil {
t.Fatalf("set totp secret: %v", err)
}
deleteBody, _ := json.Marshal(map[string]string{"password": "wrongPass"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp", bytes.NewReader(deleteBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("DisableTOTP wrong password status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// TOTP should still be enabled.
user, _ := database.GetUserByID(context.Background(), uid)
if user.TOTPSecret == nil {
t.Error("TOTP secret should still be set after wrong password")
}
}
func TestDisableTOTP_Require2FABlocksDisable(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Enable require_2fa setting.
if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil {
t.Fatalf("enable require_2fa: %v", err)
}
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "disabletotpreq", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
// Set TOTP secret directly.
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil {
t.Fatalf("set totp secret: %v", err)
}
deleteBody, _ := json.Marshal(map[string]string{"password": "correctPass1"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp", bytes.NewReader(deleteBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("DisableTOTP require_2fa status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
// TOTP should still be enabled.
user, _ := database.GetUserByID(context.Background(), uid)
if user.TOTPSecret == nil {
t.Error("TOTP secret should still be set when require_2fa is enabled")
}
}
func TestDisableTOTP_NoAuth(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp", bytes.NewReader([]byte(`{"password":"x"}`)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("DisableTOTP no auth status = %d, want 401", rr.Code)
}
}
// ─── Logout additional tests ─────────────────────────────────────────────────
func TestLogout_InvalidToken(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSONWithToken(t, router, "/api/v1/auth/logout", "invalid-token-value", nil)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Logout invalid token status = %d, want 401", rr.Code)
}
}
func TestLogout_SessionGoneAfterLogout(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "logoutsess", hash, 4)
token, _ := auth.GenerateToken()
tokenHash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1")
// First logout should succeed.
rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("first logout status = %d, want 204", rr.Code)
}
// Second logout with the same token should fail (session already deleted).
rr2 := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil)
if rr2.Code != http.StatusUnauthorized {
t.Errorf("second logout status = %d, want 401", rr2.Code)
}
}
// ─── Me additional tests ─────────────────────────────────────────────────────
func TestMe_ReturnsCorrectUserFields(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "medetailed", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rr := getWithToken(t, router, "/api/v1/auth/me", token)
if rr.Code != http.StatusOK {
t.Fatalf("Me status = %d, want 200; 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)
}
// Verify all expected fields are present.
for _, field := range []string{"id", "username", "status", "role_id", "totp_enabled", "created_at"} {
if _, ok := resp[field]; !ok {
t.Errorf("Me response missing field %q", field)
}
}
if resp["username"] != "medetailed" {
t.Errorf("username = %v, want medetailed", resp["username"])
}
if resp["totp_enabled"] != false {
t.Errorf("totp_enabled = %v, want false", resp["totp_enabled"])
}
}
func TestMe_InvalidToken(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := getWithToken(t, router, "/api/v1/auth/me", "not-a-real-token")
if rr.Code != http.StatusUnauthorized {
t.Errorf("Me invalid token status = %d, want 401", rr.Code)
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// expiredInviteDB creates a DB with an already-expired invite.
func expiredInviteDB(t *testing.T) (*db.DB, string) {
t.Helper()
database := newAuthTestDB(t)
ownerID, _ := database.CreateUser(context.Background(), "expowner", "hash", 1)
past := time.Now().Add(-time.Hour)
code, _ := database.CreateInvite(context.Background(), ownerID, 0, &past)
return database, code
}
func TestRegister_ExpiredInvite(t *testing.T) {
database, code := expiredInviteDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "newuser",
"password": "securePass1",
"invite_code": code,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("Register expired invite status = %d, want 400", rr.Code)
}
}
// TestRegister_UsesTrustedForwardedIP pins OC-0093: handleRegister must
// resolve the client IP through the same trusted-proxy list handleLogin
// uses, not unconditionally use RemoteAddr. Behind a trusted reverse proxy,
// the sessions.ip row registration creates must record the real client, not
// the proxy's own address — otherwise the same client shows two different
// IPs on the "active sessions" screen depending on whether they registered
// or logged in.
func TestRegister_UsesTrustedForwardedIP(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouterWithProxies(database, limiter, []string{"127.0.0.0/8"})
ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte(
`{"username":"newuser","password":"securePass1","invite_code":"`+code+`"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "203.0.113.9")
req.RemoteAddr = "127.0.0.1:9999" // the trusted reverse proxy's own hop
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
token, _ := resp["token"].(string)
if token == "" {
t.Fatal("Register response missing token")
}
sess, err := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token))
if err != nil || sess == nil {
t.Fatalf("GetSessionByTokenHash: %v", err)
}
if sess.IP != "203.0.113.9" {
t.Errorf("session IP = %q, want the trusted-forwarded client IP %q — registration behind a reverse proxy must not record the proxy's own address", sess.IP, "203.0.113.9")
}
}