diff --git a/agents/antigravity/done/TASK-2026-08-20-seed-precommit-atomicity.md b/agents/antigravity/done/TASK-2026-08-20-seed-precommit-atomicity.md new file mode 100644 index 0000000..9aed010 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-20-seed-precommit-atomicity.md @@ -0,0 +1,109 @@ +# Phase 2.4.1 — Atomic Snapshot + Seed Commitment Binding Report + +**Date:** 2026-08-20 +**Base Commit SHA:** `78151572bd2ae01645d70a0768c6ece517e2cab0` +**Status:** COMPLETED / READY FOR RE-REVIEW +**Assigned Agent:** Antigravity (Implementation Orchestrator) + +--- + +## 1. Problem Addressed + +В ходе независимого ревью Phase 2.4 было выявлено, что: +1. `createAndLockSnapshot` в Prisma-репозитории допускал смену статуса из `READY` или `SNAPSHOT_LOCKED` (`status IN ('READY', 'SNAPSHOT_LOCKED')`), а Memory-репозиторий аналогично допускал повторную фиксацию слепков. +2. В роуте `POST /api/giveaways/[id]/snapshot` фиксация слепка и чтение `giveaway.seed` для вычисления `seedCommitment` выполнялись двумя независимыми операциями (`createAndLockSnapshot` с последующим `getById`). При конкурентных запросах блокировки это могло привести к гонке, когда ответ возвращал слепок A с хешем seed B. + +--- + +## 2. Implemented Solutions + +1. **Single Lock Invariant:** + - Переход разрешён **только** `READY` → `SNAPSHOT_LOCKED`. + - Любая попытка заблокировать слепок, когда статус отличен от `READY` (например, уже `SNAPSHOT_LOCKED` или `DRAWN`), немедленно возвращает `409 CONFLICT`. + - При этом новый seed не генерируется, повторный слепок не создаётся, существующий `seedCommitment` остаётся неизменным. + +2. **Atomic Return of `{ snapshot, seedCommitment }`:** + - Интерфейс `IGiveawayRepository.createAndLockSnapshot` и класс `GiveawayStore.createAndLockSnapshot` теперь возвращают `Promise`: + ```typescript + export interface LockedSnapshotResult { + snapshot: ParticipantSnapshotData; + seedCommitment: string; + } + ``` + - Генерация CSPRNG seed, вычисление `seedCommitment = sha256(seed)`, сохранение seed в базе данных и создание записи `ParticipantSnapshot` выполняются строго внутри единой атомарной транзакции (в Prisma: `$transaction`; в Memory: синхронная мутация Map). + +3. **Elimination of Post-Transaction Query:** + - Роут `POST /api/giveaways/[id]/snapshot` использует `seedCommitment`, возвращённый непосредственно из атомарной операции `GiveawayStore.createAndLockSnapshot`. Дополнительный запрос `GiveawayStore.getById(id)` полностью исключён. + +4. **Driver Parity (Memory & Prisma):** + - `PrismaGiveawayRepository`: `where: { id, status: 'READY' }` внутри `$transaction`. + - `MemoryGiveawayRepository`: строгая проверка `if (gw.status !== 'READY') throw new ConflictError(...)`. + +5. **Idempotency & Commitment Stability:** + - Повторные вызовы с одинаковым `Idempotency-Key` отдают закэшированный ответ 200 со стабильным `seedCommitment` без перегенерации seed. + - Повторный вызов с новым ключом после фиксации слепка возвращает `409 CONFLICT`. + - До жеребьёвки `giveaway.seed` маскируется (`null`), отдаётся только `seedCommitment`. После жеребьёвки `sha256(drawResult.seedUsed) === seedCommitment`. + +--- + +## 3. Files Changed + +| File | Type | Description | +|------|------|-------------| +| `src/lib/repository/giveaway-repository.ts` | Interface | Добавлен интерфейс `LockedSnapshotResult`, обновлена сигнатура `createAndLockSnapshot`. | +| `src/lib/giveaway-store.ts` | Store | Обновлена сигнатура `GiveawayStore.createAndLockSnapshot` (`Promise`). | +| `src/lib/repository/memory-repository.ts` | Driver | Строгий инвариант `READY` → `SNAPSHOT_LOCKED` (409 на повторы), атомарный возврат `{ snapshot, seedCommitment }`. | +| `src/lib/repository/prisma-repository.ts` | Driver | Условие `status: 'READY'` внутри `$transaction`, атомарный возврат `{ snapshot, seedCommitment }`. | +| `src/app/api/giveaways/[id]/snapshot/route.ts` | API Route | Прямое использование возвращённых `{ snapshot, seedCommitment }`, убран redundant `getById`. | +| `tests/winner-count-contract.test.ts` | Tests | Деструктуризация `{ snapshot }` из вызовов `createAndLockSnapshot`. | +| `tests/persistence.test.ts` | Tests | Деструктуризация `{ snapshot, seedCommitment }`. | +| `tests/snapshot-binding.test.ts` | Tests | Деструктуризация `{ snapshot: snapshotV1/V2 }`. | +| `tests/concurrency-draw.test.ts` | Tests | Деструктуризация `{ snapshot }`. | +| `tests/concurrency-draw-100.test.ts` | Tests | Деструктуризация `{ snapshot }`. | +| `tests/seed-precommit-gate.test.ts` | Tests | Деструктуризация `{ snapshot, seedCommitment }`, проверка равенства commitment. | +| `tests/snapshot-seed-atomicity.test.ts` | Tests (NEW) | Комплексный сьют проверки атомарности, конкуренции и стабильности commitment (4 теста). | + +--- + +## 4. Unchanged Core Algorithms + +Все алгоритмы генерации и верификации не изменялись: +- `HMAC_SHA256_FY_V1` +- `DeterministicHmacStream` +- `executeDeterministicDrawV1` +- `computeParticipantsSnapshotHash` +- `computeConditionsHash` +- `computeDeterministicProofHash` +- `computeAuditEventHash` +- `verifyDrawResult` + +--- + +## 5. Test & Gate Results + +Фактически выполненные команды: + +```text +npx prisma generate -> EXIT 0 (Prisma Client v5.22.0) +npm test -> EXIT 0 (49 test files, 284 passed, 0 failed) +npm run lint -> EXIT 0 (Clean) +npm run build -> EXIT 0 (Next.js production build succeeded) +``` + +### Concurrency Test Evidence: +1. `tests/snapshot-seed-atomicity.test.ts` (4 теста): + - `Memory repository: 20 concurrent createAndLockSnapshot calls yield exactly 1 success and 19 ConflictErrors` → **PASS** + - `API route: concurrent snapshot lock requests with different Idempotency-Keys produce exactly 1 200 and remaining 409s` → **PASS** + - `idempotency: replaying same key returns cached commitment; new key after lock returns 409` → **PASS** + - `commitment stability: commitment is invariant across reads and equals sha256(seedUsed) after draw` → **PASS** +2. `tests/seed-precommit-gate.test.ts` (7 тестов) → **PASS** +3. `tests/concurrency-draw-100.test.ts` (2 теста) → **PASS** + +--- + +## 6. Audit & Migration + +- **Database migration required:** NO (поле `Giveaway.seed` уже присутствует в схеме Prisma). +- **CRITICAL/HIGH findings:** 0 open in implementation. +- **UNVERIFIED claims:** None. +- **Next step:** Передача на независимое security re-review (Grok/Claude/OpenCode). diff --git a/agents/antigravity/inbox/TASK-2026-08-20-seed-precommit-atomicity.md b/agents/antigravity/inbox/TASK-2026-08-20-seed-precommit-atomicity.md new file mode 100644 index 0000000..f953643 --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-20-seed-precommit-atomicity.md @@ -0,0 +1,18 @@ +# Task: Phase 2.4.1 — Atomic Snapshot + Seed Commitment Binding + +**Assigned to:** Antigravity (Implementation Orchestrator) +**Priority:** CRITICAL (fairness / concurrency) +**Date:** 2026-08-20 +**Base SHA:** `78151572bd2ae01645d70a0768c6ece517e2cab0` + +## Scope +1. Enforce strict single-lock invariant: `createAndLockSnapshot` only transitions `READY` → `SNAPSHOT_LOCKED`. Any request on already locked/drawn giveaway yields 409 CONFLICT. +2. Extend `createAndLockSnapshot` repository interface and implementations (`MemoryGiveawayRepository` and `PrismaGiveawayRepository`) to atomically generate `seed`, compute `seedCommitment = sha256(seed)`, update DB, and return `{ snapshot: ParticipantSnapshotData; seedCommitment: string }`. +3. Eliminate secondary `getById` read in `POST /api/giveaways/[id]/snapshot`. Use returned `seedCommitment` directly. +4. Concurrency regression tests (memory & API): + - Multiple concurrent snapshot requests: exactly 1 succeeds, others get 409. + - Snapshot count strictly 1, seed commitment matches persisted seed. + - Idempotency replay returns cached commitment without generating new seed. + - Post-draw verification `sha256(seedUsed) === seedCommitment`. +5. Run full gate: `npx prisma generate`, `npm test`, `npm run lint`, `npm run build`. +6. Output report in `agents/antigravity/done/TASK-2026-08-20-seed-precommit-atomicity.md`. diff --git a/src/app/api/giveaways/[id]/snapshot/route.ts b/src/app/api/giveaways/[id]/snapshot/route.ts index cedbc4a..c4e049a 100644 --- a/src/app/api/giveaways/[id]/snapshot/route.ts +++ b/src/app/api/giveaways/[id]/snapshot/route.ts @@ -8,7 +8,6 @@ import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; import { IdempotencyStore } from '@/lib/idempotency'; import { resolveClientIp } from '@/lib/client-ip'; import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; -import { computeSeedCommitment } from '@/core/randomizer/hasher'; export const dynamic = 'force-dynamic'; @@ -58,15 +57,12 @@ export async function POST( } // Atomically create and lock snapshot + pre-commit seed in database - const snapshot = await GiveawayStore.createAndLockSnapshot( + const { snapshot, seedCommitment } = await GiveawayStore.createAndLockSnapshot( id, eligibleParticipants, validated.filterRules ); - const updatedGw = await GiveawayStore.getById(id); - const seedCommitment = updatedGw?.seed ? computeSeedCommitment(updatedGw.seed) : null; - const responseBody = { success: true, giveawayId: id, diff --git a/src/lib/giveaway-store.ts b/src/lib/giveaway-store.ts index 2bd121a..26b653a 100644 --- a/src/lib/giveaway-store.ts +++ b/src/lib/giveaway-store.ts @@ -3,7 +3,8 @@ import { GiveawayWithRelations, GiveawaySummary, PaginatedParticipantsResult, - CreateGiveawayInput + CreateGiveawayInput, + LockedSnapshotResult } from './repository/giveaway-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository'; @@ -68,7 +69,7 @@ export class GiveawayStore { id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules - ): Promise { + ): Promise { return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules); } diff --git a/src/lib/repository/giveaway-repository.ts b/src/lib/repository/giveaway-repository.ts index 6b38b2b..704d19d 100644 --- a/src/lib/repository/giveaway-repository.ts +++ b/src/lib/repository/giveaway-repository.ts @@ -73,6 +73,11 @@ export interface CreateGiveawayInput { organizerId: string; } +export interface LockedSnapshotResult { + snapshot: ParticipantSnapshotData; + seedCommitment: string; +} + export interface IGiveawayRepository { createGiveaway(input: CreateGiveawayInput): Promise; getGiveawayById(id: string): Promise; @@ -90,7 +95,7 @@ export interface IGiveawayRepository { id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules - ): Promise; + ): Promise; getLatestSnapshot(giveawayId: string): Promise; saveDrawResultAndAudit( id: string, diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 34138e0..aaf2a92 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -3,7 +3,8 @@ import { CreateGiveawayInput, GiveawayWithRelations, GiveawaySummary, - PaginatedParticipantsResult + PaginatedParticipantsResult, + LockedSnapshotResult } from './giveaway-repository'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilteredParticipant } from '../../core/types/participant'; @@ -186,12 +187,15 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules - ): Promise { + ): Promise { const gw = this.giveaways.get(id); if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); - if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') { - throw new ConflictError(`Cannot lock snapshot in final status "${gw.status}"`); + // Strict Single Lock Invariant: Only READY -> SNAPSHOT_LOCKED transition is permitted + if (gw.status !== 'READY') { + throw new ConflictError( + `Cannot lock snapshot: giveaway "${id}" is in status "${gw.status}", but requires "READY"` + ); } if (eligibleParticipants.length === 0) { @@ -222,15 +226,19 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { // Generate and lock cryptographic seed atomically with snapshot creation const seed = generateCryptoSecureSeed(); + const seedCommitment = computeSeedCommitment(seed); gw.status = 'SNAPSHOT_LOCKED'; gw.filterRules = rules; gw.latestSnapshot = snapshot; gw.seed = seed; - gw.seedCommitment = computeSeedCommitment(seed); + gw.seedCommitment = seedCommitment; gw.updatedAt = new Date().toISOString(); - return snapshot; + return { + snapshot, + seedCommitment, + }; } async getLatestSnapshot(giveawayId: string): Promise { diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index bb8417c..f40ceee 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -3,7 +3,8 @@ import { CreateGiveawayInput, GiveawayWithRelations, GiveawaySummary, - PaginatedParticipantsResult + PaginatedParticipantsResult, + LockedSnapshotResult } from './giveaway-repository'; import { prisma } from '../prisma'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; @@ -358,12 +359,15 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules - ): Promise { + ): Promise { const current = await this.getGiveawayById(id); if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); - if (current.status === 'DRAWN' || current.status === 'PUBLISHED') { - throw new ConflictError(`Cannot lock snapshot in final status "${current.status}"`); + // Strict Single Lock Invariant: Only READY -> SNAPSHOT_LOCKED transition is permitted + if (current.status !== 'READY') { + throw new ConflictError( + `Cannot lock snapshot: giveaway "${id}" is in status "${current.status}", but requires "READY"` + ); } if (eligibleParticipants.length === 0) { @@ -375,14 +379,15 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { try { return await prisma.$transaction(async (tx) => { - // Generate cryptographic seed for pre-commitment + // Generate cryptographic seed and commitment const seed = generateCryptoSecureSeed(); + const seedCommitment = computeSeedCommitment(seed); - // Atomic status and seed guard + // Atomic status and seed guard: ONLY transition from READY const updateRes = await tx.giveaway.updateMany({ where: { id, - status: { in: ['READY', 'SNAPSHOT_LOCKED'] }, + status: 'READY', }, data: { status: 'SNAPSHOT_LOCKED', @@ -415,15 +420,18 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { }); return { - id: snapshot.id, - giveawayId: snapshot.giveawayId, - version: snapshot.version, - createdAt: snapshot.createdAt.toISOString(), - eligibleParticipants, - filterRulesSnapshot: rules, - participantCount: snapshot.participantCount, - participantsSnapshotHash: snapshot.participantsSnapshotHash, - conditionsHash: snapshot.conditionsHash, + snapshot: { + id: snapshot.id, + giveawayId: snapshot.giveawayId, + version: snapshot.version, + createdAt: snapshot.createdAt.toISOString(), + eligibleParticipants, + filterRulesSnapshot: rules, + participantCount: snapshot.participantCount, + participantsSnapshotHash: snapshot.participantsSnapshotHash, + conditionsHash: snapshot.conditionsHash, + }, + seedCommitment, }; }); } catch (err: any) { diff --git a/tests/concurrency-draw-100.test.ts b/tests/concurrency-draw-100.test.ts index ed35bdd..938e4ec 100644 --- a/tests/concurrency-draw-100.test.ts +++ b/tests/concurrency-draw-100.test.ts @@ -41,7 +41,7 @@ describe('100-Draw Concurrency Regression & Mixed Race', () => { organizerId: 'usr_conc_100', }); - const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); + const { snapshot } = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); // Launch 100 concurrent draw requests const drawPromises = Array.from({ length: 100 }, async (_, index) => { diff --git a/tests/concurrency-draw.test.ts b/tests/concurrency-draw.test.ts index 0947d50..13604bc 100644 --- a/tests/concurrency-draw.test.ts +++ b/tests/concurrency-draw.test.ts @@ -41,7 +41,7 @@ describe('Concurrency Double Draw Protection', () => { organizerId: 'usr_conc_draw', }); - const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); + const { snapshot } = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); // 2. Launch 20 concurrent draw attempts const concurrentDrawPromises = Array.from({ length: 20 }, async (_, index) => { diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 0df57b7..4ea3c8d 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -78,7 +78,7 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(updatedGw.participants.length).toBe(3); // 3. Create & Lock Snapshot - const snapshot = await repo.createAndLockSnapshot( + const { snapshot, seedCommitment } = await repo.createAndLockSnapshot( gw.id, sampleParticipants, DEFAULT_FILTER_RULES @@ -88,6 +88,7 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(snapshot.participantCount).toBe(3); expect(snapshot.participantsSnapshotHash).toBeDefined(); expect(snapshot.conditionsHash).toBeDefined(); + expect(seedCommitment).toBeDefined(); // Verify giveaway status transitioned to SNAPSHOT_LOCKED const lockedGw = await repo.getGiveawayById(gw.id); diff --git a/tests/seed-precommit-gate.test.ts b/tests/seed-precommit-gate.test.ts index c44747c..cb234e0 100644 --- a/tests/seed-precommit-gate.test.ts +++ b/tests/seed-precommit-gate.test.ts @@ -306,14 +306,16 @@ describe('Phase 2.4 — Seed Pre-Commit Gate (Seed Grinding Elimination)', () => expect(gw.seed).toBeNull(); expect(gw.seedCommitment).toBeNull(); - const snapshot = await repo.createAndLockSnapshot(gw.id, testParticipants.slice(0, 10), DEFAULT_FILTER_RULES); + const { snapshot, seedCommitment } = await repo.createAndLockSnapshot(gw.id, testParticipants.slice(0, 10), DEFAULT_FILTER_RULES); expect(snapshot.id).toBeDefined(); + expect(seedCommitment).toBeDefined(); const lockedGw = await repo.getGiveawayById(gw.id); expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED'); expect(lockedGw?.seed).toBeDefined(); expect(lockedGw?.seed).toHaveLength(32); expect(lockedGw?.seedCommitment).toBe(computeSeedCommitment(lockedGw!.seed!)); + expect(seedCommitment).toBe(lockedGw?.seedCommitment); }); // ─── Test 7: Repository Driver Parity (Prisma repository mapping & seed commitment) ─── diff --git a/tests/snapshot-binding.test.ts b/tests/snapshot-binding.test.ts index d3aea9a..04f0516 100644 --- a/tests/snapshot-binding.test.ts +++ b/tests/snapshot-binding.test.ts @@ -73,13 +73,13 @@ describe('DrawResult Snapshot Binding Regression Tests', () => { }); // 2. Create snapshot V1 - const snapshotV1 = await repo.createAndLockSnapshot(gw.id, participantsV1, DEFAULT_FILTER_RULES); + const { snapshot: snapshotV1 } = await repo.createAndLockSnapshot(gw.id, participantsV1, DEFAULT_FILTER_RULES); expect(snapshotV1.version).toBe(1); const hashV1 = snapshotV1.participantsSnapshotHash; // 3. Unlock / simulate revision and create Snapshot V2 await repo.updateStatus(gw.id, 'READY'); - const snapshotV2 = await repo.createAndLockSnapshot(gw.id, participantsV2, { + const { snapshot: snapshotV2 } = await repo.createAndLockSnapshot(gw.id, participantsV2, { ...DEFAULT_FILTER_RULES, requireComment: true, }); diff --git a/tests/snapshot-seed-atomicity.test.ts b/tests/snapshot-seed-atomicity.test.ts new file mode 100644 index 0000000..cd8515f --- /dev/null +++ b/tests/snapshot-seed-atomicity.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { createHash } from 'crypto'; +import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route'; +import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route'; +import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route'; +import { GET as verifyGet } from '../src/app/api/giveaways/[id]/verify/route'; +import { GiveawayStore } from '../src/lib/giveaway-store'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ConflictError } from '../src/core/errors/http-errors'; +import { computeSeedCommitment } from '../src/core/randomizer/hasher'; + +describe('Phase 2.4.1 — Atomic Snapshot + Seed Commitment Binding', () => { + const organizerUser = { id: 'usr_atomic_snap_organizer', vkUserId: '888222' }; + let sessionCookie: string; + + const testParticipants: FilteredParticipant[] = Array.from({ length: 50 }, (_, i) => ({ + platformUserId: `${2000 + i}`, + firstName: `User${i}`, + lastName: `Atomic${i}`, + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + })); + + beforeEach(async () => { + GiveawayStore.setRepository(new MemoryGiveawayRepository()); + defaultSessionStore.clear(); + const sessionId = await defaultSessionStore.createSession(organizerUser); + sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId}`; + }); + + async function createReadyGiveaway() { + const gw = await GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-33445566_789', + post: { + platform: 'VK', + ownerId: '-33445566', + postId: '789', + sourceUrl: 'https://vk.com/wall-33445566_789', + title: 'Atomic Snapshot Test Post', + likesCount: 50, + commentsCount: 0, + repostsCount: 0, + }, + filterRules: DEFAULT_FILTER_RULES, + organizerId: organizerUser.id, + }); + + await GiveawayStore.updateParticipants(gw.id, testParticipants); + return gw; + } + + // ─── Test 1: Memory Repository Single Lock Invariant & Concurrency ─────────── + it('Memory repository: 20 concurrent createAndLockSnapshot calls yield exactly 1 success and 19 ConflictErrors', async () => { + const repo = new MemoryGiveawayRepository(); + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-1_1', + post: { + platform: 'VK', + ownerId: '-1', + postId: '1', + sourceUrl: 'https://vk.com/wall-1_1', + title: 'Parity Test', + likesCount: 50, + commentsCount: 0, + repostsCount: 0, + }, + filterRules: DEFAULT_FILTER_RULES, + organizerId: 'org_atomic_mem', + }); + + await repo.saveParticipants(gw.id, testParticipants); + + const attempts = Array.from({ length: 20 }, async (_, index) => { + try { + const res = await repo.createAndLockSnapshot(gw.id, testParticipants, { + ...DEFAULT_FILTER_RULES, + requireLike: index % 2 === 0, + }); + return { success: true, res }; + } catch (err: any) { + return { success: false, error: err }; + } + }); + + const results = await Promise.all(attempts); + const successes = results.filter(r => r.success); + const conflicts = results.filter(r => !r.success); + + expect(successes).toHaveLength(1); + expect(conflicts).toHaveLength(19); + + // The winning result has both snapshot and seedCommitment + const winning = (successes[0] as any).res; + expect(winning.snapshot).toBeDefined(); + expect(winning.seedCommitment).toBeDefined(); + expect(winning.seedCommitment).toHaveLength(64); + + // Verify DB state + const locked = await repo.getGiveawayById(gw.id); + expect(locked?.status).toBe('SNAPSHOT_LOCKED'); + expect(locked?.snapshots).toHaveLength(1); + expect(locked?.seed).toBeDefined(); + expect(computeSeedCommitment(locked!.seed!)).toBe(winning.seedCommitment); + + // All failed attempts threw ConflictError + conflicts.forEach(c => { + expect(c.error).toBeInstanceOf(ConflictError); + }); + }); + + // ─── Test 2: API Concurrency with different Idempotency keys ───────────────── + it('API route: concurrent snapshot lock requests with different Idempotency-Keys produce exactly 1 200 and remaining 409s', async () => { + const gw = await createReadyGiveaway(); + + const requests = Array.from({ length: 5 }, (_, i) => { + const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + 'Idempotency-Key': `key-diff-${i}-${Date.now()}`, + }, + body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }), + }); + return snapshotPost(req, { params: { id: gw.id } }); + }); + + const responses = await Promise.all(requests); + const statusCodes = responses.map(r => r.status); + + const count200 = statusCodes.filter(s => s === 200).length; + const count409 = statusCodes.filter(s => s === 409).length; + + expect(count200).toBe(1); + expect(count409).toBe(4); + + const successRes = responses.find(r => r.status === 200)!; + const body = await successRes.json(); + expect(body.success).toBe(true); + expect(body.status).toBe('SNAPSHOT_LOCKED'); + expect(body.snapshot).toBeDefined(); + expect(body.seedCommitment).toBeDefined(); + + // Verify persisted seed matches returned commitment directly + const stored = await GiveawayStore.getById(gw.id); + expect(stored?.status).toBe('SNAPSHOT_LOCKED'); + expect(computeSeedCommitment(stored!.seed!)).toBe(body.seedCommitment); + }); + + // ─── Test 3: Idempotency Replay with same key vs new key ───────────────────── + it('idempotency: replaying same key returns cached commitment; new key after lock returns 409', async () => { + const gw = await createReadyGiveaway(); + const idempotencyKey = `idem-key-stable-${Date.now()}`; + + // 1. Initial lock + const req1 = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }), + }); + + const res1 = await snapshotPost(req1, { params: { id: gw.id } }); + expect(res1.status).toBe(200); + const data1 = await res1.json(); + const initialCommitment = data1.seedCommitment; + expect(initialCommitment).toMatch(/^[a-f0-9]{64}$/); + + // 2. Replay with identical key -> returns cached 200 with identical snapshot & commitment + const replayReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }), + }); + + const replayRes = await snapshotPost(replayReq, { params: { id: gw.id } }); + expect(replayRes.status).toBe(200); + const replayData = await replayRes.json(); + expect(replayData.seedCommitment).toBe(initialCommitment); + expect(replayData.snapshot.id).toBe(data1.snapshot.id); + + // 3. New request with different key -> 409 Conflict + const newKeyReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + 'Idempotency-Key': `new-key-${Date.now()}`, + }, + body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }), + }); + + const newKeyRes = await snapshotPost(newKeyReq, { params: { id: gw.id } }); + expect(newKeyRes.status).toBe(409); + }); + + // ─── Test 4: Commitment Stability and Provable Draw Verification ──────────── + it('commitment stability: commitment is invariant across reads and equals sha256(seedUsed) after draw', async () => { + const gw = await createReadyGiveaway(); + + // 1. Lock snapshot + const snapReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + }, + body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }), + }); + + const snapRes = await snapshotPost(snapReq, { params: { id: gw.id } }); + expect(snapRes.status).toBe(200); + const snapData = await snapRes.json(); + const lockedCommitment = snapData.seedCommitment; + + // 2. Repeated GET before draw + for (let i = 0; i < 3; i++) { + const getReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, { + method: 'GET', + headers: { Cookie: sessionCookie }, + }); + const getRes = await giveawayDetailGet(getReq, { params: { id: gw.id } }); + const getData = await getRes.json(); + expect(getData.giveaway.seed).toBeNull(); // Masked before DRAWN + expect(getData.giveaway.seedCommitment).toBe(lockedCommitment); + } + + // 3. Execute draw + const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/draw`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: sessionCookie, + }, + body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }), + }); + + const drawRes = await drawPost(drawReq, { params: { id: gw.id } }); + expect(drawRes.status).toBe(200); + const drawData = await drawRes.json(); + + const seedUsed = drawData.drawResult.seedUsed; + expect(createHash('sha256').update(seedUsed).digest('hex')).toBe(lockedCommitment); + + // 4. Verify public endpoint + const verifyReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/verify`, { + method: 'GET', + }); + const verifyRes = await verifyGet(verifyReq, { params: { id: gw.id } }); + const verifyData = await verifyRes.json(); + expect(verifyData.verified).toBe(true); + }); +}); diff --git a/tests/winner-count-contract.test.ts b/tests/winner-count-contract.test.ts index db7ed65..87f7d4c 100644 --- a/tests/winner-count-contract.test.ts +++ b/tests/winner-count-contract.test.ts @@ -54,7 +54,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => { }); await GiveawayStore.updateParticipants(gw.id, threeParticipants); - const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); + const { snapshot } = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); const result = executeDeterministicDrawV1({ giveawayId: gw.id, @@ -90,7 +90,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => { }); await GiveawayStore.updateParticipants(gw.id, threeParticipants); - const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); + const { snapshot } = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); const result = executeDeterministicDrawV1({ giveawayId: gw.id, @@ -126,7 +126,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => { }); await GiveawayStore.updateParticipants(gw.id, threeParticipants); - const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); + const { snapshot } = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES); expect(() => executeDeterministicDrawV1({