chahinebrini 056726a166 feat(admin): Phase 2 Backend — Users + Moderation endpoints + 2 schema migrations
Two parallel agent-batches consolidated:

USERS-MGMT (rebreak-backend agent):
- Schema: Profile gets banned, bannedAt, bannedReason, deletedAt + indexes
- Migration: 20260509_profile_admin_management (additive, idempotent)
- DB-layer backend/server/db/adminUsers.ts:
  listAdminUsers (cursor-pagination, search, plan-filter)
  updateAdminUser (plan-validation, ban-stamping)
  softDeleteAdminUser (DSGVO PII-scrub: nickname=null, email=deleted-{shortid}@deleted.local)
- 3 endpoints under /api/admin/users:
  GET (list with ?cursor&limit&q&plan&includeDeleted)
  PATCH /:id (plan/banned/bannedReason/lyraVoiceId)
  DELETE /:id (soft-delete idempotent)
- 12 tests passing

MODERATION (rebreak-backend agent):
- Schema: CommunityPost+CommunityReply get isModerated, isDeleted, deletedAt,
  reportedAt + index (is_moderated, reported_at)
- New ModerationAction model → audit-log table
- Migration: 20260509_moderation_queue (additive, idempotent)
- DB-layer backend/server/db/moderation.ts:
  listModerationQueue (merge posts+comments, sort by reportedAt, cursor)
  dismissModerationItem
  deleteModerationItem (content scrub + audit snapshot)
  banUserFromModerationItem (reuses banned/bannedAt/bannedReason fields)
- 4 endpoints under /api/admin/moderation:
  GET /queue, POST /:id/dismiss, POST /:id/delete, POST /:id/ban-user
- 11 tests passing

Backend total: 78 tests passing | 4 skipped (pre-existing requireAdmin tests)

Auth: x-admin-secret header (consistent with existing /admin/* endpoints).

DSGVO:
- Soft-delete scrubt PII statt hard-delete
- Email NICHT in admin user-list (lebt nur in auth.users)
- Audit-log für moderation-actions (90-day cleanup-cron pending hans-mueller-DSB-review)

⚠️ MIGRATIONS — auto-deploy via pipeline (commit b38bf17 detection):
- 20260509_profile_admin_management
- 20260509_moderation_queue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:48:35 +02:00

251 lines
9.4 KiB
TypeScript

/**
* Tests for admin users management — db/adminUsers + endpoints.
*
* Covers:
* - listAdminUsers: pagination cursor + plan-filter + search
* - updateAdminUser: plan-validation + ban-stamping + voice
* - softDeleteAdminUser: PII-scrubbing + idempotency
* - GET endpoint: 401 ohne admin-secret
* - PATCH endpoint: 401 ohne admin-secret + happy path
* - DELETE endpoint: 401 + happy path
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
// Snapshot der globalen Nitro-Stubs (siehe tests/setup.ts) damit wir nach
// Endpoint-Tests die Originale wiederherstellen können — sonst leakt
// `getHeader`-mock auf andere Test-Files (singleFork-Pool).
const g = globalThis as Record<string, unknown>;
const originalStubs = {
getHeader: g.getHeader,
getQuery: g.getQuery,
getRouterParam: g.getRouterParam,
readBody: g.readBody,
useRuntimeConfig: g.useRuntimeConfig,
};
// ─── Prisma mock ─────────────────────────────────────────────────────────────
const prismaMock = vi.hoisted(() => ({
profile: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => prismaMock,
}));
import {
listAdminUsers,
updateAdminUser,
softDeleteAdminUser,
} from "../../server/db/adminUsers";
beforeEach(() => {
vi.clearAllMocks();
// useRuntimeConfig stub gibt adminSecret für endpoint-tests
g.useRuntimeConfig = vi.fn(() => ({
adminSecret: "test-secret",
public: { supabase: { url: "", key: "" } },
}));
});
afterEach(() => {
// Globale Nitro-Stubs zurücksetzen — sonst leakt getHeader-mock auf
// andere Test-Files (singleFork-Pool teilt sich Modul-Globals).
for (const [k, v] of Object.entries(originalStubs)) {
g[k] = v;
}
});
// ─── listAdminUsers ──────────────────────────────────────────────────────────
describe("listAdminUsers — pagination + nextCursor", () => {
it("returns nextCursor when more rows exist (limit+1 fetched)", async () => {
// Simuliere 3 rows bei limit=2 → over-fetch ist 3, also nextCursor = items[1].id
const fakeRows = [
makeRow("aaa", { plan: "free" }),
makeRow("bbb", { plan: "pro" }),
makeRow("ccc", { plan: "free" }),
];
prismaMock.profile.findMany.mockResolvedValueOnce(fakeRows);
const result = await listAdminUsers({ limit: 2 });
expect(result.items).toHaveLength(2);
expect(result.items.map((r) => r.id)).toEqual(["aaa", "bbb"]);
expect(result.nextCursor).toBe("bbb");
expect(prismaMock.profile.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 3, // limit + 1
where: expect.objectContaining({ deletedAt: null }),
}),
);
});
it("nextCursor is null when no more rows", async () => {
prismaMock.profile.findMany.mockResolvedValueOnce([
makeRow("only", { plan: "legend" }),
]);
const result = await listAdminUsers({ limit: 50 });
expect(result.nextCursor).toBeNull();
expect(result.items).toHaveLength(1);
});
it("applies plan-filter + search-term to where-clause", async () => {
prismaMock.profile.findMany.mockResolvedValueOnce([]);
await listAdminUsers({ plan: "pro", q: "Chahine" });
const callArgs = prismaMock.profile.findMany.mock.calls[0]![0]!;
expect(callArgs.where.plan).toBe("pro");
expect(callArgs.where.OR).toEqual([
{ nickname: { contains: "Chahine", mode: "insensitive" } },
{ username: { contains: "Chahine", mode: "insensitive" } },
]);
});
});
// ─── updateAdminUser ─────────────────────────────────────────────────────────
describe("updateAdminUser — validates plan + stamps bannedAt", () => {
it("rejects unknown plan-values with 400", async () => {
await expect(
updateAdminUser("user-id", { plan: "enterprise" }),
).rejects.toMatchObject({ statusCode: 400 });
expect(prismaMock.profile.update).not.toHaveBeenCalled();
});
it("stamps bannedAt when banned=true and clears reason on un-ban", async () => {
prismaMock.profile.update.mockResolvedValueOnce(
makeRow("u1", { banned: true, bannedAt: new Date() }),
);
await updateAdminUser("u1", { banned: true });
const dataArg = prismaMock.profile.update.mock.calls[0]![0]!.data;
expect(dataArg.banned).toBe(true);
expect(dataArg.bannedAt).toBeInstanceOf(Date);
prismaMock.profile.update.mockResolvedValueOnce(
makeRow("u1", { banned: false, bannedAt: null }),
);
await updateAdminUser("u1", { banned: false });
const dataArg2 = prismaMock.profile.update.mock.calls[1]![0]!.data;
expect(dataArg2.banned).toBe(false);
expect(dataArg2.bannedAt).toBeNull();
expect(dataArg2.bannedReason).toBeNull();
});
it("rejects empty patch (no allowed fields → 400)", async () => {
await expect(updateAdminUser("user-id", {})).rejects.toMatchObject({
statusCode: 400,
});
});
});
// ─── softDeleteAdminUser ─────────────────────────────────────────────────────
describe("softDeleteAdminUser — DSGVO PII-scrub + idempotent", () => {
it("scrubs PII fields and stamps deletedAt", async () => {
prismaMock.profile.findUnique.mockResolvedValueOnce({ deletedAt: null });
prismaMock.profile.update.mockResolvedValueOnce({});
const result = await softDeleteAdminUser(
"128df360-2008-4d6f-8aa1-bdb41ec1362f",
);
expect(result).toEqual({ ok: true, alreadyDeleted: false });
const updateCall = prismaMock.profile.update.mock.calls[0]![0]!;
expect(updateCall.where.id).toBe("128df360-2008-4d6f-8aa1-bdb41ec1362f");
expect(updateCall.data.nickname).toBeNull();
expect(updateCall.data.avatar).toBeNull();
expect(updateCall.data.username).toMatch(/^deleted-[a-f0-9]{8}$/);
expect(updateCall.data.birthYear).toBeNull();
expect(updateCall.data.gender).toBeNull();
expect(updateCall.data.bundesland).toBeNull();
expect(updateCall.data.stripeCustomerId).toBeNull();
expect(updateCall.data.deletedAt).toBeInstanceOf(Date);
});
it("is idempotent — returns alreadyDeleted=true on re-run", async () => {
prismaMock.profile.findUnique.mockResolvedValueOnce({
deletedAt: new Date("2026-01-01"),
});
const result = await softDeleteAdminUser("user-id");
expect(result).toEqual({ ok: true, alreadyDeleted: true });
expect(prismaMock.profile.update).not.toHaveBeenCalled();
});
it("throws 404 if user not found", async () => {
prismaMock.profile.findUnique.mockResolvedValueOnce(null);
await expect(softDeleteAdminUser("ghost")).rejects.toMatchObject({
statusCode: 404,
});
});
});
// ─── Endpoints — Auth-Guard ──────────────────────────────────────────────────
describe("GET /api/admin/users — 401 ohne admin-secret", () => {
it("rejects request without x-admin-secret header", async () => {
(globalThis as Record<string, unknown>).getHeader = vi.fn(() => undefined);
(globalThis as Record<string, unknown>).getQuery = vi.fn(() => ({}));
const mod = await import("../../server/api/admin/users/index.get");
const handler = mod.default as (e: unknown) => Promise<unknown>;
await expect(handler({})).rejects.toMatchObject({ statusCode: 401 });
});
});
describe("PATCH /api/admin/users/[id] — 401 ohne admin-secret", () => {
it("rejects request with wrong secret", async () => {
(globalThis as Record<string, unknown>).getHeader = vi.fn(
() => "wrong-secret",
);
(globalThis as Record<string, unknown>).getRouterParam = vi.fn(
() => "user-id",
);
(globalThis as Record<string, unknown>).readBody = vi.fn(async () => ({
banned: true,
}));
const mod = await import("../../server/api/admin/users/[id].patch");
const handler = mod.default as (e: unknown) => Promise<unknown>;
await expect(handler({})).rejects.toMatchObject({ statusCode: 401 });
});
});
describe("DELETE /api/admin/users/[id] — 401 ohne admin-secret", () => {
it("rejects request without secret", async () => {
(globalThis as Record<string, unknown>).getHeader = vi.fn(() => undefined);
(globalThis as Record<string, unknown>).getRouterParam = vi.fn(
() => "user-id",
);
const mod = await import("../../server/api/admin/users/[id].delete");
const handler = mod.default as (e: unknown) => Promise<unknown>;
await expect(handler({})).rejects.toMatchObject({ statusCode: 401 });
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeRow(id: string, overrides: Partial<Record<string, unknown>> = {}) {
return {
id,
nickname: `nick-${id}`,
username: `user-${id}`,
avatar: null,
plan: "free",
streak: 0,
banned: false,
bannedAt: null,
deletedAt: null,
createdAt: new Date(),
lyraVoiceId: null,
premiumUntil: null,
proTrialExpiresAt: null,
...overrides,
};
}