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:
@@ -31,6 +31,19 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 frontend + Docker.
|
|||||||
- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page
|
- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page
|
||||||
- **Patch test duration on services**: `patch_test_duration_hours` exposed on Service type; creating a service with duration > 0 auto-creates a patch test record
|
- **Patch test duration on services**: `patch_test_duration_hours` exposed on Service type; creating a service with duration > 0 auto-creates a patch test record
|
||||||
- **`created_by_name` on bookings**: admin booking details now show who created the booking (admin name)
|
- **`created_by_name` on bookings**: admin booking details now show who created the booking (admin name)
|
||||||
|
- **Exceptional scheduling fix**: exceptional application queries expand start to Monday of the week, fixing single-day queries missing Monday-based `week_start` records
|
||||||
|
- **Portfolio image upload limits**: 20MB file size limit with visual feedback (red borders, error text), upload button disabled for oversized files
|
||||||
|
- **NavBar enhancements**: admin links reordered (Today/Schedule promoted), unread notification badge on mobile burger icon, backdrop overlay + slide transition for mobile menu
|
||||||
|
- **TodayCalendar improvements**: fetches working/available hours for full week range instead of single day, closing time indicator on timeline, skips lunch suggestion for short days (5h or less)
|
||||||
|
- **Lunch protection refinement**: `shouldApplyLunchProtection()` skips lunch protection for days with 5 or fewer working hours
|
||||||
|
- **`formatDateISO` utility**: new `formatDateISO(date)` function in `lib/utils/format.ts` returning YYYY-MM-DD format for API calls, later extracted to shared utilities
|
||||||
|
- **JWT revocation with JTI**: every JWT carries a unique `jti` claim (UUID v4), in-memory revoked JTI tracking with 5-minute cleanup ticker, `POST /api/logout` endpoint revokes current token, refresh handler revokes old JTI before issuing replacement
|
||||||
|
- **Portfolio image deletion fix**: `extractKey` correctly extracts full S3 key path from URLs instead of just the filename, preventing orphaned files in storage
|
||||||
|
- **Notes validation**: all `Notes *string` fields across booking structs validated with `max=1000000` tag (13 fields across 4 files)
|
||||||
|
- **CharCounter component**: reusable grapheme counter using `Intl.Segmenter`, shows counter only above 750K graphemes, color-coded (green <800K, yellow 800K-950K, red >950K), integrated into 6 booking/admin components
|
||||||
|
- **loginInProgress rate limiting**: switched from `map[string]bool` to `map[string]time.Time` with 30-second staleness check, 20-entry cap (returns 429 when full), ticker goroutine cleans up stuck entries
|
||||||
|
- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page
|
||||||
|
- **Portfolio image upload backend limit**: separate `portfolioBodyLimit` (20MB) applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
+109
-18
@@ -2,7 +2,9 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
@@ -13,46 +15,135 @@ var TokenAuth *jwtauth.JWTAuth
|
|||||||
// AuthResponse is the response structure for login/refresh endpoints
|
// AuthResponse is the response structure for login/refresh endpoints
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"`
|
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) {
|
func InitJWT(secret string) {
|
||||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken creates a JWT with user_id and role
|
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
|
||||||
func GenerateToken(userID string, role string) (string, error) {
|
// Returns the token string, the JTI, and any error
|
||||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
func GenerateToken(userID string, role string) (string, string, error) {
|
||||||
"user_id": userID,
|
jti, err := generateJTI()
|
||||||
"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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
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{}
|
var uidVal interface{}
|
||||||
if err := token.Get("user_id", &uidVal); err != nil {
|
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)
|
userID, ok := uidVal.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", fmt.Errorf("invalid user_id claim")
|
return "", "", "", fmt.Errorf("invalid user_id claim")
|
||||||
}
|
}
|
||||||
|
|
||||||
var roleVal interface{}
|
var roleVal interface{}
|
||||||
if err := token.Get("role", &roleVal); err != nil {
|
if err := token.Get("role", &roleVal); err != nil {
|
||||||
return "", "", fmt.Errorf("invalid role claim")
|
return "", "", "", fmt.Errorf("invalid role claim")
|
||||||
}
|
}
|
||||||
role, ok = roleVal.(string)
|
role, ok = roleVal.(string)
|
||||||
if !ok {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crussell/auth"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/internal/dav"
|
"crussell/internal/dav"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
@@ -38,6 +39,7 @@ import (
|
|||||||
"crussell/testutils/jwt"
|
"crussell/testutils/jwt"
|
||||||
"crussell/testutils/testdb"
|
"crussell/testutils/testdb"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
// Ensure test compilation - import pgxpool to avoid unused import
|
||||||
var _ = func() *pgxpool.Pool { return nil }
|
var _ = func() *pgxpool.Pool { return nil }
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ var (
|
|||||||
titleCaser = cases.Title(language.English)
|
titleCaser = cases.Title(language.English)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxLoginInProgress = 20
|
||||||
|
|
||||||
// Login state management
|
// Login state management
|
||||||
var (
|
var (
|
||||||
loginStateMu sync.Mutex
|
loginStateMu sync.Mutex
|
||||||
loginInProgress = make(map[string]bool)
|
loginInProgress = make(map[string]time.Time)
|
||||||
loginAttempts = make(map[string]time.Time)
|
loginAttempts = make(map[string]time.Time)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +53,12 @@ func init() {
|
|||||||
delete(loginAttempts, userID)
|
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()
|
loginStateMu.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -311,12 +319,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Check if user is already logging in
|
// Check if user is already logging in
|
||||||
loginStateMu.Lock()
|
loginStateMu.Lock()
|
||||||
if loginInProgress[userID] {
|
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
|
||||||
loginStateMu.Unlock()
|
loginStateMu.Unlock()
|
||||||
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
||||||
return
|
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()
|
loginStateMu.Unlock()
|
||||||
|
|
||||||
// Always clear flag when done
|
// Always clear flag when done
|
||||||
@@ -363,20 +377,21 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
tokenString, err := auth.GenerateToken(userID, role)
|
tokenString, jti, err := auth.GenerateToken(userID, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
// POST /api/refresh-token (requires auth middleware)
|
||||||
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, _ := mw.GetUserID(r.Context())
|
userID, _ := mw.GetUserID(r.Context())
|
||||||
role, _ := mw.GetUserRole(r.Context())
|
role, _ := mw.GetUserRole(r.Context())
|
||||||
|
oldJTI, _ := mw.GetJTI(r.Context())
|
||||||
|
|
||||||
// Verify user still exists and role hasn't changed
|
// Verify user still exists and role hasn't changed
|
||||||
var currentRole string
|
var currentRole string
|
||||||
@@ -395,15 +410,36 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Generate new token
|
||||||
newToken, err := auth.GenerateToken(userID, currentRole)
|
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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 {
|
type VerificationCodeRequest struct {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type Booking struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy *string `json:"created_by,omitempty"`
|
CreatedBy *string `json:"created_by,omitempty"`
|
||||||
@@ -111,7 +111,7 @@ type Payment struct {
|
|||||||
type CreateBookingRequest struct {
|
type CreateBookingRequest struct {
|
||||||
StartTime time.Time `json:"start_time" validate:"required"`
|
StartTime time.Time `json:"start_time" validate:"required"`
|
||||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
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"`
|
UserID *string `json:"user_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ type ProgressBookingRequest struct {
|
|||||||
// ConfirmBookingRequest represents the request payload for confirming a booking
|
// ConfirmBookingRequest represents the request payload for confirming a booking
|
||||||
type ConfirmBookingRequest struct {
|
type ConfirmBookingRequest struct {
|
||||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
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
|
// ServiceOverride represents override values for a specific service in a booking
|
||||||
@@ -142,7 +142,7 @@ type ServiceOverride struct {
|
|||||||
type UpdateBookingServicesRequest struct {
|
type UpdateBookingServicesRequest struct {
|
||||||
ServiceIDs []string `json:"service_ids"`
|
ServiceIDs []string `json:"service_ids"`
|
||||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
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
|
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
||||||
@@ -155,7 +155,7 @@ type DeleteBookingRequest struct {
|
|||||||
type AdminUserSummary struct {
|
type AdminUserSummary struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
|
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
|
// AdminBookingSummary represents a complete booking summary for admin view
|
||||||
@@ -184,7 +184,7 @@ type UserSummary struct {
|
|||||||
ReferralCode *string `json:"referral_code,omitempty"`
|
ReferralCode *string `json:"referral_code,omitempty"`
|
||||||
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
|
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BookingServiceDetail struct {
|
type BookingServiceDetail struct {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/handlers/user"
|
"crussell/handlers/user"
|
||||||
|
"crussell/internal/validators"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"crussell/testutils/fixtures"
|
"crussell/testutils/fixtures"
|
||||||
"crussell/testutils/jwt"
|
"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
|
// Ensure test compilation - import pgxpool to avoid unused import
|
||||||
var _ = func() *pgxpool.Pool { return nil }
|
var _ = func() *pgxpool.Pool { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ type AdminCreateBookingForUserRequest struct {
|
|||||||
StartTime time.Time `json:"start_time" validate:"required"`
|
StartTime time.Time `json:"start_time" validate:"required"`
|
||||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
||||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
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
|
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"`
|
RequestedBy string `json:"requested_by"`
|
||||||
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
||||||
NewServices []string `json:"new_services"`
|
NewServices []string `json:"new_services"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
HasOverrides bool `json:"has_overrides"`
|
HasOverrides bool `json:"has_overrides"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
// Joined fields
|
// Joined fields
|
||||||
@@ -880,7 +880,7 @@ type EditSnapshot struct {
|
|||||||
StartTime *time.Time `json:"start_time"`
|
StartTime *time.Time `json:"start_time"`
|
||||||
EndTime *time.Time `json:"end_time"`
|
EndTime *time.Time `json:"end_time"`
|
||||||
Services []EditServiceDetail `json:"services"`
|
Services []EditServiceDetail `json:"services"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditUserSummary struct {
|
type EditUserSummary struct {
|
||||||
@@ -895,7 +895,7 @@ type EnrichedEditRequest struct {
|
|||||||
BookingID string `json:"booking_id"`
|
BookingID string `json:"booking_id"`
|
||||||
RequestedBy string `json:"requested_by"`
|
RequestedBy string `json:"requested_by"`
|
||||||
RequestedAt time.Time `json:"requested_at"`
|
RequestedAt time.Time `json:"requested_at"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
||||||
Original *EditSnapshot `json:"original"`
|
Original *EditSnapshot `json:"original"`
|
||||||
Proposed *EditSnapshot `json:"proposed"`
|
Proposed *EditSnapshot `json:"proposed"`
|
||||||
User *EditUserSummary `json:"user,omitempty"`
|
User *EditUserSummary `json:"user,omitempty"`
|
||||||
@@ -1170,7 +1170,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
||||||
NewServices []string `json:"new_services"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
var err error
|
var err error
|
||||||
userID, _, err = auth.VerifyToken(tokenString, r.Context())
|
userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Invalid token - treat as unauthenticated
|
// Invalid token - treat as unauthenticated
|
||||||
userID = ""
|
userID = ""
|
||||||
|
|||||||
@@ -733,11 +733,27 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractKey(url string) string {
|
func extractKey(url string) string {
|
||||||
parts := strings.Split(url, "/")
|
// URL format: https://endpoint/bucket/portfolio/1234567890.jpg
|
||||||
if len(parts) > 0 {
|
// Need to return: portfolio/1234567890.jpg
|
||||||
return parts[len(parts)-1]
|
// 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) {
|
func GetImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
// createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker
|
||||||
func createJpegWithExifMarker(jpegData []byte) []byte {
|
func createJpegWithExifMarker(jpegData []byte) []byte {
|
||||||
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
|
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
var err error
|
var err error
|
||||||
userID, role, err = auth.VerifyToken(tokenString, r.Context())
|
userID, role, _, err = auth.VerifyToken(tokenString, r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Invalid token - treat as unauthenticated
|
// Invalid token - treat as unauthenticated
|
||||||
userID = ""
|
userID = ""
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type AppointmentInfo struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
User *UserInfo `json:"user,omitempty"`
|
User *UserInfo `json:"user,omitempty"`
|
||||||
Services []ServiceInfo `json:"services"`
|
Services []ServiceInfo `json:"services"`
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ type AdminUserDetail struct {
|
|||||||
LastLoginAt *string `json:"lastLoginAt,omitempty"`
|
LastLoginAt *string `json:"lastLoginAt,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
|
|
||||||
// GDPR consent fields
|
// GDPR consent fields
|
||||||
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
|
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
|
||||||
|
|||||||
+7
-3
@@ -54,8 +54,9 @@ func limitBody(limit int64) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
|
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
|
||||||
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
|
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
|
||||||
|
portfolioBodyLimit int64 = 20 * 1024 * 1024 // 20MB
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDB() {
|
func initDB() {
|
||||||
@@ -164,6 +165,9 @@ func main() {
|
|||||||
// Login: Has its own internal rate limiting
|
// Login: Has its own internal rate limiting
|
||||||
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler)
|
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
|
// Email verification
|
||||||
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
|
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)
|
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.RequireAuth)
|
||||||
r.Use(mw.RequireAdmin)
|
r.Use(mw.RequireAdmin)
|
||||||
r.Use(mw.RateLimit(60, time.Minute))
|
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)
|
r.Delete("/images/{id}", portfolio.DeleteImage)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-3
@@ -13,6 +13,7 @@ type contextKey string
|
|||||||
const (
|
const (
|
||||||
UserIDKey contextKey = "user_id"
|
UserIDKey contextKey = "user_id"
|
||||||
UserRoleKey contextKey = "user_role"
|
UserRoleKey contextKey = "user_role"
|
||||||
|
JTIKey contextKey = "jti"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireAuth middleware - validates JWT and adds user info to context
|
// 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 ")
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user info to context
|
// Add user info and JTI to context
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
ctx = context.WithValue(ctx, UserRoleKey, role)
|
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||||
|
ctx = context.WithValue(ctx, JTIKey, jti)
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
@@ -47,10 +49,11 @@ func OptionalAuth(next http.Handler) http.Handler {
|
|||||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
tokenString := strings.TrimPrefix(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 {
|
if err == nil {
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
ctx = context.WithValue(ctx, UserRoleKey, role)
|
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||||
|
ctx = context.WithValue(ctx, JTIKey, jti)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,3 +112,8 @@ func GetUserRole(ctx context.Context) (string, bool) {
|
|||||||
role, ok := ctx.Value(UserRoleKey).(string)
|
role, ok := ctx.Value(UserRoleKey).(string)
|
||||||
return role, ok
|
return role, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetJTI(ctx context.Context) (string, bool) {
|
||||||
|
jti, ok := ctx.Value(JTIKey).(string)
|
||||||
|
return jti, ok
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func EnsureInitialized() {
|
|||||||
|
|
||||||
func GenerateTestToken(userID, role string) string {
|
func GenerateTestToken(userID, role string) string {
|
||||||
EnsureInitialized()
|
EnsureInitialized()
|
||||||
token, err := auth.GenerateToken(userID, role)
|
token, _, err := auth.GenerateToken(userID, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to generate test token: " + err.Error())
|
panic("failed to generate test token: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -893,6 +894,7 @@
|
|||||||
placeholder="Tell us why you need to make changes"
|
placeholder="Tell us why you need to make changes"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={notes} />
|
||||||
</div>
|
</div>
|
||||||
{:else if editMode === 'services'}
|
{:else if editMode === 'services'}
|
||||||
<!-- ─── Services Only (with time restriction) ── -->
|
<!-- ─── Services Only (with time restriction) ── -->
|
||||||
@@ -1018,6 +1020,7 @@
|
|||||||
placeholder="Any special requests or notes for your appointment"
|
placeholder="Any special requests or notes for your appointment"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={notes} />
|
||||||
</div>
|
</div>
|
||||||
{:else if editMode === 'both-services'}
|
{:else if editMode === 'both-services'}
|
||||||
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
||||||
@@ -1128,6 +1131,7 @@
|
|||||||
placeholder="Any special requests or notes for your appointment"
|
placeholder="Any special requests or notes for your appointment"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={notes} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -492,6 +493,7 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={notes} />
|
||||||
{#if notes.trim() !== (booking.notes || '')}
|
{#if notes.trim() !== (booking.notes || '')}
|
||||||
<div class="mt-1 text-xs text-emerald-600">
|
<div class="mt-1 text-xs text-emerald-600">
|
||||||
✓ Notes will be saved (different from original)
|
✓ Notes will be saved (different from original)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
|
|
||||||
// Booking Components
|
// Booking Components
|
||||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
@@ -1112,6 +1113,7 @@
|
|||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<CharCounter text={notes} />
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
<Card.Footer class="flex justify-between">
|
<Card.Footer class="flex justify-between">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
import type { Booking, BookingService, Service } from '$lib/types/booking';
|
import type { Booking, BookingService, Service } from '$lib/types/booking';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -614,6 +615,7 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={notes} />
|
||||||
{#if notes.trim() !== (booking.notes || '')}
|
{#if notes.trim() !== (booking.notes || '')}
|
||||||
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
|
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
|
|
||||||
// Booking Components
|
// Booking Components
|
||||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
@@ -811,6 +812,7 @@
|
|||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<CharCounter text={notes} />
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
<Card.Footer class="flex justify-between">
|
<Card.Footer class="flex justify-between">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
||||||
@@ -1659,6 +1660,7 @@
|
|||||||
placeholder="Any allergies, preferences, or special requirements..."
|
placeholder="Any allergies, preferences, or special requirements..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
<CharCounter text={customerInfo.specialRequests} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
text: string;
|
||||||
|
maxChars?: number;
|
||||||
|
threshold?: number; // when to start showing counter
|
||||||
|
};
|
||||||
|
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
|
||||||
|
|
||||||
|
const graphemeCount = $derived.by(() => {
|
||||||
|
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||||
|
return [...segmenter.segment(text)].length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShow = $derived(graphemeCount > threshold);
|
||||||
|
const remaining = $derived(maxChars - graphemeCount);
|
||||||
|
const color = $derived(
|
||||||
|
graphemeCount > 950000 ? 'text-red-600' :
|
||||||
|
graphemeCount > 800000 ? 'text-yellow-600' :
|
||||||
|
'text-green-600'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if shouldShow}
|
||||||
|
<p class="text-xs {color}">
|
||||||
|
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
@@ -147,7 +147,17 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inside AuthStore
|
// inside AuthStore
|
||||||
logout = () => {
|
logout = async () => {
|
||||||
|
if (this.token) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${this.token}` }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore network errors - still clear local state
|
||||||
|
}
|
||||||
|
}
|
||||||
this.clearAuth();
|
this.clearAuth();
|
||||||
goto('/', { invalidateAll: true });
|
goto('/', { invalidateAll: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -245,10 +245,16 @@
|
|||||||
let zoom = $state(1);
|
let zoom = $state(1);
|
||||||
let previewUrl = $state('');
|
let previewUrl = $state('');
|
||||||
|
|
||||||
|
const PROFILE_PIC_MAX_SIZE = 15 * 1024 * 1024; // 15MB
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
function handleFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
if (file.size > PROFILE_PIC_MAX_SIZE) {
|
||||||
|
toast.error('Profile picture must be under 15MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
cropImageUrl = URL.createObjectURL(file);
|
cropImageUrl = URL.createObjectURL(file);
|
||||||
cropDialogOpen = true;
|
cropDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,11 +477,11 @@ The window shows the full booking details so you can review them before making y
|
|||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications.
|
The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications. On mobile, the unread count also appears on the hamburger menu icon so you can see it at a glance when the menu is collapsed.
|
||||||
|
|
||||||
### How to Access
|
### How to Access
|
||||||
|
|
||||||
Click the **bell icon** in the top-right corner of the website to go to the Notifications page. The bell shows a number indicating how many unread notifications you have.
|
Click the **bell icon** in the top-right corner of the website (or the **notification badge** on the mobile hamburger menu) to go to the Notifications page. The bell shows a number indicating how many unread notifications you have.
|
||||||
|
|
||||||
### What You'll See
|
### What You'll See
|
||||||
|
|
||||||
@@ -554,9 +554,10 @@ The portfolio is the salon's gallery of nail art photos that customers can brows
|
|||||||
2. Find the image upload section
|
2. Find the image upload section
|
||||||
3. **Drag and drop** an image file, or click to select one from your device
|
3. **Drag and drop** an image file, or click to select one from your device
|
||||||
4. A preview of the image appears so you can check it looks right
|
4. A preview of the image appears so you can check it looks right
|
||||||
5. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent
|
5. **File size limit**: Images must be under 20MB. If you select a file larger than 20MB, the upload area gets a red border and shows an error message, and the upload button is disabled until you choose a smaller file
|
||||||
6. A confirmation window appears before the image is uploaded — this is your last chance to double-check
|
6. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent
|
||||||
7. **Confirm** — the image is uploaded and appears in the portfolio gallery
|
7. A confirmation window appears before the image is uploaded — this is your last chance to double-check
|
||||||
|
8. **Confirm** — the image is uploaded and appears in the portfolio gallery
|
||||||
|
|
||||||
### Tips for Tagging
|
### Tips for Tagging
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
**Last Updated:** May 2026 — Admin schedule page (weekly calendar view), referral code registration, BookingFlow welcome step for guests, patch_test_duration_hours on services, shared format utilities, created_by_name on bookings, admin login redirect, 446/449 tests passing (3 skipped)
|
**Last Updated:** June 2026 — JWT revocation with JTI, portfolio image upload limits (20MB frontend/backend), notes validation (max=1000000), CharCounter component, loginInProgress rate limiting cap, profile picture 15MB limit, formatDateISO utility, NavBar/TodayCalendar/lunch protection refinements
|
||||||
**Status:** Living backlog — add to this as gaps are discovered
|
**Status:** Living backlog — add to this as gaps are discovered
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ flowchart TD
|
|||||||
- Email verification and password reset endpoints (backend ready, frontend not wired)
|
- Email verification and password reset endpoints (backend ready, frontend not wired)
|
||||||
- Guest/disposable accounts for one-off bookings
|
- Guest/disposable accounts for one-off bookings
|
||||||
- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page
|
- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page
|
||||||
|
- **JWT revocation with JTI**: every JWT includes a unique `jti` claim (UUID v4) for revocation tracking. In-memory map of revoked JTIs with 5-minute cleanup ticker. `POST /api/logout` revokes the current token. Refresh handler revokes old JTI before issuing new token
|
||||||
|
|
||||||
### Booking System
|
### Booking System
|
||||||
- Three booking flows: self-service (customer), walk-in (admin), call-in (admin)
|
- Three booking flows: self-service (customer), walk-in (admin), call-in (admin)
|
||||||
@@ -207,7 +208,7 @@ All flows integrate with holiday/exceptional hours and time blockers. All bookin
|
|||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
**446/449 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, enriched edit request workflows, GetBookingsByCreatedRange endpoint, scheduling exceptional hours, and referral code validation.
|
**446/449 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, enriched edit request workflows, GetBookingsByCreatedRange endpoint, scheduling exceptional hours, referral code validation, and JWT revocation via JTI logout.
|
||||||
|
|
||||||
| Package | Coverage Area |
|
| Package | Coverage Area |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Backend (:8080)
|
|||||||
|
|
||||||
| Middleware | Purpose |
|
| Middleware | Purpose |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| `RequireAuth` | Validates JWT, adds user_id and user_role to context |
|
| `RequireAuth` | Validates JWT, extracts `jti` claim, adds user_id and user_role to context; rejects revoked JTIs |
|
||||||
| `OptionalAuth` | Extracts user info if token present, passes through otherwise |
|
| `OptionalAuth` | Extracts user info if token present, passes through otherwise |
|
||||||
| `RequireAdmin` | Allows admin role only |
|
| `RequireAdmin` | Allows admin role only |
|
||||||
| `RequireVerified` | Allows verified_email or admin |
|
| `RequireVerified` | Allows verified_email or admin |
|
||||||
@@ -178,12 +178,15 @@ src/lib/components/
|
|||||||
- `getDayWithOrdinal()` — formats a CalendarDate with ordinal suffix (e.g., "January 15th")
|
- `getDayWithOrdinal()` — formats a CalendarDate with ordinal suffix (e.g., "January 15th")
|
||||||
- Types: `DayHours`, `DayAvailability` — shared type definitions for working/available hours data
|
- Types: `DayHours`, `DayAvailability` — shared type definitions for working/available hours data
|
||||||
|
|
||||||
|
- **`CharCounter` component** (`lib/components/ui/CharCounter.svelte`): Reusable grapheme counter using `Intl.Segmenter` for correct character boundary detection (handles emoji, multi-byte chars). Hidden below 750K graphemes, color-coded above: green (<800K), yellow (800K-950K), red (>950K). Integrated into 6 booking/admin components for notes fields.
|
||||||
|
|
||||||
- **`lib/utils/format.ts`**: Shared formatting utilities for consistent display across the app
|
- **`lib/utils/format.ts`**: Shared formatting utilities for consistent display across the app
|
||||||
- `formatDuration(minutes)` — converts minutes to human-readable string (e.g., 90 → "1h 30m")
|
- `formatDuration(minutes)` — converts minutes to human-readable string (e.g., 90 → "1h 30m")
|
||||||
- `formatDateTime(date)` — formats to "Weekday, Month Day at HH:MM AM/PM"
|
- `formatDateTime(date)` — formats to "Weekday, Month Day at HH:MM AM/PM"
|
||||||
- `formatDate(date)` — formats to "Weekday, Month Day" (no time)
|
- `formatDate(date)` — formats to "Weekday, Month Day" (no time)
|
||||||
- `formatTime(date)` — formats to "HH:MM AM/PM"
|
- `formatTime(date)` — formats to "HH:MM AM/PM"
|
||||||
- `calculateAge(dateOfBirth)` — calculates age in years from DOB string
|
- `calculateAge(dateOfBirth)` — calculates age in years from DOB string
|
||||||
|
- `formatDateISO(date)` — formats a Date object to YYYY-MM-DD string for API calls
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -217,7 +220,8 @@ src/lib/components/
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| POST | `/api/refresh-token` | Refresh JWT (role-change detection) |
|
| POST | `/api/logout` | Revoke current JWT token |
|
||||||
|
| POST | `/api/refresh-token` | Refresh JWT (role-change detection, revokes old JTI) |
|
||||||
| GET | `/api/user/profile` | Get current user profile |
|
| GET | `/api/user/profile` | Get current user profile |
|
||||||
| PUT | `/api/user/profile` | Update profile |
|
| PUT | `/api/user/profile` | Update profile |
|
||||||
| POST | `/api/user/profile-picture` | Upload profile picture (cropper) |
|
| POST | `/api/user/profile-picture` | Upload profile picture (cropper) |
|
||||||
@@ -653,6 +657,8 @@ type EnrichedEditRequest struct {
|
|||||||
|
|
||||||
**Frontend:** `buildLunchProtection()` in `lib/utils/timeSlots.ts` consolidates lunch protection logic that was previously duplicated across BookingFlow, BookingCreateModal, and EditRequestModal. All booking flows now use the shared utility.
|
**Frontend:** `buildLunchProtection()` in `lib/utils/timeSlots.ts` consolidates lunch protection logic that was previously duplicated across BookingFlow, BookingCreateModal, and EditRequestModal. All booking flows now use the shared utility.
|
||||||
|
|
||||||
|
**`shouldApplyLunchProtection()`:** A new helper that skips lunch protection entirely for short working days (5 hours or less). This prevents false lunch-break warnings on half-days or days with abbreviated hours.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Referral Code System
|
### Referral Code System
|
||||||
@@ -803,7 +809,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
|||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
|
|
||||||
**446/449 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments, GetBookingsByCreatedRange endpoint, scheduling exceptional hours validation, and referral code registration.
|
**446/449 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments, GetBookingsByCreatedRange endpoint, scheduling exceptional hours validation, referral code registration, and JWT revocation via JTI logout.
|
||||||
- `handlers/auth` — Authentication (login, register, referral code validation, refresh, verification)
|
- `handlers/auth` — Authentication (login, register, referral code validation, refresh, verification)
|
||||||
- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange
|
- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange
|
||||||
- `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards)
|
- `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards)
|
||||||
@@ -834,6 +840,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
|||||||
- **Buckets**: `crussell` (portfolio), `crussell-profile-pics` (profile pictures)
|
- **Buckets**: `crussell` (portfolio), `crussell-profile-pics` (profile pictures)
|
||||||
- **Image formats**: AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250)
|
- **Image formats**: AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250)
|
||||||
- **Security**: EXIF/GPS metadata stripped on upload via `imaging` library
|
- **Security**: EXIF/GPS metadata stripped on upload via `imaging` library
|
||||||
|
- **extractKey fix**: Portfolio image delete now correctly extracts the full S3 key path (e.g., `portfolio/1234567890.jpg`) from URLs instead of just the filename. This prevents orphaned files in storage
|
||||||
|
|
||||||
### Square Payment Integration
|
### Square Payment Integration
|
||||||
|
|
||||||
@@ -879,6 +886,10 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
|||||||
- **Secret**: `JWT_SECRET_KEY` environment variable (required, checked in `init()`)
|
- **Secret**: `JWT_SECRET_KEY` environment variable (required, checked in `init()`)
|
||||||
- **Refresh**: Auto-refresh via `POST /api/refresh-token`
|
- **Refresh**: Auto-refresh via `POST /api/refresh-token`
|
||||||
- **Role-change detection**: Forces re-login if role changed
|
- **Role-change detection**: Forces re-login if role changed
|
||||||
|
- **JWT ID (JTI)**: Every JWT includes a unique `jti` claim (UUID v4) for revocation tracking
|
||||||
|
- **Revocation**: In-memory `map[string]time.Time` tracks revoked JTIs. A background ticker runs every 5 minutes to purge expired entries
|
||||||
|
- **Logout**: `POST /api/logout` revokes the current JWT token by adding its JTI to the revocation map
|
||||||
|
- **Refresh revocation**: When a new token is issued via refresh, the old JTI is added to the revocation map
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
|
|
||||||
@@ -894,6 +905,12 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
|||||||
|
|
||||||
**IP extraction**: CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
|
**IP extraction**: CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
|
||||||
|
|
||||||
|
**loginInProgress rate limiting:**
|
||||||
|
- `loginInProgress` is a `map[string]time.Time` tracking in-progress login attempts
|
||||||
|
- 30-second staleness check: entries older than 30s are treated as expired
|
||||||
|
- 20-entry size cap: returns 429 "too many login attempts" when full
|
||||||
|
- A ticker goroutine periodically cleans up stuck/expired entries
|
||||||
|
|
||||||
### Security Headers
|
### Security Headers
|
||||||
|
|
||||||
- `X-Content-Type-Options: nosniff`
|
- `X-Content-Type-Options: nosniff`
|
||||||
@@ -910,11 +927,14 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
|||||||
- **Age**: Must be 16+ years
|
- **Age**: Must be 16+ years
|
||||||
- **Services**: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100)
|
- **Services**: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100)
|
||||||
- **Portfolio**: tags/filters (256 char max), filter category validation, image ID pattern security
|
- **Portfolio**: tags/filters (256 char max), filter category validation, image ID pattern security
|
||||||
|
- **Notes**: all `Notes *string` fields across booking structs (13 fields across 4 files) have `validate:"omitempty,max=1000000"` tag
|
||||||
|
|
||||||
### Image Security
|
### Image Security
|
||||||
|
|
||||||
- EXIF/GPS metadata stripped on all uploads via `imaging` library
|
- EXIF/GPS metadata stripped on all uploads via `imaging` library
|
||||||
- Profile pictures stored in separate bucket from portfolio images
|
- Profile pictures stored in separate bucket from portfolio images
|
||||||
|
- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page
|
||||||
|
- **Portfolio image upload limit**: 20MB backend limit via `portfolioBodyLimit` constant applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures. Frontend also enforces 20MB with visual feedback (red borders, error text) and disables upload button for oversized files
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user