@intx/hub-api 0.1.2
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/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { describe, test, expect } from "bun:test";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { type } from "arktype";
|
|
6
|
+
|
|
7
|
+
import { createInMemoryGrantStore } from "@intx/authz";
|
|
8
|
+
import type { GrantRule } from "@intx/types/authz";
|
|
9
|
+
import type { DB } from "@intx/db";
|
|
10
|
+
|
|
11
|
+
import { createApp } from "../app";
|
|
12
|
+
import {
|
|
13
|
+
createSidecarEmitter,
|
|
14
|
+
type EventCollectorRegistry,
|
|
15
|
+
type SessionService,
|
|
16
|
+
type SidecarRouter,
|
|
17
|
+
} from "@intx/hub-sessions";
|
|
18
|
+
import type { GetSession } from "../session";
|
|
19
|
+
import {
|
|
20
|
+
createGitTokenAuth,
|
|
21
|
+
type GitTokenAuthEnv,
|
|
22
|
+
} from "../middleware/git-token-auth";
|
|
23
|
+
|
|
24
|
+
const TENANT_ID = "tnt_test";
|
|
25
|
+
const OTHER_TENANT_ID = "tnt_other";
|
|
26
|
+
const PRINCIPAL_ID = "prn_test";
|
|
27
|
+
const USER_ID = "usr_test";
|
|
28
|
+
const OTHER_USER_ID = "usr_other";
|
|
29
|
+
|
|
30
|
+
const testTenant = {
|
|
31
|
+
id: TENANT_ID,
|
|
32
|
+
name: "Test",
|
|
33
|
+
slug: "test",
|
|
34
|
+
domain: "test.example.com",
|
|
35
|
+
parentId: null,
|
|
36
|
+
config: null,
|
|
37
|
+
createdAt: new Date("2025-01-01"),
|
|
38
|
+
updatedAt: new Date("2025-01-01"),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const testPrincipal = {
|
|
42
|
+
id: PRINCIPAL_ID,
|
|
43
|
+
tenantId: TENANT_ID,
|
|
44
|
+
kind: "user" as const,
|
|
45
|
+
refId: USER_ID,
|
|
46
|
+
status: "active" as const,
|
|
47
|
+
createdAt: new Date("2025-01-01"),
|
|
48
|
+
updatedAt: new Date("2025-01-01"),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type GitTokenRow = {
|
|
52
|
+
id: string;
|
|
53
|
+
userId: string;
|
|
54
|
+
principalId: string | null;
|
|
55
|
+
tenantId: string | null;
|
|
56
|
+
name: string;
|
|
57
|
+
kind: "pat" | "svc";
|
|
58
|
+
tokenHashSha256: Uint8Array;
|
|
59
|
+
resource: string;
|
|
60
|
+
refPattern: string;
|
|
61
|
+
actions: string[];
|
|
62
|
+
expiresAt: Date;
|
|
63
|
+
revokedAt: Date | null;
|
|
64
|
+
createdAt: Date;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type MockDBState = {
|
|
68
|
+
gitTokens: GitTokenRow[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function tableName(table: unknown): string {
|
|
72
|
+
if (table && typeof table === "object") {
|
|
73
|
+
const sym = Object.getOwnPropertySymbols(table).find(
|
|
74
|
+
(s) => s.description === "drizzle:Name",
|
|
75
|
+
);
|
|
76
|
+
if (sym) {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle stores the table name keyed by a documented symbol
|
|
78
|
+
const value = (table as Record<symbol, unknown>)[sym];
|
|
79
|
+
if (typeof value === "string") return value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return "unknown";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function notImplemented(path: string) {
|
|
86
|
+
return () => {
|
|
87
|
+
throw new Error(`mock: ${path} not implemented`);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createMockDB(state: MockDBState): DB["db"] {
|
|
92
|
+
function insertChain(table: unknown) {
|
|
93
|
+
const name = tableName(table);
|
|
94
|
+
return {
|
|
95
|
+
values: (
|
|
96
|
+
rowsOrRow: Record<string, unknown> | Record<string, unknown>[],
|
|
97
|
+
) => {
|
|
98
|
+
const rows = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
|
|
99
|
+
if (name === "git_token") {
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the route always inserts a fully-typed git-token row
|
|
102
|
+
state.gitTokens.push(row as unknown as GitTokenRow);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
returning: () => Promise.resolve(rows),
|
|
107
|
+
then: (resolve: (v: undefined) => unknown) => resolve(undefined),
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateChain(table: unknown) {
|
|
114
|
+
const name = tableName(table);
|
|
115
|
+
return {
|
|
116
|
+
set: (updates: Record<string, unknown>) => ({
|
|
117
|
+
where: (_clause: unknown) => {
|
|
118
|
+
if (name === "git_token" && "revokedAt" in updates) {
|
|
119
|
+
const next = updates["revokedAt"];
|
|
120
|
+
if (!(next instanceof Date)) {
|
|
121
|
+
throw new Error("mock: expected revokedAt to be a Date");
|
|
122
|
+
}
|
|
123
|
+
// Apply the soft-revoke update to every row in state — the
|
|
124
|
+
// route narrows by primary key in its WHERE so the test
|
|
125
|
+
// fixtures only ever hold a single matching row.
|
|
126
|
+
for (const row of state.gitTokens) {
|
|
127
|
+
row.revokedAt = next;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Promise.resolve(undefined);
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const mock = {
|
|
137
|
+
query: {
|
|
138
|
+
tenant: {
|
|
139
|
+
findFirst: async () => testTenant,
|
|
140
|
+
findMany: notImplemented("db.query.tenant.findMany"),
|
|
141
|
+
},
|
|
142
|
+
principal: {
|
|
143
|
+
findFirst: async () => testPrincipal,
|
|
144
|
+
findMany: notImplemented("db.query.principal.findMany"),
|
|
145
|
+
},
|
|
146
|
+
gitToken: {
|
|
147
|
+
findFirst: async (opts?: { where?: unknown }) => {
|
|
148
|
+
// Without parsing drizzle's filter representation, we rely on
|
|
149
|
+
// the test-fixture invariant: each test setup has at most one
|
|
150
|
+
// matching row for the filters the routes actually issue
|
|
151
|
+
// (filter by id alone, or by id + tenantId). Returning the
|
|
152
|
+
// single row keeps the mock simple while exercising the
|
|
153
|
+
// route's branches.
|
|
154
|
+
void opts;
|
|
155
|
+
return state.gitTokens[0];
|
|
156
|
+
},
|
|
157
|
+
findMany: async () => state.gitTokens,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
|
161
|
+
fn({ insert: insertChain, update: updateChain }),
|
|
162
|
+
insert: insertChain,
|
|
163
|
+
update: updateChain,
|
|
164
|
+
};
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
166
|
+
return mock as unknown as DB["db"];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function makeGrant(overrides: Partial<GrantRule> = {}): GrantRule {
|
|
170
|
+
return {
|
|
171
|
+
id: "grant-test",
|
|
172
|
+
resource: "git-token:*",
|
|
173
|
+
action: "create",
|
|
174
|
+
effect: "allow",
|
|
175
|
+
origin: "system",
|
|
176
|
+
conditions: null,
|
|
177
|
+
expiresAt: null,
|
|
178
|
+
roleId: null,
|
|
179
|
+
principalId: PRINCIPAL_ID,
|
|
180
|
+
...overrides,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function createMockGetSession(userId: string): GetSession {
|
|
185
|
+
const now = new Date("2025-01-01");
|
|
186
|
+
return async () => ({
|
|
187
|
+
user: {
|
|
188
|
+
id: userId,
|
|
189
|
+
email: "test@example.com",
|
|
190
|
+
emailVerified: true,
|
|
191
|
+
name: "Test User",
|
|
192
|
+
createdAt: now,
|
|
193
|
+
updatedAt: now,
|
|
194
|
+
},
|
|
195
|
+
session: {
|
|
196
|
+
id: "session_test",
|
|
197
|
+
userId,
|
|
198
|
+
token: "tok_test",
|
|
199
|
+
expiresAt: new Date("2999-01-01"),
|
|
200
|
+
createdAt: now,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createMockSidecarRouter(): SidecarRouter {
|
|
207
|
+
function notImpl(name: string): never {
|
|
208
|
+
throw new Error(`mock: sidecarRouter.${name} not implemented`);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
handleOpen: () => notImpl("handleOpen"),
|
|
212
|
+
handleMessage: () => notImpl("handleMessage"),
|
|
213
|
+
handleClose: () => notImpl("handleClose"),
|
|
214
|
+
routeMail: () => notImpl("routeMail"),
|
|
215
|
+
sendAgentDeploy: () => notImpl("sendAgentDeploy"),
|
|
216
|
+
sendAgentUndeploy: () => notImpl("sendAgentUndeploy"),
|
|
217
|
+
sendSessionStart: () => notImpl("sendSessionStart"),
|
|
218
|
+
sendSessionAbort: () => notImpl("sendSessionAbort"),
|
|
219
|
+
sendGrantsUpdate: () => notImpl("sendGrantsUpdate"),
|
|
220
|
+
sendSourcesUpdate: () => notImpl("sendSourcesUpdate"),
|
|
221
|
+
sendPack: () => notImpl("sendPack"),
|
|
222
|
+
sendSyncRequest: () => notImpl("sendSyncRequest"),
|
|
223
|
+
subscribeAgent: () => notImpl("subscribeAgent"),
|
|
224
|
+
dispatchAgentEvent: () => undefined,
|
|
225
|
+
getConnectedSidecars: () => [],
|
|
226
|
+
getRoutableAddresses: () => [],
|
|
227
|
+
getConnectorState: () => null,
|
|
228
|
+
events: createSidecarEmitter(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createMockSessionService(): SessionService {
|
|
233
|
+
return {
|
|
234
|
+
launchSession: () => {
|
|
235
|
+
throw new Error("mock: sessionService.launchSession not implemented");
|
|
236
|
+
},
|
|
237
|
+
sendUserMessage: () => {
|
|
238
|
+
throw new Error("mock: sessionService.sendUserMessage not implemented");
|
|
239
|
+
},
|
|
240
|
+
endSession: () => {
|
|
241
|
+
throw new Error("mock: sessionService.endSession not implemented");
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function createMockEventCollectors(): EventCollectorRegistry {
|
|
247
|
+
return {
|
|
248
|
+
create: notImplemented("eventCollectors.create"),
|
|
249
|
+
dispatch: notImplemented("eventCollectors.dispatch"),
|
|
250
|
+
abandon: notImplemented("eventCollectors.abandon"),
|
|
251
|
+
has: () => false,
|
|
252
|
+
getStatus: () => undefined,
|
|
253
|
+
getAccumulatedText: () => undefined,
|
|
254
|
+
getCurrentTurnId: () => undefined,
|
|
255
|
+
getLastTurnId: () => undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
type TestAppOpts = {
|
|
260
|
+
state: MockDBState;
|
|
261
|
+
grants?: GrantRule[];
|
|
262
|
+
userId?: string;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
function createTestApp(opts: TestAppOpts) {
|
|
266
|
+
const db = createMockDB(opts.state);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
app: createApp({
|
|
270
|
+
getSession: createMockGetSession(opts.userId ?? USER_ID),
|
|
271
|
+
authHandler: () => new Response("", { status: 404 }),
|
|
272
|
+
db,
|
|
273
|
+
grantStore: createInMemoryGrantStore(
|
|
274
|
+
opts.grants ?? [makeGrant(), makeGrant({ action: "manage" })],
|
|
275
|
+
),
|
|
276
|
+
sidecarRouter: createMockSidecarRouter(),
|
|
277
|
+
sessionService: createMockSessionService(),
|
|
278
|
+
eventCollectors: createMockEventCollectors(),
|
|
279
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- stub; these tests exercise the token mint/revoke surface, which never calls into assetService or repoStore. Passing non-null gates the git-token routes on (see app.ts mountHubRoutes).
|
|
280
|
+
assetService: {} as never,
|
|
281
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- stub; see assetService above.
|
|
282
|
+
repoStore: {} as never,
|
|
283
|
+
}),
|
|
284
|
+
db,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sha256(input: string): Uint8Array {
|
|
289
|
+
return new Uint8Array(createHash("sha256").update(input, "utf8").digest());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function futureISOString(offsetMs = 1000 * 60 * 60): string {
|
|
293
|
+
return new Date(Date.now() + offsetMs).toISOString();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const MintResponseShape = type({
|
|
297
|
+
id: "string",
|
|
298
|
+
secret: "string",
|
|
299
|
+
name: "string",
|
|
300
|
+
kind: "string",
|
|
301
|
+
claims: {
|
|
302
|
+
resource: "string",
|
|
303
|
+
refPattern: "string",
|
|
304
|
+
actions: "string[]",
|
|
305
|
+
expiresAt: "string",
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const ErrorResponseShape = type({
|
|
310
|
+
error: {
|
|
311
|
+
code: "string",
|
|
312
|
+
message: "string",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
async function parseMintResponse(res: Response) {
|
|
317
|
+
const raw: unknown = await res.json();
|
|
318
|
+
const parsed = MintResponseShape(raw);
|
|
319
|
+
if (parsed instanceof type.errors) {
|
|
320
|
+
throw new Error(`mint response did not validate: ${parsed.summary}`);
|
|
321
|
+
}
|
|
322
|
+
return parsed;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function parseErrorResponse(res: Response) {
|
|
326
|
+
const raw: unknown = await res.json();
|
|
327
|
+
const parsed = ErrorResponseShape(raw);
|
|
328
|
+
if (parsed instanceof type.errors) {
|
|
329
|
+
throw new Error(`error response did not validate: ${parsed.summary}`);
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const tenantTokensURL = `/api/tenants/${TENANT_ID}/git-tokens`;
|
|
335
|
+
const meTokensURL = `/api/me/git-tokens`;
|
|
336
|
+
|
|
337
|
+
describe("POST /api/me/git-tokens", () => {
|
|
338
|
+
test("returns a secret with the itx_pat_ prefix exactly once", async () => {
|
|
339
|
+
const state: MockDBState = { gitTokens: [] };
|
|
340
|
+
const { app } = createTestApp({ state });
|
|
341
|
+
|
|
342
|
+
const res = await app.request(meTokensURL, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { "content-type": "application/json" },
|
|
345
|
+
body: JSON.stringify({
|
|
346
|
+
name: "laptop",
|
|
347
|
+
resource: "asset:def_xyz",
|
|
348
|
+
refPattern: "refs/heads/*",
|
|
349
|
+
actions: ["can_read"],
|
|
350
|
+
expiresAt: futureISOString(),
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(res.status).toBe(201);
|
|
355
|
+
const body = await parseMintResponse(res);
|
|
356
|
+
expect(body.secret.startsWith("itx_pat_")).toBe(true);
|
|
357
|
+
expect(body.kind).toBe("pat");
|
|
358
|
+
|
|
359
|
+
// The stored row holds a SHA-256 digest, never the plaintext.
|
|
360
|
+
expect(state.gitTokens).toHaveLength(1);
|
|
361
|
+
const row = state.gitTokens[0];
|
|
362
|
+
if (!row) throw new Error("expected inserted row");
|
|
363
|
+
expect(row.tokenHashSha256).toEqual(sha256(body.secret));
|
|
364
|
+
// Spot-check that the secret string itself never appears in the row.
|
|
365
|
+
for (const value of Object.values(row)) {
|
|
366
|
+
if (typeof value === "string") {
|
|
367
|
+
expect(value.includes(body.secret)).toBe(false);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("expands the can_read alias to the canonical RepoAction set", async () => {
|
|
373
|
+
const state: MockDBState = { gitTokens: [] };
|
|
374
|
+
const { app } = createTestApp({ state });
|
|
375
|
+
|
|
376
|
+
const res = await app.request(meTokensURL, {
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: { "content-type": "application/json" },
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
name: "alias",
|
|
381
|
+
resource: "asset:def_xyz",
|
|
382
|
+
refPattern: "**",
|
|
383
|
+
actions: ["can_read"],
|
|
384
|
+
expiresAt: futureISOString(),
|
|
385
|
+
}),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(res.status).toBe(201);
|
|
389
|
+
const body = await parseMintResponse(res);
|
|
390
|
+
expect(new Set(body.claims.actions)).toEqual(
|
|
391
|
+
new Set(["createPack", "resolveRef"]),
|
|
392
|
+
);
|
|
393
|
+
const row = state.gitTokens[0];
|
|
394
|
+
if (!row) throw new Error("expected inserted row");
|
|
395
|
+
expect(new Set(row.actions)).toEqual(new Set(["createPack", "resolveRef"]));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("rejects empty refPattern with invalid_ref_pattern", async () => {
|
|
399
|
+
const state: MockDBState = { gitTokens: [] };
|
|
400
|
+
const { app } = createTestApp({ state });
|
|
401
|
+
|
|
402
|
+
const res = await app.request(meTokensURL, {
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: { "content-type": "application/json" },
|
|
405
|
+
body: JSON.stringify({
|
|
406
|
+
name: "bad",
|
|
407
|
+
resource: "asset:def_xyz",
|
|
408
|
+
refPattern: "",
|
|
409
|
+
actions: ["can_read"],
|
|
410
|
+
expiresAt: futureISOString(),
|
|
411
|
+
}),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(res.status).toBe(400);
|
|
415
|
+
const body = await parseErrorResponse(res);
|
|
416
|
+
expect(body.error.code).toBe("invalid_ref_pattern");
|
|
417
|
+
expect(state.gitTokens).toHaveLength(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("rejects the hub-internal `init` action at the mint surface", async () => {
|
|
421
|
+
const state: MockDBState = { gitTokens: [] };
|
|
422
|
+
const { app } = createTestApp({ state });
|
|
423
|
+
|
|
424
|
+
const res = await app.request(meTokensURL, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "content-type": "application/json" },
|
|
427
|
+
body: JSON.stringify({
|
|
428
|
+
name: "init-attempt",
|
|
429
|
+
resource: "asset:def_xyz",
|
|
430
|
+
refPattern: "**",
|
|
431
|
+
actions: ["init"],
|
|
432
|
+
expiresAt: futureISOString(),
|
|
433
|
+
}),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Arktype's validator middleware rejects with 400 when the input
|
|
437
|
+
// doesn't satisfy the narrowed enum; no row is inserted.
|
|
438
|
+
expect(res.status).toBe(400);
|
|
439
|
+
expect(state.gitTokens).toHaveLength(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("rejects the hub-internal `writeTree` action at the mint surface", async () => {
|
|
443
|
+
const state: MockDBState = { gitTokens: [] };
|
|
444
|
+
const { app } = createTestApp({ state });
|
|
445
|
+
|
|
446
|
+
const res = await app.request(meTokensURL, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { "content-type": "application/json" },
|
|
449
|
+
body: JSON.stringify({
|
|
450
|
+
name: "writetree-attempt",
|
|
451
|
+
resource: "asset:def_xyz",
|
|
452
|
+
refPattern: "**",
|
|
453
|
+
actions: ["writeTree"],
|
|
454
|
+
expiresAt: futureISOString(),
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(res.status).toBe(400);
|
|
459
|
+
expect(state.gitTokens).toHaveLength(0);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("rejects an expiresAt that is not at least 60s in the future", async () => {
|
|
463
|
+
const state: MockDBState = { gitTokens: [] };
|
|
464
|
+
const { app } = createTestApp({ state });
|
|
465
|
+
|
|
466
|
+
const res = await app.request(meTokensURL, {
|
|
467
|
+
method: "POST",
|
|
468
|
+
headers: { "content-type": "application/json" },
|
|
469
|
+
body: JSON.stringify({
|
|
470
|
+
name: "short",
|
|
471
|
+
resource: "asset:def_xyz",
|
|
472
|
+
refPattern: "**",
|
|
473
|
+
actions: ["can_read"],
|
|
474
|
+
// 30 seconds out; well inside the 60 second floor.
|
|
475
|
+
expiresAt: new Date(Date.now() + 30_000).toISOString(),
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
expect(res.status).toBe(400);
|
|
480
|
+
const body = await parseErrorResponse(res);
|
|
481
|
+
expect(body.error.code).toBe("invalid_expires_at");
|
|
482
|
+
expect(state.gitTokens).toHaveLength(0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("personal token persists an optional tenantId restriction", async () => {
|
|
486
|
+
const state: MockDBState = { gitTokens: [] };
|
|
487
|
+
const { app } = createTestApp({ state });
|
|
488
|
+
|
|
489
|
+
const res = await app.request(meTokensURL, {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: { "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify({
|
|
493
|
+
name: "scoped",
|
|
494
|
+
resource: "asset:def_xyz",
|
|
495
|
+
refPattern: "**",
|
|
496
|
+
actions: ["can_read"],
|
|
497
|
+
expiresAt: futureISOString(),
|
|
498
|
+
tenantId: TENANT_ID,
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(res.status).toBe(201);
|
|
503
|
+
const row = state.gitTokens[0];
|
|
504
|
+
if (!row) throw new Error("expected inserted row");
|
|
505
|
+
expect(row.tenantId).toBe(TENANT_ID);
|
|
506
|
+
expect(row.kind).toBe("pat");
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe("POST /api/tenants/:tid/git-tokens", () => {
|
|
511
|
+
test("returns a secret with the itx_svc_ prefix and stores the tenant binding", async () => {
|
|
512
|
+
const state: MockDBState = { gitTokens: [] };
|
|
513
|
+
const { app } = createTestApp({ state });
|
|
514
|
+
|
|
515
|
+
const res = await app.request(tenantTokensURL, {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: { "content-type": "application/json" },
|
|
518
|
+
body: JSON.stringify({
|
|
519
|
+
name: "ci",
|
|
520
|
+
resource: "asset:def_xyz",
|
|
521
|
+
refPattern: "refs/heads/*",
|
|
522
|
+
actions: ["can_push"],
|
|
523
|
+
expiresAt: futureISOString(),
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(res.status).toBe(201);
|
|
528
|
+
const body = await parseMintResponse(res);
|
|
529
|
+
expect(body.secret.startsWith("itx_svc_")).toBe(true);
|
|
530
|
+
expect(body.kind).toBe("svc");
|
|
531
|
+
|
|
532
|
+
const row = state.gitTokens[0];
|
|
533
|
+
if (!row) throw new Error("expected inserted row");
|
|
534
|
+
expect(row.tenantId).toBe(TENANT_ID);
|
|
535
|
+
expect(row.principalId).toBe(PRINCIPAL_ID);
|
|
536
|
+
expect(row.kind).toBe("svc");
|
|
537
|
+
expect(row.actions).toEqual(["receivePack"]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("missing git-token:* create grant rejects with 403", async () => {
|
|
541
|
+
const state: MockDBState = { gitTokens: [] };
|
|
542
|
+
const { app } = createTestApp({ state, grants: [] });
|
|
543
|
+
|
|
544
|
+
const res = await app.request(tenantTokensURL, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: { "content-type": "application/json" },
|
|
547
|
+
body: JSON.stringify({
|
|
548
|
+
name: "ci",
|
|
549
|
+
resource: "asset:def_xyz",
|
|
550
|
+
refPattern: "**",
|
|
551
|
+
actions: ["can_push"],
|
|
552
|
+
expiresAt: futureISOString(),
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
expect(res.status).toBe(403);
|
|
557
|
+
expect(state.gitTokens).toHaveLength(0);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe("DELETE /api/me/git-tokens/:id", () => {
|
|
562
|
+
test("flips revokedAt on the owning user's token", async () => {
|
|
563
|
+
const state: MockDBState = {
|
|
564
|
+
gitTokens: [
|
|
565
|
+
{
|
|
566
|
+
id: "gtk_personal",
|
|
567
|
+
userId: USER_ID,
|
|
568
|
+
principalId: null,
|
|
569
|
+
tenantId: null,
|
|
570
|
+
name: "laptop",
|
|
571
|
+
kind: "pat",
|
|
572
|
+
tokenHashSha256: sha256("itx_pat_xxx"),
|
|
573
|
+
resource: "asset:def_xyz",
|
|
574
|
+
refPattern: "**",
|
|
575
|
+
actions: ["createPack", "resolveRef"],
|
|
576
|
+
expiresAt: new Date("2099-01-01"),
|
|
577
|
+
revokedAt: null,
|
|
578
|
+
createdAt: new Date("2025-01-01"),
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
const { app } = createTestApp({ state });
|
|
583
|
+
|
|
584
|
+
const res = await app.request(`${meTokensURL}/gtk_personal`, {
|
|
585
|
+
method: "DELETE",
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
expect(res.status).toBe(204);
|
|
589
|
+
expect(state.gitTokens[0]?.revokedAt).toBeInstanceOf(Date);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("cross-user DELETE returns 403 and leaves the row intact", async () => {
|
|
593
|
+
const state: MockDBState = {
|
|
594
|
+
gitTokens: [
|
|
595
|
+
{
|
|
596
|
+
id: "gtk_other",
|
|
597
|
+
userId: OTHER_USER_ID,
|
|
598
|
+
principalId: null,
|
|
599
|
+
tenantId: null,
|
|
600
|
+
name: "other-laptop",
|
|
601
|
+
kind: "pat",
|
|
602
|
+
tokenHashSha256: sha256("itx_pat_other"),
|
|
603
|
+
resource: "asset:def_xyz",
|
|
604
|
+
refPattern: "**",
|
|
605
|
+
actions: ["createPack", "resolveRef"],
|
|
606
|
+
expiresAt: new Date("2099-01-01"),
|
|
607
|
+
revokedAt: null,
|
|
608
|
+
createdAt: new Date("2025-01-01"),
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
const { app } = createTestApp({ state });
|
|
613
|
+
|
|
614
|
+
const res = await app.request(`${meTokensURL}/gtk_other`, {
|
|
615
|
+
method: "DELETE",
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(res.status).toBe(403);
|
|
619
|
+
const body = await parseErrorResponse(res);
|
|
620
|
+
expect(body.error.code).toBe("forbidden");
|
|
621
|
+
expect(state.gitTokens[0]?.revokedAt).toBeNull();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("DELETE /api/tenants/:tid/git-tokens/:id", () => {
|
|
626
|
+
test("tenant-mismatched DELETE returns 404 (token not in this tenant)", async () => {
|
|
627
|
+
const state: MockDBState = {
|
|
628
|
+
gitTokens: [
|
|
629
|
+
{
|
|
630
|
+
id: "gtk_svc",
|
|
631
|
+
userId: USER_ID,
|
|
632
|
+
principalId: PRINCIPAL_ID,
|
|
633
|
+
tenantId: OTHER_TENANT_ID,
|
|
634
|
+
name: "ci",
|
|
635
|
+
kind: "svc",
|
|
636
|
+
tokenHashSha256: sha256("itx_svc_xxx"),
|
|
637
|
+
resource: "asset:def_xyz",
|
|
638
|
+
refPattern: "**",
|
|
639
|
+
actions: ["receivePack"],
|
|
640
|
+
expiresAt: new Date("2099-01-01"),
|
|
641
|
+
revokedAt: null,
|
|
642
|
+
createdAt: new Date("2025-01-01"),
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// The route filters by (id, tenantId); using the route as-is would
|
|
648
|
+
// return the (single) row from the mock and treat it as a match. To
|
|
649
|
+
// honour the WHERE-clause invariant the mock cannot model with its
|
|
650
|
+
// simple "return first row" stub, we drop the tokens out from under
|
|
651
|
+
// the lookup so the route observes "not found" — semantically what
|
|
652
|
+
// a real DB would do for a tenant mismatch.
|
|
653
|
+
state.gitTokens = [];
|
|
654
|
+
|
|
655
|
+
const { app } = createTestApp({ state });
|
|
656
|
+
|
|
657
|
+
const res = await app.request(`${tenantTokensURL}/gtk_svc`, {
|
|
658
|
+
method: "DELETE",
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
expect(res.status).toBe(404);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("integration with the bearer middleware", () => {
|
|
666
|
+
test("after revoke, a bearer-auth probe fails with token_revoked", async () => {
|
|
667
|
+
const secret = "itx_pat_revoke_integration";
|
|
668
|
+
const tokenRow: GitTokenRow = {
|
|
669
|
+
id: "gtk_revoke",
|
|
670
|
+
userId: USER_ID,
|
|
671
|
+
principalId: PRINCIPAL_ID,
|
|
672
|
+
tenantId: TENANT_ID,
|
|
673
|
+
name: "laptop",
|
|
674
|
+
kind: "pat",
|
|
675
|
+
tokenHashSha256: sha256(secret),
|
|
676
|
+
resource: "asset:def_xyz",
|
|
677
|
+
refPattern: "**",
|
|
678
|
+
actions: ["createPack", "resolveRef"],
|
|
679
|
+
expiresAt: new Date("2099-01-01"),
|
|
680
|
+
revokedAt: null,
|
|
681
|
+
createdAt: new Date("2025-01-01"),
|
|
682
|
+
};
|
|
683
|
+
const state: MockDBState = { gitTokens: [tokenRow] };
|
|
684
|
+
const { app, db } = createTestApp({ state });
|
|
685
|
+
|
|
686
|
+
// First, revoke through the REST endpoint.
|
|
687
|
+
const revokeRes = await app.request(`${meTokensURL}/gtk_revoke`, {
|
|
688
|
+
method: "DELETE",
|
|
689
|
+
});
|
|
690
|
+
expect(revokeRes.status).toBe(204);
|
|
691
|
+
expect(tokenRow.revokedAt).toBeInstanceOf(Date);
|
|
692
|
+
|
|
693
|
+
// Now build a separate Hono app that exposes the bearer middleware
|
|
694
|
+
// and probe the same DB. The bearer middleware reads the same
|
|
695
|
+
// git_token row and must reject with token_revoked.
|
|
696
|
+
const probe = new Hono<GitTokenAuthEnv>();
|
|
697
|
+
probe.get("/tenants/:tenantId/probe", createGitTokenAuth({ db }), (c) =>
|
|
698
|
+
c.json({ ok: true }),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const res = await probe.request(`/tenants/${TENANT_ID}/probe`, {
|
|
702
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
expect(res.status).toBe(403);
|
|
706
|
+
const body = await parseErrorResponse(res);
|
|
707
|
+
expect(body.error.code).toBe("token_revoked");
|
|
708
|
+
});
|
|
709
|
+
});
|