feat(auth,security,scheduling): JWT revocation, S3 fix, notes validation, docs, tests

- JWT revocation with JTI (UUID v4): in-memory tracking, POST /api/logout,
  refresh handler revokes old JTI, RequireAuth rejects revoked tokens
- Fix extractKey for S3 portfolio deletion: extracts full key path from URLs
  instead of just filename, preventing orphaned storage files
- Notes validation: max=1000000 on all 13 Notes fields across 4 booking structs
- CharCounter: grapheme-aware counter (Intl.Segmenter), threshold 750K,
  color-coded, integrated into 6 booking/admin components
- loginInProgress: timestamp-based tracking, 30s staleness, 20-entry cap (429),
  ticker cleanup for stuck entries
- Profile picture 15MB client-side limit, portfolio 20MB backend limit
- Exceptional scheduling: expand query start to Monday of week
- TodayCalendar: week-range fetching, closing time indicator, short-day lunch skip
- NavBar: link reorder, mobile burger badge, slide transition, backdrop
- ImageUpload: 20MB limit with visual feedback
- formatDateISO: shared YYYY-MM-DD utility, shouldApplyLunchProtection helper
- Update README.md and all Obsidian docs (Overview, Technical, Admin, Future Work)
- Add 28 new tests: JWT (11), auth handlers (7), portfolio extractKey (5),
  notes validation (5). go build + go vet clean with test,dev tags
This commit is contained in:
2026-06-03 11:17:41 +01:00
parent 169d7dc6e3
commit bffb984ebb
30 changed files with 1016 additions and 62 deletions
+109 -18
View File
@@ -2,7 +2,9 @@ package auth
import (
"context"
"crypto/rand"
"fmt"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
@@ -13,46 +15,135 @@ var TokenAuth *jwtauth.JWTAuth
// AuthResponse is the response structure for login/refresh endpoints
type AuthResponse struct {
Token string `json:"token"`
JTI string `json:"jti"`
}
// In-memory revoked JTI tracking
var (
revokedJTIs = make(map[string]time.Time)
revokedJTIsMu sync.RWMutex
)
// generateJTI generates a UUID v4 string using crypto/rand
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
func generateJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate JTI: %w", err)
}
// Set version 4 bits
b[6] = (b[6] & 0x0f) | 0x40
// Set variant bits (10xx)
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// RevokeJTI adds a JTI to the revoked set with its expiry time
func RevokeJTI(jti string, expiresAt time.Time) {
revokedJTIsMu.Lock()
defer revokedJTIsMu.Unlock()
revokedJTIs[jti] = expiresAt
}
// IsJTIRevoked checks if a JTI is in the revoked set
func IsJTIRevoked(jti string) bool {
revokedJTIsMu.RLock()
defer revokedJTIsMu.RUnlock()
_, revoked := revokedJTIs[jti]
return revoked
}
// CleanupRevokedJTIs removes entries where the expiry time has passed
func CleanupRevokedJTIs() {
revokedJTIsMu.Lock()
defer revokedJTIsMu.Unlock()
now := time.Now()
for jti, expiresAt := range revokedJTIs {
if now.After(expiresAt) {
delete(revokedJTIs, jti)
}
}
}
func init() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
CleanupRevokedJTIs()
}
}()
}
func InitJWT(secret string) {
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
}
// GenerateToken creates a JWT with user_id and role
func GenerateToken(userID string, role string) (string, error) {
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
"user_id": userID,
"role": role,
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
})
return tokenString, err
}
// VerifyToken validates JWT and returns user_id and role
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) {
token, err := TokenAuth.Decode(tokenString)
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
// Returns the token string, the JTI, and any error
func GenerateToken(userID string, role string) (string, string, error) {
jti, err := generateJTI()
if err != nil {
return "", "", err
}
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
"user_id": userID,
"role": role,
"jti": jti,
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
})
return tokenString, jti, err
}
// VerifyToken validates JWT and returns user_id, role, and jti
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) {
token, err := TokenAuth.Decode(tokenString)
if err != nil {
return "", "", "", err
}
var uidVal interface{}
if err := token.Get("user_id", &uidVal); err != nil {
return "", "", fmt.Errorf("invalid user_id claim")
return "", "", "", fmt.Errorf("invalid user_id claim")
}
userID, ok := uidVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid user_id claim")
return "", "", "", fmt.Errorf("invalid user_id claim")
}
var roleVal interface{}
if err := token.Get("role", &roleVal); err != nil {
return "", "", fmt.Errorf("invalid role claim")
return "", "", "", fmt.Errorf("invalid role claim")
}
role, ok = roleVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid role claim")
return "", "", "", fmt.Errorf("invalid role claim")
}
return userID, role, nil
var jtiVal interface{}
if err := token.Get("jti", &jtiVal); err != nil {
return "", "", "", fmt.Errorf("invalid jti claim")
}
jti, ok = jtiVal.(string)
if !ok || jti == "" {
return "", "", "", fmt.Errorf("invalid jti claim")
}
if IsJTIRevoked(jti) {
return "", "", "", fmt.Errorf("token revoked")
}
jti, ok = jtiVal.(string)
if !ok || jti == "" {
return "", "", "", fmt.Errorf("invalid jti claim")
}
if IsJTIRevoked(jti) {
return "", "", "", fmt.Errorf("token revoked")
}
return userID, role, jti, nil
}
+276
View File
@@ -0,0 +1,276 @@
//go:build test
// +build test
package auth
// Package auth contains tests for JWT generation, verification, and JTI revocation.
//
// Test Coverage:
// - generateJTI: UUID v4 format validation, uniqueness
// - GenerateToken: returns non-empty token and JTI, JTI matches claim
// - VerifyToken: returns correct user_id/role/JTI, rejects revoked/missing JTI
// - RevokeJTI: adds to revoked set, IsJTIRevoked reflects changes
// - CleanupRevokedJTIs: removes expired JTIs, keeps valid ones
import (
"context"
"os"
"strings"
"testing"
"time"
)
func TestMain(m *testing.M) {
InitJWT("test-secret-key-for-jwt-test")
code := m.Run()
os.Exit(code)
}
// =============================================================================
// generateJTI Tests
// =============================================================================
// TestGenerateJTI_Format verifies that generateJTI returns a valid UUID v4 string
// in the format: 8-4-4-4-12 hexadecimal digits with version and variant bits set.
func TestGenerateJTI_Format(t *testing.T) {
jti, err := generateJTI()
if err != nil {
t.Fatalf("generateJTI() failed: %v", err)
}
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
parts := strings.Split(jti, "-")
if len(parts) != 5 {
t.Errorf("expected 5 parts in UUID, got %d: %s", len(parts), jti)
}
// Check each part length: 8-4-4-4-12
expectedLengths := []int{8, 4, 4, 4, 12}
for i, part := range parts {
if len(part) != expectedLengths[i] {
t.Errorf("part %d expected length %d, got %d (jti=%s)", i, expectedLengths[i], len(part), jti)
}
}
// Check version nibble (4xxx) in the third group
if len(parts[2]) > 0 && parts[2][0] != '4' {
t.Errorf("expected version 4 UUID, got version %c in jti=%s", parts[2][0], jti)
}
// Check variant bits (8xxx, 9xxx, axxx, or bxxx) in the fourth group
if len(parts[3]) > 0 {
c := parts[3][0]
if c != '8' && c != '9' && c != 'a' && c != 'b' {
t.Errorf("expected variant bits 10xx, got %c in jti=%s", c, jti)
}
}
}
// TestGenerateJTI_Unique generates 100 JTIs and verifies all are unique.
func TestGenerateJTI_Unique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
jti, err := generateJTI()
if err != nil {
t.Fatalf("generateJTI() failed at iteration %d: %v", i, err)
}
if seen[jti] {
t.Errorf("duplicate JTI generated at iteration %d: %s", i, jti)
}
seen[jti] = true
}
if len(seen) != 100 {
t.Errorf("expected 100 unique JTIs, got %d", len(seen))
}
}
// =============================================================================
// GenerateToken Tests
// =============================================================================
// TestGenerateToken_ReturnsJTI verifies that GenerateToken returns both a
// non-empty token string and a non-empty JTI string.
func TestGenerateToken_ReturnsJTI(t *testing.T) {
token, jti, err := GenerateToken("user-001", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
if jti == "" {
t.Error("expected non-empty JTI")
}
}
// TestGenerateToken_JTIInClaims decodes the generated token and verifies
// the "jti" claim matches the returned JTI value.
func TestGenerateToken_JTIInClaims(t *testing.T) {
token, jti, err := GenerateToken("user-002", "admin")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
decoded, err := TokenAuth.Decode(token)
if err != nil {
t.Fatalf("failed to decode token: %v", err)
}
var claimJTI string
if err := decoded.Get("jti", &claimJTI); err != nil {
t.Fatalf("failed to get jti claim: %v", err)
}
if claimJTI != jti {
t.Errorf("expected jti claim %q, got %q", jti, claimJTI)
}
}
// =============================================================================
// VerifyToken Tests
// =============================================================================
// TestVerifyToken_ReturnsJTI creates a token, verifies it, and checks the
// returned user_id, role, and JTI match the expected values.
func TestVerifyToken_ReturnsJTI(t *testing.T) {
token, jti, err := GenerateToken("user-003", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
userID, role, returnedJTI, err := VerifyToken(token, context.Background())
if err != nil {
t.Fatalf("VerifyToken() failed: %v", err)
}
if userID != "user-003" {
t.Errorf("expected userID 'user-003', got %q", userID)
}
if role != "verified_email" {
t.Errorf("expected role 'verified_email', got %q", role)
}
if returnedJTI != jti {
t.Errorf("expected JTI %q, got %q", jti, returnedJTI)
}
}
// TestVerifyToken_RevokedJTI creates a token, revokes its JTI, and verifies
// that VerifyToken returns an error containing "token revoked".
func TestVerifyToken_RevokedJTI(t *testing.T) {
token, jti, err := GenerateToken("user-004", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
_, _, _, err = VerifyToken(token, context.Background())
if err == nil {
t.Fatal("expected error for revoked JTI, got nil")
}
if !strings.Contains(err.Error(), "token revoked") {
t.Errorf("expected 'token revoked' error, got: %v", err)
}
}
// TestVerifyToken_MissingJTI creates a token without a "jti" claim (using
// TokenAuth.Encode directly) and verifies that VerifyToken returns an error
// containing "invalid jti claim".
func TestVerifyToken_MissingJTI(t *testing.T) {
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
"user_id": "user-005",
"role": "verified_email",
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
})
if err != nil {
t.Fatalf("failed to create token without JTI: %v", err)
}
_, _, _, err = VerifyToken(tokenString, context.Background())
if err == nil {
t.Fatal("expected error for missing JTI, got nil")
}
if !strings.Contains(err.Error(), "invalid jti claim") {
t.Errorf("expected 'invalid jti claim' error, got: %v", err)
}
}
// =============================================================================
// RevokeJTI / IsJTIRevoked Tests
// =============================================================================
// TestRevokeJTI_AddsToSet verifies that calling RevokeJTI adds the JTI to the
// revoked set, and IsJTIRevoked returns true for it.
func TestRevokeJTI_AddsToSet(t *testing.T) {
_, jti, err := GenerateToken("user-006", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
if IsJTIRevoked(jti) {
t.Fatal("JTI should not be revoked before calling RevokeJTI")
}
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
if !IsJTIRevoked(jti) {
t.Error("expected IsJTIRevoked to return true after RevokeJTI")
}
}
// TestIsJTIRevoked_NonExistent verifies that checking a non-existent JTI
// returns false.
func TestIsJTIRevoked_NonExistent(t *testing.T) {
if IsJTIRevoked("nonexistent-jti-12345") {
t.Error("expected IsJTIRevoked to return false for non-existent JTI")
}
}
// =============================================================================
// CleanupRevokedJTIs Tests
// =============================================================================
// TestCleanupRevokedJTIs_RemovesExpired adds a JTI with a past expiry time,
// runs CleanupRevokedJTIs, and verifies the JTI is removed from the set.
func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) {
_, jti, err := GenerateToken("user-007", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
// Add with past expiry (1 hour ago)
RevokeJTI(jti, time.Now().Add(-1*time.Hour))
if !IsJTIRevoked(jti) {
t.Fatal("JTI should be in revoked set before cleanup")
}
CleanupRevokedJTIs()
if IsJTIRevoked(jti) {
t.Error("expected expired JTI to be removed after cleanup")
}
}
// TestCleanupRevokedJTIs_KeepsValid adds a JTI with a future expiry time,
// runs CleanupRevokedJTIs, and verifies the JTI is still in the set.
func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
_, jti, err := GenerateToken("user-008", "verified_email")
if err != nil {
t.Fatalf("GenerateToken() failed: %v", err)
}
// Add with future expiry
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
if !IsJTIRevoked(jti) {
t.Fatal("JTI should be in revoked set before cleanup")
}
CleanupRevokedJTIs()
if !IsJTIRevoked(jti) {
t.Error("expected valid (future expiry) JTI to remain after cleanup")
}
}
+288
View File
@@ -31,6 +31,7 @@ import (
"testing"
"time"
"crussell/auth"
"crussell/db"
"crussell/internal/dav"
"crussell/mw"
@@ -38,6 +39,7 @@ import (
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -1105,5 +1107,291 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) {
}
}
// =============================================================================
// Logout Handler Tests
// =============================================================================
// TestLogoutHandler_Success verifies that a valid logout request returns
// 200 OK with {"success": true} and revokes the JTI.
func TestLogoutHandler_Success(t *testing.T) {
resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Generate a token and get its JTI
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// Create request with JTI in context (simulating RequireAuth middleware)
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
ctx := context.WithValue(req.Context(), mw.JTIKey, jti)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
}
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if !resp.Success {
t.Error("expected success=true in response")
}
// Verify JTI was revoked
if !auth.IsJTIRevoked(jti) {
t.Error("expected JTI to be revoked after logout")
}
}
// TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is
// revoked and the token can no longer be used with authenticated endpoints.
func TestLogoutHandler_RevokesJTI(t *testing.T) {
resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Generate a token and get its JTI
token, jti, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// Call logout with JTI in context
req := httptest.NewRequest("POST", "/api/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
ctx := context.WithValue(req.Context(), mw.JTIKey, jti)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String())
}
// Try to use the same token through RequireAuth middleware
router := chi.NewRouter()
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req2 := httptest.NewRequest("GET", "/api/protected", nil)
req2.Header.Set("Authorization", "Bearer "+token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for revoked token, got %d", w2.Code)
}
}
// TestLogoutHandler_NoToken verifies that calling logout without an
// Authorization header returns 401.
func TestLogoutHandler_NoToken(t *testing.T) {
resetTestData(t)
req := httptest.NewRequest("POST", "/api/logout", nil)
w := httptest.NewRecorder()
// Without JTI in context, handler returns 401
LogoutHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestLogoutHandler_InvalidToken verifies that calling logout with an empty
// JTI returns 401.
func TestLogoutHandler_InvalidToken(t *testing.T) {
resetTestData(t)
req := httptest.NewRequest("POST", "/api/logout", nil)
ctx := context.WithValue(req.Context(), mw.JTIKey, "")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
LogoutHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Refresh Token JTI Tests
// =============================================================================
// TestRefreshToken_RevokesOldJTI verifies that refreshing a token revokes the
// old JTI and issues a new one. The old token becomes invalid after refresh.
func TestRefreshToken_RevokesOldJTI(t *testing.T) {
resetTestData(t)
// Create a test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Generate initial token and JTI
oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email")
if err != nil {
t.Fatalf("failed to generate old token: %v", err)
}
// Verify old JTI is not yet revoked
if auth.IsJTIRevoked(oldJTI) {
t.Fatal("old JTI should not be revoked before refresh")
}
// Call refresh handler with old JTI in context
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
req.Header.Set("Authorization", "Bearer "+oldToken)
ctx := req.Context()
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
ctx = context.WithValue(ctx, mw.JTIKey, oldJTI)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
RefreshTokenHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
}
// Verify old JTI was revoked
if !auth.IsJTIRevoked(oldJTI) {
t.Error("expected old JTI to be revoked after refresh")
}
// Verify old token is rejected by middleware
router := chi.NewRouter()
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req2 := httptest.NewRequest("GET", "/api/protected", nil)
req2.Header.Set("Authorization", "Bearer "+oldToken)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for revoked token after refresh, got %d", w2.Code)
}
}
// =============================================================================
// Login Response JTI Tests
// =============================================================================
// TestLoginResponse_IncludesJTI verifies that the login response includes both
// "token" and "jti" fields, both non-empty.
func TestLoginResponse_IncludesJTI(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user with known email
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jti-test@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
body := LoginRequest{
Email: "jti-test@test.com",
Password: "testpassword123",
}
w := makeRequest(handler, "POST", "/api/login", body)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
JTI string `json:"jti"`
}
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Token == "" {
t.Error("expected non-empty token in login response")
}
if resp.JTI == "" {
t.Error("expected non-empty jti in login response")
}
}
// =============================================================================
// LoginInProgress Rate Limiting Tests
// =============================================================================
// TestLoginInProgress_Cap verifies that when the loginInProgress map is full
// (20 concurrent logins), the 21st attempt returns HTTP 429 Too Many Requests.
func TestLoginInProgress_Cap(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(LoginHandler)
// Create a test user
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "ratelimit@test.com", "verified_email")
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Fill the loginInProgress map with 20 entries
loginStateMu.Lock()
for i := 0; i < maxLoginInProgress; i++ {
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = time.Now()
}
loginStateMu.Unlock()
defer func() {
// Clean up
loginStateMu.Lock()
for i := 0; i < maxLoginInProgress; i++ {
delete(loginInProgress, fmt.Sprintf("stale-user-%d", i))
}
loginStateMu.Unlock()
}()
// Attempt login - should get 429
body := LoginRequest{
Email: "ratelimit@test.com",
Password: "testpassword123",
}
w := makeRequest(handler, "POST", "/api/login", body)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected status 429, got %d. body: %s", w.Code, w.Body.String())
}
}
// Ensure test compilation - import pgxpool to avoid unused import
var _ = func() *pgxpool.Pool { return nil }
+43 -7
View File
@@ -30,10 +30,12 @@ var (
titleCaser = cases.Title(language.English)
)
const maxLoginInProgress = 20
// Login state management
var (
loginStateMu sync.Mutex
loginInProgress = make(map[string]bool)
loginInProgress = make(map[string]time.Time)
loginAttempts = make(map[string]time.Time)
)
@@ -51,6 +53,12 @@ func init() {
delete(loginAttempts, userID)
}
}
// Clean up stuck loginInProgress entries (older than 30s)
for userID, startedAt := range loginInProgress {
if now.Sub(startedAt) > 30*time.Second {
delete(loginInProgress, userID)
}
}
loginStateMu.Unlock()
}
}()
@@ -311,12 +319,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Check if user is already logging in
loginStateMu.Lock()
if loginInProgress[userID] {
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
loginStateMu.Unlock()
http.Error(w, "login already in progress", http.StatusConflict) // 409
return
}
loginInProgress[userID] = true
// Cap the map size - drop new request if at capacity
if len(loginInProgress) >= maxLoginInProgress {
loginStateMu.Unlock()
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
return
}
loginInProgress[userID] = time.Now()
loginStateMu.Unlock()
// Always clear flag when done
@@ -363,20 +377,21 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
}
// Generate JWT
tokenString, err := auth.GenerateToken(userID, role)
tokenString, jti, err := auth.GenerateToken(userID, role)
if err != nil {
http.Error(w, "could not generate token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString})
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString, JTI: jti})
}
// POST /api/refresh-token (requires auth middleware)
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
userID, _ := mw.GetUserID(r.Context())
role, _ := mw.GetUserRole(r.Context())
oldJTI, _ := mw.GetJTI(r.Context())
// Verify user still exists and role hasn't changed
var currentRole string
@@ -395,15 +410,36 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Revoke the old token's JTI before issuing a new one
if oldJTI != "" {
// Use a 30-day expiry from now for the revoked JTI (matching token lifetime)
auth.RevokeJTI(oldJTI, time.Now().Add(30*24*time.Hour))
}
// Generate new token
newToken, err := auth.GenerateToken(userID, currentRole)
newToken, jti, err := auth.GenerateToken(userID, currentRole)
if err != nil {
http.Error(w, "could not generate token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken})
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken, JTI: jti})
}
// POST /api/logout (requires auth middleware)
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
jti, ok := mw.GetJTI(r.Context())
if !ok || jti == "" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Revoke the JTI — it will be kept until the token's natural expiry (30 days)
auth.RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
type VerificationCodeRequest struct {
+6 -6
View File
@@ -33,7 +33,7 @@ type Booking struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *string `json:"created_by,omitempty"`
@@ -111,7 +111,7 @@ type Payment struct {
type CreateBookingRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
UserID *string `json:"user_id,omitempty"`
}
@@ -128,7 +128,7 @@ type ProgressBookingRequest struct {
// ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct {
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// ServiceOverride represents override values for a specific service in a booking
@@ -142,7 +142,7 @@ type ServiceOverride struct {
type UpdateBookingServicesRequest struct {
ServiceIDs []string `json:"service_ids"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// DeleteBookingRequest represents the request payload for deleting a booking with payment
@@ -155,7 +155,7 @@ type DeleteBookingRequest struct {
type AdminUserSummary struct {
FullName string `json:"full_name"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// AdminBookingSummary represents a complete booking summary for admin view
@@ -184,7 +184,7 @@ type UserSummary struct {
ReferralCode *string `json:"referral_code,omitempty"`
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
CreatedAt string `json:"created_at"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
type BookingServiceDetail struct {
@@ -29,6 +29,7 @@ import (
"crussell/db"
"crussell/handlers/user"
"crussell/internal/validators"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
@@ -2237,6 +2238,89 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) {
}
}
// =============================================================================
// Notes Validation Tests
// =============================================================================
// TestNotesValidation_UnderLimit verifies that a Notes string of 999,999
// characters passes go-playground/validator validation (max=1000000).
func TestNotesValidation_UnderLimit(t *testing.T) {
longNotes := strings.Repeat("a", 999999)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &longNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for 999999-char notes, got: %v", err)
}
}
// TestNotesValidation_AtLimit verifies that a Notes string of exactly
// 1,000,000 characters passes validation (max=1000000, boundary test).
func TestNotesValidation_AtLimit(t *testing.T) {
exactNotes := strings.Repeat("b", 1000000)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &exactNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for 1000000-char notes (boundary), got: %v", err)
}
}
// TestNotesValidation_OverLimit verifies that a Notes string of 1,000,001
// characters fails validation with max=1000000.
func TestNotesValidation_OverLimit(t *testing.T) {
tooLongNotes := strings.Repeat("c", 1000001)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &tooLongNotes,
}
err := validators.Validate.Struct(&req)
if err == nil {
t.Error("expected validation to fail for 1000001-char notes (over limit)")
}
}
// TestNotesValidation_NilPointer verifies that a nil Notes pointer passes
// validation (omitempty tag allows nil/empty).
func TestNotesValidation_NilPointer(t *testing.T) {
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: nil,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for nil Notes (omitempty), got: %v", err)
}
}
// TestNotesValidation_EmptyString verifies that an empty string Notes passes
// validation (omitempty and max=1000000 allows empty strings).
func TestNotesValidation_EmptyString(t *testing.T) {
emptyNotes := ""
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &emptyNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for empty string Notes, got: %v", err)
}
}
// Ensure test compilation - import pgxpool to avoid unused import
var _ = func() *pgxpool.Pool { return nil }
+5 -5
View File
@@ -422,7 +422,7 @@ type AdminCreateBookingForUserRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // appointment notes, visible to customers and staff
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks
}
@@ -860,7 +860,7 @@ type BookingEditRequest struct {
RequestedBy string `json:"requested_by"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
HasOverrides bool `json:"has_overrides"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
@@ -880,7 +880,7 @@ type EditSnapshot struct {
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
Services []EditServiceDetail `json:"services"`
Notes *string `json:"notes"`
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
}
type EditUserSummary struct {
@@ -895,7 +895,7 @@ type EnrichedEditRequest struct {
BookingID string `json:"booking_id"`
RequestedBy string `json:"requested_by"`
RequestedAt time.Time `json:"requested_at"`
Notes *string `json:"notes"`
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
Original *EditSnapshot `json:"original"`
Proposed *EditSnapshot `json:"proposed"`
User *EditUserSummary `json:"user,omitempty"`
@@ -1170,7 +1170,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
+1 -1
View File
@@ -69,7 +69,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
var err error
userID, _, err = auth.VerifyToken(tokenString, r.Context())
userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
if err != nil {
// Invalid token - treat as unauthenticated
userID = ""
+20 -4
View File
@@ -733,11 +733,27 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
}
func extractKey(url string) string {
parts := strings.Split(url, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
// URL format: https://endpoint/bucket/portfolio/1234567890.jpg
// Need to return: portfolio/1234567890.jpg
// Find the bucket segment: skip past scheme://endpoint/
idx := strings.Index(url, "://")
if idx == -1 {
return url
}
return url
rest := url[idx+3:] // skip "://"
// Now rest = "endpoint/bucket/portfolio/1234567890.jpg"
// Skip first path segment (endpoint)
slashIdx := strings.Index(rest, "/")
if slashIdx == -1 {
return rest
}
rest = rest[slashIdx+1:] // "bucket/portfolio/1234567890.jpg"
// Skip second path segment (bucket)
slashIdx = strings.Index(rest, "/")
if slashIdx == -1 {
return rest
}
return rest[slashIdx+1:] // "portfolio/1234567890.jpg"
}
func GetImage(w http.ResponseWriter, r *http.Request) {
+59
View File
@@ -559,6 +559,65 @@ func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) {
}
}
// =============================================================================
// extractKey Tests
// =============================================================================
// TestExtractKey_FullPath tests extractKey with a full S3/R2 URL, verifying
// it extracts the key after bucket: "portfolio/1234567890.jpg".
func TestExtractKey_FullPath(t *testing.T) {
url := "https://endpoint.example.com/crussell/portfolio/1234567890.jpg"
expected := "portfolio/1234567890.jpg"
result := extractKey(url)
if result != expected {
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
}
}
// TestExtractKey_NestedPath tests extractKey with a nested path (thumbnail
// subdirectory), verifying it extracts the full key including subdirectories.
func TestExtractKey_NestedPath(t *testing.T) {
url := "https://endpoint.example.com/crussell/portfolio/thumbs/1234567890.jpg"
expected := "portfolio/thumbs/1234567890.jpg"
result := extractKey(url)
if result != expected {
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
}
}
// TestExtractKey_NoScheme tests extractKey with a URL missing the scheme,
// which exercises the fallback path (no "://" found → returns url as-is).
func TestExtractKey_NoScheme(t *testing.T) {
url := "endpoint.example.com/crussell/portfolio/1234567890.jpg"
expected := "endpoint.example.com/crussell/portfolio/1234567890.jpg"
result := extractKey(url)
if result != expected {
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
}
}
// TestExtractKey_PlainFilename tests extractKey with just a filename (no
// URL structure at all), verifying it returns the input unchanged.
func TestExtractKey_PlainFilename(t *testing.T) {
url := "1234567890.jpg"
expected := "1234567890.jpg"
result := extractKey(url)
if result != expected {
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
}
}
// TestExtractKey_EmptyString tests extractKey with an empty string,
// verifying it returns an empty string.
func TestExtractKey_EmptyString(t *testing.T) {
url := ""
expected := ""
result := extractKey(url)
if result != expected {
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
}
}
// createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker
func createJpegWithExifMarker(jpegData []byte) []byte {
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
+1 -1
View File
@@ -233,7 +233,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
var err error
userID, role, err = auth.VerifyToken(tokenString, r.Context())
userID, role, _, err = auth.VerifyToken(tokenString, r.Context())
if err != nil {
// Invalid token - treat as unauthenticated
userID = ""
+1 -1
View File
@@ -29,7 +29,7 @@ type AppointmentInfo struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
User *UserInfo `json:"user,omitempty"`
Services []ServiceInfo `json:"services"`
DurationMinutes int `json:"duration_minutes"`
+1 -1
View File
@@ -75,7 +75,7 @@ type AdminUserDetail struct {
LastLoginAt *string `json:"lastLoginAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Notes *string `json:"notes,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
// GDPR consent fields
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
+7 -3
View File
@@ -54,8 +54,9 @@ func limitBody(limit int64) func(http.Handler) http.Handler {
}
const (
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
portfolioBodyLimit int64 = 20 * 1024 * 1024 // 20MB
)
func initDB() {
@@ -164,6 +165,9 @@ func main() {
// Login: Has its own internal rate limiting
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler)
// Logout: requires valid token
r.With(mw.RequireAuth).Post("/logout", authHandlers.LogoutHandler)
// Email verification
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
r.With(mw.RateLimit(20, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/check", authHandlers.VerifyCodeHandler)
@@ -185,7 +189,7 @@ func main() {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.With(limitBody(uploadBodyLimit)).Post("/images", portfolio.UploadImage)
r.With(limitBody(portfolioBodyLimit)).Post("/images", portfolio.UploadImage)
r.Delete("/images/{id}", portfolio.DeleteImage)
})
})
+11 -3
View File
@@ -13,6 +13,7 @@ type contextKey string
const (
UserIDKey contextKey = "user_id"
UserRoleKey contextKey = "user_role"
JTIKey contextKey = "jti"
)
// RequireAuth middleware - validates JWT and adds user info to context
@@ -26,15 +27,16 @@ func RequireAuth(next http.Handler) http.Handler {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
userID, role, err := auth.VerifyToken(tokenString, r.Context())
userID, role, jti, err := auth.VerifyToken(tokenString, r.Context())
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Add user info to context
// Add user info and JTI to context
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserRoleKey, role)
ctx = context.WithValue(ctx, JTIKey, jti)
next.ServeHTTP(w, r.WithContext(ctx))
})
@@ -47,10 +49,11 @@ func OptionalAuth(next http.Handler) http.Handler {
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
userID, role, err := auth.VerifyToken(tokenString, r.Context())
userID, role, jti, err := auth.VerifyToken(tokenString, r.Context())
if err == nil {
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserRoleKey, role)
ctx = context.WithValue(ctx, JTIKey, jti)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
@@ -109,3 +112,8 @@ func GetUserRole(ctx context.Context) (string, bool) {
role, ok := ctx.Value(UserRoleKey).(string)
return role, ok
}
func GetJTI(ctx context.Context) (string, bool) {
jti, ok := ctx.Value(JTIKey).(string)
return jti, ok
}
+1 -1
View File
@@ -37,7 +37,7 @@ func EnsureInitialized() {
func GenerateTestToken(userID, role string) string {
EnsureInitialized()
token, err := auth.GenerateToken(userID, role)
token, _, err := auth.GenerateToken(userID, role)
if err != nil {
panic("failed to generate test token: " + err.Error())
}