@openparachute/hub 0.7.5-rc.1 → 0.7.5-rc.3
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 +10 -3
- package/src/__tests__/admin-surfaces.test.ts +207 -0
- package/src/__tests__/git-notify.test.ts +144 -0
- package/src/__tests__/git-registry.test.ts +203 -0
- package/src/__tests__/git-transport.test.ts +229 -6
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/admin-surfaces.ts +158 -0
- package/src/git-notify.ts +176 -0
- package/src/git-registry.ts +234 -0
- package/src/git-transport.ts +99 -70
- package/src/hub-server.ts +48 -1
- package/src/scope-explanations.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.5-rc.
|
|
3
|
+
"version": "0.7.5-rc.3",
|
|
4
4
|
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
"bin": {
|
|
12
12
|
"parachute": "src/cli.ts"
|
|
13
13
|
},
|
|
14
|
-
"workspaces": [
|
|
15
|
-
|
|
14
|
+
"workspaces": [
|
|
15
|
+
"packages/*"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"web/ui/dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
16
23
|
"repository": {
|
|
17
24
|
"type": "git",
|
|
18
25
|
"url": "https://github.com/ParachuteComputer/parachute-hub.git"
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { notifySurfacePushed } from "../git-notify.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
8
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
9
|
+
|
|
10
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
11
|
+
|
|
12
|
+
interface Harness {
|
|
13
|
+
db: ReturnType<typeof openHubDb>;
|
|
14
|
+
cleanup: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeHarness(): Harness {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-notify-"));
|
|
19
|
+
const db = openHubDb(hubDbPath(dir));
|
|
20
|
+
rotateSigningKey(db);
|
|
21
|
+
return {
|
|
22
|
+
db,
|
|
23
|
+
cleanup: () => {
|
|
24
|
+
db.close();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A fetch spy that records the last call and returns a canned response. */
|
|
31
|
+
function fetchSpy(status = 200, body = '{"ok":true}') {
|
|
32
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
33
|
+
const impl = ((url: string | URL | Request, init?: RequestInit) => {
|
|
34
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
35
|
+
return Promise.resolve(new Response(body, { status }));
|
|
36
|
+
}) as unknown as typeof fetch;
|
|
37
|
+
return { impl, calls };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("notifySurfacePushed", () => {
|
|
41
|
+
test("no surface module installed → no-op, no fetch", async () => {
|
|
42
|
+
const h = makeHarness();
|
|
43
|
+
const spy = fetchSpy();
|
|
44
|
+
try {
|
|
45
|
+
const out = await notifySurfacePushed("brain", {
|
|
46
|
+
db: h.db,
|
|
47
|
+
issuer: ISSUER,
|
|
48
|
+
resolveModuleOrigin: () => null,
|
|
49
|
+
cloneBaseOrigin: ISSUER,
|
|
50
|
+
fetchImpl: spy.impl,
|
|
51
|
+
log: { warn() {}, info() {} },
|
|
52
|
+
});
|
|
53
|
+
expect(out.notified).toBe(false);
|
|
54
|
+
expect(out.reason).toBe("surface-module-not-installed");
|
|
55
|
+
expect(spy.calls.length).toBe(0);
|
|
56
|
+
} finally {
|
|
57
|
+
h.cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("posts to /surface/api/git-pushed with a surface:admin bearer + surface:<name>:read pull token", async () => {
|
|
62
|
+
const h = makeHarness();
|
|
63
|
+
const spy = fetchSpy();
|
|
64
|
+
try {
|
|
65
|
+
const out = await notifySurfacePushed("brain", {
|
|
66
|
+
db: h.db,
|
|
67
|
+
issuer: ISSUER,
|
|
68
|
+
resolveModuleOrigin: (short) => (short === "surface" ? "http://127.0.0.1:1946" : null),
|
|
69
|
+
cloneBaseOrigin: ISSUER,
|
|
70
|
+
fetchImpl: spy.impl,
|
|
71
|
+
log: { warn() {}, info() {} },
|
|
72
|
+
});
|
|
73
|
+
expect(out.notified).toBe(true);
|
|
74
|
+
expect(spy.calls.length).toBe(1);
|
|
75
|
+
|
|
76
|
+
const call = spy.calls[0]!;
|
|
77
|
+
expect(call.url).toBe("http://127.0.0.1:1946/surface/api/git-pushed");
|
|
78
|
+
expect(call.init.method).toBe("POST");
|
|
79
|
+
|
|
80
|
+
const headers = new Headers(call.init.headers as Record<string, string>);
|
|
81
|
+
const auth = headers.get("authorization") ?? "";
|
|
82
|
+
expect(auth.startsWith("Bearer ")).toBe(true);
|
|
83
|
+
|
|
84
|
+
// notify-auth bearer validates as surface:admin, aud "surface".
|
|
85
|
+
const notifyTok = auth.slice("Bearer ".length);
|
|
86
|
+
const notifyClaims = await validateAccessToken(h.db, notifyTok, [ISSUER]);
|
|
87
|
+
expect((notifyClaims.payload as { scope?: string }).scope).toBe("surface:admin");
|
|
88
|
+
expect(notifyClaims.payload.aud).toBe("surface");
|
|
89
|
+
|
|
90
|
+
// Body carries the surface name, a loopback clone_url, and a pull token
|
|
91
|
+
// scoped to exactly surface:brain:read.
|
|
92
|
+
const body = JSON.parse(String(call.init.body)) as {
|
|
93
|
+
surface: string;
|
|
94
|
+
clone_url: string;
|
|
95
|
+
pull_token: string;
|
|
96
|
+
};
|
|
97
|
+
expect(body.surface).toBe("brain");
|
|
98
|
+
expect(body.clone_url).toBe("http://127.0.0.1:1939/git/brain");
|
|
99
|
+
const pullClaims = await validateAccessToken(h.db, body.pull_token, [ISSUER]);
|
|
100
|
+
expect((pullClaims.payload as { scope?: string }).scope).toBe("surface:brain:read");
|
|
101
|
+
expect(pullClaims.payload.aud).toBe("surface");
|
|
102
|
+
} finally {
|
|
103
|
+
h.cleanup();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("surface-host rejection is reported, never thrown", async () => {
|
|
108
|
+
const h = makeHarness();
|
|
109
|
+
const spy = fetchSpy(403, "forbidden");
|
|
110
|
+
try {
|
|
111
|
+
const out = await notifySurfacePushed("brain", {
|
|
112
|
+
db: h.db,
|
|
113
|
+
issuer: ISSUER,
|
|
114
|
+
resolveModuleOrigin: () => "http://127.0.0.1:1946",
|
|
115
|
+
cloneBaseOrigin: ISSUER,
|
|
116
|
+
fetchImpl: spy.impl,
|
|
117
|
+
log: { warn() {}, info() {} },
|
|
118
|
+
});
|
|
119
|
+
expect(out.notified).toBe(false);
|
|
120
|
+
expect(out.reason).toBe("notify-rejected:403");
|
|
121
|
+
} finally {
|
|
122
|
+
h.cleanup();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a fetch throw is swallowed (best-effort)", async () => {
|
|
127
|
+
const h = makeHarness();
|
|
128
|
+
const throwing = (() => Promise.reject(new Error("econnrefused"))) as unknown as typeof fetch;
|
|
129
|
+
try {
|
|
130
|
+
const out = await notifySurfacePushed("brain", {
|
|
131
|
+
db: h.db,
|
|
132
|
+
issuer: ISSUER,
|
|
133
|
+
resolveModuleOrigin: () => "http://127.0.0.1:1946",
|
|
134
|
+
cloneBaseOrigin: ISSUER,
|
|
135
|
+
fetchImpl: throwing,
|
|
136
|
+
log: { warn() {}, info() {} },
|
|
137
|
+
});
|
|
138
|
+
expect(out.notified).toBe(false);
|
|
139
|
+
expect(out.reason).toBe("notify-error");
|
|
140
|
+
} finally {
|
|
141
|
+
h.cleanup();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
ensureSurfaceRepo,
|
|
8
|
+
isSurfaceRegistered,
|
|
9
|
+
listSurfaces,
|
|
10
|
+
loadRegistry,
|
|
11
|
+
registerSurface,
|
|
12
|
+
registryPath,
|
|
13
|
+
repoDirFor,
|
|
14
|
+
} from "../git-registry.ts";
|
|
15
|
+
|
|
16
|
+
function tmpGitRoot(): { gitRoot: string; cleanup: () => void } {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-registry-"));
|
|
18
|
+
const gitRoot = join(dir, "git");
|
|
19
|
+
return { gitRoot, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("loadRegistry", () => {
|
|
23
|
+
test("missing file → empty registry", () => {
|
|
24
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
25
|
+
try {
|
|
26
|
+
expect(loadRegistry(gitRoot)).toEqual({ version: 1, surfaces: {} });
|
|
27
|
+
} finally {
|
|
28
|
+
cleanup();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("corrupt JSON → empty registry (never throws)", () => {
|
|
33
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(gitRoot, { recursive: true });
|
|
36
|
+
writeFileSync(registryPath(gitRoot), "{ not json");
|
|
37
|
+
expect(loadRegistry(gitRoot)).toEqual({ version: 1, surfaces: {} });
|
|
38
|
+
} finally {
|
|
39
|
+
cleanup();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("ensureSurfaceRepo", () => {
|
|
45
|
+
test("provisions a bare repo with http.receivepack=true + post-receive hook", async () => {
|
|
46
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
47
|
+
try {
|
|
48
|
+
const repoDir = await ensureSurfaceRepo(gitRoot, "foo");
|
|
49
|
+
expect(repoDir).toBe(repoDirFor(gitRoot, "foo"));
|
|
50
|
+
expect(existsSync(repoDir)).toBe(true);
|
|
51
|
+
expect(existsSync(join(repoDir, "hooks", "post-receive"))).toBe(true);
|
|
52
|
+
const rp = spawnSync("git", ["-C", repoDir, "config", "http.receivepack"], {
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
});
|
|
55
|
+
expect(rp.stdout.trim()).toBe("true");
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("idempotent — a second call is a no-op that returns the same path", async () => {
|
|
62
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
63
|
+
try {
|
|
64
|
+
const a = await ensureSurfaceRepo(gitRoot, "foo");
|
|
65
|
+
const b = await ensureSurfaceRepo(gitRoot, "foo");
|
|
66
|
+
expect(a).toBe(b);
|
|
67
|
+
} finally {
|
|
68
|
+
cleanup();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects an invalid surface name", async () => {
|
|
73
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
74
|
+
try {
|
|
75
|
+
await expect(ensureSurfaceRepo(gitRoot, "../evil")).rejects.toThrow();
|
|
76
|
+
} finally {
|
|
77
|
+
cleanup();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("registerSurface", () => {
|
|
83
|
+
test("provisions the repo + records the entry", async () => {
|
|
84
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
85
|
+
try {
|
|
86
|
+
const entry = await registerSurface(gitRoot, "brain", {
|
|
87
|
+
mount: "/surface/brain",
|
|
88
|
+
mode: "prod",
|
|
89
|
+
});
|
|
90
|
+
expect(entry.name).toBe("brain");
|
|
91
|
+
expect(entry.mount).toBe("/surface/brain");
|
|
92
|
+
expect(entry.mode).toBe("prod");
|
|
93
|
+
expect(existsSync(repoDirFor(gitRoot, "brain"))).toBe(true);
|
|
94
|
+
expect(loadRegistry(gitRoot).surfaces.brain?.name).toBe("brain");
|
|
95
|
+
} finally {
|
|
96
|
+
cleanup();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("idempotent re-register preserves the original registeredAt", async () => {
|
|
101
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
102
|
+
try {
|
|
103
|
+
const first = await registerSurface(gitRoot, "brain", {
|
|
104
|
+
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
105
|
+
});
|
|
106
|
+
const second = await registerSurface(gitRoot, "brain", {
|
|
107
|
+
mount: "/surface/brain",
|
|
108
|
+
now: () => new Date("2026-02-02T00:00:00Z"),
|
|
109
|
+
});
|
|
110
|
+
expect(second.registeredAt).toBe(first.registeredAt);
|
|
111
|
+
expect(second.registeredAt).toBe("2026-01-01T00:00:00.000Z");
|
|
112
|
+
// Later metadata still applies.
|
|
113
|
+
expect(second.mount).toBe("/surface/brain");
|
|
114
|
+
} finally {
|
|
115
|
+
cleanup();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects an invalid name without provisioning", async () => {
|
|
120
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
121
|
+
try {
|
|
122
|
+
await expect(registerSurface(gitRoot, "a/b")).rejects.toThrow(/invalid surface name/);
|
|
123
|
+
expect(existsSync(registryPath(gitRoot))).toBe(false);
|
|
124
|
+
} finally {
|
|
125
|
+
cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("isSurfaceRegistered", () => {
|
|
131
|
+
test("false for an unknown name, true after register", async () => {
|
|
132
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
133
|
+
try {
|
|
134
|
+
expect(isSurfaceRegistered(gitRoot, "foo")).toBe(false);
|
|
135
|
+
await registerSurface(gitRoot, "foo");
|
|
136
|
+
expect(isSurfaceRegistered(gitRoot, "foo")).toBe(true);
|
|
137
|
+
} finally {
|
|
138
|
+
cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("grandfathers a bare repo that exists on disk without a registry entry", async () => {
|
|
143
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
144
|
+
try {
|
|
145
|
+
await ensureSurfaceRepo(gitRoot, "legacy");
|
|
146
|
+
// No registry.json entry, but the repo exists → still registered.
|
|
147
|
+
expect(existsSync(registryPath(gitRoot))).toBe(false);
|
|
148
|
+
expect(isSurfaceRegistered(gitRoot, "legacy")).toBe(true);
|
|
149
|
+
} finally {
|
|
150
|
+
cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("false for a name that fails the charset (never touches the filesystem)", () => {
|
|
155
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
156
|
+
try {
|
|
157
|
+
expect(isSurfaceRegistered(gitRoot, "../etc")).toBe(false);
|
|
158
|
+
} finally {
|
|
159
|
+
cleanup();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("listSurfaces", () => {
|
|
165
|
+
test("returns entries sorted by name", async () => {
|
|
166
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
167
|
+
try {
|
|
168
|
+
await registerSurface(gitRoot, "zeta");
|
|
169
|
+
await registerSurface(gitRoot, "alpha");
|
|
170
|
+
await registerSurface(gitRoot, "mid");
|
|
171
|
+
expect(listSurfaces(gitRoot).map((s) => s.name)).toEqual(["alpha", "mid", "zeta"]);
|
|
172
|
+
} finally {
|
|
173
|
+
cleanup();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("excludes a grandfathered disk-only repo (registry entries only)", async () => {
|
|
178
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
179
|
+
try {
|
|
180
|
+
await registerSurface(gitRoot, "registered");
|
|
181
|
+
await ensureSurfaceRepo(gitRoot, "grandfathered"); // repo exists, no entry
|
|
182
|
+
// isSurfaceRegistered grandfathers it (pushable), but listSurfaces does not.
|
|
183
|
+
expect(isSurfaceRegistered(gitRoot, "grandfathered")).toBe(true);
|
|
184
|
+
expect(listSurfaces(gitRoot).map((s) => s.name)).toEqual(["registered"]);
|
|
185
|
+
} finally {
|
|
186
|
+
cleanup();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("saveRegistry atomicity", () => {
|
|
192
|
+
test("registry.json is written pretty + reloadable", async () => {
|
|
193
|
+
const { gitRoot, cleanup } = tmpGitRoot();
|
|
194
|
+
try {
|
|
195
|
+
await registerSurface(gitRoot, "foo");
|
|
196
|
+
const raw = readFileSync(registryPath(gitRoot), "utf8");
|
|
197
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
198
|
+
expect(JSON.parse(raw).surfaces.foo.name).toBe("foo");
|
|
199
|
+
} finally {
|
|
200
|
+
cleanup();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|