@openparachute/vault 0.7.0-rc.7 → 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 +895 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +439 -49
- package/core/src/notes.ts +39 -4
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +230 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +36 -3
- package/src/routes.ts +363 -39
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +745 -4
package/src/vault.test.ts
CHANGED
|
@@ -1554,7 +1554,16 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1554
1554
|
closeAllStores();
|
|
1555
1555
|
});
|
|
1556
1556
|
|
|
1557
|
-
|
|
1557
|
+
// vault#555 fix 2 REVISED this test's contract. Pre-fix-2 a tags-only
|
|
1558
|
+
// `update-note` (here, adding a tag that ALSO triggers a schema-default
|
|
1559
|
+
// backfill) did NOT bump `updated_at` — this test asserted that. Fix 2
|
|
1560
|
+
// makes ANY tag mutation bump `updated_at` (so cursor/sync loops see it);
|
|
1561
|
+
// the separate defaults backfill still rides `skipUpdatedAt: true` and
|
|
1562
|
+
// doesn't itself bump. Net: the tag add bumps updatedAt exactly once, and
|
|
1563
|
+
// the default still lands. (The old assertion passed only by
|
|
1564
|
+
// create+update landing in the same millisecond — a latent flake fix 2
|
|
1565
|
+
// turned into a real contradiction; caught on a review re-run.)
|
|
1566
|
+
test("update-note tags.add bumps updatedAt AND still applies the schema default (vault#555 fix 2)", async () => {
|
|
1558
1567
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1559
1568
|
const { writeVaultConfig } = await import("./config.ts");
|
|
1560
1569
|
const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
|
|
@@ -1568,8 +1577,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1568
1577
|
|
|
1569
1578
|
const vaultStore = getVaultStore(vaultName);
|
|
1570
1579
|
// vault#553 Decision B: backfill is explicit-`default`-only — declare
|
|
1571
|
-
// one so this
|
|
1572
|
-
// assert it lands without bumping updatedAt).
|
|
1580
|
+
// one so this still exercises an actual defaults-write.
|
|
1573
1581
|
await vaultStore.upsertTagSchema("person", {
|
|
1574
1582
|
description: "A person",
|
|
1575
1583
|
fields: { name: { type: "string", default: "unknown" } },
|
|
@@ -1582,9 +1590,13 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1582
1590
|
|
|
1583
1591
|
const note = await createNote.execute({ content: "Test" }) as any;
|
|
1584
1592
|
const originalUpdatedAt = note.updatedAt;
|
|
1593
|
+
await new Promise((r) => setTimeout(r, 5)); // deterministic ms gap
|
|
1585
1594
|
await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
|
|
1586
1595
|
const after = await queryNotes.execute({ id: note.id }) as any;
|
|
1587
|
-
|
|
1596
|
+
// Fix 2: the tag mutation bumped updatedAt.
|
|
1597
|
+
expect(after.updatedAt).not.toBe(originalUpdatedAt);
|
|
1598
|
+
expect(new Date(after.updatedAt) > new Date(originalUpdatedAt)).toBe(true);
|
|
1599
|
+
// The schema default still backfilled.
|
|
1588
1600
|
expect(after.metadata.name).toBe("unknown");
|
|
1589
1601
|
|
|
1590
1602
|
close();
|
|
@@ -1726,6 +1738,232 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1726
1738
|
|
|
1727
1739
|
closeAllStores();
|
|
1728
1740
|
});
|
|
1741
|
+
|
|
1742
|
+
// vault#555 auth review — validation_status must not leak an out-of-scope
|
|
1743
|
+
// co-tag's schema shape (field name / type / enum) to a scoped caller
|
|
1744
|
+
// (the #560 leak class). A note the caller CAN see (in-scope tag) may also
|
|
1745
|
+
// carry an out-of-scope tag whose schema it violates.
|
|
1746
|
+
test("MCP query-notes scrubs out-of-scope tag schema from validation_status; only the in-scope tag's warning survives", async () => {
|
|
1747
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1748
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1749
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1750
|
+
|
|
1751
|
+
const vaultName = `tagscope-vs-${Date.now()}`;
|
|
1752
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1753
|
+
const store = getVaultStore(vaultName);
|
|
1754
|
+
// In-scope tag "work" declares a field the note violates; out-of-scope
|
|
1755
|
+
// "project-manhattan" declares a SECRET field/enum the note also violates.
|
|
1756
|
+
await store.upsertTagSchema("work", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
|
|
1757
|
+
await store.upsertTagSchema("project-manhattan", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
|
|
1758
|
+
const note = await store.createNote("co-tagged", {
|
|
1759
|
+
path: "CoTagged",
|
|
1760
|
+
tags: ["work", "project-manhattan"],
|
|
1761
|
+
metadata: { priority: "URGENT", codeword: "leaked" },
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1765
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1766
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
1767
|
+
|
|
1768
|
+
// The out-of-scope tag's SCHEMA SHAPE — the leak fix 3 introduced and
|
|
1769
|
+
// this scrub closes — must appear nowhere in the whole response.
|
|
1770
|
+
// `fizzbuzz` (its enum value) exists ONLY in project-manhattan's schema,
|
|
1771
|
+
// so its total absence proves no schema-shape leak. (`codeword` is also
|
|
1772
|
+
// a key in the note's OWN metadata, visible to anyone who can read the
|
|
1773
|
+
// note, so it's not a schema-shape indicator.)
|
|
1774
|
+
const serialized = JSON.stringify(result);
|
|
1775
|
+
expect(serialized).not.toContain("fizzbuzz");
|
|
1776
|
+
// The tag NAME must be gone from validation_status specifically. NOTE:
|
|
1777
|
+
// it still appears in the note's `.tags` array — that's PRE-EXISTING
|
|
1778
|
+
// scoped-read behavior (noteWithinTagScope has never scrubbed a returned
|
|
1779
|
+
// note's tag set), independent of fix 3, and out of scope for this fix.
|
|
1780
|
+
expect(JSON.stringify(result.validation_status)).not.toContain("project-manhattan");
|
|
1781
|
+
|
|
1782
|
+
// The in-scope tag's own warning DOES survive.
|
|
1783
|
+
const vs = result.validation_status;
|
|
1784
|
+
expect(vs).toBeTruthy();
|
|
1785
|
+
expect(vs.schemas).toEqual(["work"]);
|
|
1786
|
+
expect(vs.warnings).toHaveLength(1);
|
|
1787
|
+
expect(vs.warnings[0].schema).toBe("work");
|
|
1788
|
+
expect(vs.warnings[0].field).toBe("priority");
|
|
1789
|
+
|
|
1790
|
+
closeAllStores();
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test("UNSCOPED query-notes still surfaces BOTH co-tags' validation_status (regression)", async () => {
|
|
1794
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1795
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1796
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1797
|
+
|
|
1798
|
+
const vaultName = `tagscope-vs-unscoped-${Date.now()}`;
|
|
1799
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1800
|
+
const store = getVaultStore(vaultName);
|
|
1801
|
+
await store.upsertTagSchema("work", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
|
|
1802
|
+
await store.upsertTagSchema("project-manhattan", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
|
|
1803
|
+
const note = await store.createNote("co-tagged", {
|
|
1804
|
+
path: "CoTagged",
|
|
1805
|
+
tags: ["work", "project-manhattan"],
|
|
1806
|
+
metadata: { priority: "URGENT", codeword: "leaked" },
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
const tools = generateScopedMcpTools(vaultName); // unscoped
|
|
1810
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1811
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
1812
|
+
const vs = result.validation_status;
|
|
1813
|
+
expect(vs.schemas.sort()).toEqual(["project-manhattan", "work"]);
|
|
1814
|
+
expect(vs.warnings).toHaveLength(2);
|
|
1815
|
+
|
|
1816
|
+
closeAllStores();
|
|
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
|
+
});
|
|
1729
1967
|
});
|
|
1730
1968
|
|
|
1731
1969
|
describe("auth permissions", () => {
|
|
@@ -3899,6 +4137,111 @@ describe("HTTP tag-scope confidentiality (security review)", async () => {
|
|
|
3899
4137
|
expect(targets).toContain("NoSuchPersonal");
|
|
3900
4138
|
});
|
|
3901
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
|
+
|
|
3902
4245
|
});
|
|
3903
4246
|
|
|
3904
4247
|
describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
|
|
@@ -4185,6 +4528,34 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4185
4528
|
expect(body.tags).not.toContain("old");
|
|
4186
4529
|
});
|
|
4187
4530
|
|
|
4531
|
+
// vault#555 fix 2 — a tags-only (or links-only) PATCH with `force: true`
|
|
4532
|
+
// (no `if_updated_at`) used to leave `updated_at` frozen: `updates` had no
|
|
4533
|
+
// core fields, so `store.updateNote` was never called at all.
|
|
4534
|
+
test("PATCH tags-only/links-only with force:true bumps updated_at", async () => {
|
|
4535
|
+
await store.createNote("x", { id: "x", tags: ["old"] });
|
|
4536
|
+
await store.createNote("y", { id: "y" });
|
|
4537
|
+
const before = (await store.getNote("x"))!.updatedAt;
|
|
4538
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
4539
|
+
|
|
4540
|
+
const tagsRes = await handleNotes(
|
|
4541
|
+
mkReq("PATCH", "/notes/x", { tags: { add: ["new"] }, force: true }),
|
|
4542
|
+
store,
|
|
4543
|
+
"/x",
|
|
4544
|
+
);
|
|
4545
|
+
const tagsBody = await tagsRes.json() as any;
|
|
4546
|
+
expect(tagsBody.updatedAt).not.toBe(before);
|
|
4547
|
+
expect(new Date(tagsBody.updatedAt) > new Date(before)).toBe(true);
|
|
4548
|
+
|
|
4549
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
4550
|
+
const linksRes = await handleNotes(
|
|
4551
|
+
mkReq("PATCH", "/notes/x", { links: { add: [{ target: "y", relationship: "mentions" }] }, force: true }),
|
|
4552
|
+
store,
|
|
4553
|
+
"/x",
|
|
4554
|
+
);
|
|
4555
|
+
const linksBody = await linksRes.json() as any;
|
|
4556
|
+
expect(new Date(linksBody.updatedAt) > new Date(tagsBody.updatedAt)).toBe(true);
|
|
4557
|
+
});
|
|
4558
|
+
|
|
4188
4559
|
test("PATCH adds/removes links", async () => {
|
|
4189
4560
|
await store.createNote("a", { id: "a" });
|
|
4190
4561
|
await store.createNote("b", { id: "b" });
|
|
@@ -4248,6 +4619,59 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4248
4619
|
expect(body.links[0].targetId).toBe("c");
|
|
4249
4620
|
});
|
|
4250
4621
|
|
|
4622
|
+
// vault#555 — REST PATCH links.add gets the same basename/title
|
|
4623
|
+
// resolution + lazy forward-ref queueing as the MCP update-note tool
|
|
4624
|
+
// (both surfaces share core/wikilinks.ts's resolveOrQueueLink).
|
|
4625
|
+
test("PATCH links.add resolves by BASENAME and warns+queues when unresolvable", async () => {
|
|
4626
|
+
await store.createNote("a", { id: "a" });
|
|
4627
|
+
await store.createNote("Bob's note", { path: "People/Bob" });
|
|
4628
|
+
const byTitle = await handleNotes(
|
|
4629
|
+
mkReq("PATCH", "/notes/a", { links: { add: [{ target: "Bob", relationship: "knows" }] }, force: true }),
|
|
4630
|
+
store,
|
|
4631
|
+
"/a",
|
|
4632
|
+
);
|
|
4633
|
+
expect(byTitle.status).toBe(200);
|
|
4634
|
+
const byTitleBody = await byTitle.json() as any;
|
|
4635
|
+
expect(byTitleBody.warnings).toBeUndefined();
|
|
4636
|
+
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
|
|
4637
|
+
|
|
4638
|
+
const unresolved = await handleNotes(
|
|
4639
|
+
mkReq("PATCH", "/notes/a", { links: { add: [{ target: "Not Yet Real", relationship: "wants" }] }, force: true }),
|
|
4640
|
+
store,
|
|
4641
|
+
"/a",
|
|
4642
|
+
);
|
|
4643
|
+
const unresolvedBody = await unresolved.json() as any;
|
|
4644
|
+
expect(unresolvedBody.warnings).toBeDefined();
|
|
4645
|
+
expect(unresolvedBody.warnings[0].code).toBe("unresolved_link");
|
|
4646
|
+
expect(unresolvedBody.warnings[0].target).toBe("Not Yet Real");
|
|
4647
|
+
|
|
4648
|
+
const target = await store.createNote("arrived", { path: "Not Yet Real" });
|
|
4649
|
+
const links = await store.getLinks("a", { direction: "outbound" });
|
|
4650
|
+
expect(links.some((l) => l.targetId === target.id && l.relationship === "wants")).toBe(true);
|
|
4651
|
+
});
|
|
4652
|
+
|
|
4653
|
+
// vault#555 — POST /notes batch: a link to a note created LATER in the
|
|
4654
|
+
// same batch must resolve (forward-ref), matching MCP create-note.
|
|
4655
|
+
test("POST /notes batch: a link to a note created LATER in the same batch resolves (forward-ref)", async () => {
|
|
4656
|
+
const res = await handleNotes(
|
|
4657
|
+
mkReq("POST", "/notes", {
|
|
4658
|
+
notes: [
|
|
4659
|
+
{ path: "RestA", content: "links to RestB", links: [{ target: "RestB", relationship: "knows" }] },
|
|
4660
|
+
{ path: "RestB", content: "the target" },
|
|
4661
|
+
],
|
|
4662
|
+
}),
|
|
4663
|
+
store,
|
|
4664
|
+
"",
|
|
4665
|
+
);
|
|
4666
|
+
expect(res.status).toBe(201);
|
|
4667
|
+
const body = await res.json() as any[];
|
|
4668
|
+
const a = body.find((n: any) => n.path === "RestA");
|
|
4669
|
+
expect(a.warnings).toBeUndefined();
|
|
4670
|
+
const links = await store.getLinks(a.id, { direction: "outbound" });
|
|
4671
|
+
expect(links).toHaveLength(1);
|
|
4672
|
+
expect(links[0]!.relationship).toBe("knows");
|
|
4673
|
+
});
|
|
4674
|
+
|
|
4251
4675
|
test("PATCH without a link mutation or flag does NOT include links", async () => {
|
|
4252
4676
|
await store.createNote("a", { id: "a" });
|
|
4253
4677
|
await store.createNote("b", { id: "b" });
|
|
@@ -4436,6 +4860,42 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4436
4860
|
expect((await store.getNote("x"))!.content).toBe("hi hi");
|
|
4437
4861
|
});
|
|
4438
4862
|
|
|
4863
|
+
// vault#555 fix 7 (INVESTIGATE) — a tester reported "old_text not found"
|
|
4864
|
+
// failing 7/8 for batch/parallel content_edit calls sharing the same
|
|
4865
|
+
// old_text. Not reproducible against DIFFERENT notes (see
|
|
4866
|
+
// core/src/core.test.ts's matching investigation for the full writeup
|
|
4867
|
+
// and the "SAME note" control that DOES reproduce "1 succeeds, 7 fail" —
|
|
4868
|
+
// correct behavior, likely what the original report actually hit). REST
|
|
4869
|
+
// parity check here: N parallel PATCH requests, same old_text, different
|
|
4870
|
+
// notes, all succeed.
|
|
4871
|
+
test("PATCH parallel: N concurrent content_edit calls with the SAME old_text on DIFFERENT notes all succeed", async () => {
|
|
4872
|
+
const N = 8;
|
|
4873
|
+
const notes = [];
|
|
4874
|
+
for (let i = 0; i < N; i++) {
|
|
4875
|
+
notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `rest-ce-${i}` }));
|
|
4876
|
+
}
|
|
4877
|
+
|
|
4878
|
+
const results = await Promise.all(
|
|
4879
|
+
notes.map((n) =>
|
|
4880
|
+
handleNotes(
|
|
4881
|
+
mkReq("PATCH", `/notes/${n.id}`, {
|
|
4882
|
+
content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
|
|
4883
|
+
force: true,
|
|
4884
|
+
}),
|
|
4885
|
+
store,
|
|
4886
|
+
`/${n.id}`,
|
|
4887
|
+
),
|
|
4888
|
+
),
|
|
4889
|
+
);
|
|
4890
|
+
for (const res of results) {
|
|
4891
|
+
expect(res.status).toBe(200);
|
|
4892
|
+
}
|
|
4893
|
+
for (let i = 0; i < N; i++) {
|
|
4894
|
+
const fresh = await store.getNote(notes[i]!.id);
|
|
4895
|
+
expect(fresh!.content).toBe(`prefix REPLACED suffix-${i}`);
|
|
4896
|
+
}
|
|
4897
|
+
});
|
|
4898
|
+
|
|
4439
4899
|
test("PATCH rejects content + append combination with 400", async () => {
|
|
4440
4900
|
await store.createNote("seed", { id: "x" });
|
|
4441
4901
|
|
|
@@ -4618,6 +5078,87 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4618
5078
|
});
|
|
4619
5079
|
});
|
|
4620
5080
|
|
|
5081
|
+
// vault#555 fix 3 — validation_status used to be visible ONLY on the
|
|
5082
|
+
// one-time create/update WRITE response; a caller reading the note back
|
|
5083
|
+
// (GET single note, or the structured-query list) saw nothing at all,
|
|
5084
|
+
// contradicting "advisory violations surface as warnings" for an
|
|
5085
|
+
// enum_mismatch on a non-strict (even indexed) field.
|
|
5086
|
+
describe("HTTP GET /notes — validation_status on reads (vault#555)", async () => {
|
|
5087
|
+
test("GET /notes/:id surfaces validation_status (enum_mismatch on an indexed field)", async () => {
|
|
5088
|
+
const res0 = await handleTags(
|
|
5089
|
+
mkReq("PUT", "/tags/widget555", { fields: { status: { type: "string", enum: ["a", "b"], indexed: true } } }),
|
|
5090
|
+
store,
|
|
5091
|
+
"/widget555",
|
|
5092
|
+
);
|
|
5093
|
+
expect(res0.status).toBe(200);
|
|
5094
|
+
const created = await store.createNote("x", { tags: ["widget555"], metadata: { status: "bogus" } });
|
|
5095
|
+
|
|
5096
|
+
const res = await handleNotes(mkReq("GET", `/notes/${created.id}`), store, `/${created.id}`);
|
|
5097
|
+
expect(res.status).toBe(200);
|
|
5098
|
+
const body = await res.json() as any;
|
|
5099
|
+
expect(body.validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
|
|
5100
|
+
expect(body.validation_status.warnings[0].field).toBe("status");
|
|
5101
|
+
});
|
|
5102
|
+
|
|
5103
|
+
test("GET /notes (structured query list) surfaces validation_status per note", async () => {
|
|
5104
|
+
await store.upsertTagSchema("task555list", {
|
|
5105
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
5106
|
+
});
|
|
5107
|
+
await store.createNote("x", { tags: ["task555list"], metadata: { priority: "ULTRA" } });
|
|
5108
|
+
|
|
5109
|
+
const res = await handleNotes(mkReq("GET", "/notes?tag=task555list"), store, "");
|
|
5110
|
+
expect(res.status).toBe(200);
|
|
5111
|
+
const body = await res.json() as any[];
|
|
5112
|
+
expect(body).toHaveLength(1);
|
|
5113
|
+
expect(body[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
|
|
5114
|
+
});
|
|
5115
|
+
|
|
5116
|
+
test("GET /notes/:id and list both omit validation_status when no tag declares fields", async () => {
|
|
5117
|
+
const note = await store.createNote("plain", { tags: ["plain555"] });
|
|
5118
|
+
const single = await handleNotes(mkReq("GET", `/notes/${note.id}`), store, `/${note.id}`);
|
|
5119
|
+
const singleBody = await single.json() as any;
|
|
5120
|
+
expect(singleBody.validation_status).toBeUndefined();
|
|
5121
|
+
|
|
5122
|
+
const list = await handleNotes(mkReq("GET", "/notes?tag=plain555"), store, "");
|
|
5123
|
+
const listBody = await list.json() as any[];
|
|
5124
|
+
expect(listBody[0].validation_status).toBeUndefined();
|
|
5125
|
+
});
|
|
5126
|
+
|
|
5127
|
+
// vault#555 auth review — REST parity for the validation_status scope
|
|
5128
|
+
// scrub (the #560 leak class). A scoped caller reading a note co-tagged
|
|
5129
|
+
// with an out-of-scope tag must not learn that tag's schema shape.
|
|
5130
|
+
test("GET /notes/:id and list scrub out-of-scope co-tag schema from validation_status for a scoped caller", async () => {
|
|
5131
|
+
await store.upsertTagSchema("workrs", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
|
|
5132
|
+
await store.upsertTagSchema("manhattanrs", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
|
|
5133
|
+
const note = await store.createNote("co-tagged", {
|
|
5134
|
+
path: "CoTaggedRS",
|
|
5135
|
+
tags: ["workrs", "manhattanrs"],
|
|
5136
|
+
metadata: { priority: "URGENT", codeword: "leaked" },
|
|
5137
|
+
});
|
|
5138
|
+
const scope = { allowed: new Set(["workrs"]), raw: ["workrs"] };
|
|
5139
|
+
|
|
5140
|
+
// Single GET — `fizzbuzz` (the out-of-scope enum value) exists only in
|
|
5141
|
+
// manhattanrs's schema, so its absence proves no schema-shape leak. The
|
|
5142
|
+
// tag name is scrubbed from validation_status (still in `.tags` —
|
|
5143
|
+
// pre-existing, out of scope; see the MCP counterpart test).
|
|
5144
|
+
const single = await handleNotes(mkReq("GET", `/notes/${note.id}`), store, `/${note.id}`, undefined, scope);
|
|
5145
|
+
const singleBody = await single.json() as any;
|
|
5146
|
+
expect(JSON.stringify(singleBody)).not.toContain("fizzbuzz");
|
|
5147
|
+
expect(JSON.stringify(singleBody.validation_status)).not.toContain("manhattanrs");
|
|
5148
|
+
expect(singleBody.validation_status.schemas).toEqual(["workrs"]);
|
|
5149
|
+
expect(singleBody.validation_status.warnings).toHaveLength(1);
|
|
5150
|
+
expect(singleBody.validation_status.warnings[0].field).toBe("priority");
|
|
5151
|
+
|
|
5152
|
+
// List GET
|
|
5153
|
+
const list = await handleNotes(mkReq("GET", "/notes?tag=workrs&include_content=true"), store, "", undefined, scope);
|
|
5154
|
+
const listBody = await list.json() as any[];
|
|
5155
|
+
expect(JSON.stringify(listBody)).not.toContain("fizzbuzz");
|
|
5156
|
+
const target = listBody.find((n) => n.id === note.id);
|
|
5157
|
+
expect(JSON.stringify(target.validation_status)).not.toContain("manhattanrs");
|
|
5158
|
+
expect(target.validation_status.schemas).toEqual(["workrs"]);
|
|
5159
|
+
});
|
|
5160
|
+
});
|
|
5161
|
+
|
|
4621
5162
|
describe("HTTP POST /notes — validation_status attachment (vault#287)", async () => {
|
|
4622
5163
|
// Mirror of the PATCH cases for create. The MCP create-note path
|
|
4623
5164
|
// attaches validation_status; HTTP POST must match (vault#287).
|
|
@@ -4677,6 +5218,206 @@ describe("HTTP POST /notes — validation_status attachment (vault#287)", async
|
|
|
4677
5218
|
});
|
|
4678
5219
|
});
|
|
4679
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
|
+
|
|
4680
5421
|
// vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
|
|
4681
5422
|
// the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
|
|
4682
5423
|
// for idempotent upsert without a separate query-first round trip.
|