237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
import { persistMemoryProviderConfig, updateMemoryProviderDefaultScope } from "./router.js";
|
||
|
|
|
||
|
|
const actor = {
|
||
|
|
userId: "user-1",
|
||
|
|
spaceId: "ws-1",
|
||
|
|
email: "a@b.com",
|
||
|
|
isDeploymentOwner: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
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 prisma = {
|
||
|
|
spaceMember: {
|
||
|
|
findUnique: vi
|
||
|
|
.fn()
|
||
|
|
.mockResolvedValue(
|
||
|
|
overrides.spaceOwner === false ? null : { role: overrides.memberRole ?? "owner" },
|
||
|
|
),
|
||
|
|
},
|
||
|
|
spaceMemoryConfig: { findUnique, update, upsert },
|
||
|
|
secret: { create: secretCreate, deleteMany: secretDeleteMany },
|
||
|
|
$transaction: vi.fn(),
|
||
|
|
};
|
||
|
|
prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => unknown) =>
|
||
|
|
callback(prisma),
|
||
|
|
);
|
||
|
|
const deps = {
|
||
|
|
prisma,
|
||
|
|
secrets: { put: vi.fn().mockResolvedValue({ id: "secret-new", ciphertext: "cipher" }) },
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
deps,
|
||
|
|
secretCreate,
|
||
|
|
secretDeleteMany,
|
||
|
|
findUnique,
|
||
|
|
upsert,
|
||
|
|
update,
|
||
|
|
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 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 as never, 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 as never, actor, 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 as never,
|
||
|
|
actor,
|
||
|
|
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 as never, actor, {
|
||
|
|
...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 as never,
|
||
|
|
actor,
|
||
|
|
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 as never,
|
||
|
|
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 as never, actor, {
|
||
|
|
...connectionInput("cloud"),
|
||
|
|
credentials: { apiKey: "sm_new_key_12345" },
|
||
|
|
});
|
||
|
|
expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } });
|
||
|
|
vi.unstubAllGlobals();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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 as never, 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 as never, 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 as never, actor, "shared"),
|
||
|
|
).rejects.toThrow();
|
||
|
|
expect(update).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|