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>
315 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|