evOo-local/apps/api/src/ai-consent.test.ts
Claude ebac3ef9d1
fix(media): disclose the transcription server before dictation uses it
Dictation falls back to the owner's transcription server when the voice
provider cannot transcribe, so that server is a voice recipient too: it is
listed for voice use (as "Transcription (<origin>)") whenever it would take
the recording, and not when the voice provider transcribes itself.

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

215 lines
9.2 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("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext-voice",
} as never);
deps.secrets = {
load: vi.fn(() =>
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
credential: { provider: "kokoro" },
voiceId: "af_heart",
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
["Local voice", "voice"],
["Transcription (http://127.0.0.1:8080)", "voice"],
]);
});
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,
},
}),
);
});
});