refresh-tokens-store.test.mjs file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 /**
2 * Durable refresh-token store tests (self-hosted file backend).
3 *
4 * Tiers: unit (each store op), integration (issue->rotate->revoke via disk),
5 * data-integrity (atomic write, corruption fails closed, no secret on disk),
6 * security (reuse detection persists across reads, wrong secret rejected),
7 * performance (write stays bounded).
8 */
9
10 import { describe, it, beforeEach, afterEach } from 'node:test';
11 import assert from 'node:assert/strict';
12 import fs from 'node:fs';
13 import os from 'node:os';
14 import path from 'node:path';
15 import {
16 readRefreshTokens,
17 writeRefreshTokens,
18 issueRefreshToken,
19 rotateRefreshToken,
20 revokeRefreshToken,
21 revokeAllRefreshTokensForSub,
22 pruneRefreshTokens,
23 } from '../hub/refresh-tokens.mjs';
24 import { parseToken, REFRESH_FAILURE } from '../hub/lib/refresh-token-core.mjs';
25
26 const SUB = 'github:777';
27 let dataDir;
28
29 beforeEach(() => {
30 dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-rt-'));
31 });
32 afterEach(() => {
33 try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
34 });
35
36 const storeFile = () => path.join(dataDir, 'hub_refresh_tokens.json');
37
38 describe('store — unit', () => {
39 it('reads empty when no file exists', () => {
40 assert.deepEqual(readRefreshTokens(dataDir), {});
41 });
42
43 it('issueRefreshToken writes a file and returns a usable token', () => {
44 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
45 assert.ok(token.includes('.'));
46 assert.ok(fs.existsSync(storeFile()));
47 const records = readRefreshTokens(dataDir);
48 assert.equal(Object.keys(records).length, 1);
49 });
50
51 it('write is atomic — no leftover .tmp files remain', () => {
52 issueRefreshToken(dataDir, SUB, { now: 1000 });
53 const leftovers = fs.readdirSync(dataDir).filter((f) => f.endsWith('.tmp'));
54 assert.equal(leftovers.length, 0, 'temp files must be renamed away');
55 });
56 });
57
58 describe('store — integration via disk', () => {
59 it('issue -> rotate -> rotate persists state across separate reads', () => {
60 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
61 const r1 = rotateRefreshToken(dataDir, token, { now: 2000 });
62 assert.equal(r1.ok, true);
63 const r2 = rotateRefreshToken(dataDir, r1.token, { now: 3000 });
64 assert.equal(r2.ok, true);
65 assert.equal(r2.sub, SUB);
66 });
67
68 it('logout via revokeRefreshToken invalidates the token on disk', () => {
69 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
70 const out = revokeRefreshToken(dataDir, token);
71 assert.equal(out.revoked, true);
72 const after = rotateRefreshToken(dataDir, token, { now: 2000 });
73 assert.equal(after.ok, false);
74 });
75
76 it('revokeAllRefreshTokensForSub clears every session for a user on disk', () => {
77 issueRefreshToken(dataDir, SUB, { now: 1000 });
78 issueRefreshToken(dataDir, SUB, { now: 1000 });
79 issueRefreshToken(dataDir, 'github:other', { now: 1000 });
80 const { count } = revokeAllRefreshTokensForSub(dataDir, SUB);
81 assert.equal(count, 2);
82 const remaining = readRefreshTokens(dataDir);
83 assert.equal(Object.values(remaining).every((r) => r.sub === 'github:other'), true);
84 });
85 });
86
87 describe('store — data integrity', () => {
88 it('a corrupt store file fails closed (reads empty) instead of throwing', () => {
89 fs.writeFileSync(storeFile(), '{ this is : not json ', 'utf8');
90 assert.deepEqual(readRefreshTokens(dataDir), {});
91 // And we can recover by issuing fresh.
92 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
93 assert.ok(token);
94 });
95
96 it('records with no token_hash are ignored on read (schema guard)', () => {
97 fs.writeFileSync(storeFile(), JSON.stringify({ tokens: { bad: { sub: SUB } } }), 'utf8');
98 assert.deepEqual(readRefreshTokens(dataDir), {});
99 });
100
101 it('the raw secret never appears in the on-disk file', () => {
102 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
103 const { secret } = parseToken(token);
104 const onDisk = fs.readFileSync(storeFile(), 'utf8');
105 assert.ok(!onDisk.includes(secret), 'plaintext secret must not be written to disk');
106 });
107
108 it('store file is written with owner-only permissions (0600) where supported', () => {
109 issueRefreshToken(dataDir, SUB, { now: 1000 });
110 if (process.platform === 'win32') return; // POSIX perms not meaningful on Windows
111 const mode = fs.statSync(storeFile()).mode & 0o777;
112 assert.equal(mode, 0o600, `expected 0600, got ${mode.toString(8)}`);
113 });
114 });
115
116 describe('store — security', () => {
117 it('reuse detection survives a round-trip through disk and burns the family', () => {
118 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
119 const r1 = rotateRefreshToken(dataDir, token, { now: 2000 });
120 assert.equal(r1.ok, true);
121 // Replay the original (already-rotated) token — read fresh from disk each call.
122 const replay = rotateRefreshToken(dataDir, token, { now: 3000 });
123 assert.equal(replay.ok, false);
124 assert.equal(replay.reason, REFRESH_FAILURE.REUSE);
125 // Victim's live successor is now dead too.
126 const victim = rotateRefreshToken(dataDir, r1.token, { now: 4000 });
127 assert.equal(victim.ok, false);
128 });
129
130 it('wrong secret for a known id is rejected and leaves the real session intact', () => {
131 const { token } = issueRefreshToken(dataDir, SUB, { now: 1000 });
132 const { id } = parseToken(token);
133 const bad = rotateRefreshToken(dataDir, `${id}.${'Z'.repeat(43)}`, { now: 2000 });
134 assert.equal(bad.ok, false);
135 const good = rotateRefreshToken(dataDir, token, { now: 3000 });
136 assert.equal(good.ok, true);
137 });
138 });
139
140 describe('store — maintenance', () => {
141 it('pruneRefreshTokens removes dead families', () => {
142 issueRefreshToken(dataDir, 'github:dead', { now: 1000, tokenTtlMs: 1000, familyTtlMs: 1000 });
143 issueRefreshToken(dataDir, 'github:live', { now: 1000 });
144 const { removed } = pruneRefreshTokens(dataDir, { now: 5000 });
145 assert.equal(removed, 1);
146 assert.equal(Object.values(readRefreshTokens(dataDir)).every((r) => r.sub === 'github:live'), true);
147 });
148
149 it('writeRefreshTokens requires a data dir', () => {
150 assert.throws(() => writeRefreshTokens('', {}), /data_dir required/);
151 });
152 });