From 455bd8cab6a0f849a5e350be652a4e6a5ebc152c Mon Sep 17 00:00:00 2001 From: Davichi-1 <108071481+Davichi-1@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:48:35 +0100 Subject: [PATCH] feat: non-prod sample market seed endpoint Add POST /api/admin/seed for development/staging/test environments that inserts a small, fixed batch of sample markets for E2E and demos. - Non-prod only: 404 in production (route hidden before auth) plus a service-level SeedNotAllowedError guard as defense-in-depth. - Idempotent: stable primary keys + ON CONFLICT DO NOTHING; repeat calls insert nothing and report rows as skipped. - Tracked: seeded rows tagged with metadata.seeded=true + batch version and listable via seedService.listSeeded(). - Secure: admin JWT guard, per-token rate limit, strict body validation with the standard error envelope, structured logging + audit trail with correlation ids. - Documented in docs/seed.md; covered by route + service tests. Closes #160 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/seed.md | 133 ++++++++++++++++ src/index.ts | 2 + src/routes/admin/seed.ts | 125 +++++++++++++++ src/services/seedService.ts | 299 ++++++++++++++++++++++++++++++++++++ tests/adminSeed.test.ts | 248 ++++++++++++++++++++++++++++++ tests/seedService.test.ts | 200 ++++++++++++++++++++++++ 6 files changed, 1007 insertions(+) create mode 100644 docs/seed.md create mode 100644 src/routes/admin/seed.ts create mode 100644 src/services/seedService.ts create mode 100644 tests/adminSeed.test.ts create mode 100644 tests/seedService.test.ts diff --git a/docs/seed.md b/docs/seed.md new file mode 100644 index 0000000..07fcdd2 --- /dev/null +++ b/docs/seed.md @@ -0,0 +1,133 @@ +# Sample Market Seeding (non-production) + +`POST /api/admin/seed` inserts a small, fixed batch of **sample markets** so that +E2E suites and demos have predictable data to work against. It is intended for +**development, staging, and test** environments only. + +- **Source:** [`src/routes/admin/seed.ts`](../src/routes/admin/seed.ts), + [`src/services/seedService.ts`](../src/services/seedService.ts) +- **Related:** [errors.md](./errors.md), [log-events.md](./log-events.md), + [rate-limiting.md](./rate-limiting.md) + +## Endpoint + +``` +POST /api/admin/seed +Authorization: Bearer +Content-Type: application/json + +{} # empty body — the endpoint takes no parameters +``` + +### Success — `200 OK` + +```json +{ + "data": { + "requested": 5, + "inserted": 5, + "skipped": 0, + "batchVersion": 1, + "insertedIds": [ + "seed-market-001", + "seed-market-002", + "seed-market-003", + "seed-market-004", + "seed-market-005" + ], + "markets": [ + { + "id": "seed-market-001", + "question": "Will BTC close above $100k by year end?", + "status": "open", + "resolutionTime": "2026-07-30T10:00:00.000Z" + } + ] + } +} +``` + +| Field | Meaning | +| ------------- | ------------------------------------------------------------------- | +| `requested` | Number of sample markets the batch defines (currently 5). | +| `inserted` | Rows **this call** actually created. | +| `skipped` | Rows that already existed and were left untouched (idempotent skip). | +| `batchVersion`| Version of the sample batch that produced the seed. | +| `insertedIds` | Ids created by this call (empty on a repeat run). | +| `markets` | Every seeded market currently tracked in the database. | + +## Behaviour + +### Non-production only + +The endpoint is unavailable in production: + +- In production the router responds **`404 not_found`** *before* authentication, + so the route is not even probeable. +- As defense-in-depth, `seedSampleMarkets()` itself throws `SeedNotAllowedError` + (→ `403 seed_not_allowed`) if invoked when `NODE_ENV=production`, so sample + data can never be written to a production database even via a direct call. + +### Idempotent + +Each sample market has a **stable primary key** (`seed-market-001` …). Inserts +use `ON CONFLICT (id) DO NOTHING`, so: + +- The **first** call inserts the full batch (`inserted: 5, skipped: 0`). +- Every **subsequent** call inserts nothing (`inserted: 0, skipped: 5`) and + returns `200`. No duplicates are ever created. + +### Tracked + +Every seeded row is tagged in its `metadata` column: + +```json +{ "seeded": true, "seedBatchVersion": 1, "outcomes": ["yes", "no"] } +``` + +Seeded markets can therefore be listed and distinguished from real +(indexed / admin-created) markets — `seedService.listSeeded()` returns exactly +the rows where `metadata->>'seeded' = 'true'`. + +## Security + +| Layer | Behaviour | +| ---------------- | ------------------------------------------------------------ | +| Production guard | `404` in production (route hidden, runs before auth). | +| Rate limit | `30 req/min` per admin token (IP fallback) → `429`. | +| Admin auth | Valid admin JWT required; otherwise `403 forbidden`. | +| Input validation | Body validated with a strict schema; extra fields → `400 validation_error`. | + +## Observability + +- Emits a `market.created` structured log event per inserted row, carrying the + `correlationId`, the admin `actor`, and `seeded: true`. +- Writes an `admin.seed_markets` entry to `audit_logs` (actor address, IP, + correlation id). +- Responses and logs propagate the request id via the standard + `X-Request-Id` correlation header. + +## Error envelope + +All errors use the standard envelope: + +```json +{ "error": { "code": "validation_error", "details": [], "requestId": "" } } +``` + +| Status | `code` | When | +| ------ | --------------------- | ----------------------------------------------- | +| 400 | `validation_error` | Unexpected fields in the request body. | +| 403 | `forbidden` | Missing / invalid / non-admin JWT. | +| 403 | `seed_not_allowed` | Service invoked directly in production. | +| 404 | `not_found` | Endpoint called in production. | +| 429 | `rate_limit_exceeded` | Per-token rate limit exceeded. | + +## Example + +```bash +curl -X POST http://localhost:3001/api/admin/seed \ + -H "Authorization: Bearer $ADMIN_JWT" \ + -H "Content-Type: application/json" \ + -d '{}' +``` diff --git a/src/index.ts b/src/index.ts index 4934f1d..1f77b53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { notificationsRouter } from "./routes/notifications"; import { socialRouter } from "./routes/social"; import { adminAuditRouter } from "./routes/admin/audit"; import { adminMarketsRouter } from "./routes/admin/markets"; +import { adminSeedRouter } from "./routes/admin/seed"; import { errorHandler } from "./middleware/errorHandler"; import { requestContextStorage } from "./lib/requestContext"; import { REQUEST_ID_HEADER } from "./lib/http"; @@ -105,6 +106,7 @@ export function createApp(_options?: unknown): express.Express { app.use("/api/me/devices", devicesRouter); app.use("/api/admin/audit", adminAuditRouter); app.use("/api/admin/markets", adminMarketsRouter); + app.use("/api/admin/seed", adminSeedRouter); app.get("/metrics", async (req, res) => { const metricsAuthToken = process.env.METRICS_AUTH_TOKEN; diff --git a/src/routes/admin/seed.ts b/src/routes/admin/seed.ts new file mode 100644 index 0000000..f7392c7 --- /dev/null +++ b/src/routes/admin/seed.ts @@ -0,0 +1,125 @@ +/** + * Admin sample-market seed router — NON-PRODUCTION ONLY. + * + * POST /api/admin/seed → insert a small, fixed batch of sample markets + * (idempotent) for E2E tests and demos. + * + * Security layers (outermost first): + * 1. Production guard — in production the endpoint behaves as if it does not + * exist (404). This runs BEFORE auth so the route is not even probeable. + * 2. Rate limit — 30 requests/min, keyed per admin token (IP fallback). + * 3. requireAdmin — valid admin JWT required (403 otherwise). + * + * The seed itself is idempotent (see src/services/seedService.ts): repeat calls + * insert nothing and report the existing rows as "skipped". + */ + +import { Router, type Request, type Response } from "express"; +import { rateLimit } from "express-rate-limit"; +import { z } from "zod"; +import { env } from "../../config/env"; +import { requireAdmin } from "../../middleware/requireAdmin"; +import { seedSampleMarkets, SeedNotAllowedError } from "../../services/seedService"; + +/** Pulls the first valid IP from X-Forwarded-For or falls back to socket/ip. */ +function extractClientIp(req: Request): string { + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string") { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + if (Array.isArray(forwarded) && forwarded.length > 0) { + return forwarded[0]!; + } + return req.ip ?? req.socket.remoteAddress ?? "unknown"; +} + +function requestIdOf(req: { id?: unknown }): string { + return typeof req.id === "string" ? req.id : ""; +} + +// The seed endpoint takes no parameters. `.strict()` rejects any unexpected +// fields at the boundary so callers get a clear validation error rather than a +// silently-ignored payload. +const bodySchema = z.object({}).strict(); + +export interface AdminSeedRouterOptions { + /** Requests per minute per admin token. Default: 30 */ + rateLimitPerMinute?: number; +} + +export function createAdminSeedRouter(opts: AdminSeedRouterOptions = {}): Router { + const router = Router(); + const limit = opts.rateLimitPerMinute ?? 30; + + // 1. Production guard — hide the endpoint entirely outside non-prod. + router.use((req, res, next) => { + if (env.NODE_ENV === "production") { + res + .status(404) + .json({ error: { code: "not_found", requestId: requestIdOf({ id: req.id }) } }); + return; + } + next(); + }); + + // 2. Per-admin-token rate limit (IP fallback for unauthenticated callers). + router.use( + rateLimit({ + windowMs: 60_000, + limit, + keyGenerator: (req) => + (req.headers.authorization as string | undefined) ?? req.ip ?? "unknown", + standardHeaders: "draft-6", + legacyHeaders: false, + message: { error: { code: "rate_limit_exceeded" } }, + }), + ); + + // 3. Admin guard. + router.use(requireAdmin); + + router.post("/", async (req: Request, res: Response, next) => { + const requestId = requestIdOf({ id: req.id }); + + const parsed = bodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + details: parsed.error.issues, + requestId, + }, + }); + return; + } + + if (!req.adminAddress) { + // requireAdmin guarantees this; narrow defensively for direct callers. + res.status(401).json({ error: { code: "unauthorized", requestId } }); + return; + } + + try { + const result = await seedSampleMarkets({ + adminAddress: req.adminAddress, + ip: extractClientIp(req), + correlationId: requestId, + }); + res.status(200).json({ data: result }); + } catch (err) { + if (err instanceof SeedNotAllowedError) { + res.status(err.status).json({ + error: { code: err.code, message: err.message, requestId }, + }); + return; + } + next(err); + } + }); + + return router; +} + +// Default export wired into src/index.ts. +export const adminSeedRouter = createAdminSeedRouter(); diff --git a/src/services/seedService.ts b/src/services/seedService.ts new file mode 100644 index 0000000..2b5459d --- /dev/null +++ b/src/services/seedService.ts @@ -0,0 +1,299 @@ +/** + * @module seedService + * + * Owns the "seed a small batch of sample markets" workflow used for E2E tests + * and demos in **non-production** environments (development / staging / test). + * + * Responsibilities: + * - Insert a fixed, deterministic batch of sample markets so E2E suites and + * demos have predictable data to work against. + * - Be IDEMPOTENT: re-running the seed never creates duplicates. Each sample + * market has a stable primary key and we rely on `ON CONFLICT DO NOTHING`, + * so a second call inserts zero rows and reports them as "skipped". + * - TRACK seeded markets: every seeded row is tagged with + * `metadata.seeded = true` plus the batch version, so they can be listed, + * audited, and distinguished from real (indexed / admin-created) markets. + * - Refuse to run in production (defense-in-depth — the route is also hidden + * in production; see src/routes/admin/seed.ts). + * - Emit a structured `market.created` log event per inserted row and write a + * compliance audit entry, both carrying a correlation id. + * + * See docs/seed.md for the operator-facing documentation. + */ + +import { asc, sql } from "drizzle-orm"; +import { db } from "../db/client"; +import { markets } from "../db/schema"; +import { env } from "../config/env"; +import { logger } from "../config/logger"; +import { createAuditLog } from "./auditService"; +import { emitMarketEvent, LogEvent } from "../logging/events"; +import { getRequestId } from "../lib/requestContext"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/** + * Bumping this invalidates "what this batch should contain" for tracking + * purposes. Existing seeded rows keep their recorded version, so you can tell + * which batch produced them. + */ +export const SEED_BATCH_VERSION = 1; + +/** Status assigned to freshly seeded markets — matches the "open for predictions" state. */ +const SEED_MARKET_STATUS = "open"; + +/** Ledger recorded for seeded rows. They are not on-chain, so 0 is a sentinel. */ +const SEED_INDEXED_LEDGER = 0; + +/** One day in milliseconds — used to spread sample resolution times into the future. */ +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +// ─── Sample data ──────────────────────────────────────────────────────────── + +interface SampleMarketSeed { + /** Stable primary key — the source of idempotency. */ + id: string; + question: string; + /** Days from "now" until the market resolves. */ + resolutionInDays: number; + /** Possible outcomes, stored in metadata for demo UIs. */ + outcomes: readonly string[]; +} + +/** + * The fixed sample batch. Stable ids (`seed-market-NNN`) make the seed + * idempotent and easy to reference from E2E specs. + */ +export const SAMPLE_MARKETS: readonly SampleMarketSeed[] = [ + { + id: "seed-market-001", + question: "Will BTC close above $100k by year end?", + resolutionInDays: 30, + outcomes: ["yes", "no"], + }, + { + id: "seed-market-002", + question: "Will the next Stellar protocol upgrade ship on time?", + resolutionInDays: 14, + outcomes: ["yes", "no"], + }, + { + id: "seed-market-003", + question: "Which team wins the demo hackathon?", + resolutionInDays: 7, + outcomes: ["alpha", "bravo", "charlie"], + }, + { + id: "seed-market-004", + question: "Will daily active users exceed 10k this quarter?", + resolutionInDays: 60, + outcomes: ["yes", "no"], + }, + { + id: "seed-market-005", + question: "Will it rain in the demo city on launch day?", + resolutionInDays: 3, + outcomes: ["yes", "no"], + }, +]; + +// ─── Errors ───────────────────────────────────────────────────────────────── + +/** + * Thrown when seeding is attempted in production. The route layer hides the + * endpoint entirely in production; this is a second, independent guard so the + * service can never write sample data to a prod database even if called + * directly. + */ +export class SeedNotAllowedError extends Error { + readonly status = 403; + readonly code = "seed_not_allowed"; + constructor() { + super("Market seeding is disabled in production"); + this.name = "SeedNotAllowedError"; + } +} + +// ─── Public shapes ────────────────────────────────────────────────────────── + +export interface SeededMarketSummary { + id: string; + question: string; + status: string; + resolutionTime: string; +} + +export interface SeedResult { + /** How many sample markets the batch defines. */ + requested: number; + /** How many rows this call actually inserted. */ + inserted: number; + /** How many rows already existed and were left untouched (idempotent skip). */ + skipped: number; + /** Batch version that produced this seed. */ + batchVersion: number; + /** Ids inserted by THIS call (empty on a repeat run). */ + insertedIds: string[]; + /** Every seeded market currently tracked in the DB (the full batch). */ + markets: SeededMarketSummary[]; +} + +export interface SeedCallContext { + /** Stellar address of the admin initiating the seed — recorded in the audit trail. */ + adminAddress: string; + /** Real client IP — passed so audit_logs.ip is accurate. */ + ip: string; + /** Optional explicit correlation id; falls back to the ALS-derived request id. */ + correlationId?: string; +} + +/** Row shape inserted into the `markets` table. */ +interface NewSeedMarketRow { + id: string; + question: string; + status: string; + resolutionTime: Date; + indexedLedger: number; + metadata: { + seeded: true; + seedBatchVersion: number; + outcomes: readonly string[]; + }; +} + +// ─── Repository contract ──────────────────────────────────────────────────── + +export interface SeedRepository { + /** + * Insert the given sample rows, skipping any whose id already exists. + * Returns the ids that were ACTUALLY inserted (Postgres `ON CONFLICT DO + * NOTHING ... RETURNING` only returns newly-inserted rows). + */ + insertSampleMarkets(rows: NewSeedMarketRow[]): Promise; + /** List every market tagged as seeded, ordered by id. */ + listSeededMarkets(): Promise; +} + +// ─── Repository implementation (Drizzle) ──────────────────────────────────── + +export class DrizzleSeedRepository implements SeedRepository { + constructor(private readonly database: typeof db = db) {} + + async insertSampleMarkets(rows: NewSeedMarketRow[]): Promise { + if (rows.length === 0) return []; + const inserted = await this.database + .insert(markets) + .values(rows) + .onConflictDoNothing({ target: markets.id }) + .returning({ id: markets.id }); + return inserted.map((r) => r.id); + } + + async listSeededMarkets(): Promise { + const rows = await this.database + .select({ + id: markets.id, + question: markets.question, + status: markets.status, + resolutionTime: markets.resolutionTime, + }) + .from(markets) + // metadata is jsonb; `->> 'seeded'` yields the text 'true' for our tag. + .where(sql`${markets.metadata} ->> 'seeded' = 'true'`) + .orderBy(asc(markets.id)); + + return rows.map((r) => ({ + id: r.id, + question: r.question, + status: r.status, + resolutionTime: + r.resolutionTime instanceof Date + ? r.resolutionTime.toISOString() + : String(r.resolutionTime), + })); + } +} + +// ─── Service API ──────────────────────────────────────────────────────────── + +/** Build the concrete rows for the sample batch relative to `now`. */ +function buildSeedRows(now: number): NewSeedMarketRow[] { + return SAMPLE_MARKETS.map((m) => ({ + id: m.id, + question: m.question, + status: SEED_MARKET_STATUS, + resolutionTime: new Date(now + m.resolutionInDays * ONE_DAY_MS), + indexedLedger: SEED_INDEXED_LEDGER, + metadata: { + seeded: true, + seedBatchVersion: SEED_BATCH_VERSION, + outcomes: m.outcomes, + }, + })); +} + +/** + * Seed the fixed batch of sample markets. Idempotent and non-prod only. + * + * @throws SeedNotAllowedError when NODE_ENV is "production". + */ +export async function seedSampleMarkets( + ctx: SeedCallContext, + repo: SeedRepository = new DrizzleSeedRepository(), +): Promise { + if (env.NODE_ENV === "production") { + throw new SeedNotAllowedError(); + } + + const correlationId = ctx.correlationId ?? getRequestId(); + const rows = buildSeedRows(Date.now()); + + const insertedIds = await repo.insertSampleMarkets(rows); + const seeded = await repo.listSeededMarkets(); + + for (const id of insertedIds) { + emitMarketEvent(LogEvent.MARKET_CREATED, { + marketId: id, + actor: ctx.adminAddress, + correlationId, + seeded: true, + seedBatchVersion: SEED_BATCH_VERSION, + }); + } + + await createAuditLog({ + action: "admin.seed_markets", + walletAddress: ctx.adminAddress, + ip: ctx.ip, + correlationId, + }); + + logger.info( + { + correlationId, + adminAddress: ctx.adminAddress, + requested: rows.length, + inserted: insertedIds.length, + skipped: rows.length - insertedIds.length, + batchVersion: SEED_BATCH_VERSION, + }, + "admin.seed_markets", + ); + + return { + requested: rows.length, + inserted: insertedIds.length, + skipped: rows.length - insertedIds.length, + batchVersion: SEED_BATCH_VERSION, + insertedIds, + markets: seeded, + }; +} + +// ─── Default singleton wired with the live Drizzle client ─────────────────── + +const defaultRepository = new DrizzleSeedRepository(); +export const seedService = { + seed: (ctx: SeedCallContext) => seedSampleMarkets(ctx, defaultRepository), + listSeeded: () => defaultRepository.listSeededMarkets(), +}; diff --git a/tests/adminSeed.test.ts b/tests/adminSeed.test.ts new file mode 100644 index 0000000..5a88b30 --- /dev/null +++ b/tests/adminSeed.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for the admin sample-market seed route. + * + * POST /api/admin/seed + * + * Strategy: + * - Mock `src/services/seedService` so no real DB is needed. + * - Sign real JWTs (role:"admin") to exercise the full requireAdmin path. + * - Mount `createAdminSeedRouter()` directly so the rate-limit ceiling can be + * lowered for the 429 test. + */ + +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +jest.mock("../src/services/seedService", () => ({ + seedSampleMarkets: jest.fn(), + SeedNotAllowedError: class SeedNotAllowedError extends Error { + readonly status = 403; + readonly code = "seed_not_allowed"; + constructor() { + super("Market seeding is disabled in production"); + this.name = "SeedNotAllowedError"; + } + }, +})); + +// Prevent a Pool connection at import time. +jest.mock("../src/db/client", () => ({ db: {} })); + +// Verify admin JWTs with the test secret directly. This keeps the test isolated +// from the key-ring/env wiring (which is unrelated to the seed endpoint) while +// still exercising the real `requireAdmin` middleware end-to-end. +jest.mock("../src/services/jwtService", () => { + const jwtLib = jest.requireActual("jsonwebtoken"); + return { + verifyAccessToken: (token: string) => + jwtLib.verify(token, process.env.JWT_SECRET as string, { + algorithms: ["HS256"], + issuer: process.env.JWT_ISSUER || "predictify", + audience: process.env.JWT_AUDIENCE || "predictify-app", + }), + }; +}); + +import { seedSampleMarkets, SeedNotAllowedError } from "../src/services/seedService"; +import { createAdminSeedRouter } from "../src/routes/admin/seed"; +import { errorHandler } from "../src/middleware/errorHandler"; +import { env } from "../src/config/env"; + +const mockSeed = seedSampleMarkets as jest.MockedFunction; + +// ── JWT fixtures ──────────────────────────────────────────────────────────── +const SECRET = process.env.JWT_SECRET || "test-secret-with-at-least-32-characters"; +const ISSUER = process.env.JWT_ISSUER || "predictify"; +const AUDIENCE = process.env.JWT_AUDIENCE || "predictify-app"; + +const ADMIN_ADDR = "GADMIN7777777777777777777777777777777777777777777777777777"; +const USER_ADDR = "GUSER88888888888888888888888888888888888888888888888888888"; + +function signJwt(payload: object): string { + return jwt.sign(payload, SECRET, { issuer: ISSUER, audience: AUDIENCE, expiresIn: "1h" }); +} + +const adminJwt = signJwt({ sub: ADMIN_ADDR, role: "admin" }); +const userJwt = signJwt({ sub: USER_ADDR, role: "user" }); + +const SEED_RESULT = { + requested: 5, + inserted: 5, + skipped: 0, + batchVersion: 1, + insertedIds: ["seed-market-001"], + markets: [ + { + id: "seed-market-001", + question: "q", + status: "open", + resolutionTime: "2026-07-30T10:00:00.000Z", + }, + ], +}; + +function makeApp(rateLimitPerMinute = 30): express.Express { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as express.Request & { id?: string }).id = + (req.headers["x-request-id"] as string | undefined) ?? "admin-seed-req"; + next(); + }); + app.use("/api/admin/seed", createAdminSeedRouter({ rateLimitPerMinute })); + app.use(errorHandler); + return app; +} + +beforeEach(() => jest.clearAllMocks()); + +// ── Auth guard ────────────────────────────────────────────────────────────── +describe("requireAdmin guard", () => { + it("returns 403 with no Authorization header", async () => { + const res = await request(makeApp()).post("/api/admin/seed"); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: { code: "forbidden" } }); + expect(mockSeed).not.toHaveBeenCalled(); + }); + + it("returns 403 with a non-admin JWT", async () => { + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${userJwt}`); + expect(res.status).toBe(403); + expect(mockSeed).not.toHaveBeenCalled(); + }); + + it("returns 403 with an expired JWT", async () => { + const expired = jwt.sign({ sub: ADMIN_ADDR, role: "admin" }, SECRET, { + issuer: ISSUER, + audience: AUDIENCE, + expiresIn: -1, + }); + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${expired}`); + expect(res.status).toBe(403); + }); +}); + +// ── Success & idempotency ─────────────────────────────────────────────────── +describe("POST /api/admin/seed", () => { + it("returns 200 with the seed result and forwards admin context", async () => { + mockSeed.mockResolvedValue(SEED_RESULT); + + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .set("X-Request-Id", "seed-req-1") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: SEED_RESULT }); + expect(mockSeed).toHaveBeenCalledTimes(1); + expect(mockSeed).toHaveBeenCalledWith({ + adminAddress: ADMIN_ADDR, + ip: expect.any(String), + correlationId: "seed-req-1", + }); + }); + + it("uses the first X-Forwarded-For hop as the client IP", async () => { + mockSeed.mockResolvedValue(SEED_RESULT); + + await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .set("X-Forwarded-For", "203.0.113.7, 70.41.3.18") + .send({}); + + expect(mockSeed).toHaveBeenCalledWith( + expect.objectContaining({ ip: "203.0.113.7" }), + ); + }); + + it("is idempotent — a repeat call reports inserted:0", async () => { + mockSeed.mockResolvedValueOnce({ ...SEED_RESULT, inserted: 0, skipped: 5, insertedIds: [] }); + + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.data.inserted).toBe(0); + expect(res.body.data.skipped).toBe(5); + }); + + it("returns 400 when the body contains unexpected fields", async () => { + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .set("X-Request-Id", "bad-body") + .send({ count: 99 }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + expect(res.body.error.requestId).toBe("bad-body"); + expect(mockSeed).not.toHaveBeenCalled(); + }); + + it("maps SeedNotAllowedError to 403 seed_not_allowed", async () => { + mockSeed.mockRejectedValue(new SeedNotAllowedError()); + + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("seed_not_allowed"); + }); + + it("lets unexpected errors bubble to the global handler (500)", async () => { + mockSeed.mockRejectedValue(new Error("db down")); + + const res = await request(makeApp()) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .send({}); + + expect(res.status).toBe(500); + }); +}); + +// ── Production guard ──────────────────────────────────────────────────────── +describe("production guard", () => { + it("returns 404 before auth runs when NODE_ENV=production", async () => { + const original = env.NODE_ENV; + (env as { NODE_ENV: string }).NODE_ENV = "production"; + try { + // No Authorization header — the route must hide itself, not 403. + const res = await request(makeApp()).post("/api/admin/seed").send({}); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("not_found"); + expect(mockSeed).not.toHaveBeenCalled(); + } finally { + (env as { NODE_ENV: string }).NODE_ENV = original; + } + }); +}); + +// ── Rate limiting ─────────────────────────────────────────────────────────── +describe("rate limiting", () => { + it("returns 429 after the per-token ceiling is exceeded", async () => { + mockSeed.mockResolvedValue(SEED_RESULT); + const app = makeApp(2); + + await request(app).post("/api/admin/seed").set("Authorization", `Bearer ${adminJwt}`).send({}); + await request(app).post("/api/admin/seed").set("Authorization", `Bearer ${adminJwt}`).send({}); + const third = await request(app) + .post("/api/admin/seed") + .set("Authorization", `Bearer ${adminJwt}`) + .send({}); + + expect(third.status).toBe(429); + expect(third.body).toEqual({ error: { code: "rate_limit_exceeded" } }); + }); +}); diff --git a/tests/seedService.test.ts b/tests/seedService.test.ts new file mode 100644 index 0000000..e1286f1 --- /dev/null +++ b/tests/seedService.test.ts @@ -0,0 +1,200 @@ +/** + * Unit tests for seedService. + * + * Strategy: + * - Mock db/client so importing the service never opens a Pool. + * - Mock auditService + logging/events so the service's only real moving part + * is the (injected) repository. + * - Exercise the Drizzle repository against a hand-rolled fake query builder. + */ + +jest.mock("../src/db/client", () => ({ db: {} })); +jest.mock("../src/services/auditService", () => ({ + createAuditLog: jest.fn().mockResolvedValue("corr-id"), +})); +jest.mock("../src/logging/events", () => ({ + emitMarketEvent: jest.fn(), + LogEvent: { MARKET_CREATED: "market.created" }, +})); + +import { + seedSampleMarkets, + SAMPLE_MARKETS, + SEED_BATCH_VERSION, + SeedNotAllowedError, + DrizzleSeedRepository, + type SeedRepository, + type SeededMarketSummary, +} from "../src/services/seedService"; +import { env } from "../src/config/env"; +import { createAuditLog } from "../src/services/auditService"; +import { emitMarketEvent } from "../src/logging/events"; + +const mockAudit = createAuditLog as jest.MockedFunction; +const mockEmit = emitMarketEvent as jest.MockedFunction; + +const CTX = { adminAddress: "GADMIN", ip: "1.2.3.4", correlationId: "req-1" }; + +/** In-memory fake repo that mimics ON CONFLICT DO NOTHING semantics. */ +function makeRepo(existingIds: string[] = []): SeedRepository & { + store: Set; + insertSampleMarkets: jest.Mock; + listSeededMarkets: jest.Mock; +} { + const store = new Set(existingIds); + return { + store, + insertSampleMarkets: jest.fn(async (rows: { id: string }[]) => { + const inserted: string[] = []; + for (const r of rows) { + if (!store.has(r.id)) { + store.add(r.id); + inserted.push(r.id); + } + } + return inserted; + }), + listSeededMarkets: jest.fn(async (): Promise => + [...store].sort().map((id) => ({ + id, + question: "q", + status: "open", + resolutionTime: "2026-01-01T00:00:00.000Z", + })), + ), + }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe("seedSampleMarkets", () => { + it("inserts the whole batch on a fresh database", async () => { + const repo = makeRepo(); + const result = await seedSampleMarkets(CTX, repo); + + expect(result.requested).toBe(SAMPLE_MARKETS.length); + expect(result.inserted).toBe(SAMPLE_MARKETS.length); + expect(result.skipped).toBe(0); + expect(result.batchVersion).toBe(SEED_BATCH_VERSION); + expect(result.insertedIds).toHaveLength(SAMPLE_MARKETS.length); + expect(result.markets).toHaveLength(SAMPLE_MARKETS.length); + + // One created-event per inserted row + one audit entry. + expect(mockEmit).toHaveBeenCalledTimes(SAMPLE_MARKETS.length); + expect(mockAudit).toHaveBeenCalledTimes(1); + expect(mockAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "admin.seed_markets", + walletAddress: "GADMIN", + ip: "1.2.3.4", + correlationId: "req-1", + }), + ); + }); + + it("is idempotent — a repeat run inserts nothing and emits no events", async () => { + const repo = makeRepo(SAMPLE_MARKETS.map((m) => m.id)); + const result = await seedSampleMarkets(CTX, repo); + + expect(result.inserted).toBe(0); + expect(result.skipped).toBe(SAMPLE_MARKETS.length); + expect(result.insertedIds).toEqual([]); + expect(result.markets).toHaveLength(SAMPLE_MARKETS.length); + expect(mockEmit).not.toHaveBeenCalled(); + // Audit is still written so the (no-op) admin action is traceable. + expect(mockAudit).toHaveBeenCalledTimes(1); + }); + + it("propagates the admin address as the created-event actor", async () => { + const repo = makeRepo(); + await seedSampleMarkets(CTX, repo); + expect(mockEmit).toHaveBeenCalledWith( + "market.created", + expect.objectContaining({ actor: "GADMIN", seeded: true, correlationId: "req-1" }), + ); + }); + + it("refuses to seed in production", async () => { + const repo = makeRepo(); + const original = env.NODE_ENV; + (env as { NODE_ENV: string }).NODE_ENV = "production"; + try { + await expect(seedSampleMarkets(CTX, repo)).rejects.toBeInstanceOf( + SeedNotAllowedError, + ); + expect(repo.insertSampleMarkets).not.toHaveBeenCalled(); + } finally { + (env as { NODE_ENV: string }).NODE_ENV = original; + } + }); +}); + +describe("SeedNotAllowedError", () => { + it("carries a 403 status and stable code", () => { + const err = new SeedNotAllowedError(); + expect(err.status).toBe(403); + expect(err.code).toBe("seed_not_allowed"); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("DrizzleSeedRepository", () => { + it("insertSampleMarkets short-circuits on empty input", async () => { + const repo = new DrizzleSeedRepository({} as never); + expect(await repo.insertSampleMarkets([])).toEqual([]); + }); + + it("insertSampleMarkets maps the ids Postgres returns", async () => { + const returning = jest.fn().mockResolvedValue([{ id: "a" }, { id: "b" }]); + const onConflictDoNothing = jest.fn().mockReturnValue({ returning }); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + const repo = new DrizzleSeedRepository({ insert } as never); + + const ids = await repo.insertSampleMarkets([{ id: "a" } as never]); + + expect(ids).toEqual(["a", "b"]); + expect(insert).toHaveBeenCalledTimes(1); + expect(onConflictDoNothing).toHaveBeenCalledWith({ target: expect.anything() }); + }); + + it("listSeededMarkets serializes Date resolutionTime to ISO", async () => { + const rows = [ + { + id: "seed-market-001", + question: "q", + status: "open", + resolutionTime: new Date("2026-01-01T00:00:00.000Z"), + }, + ]; + const orderBy = jest.fn().mockResolvedValue(rows); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const repo = new DrizzleSeedRepository({ select } as never); + + const out = await repo.listSeededMarkets(); + expect(out).toEqual([ + { + id: "seed-market-001", + question: "q", + status: "open", + resolutionTime: "2026-01-01T00:00:00.000Z", + }, + ]); + }); + + it("listSeededMarkets stringifies a non-Date resolutionTime", async () => { + const rows = [ + { id: "x", question: "q", status: "open", resolutionTime: "2026-01-01" }, + ]; + const orderBy = jest.fn().mockResolvedValue(rows); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const repo = new DrizzleSeedRepository({ select } as never); + + const out = await repo.listSeededMarkets(); + expect(out[0]!.resolutionTime).toBe("2026-01-01"); + }); +});