2026-08-16 23:31:29 +02:00
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
2026-08-16 23:44:55 +02:00
"github.com/kristianwind/verdande/internal/auth"
2026-08-16 23:31:29 +02:00
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// testServer runs the real router against a real database over a real listener.
// Auth is mostly cookies, status codes and middleware ordering — none of which a
// unit test of the handler functions would exercise.
type testServer struct {
* httptest . Server
db * store . DB
client * http . Client
2026-08-20 21:26:13 +02:00
// api er den samme server, routeren hænger på. Et par prøver har brug for at
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
api * Server
2026-08-16 23:31:29 +02:00
}
func newTestServer ( t * testing . T ) * testServer {
t . Helper ()
2026-08-17 08:21:51 +02:00
return newTestServerWith ( t , nil )
}
// newTestServerWith lets a test vary the configuration — a registered OAuth client,
// update checking on — without every other test paying for it.
func newTestServerWith ( t * testing . T , configure func ( * config . Config )) * testServer {
t . Helper ()
2026-08-16 23:31:29 +02:00
db , err := store . Open ( filepath . Join ( t . TempDir (), "test.db" ))
if err != nil {
t . Fatalf ( "open db: %v" , err )
}
t . Cleanup ( func () { db . Close () })
cfg := & config . Config {
2026-08-18 11:56:54 +02:00
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL : "http://localhost" ,
2026-08-16 23:31:29 +02:00
DataDir : t . TempDir (),
SessionTTL : 24 * time . Hour ,
InviteTTL : 7 * 24 * time . Hour ,
ResetTTL : time . Hour ,
Dev : true ,
}
2026-08-17 08:21:51 +02:00
if configure != nil {
configure ( cfg )
}
2026-08-16 23:31:29 +02:00
// Discard: a passing test should print nothing, and these handlers log freely.
log := slog . New ( slog . NewTextHandler ( io . Discard , nil ))
2026-08-20 21:26:13 +02:00
api := New ( cfg , db , log , nil )
srv := httptest . NewServer ( api )
2026-08-16 23:31:29 +02:00
t . Cleanup ( srv . Close )
jar , err := cookiejar . New ( nil )
if err != nil {
t . Fatal ( err )
}
return & testServer {
Server : srv ,
db : db ,
// The cookie jar is what makes this a session test rather than a
// sequence of unrelated requests.
client : & http . Client { Jar : jar , Timeout : 10 * time . Second },
2026-08-20 21:26:13 +02:00
api : api ,
2026-08-16 23:31:29 +02:00
}
}
func ( ts * testServer ) do ( t * testing . T , method , path string , body any ) ( * http . Response , map [ string ] any ) {
t . Helper ()
var reader io . Reader
if body != nil {
raw , err := json . Marshal ( body )
if err != nil {
t . Fatalf ( "marshal body: %v" , err )
}
reader = bytes . NewReader ( raw )
}
req , err := http . NewRequest ( method , ts . URL + path , reader )
if err != nil {
t . Fatalf ( "build request: %v" , err )
}
if body != nil {
req . Header . Set ( "Content-Type" , "application/json" )
}
// Same-origin, as a browser would report for the app's own fetches.
req . Header . Set ( "Sec-Fetch-Site" , "same-origin" )
resp , err := ts . client . Do ( req )
if err != nil {
t . Fatalf ( "%s %s: %v" , method , path , err )
}
t . Cleanup ( func () { resp . Body . Close () })
var decoded map [ string ] any
raw , _ := io . ReadAll ( resp . Body )
if len ( raw ) > 0 {
_ = json . Unmarshal ( raw , & decoded )
}
return resp , decoded
}
// bootstrap creates the first admin and leaves the client signed in.
func ( ts * testServer ) bootstrap ( t * testing . T ) {
t . Helper ()
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "kristian@example.dk" , "name" : "Kristian" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusCreated {
t . Fatalf ( "bootstrap: status %d" , resp . StatusCode )
}
}
func TestSetupCreatesTheFirstAdminOnlyOnce ( t * testing . T ) {
ts := newTestServer ( t )
resp , body := ts . do ( t , "GET" , "/api/v1/auth/setup" , nil )
if resp . StatusCode != http . StatusOK || body [ "needs_setup" ] != true {
t . Fatalf ( "a fresh instance should need setup: %d %v" , resp . StatusCode , body )
}
resp , body = ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "kristian@example.dk" , "name" : "Kristian" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusCreated {
t . Fatalf ( "setup: status %d, body %v" , resp . StatusCode , body )
}
user , _ := body [ "user" ].( map [ string ] any )
if user == nil || user [ "is_admin" ] != true {
t . Errorf ( "the first account is not an admin: %v" , body )
}
// The password hash must never appear in a response.
if _ , leaked := user [ "password_hash" ]; leaked {
t . Error ( "the response carries the password hash" )
}
// A second attempt must be refused, or anyone could add themselves as admin.
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "someone@else.dk" , "name" : "Someone" , "password" : "et andet kodeord" ,
})
if resp . StatusCode != http . StatusConflict {
t . Errorf ( "second setup: status %d, want 409" , resp . StatusCode )
}
resp , body = ts . do ( t , "GET" , "/api/v1/auth/setup" , nil )
if body [ "needs_setup" ] != false {
t . Errorf ( "instance still reports needing setup: %v" , body )
}
}
func TestSetupCreatesAnInbox ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
user , err := ts . db . UserByEmail ( t . Context (), "kristian@example.dk" )
if err != nil {
t . Fatal ( err )
}
if _ , err := ts . db . InboxID ( t . Context (), user . ID ); err != nil {
t . Errorf ( "no Inbox was created for the first user: %v" , err )
}
}
func TestLoginAndLogout ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
// Signed in from bootstrap.
resp , body := ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusOK || body [ "email" ] != "kristian@example.dk" {
t . Fatalf ( "me: %d %v" , resp . StatusCode , body )
}
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
if resp . StatusCode != http . StatusNoContent {
t . Fatalf ( "logout: status %d" , resp . StatusCode )
}
resp , _ = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "me after logout: status %d, want 401" , resp . StatusCode )
}
resp , body = ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "login: %d %v" , resp . StatusCode , body )
}
if body [ "totp_required" ] != false {
t . Errorf ( "login asked for a code with 2FA off: %v" , body )
}
resp , _ = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusOK {
t . Errorf ( "me after login: status %d" , resp . StatusCode )
}
}
// The address is normalised, so one account cannot be reached under two spellings
// and, worse, a second account cannot be created that shadows the first.
func TestLoginIgnoresEmailCaseAndSpace ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
for _ , email := range [] string {
"kristian@example.dk" , "KRISTIAN@example.dk" , " Kristian@Example.DK " ,
} {
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : email , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusOK {
t . Errorf ( "login as %q: status %d" , email , resp . StatusCode )
}
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
}
}
// A wrong password and an address with no account must be indistinguishable, or the
// login form becomes a way to find out who has an account here.
func TestLoginDoesNotRevealWhichAccountsExist ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
respWrong , bodyWrong := ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "forkert kodeord" ,
})
respMissing , bodyMissing := ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "findes-ikke@example.dk" , "password" : "forkert kodeord" ,
})
if respWrong . StatusCode != respMissing . StatusCode {
t . Errorf ( "status differs: %d vs %d" , respWrong . StatusCode , respMissing . StatusCode )
}
if bodyWrong [ "error" ] != bodyMissing [ "error" ] || bodyWrong [ "code" ] != bodyMissing [ "code" ] {
t . Errorf ( "message differs:\n wrong password: %v\n no account: %v" , bodyWrong , bodyMissing )
}
}
func TestSessionCookieIsHardened ( t * testing . T ) {
ts := newTestServer ( t )
// Read the cookie off the response, not out of the jar: Go's cookiejar keeps
// only name and value, so every attribute checked here would come back unset
// regardless of what the server actually sent.
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "kristian@example.dk" , "name" : "Kristian" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusCreated {
t . Fatalf ( "setup: status %d" , resp . StatusCode )
}
var session * http . Cookie
for _ , c := range resp . Cookies () {
if c . Name == sessionCookieInsecure || c . Name == sessionCookieSecure {
session = c
}
}
if session == nil {
t . Fatal ( "no session cookie was set" )
}
if ! session . HttpOnly {
t . Error ( "session cookie is readable from JavaScript" )
}
if session . SameSite != http . SameSiteLaxMode {
t . Errorf ( "SameSite = %v, want Lax" , session . SameSite )
}
if session . Path != "/" {
t . Errorf ( "Path = %q, want /" , session . Path )
}
// The stored value must be a hash, never the cookie itself: a database dump
// must not contain anything that can be pasted into a browser.
var stored string
err := ts . db . QueryRow ( `SELECT id FROM sessions LIMIT 1` ). Scan ( & stored )
if err != nil {
t . Fatal ( err )
}
if stored == session . Value {
t . Error ( "the session token is stored verbatim; it must be hashed" )
}
}
func TestTOTPEnrolmentAndTwoStepLogin ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
// not lock the account.
2026-08-20 08:29:43 +02:00
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
// at slå den fra, og den har altid spurgt.
resp , body := ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {
"password" : "et langt kodeord" ,
})
2026-08-16 23:31:29 +02:00
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "totp setup: %d %v" , resp . StatusCode , body )
}
secret , _ := body [ "secret" ].( string )
if secret == "" {
t . Fatal ( "no secret returned" )
}
_ , me := ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if me [ "totp_enabled" ] != false {
t . Error ( "2FA switched on before the code was confirmed" )
}
// Confirm with a real code.
resp , body = ts . do ( t , "POST" , "/api/v1/auth/totp/confirm" , map [ string ] string {
"code" : codeFor ( t , secret ),
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "totp confirm: %d %v" , resp . StatusCode , body )
}
codes , _ := body [ "recovery_codes" ].([] any )
if len ( codes ) == 0 {
t . Fatal ( "no recovery codes were issued" )
}
// Now a login is two steps.
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
resp , body = ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusOK || body [ "totp_required" ] != true {
t . Fatalf ( "login should ask for a code: %d %v" , resp . StatusCode , body )
}
// The half-finished session must not reach anything.
resp , body = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "a pending session reached /me: %d" , resp . StatusCode )
}
if body [ "code" ] != CodeTOTPRequired {
t . Errorf ( "code = %v, want %s" , body [ "code" ], CodeTOTPRequired )
}
// A wrong code does not finish the login.
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : "000000" })
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "a wrong code was accepted: %d" , resp . StatusCode )
}
// The right one does.
resp , body = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string {
"code" : codeFor ( t , secret ),
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "totp login: %d %v" , resp . StatusCode , body )
}
resp , _ = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusOK {
t . Errorf ( "me after completing 2FA: %d" , resp . StatusCode )
}
}
2026-08-23 23:34:34 +02:00
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
resp , body := ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {
"password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "totp setup: %d %v" , resp . StatusCode , body )
}
secret , _ := body [ "secret" ].( string )
resp , body = ts . do ( t , "POST" , "/api/v1/auth/totp/confirm" , map [ string ] string {
"code" : codeFor ( t , secret ),
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "totp confirm: %d %v" , resp . StatusCode , body )
}
// One code, captured once and presented twice.
code := codeFor ( t , secret )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
resp , body = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : code })
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "first use of the code failed: %d %v" , resp . StatusCode , body )
}
// A second login, same code, same window.
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
resp , body = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : code })
if resp . StatusCode != http . StatusUnauthorized {
t . Fatalf ( "the same code was accepted twice: %d %v" , resp . StatusCode , body )
}
}
2026-08-16 23:31:29 +02:00
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
2026-08-20 08:29:43 +02:00
_ , body := ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {
"password" : "et langt kodeord" ,
})
2026-08-16 23:31:29 +02:00
secret := body [ "secret" ].( string )
_ , body = ts . do ( t , "POST" , "/api/v1/auth/totp/confirm" , map [ string ] string {
"code" : codeFor ( t , secret ),
})
raw := body [ "recovery_codes" ].([] any )
recovery := raw [ 0 ].( string )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : recovery })
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "recovery code rejected: %d" , resp . StatusCode )
}
// The same code must not work a second time.
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : recovery })
if resp . StatusCode == http . StatusOK {
t . Error ( "a recovery code worked twice" )
}
}
func TestChangePasswordRequiresTheCurrentOne ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/password/change" , map [ string ] string {
"current_password" : "det forkerte" , "new_password" : "et helt nyt kodeord" ,
})
if resp . StatusCode != http . StatusUnprocessableEntity {
t . Errorf ( "wrong current password: status %d, want 422" , resp . StatusCode )
}
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/password/change" , map [ string ] string {
"current_password" : "et langt kodeord" , "new_password" : "et helt nyt kodeord" ,
})
if resp . StatusCode != http . StatusNoContent {
t . Fatalf ( "change password: status %d" , resp . StatusCode )
}
// The session that made the change survives; the old password does not.
resp , _ = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusOK {
t . Error ( "changing the password logged out the session that did it" )
}
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
if resp . StatusCode == http . StatusOK {
t . Error ( "the old password still works" )
}
}
func TestPasswordRulesRejectShortOnesOnly ( t * testing . T ) {
ts := newTestServer ( t )
resp , body := ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "k@example.dk" , "name" : "K" , "password" : "kort" ,
})
if resp . StatusCode != http . StatusUnprocessableEntity {
t . Fatalf ( "short password: status %d" , resp . StatusCode )
}
fields , _ := body [ "fields" ].( map [ string ] any )
if fields [ "password" ] == nil {
t . Errorf ( "the error does not name the password field: %v" , body )
}
// A long passphrase with no digits or symbols must be accepted: composition
// rules push people towards worse passwords, not better ones.
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/setup" , map [ string ] string {
"email" : "k@example.dk" , "name" : "K" , "password" : "syv heste løber over marken" ,
})
if resp . StatusCode != http . StatusCreated {
t . Errorf ( "a long passphrase was rejected: status %d" , resp . StatusCode )
}
}
// Asking to reset a password must not reveal whether the address has an account.
func TestForgotPasswordAnswersTheSameEitherWay ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
respKnown , bodyKnown := ts . do ( t , "POST" , "/api/v1/auth/password/forgot" , map [ string ] string {
"email" : "kristian@example.dk" ,
})
respUnknown , bodyUnknown := ts . do ( t , "POST" , "/api/v1/auth/password/forgot" , map [ string ] string {
"email" : "findes-ikke@example.dk" ,
})
if respKnown . StatusCode != respUnknown . StatusCode {
t . Errorf ( "status differs: %d vs %d" , respKnown . StatusCode , respUnknown . StatusCode )
}
if bodyKnown [ "status" ] != bodyUnknown [ "status" ] {
t . Errorf ( "message differs: %v vs %v" , bodyKnown , bodyUnknown )
}
}
func TestPasswordResetEndsEverySession ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
user , err := ts . db . UserByEmail ( t . Context (), "kristian@example.dk" )
if err != nil {
t . Fatal ( err )
}
token , err := ts . db . CreatePasswordReset ( t . Context (), user . ID , time . Hour )
if err != nil {
t . Fatal ( err )
}
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/password/reset" , map [ string ] string {
"token" : token , "password" : "et helt nyt kodeord" ,
})
if resp . StatusCode != http . StatusNoContent {
t . Fatalf ( "reset: status %d" , resp . StatusCode )
}
// Everything is signed out — a reset is what you do when you think somebody
// else has been in your account.
resp , _ = ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "a session survived a password reset: %d" , resp . StatusCode )
}
// The link works exactly once.
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/password/reset" , map [ string ] string {
"token" : token , "password" : "endnu et kodeord" ,
})
if resp . StatusCode != http . StatusBadRequest {
t . Errorf ( "a reset link worked twice: %d" , resp . StatusCode )
}
}
func TestSignupRequiresAnInvite ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/signup" , map [ string ] string {
"token" : "opfundet" , "name" : "Ubuden" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusBadRequest {
t . Errorf ( "signup without a valid invite: status %d, want 400" , resp . StatusCode )
}
admin , err := ts . db . UserByEmail ( t . Context (), "kristian@example.dk" )
if err != nil {
t . Fatal ( err )
}
token , _ , err := ts . db . CreateInvite ( t . Context (), "ny@example.dk" , "" , store . RoleEditor , admin . ID , time . Hour )
if err != nil {
t . Fatal ( err )
}
// The address comes from the invite, not from the form — so an invite cannot
// be redirected to somebody else by editing the request.
resp , body := ts . do ( t , "POST" , "/api/v1/auth/signup" , map [ string ] string {
"token" : token , "name" : "Ny Bruger" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusCreated {
t . Fatalf ( "signup: %d %v" , resp . StatusCode , body )
}
user , _ := body [ "user" ].( map [ string ] any )
if user [ "email" ] != "ny@example.dk" {
t . Errorf ( "email = %v, want the invited address" , user [ "email" ])
}
if user [ "is_admin" ] != false {
t . Error ( "an invited user was made an admin" )
}
// And the invite is spent.
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/signup" , map [ string ] string {
"token" : token , "name" : "Endnu En" , "password" : "et langt kodeord" ,
})
if resp . StatusCode == http . StatusCreated {
t . Error ( "an invite link was used twice" )
}
}
func TestUnauthenticatedRequestsAreRefused ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
for _ , tc := range [] struct { method , path string }{
{ "GET" , "/api/v1/auth/me" },
{ "POST" , "/api/v1/auth/totp/setup" },
{ "POST" , "/api/v1/auth/password/change" },
{ "GET" , "/api/v1/recovery-codes" },
} {
t . Run ( tc . method + " " + tc . path , func ( t * testing . T ) {
resp , _ := ts . do ( t , tc . method , tc . path , map [ string ] string {})
if resp . StatusCode != http . StatusUnauthorized && resp . StatusCode != http . StatusNotFound {
t . Errorf ( "status %d, want 401" , resp . StatusCode )
}
})
}
}
// A cross-site POST must not be able to act on somebody's session.
func TestCrossSiteWritesAreRefused ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
req , err := http . NewRequest ( "POST" , ts . URL + "/api/v1/auth/logout" , nil )
if err != nil {
t . Fatal ( err )
}
req . Header . Set ( "Sec-Fetch-Site" , "cross-site" )
resp , err := ts . client . Do ( req )
if err != nil {
t . Fatal ( err )
}
defer resp . Body . Close ()
if resp . StatusCode != http . StatusForbidden {
t . Errorf ( "cross-site POST: status %d, want 403" , resp . StatusCode )
}
// And the session survived it.
if r , _ := ts . do ( t , "GET" , "/api/v1/auth/me" , nil ); r . StatusCode != http . StatusOK {
t . Error ( "the cross-site request logged the user out anyway" )
}
}
func TestMalformedRequestBodies ( t * testing . T ) {
ts := newTestServer ( t )
cases := [] struct {
name , body string
contentType string
wantStatus int
}{
{ "empty" , "" , "application/json" , http . StatusBadRequest },
{ "not json" , "hejsa" , "application/json" , http . StatusBadRequest },
{ "wrong field type" , `{"email": 42}` , "application/json" , http . StatusBadRequest },
{ "unknown field" , `{"email":"a@b.dk","sneaky":1}` , "application/json" , http . StatusBadRequest },
{ "two objects" , `{"email":"a@b.dk"}{"email":"c@d.dk"}` , "application/json" , http . StatusBadRequest },
{ "wrong content type" , `{"email":"a@b.dk"}` , "text/plain" , http . StatusUnsupportedMediaType },
}
for _ , tc := range cases {
t . Run ( tc . name , func ( t * testing . T ) {
req , err := http . NewRequest ( "POST" , ts . URL + "/api/v1/auth/login" , bytes . NewReader ([] byte ( tc . body )))
if err != nil {
t . Fatal ( err )
}
req . Header . Set ( "Content-Type" , tc . contentType )
req . Header . Set ( "Sec-Fetch-Site" , "same-origin" )
resp , err := ts . client . Do ( req )
if err != nil {
t . Fatal ( err )
}
defer resp . Body . Close ()
if resp . StatusCode != tc . wantStatus {
t . Errorf ( "status %d, want %d" , resp . StatusCode , tc . wantStatus )
}
})
}
}
// Repeated password guesses must stop being answered.
func TestLoginIsRateLimited ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
var limited bool
for i := 0 ; i < 30 ; i ++ {
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "gæt" ,
})
if resp . StatusCode == http . StatusTooManyRequests {
if resp . Header . Get ( "Retry-After" ) == "" {
t . Error ( "a rate-limited response does not say when to retry" )
}
limited = true
break
}
}
if ! limited {
t . Error ( "thirty wrong passwords in a row were all answered" )
}
}
func codeFor ( t * testing . T , secret string ) string {
t . Helper ()
code , err := totp . GenerateCodeCustom ( secret , time . Now (), totp . ValidateOpts {
Period : 30 , Skew : 1 , Digits : otp . DigitsSix , Algorithm : otp . AlgorithmSHA1 ,
})
if err != nil {
t . Fatalf ( "generate code: %v" , err )
}
return code
}
2026-08-16 23:44:55 +02:00
// newJarClient gives a test its own cookie jar, which is what makes two clients
// against one server two different browsers rather than the same session twice.
func newJarClient ( t * testing . T ) * http . Client {
t . Helper ()
jar , err := cookiejar . New ( nil )
if err != nil {
t . Fatal ( err )
}
return & http . Client { Jar : jar , Timeout : 10 * time . Second }
}
func mustHash ( t * testing . T , password string ) string {
t . Helper ()
hash , err := auth . HashPassword ( password )
if err != nil {
t . Fatalf ( "hash password: %v" , err )
}
return hash
}
2026-08-17 10:39:04 +02:00
// A database that cannot be read must not look like a failed login.
//
// authenticate() cannot tell the two apart on its own: both come back as an error
// from the session lookup. Answering 401 for both means a disk problem presents as
// every session ending at once — the frontend clears its state and shows the
// sign-in screen to everybody — and the logs record a wave of failed logins rather
// than the fault. Found by the end-to-end suite, where a deleted database file
// turned into exactly that.
func TestDatabaseFailureIsNotALogout ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
// Still signed in, and the session cookie is in the jar.
resp , _ := ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "me: status %d" , resp . StatusCode )
}
// Pull the database out from under the server. Every query now fails with
// something that is not "no such session".
if err := ts . db . Close (); err != nil {
t . Fatalf ( "close db: %v" , err )
}
resp , body := ts . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode == http . StatusUnauthorized {
t . Fatal ( "a broken database signed the user out instead of reporting a fault" )
}
if resp . StatusCode != http . StatusInternalServerError {
t . Errorf ( "status %d, want 500" , resp . StatusCode )
}
if body [ "code" ] != CodeInternal {
t . Errorf ( "code = %v, want %q" , body [ "code" ], CodeInternal )
}
}
// The other half of the same contract: no cookie at all is still a plain 401.
func TestMissingCredentialsAreStillUnauthorized ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
anonymous := & testServer { Server : ts . Server , db : ts . db , client : newJarClient ( t )}
resp , body := anonymous . do ( t , "GET" , "/api/v1/auth/me" , nil )
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "status %d, want 401" , resp . StatusCode )
}
if body [ "code" ] != CodeUnauthorized {
t . Errorf ( "code = %v, want %q" , body [ "code" ], CodeUnauthorized )
}
}
// And a cookie that is not a session is a 401 too, not a 500 — an unknown token is
// a wrong credential, which is the client's problem and not the server's.
func TestUnknownSessionTokenIsUnauthorized ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
req , err := http . NewRequest ( "GET" , ts . URL + "/api/v1/auth/me" , nil )
if err != nil {
t . Fatal ( err )
}
req . AddCookie ( & http . Cookie { Name : "verdande_session" , Value : "ikke-en-rigtig-token" })
req . Header . Set ( "Sec-Fetch-Site" , "same-origin" )
resp , err := ( & http . Client {}). Do ( req )
if err != nil {
t . Fatal ( err )
}
defer resp . Body . Close ()
if resp . StatusCode != http . StatusUnauthorized {
t . Errorf ( "status %d, want 401" , resp . StatusCode )
}
}
2026-08-17 19:43:49 +02:00
// last_seen_at has been written on every request since sessions existed, for
// exactly this list, and nothing read it. A session you cannot see is a session
// you cannot end.
func TestSessionsCanBeListedAndEnded ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
// A second sign-in from "another device": same account, its own cookie jar.
other := & testServer { Server : ts . Server , db : ts . db , client : newJarClient ( t )}
resp , _ := other . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "second login: status %d" , resp . StatusCode )
}
resp , body := ts . do ( t , "GET" , "/api/v1/auth/sessions" , nil )
if resp . StatusCode != http . StatusOK {
t . Fatalf ( "list sessions: %d %v" , resp . StatusCode , body )
}
sessions , _ := body [ "sessions" ].([] any )
if len ( sessions ) != 2 {
t . Fatalf ( "want two sessions, got %v" , sessions )
}
// Exactly one is this one, or the interface cannot tell somebody which row is
// the browser they are reading it in.
current := ""
for _ , raw := range sessions {
s := raw .( map [ string ] any )
if s [ "current" ] == true {
if current != "" {
t . Fatal ( "two sessions both claim to be the current one" )
}
current = s [ "id" ].( string )
}
if s [ "device" ] == "" {
t . Errorf ( "no device summary: %v" , s )
}
if s [ "last_seen_at" ] == nil {
t . Errorf ( "no last_seen_at, which is the whole reason the column is written: %v" , s )
}
}
if current == "" {
t . Fatal ( "none of the sessions is marked as the current one" )
}
// End the other one.
var otherID string
for _ , raw := range sessions {
if s := raw .( map [ string ] any ); s [ "id" ] != current {
otherID = s [ "id" ].( string )
}
}
if resp , _ := ts . do ( t , "DELETE" , "/api/v1/auth/sessions/" + otherID , nil ); resp . StatusCode != http . StatusNoContent {
t . Fatalf ( "delete session: status %d" , resp . StatusCode )
}
// That device is signed out, and this one is not.
if r , _ := other . do ( t , "GET" , "/api/v1/auth/me" , nil ); r . StatusCode != http . StatusUnauthorized {
t . Errorf ( "the ended session still works: status %d" , r . StatusCode )
}
if r , _ := ts . do ( t , "GET" , "/api/v1/auth/me" , nil ); r . StatusCode != http . StatusOK {
t . Errorf ( "ending another session logged this one out: status %d" , r . StatusCode )
}
}
// Somebody else's session is not yours to end, even with its id in hand.
func TestASessionCanOnlyBeEndedByItsOwner ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
other := ts . newUser ( t , "anden@example.dk" , "Anden" )
_ , body := ts . do ( t , "GET" , "/api/v1/auth/sessions" , nil )
mine := body [ "sessions" ].([] any )[ 0 ].( map [ string ] any )[ "id" ].( string )
if resp , _ := other . do ( t , "DELETE" , "/api/v1/auth/sessions/" + mine , nil ); resp . StatusCode != http . StatusNotFound {
t . Errorf ( "status %d, want 404" , resp . StatusCode )
}
// And it still works.
if r , _ := ts . do ( t , "GET" , "/api/v1/auth/me" , nil ); r . StatusCode != http . StatusOK {
t . Errorf ( "somebody else ended my session: status %d" , r . StatusCode )
}
}
// An API token is accepted almost everywhere, and must not be accepted here: a
// leaked token that could end its owner's sessions, or even list their devices,
// turns a theft into a lockout.
func TestSessionsAreNotReachableWithAToken ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
user , err := ts . db . UserByEmail ( t . Context (), "kristian@example.dk" )
if err != nil {
t . Fatal ( err )
}
token := ts . apiToken ( t , user . ID )
req , _ := http . NewRequest ( "GET" , ts . URL + "/api/v1/auth/sessions" , nil )
req . Header . Set ( "Authorization" , "Bearer " + token )
resp , err := ( & http . Client {}). Do ( req )
if err != nil {
t . Fatal ( err )
}
defer resp . Body . Close ()
if resp . StatusCode != http . StatusForbidden {
t . Errorf ( "status %d, want 403" , resp . StatusCode )
}
}
func TestUserAgentsAreSummarisedForAPerson ( t * testing . T ) {
cases := [] struct {
ua string
want string
}{
{ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36" , "Chrome på macOS" },
// An iPhone's user agent says Mac OS X too, so the order of the tests is
// the whole correctness of this function.
{ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" , "Safari på iPhone" },
// And Edge claims to be Chrome, which claims to be Safari.
{ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0" , "Edge på Windows" },
{ "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0" , "Firefox på Linux" },
{ "" , "Ukendt enhed" },
// Not a browser at all. Its own name beats a word that says the server
// gave up: a CalDAV client is a real session.
{ "curl/8.4.0" , "curl/8.4.0" },
}
for _ , c := range cases {
if got := describeUserAgent ( c . ua ); got != c . want {
t . Errorf ( "describeUserAgent(%q) = %q, want %q" , c . ua , got , c . want )
}
}
}
2026-08-20 08:29:43 +02:00
// Andenfaktoren kan ikke slås til uden adgangskoden.
//
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
func TestTOTPCannotBeSwitchedOnWithoutThePassword ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {
"password" : "det forkerte kodeord" ,
})
if resp . StatusCode != http . StatusUnprocessableEntity {
t . Fatalf ( "status %d, ventede at en forkert adgangskode blev afvist" , resp . StatusCode )
}
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {})
if resp . StatusCode == http . StatusOK {
t . Fatal ( "en tom adgangskode blev accepteret" )
}
}
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
//
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
func TestAPasswordAloneDoesNotRefillTheCodeAttempts ( t * testing . T ) {
ts := newTestServer ( t )
ts . bootstrap ( t )
_ , body := ts . do ( t , "POST" , "/api/v1/auth/totp/setup" , map [ string ] string {
"password" : "et langt kodeord" ,
})
secret := body [ "secret" ].( string )
ts . do ( t , "POST" , "/api/v1/auth/totp/confirm" , map [ string ] string { "code" : codeFor ( t , secret )})
ts . do ( t , "POST" , "/api/v1/auth/logout" , nil )
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
// her kunne blive ved i det uendelige.
refused := 0
for i := 0 ; i < 14 ; i ++ {
resp , _ := ts . do ( t , "POST" , "/api/v1/auth/login" , map [ string ] string {
"email" : "kristian@example.dk" , "password" : "et langt kodeord" ,
})
if resp . StatusCode == http . StatusTooManyRequests {
refused ++
continue
}
resp , _ = ts . do ( t , "POST" , "/api/v1/auth/login/totp" , map [ string ] string { "code" : "000000" })
if resp . StatusCode == http . StatusTooManyRequests {
refused ++
}
}
if refused == 0 {
t . Fatal ( "fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden" )
}
}