Cloud/public memory providers are owner-managed infrastructure. The RPC read path is owner-gated separately; this aligns executor, history compaction, and thread-clear purge with the same boundary so non-owner members cannot load credentials or reach external endpoints at runtime. Deployment-owner local/private providers remain available to all Space members when the deployment owner configured them. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createMemoryProvider,
|
|
memoryProviderResolveRequest,
|
|
SpaceMemoryProviderResolver,
|
|
} from "./memory-provider-factory.js";
|
|
|
|
function resolverFor(
|
|
plaintext: string,
|
|
options: {
|
|
mode?: "cloud" | "local";
|
|
ownerUserId?: string | null;
|
|
baseUrl?: string;
|
|
memberRole?: string | null;
|
|
} = {},
|
|
) {
|
|
const prisma = {
|
|
spaceMemoryConfig: {
|
|
findUnique: vi.fn(async () => ({
|
|
userId: "config-author",
|
|
provider: "supermemory",
|
|
settings: {
|
|
mode: options.mode ?? "cloud",
|
|
baseUrl: options.baseUrl ?? "https://api.supermemory.ai",
|
|
},
|
|
defaultMemoryScope: "shared",
|
|
secret: { ciphertext: "encrypted" },
|
|
})),
|
|
},
|
|
deploymentSettings: {
|
|
findUnique: vi.fn(async () => ({ ownerUserId: options.ownerUserId ?? null })),
|
|
},
|
|
spaceMember: {
|
|
findUnique: vi.fn(async () =>
|
|
options.memberRole === null ? null : { role: options.memberRole ?? "owner" },
|
|
),
|
|
},
|
|
};
|
|
const secrets = { load: vi.fn(() => plaintext) };
|
|
return {
|
|
resolver: new SpaceMemoryProviderResolver(prisma as never, secrets as never),
|
|
secrets,
|
|
prisma,
|
|
};
|
|
}
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe("SpaceMemoryProviderResolver", () => {
|
|
it.each([null, "another-user"])(
|
|
"disables saved local configurations without current deployment-owner authorization: %s",
|
|
async (ownerUserId) => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { resolver, secrets } = resolverFor("sm_fake_key", {
|
|
mode: "local",
|
|
baseUrl: "http://127.0.0.1:8123/internal-action#",
|
|
ownerUserId,
|
|
});
|
|
|
|
await expect(
|
|
resolver.resolve(memoryProviderResolveRequest("workspace-1", "config-author")),
|
|
).resolves.toBeNull();
|
|
expect(secrets.load).not.toHaveBeenCalled();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("retains deployment-owner-configured local memory for Space members", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(Response.json({ results: [] }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { resolver } = resolverFor("sm_fake_key", {
|
|
mode: "local",
|
|
baseUrl: "http://localhost:6767/memory/",
|
|
ownerUserId: "config-author",
|
|
memberRole: "member",
|
|
});
|
|
const configured = await resolver.resolve(
|
|
memoryProviderResolveRequest("workspace-1", "space-member"),
|
|
);
|
|
await configured!.provider.recall(
|
|
{ query: "project", scope: "isolated", botId: "bot-1", limit: 1 },
|
|
{
|
|
operationId: "op-1",
|
|
traceId: "trace-1",
|
|
spaceId: "workspace-1",
|
|
userId: "space-member",
|
|
signal: new AbortController().signal,
|
|
},
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:6767/memory/v4/search");
|
|
});
|
|
|
|
it("blocks non-owners from loading cloud provider credentials at runtime", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { resolver, secrets } = resolverFor(JSON.stringify({ apiKey: "sm_json_key" }), {
|
|
memberRole: "member",
|
|
});
|
|
|
|
await expect(
|
|
resolver.resolve(memoryProviderResolveRequest("workspace-1", "space-member")),
|
|
).resolves.toBeNull();
|
|
expect(secrets.load).not.toHaveBeenCalled();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects ambiguous persisted URLs even when the deployment owner configured them", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const { resolver } = resolverFor("sm_fake_key", {
|
|
mode: "local",
|
|
baseUrl: "http://127.0.0.1:8123/internal-action#",
|
|
ownerUserId: "config-author",
|
|
});
|
|
await expect(
|
|
resolver.resolve(memoryProviderResolveRequest("workspace-1", "config-author")),
|
|
).rejects.toThrow(/base URL/);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads generic JSON credential payloads for Space owners", async () => {
|
|
const { resolver, prisma } = resolverFor(JSON.stringify({ apiKey: "sm_json_key" }));
|
|
|
|
const configured = await resolver.resolve(
|
|
memoryProviderResolveRequest("workspace-1", "config-author"),
|
|
);
|
|
|
|
expect(configured?.provider.describe().id).toBe("supermemory");
|
|
expect(configured?.defaultScope).toBe("shared");
|
|
expect(prisma.deploymentSettings.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps legacy raw Supermemory credentials usable after the schema migration", async () => {
|
|
const { resolver } = resolverFor("sm_legacy_key");
|
|
|
|
const configured = await resolver.resolve(
|
|
memoryProviderResolveRequest("workspace-1", "config-author"),
|
|
);
|
|
|
|
expect(configured?.provider.describe().id).toBe("supermemory");
|
|
});
|
|
|
|
it("revalidates persisted provider endpoints before using decrypted credentials", async () => {
|
|
expect(() =>
|
|
createMemoryProvider(
|
|
"supermemory",
|
|
{ mode: "local", baseUrl: "https://memory.example.com" },
|
|
{ apiKey: "sm_test_key" },
|
|
),
|
|
).toThrow(/loopback/);
|
|
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ results: [] }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = createMemoryProvider(
|
|
"supermemory",
|
|
{ mode: "cloud", baseUrl: "https://memory.example.com" },
|
|
{ apiKey: "sm_test_key" },
|
|
);
|
|
|
|
await provider.recall(
|
|
{ query: "project", scope: "isolated", botId: "bot-1", limit: 1 },
|
|
{
|
|
operationId: "op-1",
|
|
traceId: "trace-1",
|
|
spaceId: "workspace-1",
|
|
userId: "user-1",
|
|
signal: new AbortController().signal,
|
|
},
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.supermemory.ai/v4/search");
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|