memory.get no longer returns the embedding and content_tsv ---------------------------------------------------------- It used a bare select() and returned the raw DB row, while memory.list and memory.search already projected an explicit 9-field shape. On a ~13k-char memory those two internal columns were 55% of the response and pushed it past the MCP tool-output cap, so large memories could not be fetched inline at all. memory.get now returns the same 9 fields as its siblings; user_id is still selected for the authorization check and stripped before responding. memory.patch ------------ memory.update only accepts full replacement, so adding one line to a large document meant resending the whole document — expensive enough that edits were being skipped rather than risk silently truncating shared team documents. memory.patch replaces one exact occurrence of old_string. An absent or ambiguous match is an error, never a silent no-op and never an arbitrary pick; that refusal is what makes the operation safe to hand to an agent. The semantics live in lib/memory-patch.ts as a pure function, free of DB and auth, so both surfaces share them. Shared mutation layer --------------------- The MCP tools and the Web UI Server Actions each reimplemented authorize -> mutate -> re-embed -> CAS -> audit, and had drifted. Both now route through lib/memory-mutations.ts. BEHAVIOUR CHANGE: memory.delete over MCP skipped the project ACL whenever the caller authored the row, so a memory written while a share was rw stayed deletable by its author after an owner downgraded that share to ro. memory.update and the whole Web UI always checked. Authoring a row now grants no standing write privilege on any path. The one deliberate difference between the surfaces is injected as a ProjectResolver: MCP refuses an unknown project key so an agent cannot spawn near-miss projects off a typo, while the Web UI creates one because a person typing a name into a form means to. Tests and lint -------------- Adds vitest. The integration tests run against a real Postgres rather than a mocked DB. The embedder sidecar is the only stub and it is deterministic per-text, so re-embedding is verified by asserting the stored vector actually changed rather than that a mock was called. One test pins that content_tsv is a generated column and therefore cannot rot after a patch — only the embedding needs an explicit recompute. pnpm lint previously dropped into an interactive `next lint` setup prompt and exited 1; ESLint had never been configured here. Replaced with the ESLint CLI and a flat config bridging eslint-config-next through FlatCompat. Clean at --max-warnings=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
7.9 KiB
TypeScript
264 lines
7.9 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
/**
|
|
* Tests for the shared mutation layer itself — the code both the MCP
|
|
* tools and the Web UI Server Actions now route through.
|
|
*
|
|
* Two things matter here:
|
|
* 1. The ProjectResolver seam really is the ONLY behavioural difference
|
|
* between the two surfaces.
|
|
* 2. The authorization rule is uniform across update / patch / delete.
|
|
* It previously wasn't: delete-over-MCP let a row's author bypass the
|
|
* project ACL.
|
|
*/
|
|
vi.mock("@/lib/embedder", () => ({
|
|
embedText: async (text: string) => {
|
|
let h = 0;
|
|
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
|
return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000);
|
|
},
|
|
embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)),
|
|
embedderReady: async () => true,
|
|
EmbedderError: class extends Error {},
|
|
}));
|
|
|
|
const { db, pg } = await import("@/lib/db/client");
|
|
const { memories, projects, users, groups, userGroups, projectShares } = await import(
|
|
"@/lib/db/schema"
|
|
);
|
|
const { createMemory, updateMemory, patchMemory, softDeleteMemory } = await import(
|
|
"@/lib/memory-mutations"
|
|
);
|
|
const { and, eq } = await import("drizzle-orm");
|
|
type Actor = import("@/lib/memory-mutations").Actor;
|
|
type ProjectResolver = import("@/lib/memory-mutations").ProjectResolver;
|
|
|
|
const ISS = "http://test-mutations";
|
|
|
|
let actor: Actor;
|
|
let otherUserId: string;
|
|
let sharedProjectId: string;
|
|
let sharedGroupId: string;
|
|
|
|
/** Mirrors the MCP surface: unknown project keys are refused. */
|
|
const refusingResolver: ProjectResolver = async (key) => ({
|
|
ok: false,
|
|
error: `unknown project '${key}'; call project.identify first`,
|
|
});
|
|
|
|
/** Mirrors the Web UI surface: unknown project keys are created. */
|
|
function creatingResolver(userId: string): ProjectResolver {
|
|
return async (key) => {
|
|
const existing = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(and(eq(projects.key, key), eq(projects.userId, userId)))
|
|
.limit(1);
|
|
if (existing[0]) return { ok: true, value: existing[0].id };
|
|
const created = await db
|
|
.insert(projects)
|
|
.values({ userId, key, displayName: key })
|
|
.returning({ id: projects.id });
|
|
return { ok: true, value: created[0]!.id };
|
|
};
|
|
}
|
|
|
|
async function seedMemory(userId: string, projectId: string | null): Promise<string> {
|
|
const r = await db
|
|
.insert(memories)
|
|
.values({
|
|
userId,
|
|
projectId,
|
|
scope: projectId ? "project" : "user",
|
|
content: "line one\nline two\n",
|
|
tags: [],
|
|
embedding: Array(384).fill(0.5),
|
|
lastEditedBy: userId,
|
|
})
|
|
.returning({ id: memories.id });
|
|
return r[0]!.id;
|
|
}
|
|
|
|
async function setShareAccess(access: "ro" | "rw") {
|
|
await db
|
|
.insert(projectShares)
|
|
.values({ projectId: sharedProjectId, groupId: sharedGroupId, access })
|
|
.onConflictDoUpdate({
|
|
target: [projectShares.projectId, projectShares.groupId],
|
|
set: { access },
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const me = await db
|
|
.insert(users)
|
|
.values({ oidcSub: "mut-me", oidcIss: ISS })
|
|
.onConflictDoNothing()
|
|
.returning({ id: users.id });
|
|
const myId =
|
|
me[0]?.id ??
|
|
(
|
|
await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-me"))
|
|
)[0]!.id;
|
|
|
|
const other = await db
|
|
.insert(users)
|
|
.values({ oidcSub: "mut-other", oidcIss: ISS })
|
|
.onConflictDoNothing()
|
|
.returning({ id: users.id });
|
|
otherUserId =
|
|
other[0]?.id ??
|
|
(
|
|
await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-other"))
|
|
)[0]!.id;
|
|
|
|
const p = await db
|
|
.insert(projects)
|
|
.values({ userId: otherUserId, key: "mut-shared", displayName: "Shared" })
|
|
.onConflictDoNothing()
|
|
.returning({ id: projects.id });
|
|
sharedProjectId =
|
|
p[0]?.id ??
|
|
(
|
|
await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.key, "mut-shared"))
|
|
)[0]!.id;
|
|
|
|
const g = await db
|
|
.insert(groups)
|
|
.values({ oidcIss: ISS, name: "mut-team" })
|
|
.onConflictDoNothing()
|
|
.returning({ id: groups.id });
|
|
sharedGroupId =
|
|
g[0]?.id ??
|
|
(
|
|
await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "mut-team"))
|
|
)[0]!.id;
|
|
|
|
await db
|
|
.insert(userGroups)
|
|
.values({ userId: myId, groupId: sharedGroupId })
|
|
.onConflictDoNothing();
|
|
|
|
actor = { userId: myId, groups: ["mut-team"], via: "mcp" };
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db.delete(memories);
|
|
await setShareAccess("rw");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.delete(memories);
|
|
await pg.end();
|
|
});
|
|
|
|
describe("the ProjectResolver seam", () => {
|
|
test("a refusing resolver rejects an unknown project without creating one", async () => {
|
|
const res = await createMemory(
|
|
actor,
|
|
{ content: "x", scope: "project", project: "brand-new-key", tags: [] },
|
|
refusingResolver,
|
|
);
|
|
|
|
expect(res.ok).toBe(false);
|
|
const rows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.key, "brand-new-key"));
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
test("a creating resolver makes the project and writes into it", async () => {
|
|
const res = await createMemory(
|
|
actor,
|
|
{ content: "x", scope: "project", project: "made-on-demand", tags: [] },
|
|
creatingResolver(actor.userId),
|
|
);
|
|
|
|
expect(res.ok).toBe(true);
|
|
const rows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.key, "made-on-demand"));
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("authorization is uniform across mutations", () => {
|
|
// Each of these seeds a memory the actor AUTHORED, then downgrades the
|
|
// share to read-only. Authoring must not survive as a write privilege.
|
|
test("update is denied on a read-only share", async () => {
|
|
const id = await seedMemory(actor.userId, sharedProjectId);
|
|
await setShareAccess("ro");
|
|
|
|
const res = await updateMemory(actor, { id, content: "edited" }, refusingResolver);
|
|
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
test("patch is denied on a read-only share", async () => {
|
|
const id = await seedMemory(actor.userId, sharedProjectId);
|
|
await setShareAccess("ro");
|
|
|
|
const res = await patchMemory(actor, {
|
|
id,
|
|
old_string: "line one",
|
|
new_string: "line uno",
|
|
});
|
|
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
test("delete is denied on a read-only share", async () => {
|
|
const id = await seedMemory(actor.userId, sharedProjectId);
|
|
await setShareAccess("ro");
|
|
|
|
const res = await softDeleteMemory(actor, { id });
|
|
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
test("all three are allowed again once the share is read-write", async () => {
|
|
const id = await seedMemory(actor.userId, sharedProjectId);
|
|
|
|
expect((await updateMemory(actor, { id, content: "a\nb\n" }, refusingResolver)).ok).toBe(
|
|
true,
|
|
);
|
|
expect((await patchMemory(actor, { id, old_string: "a", new_string: "c" })).ok).toBe(
|
|
true,
|
|
);
|
|
expect((await softDeleteMemory(actor, { id })).ok).toBe(true);
|
|
});
|
|
|
|
test("another user's user-scope memory is invisible to all three", async () => {
|
|
const id = await seedMemory(otherUserId, null);
|
|
|
|
expect((await updateMemory(actor, { id, content: "x" }, refusingResolver)).ok).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
(await patchMemory(actor, { id, old_string: "line one", new_string: "y" })).ok,
|
|
).toBe(false);
|
|
expect((await softDeleteMemory(actor, { id })).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("audit trail records the originating surface", () => {
|
|
test("via: 'web' and via: 'mcp' are both preserved", async () => {
|
|
const webRes = await createMemory(
|
|
{ ...actor, via: "web" },
|
|
{ content: "from the web", scope: "user", tags: [] },
|
|
refusingResolver,
|
|
);
|
|
expect(webRes.ok).toBe(true);
|
|
|
|
const rows = await pg<{ actor: string }[]>`
|
|
SELECT actor FROM audit_log WHERE action = 'memory.write' ORDER BY created_at DESC LIMIT 1
|
|
`;
|
|
expect(rows[0]!.actor).toBe("web");
|
|
});
|
|
});
|