Files
AgentCompany/tests/unit/compliance-service.test.ts
T

346 lines
16 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { ApprovalService } from "../../src/main/services/approval-service";
import { ChatService } from "../../src/main/services/chat-service";
import { ComplianceService } from "../../src/main/services/compliance-service";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SocialService } from "../../src/main/services/social-service";
import { APPROVAL_STATE, SOCIAL_PLATFORM } from "../../src/shared/domain";
function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-compliance-${randomUUID()}.db`));
}
describe("compliance service", () => {
test("creates GDPR export payloads with audit coverage", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const compliance = new ComplianceService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
let spaceId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "Exportable message",
attachmentFileIds: [],
});
compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_export",
requestedByEmployeeId: employeeId,
});
});
const state = snapshot.load();
const record = state.complianceExports[0];
if (!record) throw new Error("Compliance export was not created.");
const payload = JSON.parse(record.payloadJson) as { profile: { display_name: string }; chatMessages: unknown[] };
expect(record).toMatchObject({ subjectType: "employee", subjectId: employeeId, exportType: "gdpr_export", state: "completed" });
expect(payload.profile.display_name).toBe("Dana Lee");
expect(payload.chatMessages).toHaveLength(1);
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "created")).toBe(true);
connection.close();
});
test("blocks GDPR delete under holds and redacts after release", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const approvals = new ApprovalService(connection.db);
const chat = new ChatService(connection.db);
const compliance = new ComplianceService(connection.db);
const social = new SocialService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
let spaceId = "";
let holdId = "";
let approvalId = "";
let socialAccountId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Delete me", attachmentFileIds: [] });
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "board", senderId: employeeId, bodyMd: "As the board", attachmentFileIds: [] });
approvalId = approvals.create({
orgId,
kind: "security_review",
title: "Security review",
details: "Employee requested and decided.",
risk: "low",
requestedByType: "employee",
requestedById: employeeId,
});
approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId });
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
createdByEmployeeId: employeeId,
authorizedEmployeeId: employeeId,
});
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'github-copilot'")
.get(orgId) as { id: string }).id;
connection.db.prepare(`
insert into provider_auth_sessions(
id, org_id, provider_id, provider_key, employee_id, state, nonce, code_verifier_sha256,
redirect_uri, sign_in_url, partition, created_at, expires_at, completed_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null)
`).run(
"auths-test",
orgId,
providerId,
"github-copilot",
employeeId,
"state",
"nonce",
"a".repeat(64),
`agentcompany://provider-auth/${providerId}`,
"https://github.com/login",
`persist:agentcompany-provider-${providerId}`,
new Date().toISOString(),
new Date(Date.now() + 60000).toISOString(),
);
holdId = compliance.createLegalHold({
orgId,
entityType: "employee",
entityId: employeeId,
reason: "Discovery hold",
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
}))).toThrow("Active compliance hold prevents GDPR delete.");
connection.transaction(() => {
compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId });
compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
});
});
const state = snapshot.load();
const redactedApproval = state.approvals.find((approval) => approval.id === approvalId);
const redactedSocialAccount = state.socialAccounts.find((account) => account.id === socialAccountId);
expect(state.employees[0]?.displayName).toBe("Deleted employee");
expect(state.chatMessages[0]?.senderId).toBeNull();
expect(redactedApproval?.requestedById).toBeNull();
expect(redactedApproval?.decidedByEmployeeId).toBeNull();
expect(redactedSocialAccount?.authorizedEmployeeId).toBeNull();
expect(state.socialAccountGrants).toHaveLength(0);
expect(state.providerAuthSessions).toHaveLength(0);
expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete");
expect(state.complianceExports[0]?.requestedByEmployeeId).toBeNull();
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true);
// The erased content is gone, not moved: no message text, no copy in the deletion record, no personal detail in audit payloads.
expect(state.chatMessages.map((message) => [message.bodyMd, message.senderId])).toEqual([["[deleted]", null], ["[deleted]", null]]);
expect(state.chatMessages.every((message) => !JSON.stringify(message).includes("Delete me") && !JSON.stringify(message).includes("As the board"))).toBe(true);
expect(state.chatThreads.every((thread) => !thread.title.includes("Delete me") && !thread.title.includes("As the board"))).toBe(true);
const receipt = state.complianceExports.find((record) => record.exportType === "gdpr_delete")!.payloadJson;
expect(receipt).not.toContain("Dana Lee");
expect(receipt).not.toContain("Delete me");
expect(receipt).not.toContain("As the board");
expect(JSON.parse(receipt).erased).toMatchObject({ chatMessages: 2 });
const subjectEvents = state.auditEvents.filter((event) => (event.actorType === "employee" && event.actorId === employeeId) || event.entityId === employeeId);
expect(subjectEvents.length).toBeGreaterThan(0);
expect(subjectEvents.every((event) => event.payloadJson === '{"redacted":true}')).toBe(true);
// Redaction is the one change the log allows; anything else is still refused.
expect(() => connection.db.prepare("update audit_events set action = 'rewritten' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable.");
expect(() => connection.db.prepare("update audit_events set payload_json = '{}' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable.");
expect(() => connection.db.prepare("update audit_events set id = null, payload_json = '{\"redacted\":true}' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable.");
connection.close();
});
test("blocks GDPR delete under organization retention freeze", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const compliance = new ComplianceService(connection.db);
let orgId = "";
let employeeId = "";
let agentId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
agentId = organization.createAgent({
orgId,
name: "Ada",
role: "Release reviewer",
department: "Engineering",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
});
compliance.createRetentionFreeze({
orgId,
scopeType: "organization",
scopeId: orgId,
reason: "Retention freeze",
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => compliance.createExport({
orgId,
subjectType: "agent",
subjectId: agentId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
}))).toThrow("Active compliance hold prevents GDPR delete.");
connection.close();
});
test("blocks GDPR delete under related chat and file holds", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const compliance = new ComplianceService(connection.db);
let orgId = "";
let employeeId = "";
let spaceId = "";
let threadId = "";
let fileId = "";
let holdId = "";
let freezeId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Held chat", attachmentFileIds: [] });
threadId = (connection.db.prepare("select thread_id from chat_messages where org_id = ?").get(orgId) as { thread_id: string }).thread_id;
fileId = "file-held";
connection.db.prepare(`
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "held.txt", "text/plain", 4, "a".repeat(64), "artifacts/held.txt", "employee", employeeId, new Date().toISOString());
holdId = compliance.createLegalHold({
orgId,
entityType: "chat_thread",
entityId: threadId,
reason: "Thread discovery",
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
}))).toThrow("Active compliance hold prevents GDPR delete.");
connection.transaction(() => {
compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId });
freezeId = compliance.createRetentionFreeze({
orgId,
scopeType: "chat_space",
scopeId: spaceId,
reason: "Space retention",
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
}))).toThrow("Active compliance hold prevents GDPR delete.");
connection.transaction(() => {
compliance.setRetentionFreezeState({ orgId, retentionFreezeId: freezeId, active: false, employeeId });
compliance.createLegalHold({
orgId,
entityType: "file_object",
entityId: fileId,
reason: "File discovery",
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => compliance.createExport({
orgId,
subjectType: "employee",
subjectId: employeeId,
exportType: "gdpr_delete",
requestedByEmployeeId: employeeId,
}))).toThrow("Active compliance hold prevents GDPR delete.");
connection.close();
});
test("a hold on a thread or channel where the employee spoke as the board blocks their deletion too", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-board-hold-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const compliance = new ComplianceService(connection.db);
let orgId = "";
let spaceId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana Lee", mission: "m", goal: "g" });
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "board-room", topic: "t", visibility: "workspace" });
});
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
connection.transaction(() => chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "board", senderId: employeeId, bodyMd: "Board decision", attachmentFileIds: [] }));
const threadId = (connection.db.prepare("select thread_id from chat_messages where org_id = ?").get(orgId) as { thread_id: string }).thread_id;
const holdId = connection.transaction(() => compliance.createLegalHold({ orgId, entityType: "chat_thread", entityId: threadId, reason: "litigation", createdByEmployeeId: employeeId }));
const erase = () => connection.transaction(() => compliance.createExport({ orgId, subjectType: "employee", subjectId: employeeId, exportType: "gdpr_delete", requestedByEmployeeId: employeeId }));
expect(erase).toThrow("Active compliance hold prevents GDPR delete.");
connection.transaction(() => compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId }));
connection.transaction(() => compliance.createRetentionFreeze({ orgId, scopeType: "chat_space", scopeId: spaceId, reason: "audit", createdByEmployeeId: employeeId }));
expect(erase).toThrow("Active compliance hold prevents GDPR delete.");
expect(connection.db.prepare("select body_md from chat_messages where org_id = ?").get(orgId)).toEqual({ body_md: "Board decision" });
connection.close();
});
});