evOo-local/apps/api/src/ai-consent.test.ts
Claude 533f82d856
feat(media): disclose media servers in AI data sharing
Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.

Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:51:13 +00:00

194 lines
8.3 KiB
TypeScript

import { serializeModelSecret } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { AI_DISCLOSURE_VERSION } from "@rakazo/contracts";
import { describe, expect, it, vi } from "vitest";
vi.mock("./thread-target.js", () => ({
resolveThreadTarget: async () => ({ kind: "bot", botId: "bot" }),
}));
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import type { RouterDeps } from "./router.js";
function setup() {
const upsert = vi.fn(async () => ({}));
const deps = {
env: { agentRuntime: "scripted" },
prisma: {
spaceModelPreference: { findMany: vi.fn(async () => []), findFirst: vi.fn(async () => null) },
spaceModelTier: { findMany: vi.fn(async () => []) },
secret: { findMany: vi.fn(async () => []) },
bot: { findMany: vi.fn(async () => []) },
spaceVoicePreference: {
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
},
spaceMemoryConfig: {
findUnique: vi.fn(async () => null),
count: vi.fn(async () => 0),
},
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) },
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)),
},
} as unknown as RouterDeps;
return { deps, upsert, actor: { userId: "user", spaceId: "space" } as Actor };
}
describe("consent grants", () => {
it("includes reachable helper models and cloud services for a targeted send", async () => {
const { deps, actor } = setup();
deps.env.agentRuntime = "pi";
deps.cloudAgent = {
key: "test",
spaceId: actor.spaceId,
provider: { describe: () => ({ id: "cursor", capabilities: { offline: false } }) },
} as RouterDeps["cloudAgent"];
vi.mocked(deps.prisma.spaceModelPreference.findMany).mockResolvedValue([
{
modelId: "helper-model",
isDefault: false,
credential: { provider: "anthropic", secretId: "test-secret" },
},
] as never);
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual(
expect.arrayContaining(["Anthropic", "Cursor"]),
);
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
expect(deps.prisma.bot.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ id: { in: ["bot"] } }) }),
);
});
it("discloses the model a bot's tier maps to on a router endpoint", async () => {
const { deps, actor } = setup();
deps.env.agentRuntime = "pi";
deps.secrets = {
load: vi.fn(() =>
serializeModelSecret({ kind: "openai_compatible", baseUrl: "http://127.0.0.1:8080/v1" }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceModelTier.findMany).mockResolvedValue([
{
tier: "code",
credentialId: "router",
modelId: "router-code",
credential: {
id: "router",
userId: actor.userId,
provider: "openai-compatible",
label: "Router",
secretId: "router-secret",
supportsImages: false,
createdAt: new Date(0),
updatedAt: new Date(0),
preferences: [],
},
},
] as never);
vi.mocked(deps.prisma.bot.findMany).mockResolvedValue([
{ modelProvider: null, modelId: null, thinkingLevel: null, modelTier: "code" },
] as never);
vi.mocked(deps.prisma.secret.findMany).mockResolvedValue([
{ id: "router-secret", ciphertext: "fake-ciphertext" },
] as never);
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients).toEqual([
expect.objectContaining({
name: expect.stringContaining("http://127.0.0.1:8080"),
detail: "Model: router-code.",
}),
]);
});
it("discloses only the selected voice provider before a voice action", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
{ credential: { provider: "openai" } },
{ credential: { provider: "elevenlabs" } },
] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("OpenAI");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
});
it("discloses memory provider details to space owners", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceMemoryConfig.findUnique).mockResolvedValue({
provider: "supermemory",
settings: { baseUrl: "http://127.0.0.1:8123" },
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toContain("Supermemory");
expect(status.recipients[0]?.name).toContain("127.0.0.1:8123");
expect(deps.prisma.spaceMemoryConfig.count).not.toHaveBeenCalled();
});
it("redacts space memory provider details for non-owner members", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceMember.findUnique).mockResolvedValue({ role: "member" } as never);
vi.mocked(deps.prisma.spaceMemoryConfig.count).mockResolvedValue(1);
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("Space memory");
expect(JSON.stringify(status.recipients)).not.toContain("127.0.0.1");
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
});
it("discloses the deployment's media servers before model use, keyed by server", async () => {
const { deps, actor } = setup();
const settings = {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
};
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext",
} as never);
deps.secrets = {
load: vi.fn(() => JSON.stringify(settings)),
} as unknown as RouterDeps["secrets"];
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
"ComfyUI (http://127.0.0.1:8188)",
"Transcription (http://127.0.0.1:8080)",
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";
expect((await aiConsentStatus(deps, actor)).privacyUrl).toBe("https://example.com/privacy");
});
it("rejects stale disclosures, unknown recipients, and a changed account or Space", async () => {
const { deps, actor, upsert } = setup();
const current = await aiConsentStatus(deps, actor);
const input = {
scope: current.scope,
version: current.version,
keys: [current.recipients[0]!.key],
};
for (const invalid of [
{ ...input, version: "old" },
{ ...input, scope: "different-account" },
{ ...input, keys: ["unknown"] },
]) {
await expect(allowAiConsent(deps, actor, invalid)).rejects.toThrow("changed");
}
expect(upsert).not.toHaveBeenCalled();
await allowAiConsent(deps, actor, input);
expect(upsert).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
create: {
userId: actor.userId,
spaceId: actor.spaceId,
recipientKey: input.keys[0],
version: AI_DISCLOSURE_VERSION,
},
}),
);
});
});