spaxel/mothership/internal/auth/handler_test.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

965 lines
24 KiB
Go

// Package auth provides authentication tests.
package auth
import (
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestHandler_StatusNotConfigured(t *testing.T) {
// Create in-memory database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
// Create handler
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Test status endpoint
req := httptest.NewRequest("GET", "/api/auth/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Should return pin_configured: false
var resp map[string]bool
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatal(err)
}
if resp["pin_configured"] {
t.Error("Expected pin_configured to be false initially")
}
}
func TestHandler_SetupPIN(t *testing.T) {
// Create in-memory database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
// Create handler
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Test setup with valid PIN
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check session cookie was set
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Error("Expected session cookie to be set")
} else if sessionCookie.MaxAge != 604800 {
t.Errorf("Expected MaxAge 604800, got %d", sessionCookie.MaxAge)
}
// Verify PIN is now configured
var pinBcrypt sql.NullString
err = db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
if err != nil {
t.Fatal(err)
}
if !pinBcrypt.Valid {
t.Error("Expected PIN to be configured after setup")
}
}
func TestHandler_SetupPINInvalid(t *testing.T) {
tests := []struct {
name string
pin string
wantStatus int
}{
{"too short", "123", http.StatusBadRequest},
{"too long", "123456789", http.StatusBadRequest},
{"non-numeric", "abcd", http.StatusBadRequest},
{"mixed", "12a4", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
reqBody := `{"pin": "` + tt.pin + `"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestHandler_SetupPINAlreadyConfigured(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// First setup should succeed
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusOK {
t.Fatalf("First setup failed: %d", w.Code)
}
// Second setup should fail
req = httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusConflict {
t.Errorf("Expected status 409, got %d", w.Code)
}
}
func TestHandler_LoginInvalidPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Try login with wrong PIN
reqBody = `{"pin": "9999"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
}
func TestHandler_LoginValidPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Login with correct PIN
reqBody = `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check session cookie was set
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Error("Expected session cookie to be set")
}
}
func TestHandler_ValidateSession(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup and login
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Validate session
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
sessionID := h.ValidateSession(req)
if sessionID == "" {
t.Error("Expected session to be valid")
}
if sessionID != sessionCookie.Value {
t.Error("Session ID mismatch")
}
}
func TestHandler_ValidateSessionInvalid(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Test with no cookie
req := httptest.NewRequest("GET", "/api/test", nil)
sessionID := h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid")
}
// Test with invalid cookie
req.AddCookie(&http.Cookie{Name: "spaxel_session", Value: "invalid"})
sessionID = h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid")
}
}
func TestHandler_Logout(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup and login
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Logout
req = httptest.NewRequest("POST", "/api/auth/logout", nil)
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check cookie was cleared
cookies = w.Result().Cookies()
var clearedCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
clearedCookie = c
break
}
}
if clearedCookie == nil || clearedCookie.MaxAge != -1 {
t.Error("Expected cookie to be cleared (MaxAge=-1)")
}
// Verify session was deleted
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
sessionID := h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid after logout")
}
}
func TestPublicPaths(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"/healthz", true},
{"/api/auth/status", true},
{"/api/auth/setup", true},
{"/api/auth/login", true},
{"/api/auth/logout", true},
{"/api/provision", true},
{"/ws/node", true},
{"/firmware/spaxel-1.0.0.bin", true},
{"/api/settings", false},
{"/api/nodes", false},
{"/ws/dashboard", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := IsPublicPath(tt.path)
if result != tt.expected {
t.Errorf("isPublicPath(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
}
func TestInstallSecret_GeneratedOnFirstRun(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Verify secret was stored
secret, err := h.GetInstallSecret()
if err != nil {
t.Fatal(err)
}
if len(secret) != 32 {
t.Errorf("Expected 32-byte secret, got %d bytes", len(secret))
}
// Verify it's hex-encodable (all bytes)
hexStr := hex.EncodeToString(secret)
if len(hexStr) != 64 {
t.Errorf("Expected 64-char hex string, got %d chars", len(hexStr))
}
}
func TestInstallSecret_EnvVarOverride(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
knownSecret := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
t.Setenv("SPAXEL_INSTALL_SECRET", knownSecret)
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Verify the stored secret matches the env var
secret, err := h.GetInstallSecret()
if err != nil {
t.Fatal(err)
}
gotHex := hex.EncodeToString(secret)
if gotHex != knownSecret {
t.Errorf("Expected secret %q, got %q", knownSecret, gotHex)
}
}
func TestInstallSecret_EnvVarInvalidHex(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
t.Setenv("SPAXEL_INSTALL_SECRET", "zzzz-invalid-hex")
_, err = NewHandler(Config{DB: db})
if err == nil {
t.Fatal("Expected error for invalid hex in SPAXEL_INSTALL_SECRET")
}
}
func TestInstallSecret_EnvVarWrongLength(t *testing.T) {
tests := []struct {
name string
secret string
}{
{"too short (8 bytes)", "a1b2c3d4e5f6a7b8"},
{"too long (40 bytes)", "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
t.Setenv("SPAXEL_INSTALL_SECRET", tt.secret)
_, err = NewHandler(Config{DB: db})
if err == nil {
t.Fatal("Expected error for wrong-length SPAXEL_INSTALL_SECRET")
}
})
}
}
func TestInstallSecret_PersistedAcrossRestarts(t *testing.T) {
// Use a temp file so the DB persists across handler instances
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
// First handler: generates secret
h1, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
secret1, err := h1.GetInstallSecret()
if err != nil {
t.Fatal(err)
}
if len(secret1) != 32 {
t.Fatalf("Expected 32-byte secret, got %d", len(secret1))
}
// Close first handler
h1.Close() //nolint:errcheck
// Second handler: should load same secret from DB (no env var set)
h2, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h2.Close() //nolint:errcheck
secret2, err := h2.GetInstallSecret()
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(secret1) != hex.EncodeToString(secret2) {
t.Error("Secret changed across handler restarts")
}
}
func TestInstallSecret_NodeTokenDerivation(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
tests := []struct {
name string
mac string
mac2 string
same bool // whether mac and mac2 should produce the same token
}{
{"same MAC always produces same token", "AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF", true},
{"different MACs produce different tokens", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", false},
{"case insensitive", "aa:bb:cc:dd:ee:ff", "AA:BB:CC:DD:EE:FF", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token1, err := h.DeriveNodeToken(tt.mac)
if err != nil {
t.Fatal(err)
}
token2, err := h.DeriveNodeToken(tt.mac2)
if err != nil {
t.Fatal(err)
}
if tt.same && token1 != token2 {
t.Errorf("Expected same token for %q and %q, got %q vs %q", tt.mac, tt.mac2, token1, token2)
}
if !tt.same && token1 == token2 {
t.Errorf("Expected different tokens for %q and %q, got same %q", tt.mac, tt.mac2, token1)
}
})
}
}
func TestHandleInstallSecret_FirstRun(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// First run (no PIN configured): should return secret without auth
req := httptest.NewRequest("GET", "/api/auth/install-secret", nil)
w := httptest.NewRecorder()
h.handleInstallSecret(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 on first run, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatal(err)
}
if len(resp["install_secret"]) != 64 {
t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"]))
}
}
func TestHandleInstallSecret_AfterPINSet_Unauthorized(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Configure PIN
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Request without auth: should be rejected
req = httptest.NewRequest("GET", "/api/auth/install-secret", nil)
w = httptest.NewRecorder()
h.handleInstallSecret(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401 after PIN set, got %d", w.Code)
}
}
func TestHandleInstallSecret_AfterPINSet_Authorized(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Configure PIN and get session
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set after setup")
}
// Request with valid session: should succeed
req = httptest.NewRequest("GET", "/api/auth/install-secret", nil)
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleInstallSecret(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 with valid session, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatal(err)
}
if len(resp["install_secret"]) != 64 {
t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"]))
}
}
func TestHandler_ChangePIN_Success(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Change PIN
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatal(err)
}
if resp["ok"] != "true" {
t.Error("Expected ok: true response")
}
// Verify old PIN no longer works
loginReqBody := `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected old PIN to be invalid (401), got %d", w.Code)
}
// Verify new PIN works
loginReqBody = `{"pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected new PIN to work (200), got %d", w.Code)
}
// Verify original session still works
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
if !h.IsAuthenticated(req) {
t.Error("Expected original session to remain valid after PIN change")
}
}
func TestHandler_ChangePIN_WrongOldPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Try to change with wrong old PIN
changeReqBody := `{"old_pin": "9999", "new_pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("Expected status 403 for wrong old PIN, got %d", w.Code)
}
// Verify original PIN still works
loginReqBody := `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected original PIN to still work after failed change, got %d", w.Code)
}
}
func TestHandler_ChangePIN_Unauthenticated(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Try to change PIN without authentication (no cookie)
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
// Use RequireAuth wrapper
wrappedHandler := h.RequireAuth(h.handleChangePIN)
wrappedHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401 for unauthenticated request, got %d", w.Code)
}
}
func TestHandler_ChangePIN_InvalidNewPIN(t *testing.T) {
tests := []struct {
name string
oldPIN string
newPIN string
wantStatus int
}{
{"too short", "1234", "123", http.StatusBadRequest},
{"too long", "1234", "123456789", http.StatusBadRequest},
{"non-numeric", "1234", "abcd", http.StatusBadRequest},
{"mixed", "1234", "12a4", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close() //nolint:errcheck
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close() //nolint:errcheck
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Try to change with invalid new PIN
changeReqBody := `{"old_pin": "` + tt.oldPIN + `", "new_pin": "` + tt.newPIN + `"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}