feat(auth): Task 01 add persistent OAuthTransaction and Session Prisma stores

This commit is contained in:
Ochenstarik 2026-08-21 17:53:38 +07:00
parent b2888950cd
commit 92f6d19225
7 changed files with 664 additions and 8 deletions

View file

@ -0,0 +1,126 @@
# Task 01: Persistent OAuth-state & Session Store Report
**Date:** 2026-08-21
**Base Commit SHA:** `b2888950cdd2d948c15acf0004a0cdc91eeb70e6`
**Status:** IMPLEMENTED / PASS
**Assigned Agent:** Antigravity (Implementation Orchestrator)
---
## 1. Executive Summary
Устранена проблема хранения OAuth-состояний и пользовательских сессий исключительно в памяти одного процесса Node.js (`MemoryOAuthTransactionStore` и `MemorySessionStore`), из-за которой в multi-instance / serverless среде или при перезапуске сервера происходили сбои аутентификации VK ID и сброс активных сессий пользователей.
Реализованы персистентные хранилища на базе PostgreSQL / Prisma:
1. В `prisma/schema.prisma` добавлены модели `OAuthTransaction` (с полями `state`, `codeVerifier`, `redirectTarget`, `createdAt`, `expiresAt`) и `Session` (с полями `sessionId`, `userId`, `user`, `createdAt`, `expiresAt`), а также индексы по `expiresAt` и `userId`.
2. Создана SQL-миграция `prisma/migrations/20260821120000_persistent_auth_stores/migration.sql`.
3. В `src/lib/auth/oauth-state.ts` реализован `PrismaOAuthTransactionStore` с атомарной транзакционной операцией `consumeTransaction` (`$transaction` find + delete), предотвращающей race conditions и гарантирующей single-use семантику OAuth state.
4. В `src/lib/auth/session.ts` реализован `PrismaSessionStore` со строгой валидацией TTL и каскадным удалением сессий при удалении пользователя.
5. Настроены фабрики `createOAuthTransactionStore()` и `createSessionStore()`: при `STORAGE_DRIVER=memory` или `NODE_ENV=test` используются in-memory реализации, в остальных случаях — Prisma-драйверы.
6. Снят фатальный запрет на запуск с `MULTI_INSTANCE=true` для Prisma-хранилищ.
---
## 2. Modified Files
| File | Type | Description |
|------|------|-------------|
| `prisma/schema.prisma` | DB Schema | Добавлены модели `OAuthTransaction` и `Session`, добавлена связь `sessions` в модель `User`. |
| `prisma/migrations/20260821120000_persistent_auth_stores/migration.sql` | Migration | SQL-миграция создания таблиц и индексов для `OAuthTransaction` и `Session`. |
| `src/lib/auth/oauth-state.ts` | Auth | Реализован `PrismaOAuthTransactionStore`, селектор `createOAuthTransactionStore`, функции установки стора. |
| `src/lib/auth/session.ts` | Auth | Реализован `PrismaSessionStore`, селектор `createSessionStore`, функции установки стора. |
| `tests/persistent-auth-stores.test.ts` | Tests (NEW) | Набор тестов на single-use конкурентность, multi-instance обмен, TTL, жизненный цикл и селекторы драйверов (11 тестов). |
---
## 3. Database Migration & Execution Order
### SQL Migration Content:
```sql
-- CreateTable
CREATE TABLE "OAuthTransaction" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"redirectTarget" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthTransaction_state_key" ON "OAuthTransaction"("state");
-- CreateIndex
CREATE INDEX "OAuthTransaction_expiresAt_idx" ON "OAuthTransaction"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
```
### Порядок применения на существующей БД:
1. Выполнить `npx prisma migrate deploy` или применить приведенный SQL-скрипт в PostgreSQL.
2. Никаких изменений существующих данных `User`, `Giveaway`, `Participant` не требуется (обратно-совместимо).
---
## 4. Verification Evidence & Test Gate
Фактически выполненные команды:
```text
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0 generated with OAuthTransaction and Session models)
npx tsc --noEmit -> EXIT 0 (Clean TypeScript check, 0 errors)
npm test -> EXIT 0 (50 test suites, 295 passed, 0 failed)
npm run lint -> EXIT 0 (Next.js ESLint passed clean)
npm run build -> EXIT 0 (Next.js production build compiled successfully)
```
### Summary of New Tests (`tests/persistent-auth-stores.test.ts`):
- `concurrent consumeTransaction calls on same state yield exactly 1 success and N-1 UnauthorizedErrors` → **PASS**
- `state created by instance A can be consumed by instance B sharing underlying state` → **PASS**
- `expired OAuth state is rejected with UnauthorizedError` → **PASS**
- `invalidateTransaction removes state explicitly` → **PASS**
- `expired Session is rejected and returns null from getSession` → **PASS**
- `createSession, getSession and destroySession work correctly` → **PASS**
- `session survives re-creation of store instance when sharing storage` → **PASS**
- `Memory stores throw fatal error when MULTI_INSTANCE=true` → **PASS**
- `Prisma stores do NOT throw when MULTI_INSTANCE=true` → **PASS**
- `createOAuthTransactionStore selects Memory in test/memory mode, Prisma in production mode` → **PASS**
- `createSessionStore selects Memory in test/memory mode, Prisma in production mode` → **PASS**
---
## 5. Core Invariants & Security
- **Randomizer / Audit Proof Invariants:** `HMAC_SHA256_FY_V1`, `DeterministicHmacStream`, `executeDeterministicDrawV1`, `verifyDrawResult` сохранены без изменений.
- **PKCE / State Invariants:** S256 code challenge, криптостойкие случайные токены (CSPRNG) сохранены.
- **Single-Use Invariant:** Гарантируется как в памяти, так и в базе данных через атомарную транзакцию `$transaction`.
---
## 6. UNVERIFIED Assertions & Tech Debt
1. **UNVERIFIED: Live PostgreSQL CI execution for Prisma auth stores:**
- В текущем тестовом окружении автоматизированные тесты Vitest выполняются с `STORAGE_DRIVER=memory` и `NODE_ENV=test`. Хотя `PrismaOAuthTransactionStore` и `PrismaSessionStore` скомпилированы и проверены, сквозной прогон с живой базой PostgreSQL в Vitest требует отдельного интеграционного сьюта.

View file

@ -0,0 +1,30 @@
# Task 01: Persistent OAuth-state & Session Store
**Assigned to:** Antigravity (Implementation Orchestrator)
**Priority:** HIGH
**Date:** 2026-08-21
**Base SHA:** `b2888950cdd2d948c15acf0004a0cdc91eeb70e6`
## Scope
1. Add `OAuthTransaction` and `Session` models to `prisma/schema.prisma`.
- `OAuthTransaction`: `id` (@id @default(cuid())), `state` (@unique), `codeVerifier`, `redirectTarget` (optional string), `createdAt`, `expiresAt`. Index on `expiresAt`.
- `Session`: `id` (@id @default(cuid())), `sessionId` (@unique), `userId` (FK to `User`), `user` relation, `createdAt`, `expiresAt`. Index on `expiresAt`, `userId`.
2. Generate migration SQL under `prisma/migrations/` (timestamped migration folder).
3. Implement `PrismaOAuthTransactionStore` in `src/lib/auth/oauth-state.ts` implementing `IOAuthTransactionStore`.
- Atomic single-use `consumeTransaction` (atomic delete/find).
- TTL check after atomic consumption.
4. Implement `PrismaSessionStore` in `src/lib/auth/session.ts` implementing `ISessionStore`.
- `createSession`: persists session with `expiresAt = Date.now() + ttlMs`.
- `getSession`: finds non-expired session by `sessionId`, loads user, returns `SessionUser` or `null`.
- `destroySession`: deletes session by `sessionId`.
- `clear`: deletes all sessions.
5. Create store factory / default selector based on `STORAGE_DRIVER` & `NODE_ENV`:
- `createOAuthTransactionStore()` & `createSessionStore()`: `STORAGE_DRIVER === 'memory' || process.env.NODE_ENV === 'test'` -> Memory, otherwise Prisma.
- Remove fatal on `MULTI_INSTANCE` for Prisma stores; keep fatal guard for Memory stores.
6. Tests in `tests/persistent-auth-stores.test.ts`:
- Concurrent `consumeTransaction` on single state: exactly 1 success, N-1 fail / 401.
- Multi-instance state consumption (two store instances sharing storage).
- TTL expiry checks for both OAuth transaction and Session.
- `destroySession` and session revival prevention.
7. Run verification gate: `npm ci`, `npx prisma generate`, `npm test`, `npm run lint`, `npm run build`, `npx tsc --noEmit`.
8. Write final report to `agents/antigravity/done/TASK-2026-08-21-01-persistent-auth-stores.md`.

View file

@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "OAuthTransaction" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"redirectTarget" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthTransaction_state_key" ON "OAuthTransaction"("state");
-- CreateIndex
CREATE INDEX "OAuthTransaction_expiresAt_idx" ON "OAuthTransaction"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -42,6 +42,7 @@ model User {
giveaways Giveaway[] giveaways Giveaway[]
credentials UserCredential? credentials UserCredential?
sessions Session[]
} }
model UserCredential { model UserCredential {
@ -169,3 +170,26 @@ model AuditRecord {
drawnAt DateTime @default(now()) drawnAt DateTime @default(now())
verifiedAt DateTime @default(now()) verifiedAt DateTime @default(now())
} }
model OAuthTransaction {
id String @id @default(cuid())
state String @unique
codeVerifier String
redirectTarget String?
createdAt DateTime @default(now())
expiresAt DateTime
@@index([expiresAt])
}
model Session {
id String @id @default(cuid())
sessionId String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
expiresAt DateTime
@@index([expiresAt])
@@index([userId])
}

View file

@ -1,5 +1,6 @@
import { randomBytes, createHash } from 'crypto'; import { randomBytes, createHash } from 'crypto';
import { UnauthorizedError, ValidationError } from '@/core/errors/http-errors'; import { UnauthorizedError, ValidationError } from '@/core/errors/http-errors';
import { prisma } from '@/lib/prisma';
export interface OAuthTransaction { export interface OAuthTransaction {
state: string; state: string;
@ -16,9 +17,9 @@ export interface IOAuthTransactionStore {
}): Promise<{ state: string; codeVerifier: string; codeChallenge: string }>; }): Promise<{ state: string; codeVerifier: string; codeChallenge: string }>;
consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }>; consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }>;
invalidateTransaction(state: string): Promise<boolean>; invalidateTransaction(state: string): Promise<boolean>;
clear(): void; clear(): void | Promise<void>;
size(): number; size(): number | Promise<number>;
cleanupExpired(): number; cleanupExpired(): number | Promise<number>;
} }
/** /**
@ -143,4 +144,125 @@ export class MemoryOAuthTransactionStore implements IOAuthTransactionStore {
} }
} }
export const defaultOAuthTransactionStore: IOAuthTransactionStore = new MemoryOAuthTransactionStore(); export class PrismaOAuthTransactionStore implements IOAuthTransactionStore {
private readonly defaultTtlMs: number;
constructor(options?: { defaultTtlMs?: number }) {
this.defaultTtlMs = options?.defaultTtlMs ?? 10 * 60 * 1000; // 10 minutes
}
public async createTransaction(options?: {
redirectTarget?: string;
ttlMs?: number;
}): Promise<{ state: string; codeVerifier: string; codeChallenge: string }> {
const state = generateOAuthState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const now = new Date();
const ttl = options?.ttlMs ?? this.defaultTtlMs;
const expiresAt = new Date(now.getTime() + ttl);
await prisma.oAuthTransaction.create({
data: {
state,
codeVerifier,
redirectTarget: options?.redirectTarget || '/',
createdAt: now,
expiresAt,
},
});
return { state, codeVerifier, codeChallenge };
}
/**
* Atomically retrieves and removes the OAuth transaction in a single operation.
* Guarantees exact-once consumption per state string in concurrent and multi-instance environments.
*/
public async consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }> {
if (!state || typeof state !== 'string') {
throw new ValidationError('OAuth state parameter is missing or invalid');
}
try {
const tx = await prisma.$transaction(async (txPrisma) => {
const found = await txPrisma.oAuthTransaction.findUnique({
where: { state },
});
if (!found) {
return null;
}
await txPrisma.oAuthTransaction.delete({
where: { state },
});
return found;
});
if (!tx) {
throw new UnauthorizedError('OAuth state not found or was already consumed (single-use constraint)');
}
if (Date.now() > tx.expiresAt.getTime()) {
throw new UnauthorizedError('OAuth state has expired');
}
return {
codeVerifier: tx.codeVerifier,
redirectTarget: tx.redirectTarget || '/',
};
} catch (error: any) {
if (error instanceof UnauthorizedError || error instanceof ValidationError) {
throw error;
}
if (error?.code === 'P2025') {
throw new UnauthorizedError('OAuth state not found or was already consumed (single-use constraint)');
}
throw error;
}
}
public async invalidateTransaction(state: string): Promise<boolean> {
if (!state || typeof state !== 'string') return false;
try {
const res = await prisma.oAuthTransaction.deleteMany({
where: { state },
});
return res.count > 0;
} catch {
return false;
}
}
public async cleanupExpired(): Promise<number> {
const res = await prisma.oAuthTransaction.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return res.count;
}
public async clear(): Promise<void> {
await prisma.oAuthTransaction.deleteMany();
}
public async size(): Promise<number> {
return await prisma.oAuthTransaction.count();
}
}
export function createOAuthTransactionStore(): IOAuthTransactionStore {
const driver = process.env.STORAGE_DRIVER || (process.env.NODE_ENV === 'test' ? 'memory' : 'prisma');
if (driver === 'memory') {
return new MemoryOAuthTransactionStore();
}
return new PrismaOAuthTransactionStore();
}
export let defaultOAuthTransactionStore: IOAuthTransactionStore = createOAuthTransactionStore();
export function setOAuthTransactionStore(store: IOAuthTransactionStore): void {
defaultOAuthTransactionStore = store;
}

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { prisma } from '@/lib/prisma';
export const SESSION_COOKIE_NAME = 'randomayzer_session'; export const SESSION_COOKIE_NAME = 'randomayzer_session';
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
@ -24,9 +25,9 @@ export interface ISessionStore {
createSession(user: SessionUser, ttlMs?: number): Promise<string>; createSession(user: SessionUser, ttlMs?: number): Promise<string>;
getSession(sessionId: string): Promise<SessionUser | null>; getSession(sessionId: string): Promise<SessionUser | null>;
destroySession(sessionId: string): Promise<void>; destroySession(sessionId: string): Promise<void>;
cleanupExpired(): number; cleanupExpired(): number | Promise<number>;
clear(): void; clear(): void | Promise<void>;
size(): number; size(): number | Promise<number>;
} }
export class MemorySessionStore implements ISessionStore { export class MemorySessionStore implements ISessionStore {
@ -97,7 +98,99 @@ export class MemorySessionStore implements ISessionStore {
} }
} }
export const defaultSessionStore: ISessionStore = new MemorySessionStore(); export class PrismaSessionStore implements ISessionStore {
private readonly defaultTtlMs: number;
constructor(options?: { defaultTtlMs?: number }) {
this.defaultTtlMs = options?.defaultTtlMs ?? SESSION_MAX_AGE_SECONDS * 1000;
}
public async createSession(user: SessionUser, ttlMs?: number): Promise<string> {
const sessionId = randomBytes(32).toString('hex');
const now = new Date();
const ttl = ttlMs ?? this.defaultTtlMs;
const expiresAt = new Date(now.getTime() + ttl);
await prisma.session.create({
data: {
sessionId,
userId: user.id,
createdAt: now,
expiresAt,
},
});
return sessionId;
}
public async getSession(sessionId: string): Promise<SessionUser | null> {
if (!sessionId) return null;
const record = await prisma.session.findUnique({
where: { sessionId },
include: {
user: true,
},
});
if (!record) return null;
if (Date.now() > record.expiresAt.getTime()) {
await prisma.session.deleteMany({
where: { sessionId },
});
return null;
}
if (!record.user) return null;
return {
id: record.user.id,
vkUserId: record.user.vkUserId,
firstName: record.user.firstName ?? undefined,
lastName: record.user.lastName ?? undefined,
username: record.user.username ?? undefined,
avatarUrl: record.user.avatarUrl ?? undefined,
};
}
public async destroySession(sessionId: string): Promise<void> {
if (sessionId) {
await prisma.session.deleteMany({
where: { sessionId },
});
}
}
public async cleanupExpired(): Promise<number> {
const res = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return res.count;
}
public async clear(): Promise<void> {
await prisma.session.deleteMany();
}
public async size(): Promise<number> {
return await prisma.session.count();
}
}
export function createSessionStore(): ISessionStore {
const driver = process.env.STORAGE_DRIVER || (process.env.NODE_ENV === 'test' ? 'memory' : 'prisma');
if (driver === 'memory') {
return new MemorySessionStore();
}
return new PrismaSessionStore();
}
export let defaultSessionStore: ISessionStore = createSessionStore();
export function setSessionStore(store: ISessionStore): void {
defaultSessionStore = store;
}
/** /**
* Extracts session user from request cookie * Extracts session user from request cookie

View file

@ -0,0 +1,221 @@
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);
});
});
});