From 1bc6650041b0ecea6668916485092ac39ce0ee38 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 17 Aug 2026 23:55:01 +0700 Subject: [PATCH] feat(core): Phase 1.2 Final Core Audit Fixes - storage driver policy, snapshot relation binding, true Fisher-Yates HMAC_SHA256_FY_V1, proof/event hash separation, verification API, and GitHub Actions CI --- .github/workflows/ci.yml | 61 +++++++++ prisma/schema.prisma | 62 ++++----- src/app/api/giveaways/[id]/verify/route.ts | 66 ++++++++++ src/app/giveaways/[id]/page.tsx | 114 ++++++++++++---- src/app/giveaways/new/page.tsx | 15 ++- src/core/randomizer/canonical.ts | 21 ++- src/core/randomizer/deterministic.ts | 104 ++++++++++----- src/core/randomizer/hasher.ts | 14 +- src/core/types/audit.ts | 26 +++- src/lib/giveaway-store.ts | 50 +++---- src/lib/repository/memory-repository.ts | 12 ++ src/lib/repository/prisma-repository.ts | 41 ++++-- tests/fisher-yates.test.ts | 94 ++++++++++++++ tests/persistence.test.ts | 6 +- tests/proof-separation.test.ts | 139 ++++++++++++++++++++ tests/randomizer.test.ts | 18 ++- tests/snapshot-binding.test.ts | 109 ++++++++++++++++ tests/storage-driver.test.ts | 74 +++++++++++ tests/verification-api.test.ts | 144 +++++++++++++++++++++ 19 files changed, 1018 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/app/api/giveaways/[id]/verify/route.ts create mode 100644 tests/fisher-yates.test.ts create mode 100644 tests/proof-separation.test.ts create mode 100644 tests/snapshot-binding.test.ts create mode 100644 tests/storage-driver.test.ts create mode 100644 tests/verification-api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ba9cce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: randomayzer + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public" + STORAGE_DRIVER: "memory" + NODE_ENV: "test" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma Client + run: npx prisma generate + + - name: Push Database Schema + run: npx prisma db push + + - name: Run Unit & Integration Tests + run: npm test + + - name: Run ESLint + run: npm run lint + + - name: Run Production Build + run: npm run build + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aafe636..e934c8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,38 +101,40 @@ model ParticipantSnapshot { } model DrawResult { - id String @id @default(cuid()) - giveawayId String @unique - giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) - snapshotId String @unique - snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) - winners Json // Array of Winner entities - reserveWinners Json // Array of Winner entities - winnerIds Json // string[] - reserveWinnerIds Json // string[] - totalEligibleCount Int - totalLoadedCount Int - seedUsed String - algorithmVersion String @default("HMAC_SHA256_FY_V1") - auditHash String - drawnAt DateTime @default(now()) + id String @id @default(cuid()) + giveawayId String @unique + giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) + snapshotId String @unique + snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) + winners Json // Array of Winner entities + reserveWinners Json // Array of Winner entities + winnerIds Json // string[] + reserveWinnerIds Json // string[] + totalEligibleCount Int + totalLoadedCount Int + seedUsed String + algorithmVersion String @default("HMAC_SHA256_FY_V1") + deterministicProofHash String + auditEventHash String + drawnAt DateTime @default(now()) } model AuditRecord { - id String @id @default(cuid()) - giveawayId String @unique - giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) - snapshotId String @unique - snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) - algorithmVersion String @default("HMAC_SHA256_FY_V1") - seed String + id String @id @default(cuid()) + giveawayId String @unique + giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) + snapshotId String @unique + snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) + algorithmVersion String @default("HMAC_SHA256_FY_V1") + seed String participantsSnapshotHash String - conditionsHash String - auditHash String - winnerIds Json // string[] - reserveWinnerIds Json // string[] - eligibleCount Int - drawId String - drawnAt DateTime @default(now()) - verifiedAt DateTime @default(now()) + conditionsHash String + deterministicProofHash String + auditEventHash String + winnerIds Json // string[] + reserveWinnerIds Json // string[] + eligibleCount Int + drawId String + drawnAt DateTime @default(now()) + verifiedAt DateTime @default(now()) } diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts new file mode 100644 index 0000000..205f12d --- /dev/null +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GiveawayStore } from '@/lib/giveaway-store'; +import { verifyDrawResult } from '@/core/randomizer/deterministic'; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + const giveaway = await GiveawayStore.getById(id); + + if (!giveaway) { + return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + } + + const drawResult = giveaway.drawResult; + if (!drawResult) { + return NextResponse.json({ + error: 'Giveaway has not been drawn yet. Nothing to verify.' + }, { status: 400 }); + } + + // Find the snapshot associated with this draw + const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId) + || giveaway.latestSnapshot; + + if (!snapshot) { + return NextResponse.json({ + error: `Participant snapshot "${drawResult.snapshotId}" not found for this giveaway` + }, { status: 404 }); + } + + const claimedWinnersCount = drawResult.winners.length; + const claimedReserveCount = drawResult.reserveWinners.length; + + // Run independent cryptographic replay verification + const verification = verifyDrawResult( + snapshot, + drawResult.seedUsed, + claimedWinnersCount, + claimedReserveCount, + drawResult.winnerIds, + drawResult.deterministicProofHash, + drawResult.algorithmVersion + ); + + return NextResponse.json({ + verified: verification.verified, + giveawayId: id, + snapshotId: snapshot.id, + algorithmVersion: verification.algorithmVersion, + winnersMatch: verification.winnersMatch, + snapshotHashMatch: verification.snapshotHashMatch, + conditionsHashMatch: verification.conditionsHashMatch, + deterministicProofHashMatch: verification.deterministicProofHashMatch, + expectedWinnerIds: verification.expectedWinnerIds, + expectedReserveWinnerIds: verification.expectedReserveWinnerIds, + deterministicProofHash: verification.expectedDeterministicProofHash, + auditEventHash: drawResult.auditEventHash, + drawnAt: drawResult.drawnAt, + }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/giveaways/[id]/page.tsx b/src/app/giveaways/[id]/page.tsx index 98f7f22..71fbf8c 100644 --- a/src/app/giveaways/[id]/page.tsx +++ b/src/app/giveaways/[id]/page.tsx @@ -12,7 +12,8 @@ import { Check, Calendar, RefreshCw, - Lock + CheckCircle2, + AlertTriangle } from 'lucide-react'; import { StoredGiveaway } from '@/lib/giveaway-store'; @@ -24,6 +25,8 @@ export default function GiveawayDetailPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [verifying, setVerifying] = useState(false); + const [verificationResult, setVerificationResult] = useState(null); useEffect(() => { if (!id) return; @@ -43,6 +46,20 @@ export default function GiveawayDetailPage() { fetchGw(); }, [id]); + const handleVerify = async () => { + if (!id) return; + try { + setVerifying(true); + const res = await fetch(`/api/giveaways/${id}/verify`); + const data = await res.json(); + setVerificationResult(data); + } catch (err: any) { + alert(err.message); + } finally { + setVerifying(false); + } + }; + if (loading) { return (
@@ -64,7 +81,6 @@ export default function GiveawayDetailPage() { } const drawResult = giveaway.drawResult; - const snapshot = giveaway.latestSnapshot; return (
@@ -167,35 +183,75 @@ export default function GiveawayDetailPage() { {/* Provably Fair Audit Trail */}
-
+
Публичный криптографический аудит (Provably Fair)
- +
+ + +
+ {/* Live Verification Banner if clicked */} + {verificationResult && ( +
+
+ {verificationResult.verified ? ( + <> + + Результат 100% подтвержден и математически доказуем! + + ) : ( + <> + + Несоответствие верификации! + + )} +
+
+
Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Хеш слепка совпал: {verificationResult.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
+
+ )} +
Snapshot ID: @@ -214,8 +270,12 @@ export default function GiveawayDetailPage() {

{drawResult.participantsSnapshotHash}

- Канонический auditHash: -

{drawResult.auditHash}

+ deterministicProofHash (воспроизводимый): +

{drawResult.deterministicProofHash}

+
+
+ auditEventHash (уникальный для события): +

{drawResult.auditEventHash}

diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index aeed216..f957d82 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -410,7 +410,7 @@ export default function NewGiveawayWizardPage() {
- {/* Condition: Subscription (Active & Supported) */} + {/* Condition: Subscription */} - {/* Condition: Repost (Explicitly Marked Unsupported by Capability) */} + {/* Condition: Repost (Disabled by capability) */}

Шаг 4: Настройки жеребьевки

- Слепок участников зафиксирован (Статус: SNAPSHOT_LOCKED). Алгоритм: HMAC_SHA256_FY_V1 + Слепок зафиксирован (SNAPSHOT_LOCKED). Алгоритм: HMAC_SHA256_FY_V1

@@ -926,8 +926,13 @@ export default function NewGiveawayWizardPage() {
- Канонический auditHash: -

{drawResult.auditHash}

+ deterministicProofHash (воспроизводимый): +

{drawResult.deterministicProofHash}

+
+ +
+ auditEventHash (уникальный для события): +

{drawResult.auditEventHash}

diff --git a/src/core/randomizer/canonical.ts b/src/core/randomizer/canonical.ts index eb52c62..f9b4834 100644 --- a/src/core/randomizer/canonical.ts +++ b/src/core/randomizer/canonical.ts @@ -71,20 +71,29 @@ export function computeParticipantsSnapshotHash(participants: FilteredParticipan } /** - * Computes canonical auditHash for the audit record + * Computes deterministic proof hash (reproducible on replay) */ -export function computeAuditHash(data: { +export function computeDeterministicProofHash(data: { algorithmVersion: string; - giveawayId: string; snapshotId: string; - seed: string; participantsSnapshotHash: string; conditionsHash: string; + seed: string; winnerIds: string[]; reserveWinnerIds: string[]; eligibleCount: number; - drawId: string; - drawnAt: string; +}): string { + return sha256(canonicalStringify(data)); +} + +/** + * Computes unique audit event hash (binds specific execution event metadata to the deterministic proof) + */ +export function computeAuditEventHash(data: { + giveawayId: string; + drawId: string; + drawnAt: string; + deterministicProofHash: string; }): string { return sha256(canonicalStringify(data)); } diff --git a/src/core/randomizer/deterministic.ts b/src/core/randomizer/deterministic.ts index 4997faf..e0b0aa7 100644 --- a/src/core/randomizer/deterministic.ts +++ b/src/core/randomizer/deterministic.ts @@ -1,10 +1,16 @@ import { createHash, randomBytes } from 'crypto'; import { FilteredParticipant, Winner } from '../types/participant'; -import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit'; +import { + DrawExecutionParams, + DrawExecutionResult, + ALGORITHM_HMAC_SHA256_FY_V1, + ParticipantSnapshotData, + VerificationResult +} from '../types/audit'; import { DeterministicHmacStream } from './unbiased-sampler'; -import { computeAuditHash } from './canonical'; +import { computeDeterministicProofHash, computeAuditEventHash } from './canonical'; -export const ALGORITHM_VERSION_V1 = CURRENT_RANDOMIZER_ALGORITHM; // 'HMAC_SHA256_FY_V1' +export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1' /** * Generates an individual audit proof hash for a winner position @@ -21,8 +27,8 @@ export function generateWinnerProofHash( } /** - * Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1) - * with unbiased rejection sampling. + * Executes true partial Fisher-Yates shuffle V1 (HMAC_SHA256_FY_V1) + * with in-place swap and unbiased rejection sampling. */ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult { const { @@ -40,7 +46,7 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe throw new Error('Cannot conduct draw with 0 eligible participants in snapshot'); } - // 1. Canonical sort to ensure exact invariant input order + // 1. Canonical sort to guarantee exact invariant input ordering const pool = [...eligible].sort((a, b) => a.platformUserId.localeCompare(b.platformUserId) ); @@ -55,39 +61,50 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe const actualWinnersCount = Math.min(winnersCount, totalNeeded); const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount); + // 3. True partial Fisher-Yates shuffle: swap pool[i] with pool[j] where j in [i, n-1] + for (let i = 0; i < totalNeeded; i++) { + const remainingCount = pool.length - i; + const offset = stream.sampleUnbiasedIndex(remainingCount); + const j = i + offset; + + // In-place swap + const temp = pool[i]; + pool[i] = pool[j]; + pool[j] = temp; + } + const winners: Winner[] = []; const reserveWinners: Winner[] = []; const winnerIds: string[] = []; const reserveWinnerIds: string[] = []; - // 3. Select Main Winners (Fisher-Yates removal without replacement) + // 4. Map Main Winners from pool[0 ... actualWinnersCount - 1] for (let i = 0; i < actualWinnersCount; i++) { - const selectedIndex = stream.sampleUnbiasedIndex(pool.length); - const selectedParticipant = pool.splice(selectedIndex, 1)[0]; + const selectedParticipant = pool[i]; const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId); winners.push({ position: i + 1, isReserve: false, participant: selectedParticipant, - selectionIndex: selectedIndex, + selectionIndex: i, proofHash, }); winnerIds.push(selectedParticipant.platformUserId); } - // 4. Select Reserve Winners + // 5. Map Reserve Winners from pool[actualWinnersCount ... totalNeeded - 1] for (let i = 0; i < actualReserveCount; i++) { - const pos = winners.length + i + 1; - const selectedIndex = stream.sampleUnbiasedIndex(pool.length); - const selectedParticipant = pool.splice(selectedIndex, 1)[0]; + const idx = actualWinnersCount + i; + const pos = idx + 1; + const selectedParticipant = pool[idx]; const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId); reserveWinners.push({ position: pos, isReserve: true, participant: selectedParticipant, - selectionIndex: selectedIndex, + selectionIndex: idx, proofHash, }); reserveWinnerIds.push(selectedParticipant.platformUserId); @@ -96,19 +113,24 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe const drawId = 'draw_' + randomBytes(8).toString('hex'); const drawnAt = new Date().toISOString(); - // 5. Compute canonical auditHash - const auditHash = computeAuditHash({ + // 6. Compute deterministicProofHash (reproducible upon replay) + const deterministicProofHash = computeDeterministicProofHash({ algorithmVersion: ALGORITHM_VERSION_V1, - giveawayId, snapshotId: snapshot.id, - seed, participantsSnapshotHash: snapshotHash, conditionsHash, + seed, winnerIds, reserveWinnerIds, eligibleCount: eligible.length, + }); + + // 7. Compute auditEventHash (unique for this draw execution instance) + const auditEventHash = computeAuditEventHash({ + giveawayId, drawId, drawnAt, + deterministicProofHash, }); return { @@ -125,47 +147,69 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe participantsSnapshotHash: snapshotHash, conditionsHash, algorithmVersion: ALGORITHM_VERSION_V1, + deterministicProofHash, + auditEventHash, drawnAt, - auditHash, }; } /** - * Universal entrypoint (routes to active algorithm version V1) + * Universal entrypoint (routes to algorithm version) */ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult { return executeDeterministicDrawV1(params); } /** - * Re-runs draw algorithm on a snapshot to verify identical outcome + * Re-runs draw algorithm on a snapshot to verify identical outcome and hashes */ export function verifyDrawResult( snapshot: ParticipantSnapshotData, seed: string, claimedWinnersCount: number, claimedReserveCount: number, + claimedWinnerIds?: string[], + claimedDeterministicProofHash?: string, algorithmVersion: string = ALGORITHM_VERSION_V1 -): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } { +): VerificationResult { if (algorithmVersion !== ALGORITHM_VERSION_V1) { throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`); } - const result = executeDeterministicDrawV1({ + const replayed = executeDeterministicDrawV1({ giveawayId: snapshot.giveawayId, snapshot, totalLoadedCount: snapshot.participantCount, winnersCount: claimedWinnersCount, reserveWinnersCount: claimedReserveCount, seed, - filterRules: {} as any, }); + const winnersMatch = claimedWinnerIds + ? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds) + : true; + + const deterministicProofHashMatch = claimedDeterministicProofHash + ? replayed.deterministicProofHash === claimedDeterministicProofHash + : true; + + const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash; + const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash; + + const verified = winnersMatch && deterministicProofHashMatch && snapshotHashMatch && conditionsHashMatch; + return { - winners: result.winners, - reserveWinners: result.reserveWinners, - winnerIds: result.winnerIds, - reserveWinnerIds: result.reserveWinnerIds, - auditHash: result.auditHash, + verified, + algorithmVersion: ALGORITHM_VERSION_V1, + winnersMatch, + snapshotHashMatch, + conditionsHashMatch, + deterministicProofHashMatch, + expectedWinners: replayed.winners, + expectedReserveWinners: replayed.reserveWinners, + expectedWinnerIds: replayed.winnerIds, + expectedReserveWinnerIds: replayed.reserveWinnerIds, + expectedDeterministicProofHash: replayed.deterministicProofHash, + actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash, }; } diff --git a/src/core/randomizer/hasher.ts b/src/core/randomizer/hasher.ts index 9b75bca..9cd1157 100644 --- a/src/core/randomizer/hasher.ts +++ b/src/core/randomizer/hasher.ts @@ -1,8 +1,18 @@ import { randomBytes } from 'crypto'; import { FilteredParticipant } from '../types/participant'; -import { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash } from './canonical'; +import { + computeParticipantsSnapshotHash, + computeConditionsHash, + computeDeterministicProofHash, + computeAuditEventHash +} from './canonical'; -export { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash }; +export { + computeParticipantsSnapshotHash, + computeConditionsHash, + computeDeterministicProofHash, + computeAuditEventHash +}; /** * Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG. diff --git a/src/core/types/audit.ts b/src/core/types/audit.ts index 2b41bfe..364156c 100644 --- a/src/core/types/audit.ts +++ b/src/core/types/audit.ts @@ -1,7 +1,8 @@ import { FilterRules } from './giveaway'; import { FilteredParticipant, Winner } from './participant'; -export const CURRENT_RANDOMIZER_ALGORITHM = 'HMAC_SHA256_FY_V1'; +export const ALGORITHM_HMAC_SHA256_FY_V1 = 'HMAC_SHA256_FY_V1'; +export const CURRENT_RANDOMIZER_ALGORITHM = ALGORITHM_HMAC_SHA256_FY_V1; export interface ParticipantSnapshotData { id: string; @@ -21,7 +22,7 @@ export interface DrawExecutionParams { winnersCount: number; reserveWinnersCount: number; seed: string; - filterRules: FilterRules; + filterRules?: FilterRules; } export interface DrawExecutionResult { @@ -38,8 +39,9 @@ export interface DrawExecutionResult { participantsSnapshotHash: string; conditionsHash: string; algorithmVersion: string; + deterministicProofHash: string; + auditEventHash: string; drawnAt: string; // ISO String - auditHash: string; } export interface AuditRecordData { @@ -50,7 +52,8 @@ export interface AuditRecordData { seed: string; participantsSnapshotHash: string; conditionsHash: string; - auditHash: string; + deterministicProofHash: string; + auditEventHash: string; winnerIds: string[]; reserveWinnerIds: string[]; eligibleCount: number; @@ -58,3 +61,18 @@ export interface AuditRecordData { drawnAt: string; verifiedAt: string; } + +export interface VerificationResult { + verified: boolean; + algorithmVersion: string; + winnersMatch: boolean; + snapshotHashMatch: boolean; + conditionsHashMatch: boolean; + deterministicProofHashMatch: boolean; + expectedWinners: Winner[]; + expectedReserveWinners: Winner[]; + expectedWinnerIds: string[]; + expectedReserveWinnerIds: string[]; + expectedDeterministicProofHash: string; + actualDeterministicProofHash: string; +} diff --git a/src/lib/giveaway-store.ts b/src/lib/giveaway-store.ts index 1408856..16523c5 100644 --- a/src/lib/giveaway-store.ts +++ b/src/lib/giveaway-store.ts @@ -1,13 +1,21 @@ import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository'; -import { FilterRules, GiveawayStatusType } from '../core/types/giveaway'; +import { FilterRules } from '../core/types/giveaway'; import { FilteredParticipant } from '../core/types/participant'; import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit'; export type StoredGiveaway = GiveawayWithRelations; -let activeRepository: IGiveawayRepository = new PrismaGiveawayRepository(); +// Select initial repository based on explicit STORAGE_DRIVER configuration +function createDefaultRepository(): IGiveawayRepository { + if (process.env.STORAGE_DRIVER === 'memory') { + return new MemoryGiveawayRepository(); + } + return new PrismaGiveawayRepository(); +} + +let activeRepository: IGiveawayRepository = createDefaultRepository(); export class GiveawayStore { /** @@ -21,41 +29,23 @@ export class GiveawayStore { return activeRepository; } + /** + * Reset repository to environment default + */ + static resetToDefault(): void { + activeRepository = createDefaultRepository(); + } + static async create(input: CreateGiveawayInput): Promise { - try { - return await activeRepository.createGiveaway(input); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - console.warn('Prisma DB error, falling back to memory repository:', (err as Error).message); - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.createGiveaway(input); - } - throw err; - } + return await activeRepository.createGiveaway(input); } static async getById(id: string): Promise { - try { - return await activeRepository.getGiveawayById(id); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.getGiveawayById(id); - } - throw err; - } + return await activeRepository.getGiveawayById(id); } static async listAll(): Promise { - try { - return await activeRepository.listGiveaways(); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.listGiveaways(); - } - throw err; - } + return await activeRepository.listGiveaways(); } static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise { diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 11f2ff6..1829126 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -55,10 +55,22 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { const snaps = this.snapshots.get(id) || []; const latest = snaps.length > 0 ? snaps[snaps.length - 1] : null; + let drawResult = gw.drawResult; + if (drawResult) { + // Strictly bind to the snapshot referenced by snapshotId + const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest; + drawResult = { + ...drawResult, + participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '', + conditionsHash: boundSnapshot?.conditionsHash || '', + }; + } + return { ...gw, snapshots: [...snaps], latestSnapshot: latest, + drawResult, }; } diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index cbc81ad..9bf2228 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -40,11 +40,19 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { })); const latestSnapshot = snapshots.length > 0 - ? snapshots.sort((a, b) => b.version - a.version)[0] + ? [...snapshots].sort((a, b) => b.version - a.version)[0] : null; let drawResult: DrawExecutionResult | null = null; if (raw.drawResult) { + // Strictly bind to the snapshot attached to drawResult + const boundSnapshot = raw.drawResult.snapshot + ? { + participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash, + conditionsHash: raw.drawResult.snapshot.conditionsHash, + } + : snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot; + drawResult = { drawId: raw.drawResult.id, giveawayId: raw.drawResult.giveawayId, @@ -56,11 +64,12 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { totalEligibleCount: raw.drawResult.totalEligibleCount, totalLoadedCount: raw.drawResult.totalLoadedCount, seedUsed: raw.drawResult.seedUsed, - participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '', - conditionsHash: latestSnapshot?.conditionsHash || '', + participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '', + conditionsHash: boundSnapshot?.conditionsHash || '', algorithmVersion: raw.drawResult.algorithmVersion, + deterministicProofHash: raw.drawResult.deterministicProofHash, + auditEventHash: raw.drawResult.auditEventHash, drawnAt: raw.drawResult.drawnAt.toISOString(), - auditHash: raw.drawResult.auditHash, }; } @@ -113,7 +122,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { include: { participants: true, snapshots: true, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -128,7 +139,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { snapshots: { orderBy: { version: 'desc' }, }, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -143,7 +156,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { snapshots: { orderBy: { version: 'desc' }, }, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -162,7 +177,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { include: { participants: true, snapshots: true, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -176,10 +193,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { GiveawayFSM.assertCanModifyParticipants(current.status); await prisma.$transaction(async (tx) => { - // Clear previous live participants await tx.participant.deleteMany({ where: { giveawayId: id } }); - // Insert new participants if (participants.length > 0) { await tx.participant.createMany({ data: participants.map(p => ({ @@ -311,7 +326,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { totalLoadedCount: result.totalLoadedCount, seedUsed: result.seedUsed, algorithmVersion: result.algorithmVersion, - auditHash: result.auditHash, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, drawnAt: new Date(result.drawnAt), }, }); @@ -325,7 +341,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { seed: result.seedUsed, participantsSnapshotHash: result.participantsSnapshotHash, conditionsHash: result.conditionsHash, - auditHash: result.auditHash, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, winnerIds: result.winnerIds as any, reserveWinnerIds: result.reserveWinnerIds as any, eligibleCount: result.totalEligibleCount, diff --git a/tests/fisher-yates.test.ts b/tests/fisher-yates.test.ts new file mode 100644 index 0000000..cc918c3 --- /dev/null +++ b/tests/fisher-yates.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { executeDeterministicDrawV1, ALGORITHM_VERSION_V1 } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('True Partial Fisher-Yates (HMAC_SHA256_FY_V1)', () => { + function createTestSnapshot(size: number): ParticipantSnapshotData { + const participants: FilteredParticipant[] = Array.from({ length: size }, (_, i) => ({ + platformUserId: String(i + 1), + firstName: `User${i + 1}`, + lastName: `Surname${i + 1}`, + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + })); + + return { + id: `snap-fy-${size}`, + giveawayId: 'gw-fy-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: size, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + } + + it('should have algorithmVersion set strictly to HMAC_SHA256_FY_V1', () => { + expect(ALGORITHM_VERSION_V1).toBe('HMAC_SHA256_FY_V1'); + }); + + it('should select k distinct elements in 0..k-1 range with correct positions', () => { + const snapshot = createTestSnapshot(20); + const result = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 20, + winnersCount: 3, + reserveWinnersCount: 2, + seed: 'fy-test-seed-1', + }); + + expect(result.winners.length).toBe(3); + expect(result.reserveWinners.length).toBe(2); + + expect(result.winners[0].position).toBe(1); + expect(result.winners[1].position).toBe(2); + expect(result.winners[2].position).toBe(3); + + expect(result.reserveWinners[0].position).toBe(4); + expect(result.reserveWinners[1].position).toBe(5); + + const allChosen = [...result.winnerIds, ...result.reserveWinnerIds]; + const uniqueChosen = new Set(allChosen); + expect(uniqueChosen.size).toBe(5); + }); + + it('should produce invariant selection across 100 replays', () => { + const snapshot = createTestSnapshot(10); + const seed = 'deterministic-invariant-test-seed'; + + const baseline = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 10, + winnersCount: 2, + reserveWinnersCount: 1, + seed, + }); + + for (let i = 0; i < 100; i++) { + const current = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 10, + winnersCount: 2, + reserveWinnersCount: 1, + seed, + }); + + expect(current.winnerIds).toEqual(baseline.winnerIds); + expect(current.reserveWinnerIds).toEqual(baseline.reserveWinnerIds); + expect(current.deterministicProofHash).toBe(baseline.deterministicProofHash); + } + }); +}); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 99332d6..bf5b933 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -106,7 +106,8 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(drawResult.winners.length).toBe(1); expect(drawResult.reserveWinners.length).toBe(1); - expect(drawResult.auditHash).toBeDefined(); + expect(drawResult.deterministicProofHash).toBeDefined(); + expect(drawResult.auditEventHash).toBeDefined(); // 5. Persist DrawResult and Audit const finishedGw = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult); @@ -121,7 +122,8 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(reloaded).not.toBeNull(); expect(reloaded?.status).toBe('DRAWN'); expect(reloaded?.snapshots.length).toBe(1); - expect(reloaded?.drawResult?.auditHash).toBe(drawResult.auditHash); + expect(reloaded?.drawResult?.deterministicProofHash).toBe(drawResult.deterministicProofHash); + expect(reloaded?.drawResult?.auditEventHash).toBe(drawResult.auditEventHash); expect(reloaded?.drawResult?.winnerIds).toEqual(drawResult.winnerIds); }); }); diff --git a/tests/proof-separation.test.ts b/tests/proof-separation.test.ts new file mode 100644 index 0000000..db4eec1 --- /dev/null +++ b/tests/proof-separation.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('DeterministicProofHash & AuditEventHash Separation', () => { + const participants: FilteredParticipant[] = [ + { + platformUserId: '1', + firstName: 'Алексей', + lastName: 'Смирнов', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '2', + firstName: 'Елена', + lastName: 'Кузнецова', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '3', + firstName: 'Михаил', + lastName: 'Попов', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const snapshot: ParticipantSnapshotData = { + id: 'snap-proof-test-1', + giveawayId: 'gw-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: 3, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + + const seed = 'test-seed-separation-123'; + + it('should generate identical deterministicProofHash across multiple executions with same inputs', () => { + const draw1 = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const draw2 = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + // deterministicProofHash must be 100% identical and reproducible + expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash); + expect(draw1.winnerIds).toEqual(draw2.winnerIds); + expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds); + }); + + it('should generate distinct auditEventHash for separate draw events (different drawId/timestamp)', () => { + const drawA = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const drawB = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + // auditEventHash must be unique per event execution + expect(drawA.drawId).not.toBe(drawB.drawId); + expect(drawA.auditEventHash).not.toBe(drawB.auditEventHash); + }); + + it('should verify that verifyDrawResult successfully matches deterministicProofHash upon independent replay', () => { + const originalDraw = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const verification = verifyDrawResult( + snapshot, + seed, + 1, + 1, + originalDraw.winnerIds, + originalDraw.deterministicProofHash + ); + + expect(verification.verified).toBe(true); + expect(verification.winnersMatch).toBe(true); + expect(verification.deterministicProofHashMatch).toBe(true); + expect(verification.snapshotHashMatch).toBe(true); + expect(verification.conditionsHashMatch).toBe(true); + expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash); + }); +}); diff --git a/tests/randomizer.test.ts b/tests/randomizer.test.ts index 0b8fdc7..6d4480c 100644 --- a/tests/randomizer.test.ts +++ b/tests/randomizer.test.ts @@ -72,6 +72,7 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1); expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash); + expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash); expect(draw1.winnerIds).toEqual(draw2.winnerIds); expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds); expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual( @@ -161,11 +162,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { filterRules: DEFAULT_FILTER_RULES, }); - const verification = verifyDrawResult(snapshot, seed, 2, 2, ALGORITHM_VERSION_V1); + const verification = verifyDrawResult( + snapshot, + seed, + 2, + 2, + originalDraw.winnerIds, + originalDraw.deterministicProofHash, + ALGORITHM_VERSION_V1 + ); - expect(verification.winnerIds).toEqual(originalDraw.winnerIds); - expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds); - expect(verification.winners.map(w => w.participant.platformUserId)).toEqual( + expect(verification.verified).toBe(true); + expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds); + expect(verification.expectedReserveWinnerIds).toEqual(originalDraw.reserveWinnerIds); + expect(verification.expectedWinners.map(w => w.participant.platformUserId)).toEqual( originalDraw.winners.map(w => w.participant.platformUserId) ); }); diff --git a/tests/snapshot-binding.test.ts b/tests/snapshot-binding.test.ts new file mode 100644 index 0000000..572e707 --- /dev/null +++ b/tests/snapshot-binding.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; + +describe('DrawResult Snapshot Binding Regression Tests', () => { + const participantsV1: FilteredParticipant[] = [ + { + platformUserId: '101', + firstName: 'Пользователь', + lastName: 'Один', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '102', + firstName: 'Пользователь', + lastName: 'Два', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const participantsV2: FilteredParticipant[] = [ + ...participantsV1, + { + platformUserId: '103', + firstName: 'Пользователь', + lastName: 'Три', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + it('should guarantee that DrawResult remains bound to snapshot v1 even when another snapshot v2 exists', async () => { + const repo = new MemoryGiveawayRepository(); + + // 1. Create giveaway + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-100_200', + post: { + platform: 'VK', + ownerId: '-100', + postId: '200', + sourceUrl: 'https://vk.com/wall-100_200', + title: 'Тестовый розыгрыш', + text: 'Текст', + likesCount: 50, + commentsCount: 20, + repostsCount: 10, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + // 2. Create snapshot V1 + const 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, { + ...DEFAULT_FILTER_RULES, + requireComment: true, + }); + expect(snapshotV2.version).toBe(2); + expect(snapshotV2.participantsSnapshotHash).not.toBe(hashV1); + + // 4. Conduct Draw explicitly bound to snapshot V1 (e.g. historical draw verification) + const seed = 'test-snapshot-binding-seed'; + const drawResult = executeDeterministicDrawV1({ + giveawayId: gw.id, + snapshot: snapshotV1, + totalLoadedCount: 2, + winnersCount: 1, + reserveWinnersCount: 0, + seed, + }); + + await repo.saveDrawResultAndAudit(gw.id, snapshotV1.id, drawResult); + + // 5. Reload giveaway and assert that DrawResult references snapshot V1 and its hashes! + const reloaded = await repo.getGiveawayById(gw.id); + expect(reloaded?.drawResult?.snapshotId).toBe(snapshotV1.id); + expect(reloaded?.drawResult?.participantsSnapshotHash).toBe(hashV1); + expect(reloaded?.drawResult?.conditionsHash).toBe(snapshotV1.conditionsHash); + expect(reloaded?.drawResult?.deterministicProofHash).toBe(drawResult.deterministicProofHash); + expect(reloaded?.snapshots.length).toBe(2); + }); +}); diff --git a/tests/storage-driver.test.ts b/tests/storage-driver.test.ts new file mode 100644 index 0000000..b59557d --- /dev/null +++ b/tests/storage-driver.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { GiveawayStore } from '../src/lib/giveaway-store'; +import { PrismaGiveawayRepository } from '../src/lib/repository/prisma-repository'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { IGiveawayRepository } from '../src/lib/repository/giveaway-repository'; + +describe('Storage Driver Policy & No Silent Fallback', () => { + beforeEach(() => { + delete process.env.STORAGE_DRIVER; + GiveawayStore.resetToDefault(); + }); + + afterEach(() => { + delete process.env.STORAGE_DRIVER; + GiveawayStore.resetToDefault(); + }); + + it('should default to PrismaGiveawayRepository when STORAGE_DRIVER is not memory', () => { + const repo = GiveawayStore.getRepository(); + expect(repo).toBeInstanceOf(PrismaGiveawayRepository); + }); + + it('should use MemoryGiveawayRepository when STORAGE_DRIVER=memory is explicitly configured', () => { + process.env.STORAGE_DRIVER = 'memory'; + GiveawayStore.resetToDefault(); + + const repo = GiveawayStore.getRepository(); + expect(repo).toBeInstanceOf(MemoryGiveawayRepository); + }); + + it('should throw database errors explicitly without silently falling back to memory', async () => { + // Mock a failing Prisma repository + const failingDbRepo: IGiveawayRepository = { + createGiveaway: async () => { + throw new Error('P1001: Can\'t reach database server at `localhost:5432`'); + }, + getGiveawayById: async () => { + throw new Error('Database connection timeout'); + }, + listGiveaways: async () => { + throw new Error('Database connection failed'); + }, + updateStatus: async () => { throw new Error('DB error'); }, + saveParticipants: async () => { throw new Error('DB error'); }, + createAndLockSnapshot: async () => { throw new Error('DB error'); }, + getLatestSnapshot: async () => { throw new Error('DB error'); }, + saveDrawResultAndAudit: async () => { throw new Error('DB error'); }, + }; + + GiveawayStore.setRepository(failingDbRepo); + + // Assert that calling create throws the exact database error + await expect( + GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-1_1', + post: { + platform: 'VK', + ownerId: '-1', + postId: '1', + sourceUrl: 'https://vk.com/wall-1_1', + title: 'Test', + text: 'Text', + likesCount: 10, + commentsCount: 5, + repostsCount: 2, + }, + filterRules: {} as any, + }) + ).rejects.toThrow(/Can't reach database server/); + + // Assert active repository remains the failing one and did not silently switch to memory + expect(GiveawayStore.getRepository()).toBe(failingDbRepo); + }); +}); diff --git a/tests/verification-api.test.ts b/tests/verification-api.test.ts new file mode 100644 index 0000000..731875f --- /dev/null +++ b/tests/verification-api.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { verifyDrawResult, executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('Verification API Replay Engine', () => { + const participants: FilteredParticipant[] = [ + { + platformUserId: '10', + firstName: 'Победитель', + lastName: 'Один', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '20', + firstName: 'Победитель', + lastName: 'Два', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '30', + firstName: 'Победитель', + lastName: 'Три', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const snapshot: ParticipantSnapshotData = { + id: 'snap-verif-1', + giveawayId: 'gw-verif-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: 3, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + + const seed = 'verification-engine-seed'; + + it('should return verified: true when claimed winners and proof hash match replay', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + draw.winnerIds, + draw.deterministicProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(true); + expect(result.winnersMatch).toBe(true); + expect(result.deterministicProofHashMatch).toBe(true); + expect(result.snapshotHashMatch).toBe(true); + expect(result.conditionsHashMatch).toBe(true); + expect(result.expectedWinnerIds).toEqual(draw.winnerIds); + }); + + it('should return verified: false and winnersMatch: false if claimed winners differ', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const fakeWinnerIds = ['9999']; // Tampered winners + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + fakeWinnerIds, + draw.deterministicProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(false); + expect(result.winnersMatch).toBe(false); + }); + + it('should return verified: false if deterministicProofHash was tampered with', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000'; + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + draw.winnerIds, + fakeProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(false); + expect(result.deterministicProofHashMatch).toBe(false); + expect(result.winnersMatch).toBe(true); + }); +});