import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { createAuthStateStore } from '../src/security/auth-state.mjs'; class FakeRedisClient extends EventEmitter { constructor({ connectError = null } = {}) { super(); this.connectError = connectError; this.isOpen = false; this.values = new Map(); this.sets = new Map(); this.hashes = new Map(); } async connect() { if (this.connectError) throw this.connectError; this.isOpen = true; } async get(key) { return this.values.has(key) ? this.values.get(key) : null; } async set(key, value) { this.values.set(key, String(value)); return 'OK'; } async del(key) { const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key)); return deleted ? 1 : 0; } async sAdd(key, value) { if (!this.sets.has(key)) this.sets.set(key, new Set()); const before = this.sets.get(key).size; this.sets.get(key).add(value); return this.sets.get(key).size - before; } async sRem(key, value) { return Number(this.sets.get(key)?.delete(value) || false); } async sMembers(key) { return [...(this.sets.get(key) || [])]; } async expire() { return 1; } async hSet(key, entries) { if (!this.hashes.has(key)) this.hashes.set(key, new Map()); for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value)); return Object.keys(entries).length; } async hGetAll(key) { return Object.fromEntries(this.hashes.get(key) || []); } async eval(_script, { keys, arguments: scriptArguments }) { const hash = this.hashes.get(keys[0]); if (!hash) return -1; const attempts = Number(hash.get('attempts') || 0) + 1; hash.set('attempts', String(attempts)); if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]); return attempts; } multi() { const operations = []; const transaction = {}; for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) { transaction[method] = (...args) => { operations.push(() => this[method](...args)); return transaction; }; } transaction.exec = async () => Promise.all(operations.map(operation => operation())); return transaction; } async quit() { this.isOpen = false; } destroy() { this.isOpen = false; } } const silentLogger = { error() {} }; { const state = await createAuthStateStore({ env: {} }); assert.equal(state.status, 'disabled'); assert.equal(state.backend, 'memory'); await state.createSession('session-1', 'user-1'); assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' }); await state.createLoginChallenge('challenge-1', 'user-1'); assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 }); await state.createTotpSetup('session-1', 'user-1', 'SECRET'); assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' }); assert.equal(await state.deleteUserSessions('user-1'), 1); assert.equal(await state.getSession('session-1'), null); await state.close(); } { const client = new FakeRedisClient(); let clientOptions; const state = await createAuthStateStore({ env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' }, logger: silentLogger, clientFactory(options) { clientOptions = options; return client; } }); assert.equal(state.status, 'ready'); assert.equal(state.backend, 'redis'); assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1'); assert.equal(clientOptions.database, 1); assert.equal(clientOptions.url, 'redis://cache.example:6379/0'); await state.createSession('session-1', 'user-1'); await state.createSession('session-2', 'user-1'); assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' }); assert.equal(await state.deleteUserSessions('user-1'), 2); assert.equal(await state.getSession('session-1'), null); assert.equal(await state.getSession('session-2'), null); await state.createLoginChallenge('challenge-1', 'user-1'); for (let attempts = 1; attempts <= 4; attempts += 1) { assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false }); } assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true }); assert.equal(await state.getLoginChallenge('challenge-1'), null); await state.createTotpSetup('session-3', 'user-1', 'SECRET'); assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' }); await state.deleteTotpSetup('session-3'); assert.equal(await state.getTotpSetup('session-3'), null); await state.close(); assert.equal(client.isOpen, false); } { const client = new FakeRedisClient(); let options; const state = await createAuthStateStore({ env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' }, logger: silentLogger, clientFactory(clientOptions) { options = clientOptions; return client; } }); assert.equal(options.url, 'rediss://sessions.example:6380/4'); assert.equal(options.database, 4); await state.close(); } { const client = new FakeRedisClient(); let options; const state = await createAuthStateStore({ env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' }, logger: silentLogger, clientFactory(clientOptions) { options = clientOptions; return client; } }); assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB'); await state.close(); } await assert.rejects( createAuthStateStore({ env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' }, logger: silentLogger, clientFactory: () => new FakeRedisClient() }), /必须使用与普通缓存不同的逻辑数据库/ ); await assert.rejects( createAuthStateStore({ env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' }, logger: silentLogger, clientFactory: () => new FakeRedisClient() }), /必须使用与普通缓存不同的逻辑数据库/ ); await assert.rejects( createAuthStateStore({ env: { REDIS_URL: 'redis://unavailable.example:6379/0' }, logger: silentLogger, clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') }) }), /认证状态存储连接失败/ ); console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');