@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,106 @@
|
|
|
1
|
+
import { isPlainObject } from "../case/util.js";
|
|
2
|
+
export class ProfileShapeError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(`rhino-local: ${message}`);
|
|
5
|
+
this.name = "ProfileShapeError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Normalise `GET /openidm/config/managed` into an `EnvProfile`.
|
|
10
|
+
*
|
|
11
|
+
* Pure, so the wire shape is testable without a tenant. Unknown keys are
|
|
12
|
+
* dropped rather than carried: this profile is consumed by validation, and a
|
|
13
|
+
* field nothing reads is a field that rots silently.
|
|
14
|
+
*/
|
|
15
|
+
export function normaliseManagedConfig(document, meta) {
|
|
16
|
+
if (!isPlainObject(document)) {
|
|
17
|
+
throw new ProfileShapeError("managed config is not an object");
|
|
18
|
+
}
|
|
19
|
+
const objects = document.objects;
|
|
20
|
+
if (!Array.isArray(objects)) {
|
|
21
|
+
throw new ProfileShapeError("managed config has no `objects` array");
|
|
22
|
+
}
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const entry of objects) {
|
|
25
|
+
if (!isPlainObject(entry) || typeof entry.name !== "string") {
|
|
26
|
+
throw new ProfileShapeError("managed object entry has no `name`");
|
|
27
|
+
}
|
|
28
|
+
out[entry.name] = normaliseObject(entry.name, entry.schema);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
tenant: meta.tenant,
|
|
32
|
+
pulledAt: meta.pulledAt,
|
|
33
|
+
sources: [meta.endpoint],
|
|
34
|
+
objects: out,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function normaliseObject(name, schema) {
|
|
38
|
+
if (!isPlainObject(schema)) {
|
|
39
|
+
// An object with no schema block is real (some shipped types carry none).
|
|
40
|
+
// Record it as existing with no properties rather than dropping it: type
|
|
41
|
+
// EXISTENCE is what distinguishes an AIC `null` from a fixture typo, and
|
|
42
|
+
// that distinction must not depend on how richly the type is described.
|
|
43
|
+
return { name, properties: {}, required: [] };
|
|
44
|
+
}
|
|
45
|
+
const properties = {};
|
|
46
|
+
if (isPlainObject(schema.properties)) {
|
|
47
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
48
|
+
properties[key] = normaliseProperty(value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const required = Array.isArray(schema.required)
|
|
52
|
+
? schema.required.filter((item) => typeof item === "string")
|
|
53
|
+
: [];
|
|
54
|
+
return { name, properties, required };
|
|
55
|
+
}
|
|
56
|
+
function normaliseProperty(value) {
|
|
57
|
+
if (!isPlainObject(value)) {
|
|
58
|
+
return { type: "any" };
|
|
59
|
+
}
|
|
60
|
+
const { type, nullable } = normaliseType(value.type);
|
|
61
|
+
const out = { type };
|
|
62
|
+
if (nullable) {
|
|
63
|
+
out.nullable = true;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(value.enum)) {
|
|
66
|
+
out.enum = value.enum;
|
|
67
|
+
}
|
|
68
|
+
if (value.items !== undefined) {
|
|
69
|
+
out.items = normaliseProperty(value.items);
|
|
70
|
+
}
|
|
71
|
+
const targets = normaliseResourceCollection(value.resourceCollection);
|
|
72
|
+
if (targets.length > 0) {
|
|
73
|
+
out.resourceCollection = targets;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* IDM spells a nullable scalar as `["string", "null"]`. Collapse that to one
|
|
79
|
+
* type plus a flag so no consumer has to re-derive it.
|
|
80
|
+
*/
|
|
81
|
+
function normaliseType(raw) {
|
|
82
|
+
if (typeof raw === "string") {
|
|
83
|
+
return { type: raw, nullable: false };
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(raw)) {
|
|
86
|
+
const names = raw.filter((item) => typeof item === "string");
|
|
87
|
+
const concrete = names.filter((item) => item !== "null");
|
|
88
|
+
return {
|
|
89
|
+
type: concrete[0] ?? "any",
|
|
90
|
+
nullable: names.length !== concrete.length,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { type: "any", nullable: false };
|
|
94
|
+
}
|
|
95
|
+
function normaliseResourceCollection(raw) {
|
|
96
|
+
if (!Array.isArray(raw)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const paths = [];
|
|
100
|
+
for (const entry of raw) {
|
|
101
|
+
if (isPlainObject(entry) && typeof entry.path === "string") {
|
|
102
|
+
paths.push(entry.path);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return paths;
|
|
106
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TenantProvider } from "../aic/provider.ts";
|
|
2
|
+
import { type AicIo } from "../aic/tenant.ts";
|
|
3
|
+
import type { EnvProfile } from "./types.ts";
|
|
4
|
+
export declare const MANAGED_CONFIG_ENDPOINT = "/openidm/config/managed";
|
|
5
|
+
export interface PullOptions {
|
|
6
|
+
tenant?: string;
|
|
7
|
+
project?: string;
|
|
8
|
+
/** Where the tenant and its bearer come from; see `resolveTenantProvider`. */
|
|
9
|
+
provider?: TenantProvider;
|
|
10
|
+
io?: AicIo;
|
|
11
|
+
now?: () => Date;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pull one environment's managed-object schema into a normalised profile.
|
|
15
|
+
*
|
|
16
|
+
* One call covers every managed object the tenant defines, which is why this
|
|
17
|
+
* is worth more than per-object fixtures: the breadth comes free.
|
|
18
|
+
*/
|
|
19
|
+
export declare function pullProfile(options?: PullOptions): Promise<{
|
|
20
|
+
profile: EnvProfile;
|
|
21
|
+
path: string;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AicLaneError, amRequest, connectTenant, defaultAicIo, } from "../aic/tenant.js";
|
|
2
|
+
import { projectRoot } from "../project.js";
|
|
3
|
+
import { normaliseManagedConfig } from "./normalise.js";
|
|
4
|
+
import { writeProfile } from "./store.js";
|
|
5
|
+
export const MANAGED_CONFIG_ENDPOINT = "/openidm/config/managed";
|
|
6
|
+
/**
|
|
7
|
+
* Pull one environment's managed-object schema into a normalised profile.
|
|
8
|
+
*
|
|
9
|
+
* One call covers every managed object the tenant defines, which is why this
|
|
10
|
+
* is worth more than per-object fixtures: the breadth comes free.
|
|
11
|
+
*/
|
|
12
|
+
export async function pullProfile(options = {}) {
|
|
13
|
+
const project = options.project ?? projectRoot();
|
|
14
|
+
const io = options.io ?? defaultAicIo(project);
|
|
15
|
+
const session = await connectTenant(io, {
|
|
16
|
+
...(options.tenant !== undefined ? { tenant: options.tenant } : {}),
|
|
17
|
+
...(options.provider !== undefined ? { provider: options.provider } : {}),
|
|
18
|
+
useConfigured: options.io === undefined,
|
|
19
|
+
project,
|
|
20
|
+
});
|
|
21
|
+
const response = await amRequest(io, session, {
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: MANAGED_CONFIG_ENDPOINT,
|
|
24
|
+
});
|
|
25
|
+
if (response.status !== 200) {
|
|
26
|
+
throw new AicLaneError(`GET ${MANAGED_CONFIG_ENDPOINT} returned ${response.status}: ${response.bodyText.slice(0, 300)}`, { status: response.status });
|
|
27
|
+
}
|
|
28
|
+
const now = (options.now ?? (() => new Date()))();
|
|
29
|
+
const profile = normaliseManagedConfig(response.body, {
|
|
30
|
+
tenant: session.tenantName,
|
|
31
|
+
pulledAt: now.toISOString(),
|
|
32
|
+
endpoint: MANAGED_CONFIG_ENDPOINT,
|
|
33
|
+
});
|
|
34
|
+
const path = writeProfile(profile);
|
|
35
|
+
return { profile, path };
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { JsonObject } from "../case/types.ts";
|
|
2
|
+
import type { EnvProfile } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Strict-check every seeded managed record against the environment schema.
|
|
5
|
+
*
|
|
6
|
+
* Runs in Node before the case reaches the JVM, so a fixture typo is reported
|
|
7
|
+
* against the case that owns it. Records are checked in `seed` mode: property
|
|
8
|
+
* names and enum values must be real, but `required` is not demanded — a
|
|
9
|
+
* fixture legitimately carries only the fields the script under test reads.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkSeededManaged(profile: EnvProfile, managed: Record<string, JsonObject[]> | undefined, caseName: string): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { checkRecord, SchemaViolation } from "./validate.js";
|
|
2
|
+
/**
|
|
3
|
+
* Strict-check every seeded managed record against the environment schema.
|
|
4
|
+
*
|
|
5
|
+
* Runs in Node before the case reaches the JVM, so a fixture typo is reported
|
|
6
|
+
* against the case that owns it. Records are checked in `seed` mode: property
|
|
7
|
+
* names and enum values must be real, but `required` is not demanded — a
|
|
8
|
+
* fixture legitimately carries only the fields the script under test reads.
|
|
9
|
+
*/
|
|
10
|
+
export function checkSeededManaged(profile, managed, caseName) {
|
|
11
|
+
if (managed === undefined) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const [collection, rows] of Object.entries(managed)) {
|
|
15
|
+
for (const [index, row] of rows.entries()) {
|
|
16
|
+
const id = typeof row._id === "string" ? row._id : String(index);
|
|
17
|
+
try {
|
|
18
|
+
checkRecord(profile, `${collection}/${id}`, row, "seed");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof SchemaViolation) {
|
|
22
|
+
throw new SchemaViolation(error.kind, error.resource, `case ${JSON.stringify(caseName)}: given.managed[${JSON.stringify(collection)}][${index}]: ${error.message.replace(/^rhino-local: /, "")}`);
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EnvProfile } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Profiles live in the harness state directory, which ignores itself.
|
|
4
|
+
* Managed object and property names are client business vocabulary, and the
|
|
5
|
+
* sensitive-metadata scanner deliberately holds no client-name denylist, so
|
|
6
|
+
* the guard is the location, not a filter.
|
|
7
|
+
*/
|
|
8
|
+
export declare function profilePath(tenant: string, root?: string): string;
|
|
9
|
+
export declare function writeProfile(profile: EnvProfile, root?: string): string;
|
|
10
|
+
export declare function readProfile(tenant: string, root?: string): EnvProfile;
|
|
11
|
+
/** Read a profile if one exists; absent means "run without schema checking". */
|
|
12
|
+
export declare function tryReadProfile(tenant: string, root?: string): EnvProfile | undefined;
|
|
13
|
+
export declare function parseProfile(text: string, path: string): EnvProfile;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureStateSubdir, stateDir } from "../project.js";
|
|
4
|
+
import { ProfileShapeError } from "./normalise.js";
|
|
5
|
+
/**
|
|
6
|
+
* Profiles live in the harness state directory, which ignores itself.
|
|
7
|
+
* Managed object and property names are client business vocabulary, and the
|
|
8
|
+
* sensitive-metadata scanner deliberately holds no client-name denylist, so
|
|
9
|
+
* the guard is the location, not a filter.
|
|
10
|
+
*/
|
|
11
|
+
export function profilePath(tenant, root = stateDir()) {
|
|
12
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tenant)) {
|
|
13
|
+
throw new ProfileShapeError(`tenant name ${JSON.stringify(tenant)} cannot name a profile file`);
|
|
14
|
+
}
|
|
15
|
+
return join(root, "profiles", `${tenant}.json`);
|
|
16
|
+
}
|
|
17
|
+
export function writeProfile(profile, root = stateDir()) {
|
|
18
|
+
const path = profilePath(profile.tenant, root);
|
|
19
|
+
ensureStateSubdir(root, "profiles");
|
|
20
|
+
writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`, {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
mode: 0o600,
|
|
23
|
+
});
|
|
24
|
+
// `mode` applies only when the write creates the file.
|
|
25
|
+
chmodSync(path, 0o600);
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
export function readProfile(tenant, root = stateDir()) {
|
|
29
|
+
const path = profilePath(tenant, root);
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
throw new ProfileShapeError(
|
|
32
|
+
// The tenant is not named: its name defaults to its hostname, and this
|
|
33
|
+
// surfaces in test output.
|
|
34
|
+
`no environment profile for this tenant in ${join(root, "profiles")}. Pull one: aic-script-tester-pull-profile`);
|
|
35
|
+
}
|
|
36
|
+
return parseProfile(readFileSync(path, "utf8"), path);
|
|
37
|
+
}
|
|
38
|
+
/** Read a profile if one exists; absent means "run without schema checking". */
|
|
39
|
+
export function tryReadProfile(tenant, root = stateDir()) {
|
|
40
|
+
return existsSync(profilePath(tenant, root))
|
|
41
|
+
? readProfile(tenant, root)
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
export function parseProfile(text, path) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
throw new ProfileShapeError(`profile at ${path} is not JSON: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof parsed !== "object" ||
|
|
54
|
+
parsed === null ||
|
|
55
|
+
typeof parsed.tenant !== "string" ||
|
|
56
|
+
typeof parsed.objects !== "object") {
|
|
57
|
+
throw new ProfileShapeError(`profile at ${path} is missing tenant/objects`);
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { JsonValue } from "../case/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A normalised snapshot of one environment's managed-object schema.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately NOT the raw `/openidm/config/managed` document. That document
|
|
6
|
+
* carries lifecycle hook SOURCE BODIES, which are client code, and a great deal
|
|
7
|
+
* of UI metadata (`viewable`, `searchable`, `iconClass`, `order`) that no
|
|
8
|
+
* validation here consults. Normalising keeps the artefact small enough to read
|
|
9
|
+
* in a diff and drops the two things we must not accumulate: script source and
|
|
10
|
+
* presentation noise.
|
|
11
|
+
*
|
|
12
|
+
* Lives under `workspace/<tenant>/`, which `.gitignore` covers in full — object
|
|
13
|
+
* and property names are client business vocabulary.
|
|
14
|
+
*/
|
|
15
|
+
export interface EnvProfile {
|
|
16
|
+
/** Tenant context name, as `aic ctx list` reports it. Never a hostname. */
|
|
17
|
+
tenant: string;
|
|
18
|
+
/** ISO-8601. Staleness is the operator's call; nothing here expires. */
|
|
19
|
+
pulledAt: string;
|
|
20
|
+
/** Endpoints this profile was built from, for the audit trail. */
|
|
21
|
+
sources: string[];
|
|
22
|
+
/** Keyed by managed object name, e.g. `alpha_user`. */
|
|
23
|
+
objects: Record<string, ObjectSchema>;
|
|
24
|
+
}
|
|
25
|
+
export interface ObjectSchema {
|
|
26
|
+
name: string;
|
|
27
|
+
properties: Record<string, PropertySchema>;
|
|
28
|
+
/** Property names IDM rejects a create without. */
|
|
29
|
+
required: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface PropertySchema {
|
|
32
|
+
/**
|
|
33
|
+
* Normalised JSON-schema type. IDM writes `["string","null"]` for a nullable
|
|
34
|
+
* scalar; that becomes `type: "string"` plus `nullable: true`, so every
|
|
35
|
+
* consumer sees one spelling.
|
|
36
|
+
*/
|
|
37
|
+
type: string;
|
|
38
|
+
nullable?: boolean;
|
|
39
|
+
/** Present only on an enum-constrained scalar. AIC enforces this on write. */
|
|
40
|
+
enum?: JsonValue[];
|
|
41
|
+
/** Element schema for `type: "array"`. */
|
|
42
|
+
items?: PropertySchema;
|
|
43
|
+
/** `managed/<target>` paths, for `type: "relationship"`. */
|
|
44
|
+
resourceCollection?: string[];
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { JsonObject } from "../case/types.ts";
|
|
2
|
+
import type { EnvProfile, ObjectSchema } from "./types.ts";
|
|
3
|
+
export declare class SchemaViolation extends Error {
|
|
4
|
+
readonly kind: "unknown-type" | "unknown-property" | "missing-required" | "bad-enum";
|
|
5
|
+
readonly resource: string;
|
|
6
|
+
constructor(kind: SchemaViolation["kind"], resource: string, message: string);
|
|
7
|
+
}
|
|
8
|
+
/** `managed/alpha_user/alice` -> `{ type: "managed/alpha_user", id: "alice" }`. */
|
|
9
|
+
export declare function splitResource(resource: string): {
|
|
10
|
+
type: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Object name for a `managed/<name>` resource, or undefined for other containers. */
|
|
14
|
+
export declare function managedObjectName(resourceType: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Does this environment define the object type behind `resource`?
|
|
17
|
+
*
|
|
18
|
+
* This is the whole reason a profile is worth pulling. Without one, the harness
|
|
19
|
+
* cannot tell "the tenant has this type and the record is simply absent"
|
|
20
|
+
* (AIC returns `null`) from "this type does not exist and your fixture has a
|
|
21
|
+
* typo" (AIC also returns `null`, unhelpfully). With one, the two become
|
|
22
|
+
* distinguishable locally, and only the second is worth failing on.
|
|
23
|
+
*/
|
|
24
|
+
export declare function knowsType(profile: EnvProfile, resource: string): boolean;
|
|
25
|
+
export declare function objectFor(profile: EnvProfile, resource: string): ObjectSchema | undefined;
|
|
26
|
+
/** Names a type that is not in the profile, listing near misses. */
|
|
27
|
+
export declare function unknownTypeError(profile: EnvProfile, resource: string): SchemaViolation;
|
|
28
|
+
/**
|
|
29
|
+
* Strict check of a record's properties against the object schema.
|
|
30
|
+
*
|
|
31
|
+
* `mode` picks which half of the rule applies. A seeded fixture is checked for
|
|
32
|
+
* property existence only — it stands in for a record the tenant already holds,
|
|
33
|
+
* and demanding `required` of it would reject every partial fixture. A write is
|
|
34
|
+
* checked for `required` too, because that is what AIC enforces on create.
|
|
35
|
+
*/
|
|
36
|
+
export declare function checkRecord(profile: EnvProfile, resource: string, record: JsonObject, mode: "seed" | "create" | "patch"): void;
|
|
37
|
+
/** True when `value` is a plain record we can check. */
|
|
38
|
+
export declare function isRecord(value: unknown): value is JsonObject;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { isPlainObject } from "../case/util.js";
|
|
2
|
+
/**
|
|
3
|
+
* CREST metadata every managed record carries regardless of the declared
|
|
4
|
+
* schema. Allowed unconditionally so strict property checking does not reject
|
|
5
|
+
* a record the tenant itself would return.
|
|
6
|
+
*/
|
|
7
|
+
const CREST_META = new Set(["_id", "_rev", "_meta", "_ref", "_refProperties"]);
|
|
8
|
+
export class SchemaViolation extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
resource;
|
|
11
|
+
constructor(kind, resource, message) {
|
|
12
|
+
super(`rhino-local: ${message}`);
|
|
13
|
+
this.name = "SchemaViolation";
|
|
14
|
+
this.kind = kind;
|
|
15
|
+
this.resource = resource;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** `managed/alpha_user/alice` -> `{ type: "managed/alpha_user", id: "alice" }`. */
|
|
19
|
+
export function splitResource(resource) {
|
|
20
|
+
const parts = resource.split("/").filter((part) => part.length > 0);
|
|
21
|
+
if (parts.length >= 3) {
|
|
22
|
+
return { type: `${parts[0]}/${parts[1]}`, id: parts.slice(2).join("/") };
|
|
23
|
+
}
|
|
24
|
+
return { type: parts.join("/") };
|
|
25
|
+
}
|
|
26
|
+
/** Object name for a `managed/<name>` resource, or undefined for other containers. */
|
|
27
|
+
export function managedObjectName(resourceType) {
|
|
28
|
+
const [container, name] = resourceType.split("/");
|
|
29
|
+
return container === "managed" && name !== undefined && name.length > 0
|
|
30
|
+
? name
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Does this environment define the object type behind `resource`?
|
|
35
|
+
*
|
|
36
|
+
* This is the whole reason a profile is worth pulling. Without one, the harness
|
|
37
|
+
* cannot tell "the tenant has this type and the record is simply absent"
|
|
38
|
+
* (AIC returns `null`) from "this type does not exist and your fixture has a
|
|
39
|
+
* typo" (AIC also returns `null`, unhelpfully). With one, the two become
|
|
40
|
+
* distinguishable locally, and only the second is worth failing on.
|
|
41
|
+
*/
|
|
42
|
+
export function knowsType(profile, resource) {
|
|
43
|
+
const { type } = splitResource(resource);
|
|
44
|
+
const name = managedObjectName(type);
|
|
45
|
+
return name !== undefined && Object.hasOwn(profile.objects, name);
|
|
46
|
+
}
|
|
47
|
+
export function objectFor(profile, resource) {
|
|
48
|
+
const name = managedObjectName(splitResource(resource).type);
|
|
49
|
+
return name === undefined ? undefined : profile.objects[name];
|
|
50
|
+
}
|
|
51
|
+
/** Names a type that is not in the profile, listing near misses. */
|
|
52
|
+
export function unknownTypeError(profile, resource) {
|
|
53
|
+
const { type } = splitResource(resource);
|
|
54
|
+
const name = managedObjectName(type);
|
|
55
|
+
const known = Object.keys(profile.objects).sort();
|
|
56
|
+
const near = name === undefined ? [] : known.filter((candidate) => close(candidate, name));
|
|
57
|
+
const hint = near.length > 0
|
|
58
|
+
? ` Did you mean ${near.map((item) => `managed/${item}`).join(", ")}?`
|
|
59
|
+
: ` This environment defines ${known.length} managed object${known.length === 1 ? "" : "s"}.`;
|
|
60
|
+
return new SchemaViolation("unknown-type", resource, `openidm: ${JSON.stringify(type)} is not a managed object in environment ${JSON.stringify(profile.tenant)}.${hint}`);
|
|
61
|
+
}
|
|
62
|
+
/** Cheap near-miss test: shared prefix, or one edit apart in length. */
|
|
63
|
+
function close(candidate, name) {
|
|
64
|
+
const a = candidate.toLowerCase();
|
|
65
|
+
const b = name.toLowerCase();
|
|
66
|
+
if (a === b) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (a.startsWith(b) || b.startsWith(a)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return Math.abs(a.length - b.length) <= 1 && sharedPrefix(a, b) >= a.length - 2;
|
|
73
|
+
}
|
|
74
|
+
function sharedPrefix(a, b) {
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < a.length && index < b.length && a[index] === b[index]) {
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
return index;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Strict check of a record's properties against the object schema.
|
|
83
|
+
*
|
|
84
|
+
* `mode` picks which half of the rule applies. A seeded fixture is checked for
|
|
85
|
+
* property existence only — it stands in for a record the tenant already holds,
|
|
86
|
+
* and demanding `required` of it would reject every partial fixture. A write is
|
|
87
|
+
* checked for `required` too, because that is what AIC enforces on create.
|
|
88
|
+
*/
|
|
89
|
+
export function checkRecord(profile, resource, record, mode) {
|
|
90
|
+
const schema = objectFor(profile, resource);
|
|
91
|
+
if (schema === undefined) {
|
|
92
|
+
throw unknownTypeError(profile, resource);
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of Object.entries(record)) {
|
|
95
|
+
if (CREST_META.has(key)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const property = schema.properties[key];
|
|
99
|
+
if (property === undefined) {
|
|
100
|
+
throw new SchemaViolation("unknown-property", resource, `openidm: ${JSON.stringify(key)} is not a property of managed/${schema.name} in environment ${JSON.stringify(profile.tenant)}.`);
|
|
101
|
+
}
|
|
102
|
+
checkEnum(resource, schema.name, key, property.enum, value);
|
|
103
|
+
if (property.items?.enum !== undefined && Array.isArray(value)) {
|
|
104
|
+
for (const element of value) {
|
|
105
|
+
checkEnum(resource, schema.name, `${key}[]`, property.items.enum, element);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (mode === "create") {
|
|
110
|
+
for (const key of schema.required) {
|
|
111
|
+
if (!Object.hasOwn(record, key)) {
|
|
112
|
+
throw new SchemaViolation("missing-required", resource, `openidm: managed/${schema.name} requires ${JSON.stringify(key)}; AIC rejects a create without it.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function checkEnum(resource, objectName, key, allowed, value) {
|
|
118
|
+
if (allowed === undefined || value === null || value === undefined) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (allowed.some((candidate) => candidate === value)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw new SchemaViolation("bad-enum", resource, `openidm: ${JSON.stringify(value)} is not an allowed value for managed/${objectName}.${key} (allowed: ${allowed.map((item) => JSON.stringify(item)).join(", ")}).`);
|
|
125
|
+
}
|
|
126
|
+
/** True when `value` is a plain record we can check. */
|
|
127
|
+
export function isRecord(value) {
|
|
128
|
+
return isPlainObject(value);
|
|
129
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Overrides {@link projectRoot}. */
|
|
2
|
+
export declare const PROJECT_ENV = "AIC_SCRIPT_TESTER_PROJECT";
|
|
3
|
+
/** Overrides {@link stateDir}. */
|
|
4
|
+
export declare const STATE_DIR_ENV = "AIC_SCRIPT_TESTER_STATE_DIR";
|
|
5
|
+
/**
|
|
6
|
+
* The project the harness is running for: `AIC_SCRIPT_TESTER_PROJECT`, else the
|
|
7
|
+
* nearest ancestor of the working directory holding `.git` (a directory, or
|
|
8
|
+
* the file a worktree has), else the working directory.
|
|
9
|
+
*
|
|
10
|
+
* Not the package's own location — installed, that is inside
|
|
11
|
+
* `node_modules`, and nothing the harness writes belongs there.
|
|
12
|
+
*/
|
|
13
|
+
export declare function projectRoot(env?: NodeJS.ProcessEnv, cwd?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Where the harness keeps per-project state: failure dumps, show-log views,
|
|
16
|
+
* environment profiles. `AIC_SCRIPT_TESTER_STATE_DIR`, else
|
|
17
|
+
* `<project>/.aic-script-tester`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stateDir(env?: NodeJS.ProcessEnv, cwd?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Create `dir` (under the state directory) and make sure the state directory
|
|
22
|
+
* ignores itself. Everything in it can name a live tenant — log bodies,
|
|
23
|
+
* hostnames, managed-object vocabulary — and a consuming repo's `.gitignore`
|
|
24
|
+
* knows nothing about it, so the guard travels with the directory rather than
|
|
25
|
+
* relying on the host repo (the same move as `src/backup.rs`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureStateSubdir(root: string, ...parts: string[]): string;
|
|
28
|
+
/**
|
|
29
|
+
* `<state>/failures/failures.jsonl`. `create` makes the directory (and the
|
|
30
|
+
* state directory's `.gitignore`); a read does not.
|
|
31
|
+
*/
|
|
32
|
+
export declare function failuresPath(options?: {
|
|
33
|
+
create?: boolean;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
}): string;
|
|
36
|
+
/** `<state>/failures/latest-logs.json`, the view `show-log` opens. */
|
|
37
|
+
export declare function latestLogsPath(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
/** Overrides {@link projectRoot}. */
|
|
4
|
+
export const PROJECT_ENV = "AIC_SCRIPT_TESTER_PROJECT";
|
|
5
|
+
/** Overrides {@link stateDir}. */
|
|
6
|
+
export const STATE_DIR_ENV = "AIC_SCRIPT_TESTER_STATE_DIR";
|
|
7
|
+
/**
|
|
8
|
+
* The project the harness is running for: `AIC_SCRIPT_TESTER_PROJECT`, else the
|
|
9
|
+
* nearest ancestor of the working directory holding `.git` (a directory, or
|
|
10
|
+
* the file a worktree has), else the working directory.
|
|
11
|
+
*
|
|
12
|
+
* Not the package's own location — installed, that is inside
|
|
13
|
+
* `node_modules`, and nothing the harness writes belongs there.
|
|
14
|
+
*/
|
|
15
|
+
export function projectRoot(env = process.env, cwd = process.cwd()) {
|
|
16
|
+
const configured = env[PROJECT_ENV];
|
|
17
|
+
if (configured !== undefined && configured !== "") {
|
|
18
|
+
return resolve(configured);
|
|
19
|
+
}
|
|
20
|
+
let dir = resolve(cwd);
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (existsSync(join(dir, ".git"))) {
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
const parent = dirname(dir);
|
|
26
|
+
if (parent === dir) {
|
|
27
|
+
return resolve(cwd);
|
|
28
|
+
}
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where the harness keeps per-project state: failure dumps, show-log views,
|
|
34
|
+
* environment profiles. `AIC_SCRIPT_TESTER_STATE_DIR`, else
|
|
35
|
+
* `<project>/.aic-script-tester`.
|
|
36
|
+
*/
|
|
37
|
+
export function stateDir(env = process.env, cwd) {
|
|
38
|
+
const configured = env[STATE_DIR_ENV];
|
|
39
|
+
if (configured !== undefined && configured !== "") {
|
|
40
|
+
return resolve(configured);
|
|
41
|
+
}
|
|
42
|
+
return join(projectRoot(env, cwd), ".aic-script-tester");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create `dir` (under the state directory) and make sure the state directory
|
|
46
|
+
* ignores itself. Everything in it can name a live tenant — log bodies,
|
|
47
|
+
* hostnames, managed-object vocabulary — and a consuming repo's `.gitignore`
|
|
48
|
+
* knows nothing about it, so the guard travels with the directory rather than
|
|
49
|
+
* relying on the host repo (the same move as `src/backup.rs`).
|
|
50
|
+
*/
|
|
51
|
+
export function ensureStateSubdir(root, ...parts) {
|
|
52
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
53
|
+
const ignore = join(root, ".gitignore");
|
|
54
|
+
if (!existsSync(ignore)) {
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(ignore, "*\n", { encoding: "utf8", flag: "wx" });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// A concurrent writer got there first; its file says the same thing.
|
|
60
|
+
if (error.code !== "EEXIST") {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// An existing file is only trusted if it still ignores everything: one
|
|
66
|
+
// edited down, or with a `!` re-include after the `*`, would let the next
|
|
67
|
+
// profile or log dump be committed. The last matching rule wins, so a
|
|
68
|
+
// trailing `*` restores the guarantee whatever came before it.
|
|
69
|
+
const text = readFileSync(ignore, "utf8");
|
|
70
|
+
const rules = text
|
|
71
|
+
.split(/\r?\n/)
|
|
72
|
+
.map((line) => line.trim())
|
|
73
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
74
|
+
if (rules.at(-1) !== "*") {
|
|
75
|
+
appendFileSync(ignore, `${text === "" || text.endsWith("\n") ? "" : "\n"}*\n`);
|
|
76
|
+
}
|
|
77
|
+
const dir = join(root, ...parts);
|
|
78
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
79
|
+
return dir;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `<state>/failures/failures.jsonl`. `create` makes the directory (and the
|
|
83
|
+
* state directory's `.gitignore`); a read does not.
|
|
84
|
+
*/
|
|
85
|
+
export function failuresPath(options = {}) {
|
|
86
|
+
const root = stateDir(options.env);
|
|
87
|
+
const dir = options.create === true ? ensureStateSubdir(root, "failures") : join(root, "failures");
|
|
88
|
+
return join(dir, "failures.jsonl");
|
|
89
|
+
}
|
|
90
|
+
/** `<state>/failures/latest-logs.json`, the view `show-log` opens. */
|
|
91
|
+
export function latestLogsPath(env = process.env) {
|
|
92
|
+
return join(ensureStateSubdir(stateDir(env), "failures"), "latest-logs.json");
|
|
93
|
+
}
|