266 lines
9.8 KiB
TypeScript
266 lines
9.8 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { GiveawayStore } from '../src/lib/giveaway-store';
|
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
|
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
|
|
import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route';
|
|
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
|
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 verifyGet } from '../src/app/api/giveaways/[id]/verify/route';
|
|
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
|
|
|
describe('Phase 2.2.1 Giveaway Ownership & AuthZ Guard Security Suite', () => {
|
|
let memoryRepo: MemoryGiveawayRepository;
|
|
const ownerUser = { id: 'usr_organizer_1', vkUserId: '111111', firstName: 'Alice' };
|
|
const intruderUser = { id: 'usr_intruder_2', vkUserId: '222222', firstName: 'Eve' };
|
|
let ownerSessionId: string;
|
|
let intruderSessionId: string;
|
|
|
|
beforeEach(async () => {
|
|
memoryRepo = new MemoryGiveawayRepository();
|
|
GiveawayStore.setRepository(memoryRepo);
|
|
|
|
defaultSessionStore.clear();
|
|
ownerSessionId = await defaultSessionStore.createSession(ownerUser);
|
|
intruderSessionId = await defaultSessionStore.createSession(intruderUser);
|
|
});
|
|
|
|
const validPostData = {
|
|
sourceUrl: 'https://vk.com/wall-1_100',
|
|
post: {
|
|
platform: 'VK' as const,
|
|
ownerId: '-1',
|
|
postId: '100',
|
|
sourceUrl: 'https://vk.com/wall-1_100',
|
|
title: 'Mega Giveaway',
|
|
text: 'Mega Giveaway Description',
|
|
likesCount: 10,
|
|
commentsCount: 5,
|
|
repostsCount: 0,
|
|
},
|
|
filterRules: {
|
|
requireLike: true,
|
|
requireComment: false,
|
|
requireRepost: false,
|
|
requireSubscription: false,
|
|
excludeAdmins: false,
|
|
excludeBlacklistedIds: [],
|
|
excludeDuplicateComments: true,
|
|
minEligibleParticipants: 1,
|
|
},
|
|
winnersCount: 1,
|
|
reserveWinnersCount: 0,
|
|
};
|
|
|
|
it('anonymous POST /api/giveaways returns 401 Unauthorized', async () => {
|
|
const req = new NextRequest('http://localhost:3000/api/giveaways', {
|
|
method: 'POST',
|
|
body: JSON.stringify(validPostData),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
const res = await giveawaysPost(req);
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
expect(body.error?.message).toMatch(/authentication required/i);
|
|
});
|
|
|
|
it('authenticated POST /api/giveaways binds organizerId strictly from server session', async () => {
|
|
const req = new NextRequest('http://localhost:3000/api/giveaways', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
...validPostData,
|
|
organizerId: 'usr_fake_spoofed_id', // Client attempt to spoof organizer
|
|
}),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
|
},
|
|
});
|
|
|
|
const res = await giveawaysPost(req);
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.giveaway.organizerId).toBe(ownerUser.id);
|
|
});
|
|
|
|
it('owner can access private giveaway details; intruder gets 403 Forbidden', async () => {
|
|
const created = await GiveawayStore.create({
|
|
sourceUrl: validPostData.sourceUrl,
|
|
post: validPostData.post,
|
|
filterRules: validPostData.filterRules,
|
|
organizerId: ownerUser.id,
|
|
});
|
|
|
|
// 1. Owner access
|
|
const ownerReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}`, {
|
|
headers: { cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}` },
|
|
});
|
|
const ownerRes = await giveawayDetailGet(ownerReq, { params: { id: created.id } });
|
|
expect(ownerRes.status).toBe(200);
|
|
|
|
// 2. Intruder access
|
|
const intruderReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}`, {
|
|
headers: { cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}` },
|
|
});
|
|
const intruderRes = await giveawayDetailGet(intruderReq, { params: { id: created.id } });
|
|
expect(intruderRes.status).toBe(403);
|
|
const intruderBody = await intruderRes.json();
|
|
expect(intruderBody.error?.message).toMatch(/not the organizer/i);
|
|
});
|
|
|
|
it('owner can import participants; intruder gets 403 Forbidden', async () => {
|
|
const created = await GiveawayStore.create({
|
|
sourceUrl: validPostData.sourceUrl,
|
|
post: validPostData.post,
|
|
filterRules: validPostData.filterRules,
|
|
organizerId: ownerUser.id,
|
|
});
|
|
|
|
const body = JSON.stringify({ filterRules: validPostData.filterRules });
|
|
|
|
// 1. Intruder attempt
|
|
const intruderReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/participants`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
|
},
|
|
});
|
|
const intruderRes = await participantsPost(intruderReq, { params: { id: created.id } });
|
|
expect(intruderRes.status).toBe(403);
|
|
|
|
// 2. Anonymous attempt
|
|
const anonReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/participants`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const anonRes = await participantsPost(anonReq, { params: { id: created.id } });
|
|
expect(anonRes.status).toBe(401);
|
|
});
|
|
|
|
it('owner can lock snapshot and execute draw; non-owner gets 403 Forbidden', async () => {
|
|
const created = await GiveawayStore.create({
|
|
sourceUrl: validPostData.sourceUrl,
|
|
post: validPostData.post,
|
|
filterRules: validPostData.filterRules,
|
|
organizerId: ownerUser.id,
|
|
});
|
|
|
|
// Populate participants
|
|
await GiveawayStore.updateParticipants(created.id, [
|
|
{
|
|
platformUserId: 'u1',
|
|
firstName: 'Bob',
|
|
lastName: 'Test',
|
|
source: 'LIKES',
|
|
liked: true,
|
|
commented: false,
|
|
commentsCount: 0,
|
|
reposted: false,
|
|
subscribed: false,
|
|
eligible: true,
|
|
},
|
|
]);
|
|
|
|
// 1. Snapshot by intruder -> 403
|
|
const intruderSnapshotReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/snapshot`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ filterRules: validPostData.filterRules }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
|
},
|
|
});
|
|
const intruderSnapRes = await snapshotPost(intruderSnapshotReq, { params: { id: created.id } });
|
|
expect(intruderSnapRes.status).toBe(403);
|
|
|
|
// 2. Snapshot by owner -> 200
|
|
const ownerSnapshotReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/snapshot`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ filterRules: validPostData.filterRules }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
|
},
|
|
});
|
|
const ownerSnapRes = await snapshotPost(ownerSnapshotReq, { params: { id: created.id } });
|
|
expect(ownerSnapRes.status).toBe(200);
|
|
|
|
// 3. Draw by intruder -> 403
|
|
const intruderDrawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
|
},
|
|
});
|
|
const intruderDrawRes = await drawPost(intruderDrawReq, { params: { id: created.id } });
|
|
expect(intruderDrawRes.status).toBe(403);
|
|
|
|
// 4. Draw by owner -> 200
|
|
const ownerDrawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
|
},
|
|
});
|
|
const ownerDrawRes = await drawPost(ownerDrawReq, { params: { id: created.id } });
|
|
expect(ownerDrawRes.status).toBe(200);
|
|
});
|
|
|
|
it('GET /api/giveaways/[id]/verify remains public without requiring authentication', async () => {
|
|
const created = await GiveawayStore.create({
|
|
sourceUrl: validPostData.sourceUrl,
|
|
post: validPostData.post,
|
|
filterRules: validPostData.filterRules,
|
|
organizerId: ownerUser.id,
|
|
});
|
|
|
|
await GiveawayStore.updateParticipants(created.id, [
|
|
{
|
|
platformUserId: 'u1',
|
|
firstName: 'Bob',
|
|
lastName: 'Test',
|
|
source: 'LIKES',
|
|
liked: true,
|
|
commented: false,
|
|
commentsCount: 0,
|
|
reposted: false,
|
|
subscribed: false,
|
|
eligible: true,
|
|
},
|
|
]);
|
|
|
|
await GiveawayStore.createAndLockSnapshot(created.id, created.participants, validPostData.filterRules);
|
|
|
|
// Draw
|
|
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
|
},
|
|
});
|
|
await drawPost(drawReq, { params: { id: created.id } });
|
|
|
|
// Public verify request without any cookie/session
|
|
const publicVerifyReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/verify`);
|
|
const verifyRes = await verifyGet(publicVerifyReq, { params: { id: created.id } });
|
|
|
|
expect(verifyRes.status).toBe(200);
|
|
const verifyBody = await verifyRes.json();
|
|
expect(verifyBody.verified).toBe(true);
|
|
expect(verifyBody.deterministicProofHash).toBeDefined();
|
|
// Verify no private credentials or PII lists are leaked
|
|
expect(verifyBody.access_token).toBeUndefined();
|
|
expect(verifyBody.participants).toBeUndefined();
|
|
});
|
|
});
|