diff --git a/drizzle/migrations/0023_admin_user_notes.sql b/drizzle/migrations/0023_admin_user_notes.sql new file mode 100644 index 0000000..9ab600a --- /dev/null +++ b/drizzle/migrations/0023_admin_user_notes.sql @@ -0,0 +1,14 @@ +-- Migration: 0023_admin_user_notes +-- Adds the admin_user_notes table for per-user freeform admin notes. + +CREATE TABLE IF NOT EXISTS admin_user_notes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + target_address text NOT NULL, + admin_address text NOT NULL, + note text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Index for efficient paginated listing by target address ordered by creation time. +CREATE INDEX IF NOT EXISTS admin_user_notes_target_idx + ON admin_user_notes (target_address, created_at DESC); diff --git a/src/db/schema.ts b/src/db/schema.ts index f637bfa..cff0d19 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -444,3 +444,41 @@ export type NewSchemaVersion = typeof schemaVersions.$inferInsert; export type Notification = typeof notifications.$inferSelect; export type NewNotification = typeof notifications.$inferInsert; + +// --------------------------------------------------------------------------- +// Admin User Notes +// --------------------------------------------------------------------------- +/** + * admin_user_notes — freeform notes written by admins against a user account. + * + * Each row represents a single note entry. Notes are append-only by design: + * admins may delete individual notes but cannot silently modify existing ones, + * preserving a trustworthy audit trail. + * + * Indexed on `target_address` + `created_at` so paginated list queries are + * efficient even as the table grows. + */ +export const adminUserNotes = pgTable( + "admin_user_notes", + { + id: uuid("id").primaryKey().defaultRandom(), + /** Stellar address of the user this note is about */ + targetAddress: text("target_address").notNull(), + /** Stellar address of the admin who wrote the note */ + adminAddress: text("admin_address").notNull(), + /** Free-form note body (max 2 000 chars enforced at the route layer) */ + note: text("note").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + adminUserNotesTargetIdx: index("admin_user_notes_target_idx").on( + t.targetAddress, + t.createdAt, + ), + }), +); + +export type AdminUserNote = typeof adminUserNotes.$inferSelect; +export type NewAdminUserNote = typeof adminUserNotes.$inferInsert; diff --git a/src/index.ts b/src/index.ts index a42d7ff..aa10547 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { userPortfolioRouter } from "./routes/users/portfolio"; import { devicesRouter } from "./routes/devices"; import { adminFeatureFlagsRouter } from "./routes/admin/feature-flags"; import { adminUsersRouter } from "./routes/adminUsers"; +import { adminNotesRouter } from "./routes/admin/users/notes"; import { leaderboardRouter } from "./routes/leaderboard"; import { createDocsRouter } from "./routes/docs"; import { sessionsRouter } from "./routes/me/sessions"; @@ -125,6 +126,7 @@ export function createApp(): express.Express { app.use("/api/me/sessions", sessionsRouter); app.use("/api/admin/audit", adminAuditRouter); app.use("/api/admin/users", adminUsersRouter); + app.use("/api/admin/users", adminNotesRouter); app.use("/api/admin/feature-flags", adminFeatureFlagsRouter); app.use('/feature-flags', featureFlagsRouter); app.use("/api/admin/markets", adminMarketsRouter); diff --git a/src/routes/admin/users/notes.ts b/src/routes/admin/users/notes.ts new file mode 100644 index 0000000..da974e7 --- /dev/null +++ b/src/routes/admin/users/notes.ts @@ -0,0 +1,309 @@ +/** + * Admin user-notes router. + * + * Endpoints + * ───────── + * GET /api/admin/users/:addr/notes List all notes for a user (newest-first). + * POST /api/admin/users/:addr/notes Create a new note for a user. + * DELETE /api/admin/users/:addr/notes/:id Delete a specific note by ID. + * + * Auth + * ──── + * All routes require a valid admin JWT (role: "admin") in the Authorization + * header, enforced by the shared `requireAdmin` middleware. + * + * Rate limiting + * ───────────── + * 60 requests / minute per admin token by default. The ceiling is injectable + * via `createAdminNotesRouter({ rateLimitPerMinute })` so tests can exercise + * the 429 path cheaply without firing 60 real requests. + * + * Audit trail + * ─────────── + * Every mutating operation (POST, DELETE) writes a row to `admin_audit_log` + * so all note changes are traceable to a specific admin. + */ + +import { Router } from "express"; +import { rateLimit } from "express-rate-limit"; +import { z } from "zod"; +import { eq, and, desc } from "drizzle-orm"; +import { requireAdmin } from "../../../middleware/requireAdmin"; +import { getRequestId } from "../../../lib/requestContext"; +import { logger } from "../../../config/logger"; +import { db } from "../../../db/client"; +import { adminUserNotes, adminAuditLog } from "../../../db/schema"; +import type { DB } from "../../../db/client"; + +// ── Validation schemas ──────────────────────────────────────────────────────── + +/** + * Stellar public key: starts with "G" followed by exactly 55 uppercase base32 + * characters. Validated at the route boundary before anything touches the DB. + */ +const stellarAddressSchema = z + .string() + .regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address"); + +/** + * Request body for POST (create note). + * `note` is trimmed and capped at 2 000 characters. + */ +const createNoteBodySchema = z + .object({ + note: z + .string() + .trim() + .min(1, "note must not be empty") + .max(2_000, "note must be at most 2000 characters"), + }) + .strict(); + +/** UUID v4 pattern — used to validate the `:id` segment on DELETE. */ +const uuidSchema = z.string().uuid("Invalid note ID"); + +// ── Router options ──────────────────────────────────────────────────────────── + +export interface AdminNotesRouterOptions { + /** Override the DB instance (used in tests). Defaults to the real `db`. */ + dbClient?: DB; + /** Requests per minute per admin token. Default: 60 */ + rateLimitPerMinute?: number; +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +export function createAdminNotesRouter(opts: AdminNotesRouterOptions = {}): Router { + const client = opts.dbClient ?? db; + const router = Router({ mergeParams: true }); + const limit = opts.rateLimitPerMinute ?? 60; + + // ── Rate limiter ──────────────────────────────────────────────────────────── + // Key on the raw Authorization header so each distinct admin token gets its + // own bucket. Falls back to IP for unauthenticated callers so they are + // still throttled before reaching requireAdmin. + 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" } }, + }), + ); + + // ── Admin guard ───────────────────────────────────────────────────────────── + router.use(requireAdmin); + + // ── GET /:addr/notes ──────────────────────────────────────────────────────── + /** + * List all notes for a user, ordered newest-first. + * + * 200 { data: AdminUserNote[] } + * 400 { error: { code: "validation_error", message: string, requestId: string } } + */ + router.get("/:addr/notes", async (req, res, next) => { + try { + const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown"; + + const addrParsed = stellarAddressSchema.safeParse(req.params.addr); + if (!addrParsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: addrParsed.error.issues[0]?.message ?? "Invalid Stellar address", + requestId: reqId, + }, + }); + return; + } + + const targetAddress = addrParsed.data; + + const notes = await client + .select({ + id: adminUserNotes.id, + targetAddress: adminUserNotes.targetAddress, + adminAddress: adminUserNotes.adminAddress, + note: adminUserNotes.note, + createdAt: adminUserNotes.createdAt, + }) + .from(adminUserNotes) + .where(eq(adminUserNotes.targetAddress, targetAddress)) + .orderBy(desc(adminUserNotes.createdAt)); + + logger.info( + { reqId, targetAddress, actor: req.adminAddress, count: notes.length }, + "admin_user_notes_listed", + ); + + res.status(200).json({ + data: notes.map((n) => ({ + id: n.id, + targetAddress: n.targetAddress, + adminAddress: n.adminAddress, + note: n.note, + createdAt: n.createdAt.toISOString(), + })), + }); + } catch (e) { + next(e); + } + }); + + // ── POST /:addr/notes ─────────────────────────────────────────────────────── + /** + * Create a new note for the specified user. + * + * Body: { "note": "" } + * + * 201 { data: AdminUserNote } + * 400 { error: { code: "validation_error", message: string, requestId: string } } + */ + router.post("/:addr/notes", async (req, res, next) => { + try { + const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown"; + + const addrParsed = stellarAddressSchema.safeParse(req.params.addr); + if (!addrParsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: addrParsed.error.issues[0]?.message ?? "Invalid Stellar address", + requestId: reqId, + }, + }); + return; + } + + const bodyParsed = createNoteBodySchema.safeParse(req.body); + if (!bodyParsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: bodyParsed.error.issues[0]?.message ?? "Invalid request body", + requestId: reqId, + }, + }); + return; + } + + const targetAddress = addrParsed.data; + const adminAddress = req.adminAddress!; + const { note } = bodyParsed.data; + + // Insert the note and return the created row in one round-trip. + const [created] = await client + .insert(adminUserNotes) + .values({ targetAddress, adminAddress, note }) + .returning(); + + // Write the admin audit log entry for full traceability. + await client.insert(adminAuditLog).values({ + adminAddress, + action: "create_user_note", + targetAddress, + }); + + logger.info( + { reqId, noteId: created.id, targetAddress, actor: adminAddress }, + "admin_user_note_created", + ); + + res.status(201).json({ + data: { + id: created.id, + targetAddress: created.targetAddress, + adminAddress: created.adminAddress, + note: created.note, + createdAt: created.createdAt.toISOString(), + }, + }); + } catch (e) { + next(e); + } + }); + + // ── DELETE /:addr/notes/:id ───────────────────────────────────────────────── + /** + * Delete a specific note by ID. The delete is scoped to both the note UUID + * and the target address so an admin cannot remove notes that belong to a + * different user by guessing IDs. + * + * 200 { data: { deleted: true } } + * 400 { error: { code: "validation_error", message: string, requestId: string } } + * 404 { error: { code: "not_found", requestId: string } } + */ + router.delete("/:addr/notes/:id", async (req, res, next) => { + try { + const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown"; + + const addrParsed = stellarAddressSchema.safeParse(req.params.addr); + if (!addrParsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: addrParsed.error.issues[0]?.message ?? "Invalid Stellar address", + requestId: reqId, + }, + }); + return; + } + + const idParsed = uuidSchema.safeParse(req.params.id); + if (!idParsed.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: idParsed.error.issues[0]?.message ?? "Invalid note ID", + requestId: reqId, + }, + }); + return; + } + + const targetAddress = addrParsed.data; + const adminAddress = req.adminAddress!; + const noteId = idParsed.data; + + // Scope the deletion to both the note ID and the target address. + const deleted = await client + .delete(adminUserNotes) + .where( + and( + eq(adminUserNotes.id, noteId), + eq(adminUserNotes.targetAddress, targetAddress), + ), + ) + .returning({ id: adminUserNotes.id }); + + if (deleted.length === 0) { + res.status(404).json({ error: { code: "not_found", requestId: reqId } }); + return; + } + + // Write admin audit log entry for traceability. + await client.insert(adminAuditLog).values({ + adminAddress, + action: "delete_user_note", + targetAddress, + }); + + logger.info( + { reqId, noteId, targetAddress, actor: adminAddress }, + "admin_user_note_deleted", + ); + + res.status(200).json({ data: { deleted: true } }); + } catch (e) { + next(e); + } + }); + + return router; +} + +// Default export wired into src/index.ts. +export const adminNotesRouter = createAdminNotesRouter(); diff --git a/tests/adminUserNotes.test.ts b/tests/adminUserNotes.test.ts new file mode 100644 index 0000000..6766633 --- /dev/null +++ b/tests/adminUserNotes.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for /api/admin/users/:addr/notes + * + * GET /api/admin/users/:addr/notes — list notes + * POST /api/admin/users/:addr/notes — create note + * DELETE /api/admin/users/:addr/notes/:id — delete note + * + * Strategy + * ──────── + * - Inject a mock DB client (opts.dbClient) so no real database is required. + * - Sign real JWTs with the test JWT_SECRET so requireAdmin exercises its full + * verification path. + * - Mount createAdminNotesRouter() on a minimal Express app so tests are + * isolated from the full application. + * - Rate-limit ceiling is set to 2 on the 429 test so we only need 3 requests. + */ + +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; +import { createAdminNotesRouter } from "../src/routes/admin/users/notes"; +import { errorHandler } from "../src/middleware/errorHandler"; + +// ── Prevent a real DB connection being opened at import time ────────────────── +jest.mock("../src/db/client", () => ({ db: {} })); + +// ── Constants ───────────────────────────────────────────────────────────────── + +const SECRET = process.env.JWT_SECRET!; +const ISSUER = process.env.JWT_ISSUER ?? "predictify"; +const AUDIENCE = process.env.JWT_AUDIENCE ?? "predictify-app"; + +// A valid Stellar public key (G + 55 uppercase base-32 chars). +const ADMIN_ADDRESS = "GADMIN7777777777777777777777777777777777777777777777777777"; +const USER_ADDRESS = "GUSER88888888888888888888888888888888888888888888888888888"; +const TARGET_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +const NOTE_ID_1 = "11111111-1111-1111-1111-111111111111"; +const NOTE_ID_2 = "22222222-2222-2222-2222-222222222222"; + +// ── JWT helpers ─────────────────────────────────────────────────────────────── + +function signJwt(payload: object): string { + return jwt.sign(payload, SECRET, { issuer: ISSUER, audience: AUDIENCE, expiresIn: "1h" }); +} + +const adminJwt = signJwt({ sub: ADMIN_ADDRESS, role: "admin" }); +const userJwt = signJwt({ sub: USER_ADDRESS, role: "user" }); + +// ── DB mock factory ─────────────────────────────────────────────────────────── + +/** + * Returns a lightweight DB mock that satisfies the chain calls made by the + * notes router: client.select(...).from(...).where(...).orderBy(...) + * client.insert(...).values(...).returning() + * client.delete(...).where(...).returning(...) + */ +function makeMockDb(overrides: { + selectRows?: object[]; + insertReturns?: object; + deleteReturns?: object[]; +} = {}) { + const insertValues = jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue( + overrides.insertReturns + ? [overrides.insertReturns] + : [{ + id: NOTE_ID_1, + targetAddress: TARGET_ADDRESS, + adminAddress: ADMIN_ADDRESS, + note: "test note", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }], + ), + }); + + const auditInsertValues = jest.fn().mockResolvedValue({}); + + // The router calls insert() twice (note insert, then audit insert). + // We track call count to distinguish them. + let insertCallCount = 0; + const insertFn = jest.fn().mockImplementation(() => { + insertCallCount++; + if (insertCallCount === 1) { + return { values: insertValues }; + } + // Second call is the audit log — no .returning() + return { values: auditInsertValues }; + }); + + const deleteFn = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue( + overrides.deleteReturns ?? [{ id: NOTE_ID_1 }], + ), + }), + }); + + const rows = overrides.selectRows ?? [ + { + id: NOTE_ID_1, + targetAddress: TARGET_ADDRESS, + adminAddress: ADMIN_ADDRESS, + note: "first note", + createdAt: new Date("2024-01-02T00:00:00.000Z"), + }, + { + id: NOTE_ID_2, + targetAddress: TARGET_ADDRESS, + adminAddress: ADMIN_ADDRESS, + note: "second note", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }, + ]; + + const selectFn = jest.fn().mockReturnValue({ + from: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + orderBy: jest.fn().mockResolvedValue(rows), + }), + }), + }); + + return { + select: selectFn, + insert: insertFn, + delete: deleteFn, + // Expose internals for assertions + _insertValues: insertValues, + _auditInsertValues: auditInsertValues, + }; +} + +// ── App factory ─────────────────────────────────────────────────────────────── + +function makeApp( + dbClient: ReturnType, + rateLimitPerMinute = 100, +): express.Express { + const app = express(); + app.use(express.json()); + app.use( + "/api/admin/users", + createAdminNotesRouter({ dbClient: dbClient as never, rateLimitPerMinute }), + ); + app.use(errorHandler); + return app; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// requireAdmin guard — shared across all three endpoints +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("requireAdmin guard", () => { + const db = makeMockDb(); + const app = makeApp(db); + + it("returns 403 with no Authorization header (GET)", async () => { + const res = await request(app).get(`/api/admin/users/${TARGET_ADDRESS}/notes`); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: { code: "forbidden" } }); + }); + + it("returns 403 with a non-admin JWT on GET", async () => { + const res = await request(app) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${userJwt}`); + expect(res.status).toBe(403); + }); + + it("returns 403 with a JWT signed by the wrong secret on GET", async () => { + const bad = jwt.sign( + { sub: ADMIN_ADDRESS, role: "admin" }, + "wrong-secret-at-least-32-characters-long", + { issuer: ISSUER, audience: AUDIENCE }, + ); + const res = await request(app) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${bad}`); + expect(res.status).toBe(403); + }); + + it("returns 403 with an expired JWT on POST", async () => { + const expired = jwt.sign( + { sub: ADMIN_ADDRESS, role: "admin" }, + SECRET, + { issuer: ISSUER, audience: AUDIENCE, expiresIn: -1 }, + ); + const res = await request(app) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${expired}`) + .send({ note: "hello" }); + expect(res.status).toBe(403); + }); + + it("returns 403 with no Authorization header (DELETE)", async () => { + const res = await request(app) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: { code: "forbidden" } }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// GET /api/admin/users/:addr/notes +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("GET /api/admin/users/:addr/notes", () => { + it("returns 200 with an array of notes", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toHaveLength(2); + }); + + it("returns notes with all expected fields", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + const note = res.body.data[0]; + expect(note).toHaveProperty("id"); + expect(note).toHaveProperty("targetAddress"); + expect(note).toHaveProperty("adminAddress"); + expect(note).toHaveProperty("note"); + expect(note).toHaveProperty("createdAt"); + }); + + it("returns createdAt as an ISO-8601 string", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(typeof res.body.data[0].createdAt).toBe("string"); + expect(() => new Date(res.body.data[0].createdAt)).not.toThrow(); + }); + + it("returns an empty array when the user has no notes", async () => { + const db = makeMockDb({ selectRows: [] }); + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it("returns 400 for an invalid Stellar address", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .get("/api/admin/users/not-an-address/notes") + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("propagates DB errors as 500 via errorHandler", async () => { + const db = makeMockDb(); + (db.select as jest.Mock).mockReturnValue({ + from: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + orderBy: jest.fn().mockRejectedValue(new Error("db down")), + }), + }), + }); + + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(500); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// POST /api/admin/users/:addr/notes +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("POST /api/admin/users/:addr/notes", () => { + it("returns 201 with the created note", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "suspicious activity noted" }); + + expect(res.status).toBe(201); + expect(res.body.data).toHaveProperty("id"); + expect(res.body.data).toHaveProperty("note"); + expect(res.body.data).toHaveProperty("createdAt"); + }); + + it("persists the note with the correct targetAddress and adminAddress", async () => { + const db = makeMockDb(); + await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "test note" }); + + expect(db._insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + targetAddress: TARGET_ADDRESS, + adminAddress: ADMIN_ADDRESS, + note: "test note", + }), + ); + }); + + it("writes an audit log row on success", async () => { + const db = makeMockDb(); + await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "audit this" }); + + expect(db._auditInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + adminAddress: ADMIN_ADDRESS, + action: "create_user_note", + targetAddress: TARGET_ADDRESS, + }), + ); + }); + + it("returns 400 for an invalid Stellar address", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post("/api/admin/users/bad-addr/notes") + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "hello" }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("returns 400 when note is missing", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("returns 400 when note is an empty string", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "" }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("returns 400 when note exceeds 2000 characters", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "x".repeat(2_001) }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("accepts a note of exactly 2000 characters", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "x".repeat(2_000) }); + + expect(res.status).toBe(201); + }); + + it("rejects unknown body fields (strict schema)", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "ok", evil: "field" }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("propagates DB insert errors as 500", async () => { + const db = makeMockDb(); + (db.insert as jest.Mock).mockReturnValueOnce({ + values: jest.fn().mockReturnValue({ + returning: jest.fn().mockRejectedValue(new Error("constraint violation")), + }), + }); + + const res = await request(makeApp(db)) + .post(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`) + .send({ note: "note" }); + + expect(res.status).toBe(500); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DELETE /api/admin/users/:addr/notes/:id +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("DELETE /api/admin/users/:addr/notes/:id", () => { + it("returns 200 with { deleted: true } on success", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual({ deleted: true }); + }); + + it("writes an audit log row on success", async () => { + const db = makeMockDb(); + + // For DELETE, there is exactly ONE insert call — the audit log. + // Override insert so we can spy on the values passed to it. + const auditValues = jest.fn().mockResolvedValue({}); + (db.insert as jest.Mock).mockReturnValueOnce({ values: auditValues }); + + await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(auditValues).toHaveBeenCalledWith( + expect.objectContaining({ + adminAddress: ADMIN_ADDRESS, + action: "delete_user_note", + targetAddress: TARGET_ADDRESS, + }), + ); + }); + + it("returns 404 when the note does not exist for that address", async () => { + const db = makeMockDb({ deleteReturns: [] }); + const res = await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("not_found"); + }); + + it("does not write audit log when note is not found", async () => { + const db = makeMockDb({ deleteReturns: [] }); + await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(db._auditInsertValues).not.toHaveBeenCalled(); + }); + + it("returns 400 for an invalid Stellar address", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .delete(`/api/admin/users/bad-address/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("returns 400 for an invalid note UUID", async () => { + const db = makeMockDb(); + const res = await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/not-a-uuid`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("validation_error"); + }); + + it("propagates DB delete errors as 500", async () => { + const db = makeMockDb(); + (db.delete as jest.Mock).mockReturnValueOnce({ + where: jest.fn().mockReturnValue({ + returning: jest.fn().mockRejectedValue(new Error("db error")), + }), + }); + + const res = await request(makeApp(db)) + .delete(`/api/admin/users/${TARGET_ADDRESS}/notes/${NOTE_ID_1}`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(500); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Rate limiting +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("rate limiting", () => { + it("returns 429 after the per-admin limit is exceeded", async () => { + const db = makeMockDb({ selectRows: [] }); + const app = makeApp(db, 2); // limit = 2 + + await request(app) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + await request(app) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + const res = await request(app) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + expect(res.status).toBe(429); + expect(res.body).toEqual({ error: { code: "rate_limit_exceeded" } }); + }); + + it("includes RateLimit headers on successful responses", async () => { + const db = makeMockDb({ selectRows: [] }); + const res = await request(makeApp(db)) + .get(`/api/admin/users/${TARGET_ADDRESS}/notes`) + .set("Authorization", `Bearer ${adminJwt}`); + + // draft-6 standard headers + expect(res.headers["ratelimit-limit"]).toBeDefined(); + expect(res.headers["ratelimit-remaining"]).toBeDefined(); + }); +});