@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,587 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
afterAll,
|
|
5
|
+
beforeAll,
|
|
6
|
+
beforeEach,
|
|
7
|
+
describe,
|
|
8
|
+
expect,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
|
|
13
|
+
import type { DB } from "@intx/db";
|
|
14
|
+
import { configureSync, getConfig, resetSync } from "@intx/log";
|
|
15
|
+
|
|
16
|
+
import { createGitTokenAuth, type GitTokenAuthEnv } from "./git-token-auth";
|
|
17
|
+
|
|
18
|
+
type GitTokenRow = {
|
|
19
|
+
id: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
principalId: string | null;
|
|
22
|
+
tenantId: string | null;
|
|
23
|
+
name: string;
|
|
24
|
+
kind: string;
|
|
25
|
+
tokenHashSha256: Uint8Array;
|
|
26
|
+
resource: string;
|
|
27
|
+
refPattern: string;
|
|
28
|
+
actions: string[];
|
|
29
|
+
expiresAt: Date;
|
|
30
|
+
revokedAt: Date | null;
|
|
31
|
+
createdAt: Date;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TenantRow = {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
domain: string;
|
|
39
|
+
parentId: string | null;
|
|
40
|
+
config: unknown;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
updatedAt: Date;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type PrincipalRow = {
|
|
46
|
+
id: string;
|
|
47
|
+
tenantId: string;
|
|
48
|
+
kind: "user" | "agent";
|
|
49
|
+
refId: string;
|
|
50
|
+
status: "active" | "suspended" | "invited" | "deactivated";
|
|
51
|
+
createdAt: Date;
|
|
52
|
+
updatedAt: Date;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type MockDBOpts = {
|
|
56
|
+
gitToken?: GitTokenRow | null;
|
|
57
|
+
tenant?: TenantRow | null;
|
|
58
|
+
principal?: PrincipalRow | null;
|
|
59
|
+
principalByRef?: PrincipalRow | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function notImplemented(path: string) {
|
|
63
|
+
return () => {
|
|
64
|
+
throw new Error(`mock: ${path} not implemented`);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createMockDB(opts: MockDBOpts): DB["db"] {
|
|
69
|
+
const mock = {
|
|
70
|
+
query: {
|
|
71
|
+
gitToken: {
|
|
72
|
+
findFirst: async () =>
|
|
73
|
+
opts.gitToken !== null && opts.gitToken !== undefined
|
|
74
|
+
? opts.gitToken
|
|
75
|
+
: undefined,
|
|
76
|
+
findMany: notImplemented("db.query.gitToken.findMany"),
|
|
77
|
+
},
|
|
78
|
+
tenant: {
|
|
79
|
+
findFirst: async () =>
|
|
80
|
+
opts.tenant !== null && opts.tenant !== undefined
|
|
81
|
+
? opts.tenant
|
|
82
|
+
: undefined,
|
|
83
|
+
findMany: notImplemented("db.query.tenant.findMany"),
|
|
84
|
+
},
|
|
85
|
+
principal: {
|
|
86
|
+
findFirst: async () => {
|
|
87
|
+
if (
|
|
88
|
+
opts.gitToken !== null &&
|
|
89
|
+
opts.gitToken !== undefined &&
|
|
90
|
+
opts.gitToken.principalId !== null
|
|
91
|
+
) {
|
|
92
|
+
return opts.principal !== null && opts.principal !== undefined
|
|
93
|
+
? opts.principal
|
|
94
|
+
: undefined;
|
|
95
|
+
}
|
|
96
|
+
if (opts.principalByRef !== null && opts.principalByRef !== undefined)
|
|
97
|
+
return opts.principalByRef;
|
|
98
|
+
if (opts.principal !== null && opts.principal !== undefined)
|
|
99
|
+
return opts.principal;
|
|
100
|
+
return undefined;
|
|
101
|
+
},
|
|
102
|
+
findMany: notImplemented("db.query.principal.findMany"),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
107
|
+
return mock as unknown as DB["db"];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sha256(input: string): Uint8Array {
|
|
111
|
+
return new Uint8Array(createHash("sha256").update(input, "utf8").digest());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const NOW = new Date("2025-01-15T00:00:00Z");
|
|
115
|
+
const FUTURE = new Date("2099-01-01T00:00:00Z");
|
|
116
|
+
const PAST = new Date("2024-01-01T00:00:00Z");
|
|
117
|
+
|
|
118
|
+
function makeTenant(id: string): TenantRow {
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
name: `Tenant ${id}`,
|
|
122
|
+
slug: id,
|
|
123
|
+
domain: `${id}.example.com`,
|
|
124
|
+
parentId: null,
|
|
125
|
+
config: null,
|
|
126
|
+
createdAt: NOW,
|
|
127
|
+
updatedAt: NOW,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makePrincipal(
|
|
132
|
+
id: string,
|
|
133
|
+
tenantId: string,
|
|
134
|
+
status: PrincipalRow["status"] = "active",
|
|
135
|
+
): PrincipalRow {
|
|
136
|
+
return {
|
|
137
|
+
id,
|
|
138
|
+
tenantId,
|
|
139
|
+
kind: "user",
|
|
140
|
+
refId: "user_alice",
|
|
141
|
+
status,
|
|
142
|
+
createdAt: NOW,
|
|
143
|
+
updatedAt: NOW,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function makeToken(overrides: Partial<GitTokenRow> = {}): GitTokenRow {
|
|
148
|
+
const secret = overrides.id ? `${overrides.id}_secret` : "default_secret";
|
|
149
|
+
return {
|
|
150
|
+
id: "tok_1",
|
|
151
|
+
userId: "user_alice",
|
|
152
|
+
principalId: "prin_1",
|
|
153
|
+
tenantId: "ten_a",
|
|
154
|
+
name: "laptop",
|
|
155
|
+
kind: "pat",
|
|
156
|
+
tokenHashSha256: sha256(`itx_pat_${secret}`),
|
|
157
|
+
resource: "asset:def_xyz",
|
|
158
|
+
refPattern: "**",
|
|
159
|
+
actions: ["createPack", "resolveRef"],
|
|
160
|
+
expiresAt: FUTURE,
|
|
161
|
+
revokedAt: null,
|
|
162
|
+
createdAt: NOW,
|
|
163
|
+
...overrides,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildApp(db: DB["db"]) {
|
|
168
|
+
const app = new Hono<GitTokenAuthEnv>();
|
|
169
|
+
const auth = createGitTokenAuth({ db });
|
|
170
|
+
app.get("/tenants/:tenantId/probe", auth, (c) => {
|
|
171
|
+
const principal = c.get("principal");
|
|
172
|
+
const tenant = c.get("tenant");
|
|
173
|
+
const claims = c.get("git-token-claims");
|
|
174
|
+
return c.json({
|
|
175
|
+
principalId: principal.id,
|
|
176
|
+
tenantId: tenant.id,
|
|
177
|
+
claims: {
|
|
178
|
+
resource: claims.resource,
|
|
179
|
+
refPattern: claims.refPattern,
|
|
180
|
+
actions: claims.actions,
|
|
181
|
+
expiresAt: claims.expiresAt.toISOString(),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
return app;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function basicAuthHeader(username: string, password: string): string {
|
|
189
|
+
return (
|
|
190
|
+
"Basic " + Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const captured: {
|
|
195
|
+
category: readonly string[];
|
|
196
|
+
level: string;
|
|
197
|
+
message: string;
|
|
198
|
+
}[] = [];
|
|
199
|
+
|
|
200
|
+
const savedConfig = getConfig();
|
|
201
|
+
|
|
202
|
+
beforeAll(() => {
|
|
203
|
+
configureSync({
|
|
204
|
+
reset: true,
|
|
205
|
+
sinks: {
|
|
206
|
+
capture: (record) => {
|
|
207
|
+
const message = Array.isArray(record.message)
|
|
208
|
+
? record.message
|
|
209
|
+
.map((part) =>
|
|
210
|
+
typeof part === "string" ? part : JSON.stringify(part),
|
|
211
|
+
)
|
|
212
|
+
.join("")
|
|
213
|
+
: String(record.message);
|
|
214
|
+
captured.push({
|
|
215
|
+
category: record.category,
|
|
216
|
+
level: record.level,
|
|
217
|
+
message,
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
loggers: [
|
|
222
|
+
{ category: [], lowestLevel: "debug", sinks: ["capture"] },
|
|
223
|
+
{
|
|
224
|
+
category: ["logtape", "meta"],
|
|
225
|
+
lowestLevel: "warning",
|
|
226
|
+
sinks: ["capture"],
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
afterAll(() => {
|
|
233
|
+
if (savedConfig) {
|
|
234
|
+
configureSync({ reset: true, ...savedConfig });
|
|
235
|
+
} else {
|
|
236
|
+
resetSync();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
captured.length = 0;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
function hasGitTokenLog(): boolean {
|
|
245
|
+
return captured.some(
|
|
246
|
+
(r) =>
|
|
247
|
+
r.category.length >= 2 &&
|
|
248
|
+
r.category[0] === "hub" &&
|
|
249
|
+
r.category[1] === "git-token",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("createGitTokenAuth", () => {
|
|
254
|
+
describe("missing or unparseable Authorization header", () => {
|
|
255
|
+
test("no header returns 401 with WWW-Authenticate", async () => {
|
|
256
|
+
const app = buildApp(createMockDB({}));
|
|
257
|
+
const res = await app.request("/tenants/ten_a/probe");
|
|
258
|
+
expect(res.status).toBe(401);
|
|
259
|
+
expect(res.headers.get("www-authenticate")).toBe(
|
|
260
|
+
'Basic realm="Interchange"',
|
|
261
|
+
);
|
|
262
|
+
const body = await res.json();
|
|
263
|
+
expect(body).toEqual({
|
|
264
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
265
|
+
});
|
|
266
|
+
expect(hasGitTokenLog()).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("malformed header (no scheme/space) returns 401", async () => {
|
|
270
|
+
const app = buildApp(createMockDB({}));
|
|
271
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
272
|
+
headers: { authorization: "garbage" },
|
|
273
|
+
});
|
|
274
|
+
expect(res.status).toBe(401);
|
|
275
|
+
expect(res.headers.get("www-authenticate")).toBe(
|
|
276
|
+
'Basic realm="Interchange"',
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("unsupported scheme returns 401", async () => {
|
|
281
|
+
const app = buildApp(createMockDB({}));
|
|
282
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
283
|
+
headers: { authorization: "Digest abc" },
|
|
284
|
+
});
|
|
285
|
+
expect(res.status).toBe(401);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("Basic with no password returns 401", async () => {
|
|
289
|
+
const app = buildApp(createMockDB({}));
|
|
290
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
291
|
+
headers: { authorization: basicAuthHeader("alice", "") },
|
|
292
|
+
});
|
|
293
|
+
expect(res.status).toBe(401);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("malformed prefix is rejected before DB hit", () => {
|
|
298
|
+
test("Bearer token without itx_ prefix returns 401 and does not query DB", async () => {
|
|
299
|
+
let queried = false;
|
|
300
|
+
const dbMock = {
|
|
301
|
+
query: {
|
|
302
|
+
gitToken: {
|
|
303
|
+
findFirst: async () => {
|
|
304
|
+
queried = true;
|
|
305
|
+
return undefined;
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
tenant: { findFirst: notImplemented("tenant.findFirst") },
|
|
309
|
+
principal: { findFirst: notImplemented("principal.findFirst") },
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
313
|
+
const db = dbMock as unknown as DB["db"];
|
|
314
|
+
const app = buildApp(db);
|
|
315
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
316
|
+
headers: { authorization: "Bearer not_a_valid_token" },
|
|
317
|
+
});
|
|
318
|
+
expect(res.status).toBe(401);
|
|
319
|
+
expect(queried).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("unknown token", () => {
|
|
324
|
+
test("returns 401 with WWW-Authenticate (does not leak validity)", async () => {
|
|
325
|
+
const app = buildApp(createMockDB({ gitToken: null }));
|
|
326
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
327
|
+
headers: { authorization: "Bearer itx_pat_nonexistent" },
|
|
328
|
+
});
|
|
329
|
+
expect(res.status).toBe(401);
|
|
330
|
+
expect(res.headers.get("www-authenticate")).toBe(
|
|
331
|
+
'Basic realm="Interchange"',
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("revoked token", () => {
|
|
337
|
+
test("returns 403 token_revoked", async () => {
|
|
338
|
+
const token = makeToken({
|
|
339
|
+
id: "tok_revoked",
|
|
340
|
+
tokenHashSha256: sha256("itx_pat_revoked_secret"),
|
|
341
|
+
revokedAt: PAST,
|
|
342
|
+
});
|
|
343
|
+
const app = buildApp(
|
|
344
|
+
createMockDB({
|
|
345
|
+
gitToken: token,
|
|
346
|
+
tenant: makeTenant("ten_a"),
|
|
347
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
351
|
+
headers: { authorization: "Bearer itx_pat_revoked_secret" },
|
|
352
|
+
});
|
|
353
|
+
expect(res.status).toBe(403);
|
|
354
|
+
const body: unknown = await res.json();
|
|
355
|
+
expect(body).toMatchObject({ error: { code: "token_revoked" } });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("expired token", () => {
|
|
360
|
+
test("returns 403 token_expired", async () => {
|
|
361
|
+
const token = makeToken({
|
|
362
|
+
id: "tok_expired",
|
|
363
|
+
tokenHashSha256: sha256("itx_pat_expired_secret"),
|
|
364
|
+
expiresAt: PAST,
|
|
365
|
+
});
|
|
366
|
+
const app = buildApp(
|
|
367
|
+
createMockDB({
|
|
368
|
+
gitToken: token,
|
|
369
|
+
tenant: makeTenant("ten_a"),
|
|
370
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
374
|
+
headers: { authorization: "Bearer itx_pat_expired_secret" },
|
|
375
|
+
});
|
|
376
|
+
expect(res.status).toBe(403);
|
|
377
|
+
const body: unknown = await res.json();
|
|
378
|
+
expect(body).toMatchObject({ error: { code: "token_expired" } });
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("tenant mismatch", () => {
|
|
383
|
+
test("tenant-bound token with mismatched URL tenant returns 403 tenant_mismatch", async () => {
|
|
384
|
+
const token = makeToken({
|
|
385
|
+
id: "tok_bound_a",
|
|
386
|
+
tokenHashSha256: sha256("itx_svc_bound_secret"),
|
|
387
|
+
kind: "svc",
|
|
388
|
+
tenantId: "ten_a",
|
|
389
|
+
});
|
|
390
|
+
const app = buildApp(
|
|
391
|
+
createMockDB({
|
|
392
|
+
gitToken: token,
|
|
393
|
+
tenant: makeTenant("ten_a"),
|
|
394
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
const res = await app.request("/tenants/ten_b/probe", {
|
|
398
|
+
headers: { authorization: "Bearer itx_svc_bound_secret" },
|
|
399
|
+
});
|
|
400
|
+
expect(res.status).toBe(403);
|
|
401
|
+
const body: unknown = await res.json();
|
|
402
|
+
expect(body).toMatchObject({ error: { code: "tenant_mismatch" } });
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("personal token principal resolution from (userId, :tid)", () => {
|
|
407
|
+
test("returns 403 principal_not_found when no principal exists for (userId, :tid)", async () => {
|
|
408
|
+
const token = makeToken({
|
|
409
|
+
id: "tok_personal",
|
|
410
|
+
tokenHashSha256: sha256("itx_pat_personal_secret"),
|
|
411
|
+
tenantId: null,
|
|
412
|
+
principalId: null,
|
|
413
|
+
});
|
|
414
|
+
const app = buildApp(
|
|
415
|
+
createMockDB({
|
|
416
|
+
gitToken: token,
|
|
417
|
+
tenant: makeTenant("ten_a"),
|
|
418
|
+
principal: null,
|
|
419
|
+
principalByRef: null,
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
423
|
+
headers: { authorization: "Bearer itx_pat_personal_secret" },
|
|
424
|
+
});
|
|
425
|
+
expect(res.status).toBe(403);
|
|
426
|
+
const body: unknown = await res.json();
|
|
427
|
+
expect(body).toMatchObject({ error: { code: "principal_not_found" } });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("succeeds when principalByRef resolves", async () => {
|
|
431
|
+
const token = makeToken({
|
|
432
|
+
id: "tok_personal",
|
|
433
|
+
tokenHashSha256: sha256("itx_pat_personal_ok"),
|
|
434
|
+
tenantId: null,
|
|
435
|
+
principalId: null,
|
|
436
|
+
});
|
|
437
|
+
const app = buildApp(
|
|
438
|
+
createMockDB({
|
|
439
|
+
gitToken: token,
|
|
440
|
+
tenant: makeTenant("ten_a"),
|
|
441
|
+
principalByRef: makePrincipal("prin_alice_a", "ten_a"),
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
445
|
+
headers: { authorization: "Bearer itx_pat_personal_ok" },
|
|
446
|
+
});
|
|
447
|
+
expect(res.status).toBe(200);
|
|
448
|
+
const body: unknown = await res.json();
|
|
449
|
+
expect(body).toMatchObject({
|
|
450
|
+
principalId: "prin_alice_a",
|
|
451
|
+
tenantId: "ten_a",
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("suspended principal", () => {
|
|
457
|
+
test("returns 403 principal_suspended", async () => {
|
|
458
|
+
const token = makeToken({
|
|
459
|
+
id: "tok_susp",
|
|
460
|
+
tokenHashSha256: sha256("itx_pat_susp_secret"),
|
|
461
|
+
});
|
|
462
|
+
const app = buildApp(
|
|
463
|
+
createMockDB({
|
|
464
|
+
gitToken: token,
|
|
465
|
+
tenant: makeTenant("ten_a"),
|
|
466
|
+
principal: makePrincipal("prin_1", "ten_a", "suspended"),
|
|
467
|
+
}),
|
|
468
|
+
);
|
|
469
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
470
|
+
headers: { authorization: "Bearer itx_pat_susp_secret" },
|
|
471
|
+
});
|
|
472
|
+
expect(res.status).toBe(403);
|
|
473
|
+
const body: unknown = await res.json();
|
|
474
|
+
expect(body).toMatchObject({ error: { code: "principal_suspended" } });
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("success path", () => {
|
|
479
|
+
test("sets principal, tenant, and git-token-claims; logs success", async () => {
|
|
480
|
+
const token = makeToken({
|
|
481
|
+
id: "tok_ok",
|
|
482
|
+
tokenHashSha256: sha256("itx_pat_ok_secret"),
|
|
483
|
+
resource: "asset:def_xyz",
|
|
484
|
+
refPattern: "refs/heads/main",
|
|
485
|
+
actions: ["createPack", "resolveRef"],
|
|
486
|
+
});
|
|
487
|
+
const app = buildApp(
|
|
488
|
+
createMockDB({
|
|
489
|
+
gitToken: token,
|
|
490
|
+
tenant: makeTenant("ten_a"),
|
|
491
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
495
|
+
headers: { authorization: "Bearer itx_pat_ok_secret" },
|
|
496
|
+
});
|
|
497
|
+
expect(res.status).toBe(200);
|
|
498
|
+
const body: unknown = await res.json();
|
|
499
|
+
expect(body).toMatchObject({
|
|
500
|
+
principalId: "prin_1",
|
|
501
|
+
tenantId: "ten_a",
|
|
502
|
+
claims: {
|
|
503
|
+
resource: "asset:def_xyz",
|
|
504
|
+
refPattern: "refs/heads/main",
|
|
505
|
+
actions: ["createPack", "resolveRef"],
|
|
506
|
+
expiresAt: FUTURE.toISOString(),
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
expect(hasGitTokenLog()).toBe(true);
|
|
510
|
+
expect(
|
|
511
|
+
captured.some(
|
|
512
|
+
(r) => r.category[1] === "git-token" && r.message.includes("success"),
|
|
513
|
+
),
|
|
514
|
+
).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("Basic auth: username variance does not affect gating", async () => {
|
|
518
|
+
const token = makeToken({
|
|
519
|
+
id: "tok_basic",
|
|
520
|
+
tokenHashSha256: sha256("itx_pat_basic_secret"),
|
|
521
|
+
});
|
|
522
|
+
const db = createMockDB({
|
|
523
|
+
gitToken: token,
|
|
524
|
+
tenant: makeTenant("ten_a"),
|
|
525
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
526
|
+
});
|
|
527
|
+
const app = buildApp(db);
|
|
528
|
+
|
|
529
|
+
for (const username of ["alice", "bob", "", "x"]) {
|
|
530
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
531
|
+
headers: {
|
|
532
|
+
authorization: basicAuthHeader(username, "itx_pat_basic_secret"),
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
expect(res.status).toBe(200);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("Basic auth: username is logged for forensics", async () => {
|
|
540
|
+
const token = makeToken({
|
|
541
|
+
id: "tok_log_user",
|
|
542
|
+
tokenHashSha256: sha256("itx_pat_log_user_secret"),
|
|
543
|
+
});
|
|
544
|
+
const app = buildApp(
|
|
545
|
+
createMockDB({
|
|
546
|
+
gitToken: token,
|
|
547
|
+
tenant: makeTenant("ten_a"),
|
|
548
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
552
|
+
headers: {
|
|
553
|
+
authorization: basicAuthHeader(
|
|
554
|
+
"forensic-username",
|
|
555
|
+
"itx_pat_log_user_secret",
|
|
556
|
+
),
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
expect(res.status).toBe(200);
|
|
560
|
+
expect(
|
|
561
|
+
captured.some((r) => r.message.includes("forensic-username")),
|
|
562
|
+
).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
describe("SHA-256 hash round-trip", () => {
|
|
567
|
+
test("the secret stored as SHA-256 matches the secret on the wire", async () => {
|
|
568
|
+
const secret = "itx_pat_roundtrip_secret";
|
|
569
|
+
const hash = sha256(secret);
|
|
570
|
+
const token = makeToken({
|
|
571
|
+
id: "tok_rt",
|
|
572
|
+
tokenHashSha256: hash,
|
|
573
|
+
});
|
|
574
|
+
const app = buildApp(
|
|
575
|
+
createMockDB({
|
|
576
|
+
gitToken: token,
|
|
577
|
+
tenant: makeTenant("ten_a"),
|
|
578
|
+
principal: makePrincipal("prin_1", "ten_a"),
|
|
579
|
+
}),
|
|
580
|
+
);
|
|
581
|
+
const res = await app.request("/tenants/ten_a/probe", {
|
|
582
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
583
|
+
});
|
|
584
|
+
expect(res.status).toBe(200);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
});
|