@openparachute/vault 0.7.9-rc.7 → 0.7.9-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/history-capture.test.ts +9 -1
- package/core/src/history-visibility.test.ts +11 -0
- package/core/src/history-visibility.ts +9 -0
- package/core/src/history.ts +8 -0
- package/core/src/store.ts +2 -0
- package/package.json +1 -1
- package/src/history-import-routes.test.ts +23 -1
- package/src/history-import.test.ts +34 -2
- package/src/history-import.ts +4 -1
- package/src/mcp-tools.ts +8 -2
- package/src/routes.ts +5 -3
- package/src/vault-compact-routes.test.ts +1 -1
- package/src/vault-history-routes.test.ts +50 -1
|
@@ -86,9 +86,17 @@ it("P5 restore reads its source before a one-version prune", async () => {
|
|
|
86
86
|
});
|
|
87
87
|
const n = await store.createNote("one");
|
|
88
88
|
await store.updateNote(n.id, { content: "two" });
|
|
89
|
-
expect((await store.restoreNoteVersion(n.id, 0, {})).content).toBe("one");
|
|
89
|
+
expect((await store.restoreNoteVersion(n.id, 0, { if_updated_at: (await store.getNote(n.id))!.updatedAt })).content).toBe("one");
|
|
90
90
|
expect(await store.listNoteVersions(n.id)).toHaveLength(1);
|
|
91
91
|
});
|
|
92
|
+
it("live restore requires a precondition and leaves current note and history unchanged without one", async () => {
|
|
93
|
+
const n = await store.createNote("old");
|
|
94
|
+
await store.updateNote(n.id, { content: "new" });
|
|
95
|
+
const before = await store.getNote(n.id), versions = await store.listNoteVersions(n.id);
|
|
96
|
+
await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({ code: "PRECONDITION_REQUIRED" });
|
|
97
|
+
expect(await store.getNote(n.id)).toEqual(before);
|
|
98
|
+
expect(await store.listNoteVersions(n.id)).toEqual(versions);
|
|
99
|
+
});
|
|
92
100
|
it("P8a tag rename captures only rewritten notes", async () => {
|
|
93
101
|
await store.upsertTagRecord("old", {});
|
|
94
102
|
const a = await store.createNote("#old a"),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { projectHistoryProvenance } from "./history-visibility.ts";
|
|
3
|
+
|
|
4
|
+
test("history redaction removes only transport provenance without mutating stored data", () => {
|
|
5
|
+
const row = Object.freeze({ actor: "editor", via: "api", content: "editor mentioned in text",
|
|
6
|
+
metadata: { actor: "user-authored value" }, origin: "git-import", import_ix: 0 });
|
|
7
|
+
expect(projectHistoryProvenance(row, false)).toBe(row);
|
|
8
|
+
expect(projectHistoryProvenance(row, true)).toEqual({ content: row.content, metadata: row.metadata,
|
|
9
|
+
origin: "git-import", import_ix: 0 });
|
|
10
|
+
expect(row.actor).toBe("editor");
|
|
11
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Response-only redaction; never remove stored provenance or rewrite note content.
|
|
2
|
+
* Tag-restricted sessions do not receive historical editor/interface identities.
|
|
3
|
+
* The transport must establish note visibility before applying this projection.
|
|
4
|
+
*/
|
|
5
|
+
export function projectHistoryProvenance<T extends object>(row: T, restricted: boolean): T | Omit<T, "actor" | "via"> {
|
|
6
|
+
if (!restricted) return row;
|
|
7
|
+
const { actor: _actor, via: _via, ...visible } = row as T & { actor?: unknown; via?: unknown };
|
|
8
|
+
return visible;
|
|
9
|
+
}
|
package/core/src/history.ts
CHANGED
|
@@ -119,6 +119,14 @@ export class HistoryUnrecoverableError extends Error {
|
|
|
119
119
|
this.name = "HistoryUnrecoverableError";
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
export class HistoryPreconditionRequiredError extends Error {
|
|
123
|
+
readonly code = "PRECONDITION_REQUIRED";
|
|
124
|
+
readonly error_type = "precondition_required";
|
|
125
|
+
constructor(readonly note_id: string) {
|
|
126
|
+
super("Restoring an existing note requires if_updated_at from the note you reviewed.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
122
130
|
export class HistoryNotFoundError extends Error {
|
|
123
131
|
code = "HISTORY_NOT_FOUND" as const;
|
|
124
132
|
note_id: string;
|
package/core/src/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HistoryPreconditionRequiredError } from "./history.js";
|
|
2
3
|
import type { Store, Note, Link, Attachment, QueryOpts, QueryNotesPage, AggregateRow, SemanticSearchResult } from "./types.js";
|
|
3
4
|
import { compactNote, compactVault, countNoteVersions, type CompactResult, type CompactSummary, captureVersion, readPriorNoteRow, resolveHistoryPolicy, appendRestoreMarker, listVersions, getVersion, latestTombstone, eraseHistory, sweepDeletedHistory, deletedHistoryStats, HistoryNotFoundError, HistoryOverflowError, HistoryUnrecoverableError, DEFAULT_HISTORY_POLICY, VERSION_MAX_BYTES, type VersionRow, type HistoryPolicy, type HistoryOp } from "./history.js";
|
|
4
5
|
import { initSchema } from "./schema.js";
|
|
@@ -834,6 +835,7 @@ export class BunSqliteStore implements Store {
|
|
|
834
835
|
const v = getVersion(this.db, id, versionIx);
|
|
835
836
|
if (!v) throw new HistoryNotFoundError(id, versionIx);
|
|
836
837
|
if (v.encoding === "overflow") throw new HistoryUnrecoverableError(id, versionIx);
|
|
838
|
+
if (opts.if_updated_at === undefined) throw new HistoryPreconditionRequiredError(id);
|
|
837
839
|
return await this.updateNote(id, {
|
|
838
840
|
content: v.content!, metadata: v.metadata, extension: v.extension ?? undefined,
|
|
839
841
|
actor: opts.actor ?? null, via: opts.via ?? null,
|
package/package.json
CHANGED
|
@@ -45,6 +45,24 @@ async function call(path: string, method = "GET", body?: unknown, verb = "admin"
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
48
|
+
test("authenticated history REST redacts scoped provenance for read/write/admin tokens", async () => {
|
|
49
|
+
const note = await store.createNote("before", { tags: ["journal"] });
|
|
50
|
+
await store.updateNote(note.id, { content: "after", actor: "HISTORICAL_EDITOR", via: "HISTORICAL_INTERFACE" });
|
|
51
|
+
for (const verb of ["read", "write", "admin"]) {
|
|
52
|
+
for (const scoped of [false, true]) {
|
|
53
|
+
for (const suffix of ["/versions", "/versions/0"]) {
|
|
54
|
+
const got = await call(`/notes/${note.id}${suffix}`, "GET", undefined, verb, scoped);
|
|
55
|
+
expect(got.status).toBe(200);
|
|
56
|
+
const row = got.body.versions?.[0] ?? got.body;
|
|
57
|
+
expect(Object.hasOwn(row, "actor")).toBe(!scoped);
|
|
58
|
+
expect(Object.hasOwn(row, "via")).toBe(!scoped);
|
|
59
|
+
if (!scoped) expect(row.actor).toBe("HISTORICAL_EDITOR");
|
|
60
|
+
expect(row.version_ix).toBe(0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
expect((await store.getNoteVersion(note.id, 0))!.actor).toBe("HISTORICAL_EDITOR");
|
|
65
|
+
});
|
|
48
66
|
test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
|
|
49
67
|
const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
|
|
50
68
|
await store.updateNote(note.id, { content: "current" });
|
|
@@ -66,7 +84,11 @@ test("imported REST references restore by ID or encoded path and preserve native
|
|
|
66
84
|
expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
|
|
67
85
|
for (const body of [{ origin: "git-import" }, { import_ix: 0 }, { origin: "git-import", import_ix: 0, version_ix: 0 }, { origin: "git-import", import_ix: -1 }]) expect((await call(`/notes/${note.id}/restore`, "POST", body)).status).toBe(400);
|
|
68
86
|
expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
|
|
69
|
-
const
|
|
87
|
+
const beforeRestore = await store.getNote(note.id);
|
|
88
|
+
const missingStamp = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
|
|
89
|
+
expect(missingStamp.status).toBe(428);
|
|
90
|
+
expect(await store.getNote(note.id)).toEqual(beforeRestore);
|
|
91
|
+
const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0, if_updated_at: beforeRestore!.updatedAt });
|
|
70
92
|
expect(restored.status).toBe(200);
|
|
71
93
|
expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
|
|
72
94
|
expect((await store.getNote(note.id))!.content).toBe("archive");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { test, expect } from "bun:test";
|
|
1
|
+
import { test, expect, spyOn } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { Database } from "bun:sqlite";
|
|
3
|
+
import { Database, SQLiteError } from "bun:sqlite";
|
|
4
4
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -119,6 +119,38 @@ test("ambiguous sidecars quarantine both identities; malformed YAML diagnostics
|
|
|
119
119
|
} finally { stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
for (const format of ["inline", "sidecar"] as const) for (const cache of ["object_cache", "parsed_cache"] as const) {
|
|
123
|
+
test(`staging SQLite failure in ${format} ${cache} aborts without blaming source notes`, () => {
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "import-stage-failure-")), stage = new Database(":memory:");
|
|
125
|
+
const exec = stage.exec.bind(stage);
|
|
126
|
+
const setup = spyOn(stage, "exec").mockImplementation((sql) => {
|
|
127
|
+
const result = exec(sql);
|
|
128
|
+
if (sql.startsWith("CREATE TABLE selection_failures")) {
|
|
129
|
+
exec(`CREATE TRIGGER fail_cache BEFORE INSERT ON ${cache} BEGIN SELECT RAISE(ABORT, 'staging write failed'); END`);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const repo = repoAt(join(dir, "repo"));
|
|
135
|
+
if (format === "inline") commit(repo, "valid body");
|
|
136
|
+
else {
|
|
137
|
+
mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
|
|
138
|
+
writeFileSync(join(repo, `.parachute/notes-meta/${noteId}.yaml`), `id: ${noteId}\npath: entry\nextension: json\n`);
|
|
139
|
+
writeFileSync(join(repo, "entry.json"), '{"valid":true}');
|
|
140
|
+
git(repo, "add", "."); git(repo, "commit", "-m", "valid sidecar");
|
|
141
|
+
}
|
|
142
|
+
let failure: unknown;
|
|
143
|
+
try { stageArchive(repo, git(repo, "rev-parse", "HEAD"), stage); }
|
|
144
|
+
catch (error) { failure = error; }
|
|
145
|
+
expect(failure).toBeInstanceOf(SQLiteError);
|
|
146
|
+
expect((failure as Error).message).toContain("staging write failed");
|
|
147
|
+
expect(stage.query("SELECT * FROM issues").all()).toEqual([]);
|
|
148
|
+
expect(stage.query("SELECT * FROM quarantine").all()).toEqual([]);
|
|
149
|
+
expect(stage.query("SELECT * FROM observations").all()).toEqual([]);
|
|
150
|
+
} finally { setup.mockRestore(); stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
test("running daemon refuses prepare before creating a pause marker or backup", async () => {
|
|
123
155
|
const dir = mkdtempSync(join(tmpdir(), "import-busy-")), saved = process.env.PARACHUTE_HOME;
|
|
124
156
|
process.env.PARACHUTE_HOME = dir;
|
package/src/history-import.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Offline git-history import. Git objects are data; no working tree is checked out. */
|
|
2
|
-
import { Database } from "bun:sqlite";
|
|
2
|
+
import { Database, SQLiteError } from "bun:sqlite";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -182,6 +182,9 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
|
|
|
182
182
|
collect(id, state, () => JSON.parse((parsedBody.get(cacheKey) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash, sidecar });
|
|
183
183
|
};
|
|
184
184
|
const failure = (entry: typeof entries[number], error: unknown, id?: unknown) => {
|
|
185
|
+
// Staging/cache failures are operational, not evidence of malformed source.
|
|
186
|
+
// Abort so no diagnostic manifest can bless an incomplete archive walk.
|
|
187
|
+
if (error instanceof SQLiteError) throw error;
|
|
185
188
|
const message = error instanceof Error ? error.message : "invalid_observation";
|
|
186
189
|
const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
|
|
187
190
|
if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
|
package/src/mcp-tools.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
|
|
9
|
+
import { projectHistoryProvenance } from "../core/src/history-visibility.js";
|
|
9
10
|
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
11
|
import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
11
12
|
import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
|
|
@@ -579,8 +580,13 @@ function applyTagScopeWrappers(
|
|
|
579
580
|
)) {
|
|
580
581
|
const id = (params as any).versions.note_id;
|
|
581
582
|
const note = resolveNote(store.db, id);
|
|
582
|
-
|
|
583
|
-
|
|
583
|
+
if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
|
|
584
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
585
|
+
}
|
|
586
|
+
if ("versions" in result && Array.isArray(result.versions)) {
|
|
587
|
+
return { ...result, versions: result.versions.map(row => projectHistoryProvenance(row, true)) };
|
|
588
|
+
}
|
|
589
|
+
return projectHistoryProvenance(result, true);
|
|
584
590
|
}
|
|
585
591
|
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
586
592
|
// - Array (legacy list, no cursor, no warnings)
|
package/src/routes.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
|
|
16
|
+
import { projectHistoryProvenance } from "../core/src/history-visibility.js";
|
|
16
17
|
import { ULID_REGEX } from "../core/src/ulid.js";
|
|
17
18
|
import type { Database } from "bun:sqlite";
|
|
18
19
|
import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
|
|
@@ -268,6 +269,7 @@ function conflictResponse(e: any): Response | null {
|
|
|
268
269
|
400,
|
|
269
270
|
);
|
|
270
271
|
}
|
|
272
|
+
if (e && e.code === "PRECONDITION_REQUIRED") return json({ error_type: "precondition_required", note_id: e.note_id, message: e.message }, 428);
|
|
271
273
|
if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
|
|
272
274
|
if (e && e.code === "HISTORY_OVERFLOW") return json({
|
|
273
275
|
error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
|
|
@@ -2942,7 +2944,7 @@ async function handleNotesInner(
|
|
|
2942
2944
|
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
|
|
2943
2945
|
const versions = await store.listNoteVersions(id, { limit, offset });
|
|
2944
2946
|
const total = await store.countNoteVersions(id);
|
|
2945
|
-
return json({ versions: versions.map(projectHistoryRow), total });
|
|
2947
|
+
return json({ versions: versions.map(row => projectHistoryProvenance(projectHistoryRow(row), tagScope.allowed !== null)), total });
|
|
2946
2948
|
}
|
|
2947
2949
|
if (method === "DELETE") {
|
|
2948
2950
|
const result = await store.eraseNoteHistory(id);
|
|
@@ -2953,7 +2955,7 @@ async function handleNotesInner(
|
|
|
2953
2955
|
if (!/^\d+$/.test(importMatch[1]!) || !Number.isSafeInteger(index) || index < 0) return json({ error: "Invalid import_ix", error_type: "invalid_request" }, 400);
|
|
2954
2956
|
try {
|
|
2955
2957
|
const version = getImportedVersion(db, id, index);
|
|
2956
|
-
return version ? json(projectHistoryRow(version)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
|
|
2958
|
+
return version ? json(projectHistoryProvenance(projectHistoryRow(version), tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
|
|
2957
2959
|
} catch (e) {
|
|
2958
2960
|
const response = conflictResponse(e);
|
|
2959
2961
|
if (response) return response;
|
|
@@ -2964,7 +2966,7 @@ async function handleNotesInner(
|
|
|
2964
2966
|
if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
|
|
2965
2967
|
try {
|
|
2966
2968
|
const version = await store.getNoteVersion(id, ix);
|
|
2967
|
-
return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2969
|
+
return version ? json(projectHistoryProvenance(version, tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2968
2970
|
} catch (e) {
|
|
2969
2971
|
const response = conflictResponse(e);
|
|
2970
2972
|
if (response) return response;
|
|
@@ -64,7 +64,7 @@ test("P9/P18 compacted REST and MCP versions roundtrip with exact keys", async (
|
|
|
64
64
|
expect(mcp).toEqual(get.body);
|
|
65
65
|
const ml = await query()({ versions: { note_id: n.id, limit: 2 } }) as any;
|
|
66
66
|
expect(ml.total).toBe(list.body.total);
|
|
67
|
-
const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0 });
|
|
67
|
+
const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt });
|
|
68
68
|
expect(restore.status).toBe(200);
|
|
69
69
|
expect((await store.getNote(n.id))!.content).toBe(before.content);
|
|
70
70
|
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
12
12
|
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
13
13
|
import { runDoctorScan } from "../core/src/doctor.ts";
|
|
14
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
15
|
+
import { resolveHistoryPolicy } from "../core/src/history.ts";
|
|
14
16
|
let taskDir: string,
|
|
15
17
|
savedHome: string | undefined,
|
|
16
18
|
store: ReturnType<typeof getVaultStore>;
|
|
@@ -78,9 +80,56 @@ async function fixture() {
|
|
|
78
80
|
await store.updateNote(n.id, { content: "two" });
|
|
79
81
|
return n;
|
|
80
82
|
}
|
|
83
|
+
test("tag-scoped REST and MCP history omit provenance without changing stored rows", async () => {
|
|
84
|
+
const n = await store.createNote("original", { path: "history-visible", tags: ["journal"] });
|
|
85
|
+
await store.updateNote(n.id, { content: "current", actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
|
|
86
|
+
const run = { run_id: "visibility", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
87
|
+
beginImportRun(store.db, run);
|
|
88
|
+
applyImportedNote(store.db, { run, noteId: n.id, targetDigest: importTargetDigest(store.db, n.id),
|
|
89
|
+
policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
|
|
90
|
+
observations: [{ content: "archive", path: "old/path", metadata: {}, extension: "md", created_at: null,
|
|
91
|
+
observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
|
|
92
|
+
const before = await store.listNoteVersions(n.id);
|
|
93
|
+
const full = (await call(n.id, "/versions")).body;
|
|
94
|
+
expect(full.versions[0]).toMatchObject({ actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
|
|
95
|
+
for (const scoped of [false, true]) {
|
|
96
|
+
const rest = (await call(n.id, "/versions", "GET", undefined, scoped)).body;
|
|
97
|
+
const mcp = await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id } });
|
|
98
|
+
expect(mcp).toEqual(rest);
|
|
99
|
+
expect(rest.total).toBe(2);
|
|
100
|
+
expect(rest.versions[0].version_ix).toBe(0);
|
|
101
|
+
expect(rest.versions[1]).toMatchObject({ origin: "git-import", import_ix: 0 });
|
|
102
|
+
for (const row of rest.versions) {
|
|
103
|
+
expect(Object.hasOwn(row, "actor")).toBe(!scoped);
|
|
104
|
+
expect(Object.hasOwn(row, "via")).toBe(!scoped);
|
|
105
|
+
}
|
|
106
|
+
for (const [path, selector, content] of [
|
|
107
|
+
["/versions/0", { version_ix: 0 }, "original"],
|
|
108
|
+
["/imports/0", { origin: "git-import", import_ix: 0 }, "archive"],
|
|
109
|
+
] as const) {
|
|
110
|
+
const one = await call(n.id, path, "GET", undefined, scoped);
|
|
111
|
+
expect(one.status).toBe(200);
|
|
112
|
+
expect(one.body.content).toBe(content);
|
|
113
|
+
expect(await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id, ...selector } })).toEqual(one.body);
|
|
114
|
+
expect(Object.hasOwn(one.body, "actor")).toBe(!scoped);
|
|
115
|
+
expect(Object.hasOwn(one.body, "via")).toBe(!scoped);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
expect(await store.listNoteVersions(n.id)).toEqual(before);
|
|
119
|
+
expect((await store.getNote(n.id))!.content).toBe("current");
|
|
120
|
+
// Empty tag lists mean unrestricted in the existing authorization contract.
|
|
121
|
+
expect(await query([])({ versions: { note_id: n.id } })).toEqual(full);
|
|
122
|
+
const denied = await query(["unrelated"])({ versions: { note_id: n.id } });
|
|
123
|
+
expect(denied).toMatchObject({ error_type: "not_found" });
|
|
124
|
+
});
|
|
81
125
|
test("P5 REST restore rejects stale optimistic tokens without capture", async () => {
|
|
82
126
|
const n = await fixture();
|
|
83
127
|
const before = await store.listNoteVersions(n.id);
|
|
128
|
+
for (const extra of [{}, { force: true }]) {
|
|
129
|
+
const missing = await call(n.id, "/restore", "POST", { version_ix: 0, ...extra });
|
|
130
|
+
expect(missing.status).toBe(428);
|
|
131
|
+
expect(missing.body.error_type).toBe("precondition_required");
|
|
132
|
+
}
|
|
84
133
|
const r = await call(n.id, "/restore", "POST", {
|
|
85
134
|
version_ix: 0,
|
|
86
135
|
if_updated_at: "1999-01-01T00:00:00.000Z",
|
|
@@ -393,7 +442,7 @@ test("P24 migration bypass restores, captures once, and emits the bypass log", a
|
|
|
393
442
|
const n = await strictFixture();
|
|
394
443
|
const log = spyOn(console, "warn").mockImplementation(() => {});
|
|
395
444
|
try {
|
|
396
|
-
const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
|
|
445
|
+
const r = await call(n.id, "/restore", "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt }, false, {
|
|
397
446
|
actor: "migrator",
|
|
398
447
|
via: "api",
|
|
399
448
|
bypassStrict: true,
|