evOo-local/apps/api/src/persist-memory-provider-config.test.ts
Cursor Agent c8b06ec7a8 fix(api): gate memory/providerConfig read behind Space owner authz
Non-owner Space members could read provider settings (mode, baseUrl,
endpoint metadata) while connect/disconnect/setDefaultScope already
required owner role. Align the read path with write authz.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 06:20:54 +00:00

438 lines
15 KiB
TypeScript

import { MemoryProviderDeploymentOwnerRequiredError } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
persistMemoryProviderConfig,
updateMemoryProviderDefaultScope,
} from "./memory-provider-config.js";
const actor = {
userId: "user-1",
spaceId: "ws-1",
email: "a@b.com",
isDeploymentOwner: false,
};
const deploymentOwner = { ...actor, isDeploymentOwner: true };
function makeDeps(
overrides: {
existing?: { id: string; secretId: string } | null;
upsertResult?: {
provider: string;
settings: Record<string, string>;
defaultMemoryScope: string;
updatedAt: Date;
};
updateResult?: {
provider: string;
settings: Record<string, string>;
defaultMemoryScope: string;
updatedAt: Date;
};
spaceOwner?: boolean;
memberRole?: string;
} = {},
) {
const secretCreate = vi.fn().mockResolvedValue({ id: "secret-new" });
const secretDeleteMany = vi.fn().mockResolvedValue({ count: 1 });
const findUnique = vi.fn().mockResolvedValue(overrides.existing ?? null);
const upsert = vi.fn().mockResolvedValue(
overrides.upsertResult ?? {
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "isolated",
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
},
);
const update = vi.fn().mockResolvedValue(
overrides.updateResult ?? {
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "shared",
updatedAt: new Date("2026-08-20T00:00:00.000Z"),
},
);
const deleteConfig = vi.fn().mockResolvedValue({ id: "cfg-1" });
const prisma = {
spaceMember: {
findUnique: vi
.fn()
.mockResolvedValue(
overrides.spaceOwner === false ? null : { role: overrides.memberRole ?? "owner" },
),
},
spaceMemoryConfig: { findUnique, update, upsert, delete: deleteConfig },
secret: { create: secretCreate, deleteMany: secretDeleteMany },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => unknown) =>
callback(prisma),
);
const deps = {
prisma: prisma as unknown as PrismaClient,
secrets: { put: vi.fn().mockResolvedValue({ id: "secret-new", ciphertext: "cipher" }) },
};
return {
deps,
secretCreate,
secretDeleteMany,
findUnique,
upsert,
update,
deleteConfig,
transaction: prisma.$transaction,
};
}
function connectionInput(mode: "cloud" | "local", baseUrl?: string) {
return {
provider: "supermemory",
settings: { mode, ...(baseUrl ? { baseUrl } : {}) },
credentials: { apiKey: "sm_test_key_12345" },
defaultMemoryScope: "isolated" as const,
};
}
describe("persistMemoryProviderConfig", () => {
it("rejects unknown providers as bad requests without probing or writing", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, transaction } = makeDeps();
try {
await expect(
persistMemoryProviderConfig(deps, actor, {
...connectionInput("cloud"),
provider: "unknown-provider",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(fetchMock).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it.each(["#", "?action=delete", "?"])(
"rejects ambiguous local URLs even for the deployment owner: %s",
async (suffix) => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, transaction } = makeDeps();
try {
await expect(
persistMemoryProviderConfig(
deps,
deploymentOwner,
connectionInput("local", `http://127.0.0.1:8123/internal-action${suffix}`),
),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(fetchMock).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
},
);
it.each(["http://127.0.0.1:8123/internal-action#", "http://localhost:6767"])(
"rejects ordinary Space owners before any local probe or write: %s",
async (baseUrl) => {
const fetchMock = vi.fn().mockResolvedValue(new Response("[]"));
vi.stubGlobal("fetch", fetchMock);
const { deps, transaction } = makeDeps();
try {
await expect(
persistMemoryProviderConfig(deps, actor, connectionInput("local", baseUrl)),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
expect(deps.secrets.put).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
},
);
it("still requires Space ownership for a deployment owner", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, transaction } = makeDeps({ spaceOwner: false });
try {
await expect(
persistMemoryProviderConfig(
deps,
deploymentOwner,
connectionInput("local", "http://localhost:6767"),
),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("rejects non-owners before probing or writing Space configuration", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps({ spaceOwner: false });
await expect(
persistMemoryProviderConfig(deps, actor, connectionInput("cloud")),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("rejects local mode without a baseUrl, without touching the database", async () => {
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(deps, deploymentOwner, connectionInput("local")),
).rejects.toThrow(/baseUrl/);
expect(upsert).not.toHaveBeenCalled();
});
it("rejects a non-loopback baseUrl in local mode without probing or touching the database", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(
deps,
deploymentOwner,
connectionInput("local", "http://169.254.169.254/latest/meta-data/"),
),
).rejects.toThrow(/loopback/);
expect(fetchMock).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("probes before persisting, and rejects (without writing) when the probe fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 })));
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(deps, deploymentOwner, {
...connectionInput("local", "http://localhost:6767"),
credentials: { apiKey: "sm_bad_key" },
}),
).rejects.toThrow();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("accepts a bracketed IPv6 loopback base URL in local mode", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps();
await persistMemoryProviderConfig(
deps,
deploymentOwner,
connectionInput("local", "http://[::1]:6767"),
);
expect(fetchMock.mock.calls[0]![0]).toBe("http://[::1]:6767/v3/container-tags/list");
expect(upsert).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("connects cloud mode, defaulting the base URL, and returns the serialized config", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
const { deps, upsert, transaction } = makeDeps();
const result = await persistMemoryProviderConfig(deps, actor, connectionInput("cloud"));
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { spaceId: "ws-1" },
create: expect.objectContaining({
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
}),
}),
);
expect(result).toEqual({
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "isolated",
updatedAt: "2026-08-19T00:00:00.000Z",
});
expect(transaction).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it("deletes the old secret when replacing an existing config with a new key", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
const { deps, secretDeleteMany } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-old" },
});
await persistMemoryProviderConfig(deps, actor, {
...connectionInput("cloud"),
credentials: { apiKey: "sm_new_key_12345" },
});
expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } });
vi.unstubAllGlobals();
});
it("rejects non-deployment-owners for public-looking Serenity hostnames that resolve private, without probing", async () => {
const prepareConnection = vi.fn();
const { deps, transaction } = makeDeps();
await expect(
persistMemoryProviderConfig(
{
...deps,
classifySettings: async () => ({
endpoint: "https://serenity.example.test/mcp",
endpointTrust: "private",
}),
prepareConnection,
},
actor,
{
provider: "serenity",
settings: { endpoint: "https://serenity.example.test/mcp", allowWrites: "false" },
credentials: { token: "serenity_test_token" },
defaultMemoryScope: "isolated",
},
),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(prepareConnection).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
});
it("rejects non-owners when prepare reclassifies a hostname as private before probing", async () => {
const prepareConnection = vi.fn(async () => {
throw new MemoryProviderDeploymentOwnerRequiredError();
});
const { deps, transaction } = makeDeps();
await expect(
persistMemoryProviderConfig(
{
...deps,
classifySettings: async () => ({
endpoint: "https://serenity.example.test/mcp",
}),
prepareConnection,
},
actor,
{
provider: "serenity",
settings: { endpoint: "https://serenity.example.test/mcp", allowWrites: "false" },
credentials: { token: "serenity_test_token" },
defaultMemoryScope: "isolated",
},
),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(prepareConnection).toHaveBeenCalledWith(
expect.objectContaining({ allowPrivateEndpoint: false }),
);
expect(transaction).not.toHaveBeenCalled();
});
});
describe("disconnectMemoryProvider", () => {
it("rejects non-owners before opening a transaction", async () => {
const { deps, transaction, secretDeleteMany } = makeDeps({ spaceOwner: false });
await expect(disconnectMemoryProvider(deps, actor)).rejects.toThrow();
expect(transaction).not.toHaveBeenCalled();
expect(secretDeleteMany).not.toHaveBeenCalled();
});
it("disconnects an existing provider and removes its secret in the same transaction", async () => {
const { deps, transaction, deleteConfig, secretDeleteMany } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-old" },
});
await expect(disconnectMemoryProvider(deps, actor)).resolves.toEqual({ ok: true });
expect(transaction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), {
isolationLevel: "Serializable",
});
expect(deleteConfig).toHaveBeenCalledWith({ where: { id: "cfg-1" } });
expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } });
});
it("leaves secrets untouched when no provider is configured", async () => {
const { deps, deleteConfig, secretDeleteMany } = makeDeps();
await expect(disconnectMemoryProvider(deps, actor)).resolves.toEqual({ ok: true });
expect(deleteConfig).not.toHaveBeenCalled();
expect(secretDeleteMany).not.toHaveBeenCalled();
});
});
describe("getMemoryProviderConfig", () => {
it("rejects non-owners before reading provider configuration", async () => {
const { deps, findUnique } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
spaceOwner: false,
});
await expect(getMemoryProviderConfig(deps, actor)).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(findUnique).not.toHaveBeenCalled();
});
it("returns serialized config for Space owners", async () => {
const config = {
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
};
const { deps } = makeDeps({ existing: { id: "cfg-1", secretId: "secret-existing" } });
deps.prisma.spaceMemoryConfig.findUnique = vi.fn().mockResolvedValue(config);
await expect(getMemoryProviderConfig(deps, actor)).resolves.toEqual({
provider: "supermemory",
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
defaultMemoryScope: "isolated",
updatedAt: "2026-08-19T00:00:00.000Z",
});
});
});
describe("updateMemoryProviderDefaultScope", () => {
it("accepts owners with additional Better Auth roles", async () => {
const { deps, update } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
memberRole: "owner,admin",
});
await updateMemoryProviderDefaultScope(deps, actor, "shared");
expect(update).toHaveBeenCalled();
});
it("updates only the generic scope setting and retains the provider secret", async () => {
const { deps, update, secretCreate, secretDeleteMany } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
});
const result = await updateMemoryProviderDefaultScope(deps, actor, "shared");
expect(update).toHaveBeenCalledWith({
where: { id: "cfg-1" },
data: { defaultMemoryScope: "shared" },
});
expect(secretCreate).not.toHaveBeenCalled();
expect(secretDeleteMany).not.toHaveBeenCalled();
expect(result.defaultMemoryScope).toBe("shared");
});
it("rejects non-owners without updating provider configuration", async () => {
const { deps, update } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
spaceOwner: false,
});
await expect(updateMemoryProviderDefaultScope(deps, actor, "shared")).rejects.toThrow();
expect(update).not.toHaveBeenCalled();
});
});