Files
shared-memory/apps/web/lib/mcp/tools.mutations.test.ts
shadowdaoandClaude Opus 5 c3bbea5134 feat: add memory.patch, trim memory.get, unify the memory write path
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>
2026-08-11 14:58:11 -07:00

245 lines
7.2 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
/**
* Integration cover for the memory MUTATION paths (write / update / delete)
* against a real Postgres. These exist mainly as a safety net for the
* shared-mutation refactor: the MCP tools and the Web UI Server Actions
* used to reimplement the same authorize → CAS → re-embed → audit sequence
* separately, and these assertions pin the behaviour that must survive
* being pulled into one place.
*/
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 { toolMap } = await import("@/lib/mcp/tools");
const { eq } = await import("drizzle-orm");
type UserContext = import("@/lib/mcp/context").UserContext;
const ISS = "http://test";
let author: UserContext;
let projectOwnerId: string;
let ownProjectId: string;
let sharedProjectId: string;
let sharedGroupId: string;
function ctxFor(userId: string, sub: string, groupNames: string[] = []): UserContext {
return { userId, sub, iss: ISS, email: null, name: null, groups: groupNames };
}
async function upsertUser(sub: string): Promise<string> {
const r = await db
.insert(users)
.values({ oidcSub: sub, oidcIss: ISS })
.onConflictDoUpdate({ target: [users.oidcIss, users.oidcSub], set: { oidcSub: sub } })
.returning({ id: users.id });
return r[0]!.id;
}
async function seedMemory(
userId: string,
projectId: string | null,
content = "seed content",
): Promise<string> {
const r = await db
.insert(memories)
.values({
userId,
projectId,
scope: projectId ? "project" : "user",
content,
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 },
});
}
async function isDeleted(id: string): Promise<boolean> {
const r = await db
.select({ deletedAt: memories.deletedAt })
.from(memories)
.where(eq(memories.id, id));
return r[0]!.deletedAt !== null;
}
beforeAll(async () => {
const authorId = await upsertUser("author-sub");
projectOwnerId = await upsertUser("owner-sub");
const own = await db
.insert(projects)
.values({ userId: authorId, key: "author-own", displayName: "Author Own" })
.onConflictDoNothing()
.returning({ id: projects.id });
ownProjectId =
own[0]?.id ??
(
await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "author-own"))
)[0]!.id;
const shared = await db
.insert(projects)
.values({ userId: projectOwnerId, key: "team-shared", displayName: "Team Shared" })
.onConflictDoNothing()
.returning({ id: projects.id });
sharedProjectId =
shared[0]?.id ??
(
await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, "team-shared"))
)[0]!.id;
const g = await db
.insert(groups)
.values({ oidcIss: ISS, name: "team" })
.onConflictDoNothing()
.returning({ id: groups.id });
sharedGroupId =
g[0]?.id ??
(await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "team")))[0]!
.id;
await db
.insert(userGroups)
.values({ userId: authorId, groupId: sharedGroupId })
.onConflictDoNothing();
author = ctxFor(authorId, "author-sub", ["team"]);
});
beforeEach(async () => {
await db.delete(memories);
await setShareAccess("rw");
});
afterAll(async () => {
await db.delete(memories);
await pg.end();
});
describe("memory.write", () => {
test("writes into a project the caller owns", async () => {
const res = await toolMap["memory.write"]!.handler(
{ content: "hello", scope: "project", project: "author-own" },
author,
);
expect(res.isError).toBeFalsy();
});
test("refuses an unknown project rather than creating one", async () => {
const res = await toolMap["memory.write"]!.handler(
{ content: "hello", scope: "project", project: "does-not-exist" },
author,
);
expect(res.isError).toBe(true);
expect(res.content[0]!.text).toMatch(/project\.identify/);
});
});
describe("memory.update", () => {
test("updates content and increments version", async () => {
const id = await seedMemory(author.userId, ownProjectId);
const before = await db
.select({ version: memories.version })
.from(memories)
.where(eq(memories.id, id));
const res = await toolMap["memory.update"]!.handler(
{ id, content: "revised content" },
author,
);
expect(res.isError).toBeFalsy();
expect((res.structuredContent as { version: number }).version).toBe(
before[0]!.version + 1,
);
});
test("refuses a stale version", async () => {
const id = await seedMemory(author.userId, ownProjectId);
const res = await toolMap["memory.update"]!.handler(
{ id, content: "revised", version: 99 },
author,
);
expect(res.isError).toBe(true);
});
test("denies updating a memory in a project shared read-only", async () => {
const id = await seedMemory(author.userId, sharedProjectId);
await setShareAccess("ro");
const res = await toolMap["memory.update"]!.handler(
{ id, content: "sneaky edit" },
author,
);
expect(res.isError).toBe(true);
});
});
describe("memory.delete authorization", () => {
test("allows deleting a memory in a project shared read-write", async () => {
const id = await seedMemory(author.userId, sharedProjectId);
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBeFalsy();
expect(await isDeleted(id)).toBe(true);
});
test("denies deleting a memory in a project shared read-only, even to its author", async () => {
// The realistic path here: the memory was written while the share was
// rw, then an owner downgraded the group to ro. Authoring the row must
// not grant a standing write privilege the project ACL has revoked —
// memory.update already refuses this, and delete must agree.
const id = await seedMemory(author.userId, sharedProjectId);
await setShareAccess("ro");
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBe(true);
expect(await isDeleted(id)).toBe(false);
});
test("denies deleting another user's user-scope memory", async () => {
const id = await seedMemory(projectOwnerId, null);
const res = await toolMap["memory.delete"]!.handler({ id }, author);
expect(res.isError).toBe(true);
expect(await isDeleted(id)).toBe(false);
});
});