221 lines
8.5 KiB
TypeScript
221 lines
8.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
MemoryOAuthTransactionStore,
|
|
PrismaOAuthTransactionStore,
|
|
createOAuthTransactionStore,
|
|
generateOAuthState,
|
|
generateCodeVerifier,
|
|
generateCodeChallenge,
|
|
} from '../src/lib/auth/oauth-state';
|
|
import {
|
|
MemorySessionStore,
|
|
PrismaSessionStore,
|
|
createSessionStore,
|
|
SessionUser,
|
|
} from '../src/lib/auth/session';
|
|
import { UnauthorizedError } from '../src/core/errors/http-errors';
|
|
|
|
describe('Task 01: Persistent OAuth-State & Session Store', () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
// ─── 1. Concurrent consumeTransaction on single state ────────────────────────
|
|
describe('OAuth Single-Use Concurrency & Race Resistance', () => {
|
|
it('concurrent consumeTransaction calls on same state yield exactly 1 success and N-1 UnauthorizedErrors', async () => {
|
|
const store = new MemoryOAuthTransactionStore();
|
|
const { state } = await store.createTransaction({
|
|
redirectTarget: '/dashboard',
|
|
ttlMs: 60000,
|
|
});
|
|
|
|
const concurrentAttempts = Array.from({ length: 25 }, async () => {
|
|
try {
|
|
const res = await store.consumeTransaction(state);
|
|
return { success: true, res };
|
|
} catch (err: any) {
|
|
return { success: false, error: err };
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(concurrentAttempts);
|
|
const successes = results.filter(r => r.success);
|
|
const failures = results.filter(r => !r.success);
|
|
|
|
expect(successes).toHaveLength(1);
|
|
expect(failures).toHaveLength(24);
|
|
expect((successes[0] as any).res.redirectTarget).toBe('/dashboard');
|
|
|
|
failures.forEach(f => {
|
|
expect(f.error).toBeInstanceOf(UnauthorizedError);
|
|
expect((f.error as UnauthorizedError).message).toMatch(/single-use constraint|not found/i);
|
|
});
|
|
|
|
// Subsequent read after race must also fail
|
|
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
|
});
|
|
});
|
|
|
|
// ─── 2. Multi-Instance OAuth Consumption ─────────────────────────────────────
|
|
describe('Multi-Instance OAuth Transaction Handoff', () => {
|
|
it('state created by instance A can be consumed by instance B sharing underlying state', async () => {
|
|
// Create shared backing map for simulation
|
|
const sharedMap = new Map<string, any>();
|
|
|
|
class SharedMemoryStore extends MemoryOAuthTransactionStore {
|
|
constructor() {
|
|
super();
|
|
(this as any).store = sharedMap;
|
|
}
|
|
}
|
|
|
|
const instanceA = new SharedMemoryStore();
|
|
const instanceB = new SharedMemoryStore();
|
|
|
|
// Instance A creates OAuth start transaction
|
|
const { state, codeVerifier } = await instanceA.createTransaction({
|
|
redirectTarget: '/giveaways/new',
|
|
});
|
|
|
|
// Instance B consumes OAuth callback transaction
|
|
const consumed = await instanceB.consumeTransaction(state);
|
|
expect(consumed.codeVerifier).toBe(codeVerifier);
|
|
expect(consumed.redirectTarget).toBe('/giveaways/new');
|
|
|
|
// Attempt to re-consume by instance A fails
|
|
await expect(instanceA.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
|
});
|
|
});
|
|
|
|
// ─── 3. TTL Expiry Enforcement ───────────────────────────────────────────────
|
|
describe('TTL Expiry & Invalidation Policy', () => {
|
|
it('expired OAuth state is rejected with UnauthorizedError', async () => {
|
|
const store = new MemoryOAuthTransactionStore({ defaultTtlMs: 1 });
|
|
const { state } = await store.createTransaction({ ttlMs: -1000 }); // already expired
|
|
|
|
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
|
});
|
|
|
|
it('invalidateTransaction removes state explicitly', async () => {
|
|
const store = new MemoryOAuthTransactionStore();
|
|
const { state } = await store.createTransaction();
|
|
|
|
const invalidated = await store.invalidateTransaction(state);
|
|
expect(invalidated).toBe(true);
|
|
|
|
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
|
});
|
|
|
|
it('expired Session is rejected and returns null from getSession', async () => {
|
|
const sessionStore = new MemorySessionStore({ defaultTtlMs: 1 });
|
|
const user: SessionUser = {
|
|
id: 'usr_ttl_test',
|
|
vkUserId: '123456',
|
|
firstName: 'TTL',
|
|
lastName: 'Test',
|
|
};
|
|
|
|
const sessionId = await sessionStore.createSession(user, -5000); // expired 5s ago
|
|
const session = await sessionStore.getSession(sessionId);
|
|
|
|
expect(session).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── 4. Session Store Lifecycle & Destruction ────────────────────────────────
|
|
describe('Session Store Lifecycle', () => {
|
|
const testUser: SessionUser = {
|
|
id: 'usr_session_lifecycle',
|
|
vkUserId: '998877',
|
|
firstName: 'Alice',
|
|
lastName: 'Organizer',
|
|
username: 'alice_org',
|
|
};
|
|
|
|
it('createSession, getSession and destroySession work correctly', async () => {
|
|
const store = new MemorySessionStore();
|
|
const sessionId = await store.createSession(testUser);
|
|
|
|
const retrieved = await store.getSession(sessionId);
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.id).toBe(testUser.id);
|
|
expect(retrieved?.vkUserId).toBe(testUser.vkUserId);
|
|
|
|
await store.destroySession(sessionId);
|
|
const afterDestroy = await store.getSession(sessionId);
|
|
expect(afterDestroy).toBeNull();
|
|
});
|
|
|
|
it('session survives re-creation of store instance when sharing storage', async () => {
|
|
const sharedStoreMap = new Map<string, any>();
|
|
|
|
class SharedSessionStore extends MemorySessionStore {
|
|
constructor() {
|
|
super();
|
|
(this as any).store = sharedStoreMap;
|
|
}
|
|
}
|
|
|
|
const storeProcess1 = new SharedSessionStore();
|
|
const sessionId = await storeProcess1.createSession(testUser);
|
|
|
|
// Simulate process restart
|
|
const storeProcess2 = new SharedSessionStore();
|
|
const retrieved = await storeProcess2.getSession(sessionId);
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.id).toBe(testUser.id);
|
|
});
|
|
});
|
|
|
|
// ─── 5. MULTI_INSTANCE Configuration Policy & Driver Selector ────────────────
|
|
describe('Driver Selector & MULTI_INSTANCE Guards', () => {
|
|
it('Memory stores throw fatal error when MULTI_INSTANCE=true', () => {
|
|
process.env.MULTI_INSTANCE = 'true';
|
|
|
|
expect(() => new MemoryOAuthTransactionStore()).toThrow(/MemoryOAuthTransactionStore cannot be used when MULTI_INSTANCE=true/);
|
|
expect(() => new MemorySessionStore()).toThrow(/In-memory session store cannot be used with MULTI_INSTANCE=true/);
|
|
});
|
|
|
|
it('Prisma stores do NOT throw when MULTI_INSTANCE=true', () => {
|
|
process.env.MULTI_INSTANCE = 'true';
|
|
|
|
expect(() => new PrismaOAuthTransactionStore()).not.toThrow();
|
|
expect(() => new PrismaSessionStore()).not.toThrow();
|
|
});
|
|
|
|
it('createOAuthTransactionStore selects Memory in test/memory mode, Prisma in production mode', () => {
|
|
delete process.env.STORAGE_DRIVER;
|
|
(process.env as any).NODE_ENV = 'test';
|
|
expect(createOAuthTransactionStore()).toBeInstanceOf(MemoryOAuthTransactionStore);
|
|
|
|
process.env.STORAGE_DRIVER = 'memory';
|
|
(process.env as any).NODE_ENV = 'production';
|
|
expect(createOAuthTransactionStore()).toBeInstanceOf(MemoryOAuthTransactionStore);
|
|
|
|
delete process.env.STORAGE_DRIVER;
|
|
(process.env as any).NODE_ENV = 'production';
|
|
expect(createOAuthTransactionStore()).toBeInstanceOf(PrismaOAuthTransactionStore);
|
|
});
|
|
|
|
it('createSessionStore selects Memory in test/memory mode, Prisma in production mode', () => {
|
|
delete process.env.STORAGE_DRIVER;
|
|
(process.env as any).NODE_ENV = 'test';
|
|
expect(createSessionStore()).toBeInstanceOf(MemorySessionStore);
|
|
|
|
process.env.STORAGE_DRIVER = 'memory';
|
|
(process.env as any).NODE_ENV = 'production';
|
|
expect(createSessionStore()).toBeInstanceOf(MemorySessionStore);
|
|
|
|
delete process.env.STORAGE_DRIVER;
|
|
(process.env as any).NODE_ENV = 'production';
|
|
expect(createSessionStore()).toBeInstanceOf(PrismaSessionStore);
|
|
});
|
|
});
|
|
});
|