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>
251 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|