@openparachute/vault 0.7.0-rc.8 → 0.7.0-rc.9
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/core.test.ts +482 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +278 -29
- package/core/src/notes.ts +27 -0
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.ts +44 -0
- package/package.json +1 -1
- package/src/mcp-tools.ts +25 -3
- package/src/routes.ts +198 -17
- package/src/vault.test.ts +454 -0
package/src/vault.test.ts
CHANGED
|
@@ -1815,6 +1815,155 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1815
1815
|
|
|
1816
1816
|
closeAllStores();
|
|
1817
1817
|
});
|
|
1818
|
+
|
|
1819
|
+
// vault#555 auth-review CRITICAL: `if_exists` must NOT let a scoped MCP
|
|
1820
|
+
// session read/update/replace an out-of-scope note by naming its path.
|
|
1821
|
+
// The create-note wrapper pre-resolves the path and throws path_conflict on
|
|
1822
|
+
// an out-of-scope hit (byte-identical to a genuine conflict). Each assertion
|
|
1823
|
+
// MUST fail without the wrapper guard.
|
|
1824
|
+
test('scoped create-note if_exists:"ignore" does NOT return an out-of-scope note (throws path_conflict)', async () => {
|
|
1825
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1826
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1827
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1828
|
+
|
|
1829
|
+
const vaultName = `tagscope-ifexists-ignore-${Date.now()}`;
|
|
1830
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1831
|
+
const store = getVaultStore(vaultName);
|
|
1832
|
+
await store.createNote("SECRET MCP PAYLOAD", { path: "Secret", tags: ["personal"] });
|
|
1833
|
+
|
|
1834
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1835
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1836
|
+
// in-scope incoming tag passes the item-tag pre-check; the OUT-OF-SCOPE
|
|
1837
|
+
// existing note at this path must still be blocked.
|
|
1838
|
+
await expect(
|
|
1839
|
+
create.execute({ content: "attempted read", path: "Secret", tags: ["work"], if_exists: "ignore" }),
|
|
1840
|
+
).rejects.toThrow(/path_conflict/);
|
|
1841
|
+
|
|
1842
|
+
closeAllStores();
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test('scoped create-note if_exists:"update"/"replace" does NOT mutate an out-of-scope note', async () => {
|
|
1846
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1847
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1848
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1849
|
+
|
|
1850
|
+
const vaultName = `tagscope-ifexists-mutate-${Date.now()}`;
|
|
1851
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1852
|
+
const store = getVaultStore(vaultName);
|
|
1853
|
+
const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { keep: "me" } });
|
|
1854
|
+
|
|
1855
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1856
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1857
|
+
|
|
1858
|
+
await expect(
|
|
1859
|
+
create.execute({ content: "OVERWRITE", path: "Secret", tags: ["work"], metadata: { injected: true }, if_exists: "update" }),
|
|
1860
|
+
).rejects.toThrow(/path_conflict/);
|
|
1861
|
+
await expect(
|
|
1862
|
+
create.execute({ content: "REPLACE", path: "Secret", tags: ["work"], if_exists: "replace" }),
|
|
1863
|
+
).rejects.toThrow(/path_conflict/);
|
|
1864
|
+
|
|
1865
|
+
// Ground truth: the out-of-scope note is untouched by both attempts.
|
|
1866
|
+
const onDisk = (await store.getNote(secret.id))!;
|
|
1867
|
+
expect(onDisk.content).toBe("ORIGINAL SECRET");
|
|
1868
|
+
expect(onDisk.metadata).toEqual({ keep: "me" });
|
|
1869
|
+
expect(onDisk.tags).toEqual(["personal"]);
|
|
1870
|
+
|
|
1871
|
+
closeAllStores();
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
test("scoped create-note if_exists against an IN-scope note works normally (guard doesn't over-block)", async () => {
|
|
1875
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1876
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1877
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1878
|
+
|
|
1879
|
+
const vaultName = `tagscope-ifexists-inscope-${Date.now()}`;
|
|
1880
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1881
|
+
const store = getVaultStore(vaultName);
|
|
1882
|
+
const existing = await store.createNote("WORK BODY", { path: "MyWork", tags: ["work"] });
|
|
1883
|
+
|
|
1884
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1885
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1886
|
+
const result = await create.execute({ content: "x", path: "MyWork", tags: ["work"], if_exists: "ignore" }) as any;
|
|
1887
|
+
expect(result.existed).toBe(true);
|
|
1888
|
+
expect(result.id).toBe(existing.id);
|
|
1889
|
+
expect(result.content).toBe("WORK BODY");
|
|
1890
|
+
|
|
1891
|
+
closeAllStores();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
// vault#555 auth-review CRITICAL (RACE PATH — the incomplete-first-fix gap).
|
|
1895
|
+
// The wrapper pre-check + core's proactive getNoteByPath can BOTH miss a note
|
|
1896
|
+
// that a concurrent writer INSERTs, after which core's race backstop
|
|
1897
|
+
// re-resolves the (now-existing, out-of-scope) winner and calls
|
|
1898
|
+
// applyExistingNote on it. Without the in-core `ifExistsVisible` guard the
|
|
1899
|
+
// out-of-scope content leaks / the note is mutated. We reproduce the exact
|
|
1900
|
+
// TOCTOU by monkeypatching store.createNote to (a) create the out-of-scope
|
|
1901
|
+
// note itself (simulating the concurrent winner) and (b) throw
|
|
1902
|
+
// PathConflictError — driving execution straight into the backstop. Both
|
|
1903
|
+
// assertions MUST fail without the in-core guard.
|
|
1904
|
+
async function raceVault(mode: "ignore" | "update" | "replace") {
|
|
1905
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1906
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1907
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1908
|
+
const { PathConflictError } = await import("../core/src/notes.ts");
|
|
1909
|
+
|
|
1910
|
+
const vaultName = `tagscope-ifexists-race-${mode}-${Date.now()}`;
|
|
1911
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1912
|
+
const store = getVaultStore(vaultName);
|
|
1913
|
+
|
|
1914
|
+
// Monkeypatch createNote so the target path is created by a "concurrent
|
|
1915
|
+
// writer" with an OUT-OF-SCOPE tag, then the real INSERT loses the race.
|
|
1916
|
+
const origCreate = store.createNote.bind(store);
|
|
1917
|
+
let raced = false;
|
|
1918
|
+
(store as any).createNote = async (content: string, opts: any) => {
|
|
1919
|
+
if (opts?.path === "Secret" && !raced) {
|
|
1920
|
+
raced = true;
|
|
1921
|
+
await origCreate("TOP SECRET RACE PAYLOAD", { path: "Secret", tags: ["personal"] });
|
|
1922
|
+
throw new PathConflictError("Secret");
|
|
1923
|
+
}
|
|
1924
|
+
return origCreate(content, opts);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1928
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1929
|
+
return { store, create, closeAllStores };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
test('scoped create-note if_exists:"ignore" RACE backstop does NOT leak out-of-scope content', async () => {
|
|
1933
|
+
const { store, create, closeAllStores } = await raceVault("ignore");
|
|
1934
|
+
await expect(
|
|
1935
|
+
create.execute({ content: "attempted", path: "Secret", tags: ["work"], if_exists: "ignore" }),
|
|
1936
|
+
).rejects.toThrow(/path_conflict/);
|
|
1937
|
+
// The concurrently-created out-of-scope note is byte-unchanged and its
|
|
1938
|
+
// payload never reached the caller (the throw carries only the path).
|
|
1939
|
+
const onDisk = (await store.getNoteByPath("Secret"))!;
|
|
1940
|
+
expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD");
|
|
1941
|
+
expect(onDisk.tags).toEqual(["personal"]);
|
|
1942
|
+
closeAllStores();
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
test('scoped create-note if_exists:"update" RACE backstop does NOT mutate the out-of-scope note', async () => {
|
|
1946
|
+
const { store, create, closeAllStores } = await raceVault("update");
|
|
1947
|
+
await expect(
|
|
1948
|
+
create.execute({ content: "OVERWRITE", path: "Secret", tags: ["work"], metadata: { injected: true }, if_exists: "update" }),
|
|
1949
|
+
).rejects.toThrow(/path_conflict/);
|
|
1950
|
+
const onDisk = (await store.getNoteByPath("Secret"))!;
|
|
1951
|
+
expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD"); // unmutated
|
|
1952
|
+
expect(onDisk.metadata ?? {}).toEqual({});
|
|
1953
|
+
expect(onDisk.tags).toEqual(["personal"]);
|
|
1954
|
+
closeAllStores();
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
test('scoped create-note if_exists:"replace" RACE backstop does NOT mutate the out-of-scope note', async () => {
|
|
1958
|
+
const { store, create, closeAllStores } = await raceVault("replace");
|
|
1959
|
+
await expect(
|
|
1960
|
+
create.execute({ content: "REPLACE", path: "Secret", tags: ["work"], if_exists: "replace" }),
|
|
1961
|
+
).rejects.toThrow(/path_conflict/);
|
|
1962
|
+
const onDisk = (await store.getNoteByPath("Secret"))!;
|
|
1963
|
+
expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD"); // unmutated
|
|
1964
|
+
expect(onDisk.tags).toEqual(["personal"]);
|
|
1965
|
+
closeAllStores();
|
|
1966
|
+
});
|
|
1818
1967
|
});
|
|
1819
1968
|
|
|
1820
1969
|
describe("auth permissions", () => {
|
|
@@ -3988,6 +4137,111 @@ describe("HTTP tag-scope confidentiality (security review)", async () => {
|
|
|
3988
4137
|
expect(targets).toContain("NoSuchPersonal");
|
|
3989
4138
|
});
|
|
3990
4139
|
|
|
4140
|
+
// vault#555 auth-review CRITICAL: `if_exists` must NOT become a tag-scope
|
|
4141
|
+
// bypass. A scoped token naming an out-of-scope note's PATH must not read
|
|
4142
|
+
// (ignore), update, or replace it — treat it as a path_conflict (path taken,
|
|
4143
|
+
// invisible to this caller). Each assertion MUST fail without the guard in
|
|
4144
|
+
// applyExistingNote.
|
|
4145
|
+
test('if_exists:"ignore" does NOT return an out-of-scope note by path (POST /notes)', async () => {
|
|
4146
|
+
await store.createNote("SECRET WORK PAYLOAD", { path: "Secret", tags: ["personal"] });
|
|
4147
|
+
|
|
4148
|
+
const res = await handleNotes(
|
|
4149
|
+
mkReq("POST", "/notes", {
|
|
4150
|
+
content: "attempted read",
|
|
4151
|
+
path: "Secret",
|
|
4152
|
+
tags: ["work"], // in-scope incoming tag — passes the item-tag pre-check
|
|
4153
|
+
if_exists: "ignore",
|
|
4154
|
+
}),
|
|
4155
|
+
store,
|
|
4156
|
+
"",
|
|
4157
|
+
"v",
|
|
4158
|
+
await scopeCtx(["work"]),
|
|
4159
|
+
);
|
|
4160
|
+
// Path is taken but invisible to this caller → 409 path_conflict, and the
|
|
4161
|
+
// secret content appears NOWHERE in the response.
|
|
4162
|
+
expect(res.status).toBe(409);
|
|
4163
|
+
const text = await res.text();
|
|
4164
|
+
expect(text).not.toContain("SECRET WORK PAYLOAD");
|
|
4165
|
+
expect(JSON.parse(text).error_type).toBe("path_conflict");
|
|
4166
|
+
});
|
|
4167
|
+
|
|
4168
|
+
test('if_exists:"update" does NOT mutate an out-of-scope note by path (POST /notes)', async () => {
|
|
4169
|
+
const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { keep: "me" } });
|
|
4170
|
+
|
|
4171
|
+
const res = await handleNotes(
|
|
4172
|
+
mkReq("POST", "/notes", {
|
|
4173
|
+
content: "OVERWRITE ATTEMPT",
|
|
4174
|
+
path: "Secret",
|
|
4175
|
+
tags: ["work"],
|
|
4176
|
+
metadata: { injected: true },
|
|
4177
|
+
if_exists: "update",
|
|
4178
|
+
}),
|
|
4179
|
+
store,
|
|
4180
|
+
"",
|
|
4181
|
+
"v",
|
|
4182
|
+
await scopeCtx(["work"]),
|
|
4183
|
+
);
|
|
4184
|
+
expect(res.status).toBe(409);
|
|
4185
|
+
// Ground truth: the out-of-scope note is byte-for-byte unchanged.
|
|
4186
|
+
const onDisk = (await store.getNote(secret.id))!;
|
|
4187
|
+
expect(onDisk.content).toBe("ORIGINAL SECRET");
|
|
4188
|
+
expect(onDisk.metadata).toEqual({ keep: "me" });
|
|
4189
|
+
expect(onDisk.tags).toEqual(["personal"]); // "work" never applied
|
|
4190
|
+
});
|
|
4191
|
+
|
|
4192
|
+
test('if_exists:"replace" does NOT mutate an out-of-scope note by path (POST /notes)', async () => {
|
|
4193
|
+
const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { a: 1 } });
|
|
4194
|
+
|
|
4195
|
+
const res = await handleNotes(
|
|
4196
|
+
mkReq("POST", "/notes", {
|
|
4197
|
+
content: "REPLACE ATTEMPT",
|
|
4198
|
+
path: "Secret",
|
|
4199
|
+
tags: ["work"],
|
|
4200
|
+
if_exists: "replace",
|
|
4201
|
+
}),
|
|
4202
|
+
store,
|
|
4203
|
+
"",
|
|
4204
|
+
"v",
|
|
4205
|
+
await scopeCtx(["work"]),
|
|
4206
|
+
);
|
|
4207
|
+
expect(res.status).toBe(409);
|
|
4208
|
+
const onDisk = (await store.getNote(secret.id))!;
|
|
4209
|
+
expect(onDisk.content).toBe("ORIGINAL SECRET");
|
|
4210
|
+
expect(onDisk.metadata).toEqual({ a: 1 });
|
|
4211
|
+
});
|
|
4212
|
+
|
|
4213
|
+
test("UNSCOPED if_exists:ignore still returns the existing note (regression — guard is scope-gated)", async () => {
|
|
4214
|
+
const existing = await store.createNote("PLAIN BODY", { path: "Plain", tags: ["work"] });
|
|
4215
|
+
const res = await handleNotes(
|
|
4216
|
+
mkReq("POST", "/notes", { content: "x", path: "Plain", if_exists: "ignore" }),
|
|
4217
|
+
store,
|
|
4218
|
+
"",
|
|
4219
|
+
"v",
|
|
4220
|
+
NO_SCOPE,
|
|
4221
|
+
);
|
|
4222
|
+
expect(res.status).toBe(201);
|
|
4223
|
+
const body = await res.json() as any;
|
|
4224
|
+
expect(body.existed).toBe(true);
|
|
4225
|
+
expect(body.id).toBe(existing.id);
|
|
4226
|
+
expect(body.content).toBe("PLAIN BODY");
|
|
4227
|
+
});
|
|
4228
|
+
|
|
4229
|
+
test("in-scope if_exists:ignore against an in-scope note works normally (guard doesn't over-block)", async () => {
|
|
4230
|
+
const existing = await store.createNote("WORK BODY", { path: "MyWork", tags: ["work"] });
|
|
4231
|
+
const res = await handleNotes(
|
|
4232
|
+
mkReq("POST", "/notes", { content: "x", path: "MyWork", tags: ["work"], if_exists: "ignore" }),
|
|
4233
|
+
store,
|
|
4234
|
+
"",
|
|
4235
|
+
"v",
|
|
4236
|
+
await scopeCtx(["work"]),
|
|
4237
|
+
);
|
|
4238
|
+
expect(res.status).toBe(201);
|
|
4239
|
+
const body = await res.json() as any;
|
|
4240
|
+
expect(body.existed).toBe(true);
|
|
4241
|
+
expect(body.id).toBe(existing.id);
|
|
4242
|
+
expect(body.content).toBe("WORK BODY");
|
|
4243
|
+
});
|
|
4244
|
+
|
|
3991
4245
|
});
|
|
3992
4246
|
|
|
3993
4247
|
describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
|
|
@@ -4964,6 +5218,206 @@ describe("HTTP POST /notes — validation_status attachment (vault#287)", async
|
|
|
4964
5218
|
});
|
|
4965
5219
|
});
|
|
4966
5220
|
|
|
5221
|
+
// vault#555 — HTTP POST /notes with if_exists: idempotent upsert on a path
|
|
5222
|
+
// conflict. Mirrors the MCP create-note tool's if_exists contract exactly
|
|
5223
|
+
// (independent REST-layer reimplementation, same core primitives).
|
|
5224
|
+
describe("HTTP POST /notes — if_exists (vault#555)", async () => {
|
|
5225
|
+
test("default (no if_exists) still 409s — back-compat", async () => {
|
|
5226
|
+
await store.createNote("first", { path: "Inbox/rest-dup" });
|
|
5227
|
+
const res = await handleNotes(
|
|
5228
|
+
mkReq("POST", "/notes", { content: "second", path: "Inbox/rest-dup" }),
|
|
5229
|
+
store,
|
|
5230
|
+
"",
|
|
5231
|
+
);
|
|
5232
|
+
expect(res.status).toBe(409);
|
|
5233
|
+
const body = await res.json() as any;
|
|
5234
|
+
expect(body.error_type).toBe("path_conflict");
|
|
5235
|
+
});
|
|
5236
|
+
|
|
5237
|
+
test('if_exists:"ignore" returns 201 with the existing note untouched + existed:true', async () => {
|
|
5238
|
+
const first = await store.createNote("original", { path: "Inbox/rest-ignore", metadata: { v: 1 } });
|
|
5239
|
+
const res = await handleNotes(
|
|
5240
|
+
mkReq("POST", "/notes", {
|
|
5241
|
+
content: "attempted overwrite",
|
|
5242
|
+
path: "Inbox/rest-ignore",
|
|
5243
|
+
metadata: { v: 2 },
|
|
5244
|
+
if_exists: "ignore",
|
|
5245
|
+
}),
|
|
5246
|
+
store,
|
|
5247
|
+
"",
|
|
5248
|
+
);
|
|
5249
|
+
expect(res.status).toBe(201);
|
|
5250
|
+
const body = await res.json() as any;
|
|
5251
|
+
expect(body.existed).toBe(true);
|
|
5252
|
+
expect(body.id).toBe(first.id);
|
|
5253
|
+
expect(body.content).toBe("original");
|
|
5254
|
+
expect(body.metadata?.v).toBe(1);
|
|
5255
|
+
});
|
|
5256
|
+
|
|
5257
|
+
test('if_exists:"update" full-replaces content and RFC-7386-merges metadata', async () => {
|
|
5258
|
+
await store.createNote("v1", { path: "Inbox/rest-update", metadata: { a: 1, b: 2 } });
|
|
5259
|
+
const res = await handleNotes(
|
|
5260
|
+
mkReq("POST", "/notes", {
|
|
5261
|
+
content: "v2",
|
|
5262
|
+
path: "Inbox/rest-update",
|
|
5263
|
+
metadata: { b: null, c: 3 },
|
|
5264
|
+
if_exists: "update",
|
|
5265
|
+
}),
|
|
5266
|
+
store,
|
|
5267
|
+
"",
|
|
5268
|
+
);
|
|
5269
|
+
expect(res.status).toBe(201);
|
|
5270
|
+
const body = await res.json() as any;
|
|
5271
|
+
expect(body.existed).toBe(true);
|
|
5272
|
+
expect(body.content).toBe("v2");
|
|
5273
|
+
expect(body.metadata).toEqual({ a: 1, c: 3 });
|
|
5274
|
+
});
|
|
5275
|
+
|
|
5276
|
+
test('if_exists:"replace" wholesale-overwrites content + metadata, keeps id/createdAt', async () => {
|
|
5277
|
+
const first = await store.createNote("v1", { path: "Inbox/rest-replace", metadata: { a: 1, b: 2 } });
|
|
5278
|
+
const res = await handleNotes(
|
|
5279
|
+
mkReq("POST", "/notes", {
|
|
5280
|
+
content: "v2",
|
|
5281
|
+
path: "Inbox/rest-replace",
|
|
5282
|
+
metadata: { c: 3 },
|
|
5283
|
+
if_exists: "replace",
|
|
5284
|
+
}),
|
|
5285
|
+
store,
|
|
5286
|
+
"",
|
|
5287
|
+
);
|
|
5288
|
+
expect(res.status).toBe(201);
|
|
5289
|
+
const body = await res.json() as any;
|
|
5290
|
+
expect(body.existed).toBe(true);
|
|
5291
|
+
expect(body.id).toBe(first.id);
|
|
5292
|
+
expect(body.createdAt).toBe(first.createdAt);
|
|
5293
|
+
expect(body.content).toBe("v2");
|
|
5294
|
+
expect(body.metadata).toEqual({ c: 3 });
|
|
5295
|
+
});
|
|
5296
|
+
|
|
5297
|
+
test("plain POST (no if_exists) sees zero response-shape change", async () => {
|
|
5298
|
+
const res = await handleNotes(mkReq("POST", "/notes", { content: "plain" }), store, "");
|
|
5299
|
+
const body = await res.json() as any;
|
|
5300
|
+
expect("existed" in body).toBe(false);
|
|
5301
|
+
});
|
|
5302
|
+
|
|
5303
|
+
test("batch: if_exists is per-item — a top-level default is NOT inherited", async () => {
|
|
5304
|
+
await store.createNote("existing", { path: "Inbox/rest-batch-conflict" });
|
|
5305
|
+
const res = await handleNotes(
|
|
5306
|
+
mkReq("POST", "/notes", {
|
|
5307
|
+
if_exists: "ignore",
|
|
5308
|
+
notes: [{ content: "conflict", path: "Inbox/rest-batch-conflict" }],
|
|
5309
|
+
}),
|
|
5310
|
+
store,
|
|
5311
|
+
"",
|
|
5312
|
+
);
|
|
5313
|
+
expect(res.status).toBe(409);
|
|
5314
|
+
});
|
|
5315
|
+
|
|
5316
|
+
test("summary:true returns {created, ids, failed} for a batch", async () => {
|
|
5317
|
+
await store.createNote("pre-existing", { path: "Inbox/rest-sum-existing" });
|
|
5318
|
+
const res = await handleNotes(
|
|
5319
|
+
mkReq("POST", "/notes", {
|
|
5320
|
+
summary: true,
|
|
5321
|
+
notes: [
|
|
5322
|
+
{ content: "fresh", path: "Inbox/rest-sum-fresh", if_exists: "ignore" },
|
|
5323
|
+
{ content: "ignored", path: "Inbox/rest-sum-existing", if_exists: "ignore" },
|
|
5324
|
+
],
|
|
5325
|
+
}),
|
|
5326
|
+
store,
|
|
5327
|
+
"",
|
|
5328
|
+
);
|
|
5329
|
+
expect(res.status).toBe(201);
|
|
5330
|
+
const body = await res.json() as any;
|
|
5331
|
+
expect(body.created).toBe(1);
|
|
5332
|
+
expect(body.ids).toHaveLength(2);
|
|
5333
|
+
expect(body.failed).toEqual([]);
|
|
5334
|
+
});
|
|
5335
|
+
|
|
5336
|
+
test("summary is ignored on a single-note POST", async () => {
|
|
5337
|
+
const res = await handleNotes(mkReq("POST", "/notes", { content: "solo", summary: true }), store, "");
|
|
5338
|
+
const body = await res.json() as any;
|
|
5339
|
+
expect(body.content).toBe("solo");
|
|
5340
|
+
expect(body.created).toBeUndefined();
|
|
5341
|
+
});
|
|
5342
|
+
|
|
5343
|
+
// vault#555 CRITICAL 2 (REST tripwire — the generalist-flagged coverage gap:
|
|
5344
|
+
// the core-side tests didn't guard the REST gate, so reverting routes.ts's
|
|
5345
|
+
// gate alone left all tests green). REST parity of the two core tests.
|
|
5346
|
+
test('if_exists:"update" with ONLY tags added still advances updated_at (POST /notes)', async () => {
|
|
5347
|
+
const first = await store.createNote("body", { path: "rest-tagonly", tags: ["alpha"] });
|
|
5348
|
+
const before = first.updatedAt!;
|
|
5349
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
5350
|
+
|
|
5351
|
+
const res = await handleNotes(
|
|
5352
|
+
mkReq("POST", "/notes", { path: "rest-tagonly", tags: ["beta"], if_exists: "update" }),
|
|
5353
|
+
store,
|
|
5354
|
+
"",
|
|
5355
|
+
);
|
|
5356
|
+
expect(res.status).toBe(201);
|
|
5357
|
+
const body = await res.json() as any;
|
|
5358
|
+
expect(body.existed).toBe(true);
|
|
5359
|
+
expect(body.tags?.sort()).toEqual(["alpha", "beta"]);
|
|
5360
|
+
const after = (await store.getNote(first.id))!.updatedAt!;
|
|
5361
|
+
expect(after > before).toBe(true);
|
|
5362
|
+
});
|
|
5363
|
+
|
|
5364
|
+
test('if_exists:"update" with ONLY links added still advances updated_at (POST /notes)', async () => {
|
|
5365
|
+
await store.createNote("target", { id: "rest-tgt-linkonly", path: "Targets/rest-linkonly" });
|
|
5366
|
+
const first = await store.createNote("body", { path: "rest-linkonly", tags: ["alpha"] });
|
|
5367
|
+
const before = first.updatedAt!;
|
|
5368
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
5369
|
+
|
|
5370
|
+
const res = await handleNotes(
|
|
5371
|
+
mkReq("POST", "/notes", {
|
|
5372
|
+
path: "rest-linkonly",
|
|
5373
|
+
links: [{ target: "rest-tgt-linkonly", relationship: "relates-to" }],
|
|
5374
|
+
if_exists: "update",
|
|
5375
|
+
}),
|
|
5376
|
+
store,
|
|
5377
|
+
"",
|
|
5378
|
+
);
|
|
5379
|
+
expect(res.status).toBe(201);
|
|
5380
|
+
const after = (await store.getNote(first.id))!.updatedAt!;
|
|
5381
|
+
expect(after > before).toBe(true);
|
|
5382
|
+
});
|
|
5383
|
+
});
|
|
5384
|
+
|
|
5385
|
+
// vault#555 — has_broken_links / include_broken_links on GET /notes.
|
|
5386
|
+
describe("HTTP GET /notes — has_broken_links / include_broken_links (vault#555)", async () => {
|
|
5387
|
+
test("has_broken_links=true filters to notes with a dangling wikilink", async () => {
|
|
5388
|
+
await store.createNote("[[Nowhere]]", { path: "rest-broken" });
|
|
5389
|
+
await store.createNote("clean", { path: "rest-clean" });
|
|
5390
|
+
const res = await handleNotes(mkReq("GET", "/notes?has_broken_links=true&include_content=true"), store, "");
|
|
5391
|
+
const body = await res.json() as any[];
|
|
5392
|
+
expect(body.map((n) => n.path)).toEqual(["rest-broken"]);
|
|
5393
|
+
});
|
|
5394
|
+
|
|
5395
|
+
test("has_broken_links=false excludes them", async () => {
|
|
5396
|
+
await store.createNote("[[Nowhere]]", { path: "rest-broken2" });
|
|
5397
|
+
await store.createNote("clean", { path: "rest-clean2" });
|
|
5398
|
+
const res = await handleNotes(mkReq("GET", "/notes?has_broken_links=false&include_content=true"), store, "");
|
|
5399
|
+
const body = await res.json() as any[];
|
|
5400
|
+
expect(body.map((n) => n.path)).toEqual(["rest-clean2"]);
|
|
5401
|
+
});
|
|
5402
|
+
|
|
5403
|
+
test("include_broken_links on a single note surfaces {target, relationship}", async () => {
|
|
5404
|
+
await store.createNote("[[Ghost]]", { path: "rest-single-broken" });
|
|
5405
|
+
const res = await handleNotes(mkReq("GET", "/notes?id=rest-single-broken&include_broken_links=true"), store, "");
|
|
5406
|
+
const body = await res.json() as any;
|
|
5407
|
+
expect(body.broken_links).toEqual([{ target: "Ghost", relationship: "wikilink" }]);
|
|
5408
|
+
});
|
|
5409
|
+
|
|
5410
|
+
test("include_broken_links in list mode is batched per note", async () => {
|
|
5411
|
+
await store.createNote("[[Ghost A]]", { path: "rest-list-a" });
|
|
5412
|
+
await store.createNote("clean", { path: "rest-list-b" });
|
|
5413
|
+
const res = await handleNotes(mkReq("GET", "/notes?include_broken_links=true&include_content=true"), store, "");
|
|
5414
|
+
const body = await res.json() as any[];
|
|
5415
|
+
const byPath = new Map(body.map((n: any) => [n.path, n.broken_links]));
|
|
5416
|
+
expect(byPath.get("rest-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
|
|
5417
|
+
expect(byPath.get("rest-list-b")).toEqual([]);
|
|
5418
|
+
});
|
|
5419
|
+
});
|
|
5420
|
+
|
|
4967
5421
|
// vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
|
|
4968
5422
|
// the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
|
|
4969
5423
|
// for idempotent upsert without a separate query-first round trip.
|