feat(public): Task 06 implement public giveaway verification endpoint and page

This commit is contained in:
Ochenstarik 2026-08-21 19:11:03 +07:00
parent 1a27a10847
commit 8741569817
8 changed files with 457 additions and 18 deletions

View file

@ -16,14 +16,16 @@
- Черный список ID и логинов.
- **Детерминированный Randomizer (Provably Fair)**:
- Исключен непрозрачный `Math.random()`.
- Выборка на основе HMAC-SHA256 и перетасовки Фишера-Йетса.
- Snapshot Hash (SHA-256) канонического списка участников + Seed = 100% повторяемость и верифицируемость.
- Выборка на основе HMAC-SHA256 (`HMAC_SHA256_FY_V1`) и несмещенной перетасовки Фишера-Йетса.
- **Seed Pre-Commitment**: SHA-256 хеш сида фиксируется и публикуется на этапе фиксации слепка до жеребьевки, исключая seed grinding.
- Snapshot Hash (SHA-256) канонического списка участников + раскрытый Seed = 100% математическая воспроизводимость.
- Публичный результат (`GET /api/giveaways/[id]/public` и страница `/giveaways/[id]`) доступен любому участнику без входа в систему.
- Поддержка основных и резервных призовых мест.
- **Интерактивный UI**:
- Dashboard со статистикой и списком кампаний.
- 5-шаговый визард создания розыгрыша.
- Живое превью условий и статуса допуска каждого участника с указанием причин отклонения.
- Презентация победителей и сертификат криптографического аудита.
- Публичная страница розыгрыша с проверкой победителей и сертификатом криптографического аудита.
---

View file

@ -0,0 +1,70 @@
# Task 06: Публичная проверяемость розыгрыша Report
**Date:** 2026-08-21
**Base Commit SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b`
**Status:** COMPLETED / PASS
**Assigned Agent:** Antigravity (Implementation Orchestrator)
---
## 1. Executive Summary
Реализована архитектура публичной проверяемости результатов розыгрыша для участников и внешних наблюдателей без раскрытия персональных данных третьих лиц:
1. **Публичный API-эндпоинт (`GET /api/giveaways/[id]/public`):**
- Доступен без аутентификации, защищен rate limiter'ом `expensiveApiRateLimiter` (`giveaway-public-get:${clientIp}:${id}`).
- Возвращает метаданные публикации, условия отбора, хеш слепка `participantsSnapshotHash`, хеш условий `conditionsHash`, `algorithmVersion`.
- **`seedCommitment`:** публично доступен **и до, и после** розыгрыша.
- **`seed`:** строго скрыт (`null`) до завершения жеребьевки (`SNAPSHOT_LOCKED`), раскрывается только в статусе `DRAWN`/`PUBLISHED`.
- **Защита PII:** полные списки участников (`participants`, `eligibleParticipants`, `excludedParticipants`) исключены из публичного ответа. Публикуются только победители (имя, аватар, ID).
- Токены, учетные данные и `organizerId` исключены из ответа.
2. **Публичная страница розыгрыша (`src/app/giveaways/[id]/page.tsx`):**
- Переведена на получение данных через `/api/giveaways/[id]/public`.
- Открывается анонимным пользователям без необходимости авторизации через VK ID.
- Отображает карточки победителей, хеш сида `Seed Commitment (SHA-256)`, `deterministicProofHash`, `auditEventHash` и кнопку онлайн-верификации (`/api/giveaways/[id]/verify`).
3. **UI Визарда (Шаг 4):**
- Добавлена кнопка быстрого копирования `seedCommitment` в буфер обмена для публикации организатором в комментариях к посту до запуска розыгрыша.
4. **Документация (`README.md`, `docs/ARCHITECTURE.md`):**
- Честно зафиксированы границы проверяемости и компромисс защиты приватности (PII).
---
## 2. Границы публичной проверяемости (Provably Fair Scope & Privacy Compromise)
### Что может независимо проверить любой внешний наблюдатель:
1. **Защита от Seed Grinding:** совпадение $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ гарантирует, что случайное число было сгенерировано и зафиксировано на этапе создания слепка до жеребьевки, а не подбиралось организатором под конкретных победителей.
2. **Воспроизводимость алгоритма:** соответствие вычислений стандарту `HMAC_SHA256_FY_V1`.
3. **Целостность доказательства:** совпадение `deterministicProofHash` и `auditEventHash`.
### Что остаётся непроверяемым внешним наблюдателем (и почему):
1. **Вычисление `participantsSnapshotHash` с нуля:** без полного списка участников сторонний наблюдатель не может самостоятельно пересчитать хеш слепка участников. Список участников намеренно не отдаётся анонимам ради защиты персональных данных третьих лиц (PII).
2. **Внешний якорь времени:** доказательство фиксируется в базе данных Randomayzer. Внешний децентрализованный якорь (блокчейн, drand beacon, RFC 3161) на текущем этапе отсутствует.
---
## 3. Modified Files
| File | Type | Description |
|------|------|-------------|
| `src/app/api/giveaways/[id]/public/route.ts` | API Route (NEW) | Публичный маршрут с отдачей данных розыгрыша без PII и с защитой сида до жеребьевки. |
| `src/app/giveaways/[id]/page.tsx` | UI | Перевод страницы на `/api/giveaways/[id]/public` и отображение `seedCommitment`. |
| `src/app/giveaways/new/page.tsx` | UI | Кнопка копирования `seedCommitment` на шаге 4 визарда. |
| `docs/ARCHITECTURE.md` | Docs | Обновлен раздел механизма честности и границ проверяемости. |
| `README.md` | Docs | Описаны возможности публичной проверки и Seed Pre-Commitment. |
| `tests/public-verification.test.ts` | Tests (NEW) | Тесты публичного эндпоинта (5 тестов): доступ анонимов, маскирование seed, проверка commitment, защита 401 на приватном маршруте, отсутствие PII. |
---
## 4. Verification Evidence & Test Gate
```text
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0)
npx tsc --noEmit -> EXIT 0 (0 ошибок типизации)
npm test -> EXIT 0 (54 тестовых файла, 316 тестов прошли успешно)
npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element)
npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно)
npm audit --omit=dev -> EXIT 0 (0 vulnerabilities)
```

View file

@ -0,0 +1,32 @@
# Task 06: Публичная проверяемость розыгрыша
**Assigned to:** Antigravity (Implementation Orchestrator)
**Priority:** MEDIUM (product claim alignment)
**Date:** 2026-08-21
**Base SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b`
## Scope
1. Implement public read-only giveaway result endpoint `GET /api/giveaways/[id]/public`:
- Publicly accessible without session.
- Bounded by anonymous rate limiting (`expensiveApiRateLimiter.assertAllowed('giveaway-public-get:' + clientIp + ':' + id)`).
- Exposes safe public information:
* Post metadata & snapshot `filterRules`
* `participantsSnapshotHash`, `conditionsHash`, `algorithmVersion`
* `seedCommitment` — both BEFORE and AFTER the draw
* After `DRAWN`: `seed` (revealed only once finalized), `deterministicProofHash`, `auditEventHash`, winners & reserve winners (public winner names & IDs)
* Before `DRAWN`: `seed === null` (strictly masked)
* Zero private PII: full eligible/excluded participants list is omitted to protect third-party privacy
* Zero credential/token/internal organizer metadata
2. Update public giveaway view page `src/app/giveaways/[id]/page.tsx`:
- Works seamlessly for unauthenticated visitors by fetching from `/api/giveaways/[id]/public`.
- Displays post information, winners, seed pre-commitment SHA-256, proof hash, and mathematical verification state.
3. Update `README.md` and `docs/ARCHITECTURE.md` to document the Provably Fair model honestly and explicitly:
- What is independently verifiable by external observers (seed pre-commitment binding, draw execution reproducibility from snapshot hash + seed).
- What remains unverifiable externally without raw PII (external observers cannot re-compute `participantsSnapshotHash` without the full raw participant list, and the server DB holds the proof without external decentralized timestamping/blockchain anchor).
4. Create test suite `tests/public-verification.test.ts`:
- Anonymous user retrieves public results for `DRAWN` giveaway.
- Anonymous user does not receive `seed` on non-`DRAWN` giveaway (`seed === null`).
- `seedCommitment` is visible before draw and equals `sha256(seed)` after draw.
- Anonymous user still receives `401 Unauthorized` on private `GET /api/giveaways/[id]`.
- Zero private participant PII or tokens in public response.
5. Verification gate & report in `agents/antigravity/done/TASK-2026-08-21-06-public-verification.md`.

View file

@ -48,12 +48,34 @@ graph TD
## 3. Механизм честности и доказуемости (Provably Fair)
Каждый розыгрыш формирует криптографический аудит-след:
1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и хешируется через SHA-256:
$$\text{ParticipantsHash} = \text{SHA256}(\text{JSON}(\text{sortedEligibleParticipants}))$$
2. **Seed розыгрыша**: Пользовательский или сгенерированный криптографически стойкий seed.
3. **Детерминированный выбор**:
- Для каждого шага выбора индекса вычисляется:
$$\text{Hash}_i = \text{HMAC-SHA256}(\text{seed} + ":" + i, \text{ParticipantsHash})$$
- Индекс победителя определяется детерминированно из полученного хеша.
4. **Результат**: Зная `ParticipantsHash` и `seed`, любой внешний наблюдатель может воспроизвести выбор и убедиться в честности результата на 100%.
Каждый розыгрыш формирует криптографический аудит-след на базе алгоритма `HMAC_SHA256_FY_V1`:
1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и канонически сериализуется:
$$\text{ParticipantsSnapshotHash} = \text{SHA256}(\text{canonicalStringify}(\text{sortedEligibleParticipants}))$$
2. **Seed Pre-Commitment (Защита от Seed Grinding)**:
- В момент фиксации слепка (`SNAPSHOT_LOCKED`) сервер генерирует CSPRNG seed и публикует его SHA-256 обязательство:
$$\text{SeedCommitment} = \text{SHA256}(\text{seed})$$
- До момента проведения жеребьевки сам `seed` строго скрыт (`seed: null`), но `seedCommitment` доступен публично. Организатор может зафиксировать его публично (например, в комментарии к конкурсному посту VK) до розыгрыша.
3. **Детерминированный выбор (`HMAC_SHA256_FY_V1`)**:
- Выборка осуществляется с помощью несмещенного сэмплинга Фишера-Йетса (Fisher-Yates) поверх потока псевдослучайных байт HMAC-SHA256:
$$\text{ByteStream} = \text{HMAC-SHA256}(\text{key} = \text{seed}, \text{data} = \text{ParticipantsSnapshotHash} \parallel \text{ConditionsHash} \parallel \text{blockIndex})$$
- Позиции победителей и резерва рассчитываются детерминированно.
4. **Публичное раскрытие и аудит**:
- После перевода розыгрыша в статус `DRAWN` сервер раскрывает `seed`.
- Любой участник может проверить:
1. $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ (гарантия того, что seed не подбирался под желаемого победителя);
2. Воспроизведение результатов выборки при наличии слепка;
3. Неизменность `deterministicProofHash` и `auditEventHash`.
### 3.1. Границы публичной проверяемости и защита приватности (PII Compromise)
- **Что проверяется внешним наблюдателем:**
- Корректность раскрытия seed относительно опубликованного pre-commitment.
- Математическая повторяемость алгоритма.
- Совпадение хешей доказательства (`deterministicProofHash`).
- **Что остаётся приватным:**
- Полный список участников и их персональные данные (PII) **не отдаются анонимным пользователям** в целях соблюдения требований защиты данных третьих лиц. Публикуются только победители и хеш слепка `participantsSnapshotHash`.
- Внешний наблюдатель без исходного списка участников не может самостоятельно с нуля пересчитать `participantsSnapshotHash`.
- **Архитектурный статус доверия:**
- Доказательство формируется и проверяется на сервере Randomayzer на основе зафиксированного в БД слепка. Децентрализованный внешний якорь (блокчейн, drand, RFC 3161) на текущем этапе не используется.

View file

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
import { computeSeedCommitment } from '@/core/randomizer/hasher';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> | { id: string } }
) {
try {
const { id } = await params;
const clientIp = resolveClientIp(req);
// Anonymous rate limiter
expensiveApiRateLimiter.assertAllowed(`giveaway-public-get:${clientIp}:${id}`);
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${id}" not found`);
}
const isDrawn = giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED';
// Calculate or retrieve seed commitment (accessible before and after draw)
const seedCommitment = giveaway.seedCommitment || (giveaway.seed ? computeSeedCommitment(giveaway.seed) : null);
// Revealed seed: strictly null before finalized draw, revealed once drawn
const seed = isDrawn ? giveaway.seed : null;
const publicGiveaway = {
id: giveaway.id,
status: giveaway.status,
title: giveaway.title,
description: giveaway.description,
platform: giveaway.platform,
sourceUrl: giveaway.sourceUrl,
post: {
platform: giveaway.platform,
ownerId: giveaway.platformOwnerId,
postId: giveaway.platformPostId,
title: giveaway.title,
imageUrl: giveaway.postImageUrl,
likesCount: giveaway.postLikesCount,
commentsCount: giveaway.postCommentsCount,
repostsCount: giveaway.postRepostsCount,
},
postImageUrl: giveaway.postImageUrl,
filterRules: giveaway.latestSnapshot?.filterRulesSnapshot || giveaway.filterRules,
winnersCount: giveaway.winnersCount,
reserveWinnersCount: giveaway.reserveWinnersCount,
seedCommitment,
seed,
drawnAt: giveaway.drawnAt,
createdAt: giveaway.createdAt,
updatedAt: giveaway.updatedAt,
latestSnapshot: giveaway.latestSnapshot ? {
version: giveaway.latestSnapshot.version,
createdAt: giveaway.latestSnapshot.createdAt,
participantCount: giveaway.latestSnapshot.participantCount,
participantsSnapshotHash: giveaway.latestSnapshot.participantsSnapshotHash,
conditionsHash: giveaway.latestSnapshot.conditionsHash,
} : null,
drawResult: giveaway.drawResult ? {
drawId: giveaway.drawResult.drawId,
algorithmVersion: giveaway.drawResult.algorithmVersion,
totalEligibleCount: giveaway.drawResult.totalEligibleCount,
totalLoadedCount: giveaway.drawResult.totalLoadedCount,
seedUsed: giveaway.drawResult.seedUsed,
snapshotId: giveaway.drawResult.snapshotId,
drawnAt: giveaway.drawResult.drawnAt,
deterministicProofHash: giveaway.drawResult.deterministicProofHash,
auditEventHash: giveaway.drawResult.auditEventHash,
participantsSnapshotHash: giveaway.drawResult.participantsSnapshotHash,
conditionsHash: giveaway.drawResult.conditionsHash,
winners: giveaway.drawResult.winners.map(w => ({
position: w.position,
participant: {
platformUserId: w.participant.platformUserId,
firstName: w.participant.firstName,
lastName: w.participant.lastName,
avatarUrl: w.participant.avatarUrl,
},
})),
reserveWinners: giveaway.drawResult.reserveWinners.map(w => ({
position: w.position,
participant: {
platformUserId: w.participant.platformUserId,
firstName: w.participant.firstName,
lastName: w.participant.lastName,
avatarUrl: w.participant.avatarUrl,
},
})),
winnerIds: giveaway.drawResult.winnerIds,
reserveWinnerIds: giveaway.drawResult.reserveWinnerIds,
} : null,
};
return NextResponse.json({
success: true,
giveaway: publicGiveaway,
});
} catch (error: any) {
return handleApiError(error);
}
}

View file

@ -33,9 +33,9 @@ export default function GiveawayDetailPage() {
const fetchGw = async () => {
try {
setLoading(true);
const res = await fetch(`/api/giveaways/${id}`);
const res = await fetch(`/api/giveaways/${id}/public`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Розыгрыш не найден');
if (!res.ok) throw new Error(data.error?.message || data.error || 'Розыгрыш не найден');
setGiveaway(data.giveaway);
} catch (err: any) {
setError(err.message);
@ -269,9 +269,15 @@ export default function GiveawayDetailPage() {
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Seed:</span>
<span className="text-slate-400">Seed (Раскрыт):</span>
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
</div>
{giveaway.seedCommitment && (
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Seed Commitment (SHA-256):</span>
<p className="font-mono text-emerald-400 break-all">{giveaway.seedCommitment}</p>
</div>
)}
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot Hash:</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>

View file

@ -54,6 +54,7 @@ export default function NewGiveawayWizardPage() {
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
const [seedCommitment, setSeedCommitment] = useState<string | null>(null);
const [copiedCommitment, setCopiedCommitment] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
@ -852,9 +853,24 @@ export default function NewGiveawayWizardPage() {
Случайный криптографический seed зафиксирован на сервере в момент блокировки слепка. Ручной ввод отключен для математической гарантии честности и защиты от seed grinding.
</p>
{seedCommitment && (
<div className="p-2.5 rounded-lg bg-slate-900 border border-slate-800 text-[11px] font-mono flex items-center gap-2 overflow-x-auto">
<span className="text-slate-500 shrink-0">Commitment SHA-256:</span>
<span className="text-emerald-400 truncate">{seedCommitment}</span>
<div className="p-3 rounded-xl bg-slate-900 border border-slate-800 text-[11px] font-mono flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-500 shrink-0">Commitment SHA-256:</span>
<span className="text-emerald-400 truncate">{seedCommitment}</span>
</div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(seedCommitment);
setCopiedCommitment(true);
setTimeout(() => setCopiedCommitment(false), 2000);
}}
className="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors shrink-0 flex items-center gap-1 text-[10px]"
title="Скопировать обязательство для публикации до жеребьевки"
>
{copiedCommitment ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copiedCommitment ? 'Скопировано' : 'Копировать'}
</button>
</div>
)}
</div>

View file

@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { createHash } from 'crypto';
import { GET as publicGiveawayGet } from '../src/app/api/giveaways/[id]/public/route';
import { GET as privateGiveawayGet } from '../src/app/api/giveaways/[id]/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 { 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 { expensiveApiRateLimiter } from '../src/lib/rate-limiter';
describe('Task 06: Public Giveaway Verification Endpoint (GET /api/giveaways/[id]/public)', () => {
const organizerUser = { id: 'usr_pub_verify_org', vkUserId: '555444' };
let sessionCookie: string;
const testParticipants: FilteredParticipant[] = Array.from({ length: 25 }, (_, i) => ({
platformUserId: `${4000 + i}`,
firstName: `User${i}`,
lastName: `Public${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();
expensiveApiRateLimiter.reset();
const sessionId = await defaultSessionStore.createSession(organizerUser);
sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
});
async function createLockedGiveaway() {
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-55667788_999',
post: {
platform: 'VK',
ownerId: '-55667788',
postId: '999',
sourceUrl: 'https://vk.com/wall-55667788_999',
title: 'Public Verification Test Post',
text: 'Provably fair giveaway description',
likesCount: 25,
commentsCount: 0,
repostsCount: 0,
},
filterRules: DEFAULT_FILTER_RULES,
organizerId: organizerUser.id,
});
await GiveawayStore.updateParticipants(gw.id, testParticipants);
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 } });
const snapData = await snapRes.json();
return { giveawayId: gw.id, seedCommitment: snapData.seedCommitment, snapshot: snapData.snapshot };
}
// ─── Test 1: Anonymous Access Before Draw (Masked Seed, Exposed Commitment) ───
it('anonymous visitor before draw sees seedCommitment and snapshot hash, but seed is strictly null', async () => {
const { giveawayId, seedCommitment } = await createLockedGiveaway();
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
const gw = data.giveaway;
expect(gw.id).toBe(giveawayId);
expect(gw.status).toBe('SNAPSHOT_LOCKED');
expect(gw.title).toBe('Public Verification Test Post');
expect(gw.seedCommitment).toBe(seedCommitment);
expect(gw.seed).toBeNull(); // Strictly masked before draw!
expect(gw.latestSnapshot.participantsSnapshotHash).toBeDefined();
expect(gw.latestSnapshot.conditionsHash).toBeDefined();
expect(gw.drawResult).toBeNull();
// Verify raw PII protection
expect((gw as any).participants).toBeUndefined();
expect((gw as any).eligibleParticipants).toBeUndefined();
expect((gw as any).organizerId).toBeUndefined();
});
// ─── Test 2: Anonymous Access After Draw (Revealed Seed, Proof & Winners) ─────
it('anonymous visitor after draw sees revealed seed, deterministic proof, and winner public profiles', async () => {
const { giveawayId, seedCommitment } = await createLockedGiveaway();
// Execute draw as organizer
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/draw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: sessionCookie },
body: JSON.stringify({ winnersCount: 2, reserveWinnersCount: 1 }),
});
const drawRes = await drawPost(drawReq, { params: { id: giveawayId } });
expect(drawRes.status).toBe(200);
// Fetch public giveaway
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
expect(res.status).toBe(200);
const data = await res.json();
const gw = data.giveaway;
expect(gw.status).toBe('DRAWN');
expect(gw.seedCommitment).toBe(seedCommitment);
expect(gw.seed).toBeDefined();
expect(typeof gw.seed).toBe('string');
// SHA-256 of revealed seed MUST equal the pre-committed hash
const computedHash = createHash('sha256').update(gw.seed).digest('hex');
expect(computedHash).toBe(seedCommitment);
// Draw result details
expect(gw.drawResult).toBeDefined();
expect(gw.drawResult.algorithmVersion).toBe('HMAC_SHA256_FY_V1');
expect(gw.drawResult.deterministicProofHash).toMatch(/^[a-f0-9]{64}$/);
expect(gw.drawResult.auditEventHash).toMatch(/^[a-f0-9]{64}$/);
expect(gw.drawResult.winners).toHaveLength(2);
expect(gw.drawResult.reserveWinners).toHaveLength(1);
// Winner profiles contain only safe public fields
const winner1 = gw.drawResult.winners[0];
expect(winner1.position).toBe(1);
expect(winner1.participant.platformUserId).toBeDefined();
expect(winner1.participant.firstName).toBeDefined();
expect(winner1.participant.lastName).toBeDefined();
expect((winner1.participant as any).encryptedToken).toBeUndefined();
// Raw participant list is still omitted
expect((gw as any).participants).toBeUndefined();
});
// ─── Test 3: Private Giveaway Detail Route Remains Protected (401) ───────────
it('anonymous request to private GET /api/giveaways/[id] returns 401 Unauthorized', async () => {
const { giveawayId } = await createLockedGiveaway();
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}`);
const res = await privateGiveawayGet(req, { params: { id: giveawayId } });
expect(res.status).toBe(401);
});
// ─── Test 4: Non-Existent Giveaway Returns 404 ────────────────────────────────
it('request for non-existent giveaway returns 404 Not Found', async () => {
const req = new NextRequest('http://localhost:3000/api/giveaways/non_existent_123/public');
const res = await publicGiveawayGet(req, { params: { id: 'non_existent_123' } });
expect(res.status).toBe(404);
});
// ─── Test 5: Rate Limiting on Public Endpoint ─────────────────────────────────
it('public endpoint enforces rate limiting per client IP', async () => {
const { giveawayId } = await createLockedGiveaway();
// Exhaust expensive rate limiter (15 requests)
for (let i = 0; i < 15; i++) {
expensiveApiRateLimiter.check(`giveaway-public-get:direct-client:${giveawayId}`);
}
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
expect(res.status).toBe(429);
});
});