@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148
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/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +41 -4
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js +44 -7
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +77 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +183 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +7 -26
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/pipeline/steps/shapes.d.ts +18 -0
- package/dist/pipeline/steps/shapes.d.ts.map +1 -1
- package/dist/pipeline/steps/shapes.js +29 -0
- package/dist/pipeline/steps/shapes.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/list-read-scope.ts +87 -9
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +218 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorization-tuples.ts +7 -28
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/pipeline/steps/shapes.ts +31 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- package/src/store/port-contract.ts +102 -0
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* annotation's byte-pinned error_msg; unavailable → INTERNAL, never a
|
|
5
5
|
* softened denial), the skip arms (internal caller class, is_public,
|
|
6
6
|
* is_skip_authorization, no-config methods), check-target resolution
|
|
7
|
-
* (field_path, static resource_id, absent field = empty id
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* (field_path, static resource_id, absent field = empty id, a string
|
|
8
|
+
* resource_kind_path resolved by enum name with an unknown name = the
|
|
9
|
+
* unknown kind — never a throw), the direct-handler override in both its
|
|
10
|
+
* shapes (a server-side id; a server-side kind AND id), and the mid-chain
|
|
11
|
+
* resolved-id pattern the traced ListVersions handlers port onto.
|
|
10
12
|
*/
|
|
11
13
|
import { describe, expect, it } from "vitest";
|
|
12
14
|
import { create } from "@bufbuild/protobuf";
|
|
@@ -17,6 +19,7 @@ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb"
|
|
|
17
19
|
import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
|
|
18
20
|
import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
19
21
|
import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
|
|
22
|
+
import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
|
|
20
23
|
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
21
24
|
import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
22
25
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
@@ -37,9 +40,7 @@ import {
|
|
|
37
40
|
} from "../authorize.js";
|
|
38
41
|
|
|
39
42
|
/** Awaits the rejection and returns it as a ConnectError. */
|
|
40
|
-
async function captureError(
|
|
41
|
-
run: () => Promise<void>,
|
|
42
|
-
): Promise<ConnectError> {
|
|
43
|
+
async function captureError(run: () => Promise<void>): Promise<ConnectError> {
|
|
43
44
|
try {
|
|
44
45
|
await run();
|
|
45
46
|
} catch (error) {
|
|
@@ -290,6 +291,75 @@ describe("check-target resolution (never a throw — byte-identity)", () => {
|
|
|
290
291
|
},
|
|
291
292
|
]);
|
|
292
293
|
});
|
|
294
|
+
|
|
295
|
+
// 20260913.01 (Q-OR-2): the IamPolicy RPCs name their target inside the
|
|
296
|
+
// request as a STRING kind (`ApiResourceRef.kind`, "organization"), so
|
|
297
|
+
// `resource_kind_path` resolves a string through the kind enum's names.
|
|
298
|
+
// The option had no user before this entry; a numeric field still works.
|
|
299
|
+
it("resource_kind_path over a string field resolves through the kind enum's names (IamPolicy create)", async () => {
|
|
300
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
301
|
+
const method = IamPolicyCommandController.method.create;
|
|
302
|
+
const step = newAuthorizeStep(method, authorizer);
|
|
303
|
+
const ctx = new RequestContext(
|
|
304
|
+
method.input,
|
|
305
|
+
create(method.input, {
|
|
306
|
+
principal: { kind: "identity_account", id: "ida_alice" },
|
|
307
|
+
relation: "member",
|
|
308
|
+
resource: { kind: "organization", id: "acme" },
|
|
309
|
+
}),
|
|
310
|
+
testCallerIdentity(),
|
|
311
|
+
);
|
|
312
|
+
await step.execute(ctx);
|
|
313
|
+
expect(checks).toEqual([
|
|
314
|
+
{
|
|
315
|
+
permission: IamPermission.can_grant_access,
|
|
316
|
+
resourceKind: ApiResourceKind.organization,
|
|
317
|
+
resourceId: "acme",
|
|
318
|
+
},
|
|
319
|
+
]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("an unknown kind name resolves to the unknown kind — never a throw; the Authorizer owns the decision", async () => {
|
|
323
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
324
|
+
const method = IamPolicyCommandController.method.create;
|
|
325
|
+
const step = newAuthorizeStep(method, authorizer);
|
|
326
|
+
const ctx = new RequestContext(
|
|
327
|
+
method.input,
|
|
328
|
+
create(method.input, {
|
|
329
|
+
principal: { kind: "identity_account", id: "ida_alice" },
|
|
330
|
+
relation: "member",
|
|
331
|
+
resource: { kind: "spaceship", id: "acme" },
|
|
332
|
+
}),
|
|
333
|
+
testCallerIdentity(),
|
|
334
|
+
);
|
|
335
|
+
await expect(step.execute(ctx)).resolves.toBeUndefined();
|
|
336
|
+
expect(checks[0]?.resourceKind).toBe(
|
|
337
|
+
ApiResourceKind.api_resource_kind_unknown,
|
|
338
|
+
);
|
|
339
|
+
expect(checks[0]?.resourceId).toBe("acme");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("revokeOrgAccess names the organization statically and its id by field_path (organization_id)", async () => {
|
|
343
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
344
|
+
const method = IamPolicyCommandController.method.revokeOrgAccess;
|
|
345
|
+
const step = newAuthorizeStep(method, authorizer);
|
|
346
|
+
const ctx = new RequestContext(
|
|
347
|
+
method.input,
|
|
348
|
+
create(method.input, {
|
|
349
|
+
identityAccountId: "ida_alice",
|
|
350
|
+
organizationId: "acme",
|
|
351
|
+
}),
|
|
352
|
+
testCallerIdentity(),
|
|
353
|
+
);
|
|
354
|
+
await step.execute(ctx);
|
|
355
|
+
expect(checks).toEqual([
|
|
356
|
+
{
|
|
357
|
+
permission: IamPermission.can_grant_access,
|
|
358
|
+
resourceKind: ApiResourceKind.organization,
|
|
359
|
+
resourceId: "acme",
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
293
363
|
});
|
|
294
364
|
|
|
295
365
|
describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
|
|
@@ -348,6 +418,49 @@ describe("authorizeDirect (the direct-handler arm, C2 Stage 4)", () => {
|
|
|
348
418
|
]);
|
|
349
419
|
});
|
|
350
420
|
|
|
421
|
+
// 20260913.01 (Q-OR-2): the IamPolicy `get(IamPolicyId)` lane. Its
|
|
422
|
+
// annotation names a permission and NO kind, because the target is the
|
|
423
|
+
// loaded row's resource — kind AND id are server-side state. The
|
|
424
|
+
// override carries both; the annotation still owns the permission and
|
|
425
|
+
// the copy.
|
|
426
|
+
it("the target override may carry the resource KIND too — the IamPolicy get lane", async () => {
|
|
427
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
428
|
+
const method = IamPolicyQueryController.method.get;
|
|
429
|
+
await authorizeDirect(
|
|
430
|
+
method,
|
|
431
|
+
authorizer,
|
|
432
|
+
testCallerIdentity(),
|
|
433
|
+
create(method.input, { value: "iamp_01row" }),
|
|
434
|
+
{ resourceKind: ApiResourceKind.organization, resourceId: "acme" },
|
|
435
|
+
);
|
|
436
|
+
expect(checks).toEqual([
|
|
437
|
+
{
|
|
438
|
+
permission: IamPermission.can_view_access,
|
|
439
|
+
resourceKind: ApiResourceKind.organization,
|
|
440
|
+
resourceId: "acme",
|
|
441
|
+
},
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("an override with the id alone leaves the annotation's kind in place — the completeOAuthConnect lane is unchanged", async () => {
|
|
446
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
447
|
+
const method = IamPolicyQueryController.method.get;
|
|
448
|
+
// A kind-less annotation plus an id-only override: the kind stays
|
|
449
|
+
// unknown, exactly what the annotation says. The lane that needs a
|
|
450
|
+
// kind must say so; nothing is inferred.
|
|
451
|
+
await authorizeDirect(
|
|
452
|
+
method,
|
|
453
|
+
authorizer,
|
|
454
|
+
testCallerIdentity(),
|
|
455
|
+
create(method.input, { value: "iamp_01row" }),
|
|
456
|
+
{ resourceId: "acme" },
|
|
457
|
+
);
|
|
458
|
+
expect(checks[0]?.resourceKind).toBe(
|
|
459
|
+
ApiResourceKind.api_resource_kind_unknown,
|
|
460
|
+
);
|
|
461
|
+
expect(checks[0]?.resourceId).toBe("acme");
|
|
462
|
+
});
|
|
463
|
+
|
|
351
464
|
it("the ruling-Q1 not-found arm maps identically from the direct entry", async () => {
|
|
352
465
|
const { authorizer } = fakeAuthorizer({ kind: "not-found" });
|
|
353
466
|
const error = await authorizeDirect(
|
|
@@ -438,18 +551,30 @@ describe("authorizeResolvedResource (the mid-chain resolved-id pattern, 20260830
|
|
|
438
551
|
),
|
|
439
552
|
);
|
|
440
553
|
expect(err.code).toBe(Code.PermissionDenied);
|
|
441
|
-
expect(err.rawMessage).toBe(
|
|
554
|
+
expect(err.rawMessage).toBe(
|
|
555
|
+
"unauthorized to view workflow version history",
|
|
556
|
+
);
|
|
442
557
|
});
|
|
443
558
|
|
|
444
559
|
it("deny without lane copy falls back to the reason, then the shared fallback", async () => {
|
|
445
560
|
const reasoned = fakeAuthorizer({ kind: "deny", reason: "because" });
|
|
446
561
|
const err1 = await captureError(() =>
|
|
447
|
-
authorizeResolvedResource(
|
|
562
|
+
authorizeResolvedResource(
|
|
563
|
+
reasoned.authorizer,
|
|
564
|
+
testCallerIdentity(),
|
|
565
|
+
check,
|
|
566
|
+
"",
|
|
567
|
+
),
|
|
448
568
|
);
|
|
449
569
|
expect(err1.rawMessage).toBe("because");
|
|
450
570
|
const bare = fakeAuthorizer({ kind: "deny", reason: "" });
|
|
451
571
|
const err2 = await captureError(() =>
|
|
452
|
-
authorizeResolvedResource(
|
|
572
|
+
authorizeResolvedResource(
|
|
573
|
+
bare.authorizer,
|
|
574
|
+
testCallerIdentity(),
|
|
575
|
+
check,
|
|
576
|
+
"",
|
|
577
|
+
),
|
|
453
578
|
);
|
|
454
579
|
expect(err2.rawMessage).toBe("permission denied");
|
|
455
580
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins `derivedId`, the one encoder behind every id a kind derives from its
|
|
3
|
+
* natural key instead of minting (metadata.proto's `id` comment: a direct
|
|
4
|
+
* IdentityAccount from its subject, an IamPolicy from its triple; the
|
|
5
|
+
* Organization's slug needs no encoding). Lifted from the identity-account
|
|
6
|
+
* domain in 20260913.01 slice 2 so the second derived id did not carry a
|
|
7
|
+
* second copy of the grammar.
|
|
8
|
+
*
|
|
9
|
+
* The vectors here are the two domains' golden vectors restated through
|
|
10
|
+
* the shared function: `accountIdFor` and `policyIdFor` must equal
|
|
11
|
+
* `derivedId(prefix, text)` over their canonical text, and those domain
|
|
12
|
+
* tests pin the wire-adjacent constants. A change to the encoding
|
|
13
|
+
* re-addresses every derived row open source ever wrote.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { derivedId } from "../defaults.js";
|
|
18
|
+
|
|
19
|
+
const CROCKFORD_26 = /^[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
|
|
20
|
+
|
|
21
|
+
describe("derivedId — prefix, underscore, the top 130 bits of sha256 as 26 Crockford chars", () => {
|
|
22
|
+
it("matches the identity-account golden vector", () => {
|
|
23
|
+
// accountIdFor("auth0|user123") as pinned in identityaccount/__tests__/constants.test.ts.
|
|
24
|
+
expect(derivedId("ida", "auth0|user123")).toBe(
|
|
25
|
+
"ida_wtr3jcf281yfk9xx61kj59fsme",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("matches the IamPolicy golden vector over the canonical triple text", () => {
|
|
30
|
+
expect(
|
|
31
|
+
derivedId(
|
|
32
|
+
"iamp",
|
|
33
|
+
"identity_account:ida_wtr3jcf281yfk9xx61kj59fsme#@organization:acme#admin",
|
|
34
|
+
),
|
|
35
|
+
).toBe("iamp_vkbjb6nvqm77vtwnr2bj0x95f9");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("has the minted id's shape whatever the prefix, so nothing downstream learns a second grammar", () => {
|
|
39
|
+
const id = derivedId("xyz", "anything");
|
|
40
|
+
expect(id.startsWith("xyz_")).toBe(true);
|
|
41
|
+
expect(id.slice("xyz_".length)).toMatch(CROCKFORD_26);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is a pure function of prefix and text: equal inputs, equal ids; a changed byte, a changed id", () => {
|
|
45
|
+
expect(derivedId("ida", "same")).toBe(derivedId("ida", "same"));
|
|
46
|
+
expect(derivedId("ida", "same")).not.toBe(derivedId("ida", "same "));
|
|
47
|
+
expect(derivedId("ida", "same")).not.toBe(derivedId("iamp", "same"));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -78,7 +78,7 @@ import type { PipelineStep } from "../pipeline.js";
|
|
|
78
78
|
import type { RequestContext } from "../request-context.js";
|
|
79
79
|
import { EXISTING_RESOURCE_KEY } from "./load-existing.js";
|
|
80
80
|
import type { HasMetadataShape } from "./shapes.js";
|
|
81
|
-
import { metadataOf } from "./shapes.js";
|
|
81
|
+
import { metadataOf, parentIdOf } from "./shapes.js";
|
|
82
82
|
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
// Visibility shape policy — the reconciler's level→shape mapping, ported
|
|
@@ -156,40 +156,19 @@ export function diffVisibilityShapes(
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// ---------------------------------------------------------------------------
|
|
159
|
-
// Parent-id resolution — the ParentIdExtractorRegistry port.
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
159
|
+
// Parent-id resolution — the ParentIdExtractorRegistry port. The spec-field
|
|
160
|
+
// read itself is shapes.ts's `parentIdOf`, shared with the list read scope
|
|
161
|
+
// (20260913.04 T04) so the id a tuple is written with and the id the scope
|
|
162
|
+
// asks about are one read. This module adds the create-time rule: a
|
|
163
|
+
// missing parent fails the request.
|
|
163
164
|
// ---------------------------------------------------------------------------
|
|
164
165
|
|
|
165
|
-
/** proto snake_case field name → the generated property name. */
|
|
166
|
-
function camelCaseFieldName(specField: string): string {
|
|
167
|
-
return specField.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Extracts the parent id named by the config from the resource's spec.
|
|
172
|
-
* Returns "" for every miss (no spec, no field, non-string value) — the
|
|
173
|
-
* caller decides whether that is fatal, exactly as Java's registry
|
|
174
|
-
* returns null and the service throws.
|
|
175
|
-
*/
|
|
176
|
-
function extractParentId(resource: Message, specField: string): string {
|
|
177
|
-
// Structural access is the shapes.ts idiom: spec messages are plain
|
|
178
|
-
// objects; the property name is the camelCase form of the proto field.
|
|
179
|
-
const spec = (resource as unknown as { spec?: Record<string, unknown> }).spec;
|
|
180
|
-
if (spec === undefined) {
|
|
181
|
-
return "";
|
|
182
|
-
}
|
|
183
|
-
const value = spec[camelCaseFieldName(specField)];
|
|
184
|
-
return typeof value === "string" ? value : "";
|
|
185
|
-
}
|
|
186
|
-
|
|
187
166
|
function resolveParentLink(
|
|
188
167
|
kind: ApiResourceKind,
|
|
189
168
|
resource: Message,
|
|
190
169
|
parentConfig: ParentRelationConfig,
|
|
191
170
|
): ResolvedParentLink {
|
|
192
|
-
const parentId =
|
|
171
|
+
const parentId = parentIdOf(resource, parentConfig.specField);
|
|
193
172
|
if (parentId === "") {
|
|
194
173
|
// Java: "Parent ID required for X but not found" → the request fails.
|
|
195
174
|
throw internalError(
|
|
@@ -31,9 +31,13 @@
|
|
|
31
31
|
* Resolution never throws: an unresolvable `field_path` yields an empty
|
|
32
32
|
* resource id and an unresolvable `resource_kind_path` yields the unknown
|
|
33
33
|
* kind — the check still reaches the Authorizer, which owns the decision.
|
|
34
|
-
* A
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* A `resource_kind_path` may point at an `ApiResourceKind` field or at a
|
|
35
|
+
* string carrying a kind's enum member name (an `ApiResourceRef.kind`, the
|
|
36
|
+
* IamPolicy RPCs' spec-named target; 20260913.01 Q-OR-2); a string that is
|
|
37
|
+
* not exactly a member name is the unknown kind, which an enforcing
|
|
38
|
+
* authorizer denies. A thrown resolution would be a NEW wire behavior on
|
|
39
|
+
* requests that are legal today (byte-identity forbids it); an
|
|
40
|
+
* implementation that wants to refuse empty ids does so as a deny, visibly.
|
|
37
41
|
*
|
|
38
42
|
* `authorizeDirect` is the SAME evaluation exported for the direct
|
|
39
43
|
* handlers — the config-annotated methods that deliberately run no
|
|
@@ -42,11 +46,16 @@
|
|
|
42
46
|
* two entry shapes; a direct handler calls it after its own input
|
|
43
47
|
* validation and before any load or side effect, mirroring the Java
|
|
44
48
|
* edition's validate → authorize handler order (C2 Stage 4 ruling,
|
|
45
|
-
* 20260827.10). The optional target override serves the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* McpServerCompleteOAuthConnectHandler
|
|
49
|
+
* 20260827.10). The optional target override serves the lanes whose true
|
|
50
|
+
* target is server-side state rather than caller input, in two shapes:
|
|
51
|
+
* a server-side ID under the annotation's static kind (completeOAuthConnect
|
|
52
|
+
* authorizes the PENDING RECORD's server id — a caller-supplied id would
|
|
53
|
+
* be a confused-deputy hole, the Java McpServerCompleteOAuthConnectHandler
|
|
54
|
+
* discipline; getByEmail/getByIdpId authorize the account they looked up),
|
|
55
|
+
* and a server-side kind AND id (the IamPolicy `get`, whose annotation
|
|
56
|
+
* names no kind because the target is the loaded row's resource). The
|
|
57
|
+
* annotation keeps owning the permission, the copy and the skip arms in
|
|
58
|
+
* both shapes; only the target is the handler's.
|
|
50
59
|
*/
|
|
51
60
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
52
61
|
import type { DescMethod, DescMessage, Message } from "@bufbuild/protobuf";
|
|
@@ -66,7 +75,7 @@ import type {
|
|
|
66
75
|
AuthzDecision,
|
|
67
76
|
} from "../../extensions/authorizer.js";
|
|
68
77
|
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
69
|
-
import { getKindName } from "../apiresource-meta.js";
|
|
78
|
+
import { getKindName, kindByEnumName } from "../apiresource-meta.js";
|
|
70
79
|
import { internalError, notFoundError } from "../errors.js";
|
|
71
80
|
import type { PipelineStep } from "../pipeline.js";
|
|
72
81
|
import type { RequestContext } from "../request-context.js";
|
|
@@ -115,12 +124,16 @@ export function newAuthorizeStep<Desc extends DescMessage>(
|
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
|
-
* The
|
|
119
|
-
*
|
|
120
|
-
* annotation's `field_path`/`resource_id` resolution;
|
|
121
|
-
*
|
|
127
|
+
* The lanes whose authorization target is server-side state rather than a
|
|
128
|
+
* request field (see the module header). `resourceId` replaces the
|
|
129
|
+
* annotation's `field_path`/`resource_id` resolution; `resourceKind`, when
|
|
130
|
+
* given, replaces its `resource_kind`/`resource_kind_path` resolution — the
|
|
131
|
+
* lane whose annotation names no kind because the kind is inside the row
|
|
132
|
+
* it loaded. Everything else — skip arms, permission, copy — still comes
|
|
133
|
+
* from the annotation.
|
|
122
134
|
*/
|
|
123
135
|
export interface AuthorizeTargetOverride {
|
|
136
|
+
readonly resourceKind?: ApiResourceKind;
|
|
124
137
|
readonly resourceId: string;
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -156,7 +169,8 @@ export async function authorizeDirect(
|
|
|
156
169
|
identity,
|
|
157
170
|
{
|
|
158
171
|
permission: config.permission,
|
|
159
|
-
resourceKind:
|
|
172
|
+
resourceKind:
|
|
173
|
+
override?.resourceKind ?? resolveResourceKind(input, config),
|
|
160
174
|
resourceId: override?.resourceId ?? resolveResourceId(input, config),
|
|
161
175
|
},
|
|
162
176
|
config.errorMsg,
|
|
@@ -189,7 +203,7 @@ export async function authorizeResolvedResource(
|
|
|
189
203
|
if (identity.callerClass === "internal") {
|
|
190
204
|
return;
|
|
191
205
|
}
|
|
192
|
-
const decision = await
|
|
206
|
+
const decision = await evaluateAuthorizer(authorizer, identity, check);
|
|
193
207
|
switch (decision.kind) {
|
|
194
208
|
case "allow":
|
|
195
209
|
return;
|
|
@@ -234,9 +248,13 @@ export async function authorizeResolvedResource(
|
|
|
234
248
|
/**
|
|
235
249
|
* An Authorizer that THROWS is an evaluation failure by definition —
|
|
236
250
|
* normalized into the unavailable arm so a buggy implementation can never
|
|
237
|
-
* soften an outage into a denial by accident.
|
|
251
|
+
* soften an outage into a denial by accident. Exported for the one lane
|
|
252
|
+
* that needs the DECISION rather than the wire mapping: `checkMyPermission`
|
|
253
|
+
* answers a boolean (allow → true, deny and not-found → false) and maps
|
|
254
|
+
* only `unavailable` to the wire, through the same INTERNAL copy
|
|
255
|
+
* (20260913.01, Q-S5-3).
|
|
238
256
|
*/
|
|
239
|
-
async function
|
|
257
|
+
export async function evaluateAuthorizer(
|
|
240
258
|
authorizer: Authorizer,
|
|
241
259
|
identity: CallerIdentity,
|
|
242
260
|
check: AuthzCheck,
|
|
@@ -251,7 +269,10 @@ async function runAuthorizer(
|
|
|
251
269
|
}
|
|
252
270
|
}
|
|
253
271
|
|
|
254
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Static kind, or the resource_kind_path read — an enum field as its
|
|
274
|
+
* number, a string as an enum member name — or unknown. Never a throw.
|
|
275
|
+
*/
|
|
255
276
|
function resolveResourceKind(
|
|
256
277
|
input: Message,
|
|
257
278
|
config: RpcAuthorizationConfig,
|
|
@@ -260,9 +281,13 @@ function resolveResourceKind(
|
|
|
260
281
|
return config.resourceKind;
|
|
261
282
|
}
|
|
262
283
|
const value = resolveDotPath(input, config.resourceKindPath);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
284
|
+
if (typeof value === "number") {
|
|
285
|
+
return value as ApiResourceKind;
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === "string") {
|
|
288
|
+
return kindByEnumName(value);
|
|
289
|
+
}
|
|
290
|
+
return ApiResourceKind.api_resource_kind_unknown;
|
|
266
291
|
}
|
|
267
292
|
|
|
268
293
|
/** Static resource_id, or the field_path read, or "" — never a throw. */
|
|
@@ -14,7 +14,19 @@
|
|
|
14
14
|
* setAuditFieldsForUpdate call site declares which slot it owns —
|
|
15
15
|
* SpecAudit for definition changes (search recency, version "pushed at"),
|
|
16
16
|
* StatusAudit for operational changes (Recents, lifecycle metadata).
|
|
17
|
+
*
|
|
18
|
+
* This module is also the one home of how an id is SPELLED. `generateId`
|
|
19
|
+
* mints `{prefix}_{ulid}`; `derivedId` is its sibling for the kinds
|
|
20
|
+
* metadata.proto names as deriving their id from a natural key instead
|
|
21
|
+
* (a direct IdentityAccount from its issuer subject, an IamPolicy from its
|
|
22
|
+
* triple): `{prefix}_` + the top 130 bits of sha256 over the key's
|
|
23
|
+
* canonical text as 26 lowercase Crockford-base32 characters. The two
|
|
24
|
+
* shapes are indistinguishable on the wire, so nothing downstream learns
|
|
25
|
+
* a second grammar, and the primary key becomes the one home of "one row
|
|
26
|
+
* per natural key" without a secondary index or a scan.
|
|
17
27
|
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
18
30
|
import { create, clone } from "@bufbuild/protobuf";
|
|
19
31
|
import type { DescMessage, Message } from "@bufbuild/protobuf";
|
|
20
32
|
import { reflect } from "@bufbuild/protobuf/reflect";
|
|
@@ -353,3 +365,33 @@ function setAuditSlotReflect(
|
|
|
353
365
|
export function generateId(prefix: string): string {
|
|
354
366
|
return `${prefix}_${ulid().toLowerCase()}`;
|
|
355
367
|
}
|
|
368
|
+
|
|
369
|
+
/** Crockford base32, lowercased — the alphabet every minted ULID id uses. */
|
|
370
|
+
const CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
371
|
+
const DERIVED_ID_CHARS = 26;
|
|
372
|
+
const DERIVED_ID_BITS = BigInt(DERIVED_ID_CHARS * 5);
|
|
373
|
+
const SHA256_BITS = 256n;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The derived id of a kind whose id is a function of its natural key
|
|
377
|
+
* (metadata.proto `id`): `{prefix}_` + the top 130 bits of
|
|
378
|
+
* sha256(canonicalText) as 26 lowercase Crockford-base32 characters. Pure
|
|
379
|
+
* and total; the CALLER owns what `canonicalText` may be (the domains
|
|
380
|
+
* refuse an empty subject or an ambiguous triple before hashing), and the
|
|
381
|
+
* domains' golden vectors are the wire-adjacent pin — a change here
|
|
382
|
+
* re-addresses every derived row open source ever wrote.
|
|
383
|
+
*/
|
|
384
|
+
export function derivedId(prefix: string, canonicalText: string): string {
|
|
385
|
+
const digest = createHash("sha256").update(canonicalText, "utf8").digest();
|
|
386
|
+
let bits = 0n;
|
|
387
|
+
for (const byte of digest) {
|
|
388
|
+
bits = (bits << 8n) | BigInt(byte);
|
|
389
|
+
}
|
|
390
|
+
let top = bits >> (SHA256_BITS - DERIVED_ID_BITS);
|
|
391
|
+
let encoded = "";
|
|
392
|
+
for (let i = 0; i < DERIVED_ID_CHARS; i++) {
|
|
393
|
+
encoded = CROCKFORD_ALPHABET[Number(top & 31n)] + encoded;
|
|
394
|
+
top >>= 5n;
|
|
395
|
+
}
|
|
396
|
+
return `${prefix}_${encoded}`;
|
|
397
|
+
}
|
|
@@ -39,6 +39,37 @@ export function idValueOf(msg: Message): string {
|
|
|
39
39
|
return typeof value === "string" ? value : "";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** proto snake_case field name → the generated property name. */
|
|
43
|
+
function camelCaseFieldName(specField: string): string {
|
|
44
|
+
return specField.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The parent id a `kind_meta` ParentRelationConfig names by `spec_field`,
|
|
49
|
+
* read from the resource's spec — the ParentIdExtractorRegistry port, with
|
|
50
|
+
* zero hardcoded kind knowledge: the proto config names the field, this
|
|
51
|
+
* reads it structurally (spec messages are plain objects whose properties
|
|
52
|
+
* are the camelCase proto field names). Returns "" for every miss (no
|
|
53
|
+
* spec, no field, non-string value); the caller decides whether that is
|
|
54
|
+
* fatal — the tuple lifecycle throws at create (Java's registry returned
|
|
55
|
+
* null and the service threw), the list scope carries no parent.
|
|
56
|
+
*
|
|
57
|
+
* ONE reader for both: the id the authorization tuple was written with is
|
|
58
|
+
* the id the list scope later asks the authorization backend about, so
|
|
59
|
+
* the two cannot drift (20260913.04 T04). Takes `object` rather than
|
|
60
|
+
* `Message` because the list lanes hand the scope structurally-typed rows
|
|
61
|
+
* and their unit suite hands it plain objects — the same posture
|
|
62
|
+
* `metadataOf` takes behind its cast.
|
|
63
|
+
*/
|
|
64
|
+
export function parentIdOf(resource: object, specField: string): string {
|
|
65
|
+
const spec = (resource as { spec?: Record<string, unknown> }).spec;
|
|
66
|
+
if (spec === undefined) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
const value = spec[camelCaseFieldName(specField)];
|
|
70
|
+
return typeof value === "string" ? value : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
/** A message-typed field, statically narrowed so get()/set() type-check. */
|
|
43
74
|
export type MessageDescField = Extract<DescField, { fieldKind: "message" }>;
|
|
44
75
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the port-contract runner (../port-contract.ts), the scaffolding every
|
|
3
|
+
* domain store port's kit is built on (identity-account since 20260911.11;
|
|
4
|
+
* IamPolicy since 20260913.01 slice 2, when the scaffolding was lifted out
|
|
5
|
+
* of the first kit so the second did not copy it):
|
|
6
|
+
*
|
|
7
|
+
* - every case makes a FRESH fixture, runs its body over it, and cleans
|
|
8
|
+
* up — in that order, once each;
|
|
9
|
+
* - a failing body is the failure reported: a cleanup that fails after
|
|
10
|
+
* a failed body must not replace the assertion that matters with a
|
|
11
|
+
* teardown detail;
|
|
12
|
+
* - a cleanup that fails after a PASSING body is a real failure and
|
|
13
|
+
* propagates;
|
|
14
|
+
* - the case list keeps the declared names in the declared order, so a
|
|
15
|
+
* consumer's pinned name list is a faithful diff of the contract.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { portContractCases } from "../port-contract.js";
|
|
20
|
+
import type { PortContractFixture } from "../port-contract.js";
|
|
21
|
+
|
|
22
|
+
interface Probe {
|
|
23
|
+
readonly label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ProbeFixture = PortContractFixture<Probe>;
|
|
27
|
+
|
|
28
|
+
function fixtureFactory(
|
|
29
|
+
log: string[],
|
|
30
|
+
options: { readonly failCleanup?: boolean } = {},
|
|
31
|
+
): () => Promise<ProbeFixture> {
|
|
32
|
+
let made = 0;
|
|
33
|
+
return async () => {
|
|
34
|
+
made += 1;
|
|
35
|
+
const label = `fixture-${made}`;
|
|
36
|
+
log.push(`make ${label}`);
|
|
37
|
+
return {
|
|
38
|
+
store: { label },
|
|
39
|
+
disconnect: async () => {
|
|
40
|
+
log.push(`disconnect ${label}`);
|
|
41
|
+
},
|
|
42
|
+
cleanup: async () => {
|
|
43
|
+
log.push(`cleanup ${label}`);
|
|
44
|
+
if (options.failCleanup === true) {
|
|
45
|
+
throw new Error(`cleanup of ${label} failed`);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("portContractCases", () => {
|
|
53
|
+
it("keeps the declared names in the declared order", () => {
|
|
54
|
+
const cases = portContractCases<Probe>(
|
|
55
|
+
[
|
|
56
|
+
["first line", async () => {}],
|
|
57
|
+
["second line", async () => {}],
|
|
58
|
+
],
|
|
59
|
+
fixtureFactory([]),
|
|
60
|
+
);
|
|
61
|
+
expect(cases.map((contractCase) => contractCase.name)).toEqual([
|
|
62
|
+
"first line",
|
|
63
|
+
"second line",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("makes a fresh fixture per case, runs the body over it, then cleans up — once each", async () => {
|
|
68
|
+
const log: string[] = [];
|
|
69
|
+
const cases = portContractCases<Probe>(
|
|
70
|
+
[
|
|
71
|
+
[
|
|
72
|
+
"a",
|
|
73
|
+
async ({ store }) => {
|
|
74
|
+
log.push(`body over ${store.label}`);
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"b",
|
|
79
|
+
async ({ store }) => {
|
|
80
|
+
log.push(`body over ${store.label}`);
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
],
|
|
84
|
+
fixtureFactory(log),
|
|
85
|
+
);
|
|
86
|
+
for (const contractCase of cases) {
|
|
87
|
+
await contractCase.run();
|
|
88
|
+
}
|
|
89
|
+
expect(log).toEqual([
|
|
90
|
+
"make fixture-1",
|
|
91
|
+
"body over fixture-1",
|
|
92
|
+
"cleanup fixture-1",
|
|
93
|
+
"make fixture-2",
|
|
94
|
+
"body over fixture-2",
|
|
95
|
+
"cleanup fixture-2",
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports the body's failure, not the cleanup's, when both fail", async () => {
|
|
100
|
+
const log: string[] = [];
|
|
101
|
+
const [only] = portContractCases<Probe>(
|
|
102
|
+
[
|
|
103
|
+
[
|
|
104
|
+
"assertion loses to nothing",
|
|
105
|
+
async () => {
|
|
106
|
+
throw new Error("the assertion that matters");
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
],
|
|
110
|
+
fixtureFactory(log, { failCleanup: true }),
|
|
111
|
+
);
|
|
112
|
+
await expect(only?.run()).rejects.toThrow("the assertion that matters");
|
|
113
|
+
expect(log).toEqual(["make fixture-1", "cleanup fixture-1"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("a cleanup failure after a passing body is a real failure", async () => {
|
|
117
|
+
const [only] = portContractCases<Probe>(
|
|
118
|
+
[["passes", async () => {}]],
|
|
119
|
+
fixtureFactory([], { failCleanup: true }),
|
|
120
|
+
);
|
|
121
|
+
await expect(only?.run()).rejects.toThrow("cleanup of fixture-1 failed");
|
|
122
|
+
});
|
|
123
|
+
});
|