- Replace findSpaceMemoryConfig inline call with getMemoryProviderConfig(deps, actor) - Add owner gate: space members get 403, owners get config - Add authorization tests for providerConfig/connectProvider/setDefaultScope/disconnectProvider - Add wiring test to ensure router.ts uses the owner-gated handler
976 lines
32 KiB
TypeScript
976 lines
32 KiB
TypeScript
import { RPCHandler } from "@orpc/server/fetch";
|
|
import { COMPUTER_SCREEN_UNAVAILABLE, ComputerScreenUnavailableError } from "@rakazo/adapters";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import { REPLY_QUOTE_MAX_LENGTH } from "@rakazo/contracts";
|
|
import { openScreenCapability } from "@rakazo/core/node/screen-capability";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createRouter, type RouterDeps } from "./router.js";
|
|
|
|
describe("account preferences", () => {
|
|
function preferencesDeps(avatarStyle: string) {
|
|
const update = vi.fn().mockResolvedValue({});
|
|
const prisma = {
|
|
user: {
|
|
update,
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
|
email: "user@rakazo.test",
|
|
name: "Test User",
|
|
avatarStyle,
|
|
}),
|
|
},
|
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
return { update, deps, actor, handler: new RPCHandler(createRouter(deps)) };
|
|
}
|
|
|
|
it("keeps an unconfigured catalog offline unless explicitly requested", async () => {
|
|
const { actor, deps } = preferencesDeps("robot");
|
|
const fetch = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ results: [] }), {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
deps.remoteConnectors = { fetch } as RouterDeps["remoteConnectors"];
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
const request = async (usePublicCatalog?: boolean) =>
|
|
handler.handle(
|
|
new Request("http://127.0.0.1/rpc/capabilities/catalogSearch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { query: "notion", usePublicCatalog } }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
const { response } = await request();
|
|
await expect(response.json()).resolves.toEqual({ json: { enabled: false, results: [] } });
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
await request(true);
|
|
expect(fetch).toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists and returns the selected avatar style", async () => {
|
|
const { update, actor, handler } = preferencesDeps("organic");
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/preferences/update", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { avatarStyle: "organic" } }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(update).toHaveBeenCalledWith({
|
|
where: { id: "user-1" },
|
|
data: { avatarStyle: "organic" },
|
|
});
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({ avatarStyle: "organic" }),
|
|
});
|
|
});
|
|
|
|
it("rejects avatar styles outside robot|organic", async () => {
|
|
const { update, actor, handler } = preferencesDeps("robot");
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/preferences/update", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { avatarStyle: "dicebear" } }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("coerces unknown stored avatar styles to robot on me", async () => {
|
|
const { actor, handler } = preferencesDeps("custom-cdn");
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/me", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({ avatarStyle: "robot" }),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("model setup gate", () => {
|
|
function modelGateDeps(options: {
|
|
agentRuntime: string;
|
|
deploymentModelKey?: string;
|
|
deploymentModelCredentialCipher?: string;
|
|
}) {
|
|
const prisma = {
|
|
user: {
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
|
email: "user@rakazo.test",
|
|
name: "Test User",
|
|
avatarStyle: "robot",
|
|
}),
|
|
},
|
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
deploymentSettings: {
|
|
findUnique: vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
options.deploymentModelCredentialCipher
|
|
? { deploymentModelCredentialCipher: options.deploymentModelCredentialCipher }
|
|
: null,
|
|
),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
env: {
|
|
agentRuntime: options.agentRuntime,
|
|
defaultProvider: "openrouter",
|
|
defaultModel: "test-model",
|
|
deploymentModelKey: options.deploymentModelKey,
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
return { actor, handler: new RPCHandler(createRouter(deps)) };
|
|
}
|
|
|
|
async function call(handler: RPCHandler<never>, actor: Actor, path: string, body: unknown) {
|
|
const { response } = await handler.handle(
|
|
new Request(`http://127.0.0.1/rpc/${path}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: body }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
return response;
|
|
}
|
|
|
|
it("refuses to start a run when no model is configured", async () => {
|
|
const { actor, handler } = modelGateDeps({ agentRuntime: "pi" });
|
|
|
|
const response = await call(handler, actor, "threads/send", {
|
|
botId: "bot-1",
|
|
text: "hello",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({
|
|
code: "BAD_REQUEST",
|
|
message: "Connect a model to start a run.",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("does not require a model credential for the scripted test runtime", async () => {
|
|
const { actor, handler } = modelGateDeps({ agentRuntime: "scripted" });
|
|
|
|
const response = await call(handler, actor, "me", null);
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({ needsModel: false }),
|
|
});
|
|
});
|
|
|
|
it("accepts a deployment model key as model configuration", async () => {
|
|
const { actor, handler } = modelGateDeps({
|
|
agentRuntime: "pi",
|
|
deploymentModelKey: "fake-deployment-key",
|
|
});
|
|
|
|
const response = await call(handler, actor, "me", null);
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({ needsModel: false }),
|
|
});
|
|
});
|
|
|
|
it("does not accept a stored deployment cipher the executor cannot use", async () => {
|
|
const { actor, handler } = modelGateDeps({
|
|
agentRuntime: "pi",
|
|
deploymentModelCredentialCipher: "legacy-ciphertext",
|
|
});
|
|
|
|
const response = await call(handler, actor, "me", null);
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({ needsModel: true }),
|
|
});
|
|
});
|
|
|
|
it("rejects a reply quote without a reply target", async () => {
|
|
const { actor, handler } = modelGateDeps({ agentRuntime: "scripted" });
|
|
|
|
const response = await call(handler, actor, "threads/send", {
|
|
botId: "bot-1",
|
|
text: "hello",
|
|
replyQuote: "just this span",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
issues: expect.arrayContaining([expect.objectContaining({ path: ["replyQuote"] })]),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("rejects an over-length reply quote", async () => {
|
|
const { actor, handler } = modelGateDeps({ agentRuntime: "scripted" });
|
|
|
|
const response = await call(handler, actor, "threads/send", {
|
|
botId: "bot-1",
|
|
text: "hello",
|
|
replyToMessageId: "parent-1",
|
|
replyQuote: "x".repeat(REPLY_QUOTE_MAX_LENGTH + 1),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
issues: expect.arrayContaining([expect.objectContaining({ path: ["replyQuote"] })]),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("thread answer delivery", () => {
|
|
it("accepts a durable answer when the immediate worker wake fails", async () => {
|
|
const answerRunInput = vi.fn().mockResolvedValue(true);
|
|
const enqueue = vi.fn().mockRejectedValue(new Error("job broker unavailable"));
|
|
const sink = createTestSink();
|
|
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
|
|
const prisma = {
|
|
bot: {
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
id: "bot-1",
|
|
thread: { id: "thread-1" },
|
|
computer: null,
|
|
}),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
events: { answerRunInput },
|
|
jobs: { enqueue },
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
|
|
const { matched, response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/threads/answer", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
json: {
|
|
botId: "bot-1",
|
|
runId: "run-1",
|
|
messageId: "message-1",
|
|
answer: "Paris",
|
|
},
|
|
}),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(matched).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
|
expect(answerRunInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spaceId: "workspace-1",
|
|
threadId: "thread-1",
|
|
runId: "run-1",
|
|
}),
|
|
);
|
|
expect(enqueue).toHaveBeenCalledOnce();
|
|
expect(sink.events.some((event) => event.message === "thread answer enqueue")).toBe(true);
|
|
installLogger(createLogger({ service: "rakazo-api", level: "off", sinks: [] }));
|
|
});
|
|
});
|
|
|
|
describe("MCP server deletion", () => {
|
|
it("does not fail when a concurrent credential rotation already removed the old secret", async () => {
|
|
const deleteServer = vi.fn().mockResolvedValue({ id: "server-1" });
|
|
const deleteSecrets = vi.fn().mockResolvedValue({ count: 0 });
|
|
const prisma = {
|
|
mcpServer: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "server-1", secretId: "old-secret" }),
|
|
delete: deleteServer,
|
|
},
|
|
secret: { deleteMany: deleteSecrets },
|
|
$transaction: vi.fn((operations: Promise<unknown>[]) => Promise.all(operations)),
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
|
|
const { matched, response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/mcp/servers/remove", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { id: "server-1" } }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(matched).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
|
|
expect(deleteServer).toHaveBeenCalledWith({ where: { id: "server-1" } });
|
|
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "old-secret",
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("connections.complete", () => {
|
|
it("forwards an optional code to the managed connector", async () => {
|
|
const complete = vi.fn().mockResolvedValue({ connectionRef: "gmail" });
|
|
const connectionReady = vi.fn().mockResolvedValue(true);
|
|
const update = vi.fn().mockResolvedValue({
|
|
id: "conn-1",
|
|
connectorId: "composio",
|
|
provider: "gmail",
|
|
displayName: "Gmail",
|
|
status: "connected",
|
|
createdAt: new Date("2026-08-26T00:00:00.000Z"),
|
|
});
|
|
const prisma = {
|
|
connection: {
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
id: "conn-1",
|
|
connectorId: "composio",
|
|
provider: "gmail",
|
|
displayName: "Gmail",
|
|
providerRef: "gmail-state",
|
|
status: "pending",
|
|
createdAt: new Date("2026-08-26T00:00:00.000Z"),
|
|
}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
update,
|
|
},
|
|
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
const row = {
|
|
id: "conn-1",
|
|
connectorId: "composio",
|
|
provider: "gmail",
|
|
displayName: "Gmail",
|
|
providerRef: "gmail-state",
|
|
status: "pending",
|
|
createdAt: new Date("2026-08-26T00:00:00.000Z"),
|
|
};
|
|
const tx = {
|
|
$executeRaw: vi.fn().mockResolvedValue(undefined),
|
|
connection: {
|
|
findFirst: vi.fn().mockResolvedValueOnce(row).mockResolvedValueOnce(null),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
update: vi
|
|
.fn()
|
|
.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
|
...row,
|
|
...data,
|
|
})),
|
|
},
|
|
};
|
|
return fn(tx);
|
|
}),
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
connectors: {
|
|
managed: vi.fn(() => ({ complete, connectionReady })),
|
|
},
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
|
|
const { matched, response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/connections/complete", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
json: {
|
|
connectionId: "conn-1",
|
|
code: "123456",
|
|
},
|
|
}),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(matched).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
expect(complete).toHaveBeenCalledWith(
|
|
{ state: "gmail-state", code: "123456" },
|
|
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1" }),
|
|
);
|
|
expect(connectionReady).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("updater owner gate", () => {
|
|
function updaterDeps() {
|
|
const prisma = {
|
|
user: {
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
|
email: "user@rakazo.test",
|
|
name: "Test User",
|
|
avatarStyle: "robot",
|
|
}),
|
|
},
|
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
gitSha: "deadbeef",
|
|
updaterUrl: undefined,
|
|
updaterToken: undefined,
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
return { deps, handler: new RPCHandler(createRouter(deps)) };
|
|
}
|
|
|
|
it("forbids non-owners from updater status", async () => {
|
|
const { handler } = updaterDeps();
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-2",
|
|
email: "member@rakazo.test",
|
|
isDeploymentOwner: false,
|
|
} satisfies Actor;
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/updater/status", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("lets the deployment owner read status without applying git", async () => {
|
|
const { handler } = updaterDeps();
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "owner@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/updater/status", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.json.supported).toBe(false);
|
|
expect(["source", "compose"]).toContain(body.json.installKind);
|
|
expect(Array.isArray(body.json.manualCommands)).toBe(true);
|
|
});
|
|
|
|
it("refuses apply when the sidecar is not configured", async () => {
|
|
const { handler } = updaterDeps();
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "owner@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/updater/apply", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: {} }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
const body = await response.json();
|
|
const message = JSON.stringify(body);
|
|
expect(message).toMatch(/sidecar/i);
|
|
expect(message).not.toMatch(/git (fetch|merge|pull)/i);
|
|
});
|
|
});
|
|
|
|
describe("computer screen url", () => {
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: true,
|
|
} satisfies Actor;
|
|
const computerRow = {
|
|
id: "computer-1",
|
|
screenGeneration: 3,
|
|
kind: "e2b",
|
|
scope: "team",
|
|
state: "running",
|
|
providerRef: "sandbox-ref-1",
|
|
homeKey: "home-1",
|
|
controlHolder: "none",
|
|
controlLeaseId: null,
|
|
controlLeaseExpiresAt: null,
|
|
controlBotId: null,
|
|
controlRunId: null,
|
|
};
|
|
|
|
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
|
|
const prisma = {
|
|
bot: {
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
id: "bot-1",
|
|
screenGeneration: 2,
|
|
thread: { id: "thread-1" },
|
|
computer: computerRow,
|
|
}),
|
|
},
|
|
computer: { updateMany },
|
|
computerExecutionLease: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
sandbox: { connectScreen },
|
|
jobs: { enqueue: vi.fn().mockResolvedValue(undefined) },
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "e2b",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/computer/screenUrl", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { botId: "bot-1" } }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
return { response, updateMany };
|
|
};
|
|
|
|
it("issues lifecycle-bound capabilities for managed-provider screens too", async () => {
|
|
const { response } = await callScreenUrl(async () => ({
|
|
url: "https://screen.example/vnc.html?token=fake-token",
|
|
}));
|
|
expect(response.status).toBe(200);
|
|
const { json } = await response.json();
|
|
const url = new URL(json.url);
|
|
expect(url.origin).toBe("http://127.0.0.1:5173");
|
|
expect(openScreenCapability(url.pathname, "fake-test-secret")).toMatchObject({
|
|
scope: {
|
|
botId: "bot-1",
|
|
computerId: "computer-1",
|
|
botGeneration: 2,
|
|
computerGeneration: 3,
|
|
controlLeaseId: null,
|
|
},
|
|
target: { hostname: "screen.example", interactive: false },
|
|
});
|
|
});
|
|
|
|
it("returns desktop provider screen URLs without sealing them", async () => {
|
|
const { response } = await callScreenUrl(async () => ({
|
|
url: "desktop://screen/computer-1",
|
|
}));
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: { url: "desktop://screen/computer-1?view_only=true" },
|
|
});
|
|
});
|
|
|
|
it("clears the row instead of 500ing when the provider says the sandbox is gone", async () => {
|
|
const { response, updateMany } = await callScreenUrl(() =>
|
|
Promise.reject(
|
|
Object.assign(new Error("Sandbox is probably not running anymore"), {
|
|
name: "SandboxNotFoundError",
|
|
}),
|
|
),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({ json: { url: null } });
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "computer-1", providerRef: "sandbox-ref-1" },
|
|
data: { state: "stopped", providerRef: null },
|
|
});
|
|
});
|
|
|
|
it("keeps a transport blip an error and leaves the row alone", async () => {
|
|
const { response, updateMany } = await callScreenUrl(() =>
|
|
Promise.reject(Object.assign(new Error("fetch failed"), { code: "ECONNRESET" })),
|
|
);
|
|
expect(response.status).toBe(500);
|
|
expect(updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a recoverable conflict when the screen is temporarily busy", async () => {
|
|
const { response, updateMany } = await callScreenUrl(() =>
|
|
Promise.reject(new ComputerScreenUnavailableError()),
|
|
);
|
|
expect(response.status).toBe(409);
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: expect.objectContaining({
|
|
code: "CONFLICT",
|
|
message: COMPUTER_SCREEN_UNAVAILABLE,
|
|
}),
|
|
});
|
|
expect(updateMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("integration setup authorization", () => {
|
|
it.each([
|
|
{ owner: false, configured: false, needsSetup: false },
|
|
{ owner: true, configured: false, needsSetup: true },
|
|
{ owner: true, configured: true, needsSetup: false },
|
|
])(
|
|
"offers server setup only to an owner without configured providers: %j",
|
|
async ({ owner, configured, needsSetup }) => {
|
|
const lookup = vi.fn(async () => configured);
|
|
const deps = {
|
|
prisma: {},
|
|
env: { webOrigin: "https://example.test" },
|
|
integrationSettings: { configured: lookup },
|
|
} as unknown as RouterDeps;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
const { response } = await handler.handle(
|
|
new Request("https://example.test/rpc/integrationSetup/get", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{
|
|
prefix: "/rpc",
|
|
context: {
|
|
actor: {
|
|
userId: "user",
|
|
spaceId: "space",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: owner,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const result = (await response.json()).json;
|
|
expect(result).toMatchObject({ canConfigure: owner, needsSetup });
|
|
if (!owner) {
|
|
expect(result.providers).toEqual([]);
|
|
expect(lookup).not.toHaveBeenCalled();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects provider credentials from a non-owner before verification or persistence", async () => {
|
|
const save = vi.fn();
|
|
const deps = {
|
|
prisma: {},
|
|
env: { webOrigin: "https://example.test" },
|
|
integrationSettings: { save },
|
|
} as unknown as RouterDeps;
|
|
const handler = new RPCHandler(createRouter(deps));
|
|
const { response } = await handler.handle(
|
|
new Request("https://example.test/rpc/integrationSetup/save", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { provider: "composio", apiKey: "fake-key" } }),
|
|
}),
|
|
{
|
|
prefix: "/rpc",
|
|
context: {
|
|
actor: {
|
|
userId: "member",
|
|
spaceId: "space",
|
|
email: "member@rakazo.test",
|
|
isDeploymentOwner: false,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(save).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("memory provider config owner gate", () => {
|
|
function memoryProviderDeps(spaceOwner: boolean) {
|
|
const spaceMemoryConfigFindUnique = vi.fn().mockResolvedValue({
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
defaultMemoryScope: "isolated",
|
|
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
|
|
});
|
|
const spaceMemberFindUnique = vi.fn().mockResolvedValue({
|
|
role: spaceOwner ? "owner" : "member",
|
|
});
|
|
const prisma = {
|
|
spaceMember: { findUnique: spaceMemberFindUnique },
|
|
spaceMemoryConfig: { findUnique: spaceMemoryConfigFindUnique },
|
|
user: {
|
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
|
email: "user@rakazo.test",
|
|
name: "Test User",
|
|
avatarStyle: "robot",
|
|
}),
|
|
},
|
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
} as unknown as PrismaClient;
|
|
const deps = {
|
|
prisma,
|
|
env: {
|
|
defaultProvider: "fake",
|
|
defaultModel: "fake-model",
|
|
webOrigin: "http://127.0.0.1:5173",
|
|
screenProxySecret: "fake-test-secret",
|
|
sandboxProvider: "fake",
|
|
},
|
|
dataDir: "/tmp/rakazo-router-test",
|
|
} as unknown as RouterDeps;
|
|
const actor = {
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
email: spaceOwner ? "owner@rakazo.test" : "member@rakazo.test",
|
|
isDeploymentOwner: false,
|
|
} satisfies Actor;
|
|
return {
|
|
deps,
|
|
actor,
|
|
handler: new RPCHandler(createRouter(deps)),
|
|
spaceMemberFindUnique,
|
|
spaceMemoryConfigFindUnique,
|
|
};
|
|
}
|
|
|
|
it("forbids non-owners from reading provider configuration", async () => {
|
|
const { handler, actor, spaceMemberFindUnique, spaceMemoryConfigFindUnique } =
|
|
memoryProviderDeps(false);
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/memory/providerConfig", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(spaceMemberFindUnique).toHaveBeenCalled();
|
|
expect(spaceMemoryConfigFindUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns provider configuration for Space owners", async () => {
|
|
const { handler, actor, spaceMemoryConfigFindUnique } = memoryProviderDeps(true);
|
|
|
|
const { response } = await handler.handle(
|
|
new Request("http://127.0.0.1/rpc/memory/providerConfig", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: null }),
|
|
}),
|
|
{ prefix: "/rpc", context: { actor } },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(spaceMemoryConfigFindUnique).toHaveBeenCalled();
|
|
await expect(response.json()).resolves.toEqual({
|
|
json: {
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
defaultMemoryScope: "isolated",
|
|
updatedAt: "2026-08-19T00:00:00.000Z",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("interrupted computer reservation release", () => {
|
|
function fixture() {
|
|
const order: string[] = [];
|
|
const computerUpdate = {
|
|
findFirst: vi.fn(async () => ({ id: "update-1", computerId: "computer-1" })),
|
|
updateMany: vi.fn(async () => {
|
|
order.push("operation");
|
|
return { count: 1 };
|
|
}),
|
|
};
|
|
const computer = {
|
|
updateMany: vi.fn(async () => {
|
|
order.push("computer");
|
|
return { count: 1 };
|
|
}),
|
|
};
|
|
const prisma = { computer, computerUpdate, $transaction: vi.fn(async (fn) => fn(prisma)) };
|
|
const handler = new RPCHandler(
|
|
createRouter({ prisma, env: { sandboxProvider: "fake" } } as unknown as RouterDeps),
|
|
);
|
|
const call = async (owner: boolean, workersStopped?: boolean) =>
|
|
handler.handle(
|
|
new Request("http://127.0.0.1/rpc/computer/releaseInterrupted", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: { id: "update-1", workersStopped } }),
|
|
}),
|
|
{
|
|
prefix: "/rpc",
|
|
context: {
|
|
actor: {
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: owner,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
return { prisma, computer, computerUpdate, order, call };
|
|
}
|
|
it.each([
|
|
{ owner: false, stopped: true, status: 403 },
|
|
{ owner: true, stopped: false, status: 400 },
|
|
{ owner: true, stopped: undefined, status: 400 },
|
|
])(
|
|
"requires owner authorization and an explicit stopped-workers assertion: %j",
|
|
async ({ owner, stopped, status }) => {
|
|
const { call, prisma } = fixture();
|
|
const { response } = await call(owner, stopped);
|
|
expect(response.status).toBe(status);
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
it("atomically releases only an interrupted reservation in the owner's workspace", async () => {
|
|
const { call, computer, computerUpdate, order } = fixture();
|
|
const { response } = await call(true, true);
|
|
expect(response.status).toBe(200);
|
|
expect(computerUpdate.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "update-1",
|
|
status: "interrupted",
|
|
computer: { spaceId: "space-1", bots: { some: { userId: "user-1", archivedAt: null } } },
|
|
},
|
|
});
|
|
expect(computerUpdate.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "update-1", status: "interrupted" },
|
|
data: { status: "failed" },
|
|
});
|
|
expect(computer.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "computer-1", maintenanceId: "update-1" },
|
|
data: { maintenanceId: null, state: "error" },
|
|
});
|
|
expect(order).toEqual(["operation", "computer"]);
|
|
});
|
|
});
|