@openparachute/hub 0.7.5-rc.2 → 0.7.5-rc.4
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/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +310 -0
- package/src/__tests__/admin-surfaces.test.ts +207 -0
- package/src/__tests__/git-registry.test.ts +203 -0
- package/src/__tests__/git-transport.test.ts +181 -0
- package/src/__tests__/grants-store.test.ts +13 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/admin-agent-grants.ts +156 -6
- package/src/admin-surfaces.ts +158 -0
- package/src/git-registry.ts +247 -0
- package/src/git-transport.ts +57 -70
- package/src/grants-store.ts +25 -4
- package/src/hub-server.ts +31 -0
- package/src/scope-explanations.ts +16 -0
package/package.json
CHANGED
|
@@ -1658,3 +1658,313 @@ describe("revoke(mcp)", () => {
|
|
|
1658
1658
|
expect(readGrants(harness.storePath).find((r) => r.id === id)?.material).toBeUndefined();
|
|
1659
1659
|
});
|
|
1660
1660
|
});
|
|
1661
|
+
|
|
1662
|
+
// === surface grants (Surface Git Transport Phase 2, §6a) ===================
|
|
1663
|
+
|
|
1664
|
+
describe("surface grants (Phase 2)", () => {
|
|
1665
|
+
test("PUT: creates a pending surface grant (201) — no material, no self-grant", async () => {
|
|
1666
|
+
const bearer = await moduleBearer();
|
|
1667
|
+
const res = await dispatch(
|
|
1668
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1669
|
+
agent: "surfacer",
|
|
1670
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1671
|
+
}),
|
|
1672
|
+
);
|
|
1673
|
+
expect(res.status).toBe(201);
|
|
1674
|
+
const body = await json(res);
|
|
1675
|
+
// A note can only REQUEST — it lands pending with NO material until the
|
|
1676
|
+
// operator approves. This IS the "a note can never GRANT" invariant.
|
|
1677
|
+
expect(body.status).toBe("pending");
|
|
1678
|
+
expect(body).not.toHaveProperty("material");
|
|
1679
|
+
expect((body.connection as Record<string, unknown>).kind).toBe("surface");
|
|
1680
|
+
expect((body.connection as Record<string, unknown>).access).toBe("write");
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
test("PUT: a pending surface grant's /material 409s (never grantable pre-approval)", async () => {
|
|
1684
|
+
const bearer = await moduleBearer();
|
|
1685
|
+
const created = await json(
|
|
1686
|
+
await dispatch(
|
|
1687
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1688
|
+
agent: "surfacer",
|
|
1689
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1690
|
+
}),
|
|
1691
|
+
),
|
|
1692
|
+
);
|
|
1693
|
+
const res = await dispatch(bearerReq("GET", `/admin/grants/${created.id}/material`, bearer));
|
|
1694
|
+
expect(res.status).toBe(409);
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
test("PUT: rejects an invalid surface name (400)", async () => {
|
|
1698
|
+
const bearer = await moduleBearer();
|
|
1699
|
+
const res = await dispatch(
|
|
1700
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1701
|
+
agent: "surfacer",
|
|
1702
|
+
connection: { kind: "surface", target: "bad/name", access: "write" },
|
|
1703
|
+
}),
|
|
1704
|
+
);
|
|
1705
|
+
expect(res.status).toBe(400);
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
test("PUT: rejects a bad surface access verb (400)", async () => {
|
|
1709
|
+
const bearer = await moduleBearer();
|
|
1710
|
+
const res = await dispatch(
|
|
1711
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1712
|
+
agent: "surfacer",
|
|
1713
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "admin" },
|
|
1714
|
+
}),
|
|
1715
|
+
);
|
|
1716
|
+
expect(res.status).toBe(400);
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
test("approve: MINTS a registered surface:<name>:write token (operator-gated)", async () => {
|
|
1720
|
+
const bearer = await moduleBearer();
|
|
1721
|
+
const cookie = await operatorCookie();
|
|
1722
|
+
const created = await json(
|
|
1723
|
+
await dispatch(
|
|
1724
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1725
|
+
agent: "surfacer",
|
|
1726
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1727
|
+
}),
|
|
1728
|
+
),
|
|
1729
|
+
);
|
|
1730
|
+
const id = created.id as string;
|
|
1731
|
+
const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1732
|
+
expect(res.status).toBe(200);
|
|
1733
|
+
const body = await json(res);
|
|
1734
|
+
expect(body.status).toBe("approved");
|
|
1735
|
+
expect(body).not.toHaveProperty("material");
|
|
1736
|
+
expect(body.approvedAt).toBeDefined();
|
|
1737
|
+
|
|
1738
|
+
const stored = readGrants(harness.storePath).find((r) => r.id === id);
|
|
1739
|
+
const mat = stored?.material as { kind: string; token: string; jti: string };
|
|
1740
|
+
expect(mat.kind).toBe("surface");
|
|
1741
|
+
const claims = decodeJwt(mat.token) as Record<string, unknown>;
|
|
1742
|
+
expect(claims.scope).toBe("surface:gitcoin-brain:write");
|
|
1743
|
+
expect(claims.aud).toBe("surface.gitcoin-brain");
|
|
1744
|
+
// registered → revocable
|
|
1745
|
+
expect(findTokenRowByJti(harness.db, mat.jti)).not.toBeNull();
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
test("approve: is operator-only — a module Bearer cannot approve", async () => {
|
|
1749
|
+
const bearer = await moduleBearer();
|
|
1750
|
+
const created = await json(
|
|
1751
|
+
await dispatch(
|
|
1752
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1753
|
+
agent: "surfacer",
|
|
1754
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1755
|
+
}),
|
|
1756
|
+
),
|
|
1757
|
+
);
|
|
1758
|
+
const id = created.id as string;
|
|
1759
|
+
// A host-admin Bearer is module-auth, NOT the operator cookie the approve
|
|
1760
|
+
// path requires — so it 401s (a note/module can never self-approve).
|
|
1761
|
+
const res = await dispatch(bearerReq("POST", `/admin/grants/${id}/approve`, bearer));
|
|
1762
|
+
expect(res.status).toBe(401);
|
|
1763
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
test("approve: a non-first-admin operator cannot approve (403)", async () => {
|
|
1767
|
+
const bearer = await moduleBearer();
|
|
1768
|
+
const friend = await friendCookie();
|
|
1769
|
+
const created = await json(
|
|
1770
|
+
await dispatch(
|
|
1771
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1772
|
+
agent: "surfacer",
|
|
1773
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1774
|
+
}),
|
|
1775
|
+
),
|
|
1776
|
+
);
|
|
1777
|
+
const id = created.id as string;
|
|
1778
|
+
const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, friend));
|
|
1779
|
+
expect(res.status).toBe(403);
|
|
1780
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
test("material: an approved surface grant returns { kind:surface, token, remoteUrl }", async () => {
|
|
1784
|
+
const bearer = await moduleBearer();
|
|
1785
|
+
const cookie = await operatorCookie();
|
|
1786
|
+
const created = await json(
|
|
1787
|
+
await dispatch(
|
|
1788
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1789
|
+
agent: "surfacer",
|
|
1790
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1791
|
+
}),
|
|
1792
|
+
),
|
|
1793
|
+
);
|
|
1794
|
+
const id = created.id as string;
|
|
1795
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1796
|
+
const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
|
|
1797
|
+
expect(res.status).toBe(200);
|
|
1798
|
+
const mat = await json(res);
|
|
1799
|
+
expect(mat.kind).toBe("surface");
|
|
1800
|
+
expect(typeof mat.token).toBe("string");
|
|
1801
|
+
// The git remote the agent clones/pushes to — the git-transport endpoint.
|
|
1802
|
+
expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
|
|
1803
|
+
const claims = decodeJwt(mat.token as string) as Record<string, unknown>;
|
|
1804
|
+
expect(claims.scope).toBe("surface:gitcoin-brain:write");
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("a read-only surface grant mints surface:<name>:read", async () => {
|
|
1808
|
+
const bearer = await moduleBearer();
|
|
1809
|
+
const cookie = await operatorCookie();
|
|
1810
|
+
const created = await json(
|
|
1811
|
+
await dispatch(
|
|
1812
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1813
|
+
agent: "surfacer",
|
|
1814
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "read" },
|
|
1815
|
+
}),
|
|
1816
|
+
),
|
|
1817
|
+
);
|
|
1818
|
+
const id = created.id as string;
|
|
1819
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1820
|
+
const mat = await json(
|
|
1821
|
+
await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer)),
|
|
1822
|
+
);
|
|
1823
|
+
expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
|
|
1824
|
+
const claims = decodeJwt(mat.token as string) as Record<string, unknown>;
|
|
1825
|
+
expect(claims.scope).toBe("surface:gitcoin-brain:read");
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
test("a mixed-case surface name is canonicalized to lowercase (scope + remote + idempotency)", async () => {
|
|
1829
|
+
const bearer = await moduleBearer();
|
|
1830
|
+
const cookie = await operatorCookie();
|
|
1831
|
+
const created = await json(
|
|
1832
|
+
await dispatch(
|
|
1833
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1834
|
+
agent: "surfacer",
|
|
1835
|
+
connection: { kind: "surface", target: "GitCoin-Brain", access: "write" },
|
|
1836
|
+
}),
|
|
1837
|
+
),
|
|
1838
|
+
);
|
|
1839
|
+
// Stored target is normalized to lowercase, so the minted scope + the /material
|
|
1840
|
+
// remote match a lowercase-registered surface and the agent's echoed-target key
|
|
1841
|
+
// stays consistent (no mixed-case collapse mismatch).
|
|
1842
|
+
expect((created.connection as Record<string, unknown>).target).toBe("gitcoin-brain");
|
|
1843
|
+
// A second PUT with the lowercase twin is the SAME grant (idempotent), not a fork.
|
|
1844
|
+
const twin = await json(
|
|
1845
|
+
await dispatch(
|
|
1846
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1847
|
+
agent: "surfacer",
|
|
1848
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1849
|
+
}),
|
|
1850
|
+
),
|
|
1851
|
+
);
|
|
1852
|
+
expect(twin.id).toBe(created.id);
|
|
1853
|
+
expect(readGrants(harness.storePath).filter((r) => r.agent === "surfacer")).toHaveLength(1);
|
|
1854
|
+
// Approve → the minted scope + the /material remote are both lowercase.
|
|
1855
|
+
await dispatch(cookieReq("POST", `/admin/grants/${created.id as string}/approve`, cookie));
|
|
1856
|
+
const mat = await json(
|
|
1857
|
+
await dispatch(bearerReq("GET", `/admin/grants/${created.id as string}/material`, bearer)),
|
|
1858
|
+
);
|
|
1859
|
+
expect(mat.remoteUrl).toBe(`${HUB_ORIGIN}/git/gitcoin-brain`);
|
|
1860
|
+
expect((decodeJwt(mat.token as string) as Record<string, unknown>).scope).toBe(
|
|
1861
|
+
"surface:gitcoin-brain:write",
|
|
1862
|
+
);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
test("re-approval revokes the prior minted surface token", async () => {
|
|
1866
|
+
const bearer = await moduleBearer();
|
|
1867
|
+
const cookie = await operatorCookie();
|
|
1868
|
+
const created = await json(
|
|
1869
|
+
await dispatch(
|
|
1870
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1871
|
+
agent: "surfacer",
|
|
1872
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1873
|
+
}),
|
|
1874
|
+
),
|
|
1875
|
+
);
|
|
1876
|
+
const id = created.id as string;
|
|
1877
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1878
|
+
const firstJti = (
|
|
1879
|
+
readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
|
|
1880
|
+
).jti;
|
|
1881
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1882
|
+
const secondJti = (
|
|
1883
|
+
readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
|
|
1884
|
+
).jti;
|
|
1885
|
+
expect(secondJti).not.toBe(firstJti);
|
|
1886
|
+
expect(findTokenRowByJti(harness.db, firstJti)?.revokedAt).toBeTruthy();
|
|
1887
|
+
expect(findTokenRowByJti(harness.db, secondJti)?.revokedAt).toBeFalsy();
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
test("revoke: drops the material AND revokes the minted token in the registry", async () => {
|
|
1891
|
+
const bearer = await moduleBearer();
|
|
1892
|
+
const cookie = await operatorCookie();
|
|
1893
|
+
const created = await json(
|
|
1894
|
+
await dispatch(
|
|
1895
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1896
|
+
agent: "surfacer",
|
|
1897
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1898
|
+
}),
|
|
1899
|
+
),
|
|
1900
|
+
);
|
|
1901
|
+
const id = created.id as string;
|
|
1902
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1903
|
+
const jti = (
|
|
1904
|
+
readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
|
|
1905
|
+
).jti;
|
|
1906
|
+
const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
|
|
1907
|
+
expect(res.status).toBe(200);
|
|
1908
|
+
const stored = readGrants(harness.storePath).find((r) => r.id === id);
|
|
1909
|
+
expect(stored?.status).toBe("revoked");
|
|
1910
|
+
expect(stored?.material).toBeUndefined();
|
|
1911
|
+
expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeTruthy();
|
|
1912
|
+
// /material now 409s
|
|
1913
|
+
const mat = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
|
|
1914
|
+
expect(mat.status).toBe(409);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
test("reconcile prunes a surface grant that's no longer wanted", async () => {
|
|
1918
|
+
const bearer = await moduleBearer();
|
|
1919
|
+
const cookie = await operatorCookie();
|
|
1920
|
+
const created = await json(
|
|
1921
|
+
await dispatch(
|
|
1922
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1923
|
+
agent: "surfacer",
|
|
1924
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1925
|
+
}),
|
|
1926
|
+
),
|
|
1927
|
+
);
|
|
1928
|
+
const id = created.id as string;
|
|
1929
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
|
|
1930
|
+
const jti = (
|
|
1931
|
+
readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
|
|
1932
|
+
).jti;
|
|
1933
|
+
// reconcile with NO live connections → prune (tears down the token too)
|
|
1934
|
+
const res = await dispatch(
|
|
1935
|
+
bearerReq("POST", "/admin/grants/reconcile", bearer, {
|
|
1936
|
+
agent: "surfacer",
|
|
1937
|
+
liveConnections: [],
|
|
1938
|
+
}),
|
|
1939
|
+
);
|
|
1940
|
+
expect(res.status).toBe(200);
|
|
1941
|
+
const body = await json(res);
|
|
1942
|
+
expect(body.pruned).toBe(1);
|
|
1943
|
+
expect(getGrant(harness.storePath, id)).toBeNull();
|
|
1944
|
+
expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeTruthy();
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
test("reconcile KEEPS a surface grant that's still declared (spec-derived key match)", async () => {
|
|
1948
|
+
const bearer = await moduleBearer();
|
|
1949
|
+
const created = await json(
|
|
1950
|
+
await dispatch(
|
|
1951
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1952
|
+
agent: "surfacer",
|
|
1953
|
+
connection: { kind: "surface", target: "gitcoin-brain", access: "write" },
|
|
1954
|
+
}),
|
|
1955
|
+
),
|
|
1956
|
+
);
|
|
1957
|
+
const id = created.id as string;
|
|
1958
|
+
// The agent sends the SPEC (not a pre-computed key); the hub re-derives the
|
|
1959
|
+
// key with its OWN connectionKey → the still-wanted grant is kept.
|
|
1960
|
+
const res = await dispatch(
|
|
1961
|
+
bearerReq("POST", "/admin/grants/reconcile", bearer, {
|
|
1962
|
+
agent: "surfacer",
|
|
1963
|
+
liveConnections: [{ kind: "surface", target: "gitcoin-brain", access: "write" }],
|
|
1964
|
+
}),
|
|
1965
|
+
);
|
|
1966
|
+
expect(res.status).toBe(200);
|
|
1967
|
+
expect((await json(res)).pruned).toBe(0);
|
|
1968
|
+
expect(getGrant(harness.storePath, id)).not.toBeNull();
|
|
1969
|
+
});
|
|
1970
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { routeAdminSurfaces } from "../admin-surfaces.ts";
|
|
6
|
+
import { isSurfaceRegistered, repoDirFor } from "../git-registry.ts";
|
|
7
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
9
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
10
|
+
import { createUser } from "../users.ts";
|
|
11
|
+
|
|
12
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
13
|
+
|
|
14
|
+
interface Harness {
|
|
15
|
+
gitRoot: string;
|
|
16
|
+
db: ReturnType<typeof openHubDb>;
|
|
17
|
+
userId: string;
|
|
18
|
+
cleanup: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function makeHarness(): Promise<Harness> {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-adminsurf-"));
|
|
23
|
+
const db = openHubDb(hubDbPath(dir));
|
|
24
|
+
rotateSigningKey(db);
|
|
25
|
+
const u = await createUser(db, "owner", "pw");
|
|
26
|
+
return {
|
|
27
|
+
gitRoot: join(dir, "git"),
|
|
28
|
+
db,
|
|
29
|
+
userId: u.id,
|
|
30
|
+
cleanup: () => {
|
|
31
|
+
db.close();
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function mint(h: Harness, scopes: string[]): Promise<string> {
|
|
38
|
+
const { token } = await signAccessToken(h.db, {
|
|
39
|
+
sub: h.userId,
|
|
40
|
+
scopes,
|
|
41
|
+
audience: "operator",
|
|
42
|
+
clientId: "test-operator",
|
|
43
|
+
issuer: ISSUER,
|
|
44
|
+
});
|
|
45
|
+
return token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deps(h: Harness) {
|
|
49
|
+
return { db: h.db, gitRoot: h.gitRoot, issuer: ISSUER, knownIssuers: [ISSUER] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function req(method: string, headers?: Record<string, string>, body?: unknown): Request {
|
|
53
|
+
return new Request("http://127.0.0.1/admin/surfaces", {
|
|
54
|
+
method,
|
|
55
|
+
headers,
|
|
56
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("routeAdminSurfaces — routing", () => {
|
|
61
|
+
test("returns null for a non-/admin/surfaces path", async () => {
|
|
62
|
+
const h = await makeHarness();
|
|
63
|
+
try {
|
|
64
|
+
const res = await routeAdminSurfaces(new Request("http://127.0.0.1/admin/other"), deps(h));
|
|
65
|
+
expect(res).toBeNull();
|
|
66
|
+
} finally {
|
|
67
|
+
h.cleanup();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("405 on an unsupported method", async () => {
|
|
72
|
+
const h = await makeHarness();
|
|
73
|
+
try {
|
|
74
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
75
|
+
const res = await routeAdminSurfaces(
|
|
76
|
+
req("DELETE", { authorization: `Bearer ${token}` }),
|
|
77
|
+
deps(h),
|
|
78
|
+
);
|
|
79
|
+
expect(res?.status).toBe(405);
|
|
80
|
+
} finally {
|
|
81
|
+
h.cleanup();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("routeAdminSurfaces — auth", () => {
|
|
87
|
+
test("401 without a bearer", async () => {
|
|
88
|
+
const h = await makeHarness();
|
|
89
|
+
try {
|
|
90
|
+
const res = await routeAdminSurfaces(req("POST", {}, { name: "foo" }), deps(h));
|
|
91
|
+
expect(res?.status).toBe(401);
|
|
92
|
+
} finally {
|
|
93
|
+
h.cleanup();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("403 when the token lacks parachute:host:admin", async () => {
|
|
98
|
+
const h = await makeHarness();
|
|
99
|
+
try {
|
|
100
|
+
const token = await mint(h, ["surface:foo:write"]);
|
|
101
|
+
const res = await routeAdminSurfaces(
|
|
102
|
+
req("POST", { authorization: `Bearer ${token}` }, { name: "foo" }),
|
|
103
|
+
deps(h),
|
|
104
|
+
);
|
|
105
|
+
expect(res?.status).toBe(403);
|
|
106
|
+
// No provisioning happened on a rejected auth.
|
|
107
|
+
expect(existsSync(repoDirFor(h.gitRoot, "foo"))).toBe(false);
|
|
108
|
+
} finally {
|
|
109
|
+
h.cleanup();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("401 on a garbage token", async () => {
|
|
114
|
+
const h = await makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
const res = await routeAdminSurfaces(
|
|
117
|
+
req("POST", { authorization: "Bearer not-a-jwt" }, { name: "foo" }),
|
|
118
|
+
deps(h),
|
|
119
|
+
);
|
|
120
|
+
expect(res?.status).toBe(401);
|
|
121
|
+
} finally {
|
|
122
|
+
h.cleanup();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("routeAdminSurfaces — register + list", () => {
|
|
128
|
+
test("POST registers a surface (provisions the repo + returns the entry)", async () => {
|
|
129
|
+
const h = await makeHarness();
|
|
130
|
+
try {
|
|
131
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
132
|
+
const res = await routeAdminSurfaces(
|
|
133
|
+
req(
|
|
134
|
+
"POST",
|
|
135
|
+
{ authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
136
|
+
{ name: "brain", mount: "/surface/brain", mode: "prod" },
|
|
137
|
+
),
|
|
138
|
+
deps(h),
|
|
139
|
+
);
|
|
140
|
+
expect(res?.status).toBe(200);
|
|
141
|
+
const body = (await res?.json()) as {
|
|
142
|
+
ok: boolean;
|
|
143
|
+
surface: { name: string; mount?: string };
|
|
144
|
+
};
|
|
145
|
+
expect(body.ok).toBe(true);
|
|
146
|
+
expect(body.surface.name).toBe("brain");
|
|
147
|
+
expect(body.surface.mount).toBe("/surface/brain");
|
|
148
|
+
expect(isSurfaceRegistered(h.gitRoot, "brain")).toBe(true);
|
|
149
|
+
} finally {
|
|
150
|
+
h.cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("POST with a missing name → 400", async () => {
|
|
155
|
+
const h = await makeHarness();
|
|
156
|
+
try {
|
|
157
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
158
|
+
const res = await routeAdminSurfaces(
|
|
159
|
+
req("POST", { authorization: `Bearer ${token}` }, { mount: "/surface/x" }),
|
|
160
|
+
deps(h),
|
|
161
|
+
);
|
|
162
|
+
expect(res?.status).toBe(400);
|
|
163
|
+
} finally {
|
|
164
|
+
h.cleanup();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("POST with an invalid name → 400 (no repo provisioned)", async () => {
|
|
169
|
+
const h = await makeHarness();
|
|
170
|
+
try {
|
|
171
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
172
|
+
const res = await routeAdminSurfaces(
|
|
173
|
+
req("POST", { authorization: `Bearer ${token}` }, { name: "a/b" }),
|
|
174
|
+
deps(h),
|
|
175
|
+
);
|
|
176
|
+
expect(res?.status).toBe(400);
|
|
177
|
+
} finally {
|
|
178
|
+
h.cleanup();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("GET lists registered surfaces", async () => {
|
|
183
|
+
const h = await makeHarness();
|
|
184
|
+
try {
|
|
185
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
186
|
+
await routeAdminSurfaces(
|
|
187
|
+
req("POST", { authorization: `Bearer ${token}` }, { name: "alpha" }),
|
|
188
|
+
deps(h),
|
|
189
|
+
);
|
|
190
|
+
await routeAdminSurfaces(
|
|
191
|
+
req("POST", { authorization: `Bearer ${token}` }, { name: "zeta" }),
|
|
192
|
+
deps(h),
|
|
193
|
+
);
|
|
194
|
+
const res = await routeAdminSurfaces(
|
|
195
|
+
new Request("http://127.0.0.1/admin/surfaces", {
|
|
196
|
+
headers: { authorization: `Bearer ${token}` },
|
|
197
|
+
}),
|
|
198
|
+
deps(h),
|
|
199
|
+
);
|
|
200
|
+
expect(res?.status).toBe(200);
|
|
201
|
+
const body = (await res?.json()) as { surfaces: Array<{ name: string }> };
|
|
202
|
+
expect(body.surfaces.map((s) => s.name)).toEqual(["alpha", "zeta"]);
|
|
203
|
+
} finally {
|
|
204
|
+
h.cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|