randomayzer/tests/snapshot-unlock.test.ts

327 lines
13 KiB
TypeScript

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