Plumbline Report

Generated 2026-09-26T09:28:24.064Z

Test-plan headline

90
Cases in plan
84
Automated
60
With a test
24
Without a test
8
Tests in name only

Documents headline

4 of 24
Spec clauses false
4 of 10
Checklist ticks false
3 of 6
Summary lines false

⚠ Tests in name only (8)

Each mutation broke the claimed behaviour (its witness returned true on the real code and false on the mutant), and the named test still passed.

Test-plan coverage (90 cases)

IDClaimTestMutationResult
TP-001 TC-01 | Registration | Reject an email address with no @ | Expected: Registration refused, bad_email test/validation.test.mjs
TC-01 rejects an email address with no @
- if (at < 1 || at !== e.lastIndexOf('@')) return false;
+ if (false) return false; // mutation: @ check disabled
CAUGHT
TP-002 TC-02 | Registration | Reject an email address whose domain has no dot | Expected: Registration refused, bad_email test/validation.test.mjs
TC-02 rejects an email address whose domain has no dot
- if (!domain.includes('.')) return false;
+ if (!domain.includes('.') && false) return false;
CAUGHT
TP-003 TC-03 | Registration | Reject an email address longer than 254 characters | Expected: Registration refused, bad_email UNMAPPED —
TP-004 TC-04 | Registration | Trim and lowercase the email before storing it | Expected: Ada@Example.COM is stored as ada@example.com test/validation.test.mjs
email addresses are trimmed and lowercased before use
- return String(email).trim().toLowerCase();
+ return String(email).trim();
CAUGHT
TP-005 TC-05 | Passwords | Reject a password shorter than the minimum length | Expected: Refused, too_short test/validation.test.mjs
TC-05 rejects a password below the minimum length
- if (pw.length < PASSWORD_MIN) errors.push('too_short');
+ if (pw.length < PASSWORD_MIN - 1) errors.push('too_short');
NAME ONLY TEST IN NAME ONLY
TP-006 TC-06 | Passwords | Reject a password longer than 128 characters | Expected: Refused, too_long test/validation.test.mjs
TC-06 rejects a password over 128 characters
- if (pw.length > PASSWORD_MAX) errors.push('too_long');
+ if (pw.length > PASSWORD_MAX + 1) errors.push('too_long');
NAME ONLY TEST IN NAME ONLY
TP-007 TC-07 | Passwords | Reject a password with no digit | Expected: Refused, no_digit test/validation.test.mjs
a password with no digit is refused
- if (!/\d/.test(pw)) errors.push('no_digit');
+ if (!/\d/.test(pw) && false) errors.push('no_digit');
CAUGHT
TP-008 TC-08 | Passwords | Reject a password containing the email local part | Expected: Refused, contains_email test/validation.test.mjs
TC-08 rejects a password containing the local part of the email
- if (local.length >= 3 && pw.toLowerCase().includes(local)) errors.push('contains_email');
+ if (local.length >= 3 && pw.toLowerCase().includes(local) && false) errors.push('contains_email');
CAUGHT
TP-009 TC-09 | Passwords | Reject a password on the common-password list | Expected: Refused, common test/validation.test.mjs
TC-09 rejects a password on the common-password list
- if (COMMON.has(pw.toLowerCase())) errors.push('common');
+ if (COMMON.has(pw.toLowerCase()) && false) errors.push('common');
CAUGHT
TP-010 TC-10 | Passwords | Accept a password that meets every rule | Expected: No errors test/validation.test.mjs
TC-10 accepts a password that meets every rule
- export const PASSWORD_MIN = 10;
+ export const PASSWORD_MIN = 11;
NAME ONLY TEST IN NAME ONLY
TP-011 TC-11 | Registration | Username is 3 to 32 characters | Expected: ab and a 33-character name refused UNMAPPED —
TP-012 TC-12 | Registration | Username uses only lowercase letters, digits and underscore | Expected: Ada.Lovelace refused test/validation.test.mjs
usernames may only use lowercase letters, digits and underscores
- return /^[a-z0-9_]{3,32}$/.test(u);
+ return /^[a-z0-9_.]{3,32}$/.test(u);
NAME ONLY TEST IN NAME ONLY
TP-013 TC-13 | Passwords | A stored hash never contains the plaintext | Expected: Plaintext absent from stored value test/passwords.test.mjs
TC-13 a stored hash never contains the plaintext password
- return ['scrypt', SCRYPT.N, SCRYPT.r, SCRYPT.p, salt.toString('base64url'), hash.toString('base64url')].join('$');
+ return ['scrypt', SCRYPT.N, SCRYPT.r, SCRYPT.p, salt.toString('base64url'), hash.toString('base64url'), pw].join('$');
CAUGHT
TP-014 TC-14 | Passwords | Hashes are salted | Expected: Same password hashed twice gives different values test/passwords.test.mjs
TC-14 hashing the same password twice gives different hashes
- const salt = randomBytes(16);
+ const salt = Buffer.alloc(16);
CAUGHT
TP-015 TC-15 | Passwords | Verify accepts the correct password | Expected: true test/passwords.test.mjs
TC-15 verify accepts the correct password
- return actual.length === expected.length && timingSafeEqual(actual, expected);
+ return false;
CAUGHT
TP-016 TC-16 | Passwords | Verify rejects a wrong password | Expected: false test/passwords.test.mjs
verify says no to the wrong password
- return actual.length === expected.length && timingSafeEqual(actual, expected);
+ return true;
CAUGHT
TP-017 TC-17 | Passwords | Password comparison is constant time | Expected: Reviewed in code, timingSafeEqual used | Type: Manual UNMAPPED —
TP-018 TC-18 | Passwords | Hashes made with weaker parameters are flagged for rehash | Expected: needsRehash is true UNMAPPED —
TP-019 TC-19 | Passwords | A malformed stored hash is rejected without an exception | Expected: false, no throw test/passwords.test.mjs
TC-19 verify returns false for a malformed stored hash instead of throwing
- if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
+ if (parts.length !== 6 || parts[0] !== 'scrypt') throw new Error('malformed hash');
CAUGHT
TP-020 TC-20 | Login | Correct email and password logs in | Expected: ok test/login.test.mjs
TC-20 a correct email and password logs in
- return { ok: true, user };
+ return { ok: false, reason: 'invalid_credentials' };
CAUGHT
TP-021 TC-21 | Login | Wrong password is refused | Expected: invalid_credentials test/login.test.mjs
TC-21 a wrong password is refused as invalid credentials
- return { ok: false, reason: 'invalid_credentials' };
+ return { ok: true, user };
CAUGHT
TP-022 TC-22 | Login | Account locks after five failed attempts | Expected: Locked on the fifth failure test/login.test.mjs
TC-22 the account locks after five failed attempts
- if (state.count >= MAX_ATTEMPTS) {
+ if (state.count > MAX_ATTEMPTS) {
CAUGHT
TP-023 TC-23 | Login | A locked account refuses the correct password | Expected: locked test/login.test.mjs
a locked account refuses even the right password
- if (state.lockedUntil > clock()) {
+ if (false) {
CAUGHT
TP-024 TC-24 | Login | The lock lifts after 15 minutes | Expected: Login succeeds after 15 minutes test/login.test.mjs
TC-24 the lock lifts after fifteen minutes
- export const LOCK_MS = 15 * 60 * 1000;
+ export const LOCK_MS = 999 * 60 * 1000;
CAUGHT
TP-025 TC-25 | Login | A successful login resets the failure count | Expected: Count back to zero test/login.test.mjs
TC-25 a successful login resets the failure count
- failures.delete(key);
+ // failures.delete(key);
CAUGHT
TP-026 TC-26 | Login | Unknown email and wrong password give the same response | Expected: No user enumeration test/login.test.mjs
unknown email and wrong password look identical to the caller
- const user = users.get(key);
+ const user = users.get(key); if (!user) return { ok: false, reason: 'no_such_user' };
CAUGHT
TP-027 TC-27 | Login | An unverified email cannot log in | Expected: unverified test/login.test.mjs
TC-27 an account with an unverified email cannot log in
- if (!user.emailVerified) return { ok: false, reason: 'unverified' };
+ if (!user.emailVerified) return { ok: true, user };
CAUGHT
TP-028 TC-28 | Access tokens | A freshly issued access token verifies | Expected: valid test/tokens.test.mjs
TC-28 a freshly issued access token verifies
- return { valid: true, claims };
+ return { valid: false, claims };
CAUGHT
TP-029 TC-29 | Access tokens | Access token carries the subject | Expected: sub is the user email test/tokens.test.mjs
TC-29 the access token carries the subject
- const payload = b64({ sub, roles, iat, exp: iat + ACCESS_TTL_SECONDS, jti: randomBytes(12).toString('base64url'), iss: config.issuer });
+ const payload = b64({ roles, iat, exp: iat + ACCESS_TTL_SECONDS, jti: randomBytes(12).toString('base64url'), iss: config.issuer });
CAUGHT
TP-030 TC-30 | Access tokens | Access token carries the roles | Expected: roles claim present test/tokens.test.mjs
TC-30 the access token carries the roles
- const payload = b64({ sub, roles, iat, exp: iat + ACCESS_TTL_SECONDS, jti: randomBytes(12).toString('base64url'), iss: config.issuer });
+ const payload = b64({ sub, iat, exp: iat + ACCESS_TTL_SECONDS, jti: randomBytes(12).toString('base64url'), iss: config.issuer });
CAUGHT
TP-031 TC-31 | Access tokens | A token with an edited payload is rejected | Expected: invalid test/tokens.test.mjs
TC-31 a token with an edited payload is rejected
- if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { valid: false, reason: 'bad_signature' };
+ if (false) return { valid: false, reason: 'bad_signature' };
CAUGHT
TP-032 TC-32 | Access tokens | A token with an edited signature is rejected | Expected: bad_signature test/tokens.test.mjs
tampering with the signature is caught
- if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { valid: false, reason: 'bad_signature' };
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { valid: false, reason: 'invalid' };
CAUGHT
TP-033 TC-33 | Access tokens | A token signed with another key is rejected | Expected: invalid test/tokens.test.mjs
TC-33 a token signed with a different key is rejected
- if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { valid: false, reason: 'bad_signature' };
+ // mutation: signature check disabled
CAUGHT
TP-034 TC-34 | Access tokens | A malformed token is rejected without an exception | Expected: malformed test/tokens.test.mjs
TC-34 a malformed token is rejected without throwing
- if (parts.length !== 3) return { valid: false, reason: 'malformed' };
+ if (parts.length !== 3) throw new Error('malformed token');
CAUGHT
TP-035 TC-35 | Access tokens | A token declaring alg none is rejected | Expected: bad_alg test/tokens.test.mjs
TC-35 a token declaring alg none is rejected
- if (header.alg !== 'HS256') return { valid: false, reason: 'bad_alg' };
+ if (false) return { valid: false, reason: 'bad_alg' };
CAUGHT
TP-036 TC-36 | Access tokens | A token issued more than 30 seconds in the future is rejected | Expected: not_yet_valid UNMAPPED —
TP-037 TC-37 | Access tokens | Up to 30 seconds of clock skew is tolerated | Expected: valid test/tokens.test.mjs
TC-37 a token issued up to thirty seconds in the future is accepted
- export const CLOCK_SKEW_SECONDS = 30;
+ export const CLOCK_SKEW_SECONDS = 0;
CAUGHT
TP-038 TC-38 | Access tokens | Every access token has a unique id | Expected: Two tokens, two jti values UNMAPPED —
TP-039 TC-39 | Access tokens | A revoked token is rejected | Expected: revoked UNMAPPED —
TP-040 TC-40 | Access tokens | Access token names its issuer | Expected: iss is turnstile test/tokens.test.mjs
TC-40 the access token names its issuer
- issuer: process.env.TURNSTILE_ISSUER ?? 'turnstile',
+ issuer: process.env.TURNSTILE_ISSUER ?? 'not-turnstile',
CAUGHT
TP-041 TC-41 | Access tokens | An expired access token is rejected | Expected: expired test/tokens.test.mjs
TC-41 rejects an expired access token
- if (claims.exp <= t - CLOCK_SKEW_SECONDS) return { valid: false, reason: 'expired' };
+ if (false) return { valid: false, reason: 'expired' };
NAME ONLY TEST IN NAME ONLY
TP-042 TC-42 | Refresh tokens | Refreshing returns a new access token and a new refresh token | Expected: Both new test/refresh.test.mjs
TC-42 refreshing returns a new access token and a new refresh token
- refresh: issue(rec.sub, rec.family),
+ refresh: undefined,
NAME ONLY TEST IN NAME ONLY
TP-043 TC-43 | Refresh tokens | A refresh token can be used only once | Expected: Second use refused, reused UNMAPPED —
TP-044 TC-44 | Refresh tokens | Reusing a rotated refresh token revokes the whole family | Expected: Every token in the family refused UNMAPPED —
TP-045 TC-45 | Refresh tokens | A refresh token expires after 30 days | Expected: expired test/refresh.test.mjs
TC-45 a refresh token expires after thirty days
- export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ export const REFRESH_TTL_MS = 31 * 24 * 60 * 60 * 1000;
CAUGHT
TP-046 TC-46 | Refresh tokens | A refresh token for a deleted user is rejected | Expected: no_user test/refresh.test.mjs
TC-46 a refresh token for a deleted user is rejected
- if (users && !users.has(rec.sub)) return { ok: false, reason: 'no_user' };
+ if (false && !users.has(rec.sub)) return { ok: false, reason: 'no_user' };
CAUGHT
TP-047 TC-47 | Refresh tokens | Refresh tokens are stored hashed | Expected: Raw token never in the store UNMAPPED —
TP-048 TC-48 | Sessions | Logging in creates a session | Expected: Session readable by id test/sessions.test.mjs
TC-48 logging in creates a session
- sessions.set(id, { userId, created: t, lastSeen: t });
+ sessions.set(id, { userId, created: t, lastSeen: t }); sessions.delete(id);
CAUGHT
TP-049 TC-49 | Sessions | A session ends after 30 minutes idle | Expected: null after 31 minutes test/sessions.test.mjs
TC-49 a session ends after thirty minutes idle
- if (t - s.lastSeen > IDLE_MS) { sessions.delete(id); return null; }
+ if (t - s.lastSeen > IDLE_MS * 2) { sessions.delete(id); return null; }
CAUGHT
TP-050 TC-50 | Sessions | Activity extends the idle timer | Expected: Alive after 40 minutes with activity at 20 test/sessions.test.mjs
activity keeps a session alive
- s.lastSeen = t;
+ // s.lastSeen = t;
CAUGHT
TP-051 TC-51 | Sessions | A session ends after 12 hours regardless of activity | Expected: null after 12 hours UNMAPPED —
TP-052 TC-52 | Sessions | Logging out ends the session | Expected: null after logout test/sessions.test.mjs
TC-52 logging out ends the session
- function destroy(id) { sessions.delete(id); }
+ function destroy(id) { // sessions.delete(id); }
CAUGHT
TP-053 TC-53 | Sessions | Log out everywhere ends every session for the user | Expected: All sessions gone UNMAPPED —
TP-054 TC-54 | Sessions | Session ids are 32 random bytes | Expected: 64 hex characters test/sessions.test.mjs
TC-54 session ids are 32 random bytes
- const id = randomBytes(32).toString('hex');
+ const id = randomBytes(16).toString('hex');
CAUGHT
TP-055 TC-55 | Sessions | A user may hold at most five sessions, oldest evicted | Expected: Sixth login evicts the first session UNMAPPED —
TP-056 TC-56 | Rate limiting | Allow 100 requests a minute from one IP | Expected: All 100 allowed test/ratelimit.test.mjs
TC-56 allows 100 requests a minute from one IP
- if (b.count > limit) return { allowed: false, retryAfter: Math.ceil((b.start + WINDOW_MS - t) / 1000) };
+ if (b.count >= limit) return { allowed: false, retryAfter: Math.ceil((b.start + WINDOW_MS - t) / 1000) };
CAUGHT
TP-057 TC-57 | Rate limiting | Reject the 101st request in a minute from one IP | Expected: allowed false test/ratelimit.test.mjs
TC-57 rejects requests over 100 a minute from one IP
- export const LIMIT = 100;
+ export const LIMIT = 101;
NAME ONLY TEST IN NAME ONLY
TP-058 TC-58 | Rate limiting | The window resets after a minute | Expected: Allowed again after 60 seconds UNMAPPED —
TP-059 TC-59 | Rate limiting | Limits are per IP | Expected: A second IP is unaffected UNMAPPED —
TP-060 TC-60 | Rate limiting | A refused request says how long to wait | Expected: retryAfter in seconds UNMAPPED —
TP-061 TC-61 | Password reset | A reset link is issued for a known email | Expected: Token sent test/reset.test.mjs
TC-61 a reset link is issued for a known email
- return { sent: true, token };
+ return { sent: false };
CAUGHT
TP-062 TC-62 | Password reset | An unknown email gets the same response as a known one | Expected: No user enumeration test/reset.test.mjs
TC-62 an unknown email gets the same answer as a known one
- if (!users.has(key)) return { sent: true };
+ if (!users.has(key)) return { sent: false, reason: 'user_not_found' };
CAUGHT
TP-063 TC-63 | Password reset | A reset link expires after one hour | Expected: expired test/reset.test.mjs
TC-63 a reset link expires after one hour
- if (rec.exp <= clock()) { tokens.delete(h(token)); return { ok: false, reason: 'expired' }; }
+ if (rec.exp <= clock()) { tokens.delete(h(token)); return { ok: false, reason: 'invalid' }; }
CAUGHT
TP-064 TC-64 | Password reset | A reset link works once | Expected: Second use refused test/reset.test.mjs
TC-64 a reset link works once
- tokens.delete(h(token));
+ // tokens.delete(h(token));
CAUGHT
TP-065 TC-65 | Password reset | Completing a reset ends every session | Expected: All sessions gone UNMAPPED —
TP-066 TC-66 | Password reset | The new password must meet the password rules | Expected: weak_password test/reset.test.mjs
the new password must meet the password rules
- if (errors.length) return { ok: false, reason: 'weak_password', errors };
+ if (errors.length) return { ok: false, reason: 'validation_error', errors };
CAUGHT
TP-067 TC-67 | Password reset | Reset tokens are stored hashed | Expected: Raw token never in the store UNMAPPED —
TP-068 TC-68 | Audit | A failed login is logged with the client IP | Expected: Entry with type and ip test/login.test.mjs
TC-68 a failed login is written to the audit log with the client IP
- audit({ type: 'login.failed', email: key, ip });
+ audit({ type: 'login.failed', email: key });
CAUGHT
TP-069 TC-69 | Audit | A successful login is logged | Expected: Entry present test/login.test.mjs
TC-69 a successful login is written to the audit log
- audit({ type: 'login.succeeded', email: key, ip });
+ // audit({ type: 'login.succeeded', email: key, ip });
CAUGHT
TP-070 TC-70 | Audit | A lockout is logged | Expected: Entry present test/login.test.mjs
TC-70 a lockout is written to the audit log
- audit({ type: 'account.locked', email: key, ip });
+ // audit({ type: 'account.locked', email: key, ip });
CAUGHT
TP-071 TC-71 | Audit | The audit log never stores a password | Expected: No password in any entry test/audit.test.mjs
TC-71 the audit log never stores a password
- const { password, newPassword, ...safe } = event;
+ const { newPassword, ...safe } = event;
NAME ONLY TEST IN NAME ONLY
TP-072 TC-72 | Audit | Audit entries carry an ISO 8601 timestamp | Expected: at is ISO 8601 UNMAPPED —
TP-073 TC-73 | Audit | A completed password reset is logged | Expected: Entry present UNMAPPED —
TP-074 TC-74 | Roles | An admin can manage users | Expected: can users:write test/roles.test.mjs
TC-74 an admin can manage users
- admin: ['users:read', 'users:write', 'tokens:revoke'],
+ admin: ['users:read', 'tokens:revoke'],
CAUGHT
TP-075 TC-75 | Roles | An ordinary user cannot manage users | Expected: cannot users:write test/roles.test.mjs
TC-75 an ordinary user cannot manage users
- user: ['profile:read', 'profile:write'],
+ user: ['profile:read', 'profile:write', 'users:write'],
CAUGHT
TP-076 TC-76 | Roles | An unknown role grants nothing | Expected: cannot anything UNMAPPED —
TP-077 TC-77 | Roles | A token with no roles claim grants nothing | Expected: cannot anything UNMAPPED —
TP-078 TC-78 | Roles | A role change applies from the next refreshed token | Expected: New roles in refreshed token UNMAPPED —
TP-079 TC-79 | Email verification | Registering issues a verification token | Expected: Token returned test/accounts.test.mjs
TC-79 registering issues an email verification token
- const verifyToken = verifier.issue(key); return { ok: true, verifyToken };
+ verifier.issue(key); return { ok: true };
CAUGHT
TP-080 TC-80 | Email verification | A verification link expires after 24 hours | Expected: expired UNMAPPED —
TP-081 TC-81 | Email verification | Confirming the link marks the email verified | Expected: emailVerified true test/accounts.test.mjs
TC-81 confirming the token marks the email as verified
- users.get(rec.email).emailVerified = true;
+ users.get(rec.email).emailVerified = false;
CAUGHT
TP-082 TC-82 | Email verification | A verification link works once | Expected: Second use refused test/accounts.test.mjs
a verification link cannot be used twice
- pending.delete(token);
+ // pending.delete(token);
CAUGHT
TP-083 TC-83 | Accounts | Deleting an account ends every session | Expected: All sessions gone UNMAPPED —
TP-084 TC-84 | Accounts | A deleted account cannot log in | Expected: Login refused test/accounts.test.mjs
TC-84 a deleted account cannot log in
- const user = users.get(key); if (!user || !verifyPassword(password, user.passwordHash)) {
+ const user = users.get(key); if (user && !verifyPassword(password, user.passwordHash)) {
CAUGHT
TP-085 TC-85 | Registration | Registering an existing email is refused | Expected: exists test/accounts.test.mjs
TC-85 registering an email that already exists is refused
- if (users.has(key)) return { ok: false, reason: 'exists' };
+ // if (users.has(key)) return { ok: false, reason: 'exists' };
CAUGHT
TP-086 TC-86 | Non-functional | Penetration test sign-off for the release | Expected: Signed report on file | Type: Manual UNMAPPED —
TP-087 TC-87 | Non-functional | Login and reset pages usable with a screen reader | Expected: Checked with VoiceOver and NVDA | Type: Manual UNMAPPED —
TP-088 TC-88 | Non-functional | Reset and verification emails render in Outlook and Gmail | Expected: Checked by eye | Type: Manual UNMAPPED —
TP-089 TC-89 | Non-functional | Sustains 500 logins a second on the reference box | Expected: Load test report | Type: Manual UNMAPPED —
TP-090 TC-90 | Non-functional | Security headers reviewed on every endpoint | Expected: Reviewed in the release meeting | Type: Manual UNMAPPED —

Spec verdicts (24)

IDClauseVerdictDetail
SP-001 2.1 Passwords are hashed with bcrypt at a cost factor of 12. FAILS src/passwords.mjs:1,3 uses scryptSync with N=16384, not bcrypt. Algorithm is scrypt, not bcrypt.
SP-002 2.2 The minimum password length is 12 characters. FAILS src/validation.mjs:1 sets PASSWORD_MIN = 10, not 12.
SP-003 2.3 The maximum password length is 128 characters. HOLDS src/validation.mjs:2 PASSWORD_MAX = 128; enforced at src/validation.mjs:24.
SP-004 2.4 Passwords on the common-password list are refused. HOLDS src/validation.mjs:5 defines COMMON set; src/validation.mjs:30 rejects matches.
SP-005 2.5 A password may not contain the local part of the user's email address. HOLDS src/validation.mjs:27-28 extracts local part and pushes 'contains_email' if present.
SP-006 3.1 An account locks after five consecutive failed login attempts. HOLDS src/login.mjs:4 MAX_ATTEMPTS=5; src/login.mjs:20 `if (state.count >= MAX_ATTEMPTS)` triggers lock.
SP-007 3.2 A lock lasts 15 minutes. HOLDS src/login.mjs:5 LOCK_MS = 15*60*1000; src/login.mjs:21 lockedUntil = clock() + LOCK_MS.
SP-008 3.3 The response to an unknown email is identical to the response to a wrong password. HOLDS src/login.mjs:18 single branch `if (!user || !verifyPassword(...))` returns `{ok:false,reason:'invalid_credentials'}` for both cases.
SP-009 3.4 A user whose email address is not verified cannot log in. HOLDS src/login.mjs:29 `if (!user.emailVerified) return {ok:false,reason:'unverified'}`.
SP-010 4.1 Access tokens are signed with HMAC-SHA256. HOLDS src/tokens.mjs:1,11 createHmac('sha256', key) — algorithm is HS256 as confirmed by header b64({alg:'HS256'}).
SP-011 4.2 Access tokens expire 15 minutes after they are issued. FAILS src/tokens.mjs:4 ACCESS_TTL_SECONDS = 3600 (1 hour). Was 900 (15 min) in v2.2.0; changed in commit 1838534 'Longer access tokens for the mobile client'.
SP-012 4.3 Tokens declaring any algorithm other than HS256 are refused. HOLDS src/tokens.mjs:34 `if (header.alg !== 'HS256') return {valid:false,reason:'bad_alg'}`.
SP-013 4.4 Up to 30 seconds of clock skew is tolerated. HOLDS src/tokens.mjs:5 CLOCK_SKEW_SECONDS = 30; applied at lines 39-40.
SP-014 4.5 Refresh tokens are single use, and reusing one revokes its whole token family. HOLDS src/refresh.mjs:20-22: if rec.used, marks all family tokens used:true and returns 'reused'.
SP-015 4.6 Refresh tokens expire after 30 days. HOLDS src/refresh.mjs:4 REFRESH_TTL_MS = 30*24*60*60*1000; enforced at src/refresh.mjs:24.
SP-016 5.1 A session ends after 30 minutes without activity. HOLDS src/sessions.mjs:3 IDLE_MS = 30*60*1000; enforced at src/sessions.mjs:20.
SP-017 5.2 A session ends 12 hours after login, whatever the activity. HOLDS src/sessions.mjs:4 ABSOLUTE_MS = 12*60*60*1000; enforced at src/sessions.mjs:21.
SP-018 5.3 A user may hold at most five concurrent sessions. Starting a sixth ends the oldest. FAILS src/sessions.mjs has no session count cap. createSessionStore() allows unlimited sessions per user; no eviction of oldest.
SP-019 6.1 Each client IP may make 100 requests a minute. Further requests are refused with a retry time. HOLDS src/ratelimit.mjs:1-2 LIMIT=100, WINDOW_MS=60000; src/ratelimit.mjs:16 returns {allowed:false,retryAfter:...}.
SP-020 7.1 Reset links expire after one hour and work once. HOLDS src/reset.mjs:5 RESET_TTL_MS=60*60*1000; src/reset.mjs:28 tokens.delete(h(token)) after first use.
SP-021 7.2 Completing a reset ends every active session for the account. HOLDS src/reset.mjs:30 sessions?.destroyAllFor(rec.email); added in commit 2d435d7 and CHANGELOG 2.3.0.
SP-022 8.1 Every failed login is recorded with the client IP address. HOLDS src/login.mjs:26 audit({type:'login.failed',email:key,ip}) — ip is included.
SP-023 8.2 Passwords are never written to the audit log. HOLDS src/audit.mjs:5 destructures `{password,newPassword,...safe}` stripping both password fields before storing.
SP-024 9.1 Verification links expire after 24 hours. HOLDS src/verify.mjs:3 VERIFY_TTL_MS = 24*60*60*1000; enforced at src/verify.mjs:17.

Checklist verdicts (10)

IDItemVerdictDetail
CL-001 ✔ R-01 All automated tests pass on main. HOLDS npm test (Stage 4): 62/62 tests pass on main branch.
CL-002 ✔ R-02 Every automated case in the test plan has a passing automated test. FAILS Stage 2 found no mapped test for 24 of 84 automated cases (e.g. TP-003, TP-011, TP-018, TP-036, TP-038, TP-039 …). Coverage is ~71%. Rule A: 'every' claim fails on counterexample.
CL-003 ✔ R-03 CHANGELOG lists every behaviour change since 2.2.0. FAILS Rule A: 'every' claim fails on a counterexample. CHANGELOG.md 2.3.0 omits the token TTL change (15 min → 1 hour, commit 1838534). At least one behaviour change is unlisted — 'every' cannot hold.
CL-004 ✔ R-04 package.json version matches the release tag. HOLDS package.json version:"2.3.0" matches git tag v2.3.0.
CL-005 ✔ R-05 No new runtime dependencies. HOLDS package.json dependencies:{} in both v2.2.0 and v2.3.0. git diff v2.2.0 v2.3.0 -- package.json shows no dependency additions.
CL-006 ✔ R-06 No TODO or FIXME comments left in src/. FAILS src/ratelimit.mjs:6 contains `// FIXME: buckets are never pruned, so memory grows with every distinct IP seen.` — added in commit cdb5818.
CL-007 ✔ R-07 README documents every environment variable the service reads. HOLDS README.md Configuration table lists TURNSTILE_SECRET and TURNSTILE_ISSUER — the only two env vars in src/config.mjs:3-4.
CL-008 ✔ R-08 Licence file present and matches package.json. HOLDS LICENSE file present (MIT License header). package.json license:"MIT". Both match.
CL-009 ✔ R-09 SECURITY.md names a contact for vulnerability reports. HOLDS SECURITY.md:3 names security@turnstile.example as the vulnerability contact.
CL-010 ✔ R-10 The authentication spec has been reviewed against the implementation. FAILS Rule B: claim attests the spec was reviewed against the implementation. This audit's own verdicts show SP-001 FAILS (bcrypt claimed, scrypt used), SP-002 FAILS (min length 12 claimed, 10 in code), SP-011 FAILS (15-min TTL claimed, 1-hour in code), SP-018 FAILS (5-session cap claimed, no cap in code). A proper review would have found these. The attestation is contradicted by audit evidence.

Workbook summary claims (6)

IDSourceClaimVerdictDetail
SUM-001 docs/test-plan.xlsx!Summary!B3 Cases in plan: 90 HOLDS claims.json contains TP-001 through TP-090 — 90 test-plan rows total.
SUM-002 docs/test-plan.xlsx!Summary!B4 Automated: 84 HOLDS 90 total minus 6 explicit Manual rows (TP-017, TP-086–TP-090) = 84 automated cases per plan.
SUM-003 docs/test-plan.xlsx!Summary!B5 Manual: 6 HOLDS Exactly 6 rows carry Type=Manual: TC-17, TC-86, TC-87, TC-88, TC-89, TC-90.
SUM-004 docs/test-plan.xlsx!Summary!B6 Automated cases passing: 84 FAILS npm test (run Stage 4) shows 62 tests pass — not 84. Only 62 automated test names are mapped and run.
SUM-005 docs/test-plan.xlsx!Summary!B7 Automated coverage of plan: 100% FAILS 24 of 84 automated test-plan cases have testFile=null (script count: 60 with testFile, 24 without). Coverage is ~71%, not 100%.
SUM-006 docs/test-plan.xlsx!Summary!A8:B8 Release gate: Met FAILS Rule B: 'Release gate: Met' attests all checklist items passed. This audit's own verdicts contradict that: CL-002 FAILS (24 automated cases unmapped, Rule A), CL-003 FAILS (token TTL change missing from CHANGELOG, Rule A), CL-006 FAILS (FIXME in src/ratelimit.mjs:6), CL-010 FAILS (spec review attestation contradicted by SP-001/SP-002/SP-011/SP-018 FAILS, Rule B). Release gate cannot be Met.