Files
STTracker/packages/core/test/api-support.test.ts
T
Lucas WintherandClaude Opus 5.5 aa8e4db070 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]>
2026-10-30 21:29:38 +01:00

250 lines
14 KiB
TypeScript

import { expect, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { 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";
import { listSuggestions, quickAdd } from "../src/entries.ts";
import { addEngagement } from "../src/engagements.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import type { PluginRegistry } from "../src/registry.ts";
import { loadPlugins } from "../src/registry.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
const SECRET = "test-secret";
const warn = spyOn(console, "warn").mockImplementation(() => {});
const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]);
warn.mockRestore();
const echo = reg.get("echo")!;
const fileish: SourcePlugin = definePlugin({
manifest: { id: "fileish", name: "Fileish", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["importFile"], config: [] },
async *importFile(_ctx, file) {
yield {
kind: "entry",
key: file.name,
raw: new TextDecoder().decode(file.bytes),
work: { title: new TextDecoder().decode(file.bytes), mediaType: "book", externalIds: { isbn13: "9780000000009" } },
entry: { status: "planned" },
};
},
deviceAuth: {
start: async () => ({ userCode: "ABCD", verificationUrl: "https://x.test/device", deviceCode: "dc", intervalSec: 5, expiresInSec: 600 }),
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
},
});
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 () => {
const db = getDb();
const u = await makeUser(db);
const other = await makeUser(db, "bob");
const t = await createAppToken(db, u, "script", ["read"]);
await createAppToken(db, other, "x", ["write"]);
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 });
expect(list[0]!.revokedAt).not.toBeNull();
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);
const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" });
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
await db.k
.insertInto("match_suggestions")
.values({ id: newId(), user_id: u, work_id: draft.workId, candidate_work_id: good, score: 0.75, status: "pending", created_at: nowIso() })
.execute();
const s = await listSuggestions(db, u, draft.workId);
expect(s).toHaveLength(1);
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 () => {
const db = getDb();
const dir = await mkdtemp(join(tmpdir(), "stt-att-"));
try {
const u = await makeUser(db);
const bob = await makeUser(db, "bob");
const e = await quickAdd(db, u, { title: "Dune" });
const g = await addEngagement(db, u, e.id, {}, "user");
await requireLinkTarget(db, u, "entry", e.id);
await requireLinkTarget(db, u, "engagement", g.id);
await requireLinkTarget(db, u, "work", e.workId);
await expect(requireLinkTarget(db, bob, "entry", e.id)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, bob, "engagement", g.id)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, bob, "work", e.workId)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, u, "nope" as never, e.id)).rejects.toBeInstanceOf(ValidationError);
const a = await storeAttachment(db, dir, u, { bytes: new Uint8Array([1]), mime: "image/png", filename: "a.png" }, { entity: "entry", entityId: e.id });
const links = await listAttachmentLinks(db, u, a.id);
expect(links).toEqual([{ id: expect.any(String), entity: "entry", entityId: e.id }]);
expect(await listAttachmentLinks(db, bob, a.id)).toEqual([]);
expect((await readAttachment(db, dir, u, a.id))?.filename).toBe("a.png");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("getConnection hides other users and deleted connections; listRuns is newest first", async () => {
const db = getDb();
const u = await makeUser(db);
const bob = await makeUser(db, "bob");
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
expect((await getConnection(db, u, id)).pluginId).toBe("echo");
await expect(getConnection(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
const sync = syncHandler({ db, secret: SECRET, registry: reg });
await sync({ connectionId: id, mode: "full" });
await sync({ connectionId: id, mode: "full" });
const runs = await listRuns(db, u, id);
expect(runs).toHaveLength(2);
expect(runs[0]!.startedAt >= runs[1]!.startedAt).toBe(true);
expect(runs[0]).toMatchObject({ mode: "full", status: "ok" });
await expect(listRuns(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
await deleteConnection(db, u, id);
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest);
expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]);
expect((await getConnection(db, u, id)).status).toBe("paused");
await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest);
expect((await getConnection(db, u, id)).status).toBe("active");
await deleteConnection(db, u, id);
const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
expect(row).toMatchObject({ status: "paused", credentials_enc: null });
expect(row.deleted_at).not.toBeNull();
expect(await listConnections(db, u)).toEqual([]);
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError);
await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("deleted connections are never scheduled and their queued syncs are dropped", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest);
// Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it.
await deleteConnection(db, u, id);
await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute();
const sched = startScheduler(db, { tickMs: 3_600_000 });
await sched.tickNow();
sched.stop();
expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]);
const sync = syncHandler({ db, secret: SECRET, registry: reg });
await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError);
});
test("syncHandler runs file mode from a base64 payload", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
const s = await sync({ connectionId: id, file: { name: "x.txt", base64: Buffer.from("Dune").toString("base64") } });
expect(s).toMatchObject({ status: "ok" });
expect((await listRuns(db, u, id))[0]!.mode).toBe("file");
const entries = await db.k.selectFrom("entries").select("id").where("user_id", "=", u).execute();
expect(entries).toHaveLength(1);
});
test("deviceStart and devicePoll store credentials on success", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
expect(await deviceStart(db, SECRET, reg2, u, id)).toMatchObject({ userCode: "ABCD", deviceCode: "dc" });
expect(await devicePoll(db, SECRET, reg2, u, id, "other")).toBe("pending");
expect(await devicePoll(db, SECRET, reg2, u, id, "dc")).toBe("connected");
expect(await getConnection(db, u, id)).toMatchObject({ hasCredentials: true });
const e = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
await expect(deviceStart(db, SECRET, reg2, u, e)).rejects.toBeInstanceOf(ValidationError);
});
});