Generated 2026-09-26T09:28:24.064Z
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.
TP-005 — TC-05 | Passwords | Reject a password shorter than the minimum length | Expected: Refused, too_shortexport default async (load) => {
const { validatePassword } = await load('src/validation.mjs');
// A password of length 9 (one below the minimum of 10) must be rejected with too_short
return validatePassword('short1234').includes('too_short');
};
TP-006 — TC-06 | Passwords | Reject a password longer than 128 characters | Expected: Refused, too_longexport default async (load) => {
const { validatePassword, PASSWORD_MAX } = await load('src/validation.mjs');
// A password exactly one over the max must be rejected with too_long
const pw = 'a1' + 'x'.repeat(PASSWORD_MAX - 1); // length = PASSWORD_MAX + 1 = 129
return validatePassword(pw).includes('too_long');
};
TP-010 — TC-10 | Passwords | Accept a password that meets every rule | Expected: No errorsexport default async (load) => {
const { validatePassword } = await load('src/validation.mjs');
// harbour-lights-42 is 17 chars but let's use a exactly-10 valid password
// 'securepass1' is exactly 10 chars (s-e-c-u-r-e-p-a-s-s = 10, +1 = 11... let's count)
// 'harbour142' = h-a-r-b-o-u-r-1-4-2 = 10 chars, has digit, not common
const errors = validatePassword('harbour142');
return errors.length === 0;
};
TP-012 — TC-12 | Registration | Username uses only lowercase letters, digits and underscore | Expected: Ada.Lovelace refusedexport default async (load) => {
const { validateUsername } = await load('src/validation.mjs');
// A username with a dot must be rejected
return validateUsername('ada.lo') === false;
};
TP-041 — TC-41 | Access tokens | An expired access token is rejected | Expected: expiredexport default async (load) => {
const { issueAccess, verifyAccess } = await load('src/tokens.mjs');
const NOW = Date.UTC(2026, 8, 1, 9, 0, 0);
const t = issueAccess('ada@example.com', [], { now: NOW });
// Advance 2 hours past expiry (TTL is 3600s = 1h, skew is 30s)
const result = verifyAccess(t, { now: NOW + 2 * 3600 * 1000 });
return result.valid === false && result.reason === 'expired';
};
TP-042 — TC-42 | Refresh tokens | Refreshing returns a new access token and a new refresh token | Expected: Both newexport default async (load) => {
const { createRefreshStore } = await load('src/refresh.mjs');
let t = Date.UTC(2026, 8, 1, 9, 0, 0);
const clock = () => t;
const users = new Map([['ada@example.com', { roles: ['user'] }]]);
const store = createRefreshStore({ users, clock });
const r1 = store.issue('ada@example.com');
const out = store.refresh(r1);
// Both access and refresh must be non-empty strings
return out.ok === true && typeof out.refresh === 'string' && out.refresh.length > 0;
};
TP-057 — TC-57 | Rate limiting | Reject the 101st request in a minute from one IP | Expected: allowed falseexport default async (load) => {
const { createRateLimiter } = await load('src/ratelimit.mjs');
const check = createRateLimiter({ clock: () => 0 });
// Make exactly 100 requests (the documented limit)
for (let i = 0; i < 100; i++) check('1.2.3.4');
// The 101st request must be blocked (count > LIMIT = 100)
return check('1.2.3.4').allowed === false;
};
TP-071 — TC-71 | Audit | The audit log never stores a password | Expected: No password in any entryexport default async (load) => {
const { createAuditLog } = await load('src/audit.mjs');
const log = createAuditLog({ clock: () => 0 });
log.record({ type: 'login.failed', email: 'ada@example.com', password: 'supersecret123' });
return !JSON.stringify(log.entries).includes('supersecret123');
};
| ID | Claim | Test | Mutation | Result |
|---|---|---|---|---|
TP-001 |
TC-01 | Registration | Reject an email address with no @ | Expected: Registration refused, bad_email | test/validation.test.mjsTC-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.mjsTC-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.mjsemail 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.mjsTC-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.mjsTC-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.mjsa 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsusernames 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsverify 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsa 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.mjsTC-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.mjsTC-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.mjsunknown 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjstampering 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsactivity 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsthe 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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsTC-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.mjsa 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.mjsTC-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.mjsTC-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 | — |
| ID | Clause | Verdict | Detail |
|---|---|---|---|
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. |
| ID | Item | Verdict | Detail |
|---|---|---|---|
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. |
| ID | Source | Claim | Verdict | Detail |
|---|---|---|---|---|
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. |