rebreak-monorepo/backend/tests/admin/moderation.test.ts
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

315 lines
10 KiB
TypeScript

/**
* Tests for admin-moderation DB-layer (server/db/moderation.ts).
*
* Covers:
* - listModerationQueue: merges posts + comments, sorts by reportedAt desc
* - dismissModerationItem: clears flag, writes audit-log
* - deleteModerationItem: soft-deletes content, persists snapshot
* - banUserFromModerationItem: sets Profile.banned, writes audit-log
*
* Strategy: prisma-mock via vi.hoisted (analog demographics.patch.test.ts).
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const prismaMock = vi.hoisted(() => ({
communityPost: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
communityReply: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
profile: {
update: vi.fn(),
},
moderationAction: {
create: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => prismaMock,
}));
import {
listModerationQueue,
dismissModerationItem,
deleteModerationItem,
banUserFromModerationItem,
} from "../../server/db/moderation";
beforeEach(() => {
vi.clearAllMocks();
});
// ─── listModerationQueue ─────────────────────────────────────────────────────
describe("listModerationQueue — merges posts + comments by reportedAt desc", () => {
it("returns merged sorted list with newest report first", async () => {
prismaMock.communityPost.findMany.mockResolvedValueOnce([
{
id: "post-1",
userId: "user-1",
content: "bad post 1",
reportedAt: new Date("2026-05-01T10:00:00Z"),
createdAt: new Date("2026-04-30T10:00:00Z"),
isDeleted: false,
author: {
id: "user-1",
nickname: "alice",
avatar: null,
plan: "free",
},
},
]);
prismaMock.communityReply.findMany.mockResolvedValueOnce([
{
id: "reply-1",
postId: "post-99",
userId: "user-2",
content: "bad comment 1",
reportedAt: new Date("2026-05-02T10:00:00Z"),
createdAt: new Date("2026-05-01T10:00:00Z"),
isDeleted: false,
author: {
id: "user-2",
nickname: "bob",
avatar: null,
plan: "pro",
},
},
]);
const result = await listModerationQueue({ limit: 10 });
expect(result.items).toHaveLength(2);
// reply has newer reportedAt → first
expect(result.items[0]!.type).toBe("comment");
expect(result.items[0]!.id).toBe("reply-1");
expect(result.items[0]!.postId).toBe("post-99");
expect(result.items[1]!.type).toBe("post");
expect(result.items[1]!.id).toBe("post-1");
expect(result.nextCursor).toBeNull();
});
it("emits nextCursor when posts overflow limit", async () => {
// limit=1, posts.length=2 → nextCursor non-null
prismaMock.communityPost.findMany.mockResolvedValueOnce([
{
id: "post-1",
userId: "user-1",
content: "p1",
reportedAt: new Date("2026-05-01T10:00:00Z"),
createdAt: new Date("2026-04-30T10:00:00Z"),
isDeleted: false,
author: null,
},
{
id: "post-2",
userId: "user-1",
content: "p2",
reportedAt: new Date("2026-04-29T10:00:00Z"),
createdAt: new Date("2026-04-29T10:00:00Z"),
isDeleted: false,
author: null,
},
]);
prismaMock.communityReply.findMany.mockResolvedValueOnce([]);
const result = await listModerationQueue({ limit: 1 });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe("post-1");
expect(result.nextCursor).toBe("post:post-1");
});
it("filters where: { isModerated: true } on both tables", async () => {
prismaMock.communityPost.findMany.mockResolvedValueOnce([]);
prismaMock.communityReply.findMany.mockResolvedValueOnce([]);
await listModerationQueue();
expect(prismaMock.communityPost.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { isModerated: true },
}),
);
expect(prismaMock.communityReply.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { isModerated: true },
}),
);
});
});
// ─── dismissModerationItem ───────────────────────────────────────────────────
describe("dismissModerationItem — flag clear", () => {
it("sets isModerated=false on post + writes audit-log dismiss", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce({
id: "post-1",
content: "original content",
});
prismaMock.communityPost.update.mockResolvedValueOnce({});
prismaMock.moderationAction.create.mockResolvedValueOnce({});
const result = await dismissModerationItem("post", "post-1", "admin-1");
expect(result).toEqual({ ok: true });
expect(prismaMock.communityPost.update).toHaveBeenCalledWith({
where: { id: "post-1" },
data: { isModerated: false, reportedAt: null },
});
expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
targetType: "post",
targetId: "post-1",
action: "dismiss",
adminUserId: "admin-1",
contentSnapshot: "original content",
}),
});
});
it("404 when target post not found", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce(null);
await expect(
dismissModerationItem("post", "nonexistent", null),
).rejects.toMatchObject({ statusCode: 404 });
expect(prismaMock.communityPost.update).not.toHaveBeenCalled();
expect(prismaMock.moderationAction.create).not.toHaveBeenCalled();
});
it("dispatches to communityReply when type=comment", async () => {
prismaMock.communityReply.findUnique.mockResolvedValueOnce({
id: "reply-1",
content: "bad reply",
});
prismaMock.communityReply.update.mockResolvedValueOnce({});
prismaMock.moderationAction.create.mockResolvedValueOnce({});
await dismissModerationItem("comment", "reply-1", null);
expect(prismaMock.communityReply.update).toHaveBeenCalledWith({
where: { id: "reply-1" },
data: { isModerated: false, reportedAt: null },
});
expect(prismaMock.communityPost.update).not.toHaveBeenCalled();
});
});
// ─── deleteModerationItem ────────────────────────────────────────────────────
describe("deleteModerationItem — soft-delete with audit-snapshot", () => {
it("scrubs content + sets isDeleted=true + persists original in audit-log", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce({
id: "post-1",
content: "this is the original toxic content",
});
prismaMock.communityPost.update.mockResolvedValueOnce({});
prismaMock.moderationAction.create.mockResolvedValueOnce({});
await deleteModerationItem("post", "post-1", "admin-1", "Hass-Rede");
expect(prismaMock.communityPost.update).toHaveBeenCalledWith({
where: { id: "post-1" },
data: expect.objectContaining({
content: "",
isDeleted: true,
deletedAt: expect.any(Date),
}),
});
expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
targetType: "post",
targetId: "post-1",
action: "delete",
adminUserId: "admin-1",
contentSnapshot: "this is the original toxic content",
reason: "Hass-Rede",
}),
});
});
it("404 when target not found", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce(null);
await expect(
deleteModerationItem("post", "nonexistent", null),
).rejects.toMatchObject({ statusCode: 404 });
});
});
// ─── banUserFromModerationItem ───────────────────────────────────────────────
describe("banUserFromModerationItem — sets Profile.banned + audit-log", () => {
it("patches Profile.banned=true, writes audit-log ban_user", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce({
id: "post-1",
content: "violating content",
userId: "user-99",
});
prismaMock.profile.update.mockResolvedValueOnce({});
prismaMock.moderationAction.create.mockResolvedValueOnce({});
const result = await banUserFromModerationItem(
"post",
"post-1",
"admin-1",
"wiederholter Verstoß",
);
expect(result).toEqual({ ok: true, bannedUserId: "user-99" });
expect(prismaMock.profile.update).toHaveBeenCalledWith({
where: { id: "user-99" },
data: expect.objectContaining({
banned: true,
bannedAt: expect.any(Date),
bannedReason: "wiederholter Verstoß",
}),
});
expect(prismaMock.moderationAction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "ban_user",
contentSnapshot: "violating content",
reason: "wiederholter Verstoß",
}),
});
});
it("uses default ban-reason when none provided", async () => {
prismaMock.communityReply.findUnique.mockResolvedValueOnce({
id: "reply-1",
content: "bad",
userId: "user-99",
});
prismaMock.profile.update.mockResolvedValueOnce({});
prismaMock.moderationAction.create.mockResolvedValueOnce({});
await banUserFromModerationItem("comment", "reply-1", null, null);
expect(prismaMock.profile.update).toHaveBeenCalledWith({
where: { id: "user-99" },
data: expect.objectContaining({
banned: true,
bannedReason: "Moderation: comment reply-1",
}),
});
});
it("404 when target item not found", async () => {
prismaMock.communityPost.findUnique.mockResolvedValueOnce(null);
await expect(
banUserFromModerationItem("post", "nonexistent", null),
).rejects.toMatchObject({ statusCode: 404 });
expect(prismaMock.profile.update).not.toHaveBeenCalled();
});
});