test: add coverage tests across backend + fix mock for PENDING checkout support
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
New test files cover previously untested paths across DAV, validators, S3, Square, mw, bookings, user, and payments packages. Includes mock fix: HoldCheckouts flag on MockClient allows tests to pause auto-complete goroutine for testing PENDING checkout states. Coverage: 50.4% → 65.0% (+14.6pp)
This commit is contained in:
@@ -18,7 +18,12 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/testtx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -193,6 +198,16 @@ func TestVerifyToken_MissingJTI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsJTIRevoked_QueryError verifies that IsJTIRevoked returns false when
|
||||
// the context is cancelled (causing the QueryRow to fail).
|
||||
func TestIsJTIRevoked_QueryError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if IsJTIRevoked(ctx, "test-jti") {
|
||||
t.Error("expected false on cancelled context query error")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RevokeJTI / IsJTIRevoked Tests
|
||||
// =============================================================================
|
||||
@@ -282,3 +297,321 @@ func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
|
||||
t.Error("expected valid (future expiry) JTI to remain after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// generateRefreshTokenString Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestGenerateRefreshTokenString_Format verifies that generateRefreshTokenString
|
||||
// returns a 64-character hex string.
|
||||
func TestGenerateRefreshTokenString_Format(t *testing.T) {
|
||||
token, err := generateRefreshTokenString()
|
||||
if err != nil {
|
||||
t.Fatalf("generateRefreshTokenString() failed: %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("expected 64-character hex string, got length %d: %s", len(token), token)
|
||||
}
|
||||
// Verify all characters are valid lowercase hex
|
||||
for _, c := range token {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Errorf("non-hex character %c in token %s", c, token)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRefreshTokenString_Unique generates 100 refresh token strings
|
||||
// and verifies all are unique.
|
||||
func TestGenerateRefreshTokenString_Unique(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
token, err := generateRefreshTokenString()
|
||||
if err != nil {
|
||||
t.Fatalf("generateRefreshTokenString() failed at iteration %d: %v", i, err)
|
||||
}
|
||||
if seen[token] {
|
||||
t.Errorf("duplicate refresh token at iteration %d: %s", i, token)
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
if len(seen) != 100 {
|
||||
t.Errorf("expected 100 unique tokens, got %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GenerateRefreshToken Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestGenerateRefreshToken_Success calls GenerateRefreshToken and verifies a
|
||||
// row was inserted in the refresh_tokens table with the correct user_id and role.
|
||||
func TestGenerateRefreshToken_Success(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
|
||||
// Verify row was inserted in refresh_tokens
|
||||
var dbUserID string
|
||||
var dbRole string
|
||||
err = tx.QueryRow(ctx,
|
||||
`SELECT user_id, role FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
|
||||
token).Scan(&dbUserID, &dbRole)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refresh_tokens: %v", err)
|
||||
}
|
||||
if dbUserID != userID {
|
||||
t.Errorf("expected user_id %q, got %q", userID, dbUserID)
|
||||
}
|
||||
if dbRole != "verified_email" {
|
||||
t.Errorf("expected role 'verified_email', got %q", dbRole)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VerifyRefreshToken Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestVerifyRefreshToken_Success generates a refresh token, verifies it, and
|
||||
// asserts the returned userID and role match. Then confirms the token was
|
||||
// consumed (second call fails with "invalid or expired").
|
||||
func TestVerifyRefreshToken_Success(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// First verify should succeed
|
||||
retUserID, retRole, err := VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRefreshToken() failed: %v", err)
|
||||
}
|
||||
if retUserID != userID {
|
||||
t.Errorf("expected user_id %q, got %q", userID, retUserID)
|
||||
}
|
||||
if retRole != "verified_email" {
|
||||
t.Errorf("expected role 'verified_email', got %q", retRole)
|
||||
}
|
||||
|
||||
// Second verify with same token must fail (rotation — token consumed)
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for consumed token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_Rotation verifies the token rotation mechanism:
|
||||
// first call succeeds, second call with the same token fails.
|
||||
func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// First call should succeed
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// Second call with the same token must fail
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rotated token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
|
||||
// token string and expects it to fail with "invalid or expired".
|
||||
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
|
||||
ctx, _ := testtx.SetupTestTx(t)
|
||||
|
||||
_, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_EmptyJTI creates a token with an empty "jti" claim and
|
||||
// verifies that VerifyToken returns an error containing "invalid jti claim".
|
||||
func TestVerifyToken_EmptyJTI(t *testing.T) {
|
||||
_, tokenStr, err := TokenAuth.Encode(map[string]any{
|
||||
"user_id": "user-test",
|
||||
"role": "verified_email",
|
||||
"jti": "",
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, _, _, err = VerifyToken(tokenStr, context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid jti claim")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Nil db.Conn Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestRevokeJTI_NilConn verifies that RevokeJTI does not panic when db.Conn is nil.
|
||||
func TestRevokeJTI_NilConn(t *testing.T) {
|
||||
savedConn := db.Conn
|
||||
db.Conn = nil
|
||||
t.Cleanup(func() { db.Conn = savedConn })
|
||||
|
||||
// Should not panic when db.Conn is nil
|
||||
RevokeJTI(context.Background(), "test-jti", time.Now())
|
||||
}
|
||||
|
||||
// TestIsJTIRevoked_NilConn verifies that IsJTIRevoked returns false when db.Conn is nil.
|
||||
func TestIsJTIRevoked_NilConn(t *testing.T) {
|
||||
savedConn := db.Conn
|
||||
db.Conn = nil
|
||||
t.Cleanup(func() { db.Conn = savedConn })
|
||||
|
||||
if IsJTIRevoked(context.Background(), "test-jti") {
|
||||
t.Error("expected IsJTIRevoked to return false when db.Conn is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupRevokedJTIs_NilConn verifies CleanupRevokedJTIs returns (0, nil) when db.Conn is nil.
|
||||
func TestCleanupRevokedJTIs_NilConn(t *testing.T) {
|
||||
savedConn := db.Conn
|
||||
db.Conn = nil
|
||||
t.Cleanup(func() { db.Conn = savedConn })
|
||||
|
||||
n, err := CleanupRevokedJTIs(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 rows, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: crypto error paths in generateJTI() and generateRefreshTokenString()
|
||||
// are unreachable on Go 1.26+ because crypto/rand.Read() calls runtime.fatal()
|
||||
// instead of returning an error (see https://go.dev/issue/66821). The error
|
||||
// return exists for backward compatibility with older Go versions.
|
||||
|
||||
// =============================================================================
|
||||
// VerifyToken Edge Case Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestVerifyToken_MissingUserID creates a token without user_id claim and verifies
|
||||
// VerifyToken returns an error containing "invalid user_id claim".
|
||||
func TestVerifyToken_MissingUserID(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
||||
"role": "admin",
|
||||
"jti": "test-jti-001",
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode token: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing user_id claim, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid user_id claim") {
|
||||
t.Errorf("expected 'invalid user_id claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_WrongUserIDType creates a token with user_id as an integer (wrong type)
|
||||
// and verifies VerifyToken returns an error containing "invalid user_id claim".
|
||||
func TestVerifyToken_WrongUserIDType(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
||||
"user_id": 12345,
|
||||
"role": "admin",
|
||||
"jti": "test-jti-002",
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode token: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong user_id type, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid user_id claim") {
|
||||
t.Errorf("expected 'invalid user_id claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_MissingRole creates a token without role claim and verifies
|
||||
// VerifyToken returns an error containing "invalid role claim".
|
||||
func TestVerifyToken_MissingRole(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
||||
"user_id": "user-001",
|
||||
"jti": "test-jti-003",
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode token: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing role claim, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid role claim") {
|
||||
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_WrongRoleType creates a token with role as an integer (wrong type)
|
||||
// and verifies VerifyToken returns an error containing "invalid role claim".
|
||||
func TestVerifyToken_WrongRoleType(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
||||
"user_id": "user-001",
|
||||
"role": 12345,
|
||||
"jti": "test-jti-004",
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode token: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong role type, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid role claim") {
|
||||
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user