fix(snapshot): Phase 2.4.1 enforce atomic snapshot lock, single lock invariant and direct seed commitment binding

This commit is contained in:
Ochenstarik 2026-08-20 18:23:48 +07:00
parent 78151572bd
commit 3279f287fb
14 changed files with 457 additions and 39 deletions

View file

@ -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<LockedSnapshotResult>`:
```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<LockedSnapshotResult>`). |
| `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).

View file

@ -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`.

View file

@ -8,7 +8,6 @@ import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency'; import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip'; import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
import { computeSeedCommitment } from '@/core/randomizer/hasher';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -58,15 +57,12 @@ export async function POST(
} }
// Atomically create and lock snapshot + pre-commit seed in database // Atomically create and lock snapshot + pre-commit seed in database
const snapshot = await GiveawayStore.createAndLockSnapshot( const { snapshot, seedCommitment } = await GiveawayStore.createAndLockSnapshot(
id, id,
eligibleParticipants, eligibleParticipants,
validated.filterRules validated.filterRules
); );
const updatedGw = await GiveawayStore.getById(id);
const seedCommitment = updatedGw?.seed ? computeSeedCommitment(updatedGw.seed) : null;
const responseBody = { const responseBody = {
success: true, success: true,
giveawayId: id, giveawayId: id,

View file

@ -3,7 +3,8 @@ import {
GiveawayWithRelations, GiveawayWithRelations,
GiveawaySummary, GiveawaySummary,
PaginatedParticipantsResult, PaginatedParticipantsResult,
CreateGiveawayInput CreateGiveawayInput,
LockedSnapshotResult
} from './repository/giveaway-repository'; } from './repository/giveaway-repository';
import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository';
import { MemoryGiveawayRepository } from './repository/memory-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository';
@ -68,7 +69,7 @@ export class GiveawayStore {
id: string, id: string,
eligibleParticipants: FilteredParticipant[], eligibleParticipants: FilteredParticipant[],
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData> { ): Promise<LockedSnapshotResult> {
return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules); return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules);
} }

View file

@ -73,6 +73,11 @@ export interface CreateGiveawayInput {
organizerId: string; organizerId: string;
} }
export interface LockedSnapshotResult {
snapshot: ParticipantSnapshotData;
seedCommitment: string;
}
export interface IGiveawayRepository { export interface IGiveawayRepository {
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>; createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>; getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
@ -90,7 +95,7 @@ export interface IGiveawayRepository {
id: string, id: string,
eligibleParticipants: FilteredParticipant[], eligibleParticipants: FilteredParticipant[],
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData>; ): Promise<LockedSnapshotResult>;
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>; getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
saveDrawResultAndAudit( saveDrawResultAndAudit(
id: string, id: string,

View file

@ -3,7 +3,8 @@ import {
CreateGiveawayInput, CreateGiveawayInput,
GiveawayWithRelations, GiveawayWithRelations,
GiveawaySummary, GiveawaySummary,
PaginatedParticipantsResult PaginatedParticipantsResult,
LockedSnapshotResult
} from './giveaway-repository'; } from './giveaway-repository';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant'; import { FilteredParticipant } from '../../core/types/participant';
@ -186,12 +187,15 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
id: string, id: string,
eligibleParticipants: FilteredParticipant[], eligibleParticipants: FilteredParticipant[],
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData> { ): Promise<LockedSnapshotResult> {
const gw = this.giveaways.get(id); const gw = this.giveaways.get(id);
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') { // Strict Single Lock Invariant: Only READY -> SNAPSHOT_LOCKED transition is permitted
throw new ConflictError(`Cannot lock snapshot in final status "${gw.status}"`); if (gw.status !== 'READY') {
throw new ConflictError(
`Cannot lock snapshot: giveaway "${id}" is in status "${gw.status}", but requires "READY"`
);
} }
if (eligibleParticipants.length === 0) { if (eligibleParticipants.length === 0) {
@ -222,15 +226,19 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
// Generate and lock cryptographic seed atomically with snapshot creation // Generate and lock cryptographic seed atomically with snapshot creation
const seed = generateCryptoSecureSeed(); const seed = generateCryptoSecureSeed();
const seedCommitment = computeSeedCommitment(seed);
gw.status = 'SNAPSHOT_LOCKED'; gw.status = 'SNAPSHOT_LOCKED';
gw.filterRules = rules; gw.filterRules = rules;
gw.latestSnapshot = snapshot; gw.latestSnapshot = snapshot;
gw.seed = seed; gw.seed = seed;
gw.seedCommitment = computeSeedCommitment(seed); gw.seedCommitment = seedCommitment;
gw.updatedAt = new Date().toISOString(); gw.updatedAt = new Date().toISOString();
return snapshot; return {
snapshot,
seedCommitment,
};
} }
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> { async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {

View file

@ -3,7 +3,8 @@ import {
CreateGiveawayInput, CreateGiveawayInput,
GiveawayWithRelations, GiveawayWithRelations,
GiveawaySummary, GiveawaySummary,
PaginatedParticipantsResult PaginatedParticipantsResult,
LockedSnapshotResult
} from './giveaway-repository'; } from './giveaway-repository';
import { prisma } from '../prisma'; import { prisma } from '../prisma';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
@ -358,12 +359,15 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
id: string, id: string,
eligibleParticipants: FilteredParticipant[], eligibleParticipants: FilteredParticipant[],
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData> { ): Promise<LockedSnapshotResult> {
const current = await this.getGiveawayById(id); const current = await this.getGiveawayById(id);
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') { // Strict Single Lock Invariant: Only READY -> SNAPSHOT_LOCKED transition is permitted
throw new ConflictError(`Cannot lock snapshot in final status "${current.status}"`); if (current.status !== 'READY') {
throw new ConflictError(
`Cannot lock snapshot: giveaway "${id}" is in status "${current.status}", but requires "READY"`
);
} }
if (eligibleParticipants.length === 0) { if (eligibleParticipants.length === 0) {
@ -375,14 +379,15 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
// Generate cryptographic seed for pre-commitment // Generate cryptographic seed and commitment
const seed = generateCryptoSecureSeed(); 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({ const updateRes = await tx.giveaway.updateMany({
where: { where: {
id, id,
status: { in: ['READY', 'SNAPSHOT_LOCKED'] }, status: 'READY',
}, },
data: { data: {
status: 'SNAPSHOT_LOCKED', status: 'SNAPSHOT_LOCKED',
@ -415,15 +420,18 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
}); });
return { return {
id: snapshot.id, snapshot: {
giveawayId: snapshot.giveawayId, id: snapshot.id,
version: snapshot.version, giveawayId: snapshot.giveawayId,
createdAt: snapshot.createdAt.toISOString(), version: snapshot.version,
eligibleParticipants, createdAt: snapshot.createdAt.toISOString(),
filterRulesSnapshot: rules, eligibleParticipants,
participantCount: snapshot.participantCount, filterRulesSnapshot: rules,
participantsSnapshotHash: snapshot.participantsSnapshotHash, participantCount: snapshot.participantCount,
conditionsHash: snapshot.conditionsHash, participantsSnapshotHash: snapshot.participantsSnapshotHash,
conditionsHash: snapshot.conditionsHash,
},
seedCommitment,
}; };
}); });
} catch (err: any) { } catch (err: any) {

View file

@ -41,7 +41,7 @@ describe('100-Draw Concurrency Regression & Mixed Race', () => {
organizerId: 'usr_conc_100', 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 // Launch 100 concurrent draw requests
const drawPromises = Array.from({ length: 100 }, async (_, index) => { const drawPromises = Array.from({ length: 100 }, async (_, index) => {

View file

@ -41,7 +41,7 @@ describe('Concurrency Double Draw Protection', () => {
organizerId: 'usr_conc_draw', 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 // 2. Launch 20 concurrent draw attempts
const concurrentDrawPromises = Array.from({ length: 20 }, async (_, index) => { const concurrentDrawPromises = Array.from({ length: 20 }, async (_, index) => {

View file

@ -78,7 +78,7 @@ describe('Repository Persistence & Lifecycle Scenario', () => {
expect(updatedGw.participants.length).toBe(3); expect(updatedGw.participants.length).toBe(3);
// 3. Create & Lock Snapshot // 3. Create & Lock Snapshot
const snapshot = await repo.createAndLockSnapshot( const { snapshot, seedCommitment } = await repo.createAndLockSnapshot(
gw.id, gw.id,
sampleParticipants, sampleParticipants,
DEFAULT_FILTER_RULES DEFAULT_FILTER_RULES
@ -88,6 +88,7 @@ describe('Repository Persistence & Lifecycle Scenario', () => {
expect(snapshot.participantCount).toBe(3); expect(snapshot.participantCount).toBe(3);
expect(snapshot.participantsSnapshotHash).toBeDefined(); expect(snapshot.participantsSnapshotHash).toBeDefined();
expect(snapshot.conditionsHash).toBeDefined(); expect(snapshot.conditionsHash).toBeDefined();
expect(seedCommitment).toBeDefined();
// Verify giveaway status transitioned to SNAPSHOT_LOCKED // Verify giveaway status transitioned to SNAPSHOT_LOCKED
const lockedGw = await repo.getGiveawayById(gw.id); const lockedGw = await repo.getGiveawayById(gw.id);

View file

@ -306,14 +306,16 @@ describe('Phase 2.4 — Seed Pre-Commit Gate (Seed Grinding Elimination)', () =>
expect(gw.seed).toBeNull(); expect(gw.seed).toBeNull();
expect(gw.seedCommitment).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(snapshot.id).toBeDefined();
expect(seedCommitment).toBeDefined();
const lockedGw = await repo.getGiveawayById(gw.id); const lockedGw = await repo.getGiveawayById(gw.id);
expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED'); expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED');
expect(lockedGw?.seed).toBeDefined(); expect(lockedGw?.seed).toBeDefined();
expect(lockedGw?.seed).toHaveLength(32); expect(lockedGw?.seed).toHaveLength(32);
expect(lockedGw?.seedCommitment).toBe(computeSeedCommitment(lockedGw!.seed!)); expect(lockedGw?.seedCommitment).toBe(computeSeedCommitment(lockedGw!.seed!));
expect(seedCommitment).toBe(lockedGw?.seedCommitment);
}); });
// ─── Test 7: Repository Driver Parity (Prisma repository mapping & seed commitment) ─── // ─── Test 7: Repository Driver Parity (Prisma repository mapping & seed commitment) ───

View file

@ -73,13 +73,13 @@ describe('DrawResult Snapshot Binding Regression Tests', () => {
}); });
// 2. Create snapshot V1 // 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); expect(snapshotV1.version).toBe(1);
const hashV1 = snapshotV1.participantsSnapshotHash; const hashV1 = snapshotV1.participantsSnapshotHash;
// 3. Unlock / simulate revision and create Snapshot V2 // 3. Unlock / simulate revision and create Snapshot V2
await repo.updateStatus(gw.id, 'READY'); 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, ...DEFAULT_FILTER_RULES,
requireComment: true, requireComment: true,
}); });

View file

@ -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);
});
});

View file

@ -54,7 +54,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
}); });
await GiveawayStore.updateParticipants(gw.id, threeParticipants); 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({ const result = executeDeterministicDrawV1({
giveawayId: gw.id, giveawayId: gw.id,
@ -90,7 +90,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
}); });
await GiveawayStore.updateParticipants(gw.id, threeParticipants); 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({ const result = executeDeterministicDrawV1({
giveawayId: gw.id, giveawayId: gw.id,
@ -126,7 +126,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
}); });
await GiveawayStore.updateParticipants(gw.id, threeParticipants); 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(() => expect(() =>
executeDeterministicDrawV1({ executeDeterministicDrawV1({