Skip to content

Commit f63107e

Browse files
committed
test(auth): cover OTP exchange, lockout, rotation, and token verification
Adds behavioral tests for exchangeTempAuthCode/verifyAuthToken covering the happy-path exchange + code rotation + replay rejection, the 5-attempt lockout, TTL expiry, token verification, and the underlying crypto-token primitives (timingSafeEqual, randomDigits, randomToken). Removes plans/007-auth-otp-tests.md now that the plan is implemented and marks it DONE in plans/README.md.
1 parent e656c81 commit f63107e

3 files changed

Lines changed: 92 additions & 241 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
2+
import { createSharedState } from 'devframe/utils/shared-state'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import {
5+
exchangeTempAuthCode,
6+
getTempAuthCode,
7+
refreshTempAuthCode,
8+
verifyAuthToken,
9+
} from '../state'
10+
11+
function makeStorage() {
12+
return createSharedState({ initialValue: { trusted: {} as Record<string, any> } }) as any
13+
}
14+
function makeSession() {
15+
return { meta: {} } as any
16+
}
17+
const INFO = { ua: 'test-ua', origin: 'http://localhost' }
18+
19+
beforeEach(() => {
20+
refreshTempAuthCode()
21+
})
22+
23+
describe('exchangeTempAuthCode', () => {
24+
it('exchanges a valid code for a token, trusts the session, and rotates the code', () => {
25+
const storage = makeStorage()
26+
const session = makeSession()
27+
const code = getTempAuthCode()
28+
const token = exchangeTempAuthCode(code, session, INFO, storage)
29+
expect(token).toBeTruthy()
30+
expect(session.meta.isTrusted).toBe(true)
31+
expect(session.meta.clientAuthToken).toBe(token)
32+
expect(storage.value().trusted[token!]).toMatchObject({ authToken: token, origin: INFO.origin })
33+
// Code is rotated so it can't be replayed.
34+
expect(getTempAuthCode()).not.toBe(code)
35+
// The now-stale code no longer works.
36+
expect(exchangeTempAuthCode(code, makeSession(), INFO, storage)).toBeNull()
37+
})
38+
39+
it('rotates the code after 5 failed attempts', () => {
40+
const storage = makeStorage()
41+
const code = getTempAuthCode()
42+
const wrong = code === '000000' ? '111111' : '000000'
43+
for (let i = 0; i < 5; i++)
44+
expect(exchangeTempAuthCode(wrong, makeSession(), INFO, storage)).toBeNull()
45+
// Code rotated by the lockout — the original valid code is now dead.
46+
expect(getTempAuthCode()).not.toBe(code)
47+
expect(exchangeTempAuthCode(code, makeSession(), INFO, storage)).toBeNull()
48+
})
49+
50+
it('rejects and rotates an expired code', () => {
51+
vi.useFakeTimers()
52+
try {
53+
refreshTempAuthCode()
54+
const code = getTempAuthCode()
55+
vi.advanceTimersByTime(5 * 60_000 + 1) // past TEMP_AUTH_CODE_TTL
56+
const storage = makeStorage()
57+
expect(exchangeTempAuthCode(code, makeSession(), INFO, storage)).toBeNull()
58+
expect(getTempAuthCode()).not.toBe(code)
59+
}
60+
finally {
61+
vi.useRealTimers()
62+
}
63+
})
64+
})
65+
66+
describe('verifyAuthToken', () => {
67+
it('verifies a known token and rejects an unknown one', () => {
68+
const storage = makeStorage()
69+
const token = exchangeTempAuthCode(getTempAuthCode(), makeSession(), INFO, storage)!
70+
const session = makeSession()
71+
expect(verifyAuthToken(token, session, storage)).toBe(true)
72+
expect(session.meta.isTrusted).toBe(true)
73+
expect(verifyAuthToken('not-a-real-token', makeSession(), storage)).toBe(false)
74+
})
75+
})
76+
77+
describe('crypto-token', () => {
78+
it('timingSafeEqual: equal for identical, false for different length/content', () => {
79+
expect(timingSafeEqual('abc', 'abc')).toBe(true)
80+
expect(timingSafeEqual('abc', 'abd')).toBe(false)
81+
expect(timingSafeEqual('abc', 'abcd')).toBe(false)
82+
})
83+
it('randomDigits returns the requested length of decimal digits', () => {
84+
const d = randomDigits(6)
85+
expect(d).toHaveLength(6)
86+
expect(d).toMatch(/^\d{6}$/)
87+
})
88+
it('randomToken returns hex of the expected length', () => {
89+
expect(randomToken(16)).toMatch(/^[0-9a-f]{32}$/)
90+
})
91+
})

plans/007-auth-otp-tests.md

Lines changed: 0 additions & 240 deletions
This file was deleted.

plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ changes are allowed as long as they're marked).
1919
| 004 | Stop git argument injection via `ref`/`hash` | security/bug | P1 | S || TODO |
2020
| 005 | Don't cache a rejected RPC `setup()` promise | bug | P1 | S || TODO |
2121
| 006 | Bound `SharedState.syncIds` (memory leak) | bug | P1 | S || DONE |
22-
| 007 | Behavioral tests for the auth/OTP trust boundary | tests | P1 | S || TODO |
22+
| 007 | Behavioral tests for the auth/OTP trust boundary | tests | P1 | S || DONE |
2323
| 008 | Cap hub terminal buffer + reject restart-after-terminate | bug | P2 | S || TODO |
2424
| 009 | Fix two server-side streaming lifecycle leaks | bug | P2 | S-M || TODO |
2525
| 010 | Initialize client shared state once across trust flips | bug | P2 | S || TODO |

0 commit comments

Comments
 (0)