feat(F-088): completed feature

This commit is contained in:
chattie
2026-08-20 21:29:14 +02:00
parent 63fdc775d9
commit 699c01ade5
40 changed files with 996 additions and 381 deletions

View File

@@ -2,15 +2,51 @@
"feature_id": "F-083",
"verdict": "APPROVED",
"trace": [
{ "acceptance": "POST /auth/password-reset/request always returns 200", "result": "PASS", "evidence": "Route handler sends { ok: true } after useCase.execute regardless of branch; verified by use case tests." },
{ "acceptance": "Signed single-use token with TTL >=30 min <=24h", "result": "PASS", "evidence": "RESET_TOKEN_TTL_MS = 60*60*1000 (1h); randomBytes(32).toString('hex'); isTokenUsable enforces usedAt+expires." },
{ "acceptance": "Email sent with /cuenta/restablecer?token=...", "result": "PASS", "evidence": "LoggingPasswordResetMailer logs resetUrl; buildResetUrl returns /cuenta/restablecer?token=... ." },
{ "acceptance": "POST /auth/password-reset/confirm rejects invalid/expired/used", "result": "PASS", "evidence": "ConfirmPasswordReset throws InvalidResetTokenError; route maps to AppError(400). Tests cover not-found, expired, used paths." },
{ "acceptance": "New password works for login", "result": "PASS", "evidence": "users.updateUser(userId, { passwordHash: await hasher.hash(newPassword) }); same hasher used by RegisterUser/Login." },
{ "acceptance": "Admin /customers row has Send reset link action", "result": "PASS", "evidence": "customersApi.sendPasswordResetLink + 🔑 button in customers row; toast on result." },
{ "acceptance": "Rate limit per IP and per email", "result": "PASS", "evidence": "RequestPasswordReset.execute calls rateLimiter.allow(ipKey) and rateLimiter.allow(emailKey)." },
{ "acceptance": "Audit log entry for each request and confirm", "result": "PASS", "evidence": "Both use cases call deps.audit?.({ action: 'password_reset.requested' | 'password_reset.confirmed', userId, email, ip }); build-app wires it to AuditLogger." },
{ "acceptance": "verify.sh is green", "result": "PASS", "evidence": "tsc exit 0; eslint exit 0; vitest password-reset 9/9." }
{
"acceptance": "POST /auth/password-reset/request always returns 200",
"result": "PASS",
"evidence": "Route handler sends { ok: true } after useCase.execute regardless of branch; verified by use case tests."
},
{
"acceptance": "Signed single-use token with TTL >=30 min <=24h",
"result": "PASS",
"evidence": "RESET_TOKEN_TTL_MS = 60*60*1000 (1h); randomBytes(32).toString('hex'); isTokenUsable enforces usedAt+expires."
},
{
"acceptance": "Email sent with /cuenta/restablecer?token=...",
"result": "PASS",
"evidence": "LoggingPasswordResetMailer logs resetUrl; buildResetUrl returns /cuenta/restablecer?token=... ."
},
{
"acceptance": "POST /auth/password-reset/confirm rejects invalid/expired/used",
"result": "PASS",
"evidence": "ConfirmPasswordReset throws InvalidResetTokenError; route maps to AppError(400). Tests cover not-found, expired, used paths."
},
{
"acceptance": "New password works for login",
"result": "PASS",
"evidence": "users.updateUser(userId, { passwordHash: await hasher.hash(newPassword) }); same hasher used by RegisterUser/Login."
},
{
"acceptance": "Admin /customers row has Send reset link action",
"result": "PASS",
"evidence": "customersApi.sendPasswordResetLink + 🔑 button in customers row; toast on result."
},
{
"acceptance": "Rate limit per IP and per email",
"result": "PASS",
"evidence": "RequestPasswordReset.execute calls rateLimiter.allow(ipKey) and rateLimiter.allow(emailKey)."
},
{
"acceptance": "Audit log entry for each request and confirm",
"result": "PASS",
"evidence": "Both use cases call deps.audit?.({ action: 'password_reset.requested' | 'password_reset.confirmed', userId, email, ip }); build-app wires it to AuditLogger."
},
{
"acceptance": "verify.sh is green",
"result": "PASS",
"evidence": "tsc exit 0; eslint exit 0; vitest password-reset 9/9."
}
],
"regression_checks": [
"Login still works (existing test suite)",
@@ -19,5 +55,6 @@
],
"verdict_reason": "All acceptance criteria trace to PASS. Reset flow is end-to-end functional.",
"reviewer": "qa",
"reviewed_at": "2026-08-20T04:10:00Z"
}
"reviewed_at": "2026-08-20T04:10:00Z",
"agent": "qa"
}

View File

@@ -2,19 +2,54 @@
"feature_id": "F-083",
"verdict": "APPROVED",
"checks": [
{ "name": "Endpoints match spec", "result": "PASS", "notes": "POST /auth/password-reset/request and /confirm both registered; request always 200; confirm 200/400." },
{ "name": "Token storage is hash-only", "result": "PASS", "notes": "token_hash column UNIQUE; raw token only leaves the system via email link." },
{ "name": "Single-use enforcement", "result": "PASS", "notes": "isTokenUsable checks usedAt IS NULL + expires_at > now; markUsed is atomic UPDATE." },
{ "name": "Rate limit per IP and per email", "result": "PASS", "notes": "RequestPasswordReset.execute checks both buckets before any DB work; silently drops when over." },
{ "name": "User enumeration prevented", "result": "PASS", "notes": "No-user path returns silently after ~80ms delay; never reveals whether the email exists." },
{ "name": "Admin Send reset link action", "result": "PASS", "notes": "Per-row 🔑 button + toast in customers/page.tsx." },
{ "name": "Storefront reset page", "result": "PASS", "notes": "/cuenta/restablecer reads ?token= from URL; submits via storefront proxy." },
{ "name": "Audit log entries", "result": "PASS", "notes": "request and confirm both call auditLogger.log via the build-app hook." }
{
"name": "Endpoints match spec",
"result": "PASS",
"notes": "POST /auth/password-reset/request and /confirm both registered; request always 200; confirm 200/400."
},
{
"name": "Token storage is hash-only",
"result": "PASS",
"notes": "token_hash column UNIQUE; raw token only leaves the system via email link."
},
{
"name": "Single-use enforcement",
"result": "PASS",
"notes": "isTokenUsable checks usedAt IS NULL + expires_at > now; markUsed is atomic UPDATE."
},
{
"name": "Rate limit per IP and per email",
"result": "PASS",
"notes": "RequestPasswordReset.execute checks both buckets before any DB work; silently drops when over."
},
{
"name": "User enumeration prevented",
"result": "PASS",
"notes": "No-user path returns silently after ~80ms delay; never reveals whether the email exists."
},
{
"name": "Admin Send reset link action",
"result": "PASS",
"notes": "Per-row 🔑 button + toast in customers/page.tsx."
},
{
"name": "Storefront reset page",
"result": "PASS",
"notes": "/cuenta/restablecer reads ?token= from URL; submits via storefront proxy."
},
{
"name": "Audit log entries",
"result": "PASS",
"notes": "request and confirm both call auditLogger.log via the build-app hook."
}
],
"lint": { "errors_introduced": 0 },
"lint": {
"errors_introduced": 0
},
"typecheck": "PASS",
"tests": "9/9 passed (password-reset.test.ts)",
"verdict_reason": "Reset flow implemented end to end with the expected security properties (single-use, hashed tokens, rate limit, no enumeration, audit log).",
"reviewer": "reviewer",
"reviewed_at": "2026-08-20T04:09:00Z"
}
"reviewed_at": "2026-08-20T04:09:00Z",
"agent": "reviewer"
}

View File

@@ -2,18 +2,47 @@
"feature_id": "F-083",
"verdict": "APPROVED",
"checks": [
{ "name": "No SQL injection", "result": "PASS", "notes": "All queries use $1..$N placeholders." },
{ "name": "No token leakage in logs", "result": "PASS", "notes": "Only hash is logged in audit; raw token only in email body and URL." },
{ "name": "Brute-force resistance", "result": "PASS", "notes": "Token is 32 bytes hex (256 bits); rate limit per IP and email; 1h TTL." },
{ "name": "Timing equalisation for non-existing email", "result": "PASS", "notes": "noUserDelay adds ~80ms before returning." },
{ "name": "Replay protection", "result": "PASS", "notes": "Token marked used_at = now() in same logical operation; second confirm returns 400." },
{ "name": "Auth/RBAC unchanged", "result": "PASS", "notes": "Both endpoints public; admin Send reset link goes through existing combined auth." },
{ "name": "Dependencies", "result": "PASS", "notes": "No new packages." }
{
"name": "No SQL injection",
"result": "PASS",
"notes": "All queries use $1..$N placeholders."
},
{
"name": "No token leakage in logs",
"result": "PASS",
"notes": "Only hash is logged in audit; raw token only in email body and URL."
},
{
"name": "Brute-force resistance",
"result": "PASS",
"notes": "Token is 32 bytes hex (256 bits); rate limit per IP and email; 1h TTL."
},
{
"name": "Timing equalisation for non-existing email",
"result": "PASS",
"notes": "noUserDelay adds ~80ms before returning."
},
{
"name": "Replay protection",
"result": "PASS",
"notes": "Token marked used_at = now() in same logical operation; second confirm returns 400."
},
{
"name": "Auth/RBAC unchanged",
"result": "PASS",
"notes": "Both endpoints public; admin Send reset link goes through existing combined auth."
},
{
"name": "Dependencies",
"result": "PASS",
"notes": "No new packages."
}
],
"sast": "PASS",
"dependency_review": "PASS",
"secret_scan": "PASS",
"verdict_reason": "Reset flow meets the standard security baseline for forgotten-password flows.",
"reviewer": "security",
"reviewed_at": "2026-08-20T04:09:30Z"
}
"reviewed_at": "2026-08-20T04:09:30Z",
"agent": "security"
}