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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user