Files
OwnCord/Server/api/middleware_test.go
T
J3vbandClaude 5202e3fe1e fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)

OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.

OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.

* fix(ws): 1 defect(s) (OC-0196)

A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.

* fix(api): 1 defect(s) (OC-0198)

* fix(ws): 1 defect(s) (OC-0200)

normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.

* fix(api): 1 defect(s) (OC-0202)

* fix(admin): 1 defect(s) (OC-0203)

Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.

* fix(client): 1 defect(s) (OC-0205)

* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)

* fix(client): 1 defect(s) (OC-0208)

* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)

OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.

OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).

OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.

* fix(ws): 1 defect(s) (OC-0211)

* fix(identity): 1 defect(s) (OC-0214)

The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.

* fix(client): 1 defect(s) (OC-0215)

* fix(voice): 1 defect(s) (OC-0216)

* fix(client): 1 defect(s) (OC-0217)

* fix(voice): 1 defect(s) (OC-0219)

rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.

* fix(client): 2 defect(s) (OC-0220, OC-0224)

dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.

updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.

* fix(client): 1 defect(s) (OC-0221)

Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.

* fix(ws): 1 defect(s) (OC-0222)

handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.

* fix(mentions): 1 defect(s) (OC-0223)

* fix(admin): 1 defect(s) (OC-0225)

* fix(client): 1 defect(s) (OC-0226)

* fix(client): 1 defect(s) (OC-0228)

* fix(client): 1 defect(s) (OC-0230)

Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.

* fix(voice): 1 defect(s) (OC-0231)

* fix(client): 1 defect(s) (OC-0232)

Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.

* fix(client): 1 defect(s) (OC-0233)

notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).

* fix(client): 1 defect(s) (OC-0234)

* fix(client): 1 defect(s) (OC-0236)

* fix(ws): 1 defect(s) (OC-0237)

* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

* fix(identity): 1 defect(s) (OC-0195)

Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.

* fix(dm): 1 defect(s) (OC-0199)

handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.

* fix(voice): 1 defect(s) (OC-0206)

vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.

* fix(client): 1 defect(s) (OC-0229)

* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful

The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.

The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.

* fix(dm): 1 defect(s) (OC-0194)

Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.

The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.

Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.

* fix(ws): 1 defect(s) (OC-0210)

* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed

Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.

Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.

OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.

* docs: record the dm_channel_open emission on 1:1 DM creation

POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.

The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 20:45:30 +02:00

1355 lines
46 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// ─── Helpers ─────────────────────────────────────────────────────────────────
func newAPITestDB(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
}
// ok is a trivial handler that responds 200 OK to confirm the middleware
// passed the request through.
func ok(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// bearerToken wraps an HTTP handler with an Authorization header bearing token.
func withBearer(req *http.Request, token string) *http.Request {
req.Header.Set("Authorization", "Bearer "+token)
return req
}
// ─── AuthMiddleware tests ─────────────────────────────────────────────────────
func TestAuthMiddleware_ValidToken(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "alice", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AuthMiddleware valid token status = %d, want %d", rr.Code, http.StatusOK)
}
}
// TestAuthMiddleware_TouchSessionThrottled verifies the last_used write is
// throttled per session: the first authenticated request touches the row, and
// an immediate second request through the same middleware instance does not —
// a hot session costs at most one write per interval instead of one per
// request.
func TestAuthMiddleware_TouchSessionThrottled(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "touchy", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
const sentinel = "2000-01-01 00:00:00"
backdate := func() {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`UPDATE sessions SET last_used = ? WHERE token = ?`, sentinel, hash); err != nil {
t.Fatalf("backdating last_used: %v", err)
}
}
lastUsed := func() string {
t.Helper()
sess, err := database.GetSessionByTokenHash(context.Background(), hash)
if err != nil || sess == nil {
t.Fatalf("GetSessionByTokenHash: %v (sess=%v)", err, sess)
}
return sess.LastUsed
}
do := func() {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
}
// First request: the session has never been touched by this middleware
// instance, so last_used must be written.
backdate()
do()
if lastUsed() == sentinel {
t.Fatal("first request did not touch last_used")
}
// Second request inside the throttle interval: no write.
backdate()
do()
if lastUsed() != sentinel {
t.Error("second request within the throttle interval touched last_used; want it skipped")
}
}
func TestAuthMiddleware_MissingToken(t *testing.T) {
database := newAPITestDB(t)
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("AuthMiddleware no token status = %d, want 401", rr.Code)
}
}
func TestAuthMiddleware_ValidAPIToken(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "botuser", "hash", 4)
token, _ := auth.GenerateToken()
if _, err := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil); err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
var gotUserID int64
h := api.AuthMiddleware(database)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, ok := r.Context().Value(api.UserKey).(*db.User); ok && u != nil {
gotUserID = u.ID
}
// An API-token principal has no login session: SessionKey must be nil.
if s, ok := r.Context().Value(api.SessionKey).(*db.Session); ok && s != nil {
t.Errorf("expected nil session for API-token principal, got %+v", s)
}
w.WriteHeader(http.StatusOK)
}))
req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("API token status = %d, want 200", rr.Code)
}
if gotUserID != uid {
t.Errorf("API token authenticated as user %d, want %d", gotUserID, uid)
}
}
func TestAuthMiddleware_RevokedAPIToken(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "botuser2", "hash", 4)
token, _ := auth.GenerateToken()
id, _ := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil)
if _, err := database.RevokeAPIToken(context.Background(), id); err != nil {
t.Fatalf("RevokeAPIToken: %v", err)
}
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("revoked API token status = %d, want 401", rr.Code)
}
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
database := newAPITestDB(t)
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, "notarealtoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("AuthMiddleware invalid token status = %d, want 401", rr.Code)
}
}
func TestAuthMiddleware_ExpiredSession(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "bob", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
// Insert an already-expired session.
pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05")
_, _ = database.ExecContext(context.Background(),
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`,
uid, hash, "test", "127.0.0.1", pastTime,
)
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("AuthMiddleware expired session status = %d, want 401", rr.Code)
}
}
func TestAuthMiddleware_MalformedAuthHeader(t *testing.T) {
database := newAPITestDB(t)
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
cases := []string{
"Token abc", // wrong scheme
"Bearer", // missing token after Bearer
"abc", // no space
}
for _, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", header)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("AuthMiddleware header=%q status = %d, want 401", header, rr.Code)
}
}
}
// TestAuthMiddleware_DanglingRoleUnauthorized pins the `role == nil` guard:
// GetRoleByID returns (nil, nil) for a role_id with no roles row, so without
// the guard a nil role reached the request context and the request only died
// later, at RequirePermission's own nil check (403) — or not at all on routes
// that have no RequirePermission.
func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) {
database := newAPITestDB(t)
// users.role_id has a FK to roles(id), so the dangling row can only be
// created with FK enforcement momentarily off (db.Open pins the pool to a
// single connection, so the pragma applies to the inserts that follow).
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil {
t.Fatalf("disable foreign keys: %v", err)
}
res, err := database.ExecContext(context.Background(),
`INSERT INTO users (username, password, role_id) VALUES ('dangling', '$2a$12$fake', 999)`)
if err != nil {
t.Fatalf("insert dangling user: %v", err)
}
uid, _ := res.LastInsertId()
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil {
t.Fatalf("re-enable foreign keys: %v", err)
}
token, _ := auth.GenerateToken()
if _, err := database.ExecContext(context.Background(),
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`,
uid, auth.HashToken(token),
); err != nil {
t.Fatalf("insert session: %v", err)
}
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("AuthMiddleware dangling role status = %d, want 401", rr.Code)
}
}
// TestAuthMiddleware_DBErrorIsNotUnauthorized pins OC-0202: a transient DB
// read error while resolving the bearer token (auth.ResolveTokenHash returns
// it WRAPPED, never as a sentinel) must not be reported as 401 UNAUTHORIZED.
// The desktop client treats every 401 as "session expired": it clears auth,
// disconnects the WS, and deletes the stored OS-keyring credential. A DB
// outage is not a bad token, so it must surface as a server-side failure
// (503) instead of tearing down a perfectly valid session.
func TestAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "erin", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
// Close the underlying DB so the next GetSessionByTokenHash call fails
// with a wrapped "database is closed" error rather than sql.ErrNoRows —
// standing in for a transient outage (locked DB, disk I/O error, a
// restore swapping the file underneath the running server).
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code == http.StatusUnauthorized {
t.Errorf("AuthMiddleware DB error status = %d, want non-401 (503)", rr.Code)
}
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("AuthMiddleware DB error status = %d, want 503", rr.Code)
}
}
// ─── RequirePermission tests ──────────────────────────────────────────────────
func TestRequirePermission_Allowed(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "carol", "hash", 4) // Member role = 0x663
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(
api.RequirePermission(permissions.SendMessages)(http.HandlerFunc(ok)),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("RequirePermission allowed status = %d, want 200", rr.Code)
}
}
func TestRequirePermission_Forbidden(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "dave", "hash", 4) // Member role = 0x663
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(
api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("RequirePermission forbidden status = %d, want 403", rr.Code)
}
}
func TestRequirePermission_Administrator_Bypass(t *testing.T) {
database := newAPITestDB(t)
// Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000)
uid, _ := database.CreateUser(context.Background(), "owner", "hash", 1)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
// Any permission should pass for ADMINISTRATOR
h := api.AuthMiddleware(database)(
api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("RequirePermission administrator bypass status = %d, want 200", rr.Code)
}
}
// TestRequirePermission_MultiBitRequiresAllBits pins the one behaviour the
// HasServerPerm consolidation changed: a multi-bit mask is ALL-of, not any-of.
// The previous raw `role.Permissions&perm == 0` test returned 200 here because
// Member holds SendMessages, which was enough to make the mask non-zero.
func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(
api.RequirePermission(permissions.SendMessages | permissions.ManageRoles)(http.HandlerFunc(ok)),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("RequirePermission partial multi-bit mask status = %d, want 403", rr.Code)
}
}
// TestRequirePermission_NoRoleInContext pins the fail-closed branch: the
// server-wide authz chokepoint must deny when the request context carries no
// usable *db.Role. Every other RequirePermission test composes AuthMiddleware,
// which always installs a non-nil role, so without this the guard could be
// rewritten to `if !ok { next.ServeHTTP(w, r); return }` and stay green.
func TestRequirePermission_NoRoleInContext(t *testing.T) {
var nilRole *db.Role
tests := []struct {
name string
ctx func(context.Context) context.Context
}{
{"missing key", func(ctx context.Context) context.Context { return ctx }},
{"typed nil role", func(ctx context.Context) context.Context {
return context.WithValue(ctx, api.RoleKey, nilRole)
}},
{"wrong type", func(ctx context.Context) context.Context {
return context.WithValue(ctx, api.RoleKey, "administrator")
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
downstream := false
h := api.RequirePermission(permissions.ManageServer)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
downstream = true
ok(w, r)
}),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(tt.ctx(req.Context()))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("RequirePermission without role status = %d, want 403", rr.Code)
}
if downstream {
t.Error("RequirePermission without role ran the downstream handler")
}
})
}
}
// ─── RateLimitMiddleware tests ────────────────────────────────────────────────
func TestRateLimitMiddleware_UnderLimit(t *testing.T) {
limiter := auth.NewRateLimiter()
h := api.RateLimitMiddleware(limiter, "test:", 5, time.Minute)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("RateLimitMiddleware under limit status = %d, want 200", rr.Code)
}
}
func TestRateLimitMiddleware_OverLimit(t *testing.T) {
limiter := auth.NewRateLimiter()
limit := 3
h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok))
for range limit {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.2:1234"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
// This next request should be rate-limited.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.2:1234"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("RateLimitMiddleware over limit status = %d, want 429", rr.Code)
}
}
func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) {
limiter := auth.NewRateLimiter()
h := api.RateLimitMiddleware(limiter, "test:", 1, time.Minute)(http.HandlerFunc(ok))
// Exhaust limit.
for range 2 {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.3:1234"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.3:1234"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Header().Get("Retry-After") == "" {
t.Error("RateLimitMiddleware: missing Retry-After header on 429 response")
}
}
func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) {
// Without trusted proxies configured, X-Real-IP must be ignored.
// Each request with the same RemoteAddr host counts as the same IP regardless
// of what the X-Real-IP header says.
limiter := auth.NewRateLimiter()
limit := 2
h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok))
// Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP.
for range limit {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored
req.RemoteAddr = "10.0.0.99:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
// Third request from the same RemoteAddr should be blocked — rate key is
// 10.0.0.99, not the forged 192.168.1.1.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "192.168.1.1")
req.RemoteAddr = "10.0.0.99:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("RateLimitMiddleware no-trusted-proxy status = %d, want 429", rr.Code)
}
}
// lockedBuffer is a goroutine-safe writer for capturing log output.
type lockedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest locks
// the W3-3a hoist: the trusted-proxy CIDR list is parsed once when the
// middleware is constructed — warning about invalid entries there — never on
// the per-request path.
func TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest(t *testing.T) {
logBuf := &lockedBuffer{}
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, nil)))
defer slog.SetDefault(prev)
limiter := auth.NewRateLimiter()
h := api.RateLimitMiddleware(limiter, "test:", 100, time.Minute,
[]string{"not-a-cidr", "10.0.0.0/8"})(http.HandlerFunc(ok))
const warnMsg = "ignoring invalid CIDR entry"
if got := strings.Count(logBuf.String(), warnMsg); got != 1 {
t.Fatalf("invalid-CIDR warnings at construction = %d, want 1 (log: %q)",
got, logBuf.String())
}
// The valid entry still works: X-Real-IP honoured from the trusted proxy.
for range 3 {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:9999"
req.Header.Set("X-Real-IP", "203.0.113.77")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("request status = %d, want 200", rr.Code)
}
}
if got := strings.Count(logBuf.String(), warnMsg); got != 1 {
t.Fatalf("invalid-CIDR warnings after 3 requests = %d, want 1 — CIDRs re-parsed on the request path (log: %q)",
got, logBuf.String())
}
}
func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) {
// With a trusted proxy configured, X-Real-IP from that proxy is used.
limiter := auth.NewRateLimiter()
limit := 2
trustedCIDRs := []string{"10.0.0.0/8"}
h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok))
// Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5.
for range limit {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "203.0.113.5")
req.RemoteAddr = "10.0.0.1:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
// Third request with same X-Real-IP from same trusted proxy — should be blocked.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "203.0.113.5")
req.RemoteAddr = "10.0.0.1:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("RateLimitMiddleware trusted proxy X-Real-IP status = %d, want 429", rr.Code)
}
}
// ─── Fix 2.10: Ban expiry in AuthMiddleware ───────────────────────────────────
// TestAuthMiddleware_BannedUserBlocked verifies that an actively banned user
// with no expiry cannot pass the auth middleware.
func TestAuthMiddleware_BannedUserBlocked(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "banneduser", "hash", 4)
_ = database.BanUser(context.Background(), uid, "rule violation", nil) // permanent ban
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("AuthMiddleware banned user status = %d, want 403", rr.Code)
}
}
// TestAuthMiddleware_ExpiredBanAllowed verifies that a user whose ban has
// expired in the past can pass the auth middleware.
func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "expbanned", "hash", 4)
// Set ban with an expiry time in the past.
past := time.Now().UTC().Add(-time.Hour)
_ = database.BanUser(context.Background(), uid, "temp ban", &past)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AuthMiddleware expired-ban user status = %d, want 200", rr.Code)
}
}
// TestAuthMiddleware_ActiveTemporaryBanBlocked verifies that a user with a
// temporary ban whose expiry is in the future is still blocked.
func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "tempbanned", "hash", 4)
// Set ban with an expiry time in the future.
future := time.Now().UTC().Add(time.Hour)
_ = database.BanUser(context.Background(), uid, "temp ban", &future)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
withBearer(req, token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("AuthMiddleware active temp-ban user status = %d, want 403", rr.Code)
}
}
// ─── SecurityHeaders tests ───────────────────────────────────────────────────
func TestSecurityHeaders_AllHeadersPresent(t *testing.T) {
h := api.SecurityHeaders(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-Xss-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Cache-Control": "no-store",
}
for header, expected := range want {
if got := rr.Header().Get(header); got != expected {
t.Errorf("SecurityHeaders: %s = %q, want %q", header, got, expected)
}
}
}
func TestSecurityHeaders_PassesThrough(t *testing.T) {
// Middleware must not swallow the response — downstream handler must be called.
called := false
h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if !called {
t.Error("SecurityHeaders: downstream handler was not called")
}
if rr.Code != http.StatusTeapot {
t.Errorf("SecurityHeaders: status = %d, want 418", rr.Code)
}
}
func TestSecurityHeaders_DoesNotOverrideExistingHeaders(t *testing.T) {
// If a downstream handler sets its own CSP, SecurityHeaders should not clobber it
// because it runs before the handler writes. The middleware sets headers first,
// the handler can then override them — that is the correct layering.
// This test just confirms the middleware itself sets all seven headers.
h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handler overrides CSP after SecurityHeaders has already set it.
w.Header().Set("Content-Security-Policy", "default-src 'none'")
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
// The handler's override wins because it runs after the middleware sets the header.
if got := rr.Header().Get("Content-Security-Policy"); got != "default-src 'none'" {
t.Errorf("SecurityHeaders: handler CSP override = %q, want \"default-src 'none'\"", got)
}
}
// ─── MaxBodySize tests ────────────────────────────────────────────────────────
func TestMaxBodySize_UnderLimit(t *testing.T) {
// A body smaller than the limit must be read successfully by the handler.
const limit = 10 // bytes
body := strings.NewReader("hello") // 5 bytes — under limit
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := make([]byte, 20)
n, _ := r.Body.Read(data)
if n != 5 {
t.Errorf("MaxBodySize under limit: read %d bytes, want 5", n)
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/", body)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("MaxBodySize under limit: status = %d, want 200", rr.Code)
}
}
func TestMaxBodySize_ExactLimit(t *testing.T) {
// A body exactly at the limit must be read without error.
const limit = 5
body := strings.NewReader("hello") // exactly 5 bytes
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := make([]byte, 10)
n, _ := r.Body.Read(data)
if n != 5 {
t.Errorf("MaxBodySize exact limit: read %d bytes, want 5", n)
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/", body)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("MaxBodySize exact limit: status = %d, want 200", rr.Code)
}
}
func TestMaxBodySize_OverLimit(t *testing.T) {
// Reading beyond the limit must return an error from MaxBytesReader.
const limit = 5
body := strings.NewReader("hello world") // 11 bytes — over limit
var readErr error
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := make([]byte, 20)
_, readErr = r.Body.Read(data)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/", body)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if readErr == nil {
t.Error("MaxBodySize over limit: expected read error, got nil")
}
}
func TestMaxBodySize_NilBody(t *testing.T) {
// GET requests with no body must pass through without panic.
h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
// Must not panic.
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("MaxBodySize nil body: status = %d, want 200", rr.Code)
}
}
func TestMaxBodySize_PassesThrough(t *testing.T) {
// Downstream handler must be called and its status code preserved.
h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("data"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Errorf("MaxBodySize pass-through: status = %d, want 201", rr.Code)
}
}
// ─── AdminIPRestrict tests ──────────────────────────────────────────────────
func TestAdminIPRestrict_AllowedCIDR(t *testing.T) {
h := api.AdminIPRestrict([]string{"127.0.0.0/8"}, nil)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AdminIPRestrict allowed CIDR status = %d, want 200", rr.Code)
}
}
func TestAdminIPRestrict_BlockedCIDR(t *testing.T) {
h := api.AdminIPRestrict([]string{"10.0.0.0/8"}, nil)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.1:9999" // not in 10.0.0.0/8
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("AdminIPRestrict blocked CIDR status = %d, want 403", rr.Code)
}
}
func TestAdminIPRestrict_EmptyAllowsAll(t *testing.T) {
h := api.AdminIPRestrict(nil, nil)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.1:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AdminIPRestrict empty list status = %d, want 200", rr.Code)
}
}
func TestAdminIPRestrict_InvalidCIDR(t *testing.T) {
// Invalid CIDR should fail closed: the entry is skipped at construction,
// leaving a non-empty allowed list with zero parsed networks — nothing
// matches, so access is denied.
h := api.AdminIPRestrict([]string{"not-a-cidr"}, nil)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("AdminIPRestrict invalid CIDR status = %d, want 403", rr.Code)
}
}
func TestAdminIPRestrict_MultipleCIDRs(t *testing.T) {
h := api.AdminIPRestrict([]string{"10.0.0.0/8", "192.168.0.0/16"}, nil)(http.HandlerFunc(ok))
// First CIDR matches.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.1.2.3:9999"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AdminIPRestrict multi-CIDR (10.x) status = %d, want 200", rr.Code)
}
// Second CIDR matches.
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.50:9999"
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("AdminIPRestrict multi-CIDR (192.168.x) status = %d, want 200", rr.Code)
}
// Neither matches.
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "172.16.0.1:9999"
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("AdminIPRestrict multi-CIDR (no match) status = %d, want 403", rr.Code)
}
}
// ─── AdminIPRestrict proxy-aware tests (BUG-116) ─────────────────────────────
// TestAdminIPRestrict_TrustedProxy_UsesXForwardedFor verifies that when the
// connecting IP is a trusted proxy, the real client IP is extracted from
// X-Forwarded-For and checked against admin CIDRs.
func TestAdminIPRestrict_TrustedProxy_UsesXForwardedFor(t *testing.T) {
// Admin allowed: only 203.0.113.0/24. Trusted proxy: 127.0.0.1.
h := api.AdminIPRestrict(
[]string{"203.0.113.0/24"},
[]string{"127.0.0.0/8"},
)(http.HandlerFunc(ok))
// Request from proxy (127.0.0.1) with real client in XFF → allowed.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "203.0.113.50")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("trusted proxy + allowed XFF status = %d, want 200", rr.Code)
}
// Request from proxy with disallowed real client → blocked.
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "198.51.100.1")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("trusted proxy + blocked XFF status = %d, want 403", rr.Code)
}
}
// TestAdminIPRestrict_TrustedProxy_UsesXRealIP verifies X-Real-IP is preferred
// over X-Forwarded-For when both are present from a trusted proxy.
func TestAdminIPRestrict_TrustedProxy_UsesXRealIP(t *testing.T) {
h := api.AdminIPRestrict(
[]string{"203.0.113.0/24"},
[]string{"127.0.0.0/8"},
)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Real-IP", "203.0.113.50")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("trusted proxy + X-Real-IP status = %d, want 200", rr.Code)
}
}
// TestAdminIPRestrict_UntrustedProxy_IgnoresHeaders verifies that proxy headers
// are ignored when the connecting IP is NOT a trusted proxy.
func TestAdminIPRestrict_UntrustedProxy_IgnoresHeaders(t *testing.T) {
h := api.AdminIPRestrict(
[]string{"203.0.113.0/24"},
[]string{"10.0.0.0/8"}, // only 10.x is trusted
)(http.HandlerFunc(ok))
// Untrusted proxy at 192.168.1.1 tries to spoof XFF.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.1:9999"
req.Header.Set("X-Forwarded-For", "203.0.113.50") // spoofed
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("untrusted proxy spoofed XFF status = %d, want 403 (should use RemoteAddr)", rr.Code)
}
}
// TestAdminIPRestrict_ProxyCollapse_WithoutTrusted verifies the original bug:
// without trusted proxies, a proxy on localhost makes everything appear local.
func TestAdminIPRestrict_ProxyCollapse_WithoutTrusted(t *testing.T) {
// Admin CIDR: private networks. No trusted proxies.
h := api.AdminIPRestrict(
[]string{"127.0.0.0/8", "10.0.0.0/8"},
nil, // no trusted proxies
)(http.HandlerFunc(ok))
// External client behind nginx on localhost — RemoteAddr is 127.0.0.1.
// XFF has the real external IP, but it's ignored (no trusted proxies).
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "198.51.100.1") // real external IP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
// Without trusted proxies, 127.0.0.1 is used — passes the private CIDR check.
// This is the documented limitation: operators MUST configure trusted_proxies.
if rr.Code != http.StatusOK {
t.Errorf("no trusted proxies, proxy on localhost status = %d, want 200 (known limitation)", rr.Code)
}
}
// ─── SecurityHeadersWithTLS tests ───────────────────────────────────────────
func TestSecurityHeadersWithTLS_HSTS(t *testing.T) {
h := api.SecurityHeadersWithTLS("auto")(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if got := rr.Header().Get("Strict-Transport-Security"); got == "" {
t.Error("SecurityHeadersWithTLS: missing HSTS header when TLS enabled")
}
}
func TestSecurityHeadersWithTLS_NoHSTSWithoutTLS(t *testing.T) {
h := api.SecurityHeadersWithTLS("")(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if got := rr.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("SecurityHeadersWithTLS: unexpected HSTS header %q when TLS disabled", got)
}
}
// ─── handleLiveKitHealth tests ──────────────────────────────────────────────
func TestLiveKitHealth_Healthy(t *testing.T) {
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return true, nil
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["status"] != "ok" {
t.Errorf("status = %v, want ok", resp["status"])
}
if resp["livekit_reachable"] != true {
t.Errorf("livekit_reachable = %v, want true", resp["livekit_reachable"])
}
}
func TestLiveKitHealth_Unhealthy(t *testing.T) {
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return false, fmt.Errorf("connection refused")
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["status"] != "degraded" {
t.Errorf("status = %v, want degraded", resp["status"])
}
if resp["livekit_reachable"] != false {
t.Errorf("livekit_reachable = %v, want false", resp["livekit_reachable"])
}
if resp["error"] != "connection refused" {
t.Errorf("error = %v, want 'connection refused'", resp["error"])
}
}
func TestLiveKitHealth_UnhealthyNoError(t *testing.T) {
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return false, nil
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["error"] != "unknown" {
t.Errorf("error = %v, want 'unknown'", resp["error"])
}
}
// apiTestSchema is the full schema needed for all api tests (middleware,
// auth handler, and invite handler).
var apiTestSchema = []byte(`
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT,
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES
(1, 'Owner', '#E74C3C', 2147483647, 100, 0),
(2, 'Admin', '#F39C12', 1073741823, 80, 0),
(3, 'Moderator', '#3498DB', 1048575, 60, 0),
(4, 'Member', NULL, 1635, 40, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
avatar TEXT,
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
totp_secret TEXT,
status TEXT NOT NULL DEFAULT 'offline',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT,
identity_public_key TEXT,
display_name TEXT,
about TEXT,
custom_status TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
device TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE TABLE IF NOT EXISTS 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 invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
redeemed_by INTEGER REFERENCES users(id),
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('require_2fa', 'false'),
('registration_open', 'true');
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
category TEXT,
topic TEXT,
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,
voice_quality TEXT,
mixing_threshold INTEGER,
voice_max_video INTEGER NOT NULL DEFAULT 0,
nsfw INTEGER NOT NULL DEFAULT 0,
is_group INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
UNIQUE(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 messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
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 dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0,
mention_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)