@agiledigital/pingone-aic-script-tester 0.1.0
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +274 -0
- package/dist/bin/fetch-jar.d.ts +2 -0
- package/dist/bin/fetch-jar.js +13 -0
- package/dist/bin/pull-profile.d.ts +2 -0
- package/dist/bin/pull-profile.js +40 -0
- package/dist/bin/show-log.d.ts +2 -0
- package/dist/bin/show-log.js +31 -0
- package/dist/classes/.complete +12 -0
- package/dist/classes/AmClassShutter.class +0 -0
- package/dist/classes/AmContextFactory$ObservedContext.class +0 -0
- package/dist/classes/AmContextFactory.class +0 -0
- package/dist/classes/AmScopes.class +0 -0
- package/dist/classes/HostOps.class +0 -0
- package/dist/classes/JsValues.class +0 -0
- package/dist/classes/Json$Parser.class +0 -0
- package/dist/classes/Json.class +0 -0
- package/dist/classes/LanguageVersions.class +0 -0
- package/dist/classes/Runner.class +0 -0
- package/dist/classes/ScriptContextScope.class +0 -0
- package/dist/src/aic/callbacks.d.ts +35 -0
- package/dist/src/aic/callbacks.js +130 -0
- package/dist/src/aic/conform.d.ts +90 -0
- package/dist/src/aic/conform.js +193 -0
- package/dist/src/aic/constants.d.ts +13 -0
- package/dist/src/aic/constants.js +13 -0
- package/dist/src/aic/diff.d.ts +25 -0
- package/dist/src/aic/diff.js +201 -0
- package/dist/src/aic/emit-journey.d.ts +64 -0
- package/dist/src/aic/emit-journey.js +254 -0
- package/dist/src/aic/emit-result.d.ts +10 -0
- package/dist/src/aic/emit-result.js +39 -0
- package/dist/src/aic/emit-session.d.ts +26 -0
- package/dist/src/aic/emit-session.js +132 -0
- package/dist/src/aic/emit-subject.d.ts +40 -0
- package/dist/src/aic/emit-subject.js +210 -0
- package/dist/src/aic/file-lease.d.ts +35 -0
- package/dist/src/aic/file-lease.js +543 -0
- package/dist/src/aic/http.d.ts +21 -0
- package/dist/src/aic/http.js +127 -0
- package/dist/src/aic/idm.d.ts +10 -0
- package/dist/src/aic/idm.js +112 -0
- package/dist/src/aic/index.d.ts +35 -0
- package/dist/src/aic/index.js +21 -0
- package/dist/src/aic/js.d.ts +8 -0
- package/dist/src/aic/js.js +11 -0
- package/dist/src/aic/lease-identity.d.ts +38 -0
- package/dist/src/aic/lease-identity.js +72 -0
- package/dist/src/aic/lease-lock.d.ts +45 -0
- package/dist/src/aic/lease-lock.js +173 -0
- package/dist/src/aic/managed.d.ts +28 -0
- package/dist/src/aic/managed.js +199 -0
- package/dist/src/aic/provider.d.ts +156 -0
- package/dist/src/aic/provider.js +416 -0
- package/dist/src/aic/record.d.ts +28 -0
- package/dist/src/aic/record.js +183 -0
- package/dist/src/aic/resource-snapshot.d.ts +12 -0
- package/dist/src/aic/resource-snapshot.js +116 -0
- package/dist/src/aic/run.d.ts +102 -0
- package/dist/src/aic/run.js +450 -0
- package/dist/src/aic/tenant.d.ts +107 -0
- package/dist/src/aic/tenant.js +307 -0
- package/dist/src/aic/trace.d.ts +25 -0
- package/dist/src/aic/trace.js +56 -0
- package/dist/src/aic/txid.d.ts +11 -0
- package/dist/src/aic/txid.js +25 -0
- package/dist/src/aic/unsupported.d.ts +15 -0
- package/dist/src/aic/unsupported.js +60 -0
- package/dist/src/bindings/allowlist.d.ts +42 -0
- package/dist/src/bindings/allowlist.js +62 -0
- package/dist/src/bindings/harvest.d.ts +7 -0
- package/dist/src/bindings/harvest.js +137 -0
- package/dist/src/bindings/index.d.ts +4 -0
- package/dist/src/bindings/index.js +3 -0
- package/dist/src/bindings/preamble.d.ts +23 -0
- package/dist/src/bindings/preamble.js +36 -0
- package/dist/src/bindings/run.d.ts +33 -0
- package/dist/src/bindings/run.js +55 -0
- package/dist/src/case/equal.d.ts +2 -0
- package/dist/src/case/equal.js +40 -0
- package/dist/src/case/index.d.ts +5 -0
- package/dist/src/case/index.js +4 -0
- package/dist/src/case/portable.d.ts +10 -0
- package/dist/src/case/portable.js +28 -0
- package/dist/src/case/session.d.ts +34 -0
- package/dist/src/case/session.js +66 -0
- package/dist/src/case/state.d.ts +4 -0
- package/dist/src/case/state.js +47 -0
- package/dist/src/case/types.d.ts +266 -0
- package/dist/src/case/types.js +106 -0
- package/dist/src/case/util.d.ts +7 -0
- package/dist/src/case/util.js +115 -0
- package/dist/src/case/validate.d.ts +8 -0
- package/dist/src/case/validate.js +463 -0
- package/dist/src/case/verdict.d.ts +6 -0
- package/dist/src/case/verdict.js +810 -0
- package/dist/src/diagnostics.d.ts +7 -0
- package/dist/src/diagnostics.js +5 -0
- package/dist/src/emit-dts.d.ts +2 -0
- package/dist/src/emit-dts.js +82 -0
- package/dist/src/emit-js.d.ts +3 -0
- package/dist/src/emit-js.js +149 -0
- package/dist/src/harness/failures.d.ts +26 -0
- package/dist/src/harness/failures.js +113 -0
- package/dist/src/harness/idm.d.ts +12 -0
- package/dist/src/harness/idm.js +44 -0
- package/dist/src/harness/index.d.ts +11 -0
- package/dist/src/harness/index.js +6 -0
- package/dist/src/harness/lease.d.ts +136 -0
- package/dist/src/harness/lease.js +286 -0
- package/dist/src/harness/residue.d.ts +23 -0
- package/dist/src/harness/residue.js +73 -0
- package/dist/src/harness/show-log.d.ts +43 -0
- package/dist/src/harness/show-log.js +253 -0
- package/dist/src/harness/spec.d.ts +67 -0
- package/dist/src/harness/spec.js +189 -0
- package/dist/src/harness/step.d.ts +80 -0
- package/dist/src/harness/step.js +97 -0
- package/dist/src/harness/types.d.ts +100 -0
- package/dist/src/harness/types.js +1 -0
- package/dist/src/harness/vitest.d.ts +52 -0
- package/dist/src/harness/vitest.js +155 -0
- package/dist/src/idents.d.ts +4 -0
- package/dist/src/idents.js +42 -0
- package/dist/src/jvm.d.ts +124 -0
- package/dist/src/jvm.js +432 -0
- package/dist/src/load.d.ts +2 -0
- package/dist/src/load.js +104 -0
- package/dist/src/paths.d.ts +36 -0
- package/dist/src/paths.js +50 -0
- package/dist/src/profile/index.d.ts +5 -0
- package/dist/src/profile/index.js +3 -0
- package/dist/src/profile/normalise.d.ts +16 -0
- package/dist/src/profile/normalise.js +106 -0
- package/dist/src/profile/pull.d.ts +22 -0
- package/dist/src/profile/pull.js +36 -0
- package/dist/src/profile/seed.d.ts +11 -0
- package/dist/src/profile/seed.js +28 -0
- package/dist/src/profile/store.d.ts +13 -0
- package/dist/src/profile/store.js +60 -0
- package/dist/src/profile/types.d.ts +45 -0
- package/dist/src/profile/types.js +1 -0
- package/dist/src/profile/validate.d.ts +38 -0
- package/dist/src/profile/validate.js +129 -0
- package/dist/src/project.d.ts +37 -0
- package/dist/src/project.js +93 -0
- package/dist/src/protocol.d.ts +62 -0
- package/dist/src/protocol.js +68 -0
- package/dist/src/provider-module.d.ts +11 -0
- package/dist/src/provider-module.js +46 -0
- package/dist/src/runner.d.ts +39 -0
- package/dist/src/runner.js +337 -0
- package/dist/src/schema.d.ts +48 -0
- package/dist/src/schema.js +93 -0
- package/generated/scripted-decision-mocks.cjs +722 -0
- package/generated/scripted-decision-mocks.d.ts +626 -0
- package/generated/scripted-decision-next.json +3640 -0
- package/java/AmClassShutter.java +98 -0
- package/java/AmContextFactory.java +69 -0
- package/java/AmScopes.java +49 -0
- package/java/HostOps.java +180 -0
- package/java/JsValues.java +39 -0
- package/java/Json.java +441 -0
- package/java/LanguageVersions.java +55 -0
- package/java/Runner.java +289 -0
- package/java/ScriptContextScope.java +147 -0
- package/package.json +88 -0
- package/src/bindings/rhino/runtime.cjs +3021 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Case, Expect, Given, JsonObject } from "../case/types.ts";
|
|
3
|
+
import type { Channels, RequestDraft, SuiteSpec, WireMap } from "./types.ts";
|
|
4
|
+
/** Shared-state key prefix the tenant's config library reads before the ESV. */
|
|
5
|
+
export declare const ESV_STATE_PREFIX = "esv.";
|
|
6
|
+
/**
|
|
7
|
+
* Collapse the suite's `always` and one test's overrides into a single draft.
|
|
8
|
+
*
|
|
9
|
+
* Merging is per key, not per channel: a test that sets one header keeps the
|
|
10
|
+
* suite's others. Replacing the whole channel would make `always` useless the
|
|
11
|
+
* moment a test needed to add anything, which is the failure mode that drives
|
|
12
|
+
* people to copy the defaults into every test and then let them drift.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mergeChannels(always: Channels | undefined, override: Channels | undefined): RequestDraft;
|
|
15
|
+
/**
|
|
16
|
+
* A header or parameter is `string | string[]`; the wire form is always a
|
|
17
|
+
* list, because AM reports one list element per occurrence in send order
|
|
18
|
+
* (verified 2026-09-08 on both engines). A bare string is therefore a
|
|
19
|
+
* one-element list, not a separate case.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normaliseWire(map: WireMap | undefined): Record<string, string[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Fold the parsed inputs and the ESV overrides into shared state.
|
|
24
|
+
*
|
|
25
|
+
* Inputs land under their own names and ESVs under `esv.<name>`, so a suite
|
|
26
|
+
* declaring an input called `esv.x` would be ambiguous; that is rejected
|
|
27
|
+
* rather than resolved by precedence, because either precedence is a
|
|
28
|
+
* defensible guess and neither is visible at the call site.
|
|
29
|
+
*/
|
|
30
|
+
export declare function applyInputsAndEsv(draft: RequestDraft, input: Readonly<Record<string, unknown>>): void;
|
|
31
|
+
/** Parse a run's inputs against the suite's schema, or reject extras. */
|
|
32
|
+
export declare function parseInputs<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "inputs">, raw: unknown): Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Turn a resolved draft into a `Given`.
|
|
35
|
+
*
|
|
36
|
+
* `session` compiles to `given.existingSession`, whose shape was measured
|
|
37
|
+
* 2026-09-14 on both evaluators: a String->String map, present only when the
|
|
38
|
+
* request carries a session cookie. Values are coerced here rather than in the
|
|
39
|
+
* mock, so the AIC lane — which has to put them through a mini journey's
|
|
40
|
+
* `putSessionProperty` — sends exactly what the local lane seeded.
|
|
41
|
+
*/
|
|
42
|
+
export declare function toGiven(draft: RequestDraft, base?: Given, realm?: string): Given;
|
|
43
|
+
/** Assemble the `Case` both lanes are judged against. One definition. */
|
|
44
|
+
export declare function toCase<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "script" | "outcomes">,
|
|
45
|
+
/**
|
|
46
|
+
* Used verbatim. The vitest adapter supplies an already-qualified
|
|
47
|
+
* "describe > test" path, so prefixing the suite name here would print it
|
|
48
|
+
* twice in every failure message.
|
|
49
|
+
*/
|
|
50
|
+
caseName: string, draft: RequestDraft, expect: Expect, base?: Given): Case;
|
|
51
|
+
/**
|
|
52
|
+
* The same `Case`, from a `Given` that is already resolved.
|
|
53
|
+
*
|
|
54
|
+
* A step chain's later passes cannot go through `toCase`: `toGiven` layers the
|
|
55
|
+
* draft over the base, so the request's original seeds would win over what the
|
|
56
|
+
* previous pass actually left in state — the chain would silently restart from
|
|
57
|
+
* the top on every step.
|
|
58
|
+
*/
|
|
59
|
+
export declare function caseWithGiven<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "script" | "outcomes">, caseName: string, given: Given, expect: Expect): Case;
|
|
60
|
+
export type { JsonObject };
|
|
61
|
+
/**
|
|
62
|
+
* Who the mini journey logs in as. The principal need not exist as a managed
|
|
63
|
+
* object (measured 2026-09-14), so this is free; taking it from shared state
|
|
64
|
+
* means a suite that already sets `username` gets a session for that user
|
|
65
|
+
* without saying so twice.
|
|
66
|
+
*/
|
|
67
|
+
export declare function sessionPrincipal(draft: RequestDraft): string;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { AM_OWNED_SESSION_SET, DEFAULT_SESSION_PRINCIPAL, sessionFromPrincipal, } from "../case/session.js";
|
|
2
|
+
/** Shared-state key prefix the tenant's config library reads before the ESV. */
|
|
3
|
+
export const ESV_STATE_PREFIX = "esv.";
|
|
4
|
+
/**
|
|
5
|
+
* Collapse the suite's `always` and one test's overrides into a single draft.
|
|
6
|
+
*
|
|
7
|
+
* Merging is per key, not per channel: a test that sets one header keeps the
|
|
8
|
+
* suite's others. Replacing the whole channel would make `always` useless the
|
|
9
|
+
* moment a test needed to add anything, which is the failure mode that drives
|
|
10
|
+
* people to copy the defaults into every test and then let them drift.
|
|
11
|
+
*/
|
|
12
|
+
export function mergeChannels(always, override) {
|
|
13
|
+
return {
|
|
14
|
+
state: {
|
|
15
|
+
shared: { ...(always?.state?.shared ?? {}), ...(override?.state?.shared ?? {}) },
|
|
16
|
+
transient: {
|
|
17
|
+
...(always?.state?.transient ?? {}),
|
|
18
|
+
...(override?.state?.transient ?? {}),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
esv: { ...(always?.esv ?? {}), ...(override?.esv ?? {}) },
|
|
22
|
+
headers: { ...normaliseWire(always?.headers), ...normaliseWire(override?.headers) },
|
|
23
|
+
params: { ...normaliseWire(always?.params), ...normaliseWire(override?.params) },
|
|
24
|
+
session: { ...(always?.session ?? {}), ...(override?.session ?? {}) },
|
|
25
|
+
// Declared-empty and not-declared are different requests: `session: {}`
|
|
26
|
+
// asks for a logged-in session with no extra properties, which is a real
|
|
27
|
+
// case and is invisible if you only look at the merged key count.
|
|
28
|
+
sessionRequested: always?.session !== undefined || override?.session !== undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A header or parameter is `string | string[]`; the wire form is always a
|
|
33
|
+
* list, because AM reports one list element per occurrence in send order
|
|
34
|
+
* (verified 2026-09-08 on both engines). A bare string is therefore a
|
|
35
|
+
* one-element list, not a separate case.
|
|
36
|
+
*/
|
|
37
|
+
export function normaliseWire(map) {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
40
|
+
out[key] = wireValues(key, value);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function wireValues(key, value) {
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
return [value];
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
49
|
+
throw new Error(`rhino-local: ${key} must be a string or an array of strings`);
|
|
50
|
+
}
|
|
51
|
+
if (value.length === 0) {
|
|
52
|
+
throw new Error(`rhino-local: ${key} is an empty array — a key present with no values cannot be sent; omit the key instead`);
|
|
53
|
+
}
|
|
54
|
+
return [...value];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fold the parsed inputs and the ESV overrides into shared state.
|
|
58
|
+
*
|
|
59
|
+
* Inputs land under their own names and ESVs under `esv.<name>`, so a suite
|
|
60
|
+
* declaring an input called `esv.x` would be ambiguous; that is rejected
|
|
61
|
+
* rather than resolved by precedence, because either precedence is a
|
|
62
|
+
* defensible guess and neither is visible at the call site.
|
|
63
|
+
*/
|
|
64
|
+
export function applyInputsAndEsv(draft, input) {
|
|
65
|
+
for (const [key, value] of Object.entries(input)) {
|
|
66
|
+
if (key.startsWith(ESV_STATE_PREFIX)) {
|
|
67
|
+
throw new Error(`rhino-local: input ${JSON.stringify(key)} collides with the ${JSON.stringify(ESV_STATE_PREFIX)} namespace reserved for ESV overrides; rename the input`);
|
|
68
|
+
}
|
|
69
|
+
draft.state.shared[key] = value;
|
|
70
|
+
}
|
|
71
|
+
for (const [name, value] of Object.entries(draft.esv)) {
|
|
72
|
+
draft.state.shared[`${ESV_STATE_PREFIX}${name}`] = value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Parse a run's inputs against the suite's schema, or reject extras. */
|
|
76
|
+
export function parseInputs(spec, raw) {
|
|
77
|
+
if (spec.inputs === undefined) {
|
|
78
|
+
if (raw !== undefined && Object.keys(raw).length > 0) {
|
|
79
|
+
throw new Error(`rhino-local: ${spec.name} passed inputs but declares none; add an \`inputs\` schema to the suite`);
|
|
80
|
+
}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
const result = spec.inputs.safeParse(raw ?? {});
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
const detail = result.error.issues
|
|
86
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
87
|
+
.join("; ");
|
|
88
|
+
throw new Error(`rhino-local: ${spec.name} input invalid — ${detail}`);
|
|
89
|
+
}
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Turn a resolved draft into a `Given`.
|
|
94
|
+
*
|
|
95
|
+
* `session` compiles to `given.existingSession`, whose shape was measured
|
|
96
|
+
* 2026-09-14 on both evaluators: a String->String map, present only when the
|
|
97
|
+
* request carries a session cookie. Values are coerced here rather than in the
|
|
98
|
+
* mock, so the AIC lane — which has to put them through a mini journey's
|
|
99
|
+
* `putSessionProperty` — sends exactly what the local lane seeded.
|
|
100
|
+
*/
|
|
101
|
+
export function toGiven(draft, base = {}, realm = "alpha") {
|
|
102
|
+
const given = { ...base };
|
|
103
|
+
if (draft.sessionRequested) {
|
|
104
|
+
given.existingSession = {
|
|
105
|
+
...sessionFromPrincipal(sessionPrincipal(draft), realm),
|
|
106
|
+
...(base.existingSession ?? {}),
|
|
107
|
+
...sessionStrings(draft.session),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(draft.state.shared).length > 0) {
|
|
111
|
+
given.sharedState = { ...(base.sharedState ?? {}), ...draft.state.shared };
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(draft.state.transient).length > 0) {
|
|
114
|
+
given.transientState = {
|
|
115
|
+
...(base.transientState ?? {}),
|
|
116
|
+
...draft.state.transient,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (Object.keys(draft.headers).length > 0) {
|
|
120
|
+
given.requestHeaders = { ...(base.requestHeaders ?? {}), ...draft.headers };
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(draft.params).length > 0) {
|
|
123
|
+
given.requestParameters = {
|
|
124
|
+
...(base.requestParameters ?? {}),
|
|
125
|
+
...draft.params,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return given;
|
|
129
|
+
}
|
|
130
|
+
/** Assemble the `Case` both lanes are judged against. One definition. */
|
|
131
|
+
export function toCase(spec,
|
|
132
|
+
/**
|
|
133
|
+
* Used verbatim. The vitest adapter supplies an already-qualified
|
|
134
|
+
* "describe > test" path, so prefixing the suite name here would print it
|
|
135
|
+
* twice in every failure message.
|
|
136
|
+
*/
|
|
137
|
+
caseName, draft, expect, base = {}) {
|
|
138
|
+
return caseWithGiven(spec, caseName, toGiven(draft, base), expect);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The same `Case`, from a `Given` that is already resolved.
|
|
142
|
+
*
|
|
143
|
+
* A step chain's later passes cannot go through `toCase`: `toGiven` layers the
|
|
144
|
+
* draft over the base, so the request's original seeds would win over what the
|
|
145
|
+
* previous pass actually left in state — the chain would silently restart from
|
|
146
|
+
* the top on every step.
|
|
147
|
+
*/
|
|
148
|
+
export function caseWithGiven(spec, caseName, given, expect) {
|
|
149
|
+
return {
|
|
150
|
+
name: caseName,
|
|
151
|
+
script: spec.script,
|
|
152
|
+
outcomes: spec.outcomes,
|
|
153
|
+
given,
|
|
154
|
+
expect,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* AM session properties are strings — every value in the measured 23-key
|
|
159
|
+
* session was one, `AuthLevel: "0"` included. A number or boolean is coerced
|
|
160
|
+
* (it survives the round trip unambiguously); an object or array is refused,
|
|
161
|
+
* because `putSessionProperty` would stringify it to `[object Object]` on the
|
|
162
|
+
* AIC lane while a structured mock would keep it here. That divergence is the
|
|
163
|
+
* false-pass shape this harness exists to prevent.
|
|
164
|
+
*/
|
|
165
|
+
function sessionStrings(session) {
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const [key, value] of Object.entries(session)) {
|
|
168
|
+
if (AM_OWNED_SESSION_SET.has(key)) {
|
|
169
|
+
throw new Error(`rhino-local: session.${key} is set by AM, not by the caller — a mini journey that tries to override it fails the whole login with an unexplained 401. ${key === "UserId" || key === "Principals" || key === "UserToken" || key === "Principal" || key === "sun.am.UniversalIdentifier" ? "Set `state.username` instead; the session is minted for that principal" : "Remove it"}`);
|
|
170
|
+
}
|
|
171
|
+
if (value === null || typeof value === "object") {
|
|
172
|
+
throw new Error(`rhino-local: session.${key} must be a string — AM stores session properties as strings, so a ${value === null ? "null" : "structured value"} cannot survive the round trip`);
|
|
173
|
+
}
|
|
174
|
+
out[key] = String(value);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Who the mini journey logs in as. The principal need not exist as a managed
|
|
180
|
+
* object (measured 2026-09-14), so this is free; taking it from shared state
|
|
181
|
+
* means a suite that already sets `username` gets a session for that user
|
|
182
|
+
* without saying so twice.
|
|
183
|
+
*/
|
|
184
|
+
export function sessionPrincipal(draft) {
|
|
185
|
+
const username = draft.state.shared.username;
|
|
186
|
+
return typeof username === "string" && username.length > 0
|
|
187
|
+
? username
|
|
188
|
+
: DEFAULT_SESSION_PRINCIPAL;
|
|
189
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CallbackEffect, Expect, Given, JsonValue, RecordedEffects } from "../case/types.ts";
|
|
2
|
+
import type { IdmHandle } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* One submitted callback value — what the client types into the callback the
|
|
5
|
+
* script sent. `type` names the callback it answers; the harness matches
|
|
6
|
+
* replies to emitted callbacks by type, in order.
|
|
7
|
+
*/
|
|
8
|
+
export interface CallbackReply {
|
|
9
|
+
type: string;
|
|
10
|
+
value: JsonValue;
|
|
11
|
+
}
|
|
12
|
+
export interface StepContext<TInput> {
|
|
13
|
+
input: TInput;
|
|
14
|
+
/** 1-based position in the chain. */
|
|
15
|
+
step: number;
|
|
16
|
+
/** What this pass emitted, in order. */
|
|
17
|
+
callbacks: CallbackEffect[];
|
|
18
|
+
effects: RecordedEffects;
|
|
19
|
+
}
|
|
20
|
+
/** Everything an intermediate pass can assert, minus the outcome it cannot have. */
|
|
21
|
+
export type StepExpect = Omit<Expect, "outcome">;
|
|
22
|
+
export interface StepSpec<TInput> {
|
|
23
|
+
/**
|
|
24
|
+
* What this pass must do before it suspends. `outcome` is not offered: a
|
|
25
|
+
* pass that decided an outcome did not suspend, and the chain has nothing
|
|
26
|
+
* to reply to.
|
|
27
|
+
*/
|
|
28
|
+
expect?: StepExpect;
|
|
29
|
+
/**
|
|
30
|
+
* What the client submits back. A function receives what the pass emitted,
|
|
31
|
+
* which is how a reply depends on the choices the script offered.
|
|
32
|
+
*/
|
|
33
|
+
reply: CallbackReply[] | ((ctx: StepContext<TInput>) => CallbackReply[]);
|
|
34
|
+
/**
|
|
35
|
+
* Asserted on each lane after this pass and before the reply is submitted —
|
|
36
|
+
* the point of the chain is to check the world between two halves of a
|
|
37
|
+
* journey, not only at the end. AIC cannot dump intermediate node state
|
|
38
|
+
* without adding a script-visible callback, so `effects.evidence` marks
|
|
39
|
+
* those channels unobserved there. Fail by throwing.
|
|
40
|
+
*/
|
|
41
|
+
check?: (idm: IdmHandle, ctx: StepContext<TInput>) => void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Seed the next pass from the pass that just suspended.
|
|
45
|
+
*
|
|
46
|
+
* MEASURED 2026-09-14 against a live tenant, one scripted decision node that
|
|
47
|
+
* put a value in each bucket, sent a NameCallback, and read them back on the
|
|
48
|
+
* resumed pass (`docs/api/09-journeys.md` → "What survives a callback round
|
|
49
|
+
* trip"):
|
|
50
|
+
*
|
|
51
|
+
* - **shared state survives.** `nodeState.get` returned it and `nodeState.keys()`
|
|
52
|
+
* still listed it.
|
|
53
|
+
* - **transient state does NOT.** It came back `null`, identical to a key
|
|
54
|
+
* nobody ever set (the control), and it was absent from `keys()`. It is
|
|
55
|
+
* dropped — not promoted into secure state, which is the plausible guess a
|
|
56
|
+
* hand-rolled mock makes and the one that turns a broken script green.
|
|
57
|
+
* - **`resumedFromSuspend` stays false.** It belongs to `action.suspend()`, so
|
|
58
|
+
* a callback round trip must not set it; seeding `true` here would put the
|
|
59
|
+
* local lane in a state the AIC lane cannot even reach (the AIC lane refuses
|
|
60
|
+
* `given.resumedFromSuspend` for exactly that reason).
|
|
61
|
+
*
|
|
62
|
+
* Secure state is carried as the previous pass left it. No next-gen script can
|
|
63
|
+
* write it — there is no `putSecure` — so this is local-lane bookkeeping for a
|
|
64
|
+
* seed the case supplied, not a claim about AM. The AIC lane skips any case
|
|
65
|
+
* that declares `given.secureState`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function carryGiven(previous: Given, effects: RecordedEffects, submitted: readonly CallbackEffect[]): Given;
|
|
68
|
+
/**
|
|
69
|
+
* Merge replies into the callbacks a pass emitted, producing what the client
|
|
70
|
+
* submits.
|
|
71
|
+
*
|
|
72
|
+
* An emitted callback with no reply is submitted **without a value**, so a
|
|
73
|
+
* script that reads it fails naming the type rather than receiving whatever
|
|
74
|
+
* the callback happened to carry outbound. That asymmetry is deliberate: AM
|
|
75
|
+
* fills an unanswered input with a per-type default (an unanswered
|
|
76
|
+
* HiddenValueCallback comes back holding its own `id`, measured 2026-09-14),
|
|
77
|
+
* and inventing those defaults for every callback type would be a mock the
|
|
78
|
+
* tenant does not match.
|
|
79
|
+
*/
|
|
80
|
+
export declare function submittedCallbacks(emitted: readonly CallbackEffect[], replies: readonly CallbackReply[], label: string): CallbackEffect[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed the next pass from the pass that just suspended.
|
|
3
|
+
*
|
|
4
|
+
* MEASURED 2026-09-14 against a live tenant, one scripted decision node that
|
|
5
|
+
* put a value in each bucket, sent a NameCallback, and read them back on the
|
|
6
|
+
* resumed pass (`docs/api/09-journeys.md` → "What survives a callback round
|
|
7
|
+
* trip"):
|
|
8
|
+
*
|
|
9
|
+
* - **shared state survives.** `nodeState.get` returned it and `nodeState.keys()`
|
|
10
|
+
* still listed it.
|
|
11
|
+
* - **transient state does NOT.** It came back `null`, identical to a key
|
|
12
|
+
* nobody ever set (the control), and it was absent from `keys()`. It is
|
|
13
|
+
* dropped — not promoted into secure state, which is the plausible guess a
|
|
14
|
+
* hand-rolled mock makes and the one that turns a broken script green.
|
|
15
|
+
* - **`resumedFromSuspend` stays false.** It belongs to `action.suspend()`, so
|
|
16
|
+
* a callback round trip must not set it; seeding `true` here would put the
|
|
17
|
+
* local lane in a state the AIC lane cannot even reach (the AIC lane refuses
|
|
18
|
+
* `given.resumedFromSuspend` for exactly that reason).
|
|
19
|
+
*
|
|
20
|
+
* Secure state is carried as the previous pass left it. No next-gen script can
|
|
21
|
+
* write it — there is no `putSecure` — so this is local-lane bookkeeping for a
|
|
22
|
+
* seed the case supplied, not a claim about AM. The AIC lane skips any case
|
|
23
|
+
* that declares `given.secureState`.
|
|
24
|
+
*/
|
|
25
|
+
export function carryGiven(previous, effects, submitted) {
|
|
26
|
+
const next = { ...previous };
|
|
27
|
+
next.sharedState = clone(effects.sharedState.final);
|
|
28
|
+
// Not `{}`: absent and empty differ elsewhere in `Given`, and "the tenant
|
|
29
|
+
// dropped it" is absence.
|
|
30
|
+
delete next.transientState;
|
|
31
|
+
if (Object.keys(effects.secureState.final).length > 0) {
|
|
32
|
+
next.secureState = clone(effects.secureState.final);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
delete next.secureState;
|
|
36
|
+
}
|
|
37
|
+
next.callbacks = submitted.map((callback) => ({ ...callback }));
|
|
38
|
+
if (effects.managedStore !== undefined) {
|
|
39
|
+
// Records the earlier pass created have to be visible to the later one,
|
|
40
|
+
// or a chain can never test a journey that writes and then reads back.
|
|
41
|
+
next.managed = clone(effects.managedStore);
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Merge replies into the callbacks a pass emitted, producing what the client
|
|
47
|
+
* submits.
|
|
48
|
+
*
|
|
49
|
+
* An emitted callback with no reply is submitted **without a value**, so a
|
|
50
|
+
* script that reads it fails naming the type rather than receiving whatever
|
|
51
|
+
* the callback happened to carry outbound. That asymmetry is deliberate: AM
|
|
52
|
+
* fills an unanswered input with a per-type default (an unanswered
|
|
53
|
+
* HiddenValueCallback comes back holding its own `id`, measured 2026-09-14),
|
|
54
|
+
* and inventing those defaults for every callback type would be a mock the
|
|
55
|
+
* tenant does not match.
|
|
56
|
+
*/
|
|
57
|
+
export function submittedCallbacks(emitted, replies, label) {
|
|
58
|
+
const pending = new Map();
|
|
59
|
+
for (const reply of replies) {
|
|
60
|
+
const queue = pending.get(reply.type);
|
|
61
|
+
if (queue === undefined) {
|
|
62
|
+
pending.set(reply.type, [reply.value]);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
queue.push(reply.value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const callback of emitted) {
|
|
70
|
+
const queue = pending.get(callback.type);
|
|
71
|
+
const { value: _outbound, ...rest } = callback;
|
|
72
|
+
if (queue === undefined || queue.length === 0) {
|
|
73
|
+
out.push({ ...rest });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.push({ ...rest, value: queue.shift() });
|
|
77
|
+
}
|
|
78
|
+
const leftover = [...pending.entries()].filter(([, queue]) => queue.length > 0);
|
|
79
|
+
if (leftover.length > 0) {
|
|
80
|
+
const emittedCounts = countByType(emitted);
|
|
81
|
+
const detail = leftover
|
|
82
|
+
.map(([type, queue]) => `${queue.length} unused ${type} ${queue.length === 1 ? "reply" : "replies"} (the pass emitted ${emittedCounts.get(type) ?? 0})`)
|
|
83
|
+
.join("; ");
|
|
84
|
+
throw new Error(`rhino-local: ${label} replied to callbacks the pass did not send — ${detail}. A reply nobody asked for would seed the next pass with a value the tenant could never deliver`);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function countByType(callbacks) {
|
|
89
|
+
const counts = new Map();
|
|
90
|
+
for (const callback of callbacks) {
|
|
91
|
+
counts.set(callback.type, (counts.get(callback.type) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
return counts;
|
|
94
|
+
}
|
|
95
|
+
function clone(value) {
|
|
96
|
+
return JSON.parse(JSON.stringify(value));
|
|
97
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Expect, Given, JsonObject, JsonValue } from "../case/types.ts";
|
|
3
|
+
/** A header or parameter value. An array is sent as repeated occurrences. */
|
|
4
|
+
export type WireValue = string | readonly string[];
|
|
5
|
+
export type WireMap = Readonly<Record<string, WireValue>>;
|
|
6
|
+
export interface StateChannels {
|
|
7
|
+
shared?: JsonObject;
|
|
8
|
+
transient?: JsonObject;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Everything a run can carry into the script, in one shape. The same five
|
|
12
|
+
* channels are declarable on the suite (`always`), overridable per test, and
|
|
13
|
+
* reachable from `beforeRun` — so there is one place to look, whichever
|
|
14
|
+
* surface you are reading.
|
|
15
|
+
*/
|
|
16
|
+
export interface Channels {
|
|
17
|
+
state?: StateChannels;
|
|
18
|
+
/**
|
|
19
|
+
* ESV overrides. NOT the `systemEnv` binding — these compile to shared
|
|
20
|
+
* state under `esv.<name>`, which the tenant's config library consults
|
|
21
|
+
* before falling back to the real ESV. Seed the real binding with
|
|
22
|
+
* `given.esv` instead; both mechanisms exist and mean different things.
|
|
23
|
+
*/
|
|
24
|
+
esv?: Readonly<Record<string, string>>;
|
|
25
|
+
/** Sent on the authenticate request. No script can assign these bindings. */
|
|
26
|
+
headers?: WireMap;
|
|
27
|
+
/** Sent on the authenticate request. */
|
|
28
|
+
params?: WireMap;
|
|
29
|
+
/**
|
|
30
|
+
* `existingSession` — session properties the script sees, as a flat string
|
|
31
|
+
* map. Declaring it at all (`session: {}` included) asks for a logged-in
|
|
32
|
+
* session; the AIC lane pays for it with an extra round trip, because a
|
|
33
|
+
* session only exists once a journey has run to completion, so the lane
|
|
34
|
+
* runs a two-line mini journey and forwards its cookie to the subject.
|
|
35
|
+
*
|
|
36
|
+
* Custom properties only. The ones AM sets itself — `UserId`, `AuthLevel`
|
|
37
|
+
* and the rest — are refused, because `putSessionProperty` cannot override
|
|
38
|
+
* them and trying fails the whole login with an unexplained 401. The five
|
|
39
|
+
* AM derives from the principal come from `state.username` instead.
|
|
40
|
+
*
|
|
41
|
+
* Nothing about the subject tree changes, and the principal need not exist
|
|
42
|
+
* as a managed object (both measured 2026-09-14).
|
|
43
|
+
*/
|
|
44
|
+
session?: JsonObject;
|
|
45
|
+
}
|
|
46
|
+
/** The mutable draft `beforeRun` is handed. */
|
|
47
|
+
export interface RequestDraft {
|
|
48
|
+
state: {
|
|
49
|
+
shared: JsonObject;
|
|
50
|
+
transient: JsonObject;
|
|
51
|
+
};
|
|
52
|
+
esv: Record<string, string>;
|
|
53
|
+
headers: Record<string, string[]>;
|
|
54
|
+
params: Record<string, string[]>;
|
|
55
|
+
session: JsonObject;
|
|
56
|
+
/** Whether either level asked for a session at all. See mergeChannels. */
|
|
57
|
+
sessionRequested: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** A managed record the harness creates and is therefore responsible for. */
|
|
60
|
+
export interface FixtureSpec {
|
|
61
|
+
type: string;
|
|
62
|
+
record: JsonObject;
|
|
63
|
+
}
|
|
64
|
+
export interface BeforeRunContext<TInput> {
|
|
65
|
+
input: TInput;
|
|
66
|
+
request: RequestDraft;
|
|
67
|
+
fixtures: FixtureCreator;
|
|
68
|
+
}
|
|
69
|
+
export interface FixtureCreator {
|
|
70
|
+
create(type: string, record: JsonObject | JsonObject[]): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What checks and cleanup receive on both lanes. The surface is shared, but
|
|
74
|
+
* record shape is not projected: AIC reads retain tenant-materialized fields.
|
|
75
|
+
*/
|
|
76
|
+
export interface IdmHandle {
|
|
77
|
+
read(resource: string): Promise<JsonObject | null>;
|
|
78
|
+
query(type: string, filter: Readonly<Record<string, JsonValue>>): Promise<JsonObject[]>;
|
|
79
|
+
delete(resource: string): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
export interface CleanupContext<TInput> {
|
|
82
|
+
input: TInput;
|
|
83
|
+
}
|
|
84
|
+
export interface SuiteSpec<TSchema extends z.ZodType> {
|
|
85
|
+
name: string;
|
|
86
|
+
/** Author source, already loaded. */
|
|
87
|
+
script: string;
|
|
88
|
+
/** The outcome vocabulary. Required here — a lease has to declare it. */
|
|
89
|
+
outcomes: readonly string[];
|
|
90
|
+
inputs?: TSchema;
|
|
91
|
+
always?: Channels;
|
|
92
|
+
fixtures?: Readonly<Record<string, FixtureSpec>>;
|
|
93
|
+
beforeRun?: (ctx: BeforeRunContext<z.output<TSchema>>) => void | Promise<void>;
|
|
94
|
+
cleanup?: (idm: IdmHandle, ctx: CleanupContext<z.output<TSchema>>) => Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/** Per-test overrides, merged over the suite's `always`. */
|
|
97
|
+
export interface RunOverrides extends Channels {
|
|
98
|
+
expect: Expect;
|
|
99
|
+
}
|
|
100
|
+
export type { Expect, Given, JsonObject, JsonValue };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { AicIo } from "../aic/tenant.ts";
|
|
3
|
+
import type { TenantProvider } from "../aic/provider.ts";
|
|
4
|
+
import { Lease, type LeaseOptions, type Suite } from "./lease.ts";
|
|
5
|
+
export interface UseLeaseAicOptions {
|
|
6
|
+
id: string;
|
|
7
|
+
realm?: string;
|
|
8
|
+
tenant?: string;
|
|
9
|
+
project?: string;
|
|
10
|
+
/** Where the tenant and its bearer come from; see `resolveTenantProvider`. */
|
|
11
|
+
provider?: TenantProvider;
|
|
12
|
+
unsupported?: "fail" | "skip";
|
|
13
|
+
}
|
|
14
|
+
export interface UseLeaseOptions extends Partial<Omit<LeaseOptions, "runner" | "lane" | "realm">> {
|
|
15
|
+
spawnTimeoutMs?: number;
|
|
16
|
+
aic?: UseLeaseAicOptions;
|
|
17
|
+
/** Injected fake seam for adapter tests; production uses the default AIC I/O. */
|
|
18
|
+
aicIo?: AicIo;
|
|
19
|
+
/** Injected filesystem seam for adapter tests. */
|
|
20
|
+
aicStateDir?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The env var that opts a run into the tenant lane. */
|
|
23
|
+
export declare const AIC_LANE_ENV = "AIC_SCRIPT_TESTER_AIC";
|
|
24
|
+
/**
|
|
25
|
+
* Spread into `useLease` options to run both lanes when `AIC_SCRIPT_TESTER_AIC=1`.
|
|
26
|
+
*
|
|
27
|
+
* Off by default, deliberately. The tenant lane needs an unlocked agent and
|
|
28
|
+
* network, which CI has neither of, and a checkout without them should still
|
|
29
|
+
* be able to run the whole suite offline — so opting in is a per-invocation
|
|
30
|
+
* decision, not a property of the file:
|
|
31
|
+
*
|
|
32
|
+
* npm test # local lane only, as before
|
|
33
|
+
* AIC_SCRIPT_TESTER_AIC=1 npm test # both lanes, against the sandbox
|
|
34
|
+
*
|
|
35
|
+
* `id` must be unique per file: it seeds the deterministic resource ids, and
|
|
36
|
+
* one AIC lease per file is enforced.
|
|
37
|
+
*/
|
|
38
|
+
export declare function aicWhenEnabled(id: string, realm?: string): {
|
|
39
|
+
aic?: UseLeaseAicOptions;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Take a lease for one test file, and register its own lifecycle.
|
|
43
|
+
*
|
|
44
|
+
* The hooks are registered here rather than left to the author because
|
|
45
|
+
* forgetting teardown is the one mistake that leaks tenant state, and it
|
|
46
|
+
* leaks silently — the suite still passes. Owning the hooks makes the leak
|
|
47
|
+
* impossible to cause by omission. The same applies to failure records: the
|
|
48
|
+
* author must not have to remember to write the transaction id down.
|
|
49
|
+
*/
|
|
50
|
+
export declare function useLease<TSchema extends z.ZodType>(suite: Suite<TSchema>, options?: UseLeaseOptions): Lease<TSchema>;
|
|
51
|
+
export declare function claimAicLeaseForFile(file: string, id: string): void;
|
|
52
|
+
export declare function releaseAicLeaseForFile(file: string, id?: string): void;
|