Files
shared-memory/apps/web/lib/memory-mutations.test.ts
T
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

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");
});
});