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 unlockPost } from '../src/app/api/giveaways/[id]/unlock/route'; import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route'; import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route'; import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/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 { computeSeedCommitment } from '../src/core/randomizer/hasher'; describe('Task 04 — Snapshot Unlock Gate (SNAPSHOT_LOCKED -> READY)', () => { const organizerUser = { id: 'usr_unlock_org_1', vkUserId: '777111' }; const attackerUser = { id: 'usr_unlock_attacker', vkUserId: '666222' }; let sessionCookie: string; let attackerCookie: string; const testParticipants: FilteredParticipant[] = Array.from({ length: 30 }, (_, i) => ({ platformUserId: `${3000 + i}`, firstName: `User${i}`, lastName: `Unlock${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}`; const attackerSessionId = await defaultSessionStore.createSession(attackerUser); attackerCookie = `${SESSION_COOKIE_NAME}=${attackerSessionId}`; }); async function createReadyGiveaway() { const gw = await GiveawayStore.create({ sourceUrl: 'https://vk.com/wall-44556677_100', post: { platform: 'VK', ownerId: '-44556677', postId: '100', sourceUrl: 'https://vk.com/wall-44556677_100', title: 'Unlock Test Post', text: 'Test description', likesCount: 30, commentsCount: 0, repostsCount: 0, }, filterRules: DEFAULT_FILTER_RULES, organizerId: organizerUser.id, }); await GiveawayStore.updateParticipants(gw.id, testParticipants); return gw; } // ─── Test 1: Full Lifecycle (Lock -> Unlock -> Re-import/Modify -> Re-Lock -> Draw) ─── it('full lifecycle: lock -> unlock -> modify rules -> re-lock -> draw succeeds', async () => { const gw = await createReadyGiveaway(); // 1. Initial Lock const lockReq1 = 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 lockRes1 = await snapshotPost(lockReq1, { params: { id: gw.id } }); expect(lockRes1.status).toBe(200); const lockData1 = await lockRes1.json(); expect(lockData1.status).toBe('SNAPSHOT_LOCKED'); expect(lockData1.snapshot.version).toBe(1); const commitment1 = lockData1.seedCommitment; // 2. Unlock const unlockReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, }, }); const unlockRes = await unlockPost(unlockReq, { params: { id: gw.id } }); expect(unlockRes.status).toBe(200); const unlockData = await unlockRes.json(); expect(unlockData.success).toBe(true); expect(unlockData.status).toBe('READY'); expect(unlockData.seedCommitment).toBeNull(); // Verify stored state in DB const storedAfterUnlock = await GiveawayStore.getById(gw.id); expect(storedAfterUnlock?.status).toBe('READY'); expect(storedAfterUnlock?.seed).toBeNull(); expect(storedAfterUnlock?.seedCommitment).toBeNull(); // 3. Modify Rules / Participants while in READY const modifiedRules = { ...DEFAULT_FILTER_RULES, requireComment: false }; const partReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/participants`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, }, body: JSON.stringify({ filterRules: modifiedRules }), }); const partRes = await participantsPost(partReq, { params: { id: gw.id } }); expect(partRes.status).toBe(200); // 4. Re-lock snapshot with new version const lockReq2 = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, }, body: JSON.stringify({ filterRules: modifiedRules }), }); const lockRes2 = await snapshotPost(lockReq2, { params: { id: gw.id } }); expect(lockRes2.status).toBe(200); const lockData2 = await lockRes2.json(); expect(lockData2.status).toBe('SNAPSHOT_LOCKED'); expect(lockData2.snapshot.version).toBe(2); const commitment2 = lockData2.seedCommitment; // 5. Seeds & commitments before and after unlock are distinct expect(commitment2).not.toBe(commitment1); // 6. Draw succeeds on version 2 snapshot 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(); expect(drawData.success).toBe(true); expect(drawData.drawResult.snapshotId).toBe(lockData2.snapshot.id); expect(createHash('sha256').update(drawData.drawResult.seedUsed).digest('hex')).toBe(commitment2); }); // ─── Test 2: Unlock from Terminal State (DRAWN) -> 409 Conflict ────────────── it('unlock from DRAWN status returns 409 Conflict and preserves draw result', async () => { const gw = await createReadyGiveaway(); // Lock and Draw const lockReq = 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 }), }); await snapshotPost(lockReq, { params: { id: gw.id } }); 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); // Attempt Unlock on DRAWN giveaway -> 409 const unlockReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie }, }); const unlockRes = await unlockPost(unlockReq, { params: { id: gw.id } }); expect(unlockRes.status).toBe(409); // Verify status and drawResult are intact const stored = await GiveawayStore.getById(gw.id); expect(stored?.status).toBe('DRAWN'); expect(stored?.drawResult).toBeDefined(); }); // ─── Test 3: Unlock from READY -> 409 Conflict ────────────────────────────── it('unlock when already in READY status returns 409 Conflict', async () => { const gw = await createReadyGiveaway(); const unlockReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie }, }); const unlockRes = await unlockPost(unlockReq, { params: { id: gw.id } }); expect(unlockRes.status).toBe(409); }); // ─── Test 4: Ownership Protection (IDOR) -> 403 Forbidden ─────────────────── it('unlock of another organizer giveaway returns 403 Forbidden', async () => { const gw = await createReadyGiveaway(); // Lock as owner const lockReq = 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 }), }); await snapshotPost(lockReq, { params: { id: gw.id } }); // Attacker attempts to unlock const attackUnlockReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: attackerCookie }, }); const attackUnlockRes = await unlockPost(attackUnlockReq, { params: { id: gw.id } }); expect(attackUnlockRes.status).toBe(403); // Unauthenticated attempt -> 401 const unauthReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); const unauthRes = await unlockPost(unauthReq, { params: { id: gw.id } }); expect(unauthRes.status).toBe(401); }); // ─── Test 5: Concurrent Unlock Requests (Exactly 1 Succeeds) ───────────────── it('concurrent unlock requests: exactly 1 returns 200 OK, remaining return 409 Conflict', async () => { const gw = await createReadyGiveaway(); // Lock first const lockReq = 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 }), }); await snapshotPost(lockReq, { params: { id: gw.id } }); // Launch 10 concurrent unlocks with distinct idempotency keys const requests = Array.from({ length: 10 }, (_, i) => { const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, 'Idempotency-Key': `unlock-concurrent-${i}-${Date.now()}`, }, }); return unlockPost(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(9); const stored = await GiveawayStore.getById(gw.id); expect(stored?.status).toBe('READY'); expect(stored?.seed).toBeNull(); expect(stored?.seedCommitment).toBeNull(); }); // ─── Test 6: Idempotency Replay on Unlock ─────────────────────────────────── it('idempotency replay returns cached 200 response when using identical Idempotency-Key', async () => { const gw = await createReadyGiveaway(); const idempotencyKey = `unlock-stable-key-${Date.now()}`; // Lock first const lockReq = 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 }), }); await snapshotPost(lockReq, { params: { id: gw.id } }); // 1. First unlock with key const unlockReq1 = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, 'Idempotency-Key': idempotencyKey, }, }); const res1 = await unlockPost(unlockReq1, { params: { id: gw.id } }); expect(res1.status).toBe(200); const data1 = await res1.json(); expect(data1.status).toBe('READY'); // 2. Replay with same key -> cached 200 OK const replayReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, 'Idempotency-Key': idempotencyKey, }, }); const replayRes = await unlockPost(replayReq, { params: { id: gw.id } }); expect(replayRes.status).toBe(200); const replayData = await replayRes.json(); expect(replayData.status).toBe('READY'); // 3. New request with different key -> 409 Conflict (since now already READY) const newKeyReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: sessionCookie, 'Idempotency-Key': `new-key-${Date.now()}`, }, }); const newKeyRes = await unlockPost(newKeyReq, { params: { id: gw.id } }); expect(newKeyRes.status).toBe(409); }); });