feat(public): Task 06 implement public giveaway verification endpoint and page
This commit is contained in:
parent
1a27a10847
commit
8741569817
8 changed files with 457 additions and 18 deletions
|
|
@ -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-шаговый визард создания розыгрыша.
|
||||
- Живое превью условий и статуса допуска каждого участника с указанием причин отклонения.
|
||||
- Презентация победителей и сертификат криптографического аудита.
|
||||
- Публичная страница розыгрыша с проверкой победителей и сертификатом криптографического аудита.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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) на текущем этапе не используется.
|
||||
|
||||
|
|
|
|||
109
src/app/api/giveaways/[id]/public/route.ts
Normal file
109
src/app/api/giveaways/[id]/public/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
182
tests/public-verification.test.ts
Normal file
182
tests/public-verification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue