fix(core): token writes keep their actor in history but are user-owned in field_sources
fieldOwner(actor) maps every non-plugin actor to "user" wherever field_sources is written (entries, engagements, list ownership from history), so syncs never overwrite a field an API token edited. authenticateAppToken also returns the token name; revokeAppToken reports whether it revoked; listSuggestions queries the canonical work id. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e3a759d768
commit
aa8e4db070
@@ -6,7 +6,11 @@ import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
|
||||
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
|
||||
import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
|
||||
import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
|
||||
import { getHistory } from "../src/history.ts";
|
||||
import { fieldOwner } from "../src/history.ts";
|
||||
import { getEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { listEngagements } from "../src/engagements.ts";
|
||||
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts";
|
||||
import { startScheduler } from "../src/jobs.ts";
|
||||
import { DropJobError } from "../src/jobs.ts";
|
||||
@@ -39,7 +43,21 @@ const fileish: SourcePlugin = definePlugin({
|
||||
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
|
||||
},
|
||||
});
|
||||
const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} };
|
||||
let flipStatus: "planned" | "completed" = "planned";
|
||||
const flip: SourcePlugin = definePlugin({
|
||||
manifest: { id: "flip", name: "Flip", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["pull"], config: [] },
|
||||
async *pull() {
|
||||
yield {
|
||||
kind: "entry",
|
||||
key: "flip-1",
|
||||
raw: flipStatus,
|
||||
work: { title: "Flip", mediaType: "book", externalIds: { isbn13: "9780000000010" } },
|
||||
entry: { status: flipStatus, rating: flipStatus === "planned" ? 40 : 90 },
|
||||
};
|
||||
},
|
||||
});
|
||||
const extra: Record<string, SourcePlugin> = { fileish, flip };
|
||||
const reg2: PluginRegistry = { get: (id) => extra[id] ?? reg.get(id), list: () => [...reg.list(), fileish.manifest, flip.manifest], reload: async () => {} };
|
||||
|
||||
forEachDialect("api support", (getDb) => {
|
||||
test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => {
|
||||
@@ -48,7 +66,9 @@ forEachDialect("api support", (getDb) => {
|
||||
const other = await makeUser(db, "bob");
|
||||
const t = await createAppToken(db, u, "script", ["read"]);
|
||||
await createAppToken(db, other, "x", ["write"]);
|
||||
await revokeAppToken(db, u, t.id);
|
||||
expect(await revokeAppToken(db, other, t.id)).toBe(false);
|
||||
expect(await revokeAppToken(db, u, t.id)).toBe(true);
|
||||
expect(await revokeAppToken(db, u, t.id)).toBe(false);
|
||||
const list = await listAppTokens(db, u);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null });
|
||||
@@ -56,6 +76,53 @@ forEachDialect("api support", (getDb) => {
|
||||
expect(JSON.stringify(list)).not.toContain("hash");
|
||||
});
|
||||
|
||||
test("fieldOwner: only plugin actors keep their name in field_sources", () => {
|
||||
expect(fieldOwner("user")).toBe("user");
|
||||
expect(fieldOwner("token:script")).toBe("user");
|
||||
expect(fieldOwner("plugin:echo:c1")).toBe("plugin:echo:c1");
|
||||
});
|
||||
|
||||
test("authenticateAppToken returns the user and the token name", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const t = await createAppToken(db, u, "script", ["write"]);
|
||||
expect(await authenticateAppToken(db, t.token, "write")).toMatchObject({ user: { id: u }, tokenName: "script" });
|
||||
expect(await authenticateAppToken(db, t.token, "read")).toBeNull();
|
||||
});
|
||||
|
||||
test("a token write keeps its actor in history, is owned by the user, and a later sync files a pending change", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
flipStatus = "planned";
|
||||
const id = await createConnection(db, SECRET, u, { pluginId: "flip", name: "F", config: {} }, flip.manifest);
|
||||
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
|
||||
await sync({ connectionId: id, mode: "full" });
|
||||
const entry = await db.k.selectFrom("entries").selectAll().where("user_id", "=", u).executeTakeFirstOrThrow();
|
||||
await upsertEntry(db, u, entry.work_id, { status: "dropped" }, "token:script");
|
||||
const view = await getEntry(db, u, entry.id);
|
||||
expect(view.fieldSources.status).toBe("user");
|
||||
expect(view.fieldSources.rating).toBe(`plugin:flip:${id}`);
|
||||
const hist = await getHistory(db, u, "entry", entry.id);
|
||||
expect(hist[0]!.actor).toBe("token:script");
|
||||
|
||||
flipStatus = "completed";
|
||||
await sync({ connectionId: id, mode: "full" });
|
||||
const after = await getEntry(db, u, entry.id);
|
||||
expect(after.status).toBe("dropped");
|
||||
expect(after.rating).toBe(90);
|
||||
const pending = await db.k.selectFrom("change_events").selectAll().where("user_id", "=", u).where("status", "=", "pending").execute();
|
||||
expect(pending.map((p) => [p.type, JSON.parse(p.payload).field])).toEqual([["changed", "status"]]);
|
||||
});
|
||||
|
||||
test("token-authored engagement fields are owned by the user", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const e = await quickAdd(db, u, { title: "Dune" });
|
||||
await addEngagement(db, u, e.id, { platform: "kindle" }, "token:script");
|
||||
const [g] = await listEngagements(db, u, e.id);
|
||||
expect(g!.fieldSources).toEqual({ platform: "user" });
|
||||
});
|
||||
|
||||
test("listSuggestions lists pending suggestions for a visible work", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
@@ -70,6 +137,11 @@ forEachDialect("api support", (getDb) => {
|
||||
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
|
||||
const other = await makeUser(db, "bob");
|
||||
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError);
|
||||
|
||||
// Asking by an id the user aliased away lists the canonical work's suggestions.
|
||||
const old = await makeWork(db, { title: "Old Dune", ownerUserId: u });
|
||||
await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: old, to_work_id: draft.workId }).execute();
|
||||
expect((await listSuggestions(db, u, old)).map((x) => x.workId)).toEqual([draft.workId]);
|
||||
});
|
||||
|
||||
test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
|
||||
|
||||
Reference in New Issue
Block a user