Amp-Thread-ID: https://ampcode.com/threads/T-019e55df-9bcd-73e6-9935-c1e6238cd273 Co-authored-by: Amp <amp@ampcode.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { randomUUID } from "node:crypto";
|
|
import { describe, expect, test } from "vitest";
|
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
|
import { ApprovalService } from "../../src/main/services/approval-service";
|
|
import { FileService } from "../../src/main/services/file-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";
|
|
import { createId } from "../../src/shared/ids";
|
|
|
|
function createConnection(): DatabaseConnection {
|
|
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-social-${randomUUID()}.db`));
|
|
}
|
|
|
|
function createOrganization(organization: OrganizationService): string {
|
|
return organization.createOrganization({
|
|
organizationName: "Acme Operations",
|
|
employeeName: "Dana Lee",
|
|
mission: "Coordinate agents with auditable approvals.",
|
|
goal: "Ship a verified control plane.",
|
|
});
|
|
}
|
|
|
|
function firstEmployeeId(connection: DatabaseConnection, orgId: string): string {
|
|
return (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
|
}
|
|
|
|
function createEmployee(connection: DatabaseConnection, orgId: string, displayName: string): string {
|
|
const id = createId("emp");
|
|
connection.db.prepare(`
|
|
insert into employees(id, org_id, display_name, role, created_at)
|
|
values (?, ?, ?, ?, ?)
|
|
`).run(id, orgId, displayName, "operator", new Date().toISOString());
|
|
return id;
|
|
}
|
|
|
|
describe("social service", () => {
|
|
test("creates social accounts with scoped employee grants", () => {
|
|
const connection = createConnection();
|
|
const organization = new OrganizationService(connection.db);
|
|
const social = new SocialService(connection.db);
|
|
const snapshot = new SnapshotService(connection.db);
|
|
let employeeId = "";
|
|
|
|
connection.transaction(() => {
|
|
const orgId = createOrganization(organization);
|
|
employeeId = firstEmployeeId(connection, orgId);
|
|
social.createAccount({
|
|
orgId,
|
|
platform: SOCIAL_PLATFORM.X,
|
|
handle: "@acme",
|
|
displayName: "Acme",
|
|
createdByEmployeeId: employeeId,
|
|
authorizedEmployeeId: employeeId,
|
|
});
|
|
});
|
|
|
|
const state = snapshot.load();
|
|
const grantScope = JSON.parse(state.socialAccountGrants[0]!.scopeJson) as { actions: string[]; riskCeiling: string };
|
|
expect(state.socialAccounts).toHaveLength(1);
|
|
expect(state.socialAccountGrants[0]?.employeeId).toBe(employeeId);
|
|
expect(state.auditEvents.find((event) => event.entityType === "social_account")?.actorId).toBe(employeeId);
|
|
expect(grantScope.actions).toEqual(["login", "draft", "approve"]);
|
|
expect(grantScope.riskCeiling).toBe("medium");
|
|
connection.close();
|
|
});
|
|
|
|
test("pins files and schedules social posts behind approval", () => {
|
|
const connection = createConnection();
|
|
const organization = new OrganizationService(connection.db);
|
|
const social = new SocialService(connection.db);
|
|
const approvals = new ApprovalService(connection.db);
|
|
const files = new FileService(connection.db, join(tmpdir(), `artifacts-${randomUUID()}`));
|
|
const snapshot = new SnapshotService(connection.db);
|
|
let orgId = "";
|
|
let socialAccountId = "";
|
|
let postId = "";
|
|
let employeeId = "";
|
|
|
|
connection.transaction(() => {
|
|
orgId = createOrganization(organization);
|
|
employeeId = firstEmployeeId(connection, orgId);
|
|
socialAccountId = social.createAccount({
|
|
orgId,
|
|
platform: SOCIAL_PLATFORM.X,
|
|
handle: "@acme",
|
|
displayName: "Acme",
|
|
createdByEmployeeId: employeeId,
|
|
authorizedEmployeeId: employeeId,
|
|
});
|
|
const fileId = createId("fil");
|
|
connection.db.prepare(`
|
|
insert into file_objects(
|
|
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
|
owner_type, owner_id, space_id, pinned, created_at, updated_at
|
|
)
|
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(fileId, orgId, "launch-thread.md", "md", 256, "abc", "artifacts/launch-thread.md", "employee", employeeId, null, 0, new Date().toISOString(), new Date().toISOString());
|
|
files.setPinned(orgId, fileId, true);
|
|
postId = social.createScheduledPost({
|
|
orgId,
|
|
socialAccountId,
|
|
bodyMd: "Pilot starts Friday.",
|
|
scheduledFor: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
createdByEmployeeId: employeeId,
|
|
});
|
|
});
|
|
|
|
connection.transaction(() => {
|
|
social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "scheduled", employeeId });
|
|
});
|
|
|
|
expect(() => connection.transaction(() => social.setScheduledPostState({
|
|
orgId,
|
|
scheduledPostId: postId,
|
|
state: "published",
|
|
employeeId,
|
|
}))).toThrow("Approved social approval is required.");
|
|
|
|
connection.transaction(() => {
|
|
const approvalId = (connection.db.prepare("select approval_id from scheduled_posts where id = ?")
|
|
.get(postId) as { approval_id: string }).approval_id;
|
|
approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId });
|
|
social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "published", employeeId });
|
|
});
|
|
|
|
const state = snapshot.load();
|
|
expect(state.files[0]?.pinned).toBe(true);
|
|
expect(state.approvals[0]?.kind).toBe("social_publish");
|
|
expect(state.approvals[0]?.state).toBe(APPROVAL_STATE.APPROVED);
|
|
expect(state.scheduledPosts[0]?.state).toBe("published");
|
|
expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId);
|
|
expect(state.scheduledPosts[0]?.approvalId).toBe(state.approvals[0]?.id);
|
|
connection.close();
|
|
});
|
|
|
|
test("rejects social actions without account grants", () => {
|
|
const connection = createConnection();
|
|
const organization = new OrganizationService(connection.db);
|
|
const social = new SocialService(connection.db);
|
|
let orgId = "";
|
|
let authorizedEmployeeId = "";
|
|
let outsiderId = "";
|
|
let socialAccountId = "";
|
|
let postId = "";
|
|
|
|
connection.transaction(() => {
|
|
orgId = createOrganization(organization);
|
|
authorizedEmployeeId = firstEmployeeId(connection, orgId);
|
|
outsiderId = createEmployee(connection, orgId, "Riley Chen");
|
|
socialAccountId = social.createAccount({
|
|
orgId,
|
|
platform: SOCIAL_PLATFORM.LINKEDIN,
|
|
handle: "acme",
|
|
displayName: "Acme",
|
|
createdByEmployeeId: authorizedEmployeeId,
|
|
authorizedEmployeeId,
|
|
});
|
|
postId = social.createScheduledPost({
|
|
orgId,
|
|
socialAccountId,
|
|
bodyMd: "Launch update.",
|
|
scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
|
createdByEmployeeId: authorizedEmployeeId,
|
|
});
|
|
});
|
|
|
|
expect(() => connection.transaction(() => social.createScheduledPost({
|
|
orgId,
|
|
socialAccountId,
|
|
bodyMd: "Unauthorized update.",
|
|
scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
|
createdByEmployeeId: outsiderId,
|
|
}))).toThrow("Social account grant is required.");
|
|
expect(() => connection.transaction(() => social.setScheduledPostState({
|
|
orgId,
|
|
scheduledPostId: postId,
|
|
state: "cancelled",
|
|
employeeId: outsiderId,
|
|
}))).toThrow("Social account grant is required.");
|
|
expect(() => social.launchLoginWindow({ orgId, socialAccountId, employeeId: outsiderId })).toThrow("Social account grant is required.");
|
|
connection.close();
|
|
});
|
|
|
|
test("rejects publish governance when grant risk ceiling is too low", () => {
|
|
const connection = createConnection();
|
|
const organization = new OrganizationService(connection.db);
|
|
const social = new SocialService(connection.db);
|
|
let orgId = "";
|
|
let employeeId = "";
|
|
let socialAccountId = "";
|
|
let postId = "";
|
|
|
|
connection.transaction(() => {
|
|
orgId = createOrganization(organization);
|
|
employeeId = firstEmployeeId(connection, orgId);
|
|
socialAccountId = social.createAccount({
|
|
orgId,
|
|
platform: SOCIAL_PLATFORM.YOUTUBE,
|
|
handle: "acme",
|
|
displayName: "Acme",
|
|
createdByEmployeeId: employeeId,
|
|
authorizedEmployeeId: employeeId,
|
|
});
|
|
connection.db.prepare("update social_account_grants set scope_json = ? where social_account_id = ?")
|
|
.run(JSON.stringify({ actions: ["login", "draft", "approve"], riskCeiling: "low" }), socialAccountId);
|
|
postId = social.createScheduledPost({
|
|
orgId,
|
|
socialAccountId,
|
|
bodyMd: "Launch update.",
|
|
scheduledFor: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
createdByEmployeeId: employeeId,
|
|
});
|
|
});
|
|
|
|
expect(() => connection.transaction(() => social.setScheduledPostState({
|
|
orgId,
|
|
scheduledPostId: postId,
|
|
state: "scheduled",
|
|
employeeId,
|
|
}))).toThrow("Social account grant risk ceiling is insufficient.");
|
|
connection.close();
|
|
});
|
|
|
|
test("rejects approval decisions from employees outside the approval organization", () => {
|
|
const connection = createConnection();
|
|
const organization = new OrganizationService(connection.db);
|
|
const approvals = new ApprovalService(connection.db);
|
|
let firstOrgId = "";
|
|
let secondOrgId = "";
|
|
let approvalId = "";
|
|
let secondEmployeeId = "";
|
|
|
|
connection.transaction(() => {
|
|
firstOrgId = createOrganization(organization);
|
|
secondOrgId = organization.createOrganization({ organizationName: "Beta Ops", employeeName: "Riley Chen", mission: "B", goal: "B" });
|
|
secondEmployeeId = firstEmployeeId(connection, secondOrgId);
|
|
approvalId = approvals.create({
|
|
orgId: firstOrgId,
|
|
kind: "social_publish",
|
|
title: "Approve social post",
|
|
details: "Pilot starts Friday.",
|
|
risk: "medium",
|
|
requestedByType: "employee",
|
|
requestedById: firstEmployeeId(connection, firstOrgId),
|
|
});
|
|
});
|
|
|
|
expect(() => connection.transaction(() => approvals.decide({
|
|
orgId: firstOrgId,
|
|
approvalId,
|
|
state: APPROVAL_STATE.APPROVED,
|
|
decidedByEmployeeId: secondEmployeeId,
|
|
}))).toThrow("Deciding employee was not found.");
|
|
connection.close();
|
|
});
|
|
});
|