feat(security): Phase 2.2.1 Critical AuthZ & Credential Security - centralized ownership guards, protected giveaway mutations, safe redirect validator, CSRF defenses, TokenVault production fail-fast, and comprehensive security tests

This commit is contained in:
Ochenstarik 2026-08-18 03:08:02 +07:00
parent 7c5bf641cd
commit 5d2554f33a
26 changed files with 1241 additions and 135 deletions

View file

@ -1,14 +1,21 @@
# PostgreSQL Database
# PostgreSQL Database Connection
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public"
# VK API Configuration
# Сервисный ключ доступа приложения VK (для публичных запросов)
VK_SERVICE_TOKEN="your_vk_service_token_here"
# ID приложения VK
# Token Vault Encryption Key (Strictly required in production, min 32 chars / 256 bits)
# Generate with: openssl rand -hex 32
TOKEN_ENCRYPTION_KEY="your_32_bytes_cryptographically_secure_token_encryption_key_here"
# Application Session / Auth Secret
# Generate with: openssl rand -hex 32
AUTH_SECRET="your_cryptographically_secure_auth_session_secret_here"
# VK ID / VK API Configuration (OAuth 2.1 & Service Access)
VK_APP_ID="your_vk_app_id_here"
# Защищенный ключ приложения
VK_APP_SECRET="your_vk_app_secret_here"
VK_CLIENT_SECRET="your_vk_client_secret_here"
VK_SERVICE_TOKEN="your_vk_service_token_here"
VK_REDIRECT_URI="http://localhost:3000/api/auth/vk/callback"
# App Configuration
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NODE_ENV="development"
STORAGE_DRIVER="memory" # Use "prisma" for production PostgreSQL, "memory" for unit tests

View file

@ -0,0 +1,266 @@
# Randomayzer — Phase G-3 VK Client Adversarial Review
**Reviewer:** Grok (xAI)
**Date:** 2026-08-17
**Commit reviewed:** `7acf4d2d4ed131f999936186377e85663c19316a`
**Scope:** Phase 2.1 + 2.1.1 VkClient, retry, cancellation, pagination, rate limit, token security, error mapping, method capability claims.
**Constraints:** No production Core / Randomizer / AuditProof / Prisma / OAuth implementation changes. Docs + optional tests only.
---
## 1. Executive Verdicts
| Area | Verdict |
|------|---------|
| **Cancellation / Timeout** | **PASS WITH WARNINGS** |
| **Retry** | **PASS** |
| **Pagination** | **PASS WITH WARNINGS** |
| **Token Security** | **PASS WITH WARNINGS** |
| **Error Mapping** | **PASS** |
| **VK Contract Accuracy** | **PASS WITH WARNINGS** |
| **OAuth Readiness** | **YES** (with non-blocking risks) |
**Overall:** VkClient is solid enough to proceed to Phase 2.2 OAuth. Blocking issues are absent; remaining risks are documented and manageable.
---
## 2. Cancellation vs Timeout
### Implementation summary
```ts
let timedOut = false;
let callerCancelled = false;
// timeout → timedOut=true; controller.abort()
// caller signal → callerCancelled=true; controller.abort()
// catch order:
// 1. if (callerCancelled || signal?.aborted) → VkCancelledError
// 2. if (timedOut) → VkTimeoutError
// 3. AbortError fallback with same priority
// finally: clearTimeout + removeEventListener
```
| Scenario | Expected | Observed design |
|----------|----------|-----------------|
| signal already aborted before call | VkCancelledError, 0 retries | Yes (early check) |
| abort before rate limiter | VkCancelledError | Yes (pre-acquire check) |
| abort while waiting rate limiter | VkCancelledError after slot granted | **Partial** — acquire() has no AbortSignal; abort is only observed after acquire resolves |
| abort during fetch | VkCancelledError, no retry | Yes |
| abort during retry backoff | VkCancelledError, stop retries | Yes (backoff Promise rejects on abort) |
| timeout during fetch | VkTimeoutError, retryable | Yes |
| timeout after several retries | final VkTimeoutError | Yes |
| timeout + caller abort nearly simultaneous | **VkCancelledError** (caller wins) | Deterministic: callerCancelled checked first |
**Classification is deterministic and documented** in `docs/VK_CLIENT.md`.
**Warning:** Rate-limiter queue wait is not abortable. Long queue under load delays cancellation observation until the slot is granted. Recommendation (proposal only): pass AbortSignal into `IVkRateLimiter.acquire(signal?)`.
**Listener / timer hygiene:** `finally` always clears timeout and removes the abort listener (`{ once: true }` + explicit remove). No obvious MaxListeners accumulation on the happy path. Stress of 10k100k calls should be safe if finally runs (normal Promise path).
---
## 3. Retry Policy Matrix
### HTTP status → typed error → retryable
| HTTP | Mapped class | Retryable | Notes |
|------|--------------|-----------|-------|
| 400 | VkValidationError | No | |
| 401 | VkAuthError | No | |
| 403 | VkPermissionError | No | |
| 404 | VkNotFoundError | No | |
| 408 | VkNetworkError (fallback) | Yes* | Treated as network |
| 429 | VkRateLimitError | Yes | |
| 500504 | VkTemporaryError | Yes | |
| other 4xx | VkValidationError | No | |
| other | VkNetworkError | Yes | |
\*408 is not specially cased; falls through to Network (retryable). Acceptable.
### VK API error_code → typed error → retryable
| Code | Mapped class | Retryable | Notes |
|------|--------------|-----------|-------|
| 1 | VkTemporaryError | Yes | Unknown error |
| 5 | VkAuthError | No | Auth |
| 6 | VkRateLimitError | Yes | Too many requests / s |
| 7 | VkPermissionError | No | |
| 9 | VkRateLimitError | Yes | Flood control |
| 10 | VkTemporaryError | Yes | Internal server |
| 15 | VkPrivateResourceError | No | Access denied |
| 28 | VkAuthError | No | |
| 29 | VkRateLimitError | Yes | Rate limit |
| 30 | VkPrivateResourceError | No | Private profile |
| 36 | VkTimeoutError | Yes | Method execution timeout on VK side |
| 100 | VkValidationError | No | Invalid params |
| 104 | VkNotFoundError | No | |
| 113 | VkValidationError | No | Invalid user id |
| 203 | VkPrivateResourceError | No | |
| 210 | VkNotFoundError | No | Wall access / not found |
| 260 | VkPermissionError | No | |
| default | VkValidationError | No | Safe default |
**Critical check:** VK error **code 500 is not treated as HTTP 500**. There is no case 500 in `mapVkApiError`; HTTP 500 is handled only in `mapHttpStatusError`. Correct separation.
**Backoff:** exponential with full jitter, default maxRetries=3, initial 300 ms, maxDelay 4000 ms. Cancellation aborts backoff. Good.
**Retry storm / thundering herd:** Full jitter reduces sync; global rate limiter serializes outbound calls. 100 parallel clients hitting VK 429 will queue behind the limiter + backoff — acceptable, not a thundering herd of raw HTTP.
---
## 4. VK Rate Limiter
- Default: 10 RPS, sequential FIFO queue, minInterval ≈ 100 ms.
- `acquire()` has **no AbortSignal** → cancellation while queued is delayed (see §2).
- One large likes import (many pages) occupies the single global limiter and can **starve** concurrent short calls (e.g. wall.getById for another giveaway) for the duration of the import.
- Memory: queue of resolve callbacks; 1000 concurrent is fine; 10k+ starts to matter.
- Fairness: pure FIFO, no priority lanes.
**Proposal (non-blocking):** optional separate limiters per token type / priority, or AbortSignal on acquire.
---
## 5. Pagination (`fetchPaginatedVk`)
| Case | Behavior | Grade |
|------|----------|-------|
| 0 items | break, return [] | OK |
| 1 page | OK | OK |
| exact page boundary | continues until short page / total | OK |
| 2+ pages | accumulates | OK |
| totalCount changes mid-flight | uses latest recordedTotalCount for truncation check | OK |
| duplicated IDs across pages | accumulated as-is; provider Map dedups later | OK at client, OK at provider |
| API repeats same page forever | stopped by **maxPages** (default 10000) | **WARN** — no fingerprint / no-progress detection |
| items.length < pageSize while total larger | treated as last page (break) | OK |
| maxPages reached + truncation | throws `VkPaginationLimitError` if `throwOnTruncation` (default true) | OK — **partial set is not returned as complete** |
| caller cancel | VkCancelledError between pages | OK |
| network / rate limit mid-page | bubbles; no silent partial complete | OK |
**Stuck pagination:** only maxPages protects against a broken VK that always returns the same non-empty page.
**Proposal:** optional loop detection (fingerprint of first/last id + offset progress). Non-blocking for Phase 2.2.
---
## 6. Participant Deduplication & Subscription Batching
**Provider:** `participantsMap` keyed by `platformUserId`. Like then comment merge actions → one Participant. Correct.
**groups.isMember batching:** chunkSize = **500**, sequential calls.
Sizes 1 / 499 / 500 / 501 / 1000 / 1001 → all users covered, no duplicate checks of the same id in one batch. Partial failure of one chunk fails the whole `checkSubscription` (no per-chunk continue) — acceptable for correctness, could be improved later with partial results.
**Duplicate likes pages / name change between pages:** Map overwrites with later profile data; still one entry. Deactivated users appear with whatever fields VK returns; not specially filtered here (filter engine may later).
---
## 7. Token Security
| Vector | Protection | Result |
|--------|------------|--------|
| access_token in URL | Sent in **POST form body** only | OK |
| VK error `request_params` | `sanitizeRequestParams``[REDACTED]` for token keys | OK |
| Error.message / method | Uses method name, not full URL+token | OK |
| Network Error wrapping `err.message` | Could theoretically contain URL if fetch implementation leaks it; current code uses generic message | Low risk |
| redactToken() helper | Present for logs | OK |
| Stack traces | Do not embed token | OK |
| Fake token `SUPER_SECRET_RANDOMAYZER_TOKEN_123456` in error paths | Sanitized in request_params; not present in constructed messages | Expected pass |
**Warning:** Ensure no debug/logging middleware serializes the raw `URLSearchParams` body into error metadata. Current VkClient does not.
**Form body token:** Correct choice; never lands in query string.
---
## 8. VK Method Capability Claims vs Official Sources
| Claim | Project says | Official (dev.vk.com / schema practice) | Verdict |
|-------|--------------|------------------------------------------|---------|
| wall.getById token types | service, user, group, open | Supported with those tokens | **VERIFIED** |
| likes.getList max count | Max 100 with extended=1; 1000 IDs only | Official: max **1000** (friends_only off); extended returns profiles | **PARTIALLY VERIFIED** (project is more conservative) |
| groups.isMember max user_ids | **500** | Common community/SDK limit; official page does not always spell 500 explicitly | **PARTIALLY VERIFIED** (widely used & safe) |
| wall.getReposts limitations | capabilities.reposts = false; privacy | Method exists for service/user; practical privacy limits on third-party posts | **PARTIALLY VERIFIED** (pragmatic & correct for product) |
| groups.getMembers managers for adminDetection | requires admin rights; capability false | Correct | **VERIFIED** |
| Service token usable for listed methods | Yes | Yes for wall/likes/comments/isMember | **VERIFIED** |
| Service token lifetime | (not overclaimed in client) | Long-lived app token | OK |
No claim was found **WRONG**. Conservative count limits are safer than optimistic ones.
---
## 9. Auth / OAuth Readiness
- `VkAuthContext` already supports `SERVICE | USER | COMMUNITY` with `communityId` for group tokens.
- Factories: `createServiceAuth`, `createUserAuth`, `createCommunityAuth`.
- `validateAuthContext` enforces non-empty token and communityId for COMMUNITY.
- VkClient is token-agnostic; no hardcoded OAuth endpoints or legacy assumptions that block Phase 2.2.
- No OAuth implementation present (as required).
**Verdict: YES — safe to start Phase 2.2 OAuth.**
Non-blocking risks:
- Rate limiter is global (one import can delay OAuth-related calls).
- acquire() not cancellable.
- No token refresh / lifecycle hooks yet (expected for 2.2).
---
## 10. Error Surface (public safety)
Typed errors expose: `category`, `errorCode`, `method`, sanitized `details`.
They do **not** expose: access_token, full request URL with secrets, raw request_params with tokens.
Owner/post ids may appear in messages when the application constructs them (e.g. “Post X not found”) — acceptable and useful.
Safe public mapping path exists via existing `handleApiError` style (HTTP layer already maps AppErrors).
---
## 11. Performance Notes
- Pagination accumulates in memory (full list). For 100k likes this is the dominant cost (same as G-1 baseline).
- Retry overhead: up to 3 backoffs with jitter; small vs network.
- Rate limiter serializes to ~10 RPS → ~10k likes pages ≈ 1000 s theoretical floor (plus VK latency). Real large imports need background jobs (already noted in prior phases).
- Mock throughput of single call path is high; bottleneck is limiter + network.
---
## 12. CRITICAL / HIGH Findings
**CRITICAL:** none that block Phase 2.2.
**HIGH:**
1. Rate-limiter `acquire()` ignores AbortSignal → delayed cancellation under queue load.
2. Single global limiter → large import starves other VK traffic.
3. No pagination loop-detection beyond maxPages (stuck identical pages).
**MEDIUM:**
- likes.getList pageSize=100 is conservative vs official max 1000 (performance only).
- isMember partial chunk failure fails entire check (no partial map).
- NetworkError may surface underlying fetch message (low token risk).
---
## 13. Tests Executed / Scale
- Code review of: `vk-client.ts`, `vk-retry.ts`, `vk-errors.ts`, `vk-rate-limit.ts`, `vk-auth.ts`, `vk-provider.ts`, docs.
- Existing suites present: `vk-client-integration.test.ts`, `vk-correctness-gate.test.ts`, `vk-errors.test.ts`.
- Synthetic reasoning for cancellation races, retry matrix, token redaction, 500 concurrent queue behaviour.
- Official VK docs cross-check for capability claims (dev.vk.com).
- Stress scale for listener/timer: reasoned from `finally` + `{ once: true }`; recommend 10k mock-call run in CI.
Optional artifact: `tests/vk-client-grok-stress.test.ts` can be added later without touching production sources.
---
## 14. Final Answer
### Безопасно ли начинать Phase 2.2 OAuth?
**YES.**
**Blocking issues:** none.
**Remaining non-blocking risks:**
1. Make rate-limiter acquire abortable and/or add priority / per-token limiters before heavy production traffic.
2. Consider pagination progress fingerprint for pathological VK responses.
3. Confirm likes.getList `count` strategy (100 vs up to 1000) under real load.
4. After OAuth lands, ensure user/group tokens do not share the same global limiter bucket with long service-token imports without isolation.
VkClient cancellation/timeout separation, retry classification, token redaction, and error mapping are production-grade for the next phase.

View file

@ -0,0 +1,106 @@
# VK Client Failure Matrix — Phase G-3
**Commit:** `7acf4d2d4ed131f999936186377e85663c19316a`
**Date:** 2026-08-17
## Legend
- **OK** — correct typed error, no retry when forbidden, cleanup done
- **WARN** — integrity holds, UX or edge behaviour imperfect
- **GAP** — missing protection or incomplete behaviour
- **FAIL** — wrong classification or leak
---
## 1. Cancellation / Timeout
| Scenario | Error class | Retries after | Timer/listener cleanup | Grade |
|----------|-------------|---------------|------------------------|-------|
| signal already aborted | VkCancelledError | 0 | N/A | OK |
| abort before rate limiter | VkCancelledError | 0 | OK | OK |
| abort while queued in rate limiter | VkCancelledError (after slot) | 0 | OK | **WARN** (delayed) |
| abort during fetch | VkCancelledError | 0 | OK | OK |
| abort during backoff | VkCancelledError | stop | OK | OK |
| timeout during fetch | VkTimeoutError | yes (policy) | OK | OK |
| timeout after max retries | VkTimeoutError | stop | OK | OK |
| timeout + caller abort simultaneous | **VkCancelledError** (deterministic) | 0 | OK | OK |
## 2. Retry Classification
| Input | Class | Retryable | Grade |
|-------|-------|-----------|-------|
| HTTP 400 | Validation | No | OK |
| HTTP 401 | Auth | No | OK |
| HTTP 403 | Permission | No | OK |
| HTTP 404 | NotFound | No | OK |
| HTTP 429 | RateLimit | Yes | OK |
| HTTP 500504 | Temporary | Yes | OK |
| VK 1, 10 | Temporary | Yes | OK |
| VK 5, 28 | Auth | No | OK |
| VK 6, 9, 29 | RateLimit | Yes | OK |
| VK 7, 260 | Permission | No | OK |
| VK 15, 30, 203 | PrivateResource | No | OK |
| VK 36 | Timeout | Yes | OK |
| VK 100, 113 | Validation | No | OK |
| VK 104, 210 | NotFound | No | OK |
| VK code 500 (API) | (no special case → Validation default) | No | OK (not confused with HTTP 500) |
| Abort / Cancel | Cancelled | **No** | OK |
## 3. Pagination
| Case | Result | Grade |
|------|--------|-------|
| Empty first page | [] | OK |
| Short last page | stop, return accumulated | OK |
| maxPages + truncation | VkPaginationLimitError (default) | OK — not silent partial |
| Repeated identical page | maxPages only | **WARN** |
| Cancel between pages | VkCancelledError | OK |
| Failure mid-pagination | error bubbles, no “complete” partial | OK |
## 4. Token Security
| Vector | Token visible? | Grade |
|--------|----------------|-------|
| Request URL | No (POST body) | OK |
| Error.message | No | OK |
| request_params in VK error | [REDACTED] | OK |
| Stack / serialized metadata | No by design | OK |
| Logs via redactToken | Safe helper present | OK |
## 5. Rate Limiter
| Case | Behaviour | Grade |
|------|-----------|-------|
| Steady 10 RPS | Enforced | OK |
| 1000 concurrent acquire | FIFO queue | OK / memory light |
| Cancel while queued | Delayed until slot | **WARN** |
| Large import vs short calls | Starvation possible | **WARN** |
## 6. Capability Claims
| Claim | Verdict |
|-------|---------|
| groups.isMember batch 500 | PARTIALLY VERIFIED |
| likes.getList max 100 extended | PARTIALLY VERIFIED (official up to 1000) |
| wall.getReposts limited / capability false | PARTIALLY VERIFIED (pragmatic) |
| wall.getById token types | VERIFIED |
| adminDetection requires managers | VERIFIED |
| Any claim WRONG | **None found** |
## 7. Summary Grades
| Area | Grade |
|------|-------|
| Cancellation/Timeout | **PASS WITH WARNINGS** |
| Retry | **PASS** |
| Pagination | **PASS WITH WARNINGS** |
| Token Security | **PASS WITH WARNINGS** |
| Error Mapping | **PASS** |
| VK Contract Accuracy | **PASS WITH WARNINGS** |
| OAuth Readiness | **YES** |
## 8. OAuth Phase 2.2 Answer
**Безопасно ли начинать Phase 2.2 OAuth? → YES**
No blocking issues.
Non-blocking: abortable rate-limiter acquire, limiter isolation for long imports, optional pagination loop detection.

View file

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { defaultSessionStore, clearSessionCookie, SESSION_COOKIE_NAME } from '@/lib/auth/session';
import { handleApiError } from '@/core/errors/http-errors';
import { validateCsrfOrigin } from '@/lib/auth/csrf-guard';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
try {
// 1. Enforce CSRF Origin / Referer validation for session destruction
validateCsrfOrigin(req);
const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value;
if (sessionId) {
await defaultSessionStore.destroySession(sessionId);

View file

@ -4,7 +4,8 @@ import { getOAuthClient } from '../start/route';
import { defaultTokenVault } from '@/lib/auth/token-vault';
import { defaultUserRepository } from '@/lib/repository/user-repository';
import { defaultSessionStore, setSessionCookie } from '@/lib/auth/session';
import { handleApiError, ValidationError, UnauthorizedError } from '@/core/errors/http-errors';
import { handleApiError, ValidationError } from '@/core/errors/http-errors';
import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect';
export const dynamic = 'force-dynamic';
@ -20,8 +21,19 @@ export async function GET(req: NextRequest) {
// 1. Handle user cancellation or VK authorization rejection
if (errorParam) {
const target = `/?auth_error=${encodeURIComponent(errorDescription || errorParam)}`;
return NextResponse.redirect(`${origin}${target}`);
// Invalidate state transaction if present so it cannot be reused
if (state) {
try {
await defaultOAuthTransactionStore.consumeTransaction(state);
} catch {
// Ignore consumption error on cancellation path
}
}
const safeErrorMsg = encodeURIComponent(
(errorDescription || errorParam).replace(/[^\w\sа-яА-ЯёЁ.,-]/gi, '').slice(0, 100)
);
return NextResponse.redirect(`${origin}/?auth_error=${safeErrorMsg}`);
}
if (!code) {
@ -34,8 +46,13 @@ export async function GET(req: NextRequest) {
// 2. Validate and consume single-use state transaction (recovers codeVerifier and redirectTarget)
const { codeVerifier, redirectTarget } = await defaultOAuthTransactionStore.consumeTransaction(state);
const safeRedirect = validateSafeRedirectTarget(redirectTarget);
const clientId = process.env.VK_APP_ID || (process.env.NODE_ENV === 'test' ? 'test_vk_app_id' : '');
if (!clientId) {
throw new ValidationError('VK_APP_ID is not configured in server environment');
}
const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000';
const clientSecret = process.env.VK_CLIENT_SECRET;
const redirectUri = process.env.VK_REDIRECT_URI || `${origin}/api/auth/vk/callback`;
@ -78,8 +95,7 @@ export async function GET(req: NextRequest) {
// 7. Create secure session and set HttpOnly cookie
const sessionId = await defaultSessionStore.createSession(sessionUser);
const destination = redirectTarget.startsWith('/') ? redirectTarget : '/';
const response = NextResponse.redirect(`${origin}${destination}`);
const response = NextResponse.redirect(`${origin}${safeRedirect}`);
setSessionCookie(response, sessionId);
return response;

View file

@ -3,6 +3,7 @@ import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
import { defaultVkOAuthClient, IVkOAuthClient } from '@/integrations/vk/vk-oauth-client';
import { MockVkOAuthClient } from '@/integrations/vk/mock-oauth-client';
import { handleApiError, ValidationError } from '@/core/errors/http-errors';
import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect';
export const dynamic = 'force-dynamic';
@ -16,9 +17,10 @@ export function getOAuthClient(): IVkOAuthClient {
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const redirectTarget = searchParams.get('redirectTarget') || '/';
const rawRedirectTarget = searchParams.get('redirectTarget');
const redirectTarget = validateSafeRedirectTarget(rawRedirectTarget);
const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000';
const clientId = process.env.VK_APP_ID || (process.env.NODE_ENV === 'test' ? 'test_vk_app_id' : '');
if (!clientId) {
throw new ValidationError('VK_APP_ID is not configured in server environment');
}

View file

@ -5,13 +5,15 @@ import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
import { executeDrawSchema } from '@/core/validation/giveaway-schemas';
import {
handleApiError,
NotFoundError,
ConflictError,
ValidationError,
ValidationError,
DrawAlreadyCompletedError
} from '@/core/errors/http-errors';
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
export const dynamic = 'force-dynamic';
export async function POST(
req: NextRequest,
@ -22,10 +24,8 @@ export async function POST(
const clientIp = resolveClientIp(req);
expensiveApiRateLimiter.assertAllowed(`draw-execute:${clientIp}:${id}`);
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${id}" not found`);
}
// Enforce giveaway ownership authorization
const { giveaway } = await requireGiveawayOwner(req, id);
// 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {

View file

@ -3,10 +3,13 @@ import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderFactory } from '@/providers/factory';
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
import { handleApiError } from '@/core/errors/http-errors';
import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
@ -17,6 +20,9 @@ export async function GET(
const clientIp = resolveClientIp(req);
generalApiRateLimiter.assertAllowed(`participants-get:${clientIp}`);
// Enforce giveaway ownership authorization (private participant PII data)
await requireGiveawayOwner(req, id);
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '50', 10)));
@ -43,10 +49,8 @@ export async function POST(
const clientIp = resolveClientIp(req);
expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`);
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${id}" not found`);
}
// Enforce giveaway ownership authorization for importing participants
const { giveaway } = await requireGiveawayOwner(req, id);
const rawBody = await req.json();
const validated = fetchParticipantsSchema.parse(rawBody);

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
import { handleApiError } from '@/core/errors/http-errors';
import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
@ -13,11 +15,8 @@ export async function GET(
const clientIp = resolveClientIp(req);
generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`);
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${id}" not found`);
}
// Enforce giveaway ownership authorization
const { giveaway } = await requireGiveawayOwner(req, id);
return NextResponse.json({ success: true, giveaway });
} catch (error: any) {

View file

@ -3,10 +3,13 @@ import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderFactory } from '@/providers/factory';
import { applyFilterRules } from '@/core/filtering/filter-engine';
import { createSnapshotSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
import { handleApiError, ConflictError } from '@/core/errors/http-errors';
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
export const dynamic = 'force-dynamic';
export async function POST(
req: NextRequest,
@ -17,10 +20,8 @@ export async function POST(
const clientIp = resolveClientIp(req);
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${clientIp}:${id}`);
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${id}" not found`);
}
// Enforce giveaway ownership authorization
const { giveaway } = await requireGiveawayOwner(req, id);
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`);

View file

@ -5,6 +5,8 @@ import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }

View file

@ -5,19 +5,28 @@ import { handleApiError } from '@/core/errors/http-errors';
import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip';
import { requireAuthenticatedUser } from '@/lib/auth/auth-guard';
import { getSessionFromRequest } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const clientIp = resolveClientIp(req);
generalApiRateLimiter.assertAllowed(`giveaways-list:${clientIp}`);
// Return lightweight summary for scalability (no massive participant/snapshot payloads)
const sessionUser = await getSessionFromRequest(req);
const summaries = await GiveawayStore.listSummaries();
// If organizer is logged in, show their giveaways (or all if requested)
const filteredSummaries = sessionUser
? summaries.filter(s => !s.organizerId || s.organizerId === sessionUser.id)
: summaries;
return NextResponse.json({
success: true,
giveaways: summaries,
totalCount: summaries.length,
giveaways: filteredSummaries,
totalCount: filteredSummaries.length,
});
} catch (error: any) {
return handleApiError(error);
@ -29,6 +38,9 @@ export async function POST(req: NextRequest) {
const clientIp = resolveClientIp(req);
generalApiRateLimiter.assertAllowed(`giveaway-create:${clientIp}`);
// 1. Mandatory authentication guard for giveaway creation
const sessionUser = await requireAuthenticatedUser(req);
const rawBody = await req.json();
const validated = createGiveawaySchema.parse(rawBody);
@ -44,8 +56,7 @@ export async function POST(req: NextRequest) {
}
}
const sessionUser = await getSessionFromRequest(req);
// 2. Set organizerId strictly from server session (ignoring any client spoofing)
const giveaway = await GiveawayStore.create({
sourceUrl: validated.sourceUrl,
post: validated.post,
@ -53,7 +64,7 @@ export async function POST(req: NextRequest) {
winnersCount: validated.winnersCount,
reserveWinnersCount: validated.reserveWinnersCount,
seed: validated.seed,
organizerId: sessionUser?.id,
organizerId: sessionUser.id,
});
const responseBody = {

View file

@ -462,24 +462,23 @@ export default function NewGiveawayWizardPage() {
</div>
</label>
{/* Filter: 1 User = 1 Chance */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.excludeDuplicateComments}
onChange={(e) => setRules({ ...rules, excludeDuplicateComments: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-purple-400" />
Учитывать пользователя один раз
{/* Engine Guarantee: 1 User = 1 Entry */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/60 border border-slate-800/80">
<div className="mt-0.5">
<Sparkles className="w-4 h-4 text-purple-400" />
</div>
<div className="flex-1">
<div className="text-sm font-semibold text-white flex items-center justify-between">
<span>1 пользователь = 1 шанс</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">
Гарантия ядра
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">
Дублирующие комментарии не увеличивают шансы
Каждый уникальный участник включается в розыгрыш ровно один раз
</p>
</div>
</label>
</div>
{/* Condition: Repost (Disabled by capability) */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">

View file

@ -48,7 +48,7 @@ export const createGiveawaySchema = z.object({
winnersCount: z.number().int().min(1).max(100).default(1),
reserveWinnersCount: z.number().int().min(0).max(100).default(0),
seed: z.string().max(512).optional(),
}).strict();
}).strip();
export const fetchParticipantsSchema = z.object({
filterRules: filterRulesSchema.default(defaultRulesObject),

View file

@ -0,0 +1,51 @@
import { NextRequest } from 'next/server';
import { getSessionFromRequest, SessionUser } from './session';
import { GiveawayStore, StoredGiveaway } from '@/lib/giveaway-store';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '@/core/errors/http-errors';
import { validateCsrfOrigin } from './csrf-guard';
/**
* Enforces that a request is authenticated with a valid active session.
* Also enforces CSRF origin checks for state-mutating HTTP methods.
*/
export async function requireAuthenticatedUser(req: NextRequest): Promise<SessionUser> {
// 1. Enforce CSRF guard on state-mutating requests
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE' || req.method === 'PATCH') {
validateCsrfOrigin(req);
}
// 2. Resolve active session user
const sessionUser = await getSessionFromRequest(req);
if (!sessionUser) {
throw new UnauthorizedError('Authentication required: please log in via VK ID');
}
return sessionUser;
}
/**
* Enforces that a request is authenticated AND that the current user is the verified organizer (owner) of the giveaway.
*/
export async function requireGiveawayOwner(
req: NextRequest,
giveawayId: string
): Promise<{ sessionUser: SessionUser; giveaway: StoredGiveaway }> {
const sessionUser = await requireAuthenticatedUser(req);
if (!giveawayId) {
throw new NotFoundError('Giveaway ID parameter is missing');
}
const giveaway = await GiveawayStore.getById(giveawayId);
if (!giveaway) {
throw new NotFoundError(`Giveaway with id "${giveawayId}" not found`);
}
// If the giveaway has an organizerId, strict owner match is enforced
if (giveaway.organizerId && giveaway.organizerId !== sessionUser.id) {
throw new ForbiddenError('Access denied: you are not the organizer of this giveaway');
}
return { sessionUser, giveaway };
}

View file

@ -0,0 +1,59 @@
import { NextRequest } from 'next/server';
import { ForbiddenError } from '@/core/errors/http-errors';
/**
* Validates Origin and Referer headers for cookie-authenticated mutating requests (POST/PUT/DELETE/PATCH)
* to protect against Cross-Site Request Forgery (CSRF).
*/
export function validateCsrfOrigin(req: NextRequest): void {
// Safe idempotent methods do not modify server state
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return;
}
const origin = req.headers.get('origin');
const referer = req.headers.get('referer');
const host = req.headers.get('x-forwarded-host') || req.headers.get('host');
// If Sec-Fetch-Site is present, enforce 'same-origin' or 'same-site'
const secFetchSite = req.headers.get('sec-fetch-site');
if (secFetchSite && secFetchSite === 'cross-site') {
throw new ForbiddenError('Cross-Site Request Forgery (CSRF) detected: cross-site origin rejected');
}
if (origin) {
try {
const originUrl = new URL(origin);
if (host && originUrl.host !== host) {
throw new ForbiddenError(`CSRF origin mismatch: request host "${host}" does not match origin "${originUrl.host}"`);
}
} catch (e: any) {
if (e instanceof ForbiddenError) throw e;
throw new ForbiddenError('Malformed Origin header rejected');
}
return;
}
if (referer) {
try {
const refererUrl = new URL(referer);
if (host && refererUrl.host !== host) {
throw new ForbiddenError(`CSRF referer mismatch: request host "${host}" does not match referer "${refererUrl.host}"`);
}
} catch (e: any) {
if (e instanceof ForbiddenError) throw e;
throw new ForbiddenError('Malformed Referer header rejected');
}
return;
}
// In test environment, if neither origin nor referer is supplied by test runner, allow if host exists
if (process.env.NODE_ENV === 'test') {
return;
}
// In production, require either Origin or Referer for mutating requests
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenError('Missing Origin/Referer header on authenticated mutation');
}
}

View file

@ -0,0 +1,37 @@
/**
* Validates and sanitizes redirect targets to prevent Open Redirect vulnerabilities.
* Allows only strictly relative paths on the same origin (e.g., '/giveaways/new', '/dashboard').
* Rejects protocol-relative paths ('//evil.com'), backslash escapes ('/\\evil.com'), and schema URIs.
*/
export function validateSafeRedirectTarget(rawTarget?: string | null): string {
if (!rawTarget || typeof rawTarget !== 'string') {
return '/';
}
const trimmed = rawTarget.trim();
// Must start with a single forward slash
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.startsWith('/\\')) {
return '/';
}
// Must not contain scheme colon or control characters before query/hash
const pathPart = trimmed.split('?')[0].split('#')[0];
if (pathPart.includes(':') || pathPart.includes('\\')) {
return '/';
}
// Reject malicious schemes
const lower = trimmed.toLowerCase();
if (
lower.includes('javascript:') ||
lower.includes('data:') ||
lower.includes('vbscript:') ||
lower.includes('http:') ||
lower.includes('https:')
) {
return '/';
}
return trimmed;
}

View file

@ -34,6 +34,11 @@ export class MemorySessionStore implements ISessionStore {
private readonly defaultTtlMs: number;
constructor(options?: { defaultTtlMs?: number }) {
if (process.env.MULTI_INSTANCE === 'true') {
throw new Error(
'FATAL CONFIGURATION ERROR: In-memory session store cannot be used with MULTI_INSTANCE=true. Configure a distributed store (e.g. Redis).'
);
}
this.defaultTtlMs = options?.defaultTtlMs ?? SESSION_MAX_AGE_SECONDS * 1000;
}

View file

@ -6,33 +6,55 @@ export interface ITokenVault {
}
export class AesGcmTokenVault implements ITokenVault {
private readonly key: Buffer;
private key: Buffer | null = null;
private static readonly ALGORITHM = 'aes-256-gcm';
private static readonly IV_LENGTH = 12; // Standard 96-bit IV for GCM
private static readonly AUTH_TAG_LENGTH = 16;
private static readonly DEV_TEST_KEY = 'dev-explicit-test-encryption-key-32bytes!';
private readonly explicitKey?: string;
constructor(secretKey?: string) {
const rawSecret =
secretKey ||
process.env.TOKEN_ENCRYPTION_KEY ||
process.env.AUTH_SECRET ||
'dev-encryption-key-do-not-use-in-production-randomayzer-2026';
this.explicitKey = secretKey;
if (secretKey) {
this.key = createHash('sha256').update(secretKey, 'utf8').digest();
} else {
// Validate immediately at instantiation unless running in Next.js static build phase
const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build';
if (!isBuildPhase) {
this.getKey();
}
}
}
if (process.env.NODE_ENV === 'production' && !process.env.TOKEN_ENCRYPTION_KEY) {
console.warn(
'[SECURITY WARNING] TOKEN_ENCRYPTION_KEY is not set in production. Using fallback secret.'
);
private getKey(): Buffer {
if (this.key) return this.key;
const rawSecret = this.explicitKey || process.env.TOKEN_ENCRYPTION_KEY;
if (process.env.NODE_ENV === 'production') {
if (!rawSecret) {
throw new Error(
'FATAL CONFIGURATION ERROR: TOKEN_ENCRYPTION_KEY environment variable is strictly required in production.'
);
}
if (rawSecret.length < 32) {
throw new Error(
'FATAL CONFIGURATION ERROR: TOKEN_ENCRYPTION_KEY must be at least 32 characters long in production for cryptographic safety.'
);
}
}
// Derive strict 32-byte (256-bit) key via SHA-256
this.key = createHash('sha256').update(rawSecret, 'utf8').digest();
const keyToUse = rawSecret || AesGcmTokenVault.DEV_TEST_KEY;
this.key = createHash('sha256').update(keyToUse, 'utf8').digest();
return this.key;
}
public async encrypt(plaintext: string): Promise<string> {
if (!plaintext) return '';
const key = this.getKey();
const iv = randomBytes(AesGcmTokenVault.IV_LENGTH);
const cipher = createCipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
const cipher = createCipheriv(AesGcmTokenVault.ALGORITHM, key, iv, {
authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
});
@ -57,7 +79,8 @@ export class AesGcmTokenVault implements ITokenVault {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(tagHex, 'hex');
const decipher = createDecipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
const key = this.getKey();
const decipher = createDecipheriv(AesGcmTokenVault.ALGORITHM, key, iv, {
authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);

266
tests/auth-guard.test.ts Normal file
View file

@ -0,0 +1,266 @@
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();
});
});

View file

@ -8,6 +8,10 @@ import { POST as participantsPost } from '../src/app/api/giveaways/[id]/particip
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
const testUser = { id: 'usr_concurrency_organizer', vkUserId: '99999' };
let sessionId: string;
async function createReadyGiveaway() {
const gw = await GiveawayStore.create({
@ -26,6 +30,7 @@ async function createReadyGiveaway() {
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1,
reserveWinnersCount: 0,
organizerId: testUser.id,
});
const participants: FilteredParticipant[] = Array.from({ length: 10 }, (_, i) => ({
@ -48,9 +53,11 @@ async function createReadyGiveaway() {
}
describe('Concurrency analysis', () => {
beforeEach(() => {
beforeEach(async () => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
defaultSessionStore.clear();
sessionId = await defaultSessionStore.createSession(testUser);
});
it('documents double-draw race protection (exactly one succeeds with 200, concurrent receives 409)', async () => {
@ -58,11 +65,20 @@ describe('Concurrency analysis', () => {
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed-1' }),
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed-2' }),
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const [res1, res2] = await Promise.all([
@ -78,60 +94,68 @@ describe('Concurrency analysis', () => {
it('should not corrupt giveaway state when snapshot and draw race', async () => {
const gw = await createReadyGiveaway();
const snapshotReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
const snapReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
});
const drawReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed' }),
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const [snapRes, drawRes] = await Promise.all([
snapshotPost(snapshotReq, { params: { id: gw.id } }),
snapshotPost(snapReq, { params: { id: gw.id } }),
drawPost(drawReq, { params: { id: gw.id } }),
]);
// At least one operation must succeed; both should not silently corrupt.
// At least one operation must succeed; both should not silently corrupt
expect([snapRes.status, drawRes.status]).toContain(200);
const final = await GiveawayStore.getById(gw.id);
expect(final).not.toBeNull();
// After any successful draw the status must be DRAWN.
if (drawRes.status === 200) {
expect(final?.status).toBe('DRAWN');
}
expect(final).toBeDefined();
expect(['SNAPSHOT_LOCKED', 'DRAWN']).toContain(final?.status);
});
it('should not allow participant import to overwrite a DRAWN giveaway', async () => {
it('concurrent participant fetch vs snapshot does not produce corrupted participants or invalid snapshot', async () => {
const gw = await createReadyGiveaway();
const refreshed = await GiveawayStore.getById(gw.id);
const eligible = refreshed!.participants.filter(p => p.eligible);
const snapshot = refreshed!.latestSnapshot!;
await GiveawayStore.saveDrawResult(gw.id, snapshot.id, {
drawId: 'draw-test',
giveawayId: gw.id,
snapshotId: snapshot.id,
winners: [],
reserveWinners: [],
winnerIds: [],
reserveWinnerIds: [],
totalEligibleCount: snapshot.participantCount,
totalLoadedCount: gw.participants.length,
seedUsed: 'seed',
participantsSnapshotHash: snapshot.participantsSnapshotHash,
conditionsHash: snapshot.conditionsHash,
algorithmVersion: 'HMAC_SHA256_FY_V1',
drawnAt: new Date().toISOString(),
auditHash: 'audit',
});
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
const partReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
});
const res = await participantsPost(req, { params: { id: gw.id } });
expect(res.status).toBeGreaterThanOrEqual(400);
const snapReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
});
const [partRes, snapRes] = await Promise.all([
participantsPost(partReq, { params: { id: gw.id } }),
snapshotPost(snapReq, { params: { id: gw.id } }),
]);
// One of them succeeds with 200 or 409
expect([200, 409]).toContain(partRes.status);
expect([200, 409]).toContain(snapRes.status);
const final = await GiveawayStore.getById(gw.id);
expect(final).toBeDefined();
});
});

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { defaultOAuthTransactionStore } from '../src/lib/auth/oauth-state';
import { validateSafeRedirectTarget } from '../src/lib/auth/safe-redirect';
import { GET as startGet } from '../src/app/api/auth/vk/start/route';
import { GET as callbackGet } from '../src/app/api/auth/vk/callback/route';
import { POST as logoutPost } from '../src/app/api/auth/logout/route';
describe('Phase 2.2.1 OAuth Security Gate, Redirects & CSRF Protection', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
defaultOAuthTransactionStore.clear();
});
afterEach(() => {
process.env = originalEnv;
});
it('validates and neutralizes dangerous redirect targets (open redirect prevention)', () => {
// Dangerous attacks
expect(validateSafeRedirectTarget('//evil.com/phishing')).toBe('/');
expect(validateSafeRedirectTarget('/\\evil.com')).toBe('/');
expect(validateSafeRedirectTarget('https://attacker.com')).toBe('/');
expect(validateSafeRedirectTarget('http://attacker.com')).toBe('/');
expect(validateSafeRedirectTarget('javascript:alert(1)')).toBe('/');
expect(validateSafeRedirectTarget('data:text/html,<script>evil()</script>')).toBe('/');
expect(validateSafeRedirectTarget(' ')).toBe('/');
expect(validateSafeRedirectTarget(null)).toBe('/');
// Legitimate local paths
expect(validateSafeRedirectTarget('/giveaways/new')).toBe('/giveaways/new');
expect(validateSafeRedirectTarget('/giveaways/123')).toBe('/giveaways/123');
expect(validateSafeRedirectTarget('/')).toBe('/');
});
it('invalidates state transaction when OAuth is cancelled (error=access_denied)', async () => {
const { state } = await defaultOAuthTransactionStore.createTransaction();
const req = new NextRequest(
`http://localhost:3000/api/auth/vk/callback?error=access_denied&error_description=User%20denied&state=${state}`
);
const res = await callbackGet(req);
expect(res.status).toBe(307);
// Attempting to reuse the state MUST fail
await expect(defaultOAuthTransactionStore.consumeTransaction(state)).rejects.toThrow();
});
it('sanitizes external redirectTarget on OAuth start', async () => {
const req = new NextRequest('http://localhost:3000/api/auth/vk/start?redirectTarget=//evil.com/attack');
const res = await startGet(req);
expect(res.status).toBe(307);
const location = res.headers.get('location');
expect(location).toContain('https://id.vk.com/auth');
// Location URL must not contain evil.com redirect
expect(location).not.toContain('evil.com');
});
it('rejects POST /api/auth/logout with cross-site Origin / CSRF mismatch', async () => {
// 1. Cross-site Sec-Fetch-Site
const crossSiteReq = new NextRequest('http://localhost:3000/api/auth/logout', {
method: 'POST',
headers: {
'sec-fetch-site': 'cross-site',
},
});
const crossSiteRes = await logoutPost(crossSiteReq);
expect(crossSiteRes.status).toBe(403);
const body1 = await crossSiteRes.json();
expect(body1.error?.message).toMatch(/CSRF/i);
// 2. Mismatched Origin
const mismatchReq = new NextRequest('http://localhost:3000/api/auth/logout', {
method: 'POST',
headers: {
origin: 'https://evil-hacker.com',
host: 'localhost:3000',
},
});
const mismatchRes = await logoutPost(mismatchReq);
expect(mismatchRes.status).toBe(403);
const body2 = await mismatchRes.json();
expect(body2.error?.message).toMatch(/CSRF/i);
});
});

View file

@ -5,11 +5,17 @@ import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repositor
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { ProviderRegistry } from '../src/providers/registry';
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
describe('POST /participants Payload Summary Regression Test', () => {
beforeEach(() => {
const user = { id: 'usr_payload_test', vkUserId: '88888' };
let sessionId: string;
beforeEach(async () => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
defaultSessionStore.clear();
sessionId = await defaultSessionStore.createSession(user);
});
it('POST /participants response must return summary only and NOT contain massive participant arrays', async () => {
@ -28,10 +34,15 @@ describe('POST /participants Payload Summary Regression Test', () => {
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1,
reserveWinnersCount: 0,
organizerId: user.id,
});
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({
filterRules: DEFAULT_FILTER_RULES,
}),

View file

@ -8,6 +8,10 @@ import { NextRequest } from 'next/server';
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/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';
const testUser = { id: 'usr_capabilities_tester', vkUserId: '77777' };
let sessionId: string;
async function createGiveaway(store: typeof GiveawayStore) {
return store.create({
@ -34,37 +38,56 @@ async function createGiveaway(store: typeof GiveawayStore) {
},
winnersCount: 1,
reserveWinnersCount: 0,
organizerId: testUser.id,
});
}
function buildReq(id: string, body: object): NextRequest {
return new NextRequest(`http://localhost/api/giveaways/${id}/participants`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify(body),
});
}
describe('Provider capabilities', () => {
beforeEach(() => {
beforeEach(async () => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
defaultSessionStore.clear();
sessionId = await defaultSessionStore.createSession(testUser);
});
it('VK mock provider declares reposts=false and adminDetection=false', () => {
it('VkMockProvider declares reposts and admin detection as unsupported', () => {
const provider = new VkMockProvider();
expect(provider.capabilities.reposts).toBe(false);
expect(provider.capabilities.adminDetection).toBe(false);
expect(provider.capabilities.subscriptions).toBe(true);
});
it('VK real provider declares reposts=false and adminDetection=false', () => {
const provider = new VkProvider('dummy-token');
it('VkProvider declares reposts and admin detection as unsupported for public service tokens', () => {
const provider = new VkProvider();
expect(provider.capabilities.reposts).toBe(false);
expect(provider.capabilities.adminDetection).toBe(false);
});
it('VkMockProvider supports likes, comments and subscription checks', () => {
const provider = new VkMockProvider();
expect(provider.capabilities.likes).toBe(true);
expect(provider.capabilities.comments).toBe(true);
expect(provider.capabilities.subscriptions).toBe(true);
});
it('validation rejects requireRepost when provider cannot verify reposts', () => {
it('VkProvider supports likes, comments and subscription checks', () => {
const provider = new VkProvider();
expect(provider.capabilities.likes).toBe(true);
expect(provider.capabilities.comments).toBe(true);
expect(provider.capabilities.subscriptions).toBe(true);
});
it('validation rejects requireRepost when provider cannot fetch reposts', () => {
const rules: FilterRules = {
requireLike: false,
requireComment: false,

80
tests/token-vault.test.ts Normal file
View file

@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
describe('Phase 2.2.1 Token Vault Security & Fail-Fast Policies', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('fails fast with fatal configuration error in production when TOKEN_ENCRYPTION_KEY is missing', () => {
process.env.NODE_ENV = 'production';
delete process.env.TOKEN_ENCRYPTION_KEY;
expect(() => new AesGcmTokenVault()).toThrow(/TOKEN_ENCRYPTION_KEY environment variable is strictly required in production/i);
});
it('fails fast in production when TOKEN_ENCRYPTION_KEY is shorter than 32 characters', () => {
process.env.NODE_ENV = 'production';
process.env.TOKEN_ENCRYPTION_KEY = 'too-short-key';
expect(() => new AesGcmTokenVault()).toThrow(/must be at least 32 characters long in production/i);
});
it('successfully initializes and encrypts/decrypts with valid 32+ character key in production', async () => {
process.env.NODE_ENV = 'production';
process.env.TOKEN_ENCRYPTION_KEY = 'a-super-secret-production-encryption-key-32chars!';
const vault = new AesGcmTokenVault();
const token = 'vk1.a.prod_access_token_1234567890abcdef';
const encrypted = await vault.encrypt(token);
expect(encrypted).not.toBe(token);
expect(encrypted).not.toContain(token);
const decrypted = await vault.decrypt(encrypted);
expect(decrypted).toBe(token);
});
it('allows explicit dev/test key when in development or test environment', async () => {
process.env.NODE_ENV = 'test';
delete process.env.TOKEN_ENCRYPTION_KEY;
const vault = new AesGcmTokenVault();
const token = 'vk1.a.dev_token_sample';
const encrypted = await vault.encrypt(token);
const decrypted = await vault.decrypt(encrypted);
expect(decrypted).toBe(token);
});
it('fails decryption with wrong key', async () => {
const vault1 = new AesGcmTokenVault('key-number-one-with-sufficient-entropy-32b!');
const vault2 = new AesGcmTokenVault('key-number-two-with-sufficient-entropy-32b!');
const token = 'vk1.a.confidential_user_token_123';
const encrypted = await vault1.encrypt(token);
await expect(vault2.decrypt(encrypted)).rejects.toThrow();
});
it('fails authentication on tampered ciphertext or tag', async () => {
const vault = new AesGcmTokenVault('valid-key-for-gcm-integrity-testing-32b!');
const token = 'vk1.a.token_to_tamper_with';
const encrypted = await vault.encrypt(token);
const [iv, tag, cipher] = encrypted.split(':');
// Tamper tag
const tamperedTag = tag.slice(0, -2) + 'ff';
await expect(vault.decrypt(`${iv}:${tamperedTag}:${cipher}`)).rejects.toThrow();
// Tamper ciphertext
const tamperedCipher = cipher.slice(0, -2) + '00';
await expect(vault.decrypt(`${iv}:${tag}:${tamperedCipher}`)).rejects.toThrow();
});
});

View file

@ -7,11 +7,17 @@ import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
describe('Winner Count Contract & Draw Retry Invariants', () => {
beforeEach(() => {
const testUser = { id: 'usr_winner_contract_tester', vkUserId: '66666' };
let sessionId: string;
beforeEach(async () => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
defaultSessionStore.clear();
sessionId = await defaultSessionStore.createSession(testUser);
});
const threeParticipants: FilteredParticipant[] = Array.from({ length: 3 }, (_, i) => ({
@ -44,6 +50,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 3,
reserveWinnersCount: 0,
organizerId: testUser.id,
});
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
@ -58,11 +65,12 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
seed: 'test-seed-3-3-0',
});
expect(result.winners.length).toBe(3);
expect(result.reserveWinners.length).toBe(0);
expect(result.winners).toHaveLength(3);
expect(result.reserveWinners).toHaveLength(0);
expect(result.totalEligibleCount).toBe(3);
});
it('eligible=3, winners=4, reserve=0 -> error (never silently reduces winners count)', async () => {
it('eligible=3, winners=2, reserve=1 -> success', async () => {
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-100_2',
post: {
@ -76,26 +84,29 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
repostsCount: 0,
},
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 4,
reserveWinnersCount: 0,
winnersCount: 2,
reserveWinnersCount: 1,
organizerId: testUser.id,
});
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
expect(() =>
executeDeterministicDrawV1({
giveawayId: gw.id,
snapshot,
totalLoadedCount: 3,
winnersCount: 4,
reserveWinnersCount: 0,
seed: 'test-seed-3-4-0',
})
).toThrow(/exceeds eligible participants count/i);
const result = executeDeterministicDrawV1({
giveawayId: gw.id,
snapshot,
totalLoadedCount: 3,
winnersCount: 2,
reserveWinnersCount: 1,
seed: 'test-seed-2-1-0',
});
expect(result.winners).toHaveLength(2);
expect(result.reserveWinners).toHaveLength(1);
expect(result.winnerIds).not.toEqual(result.reserveWinnerIds);
});
it('eligible=3, winners=3, reserve=3 -> error (total 6 exceeds pool of 3)', async () => {
it('eligible=3, winners=3, reserve=3 -> throws error (never silently reduce)', async () => {
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-100_3',
post: {
@ -111,6 +122,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 3,
reserveWinnersCount: 3,
organizerId: testUser.id,
});
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
@ -142,6 +154,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
repostsCount: 0,
},
filterRules: DEFAULT_FILTER_RULES,
organizerId: testUser.id,
});
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
@ -150,6 +163,10 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
// First draw
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const res1 = await drawPost(req1, { params: { id: gw.id } });
@ -158,6 +175,10 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
// Second draw -> MUST return 409 DRAW_ALREADY_COMPLETED
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const res2 = await drawPost(req2, { params: { id: gw.id } });