Files
OwnCord/Server/api/auth_handler_test.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00

1641 lines
58 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// 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())
}
}
// ─── 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)
}
}