fix(snapshot): Phase 2.4.1 enforce atomic snapshot lock, single lock invariant and direct seed commitment binding
This commit is contained in:
parent
78151572bd
commit
3279f287fb
14 changed files with 457 additions and 39 deletions
|
|
@ -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).
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ParticipantSnapshotData> {
|
||||
): Promise<LockedSnapshotResult> {
|
||||
return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ export interface CreateGiveawayInput {
|
|||
organizerId: string;
|
||||
}
|
||||
|
||||
export interface LockedSnapshotResult {
|
||||
snapshot: ParticipantSnapshotData;
|
||||
seedCommitment: string;
|
||||
}
|
||||
|
||||
export interface IGiveawayRepository {
|
||||
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
|
||||
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
|
||||
|
|
@ -90,7 +95,7 @@ export interface IGiveawayRepository {
|
|||
id: string,
|
||||
eligibleParticipants: FilteredParticipant[],
|
||||
rules: FilterRules
|
||||
): Promise<ParticipantSnapshotData>;
|
||||
): Promise<LockedSnapshotResult>;
|
||||
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
|
||||
saveDrawResultAndAudit(
|
||||
id: string,
|
||||
|
|
|
|||
|
|
@ -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<ParticipantSnapshotData> {
|
||||
): Promise<LockedSnapshotResult> {
|
||||
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<ParticipantSnapshotData | null> {
|
||||
|
|
|
|||
|
|
@ -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<ParticipantSnapshotData> {
|
||||
): Promise<LockedSnapshotResult> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) ───
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
270
tests/snapshot-seed-atomicity.test.ts
Normal file
270
tests/snapshot-seed-atomicity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue