isc-transforms-mcp 1.0.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.
Files changed (63) hide show
  1. package/JSONS/authoritative-operation-catalog.json +280 -0
  2. package/JSONS/sailpoint.isc.transforms.accountAttribute.schema.json +164 -0
  3. package/JSONS/sailpoint.isc.transforms.base64Decode.schema.json +37 -0
  4. package/JSONS/sailpoint.isc.transforms.base64Encode.schema.json +32 -0
  5. package/JSONS/sailpoint.isc.transforms.concat.schema.json +109 -0
  6. package/JSONS/sailpoint.isc.transforms.conditional.schema.json +161 -0
  7. package/JSONS/sailpoint.isc.transforms.dateCompare.schema.json +159 -0
  8. package/JSONS/sailpoint.isc.transforms.dateFormat.schema.json +101 -0
  9. package/JSONS/sailpoint.isc.transforms.dateMath.schema.json +119 -0
  10. package/JSONS/sailpoint.isc.transforms.decomposeDiacriticalMarks.schema.json +92 -0
  11. package/JSONS/sailpoint.isc.transforms.displayName.schema.json +42 -0
  12. package/JSONS/sailpoint.isc.transforms.e164phone.schema.json +107 -0
  13. package/JSONS/sailpoint.isc.transforms.firstValid.schema.json +129 -0
  14. package/JSONS/sailpoint.isc.transforms.generateRandomString.schema.json +94 -0
  15. package/JSONS/sailpoint.isc.transforms.getEndOfString.schema.json +118 -0
  16. package/JSONS/sailpoint.isc.transforms.getReferenceIdentityAttribute.schema.json +79 -0
  17. package/JSONS/sailpoint.isc.transforms.identityAttribute.schema.json +104 -0
  18. package/JSONS/sailpoint.isc.transforms.index.schema.json +48 -0
  19. package/JSONS/sailpoint.isc.transforms.indexOf.schema.json +90 -0
  20. package/JSONS/sailpoint.isc.transforms.iso3166.schema.json +103 -0
  21. package/JSONS/sailpoint.isc.transforms.join.schema.json +113 -0
  22. package/JSONS/sailpoint.isc.transforms.lastIndexOf.schema.json +90 -0
  23. package/JSONS/sailpoint.isc.transforms.leftPad.schema.json +96 -0
  24. package/JSONS/sailpoint.isc.transforms.lookup.schema.json +100 -0
  25. package/JSONS/sailpoint.isc.transforms.lower.schema.json +80 -0
  26. package/JSONS/sailpoint.isc.transforms.normalizeNames.schema.json +79 -0
  27. package/JSONS/sailpoint.isc.transforms.randomAlphaNumeric.schema.json +53 -0
  28. package/JSONS/sailpoint.isc.transforms.randomNumeric.schema.json +53 -0
  29. package/JSONS/sailpoint.isc.transforms.reference.schema.json +90 -0
  30. package/JSONS/sailpoint.isc.transforms.replace.schema.json +96 -0
  31. package/JSONS/sailpoint.isc.transforms.replaceAll.schema.json +96 -0
  32. package/JSONS/sailpoint.isc.transforms.rfc5646.schema.json +79 -0
  33. package/JSONS/sailpoint.isc.transforms.rightPad.schema.json +96 -0
  34. package/JSONS/sailpoint.isc.transforms.rule.schema.json +106 -0
  35. package/JSONS/sailpoint.isc.transforms.split.schema.json +103 -0
  36. package/JSONS/sailpoint.isc.transforms.static.schema.json +131 -0
  37. package/JSONS/sailpoint.isc.transforms.substring.schema.json +167 -0
  38. package/JSONS/sailpoint.isc.transforms.trim.schema.json +93 -0
  39. package/JSONS/sailpoint.isc.transforms.upper.schema.json +80 -0
  40. package/JSONS/sailpoint.isc.transforms.usernameGenerator.schema.json +106 -0
  41. package/JSONS/sailpoint.isc.transforms.uuid.schema.json +32 -0
  42. package/LICENSE +21 -0
  43. package/README.md +221 -0
  44. package/bin/isc-transforms-mcp.mjs +3 -0
  45. package/dist/allowlist.js +37 -0
  46. package/dist/config.js +67 -0
  47. package/dist/http/errors.js +19 -0
  48. package/dist/http/iscAuth.js +45 -0
  49. package/dist/http/iscClient.js +73 -0
  50. package/dist/index.js +613 -0
  51. package/dist/logger.js +9 -0
  52. package/dist/redact.js +28 -0
  53. package/dist/transforms/catalog.js +566 -0
  54. package/dist/transforms/explain.js +266 -0
  55. package/dist/transforms/generate.js +551 -0
  56. package/dist/transforms/index.js +9 -0
  57. package/dist/transforms/lint.js +839 -0
  58. package/dist/transforms/normalize.js +96 -0
  59. package/dist/transforms/patterns.js +295 -0
  60. package/dist/transforms/testcases.js +350 -0
  61. package/dist/transforms/validate.js +250 -0
  62. package/dist/util/diff.js +23 -0
  63. package/package.json +76 -0
@@ -0,0 +1,350 @@
1
+ // src/transforms/testcases.ts
2
+ // Per-operation test case generator for SailPoint ISC transforms.
3
+ // Produces illustrative happy-path, null-input, and edge-case examples for
4
+ // manual QA in the ISC transform tester or automated test harnesses.
5
+ // Fully offline — no tenant access required.
6
+ import { toCanonicalType } from "./catalog.js";
7
+ const FACTORIES = {
8
+ static: (t) => {
9
+ const val = t.attributes?.value ?? "VALUE";
10
+ return [
11
+ { description: "Happy path — input is ignored", input_value: "anything", expected_output: val },
12
+ { description: "Null input — still returns static value", input_value: null, expected_output: val },
13
+ { description: "Empty string input — still returns static value", input_value: "", expected_output: val },
14
+ ];
15
+ },
16
+ lower: () => [
17
+ { description: "Lowercase a mixed-case string", input_value: "John.DOE", expected_output: "john.doe" },
18
+ { description: "Already lowercase — no change", input_value: "alice", expected_output: "alice" },
19
+ { description: "Null input", input_value: null, expected_output: null, note: "SailPoint passes null through when input is null." },
20
+ { description: "Empty string", input_value: "", expected_output: "" },
21
+ ],
22
+ upper: () => [
23
+ { description: "Uppercase a mixed-case string", input_value: "john.doe", expected_output: "JOHN.DOE" },
24
+ { description: "Already uppercase — no change", input_value: "ALICE", expected_output: "ALICE" },
25
+ { description: "Null input", input_value: null, expected_output: null },
26
+ { description: "Empty string", input_value: "", expected_output: "" },
27
+ ],
28
+ trim: () => [
29
+ { description: "Leading and trailing spaces removed", input_value: " hello world ", expected_output: "hello world" },
30
+ { description: "No spaces — no change", input_value: "noSpaces", expected_output: "noSpaces" },
31
+ { description: "Only spaces → empty string", input_value: " ", expected_output: "" },
32
+ { description: "Null input", input_value: null, expected_output: null },
33
+ ],
34
+ concat: (t) => {
35
+ const values = t.attributes?.values ?? ["a", "b"];
36
+ const staticParts = values.filter((v) => typeof v === "string");
37
+ const example = staticParts.length ? staticParts.join("") : "John.Doe";
38
+ return [
39
+ { description: "Concatenate two values", input_value: null, expected_output: example, note: "Input is the combined result of all values in the array." },
40
+ { description: "One value is null → concat returns null for that segment", input_value: null, expected_output: null, note: "If any nested transform returns null, that segment is treated as empty string or null depending on ISC version." },
41
+ ];
42
+ },
43
+ firstValid: () => [
44
+ { description: "First value is non-null — returns it", input_value: "preferred@example.com", expected_output: "preferred@example.com" },
45
+ { description: "First value is null, second is non-null", input_value: null, expected_output: "fallback@example.com", note: "Evaluation continues until a non-null value is found." },
46
+ { description: "All values are null — returns null", input_value: null, expected_output: null, note: "If no non-null value exists in the list, the transform returns null." },
47
+ ],
48
+ conditional: (t) => {
49
+ const pos = t.attributes?.positiveCondition ?? "true";
50
+ const neg = t.attributes?.negativeCondition ?? "false";
51
+ const expr = t.attributes?.expression ?? "$var eq value";
52
+ const parts = expr.split(/\beq\b/i);
53
+ const matchVal = (parts[1] ?? "VALUE").trim();
54
+ return [
55
+ { description: `Expression evaluates to true (input matches '${matchVal}')`, input_value: matchVal, expected_output: pos, note: "String comparison is case-sensitive." },
56
+ { description: "Expression evaluates to false (input does not match)", input_value: "something_else", expected_output: neg },
57
+ { description: "Null input — evaluates to false", input_value: null, expected_output: neg, note: "A null operand never equals a non-null string; evaluates as false." },
58
+ ];
59
+ },
60
+ split: (t) => {
61
+ const delim = t.attributes?.delimiter ?? ",";
62
+ const idx = t.attributes?.index ?? 0;
63
+ const sampleInput = ["part0", "part1", "part2"].join(delim);
64
+ const expected = ["part0", "part1", "part2"][idx] ?? "part0";
65
+ return [
66
+ { description: `Split on '${delim}' and return index ${idx}`, input_value: sampleInput, expected_output: expected },
67
+ { description: "Index out of range → returns null", input_value: `only${delim}two`, expected_output: idx >= 2 ? null : "two", note: "ISC returns null if the split index doesn't exist." },
68
+ { description: "Delimiter not found → entire string at index 0 or null", input_value: "no-delimiter-here", expected_output: idx === 0 ? "no-delimiter-here" : null },
69
+ { description: "Null input → null output", input_value: null, expected_output: null },
70
+ ];
71
+ },
72
+ substring: (t) => {
73
+ const begin = t.attributes?.begin ?? 0;
74
+ const end = t.attributes?.end;
75
+ const sample = "HelloWorld";
76
+ const result = end !== undefined ? sample.slice(begin, end) : sample.slice(begin);
77
+ return [
78
+ { description: `Extract from position ${begin}${end !== undefined ? ` to ${end}` : " to end"}`, input_value: sample, expected_output: result },
79
+ { description: "Input shorter than begin index → returns empty string or null", input_value: "Hi", expected_output: begin >= 2 ? null : "Hi".slice(begin, end), note: "ISC may return null for out-of-bounds begin." },
80
+ { description: "Null input → null output", input_value: null, expected_output: null },
81
+ ];
82
+ },
83
+ replace: (t) => {
84
+ const regex = t.attributes?.regex ?? "[^a-zA-Z]";
85
+ const repl = t.attributes?.replacement ?? "";
86
+ return [
87
+ { description: `Replace /${regex}/ with '${repl}'`, input_value: "Hello World!", expected_output: "Hello World!".replace(new RegExp(regex, "g"), repl), note: `Applies global replacement of pattern /${regex}/ with '${repl}'.` },
88
+ { description: "No match — input returned unchanged", input_value: "abc", expected_output: "abc" },
89
+ { description: "Null input → null output", input_value: null, expected_output: null },
90
+ ];
91
+ },
92
+ replaceAll: (t) => {
93
+ const table = t.attributes?.table ?? { "-": "", " ": "_" };
94
+ const keys = Object.keys(table).filter((k) => k !== "default");
95
+ const sampleInput = keys.length ? `a${keys[0]}b` : "a-b";
96
+ const sampleOutput = keys.length ? `a${table[keys[0]] ?? ""}b` : "ab";
97
+ return [
98
+ { description: "Replace characters per table", input_value: sampleInput, expected_output: sampleOutput },
99
+ { description: "No table keys match — input unchanged", input_value: "NOMATCHES", expected_output: "NOMATCHES" },
100
+ { description: "Null input → null output", input_value: null, expected_output: null },
101
+ ];
102
+ },
103
+ lookup: (t) => {
104
+ const table = t.attributes?.table ?? { A: "Alpha", default: "Unknown" };
105
+ const firstKey = Object.keys(table).find((k) => k !== "default") ?? "A";
106
+ const firstVal = table[firstKey] ?? "Alpha";
107
+ const defVal = table["default"] ?? null;
108
+ return [
109
+ { description: `Key '${firstKey}' found in table`, input_value: firstKey, expected_output: firstVal },
110
+ { description: "Key not found — returns 'default' value", input_value: "UNKNOWN_KEY", expected_output: defVal ?? "error", note: defVal ? "Returns the 'default' table entry." : "No 'default' key in table — ISC will throw an error." },
111
+ { description: "Null input → returns 'default' or null", input_value: null, expected_output: defVal, note: "Null input is treated as a lookup miss." },
112
+ ];
113
+ },
114
+ dateFormat: (t) => {
115
+ const inFmt = t.attributes?.inputFormat ?? "EPOCH_TIME_JAVA";
116
+ const outFmt = t.attributes?.outputFormat ?? "ISO8601";
117
+ const epochSample = inFmt === "EPOCH_TIME_JAVA" ? "1700000000000" : "2025-01-15T10:00:00Z";
118
+ const expectedNote = `Convert from ${inFmt} to ${outFmt}.`;
119
+ return [
120
+ { description: "Happy path — valid date input", input_value: epochSample, expected_output: "<formatted date>", note: expectedNote },
121
+ { description: "Invalid date string → error", input_value: "not-a-date", expected_output: "error", note: "ISC throws a parse error for unparseable date strings." },
122
+ { description: "Null input → null output", input_value: null, expected_output: null },
123
+ ];
124
+ },
125
+ dateMath: (t) => {
126
+ const expr = t.attributes?.expression ?? "now+90d";
127
+ return [
128
+ { description: `Apply dateMath expression '${expr}'`, input_value: "2025-01-01T00:00:00Z", expected_output: "<date with math applied>", note: `Expression '${expr}' applied relative to the input date.` },
129
+ { description: "Null input (when expression uses 'now') → current date + offset", input_value: null, expected_output: "<today + offset>", note: "If expression starts with 'now', input is ignored." },
130
+ { description: "Invalid input format → error", input_value: "not-a-date", expected_output: "error", note: "If input is not a valid date string, ISC throws." },
131
+ ];
132
+ },
133
+ dateCompare: (t) => {
134
+ const op = (t.attributes?.operator ?? "LT").toUpperCase();
135
+ const pos = t.attributes?.positiveCondition ?? "true";
136
+ const neg = t.attributes?.negativeCondition ?? "false";
137
+ return [
138
+ { description: `Condition is true (firstDate ${op} secondDate)`, input_value: null, expected_output: pos, note: `Returns positiveCondition when comparison '${op}' is satisfied.` },
139
+ { description: "Condition is false", input_value: null, expected_output: neg, note: `Returns negativeCondition when '${op}' is not satisfied.` },
140
+ { description: "Either date is null → error", input_value: null, expected_output: "error", note: "Both date operands must be non-null." },
141
+ ];
142
+ },
143
+ accountAttribute: (t) => {
144
+ const attr = t.attributes?.attributeName ?? "department";
145
+ const source = t.attributes?.sourceName ?? "HR Source";
146
+ return [
147
+ { description: `Account has attribute '${attr}' in '${source}'`, input_value: null, expected_output: "<attribute value from account>", note: `The transform reads '${attr}' from the first matching account in '${source}'.` },
148
+ { description: "No account linked for this source → null", input_value: null, expected_output: null, note: "If the identity has no account in the named source, the transform returns null." },
149
+ { description: "Multiple accounts → returns based on sort/filter settings", input_value: null, expected_output: "<value from sorted first account>", note: "Controlled by accountSortAttribute, accountSortDescending, accountReturnFirstLink." },
150
+ ];
151
+ },
152
+ identityAttribute: (t) => {
153
+ const name = t.attributes?.name ?? "email";
154
+ return [
155
+ { description: `Identity has attribute '${name}'`, input_value: null, expected_output: "<identity attribute value>", note: `Returns the value of '${name}' from the identity profile.` },
156
+ { description: `Identity attribute '${name}' is not set → null`, input_value: null, expected_output: null },
157
+ ];
158
+ },
159
+ leftPad: (t) => {
160
+ const len = Number(t.attributes?.length ?? 8);
161
+ const pad = t.attributes?.padding ?? "0";
162
+ const sample = "123";
163
+ const padded = sample.padStart(len, pad);
164
+ return [
165
+ { description: `Pad '${sample}' to length ${len} with '${pad}'`, input_value: sample, expected_output: padded },
166
+ { description: "Input already at or beyond length → returned as-is", input_value: "1234567890", expected_output: "1234567890", note: "leftPad does not truncate." },
167
+ { description: "Null input → null output", input_value: null, expected_output: null },
168
+ ];
169
+ },
170
+ rightPad: (t) => {
171
+ const len = Number(t.attributes?.length ?? 8);
172
+ const pad = t.attributes?.padding ?? " ";
173
+ const sample = "abc";
174
+ const padded = sample.padEnd(len, pad);
175
+ return [
176
+ { description: `Pad '${sample}' to length ${len} with '${pad}'`, input_value: sample, expected_output: padded },
177
+ { description: "Input already at or beyond length → returned as-is", input_value: "abcdefghij", expected_output: "abcdefghij" },
178
+ { description: "Null input → null output", input_value: null, expected_output: null },
179
+ ];
180
+ },
181
+ usernameGenerator: (t) => {
182
+ const patterns = t.attributes?.patterns ?? ["${fn}.${ln}"];
183
+ return [
184
+ { description: "First pattern resolves to unique value", input_value: null, expected_output: "<resolved pattern>", note: `Pattern '${patterns[0]}' is evaluated first. If it's unique, it's returned.` },
185
+ { description: "First pattern collides → falls back to next pattern", input_value: null, expected_output: "<next pattern variant>", note: "ISC checks uniqueness automatically when sourceCheck=true." },
186
+ { description: "All patterns exhausted → error", input_value: null, expected_output: "error", note: "If all patterns produce duplicates, ISC throws an error." },
187
+ ];
188
+ },
189
+ e164phone: (t) => {
190
+ const region = t.attributes?.defaultRegion ?? "US";
191
+ return [
192
+ { description: "US number without country code", input_value: "2125551234", expected_output: "+12125551234", note: `Region '${region}' used for country-code insertion.` },
193
+ { description: "Already E.164 formatted", input_value: "+442071234567", expected_output: "+442071234567" },
194
+ { description: "Invalid phone number", input_value: "not-a-phone", expected_output: "error", note: "ISC throws for unparseable phone numbers." },
195
+ { description: "Null input → null output", input_value: null, expected_output: null },
196
+ ];
197
+ },
198
+ iso3166: (t) => {
199
+ const fmt = t.attributes?.format ?? "alpha2";
200
+ const outputs = { alpha2: "US", alpha3: "USA", numeric: "840" };
201
+ const expected = outputs[fmt] ?? "US";
202
+ return [
203
+ { description: `Convert country name 'United States' to ${fmt}`, input_value: "United States", expected_output: expected, note: `Format: ${fmt}.` },
204
+ { description: "Already in target format → returned as-is", input_value: expected, expected_output: expected },
205
+ { description: "Unrecognised country → null or error", input_value: "Narnia", expected_output: null },
206
+ { description: "Null input → null output", input_value: null, expected_output: null },
207
+ ];
208
+ },
209
+ normalizeNames: () => [
210
+ { description: "Patronymic name normalisation (McIntosh)", input_value: "mcintosh", expected_output: "McIntosh" },
211
+ { description: "Toponymic prefix (de la Cruz)", input_value: "de la cruz", expected_output: "De la Cruz", note: "SailPoint follows standard capitalisation rules for toponymic prefixes." },
212
+ { description: "Roman numerals preserved", input_value: "henry viii", expected_output: "Henry VIII" },
213
+ { description: "Null input → null output", input_value: null, expected_output: null },
214
+ ],
215
+ decomposeDiacriticalMarks: () => [
216
+ { description: "Strip accents from é, ü, ñ", input_value: "Ségolène", expected_output: "Segolene" },
217
+ { description: "No accents → unchanged", input_value: "hello", expected_output: "hello" },
218
+ { description: "Null input → null output", input_value: null, expected_output: null },
219
+ ],
220
+ uuid: () => [
221
+ { description: "Returns a random UUID v4", input_value: null, expected_output: "<UUID v4>", note: "Value is non-deterministic; verify format is 8-4-4-4-12 hex." },
222
+ ],
223
+ randomAlphaNumeric: (t) => {
224
+ const len = Number(t.attributes?.length ?? 32);
225
+ return [
226
+ { description: `Generates ${len}-char alphanumeric string`, input_value: null, expected_output: `<${len}-char alphanumeric string>`, note: "Non-deterministic; verify length and character set." },
227
+ ];
228
+ },
229
+ randomNumeric: (t) => {
230
+ const len = Number(t.attributes?.length ?? 10);
231
+ return [
232
+ { description: `Generates ${len}-digit numeric string`, input_value: null, expected_output: `<${len}-digit numeric string>` },
233
+ ];
234
+ },
235
+ indexOf: (t) => {
236
+ const sub = t.attributes?.substring ?? "@";
237
+ return [
238
+ { description: `Find position of '${sub}' in email address`, input_value: `user${sub}example.com`, expected_output: String(`user${sub}example.com`.indexOf(sub)) },
239
+ { description: `'${sub}' not found → -1`, input_value: "no-such-char", expected_output: "-1" },
240
+ { description: "Null input → null", input_value: null, expected_output: null },
241
+ ];
242
+ },
243
+ lastIndexOf: (t) => {
244
+ const sub = t.attributes?.substring ?? "/";
245
+ return [
246
+ { description: `Find last '${sub}' in path`, input_value: `a${sub}b${sub}c`, expected_output: String(`a${sub}b${sub}c`.lastIndexOf(sub)) },
247
+ { description: `'${sub}' not found → -1`, input_value: "no-such-char", expected_output: "-1" },
248
+ { description: "Null input → null", input_value: null, expected_output: null },
249
+ ];
250
+ },
251
+ generateRandomString: (t) => {
252
+ const len = Number(t.attributes?.length ?? 16);
253
+ const nums = String(t.attributes?.includeNumbers ?? "true") === "true";
254
+ const spec = String(t.attributes?.includeSpecialChars ?? "false") === "true";
255
+ return [
256
+ { description: `Generate ${len}-char random string${nums ? " with numbers" : ""}${spec ? " with special chars" : ""}`, input_value: null, expected_output: `<${len}-char random string>`, note: "Uses the 'Cloud Services Deployment Utility' rule. Non-deterministic." },
257
+ ];
258
+ },
259
+ getEndOfString: (t) => {
260
+ const n = Number(t.attributes?.numChars ?? 4);
261
+ return [
262
+ { description: `Return last ${n} characters`, input_value: "Hello World", expected_output: "Hello World".slice(-n) },
263
+ { description: "Input shorter than numChars → whole string returned", input_value: "Hi", expected_output: "Hi", note: `Input has fewer than ${n} chars; whole string is returned.` },
264
+ { description: "Null input → null", input_value: null, expected_output: null },
265
+ ];
266
+ },
267
+ getReferenceIdentityAttribute: (t) => {
268
+ const uid = t.attributes?.uid ?? "manager";
269
+ const attr = t.attributes?.attributeName ?? "email";
270
+ return [
271
+ { description: `Get '${attr}' from referenced identity '${uid}'`, input_value: null, expected_output: `<${attr} of ${uid}>`, note: `Looks up the identity referenced by '${uid}' and returns its '${attr}' attribute.` },
272
+ { description: "Referenced identity not found → null", input_value: null, expected_output: null },
273
+ ];
274
+ },
275
+ rfc5646: (t) => {
276
+ const fmt = t.attributes?.format ?? "alpha2";
277
+ return [
278
+ { description: `Convert locale to RFC5646 (format: ${fmt})`, input_value: "en_US", expected_output: "en-US", note: "Converts underscore locale format to BCP 47 tag." },
279
+ { description: "Null input → null", input_value: null, expected_output: null },
280
+ ];
281
+ },
282
+ reference: (t) => {
283
+ const id = t.attributes?.id ?? "Some Other Transform";
284
+ return [
285
+ { description: `Delegates to transform '${id}'`, input_value: "<any input>", expected_output: "<output of referenced transform>", note: `The referenced transform '${id}' must exist in the tenant.` },
286
+ ];
287
+ },
288
+ base64Encode: () => [
289
+ { description: "Encode plain text", input_value: "hello", expected_output: "aGVsbG8=" },
290
+ { description: "Empty string", input_value: "", expected_output: "" },
291
+ { description: "Null input → null", input_value: null, expected_output: null },
292
+ ],
293
+ base64Decode: () => [
294
+ { description: "Decode base64 string", input_value: "aGVsbG8=", expected_output: "hello" },
295
+ { description: "Invalid base64 → error", input_value: "not!!valid", expected_output: "error" },
296
+ { description: "Null input → null", input_value: null, expected_output: null },
297
+ ],
298
+ join: (t) => {
299
+ const sep = t.attributes?.separator ?? ",";
300
+ return [
301
+ { description: `Join array values with '${sep}'`, input_value: null, expected_output: `val1${sep}val2${sep}val3`, note: "Input is the values array from attributes." },
302
+ ];
303
+ },
304
+ displayName: () => [
305
+ { description: "Returns preferredName if set", input_value: null, expected_output: "<preferredName>", note: "Falls back to givenName if preferredName is null." },
306
+ { description: "preferredName null → returns givenName", input_value: null, expected_output: "<givenName>" },
307
+ ],
308
+ rule: (t) => {
309
+ const rName = t.attributes?.name ?? "My Rule";
310
+ return [
311
+ { description: `Execute custom rule '${rName}'`, input_value: "<input defined by rule>", expected_output: "<rule output>", note: "Rule logic is defined in BeanShell/Java. Test in the ISC rule tester." },
312
+ ];
313
+ },
314
+ };
315
+ // ---------------------------------------------------------------------------
316
+ // Main export
317
+ // ---------------------------------------------------------------------------
318
+ export function generateTestCases(transformJson) {
319
+ if (!transformJson || typeof transformJson !== "object") {
320
+ throw new Error("transformJson must be a JSON object.");
321
+ }
322
+ const rawType = String(transformJson.type ?? "");
323
+ // Resolve rule-backed ops
324
+ let opType = rawType;
325
+ if (rawType === "rule" && typeof transformJson.attributes?.operation === "string") {
326
+ const rb = ["generateRandomString", "getEndOfString", "getReferenceIdentityAttribute"];
327
+ if (rb.includes(transformJson.attributes.operation))
328
+ opType = transformJson.attributes.operation;
329
+ }
330
+ const canonType = toCanonicalType(opType) ?? opType;
331
+ const factory = FACTORIES[canonType] ?? FACTORIES[rawType];
332
+ const cases = factory
333
+ ? factory(transformJson)
334
+ : [
335
+ {
336
+ description: "Happy path",
337
+ input_value: "<sample input>",
338
+ expected_output: "<expected output>",
339
+ note: `No test template available for type '${canonType}'. Write tests based on the official docs.`,
340
+ },
341
+ ];
342
+ return {
343
+ operation_type: canonType,
344
+ transform_name: String(transformJson.name ?? ""),
345
+ test_cases: cases,
346
+ note: "These are illustrative test cases for manual QA in the ISC transform tester. " +
347
+ "Actual results depend on live identity and account data. " +
348
+ "Verify with a real identity that matches each scenario.",
349
+ };
350
+ }
@@ -0,0 +1,250 @@
1
+ // src/transforms/validate.ts
2
+ // AJV-powered JSON Schema validation for SailPoint ISC Transforms.
3
+ // Stage 1 → validate against the index (root shape) schema.
4
+ // Stage 2 → validate against the operation-specific schema.
5
+ //
6
+ // All schemas are loaded from ../JSONS/ at module init and compiled once.
7
+ // Schemas use JSON Schema Draft 2020-12.
8
+ import { readFileSync } from "fs";
9
+ import { join, dirname } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import Ajv2020 from "ajv/dist/2020.js";
12
+ import addFormatsModule from "ajv-formats";
13
+ const addFormats = addFormatsModule.default ?? addFormatsModule;
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ // Both src/transforms/ and dist/transforms/ are two levels deep from the project root.
16
+ const SCHEMAS_DIR = join(__dirname, "../../JSONS");
17
+ // ---------------------------------------------------------------------------
18
+ // Schema file name map: transform type → filename
19
+ // Rule-backed operations have their own dedicated schema files.
20
+ // ---------------------------------------------------------------------------
21
+ const SCHEMA_FILE_MAP = {
22
+ accountAttribute: "sailpoint.isc.transforms.accountAttribute.schema.json",
23
+ base64Decode: "sailpoint.isc.transforms.base64Decode.schema.json",
24
+ base64Encode: "sailpoint.isc.transforms.base64Encode.schema.json",
25
+ concat: "sailpoint.isc.transforms.concat.schema.json",
26
+ conditional: "sailpoint.isc.transforms.conditional.schema.json",
27
+ dateCompare: "sailpoint.isc.transforms.dateCompare.schema.json",
28
+ dateFormat: "sailpoint.isc.transforms.dateFormat.schema.json",
29
+ dateMath: "sailpoint.isc.transforms.dateMath.schema.json",
30
+ decomposeDiacriticalMarks: "sailpoint.isc.transforms.decomposeDiacriticalMarks.schema.json",
31
+ displayName: "sailpoint.isc.transforms.displayName.schema.json",
32
+ e164phone: "sailpoint.isc.transforms.e164phone.schema.json",
33
+ firstValid: "sailpoint.isc.transforms.firstValid.schema.json",
34
+ generateRandomString: "sailpoint.isc.transforms.generateRandomString.schema.json",
35
+ getEndOfString: "sailpoint.isc.transforms.getEndOfString.schema.json",
36
+ getReferenceIdentityAttribute: "sailpoint.isc.transforms.getReferenceIdentityAttribute.schema.json",
37
+ identityAttribute: "sailpoint.isc.transforms.identityAttribute.schema.json",
38
+ indexOf: "sailpoint.isc.transforms.indexOf.schema.json",
39
+ iso3166: "sailpoint.isc.transforms.iso3166.schema.json",
40
+ join: "sailpoint.isc.transforms.join.schema.json",
41
+ lastIndexOf: "sailpoint.isc.transforms.lastIndexOf.schema.json",
42
+ leftPad: "sailpoint.isc.transforms.leftPad.schema.json",
43
+ lookup: "sailpoint.isc.transforms.lookup.schema.json",
44
+ lower: "sailpoint.isc.transforms.lower.schema.json",
45
+ normalizeNames: "sailpoint.isc.transforms.normalizeNames.schema.json",
46
+ randomAlphaNumeric: "sailpoint.isc.transforms.randomAlphaNumeric.schema.json",
47
+ randomNumeric: "sailpoint.isc.transforms.randomNumeric.schema.json",
48
+ reference: "sailpoint.isc.transforms.reference.schema.json",
49
+ replace: "sailpoint.isc.transforms.replace.schema.json",
50
+ replaceAll: "sailpoint.isc.transforms.replaceAll.schema.json",
51
+ rfc5646: "sailpoint.isc.transforms.rfc5646.schema.json",
52
+ rightPad: "sailpoint.isc.transforms.rightPad.schema.json",
53
+ rule: "sailpoint.isc.transforms.rule.schema.json",
54
+ split: "sailpoint.isc.transforms.split.schema.json",
55
+ static: "sailpoint.isc.transforms.static.schema.json",
56
+ substring: "sailpoint.isc.transforms.substring.schema.json",
57
+ trim: "sailpoint.isc.transforms.trim.schema.json",
58
+ upper: "sailpoint.isc.transforms.upper.schema.json",
59
+ usernameGenerator: "sailpoint.isc.transforms.usernameGenerator.schema.json",
60
+ uuid: "sailpoint.isc.transforms.uuid.schema.json",
61
+ };
62
+ // ---------------------------------------------------------------------------
63
+ // AJV setup — compiled once at module init
64
+ // ---------------------------------------------------------------------------
65
+ const ajv = new Ajv2020({
66
+ allErrors: true,
67
+ strict: false, // allow unknown keywords (e.g. 'examples', 'description' on $defs)
68
+ verbose: true,
69
+ });
70
+ addFormats(ajv);
71
+ // Load all JSON Schema files from the JSONS/ folder
72
+ let indexValidator = null;
73
+ const operationValidators = {};
74
+ const schemaLoadWarnings = [];
75
+ function loadSchemas() {
76
+ // Load index (root shape) schema
77
+ try {
78
+ const indexPath = join(SCHEMAS_DIR, "sailpoint.isc.transforms.index.schema.json");
79
+ const indexSchema = JSON.parse(readFileSync(indexPath, "utf-8"));
80
+ indexValidator = ajv.compile(indexSchema);
81
+ }
82
+ catch (e) {
83
+ schemaLoadWarnings.push(`Could not load index schema: ${e?.message ?? e}`);
84
+ }
85
+ // Load each operation schema
86
+ for (const [type, filename] of Object.entries(SCHEMA_FILE_MAP)) {
87
+ try {
88
+ const schemaPath = join(SCHEMAS_DIR, filename);
89
+ const schemaText = readFileSync(schemaPath, "utf-8");
90
+ const schema = JSON.parse(schemaText);
91
+ // Remove $id to avoid AJV URI conflicts when registering multiple schemas
92
+ delete schema.$id;
93
+ operationValidators[type] = ajv.compile(schema);
94
+ }
95
+ catch (e) {
96
+ schemaLoadWarnings.push(`Could not load schema for '${type}': ${e?.message ?? e}`);
97
+ }
98
+ }
99
+ }
100
+ loadSchemas();
101
+ // ---------------------------------------------------------------------------
102
+ // AJV error → readable message
103
+ // ---------------------------------------------------------------------------
104
+ function formatAjvError(err) {
105
+ const path = err.instancePath || err.schemaPath || "";
106
+ let message = err.message ?? "Validation error";
107
+ // Make messages more human-readable
108
+ if (err.keyword === "additionalProperties") {
109
+ const extra = err.params?.additionalProperty;
110
+ message = extra
111
+ ? `Unknown property '${extra}' is not allowed here.`
112
+ : "Unknown additional property not allowed.";
113
+ }
114
+ else if (err.keyword === "required") {
115
+ const missing = err.params?.missingProperty;
116
+ message = missing ? `Missing required field: '${missing}'.` : message;
117
+ }
118
+ else if (err.keyword === "const") {
119
+ message = `Value must be exactly '${err.params?.allowedValue}'.`;
120
+ }
121
+ else if (err.keyword === "enum") {
122
+ const allowed = (err.params?.allowedValues ?? []).join(", ");
123
+ message = `Value must be one of: ${allowed}.`;
124
+ }
125
+ else if (err.keyword === "minLength") {
126
+ message = `Value must be at least ${err.params?.limit} characters.`;
127
+ }
128
+ else if (err.keyword === "type") {
129
+ message = `Type mismatch: expected ${err.params?.type}, got ${typeof err.data}.`;
130
+ }
131
+ else if (err.keyword === "pattern") {
132
+ message = `Value '${String(err.data).slice(0, 60)}' does not match required pattern: ${err.params?.pattern}.`;
133
+ }
134
+ else if (err.keyword === "oneOf") {
135
+ message = `Value must match exactly one of the allowed schemas.`;
136
+ }
137
+ return {
138
+ stage: "index", // will be overridden by caller
139
+ message,
140
+ path: path || undefined,
141
+ raw: { keyword: err.keyword, params: err.params, schemaPath: err.schemaPath },
142
+ };
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Main export
146
+ // ---------------------------------------------------------------------------
147
+ const DOCS_BASE = "https://developer.sailpoint.com/docs/extensibility/transforms/operations";
148
+ const TYPE_TO_DOC_SLUG = {
149
+ accountAttribute: "account-attribute",
150
+ base64Decode: "base64-decode", base64Encode: "base64-encode",
151
+ concat: "concatenation", conditional: "conditional",
152
+ dateCompare: "date-compare", dateFormat: "date-format", dateMath: "date-math",
153
+ decomposeDiacriticalMarks: "decompose-diacritical-marks", displayName: "display-name",
154
+ e164phone: "e-164-phone", firstValid: "first-valid",
155
+ generateRandomString: "generate-random-string", getEndOfString: "get-end-of-string",
156
+ getReferenceIdentityAttribute: "get-reference-identity-attribute",
157
+ identityAttribute: "identity-attribute", indexOf: "index-of", iso3166: "iso3166",
158
+ join: "join", lastIndexOf: "last-index-of", leftPad: "left-pad", lookup: "lookup",
159
+ lower: "lower", normalizeNames: "name-normalizer", randomAlphaNumeric: "random-alphanumeric",
160
+ randomNumeric: "random-numeric", reference: "reference", replace: "replace",
161
+ replaceAll: "replace-all", rfc5646: "rfc5646", rightPad: "right-pad",
162
+ rule: "rule", split: "split", static: "static", substring: "substring",
163
+ trim: "trim", upper: "upper", usernameGenerator: "username-generator", uuid: "uuid-generator",
164
+ };
165
+ export function validateTransform(input) {
166
+ const warnings = [...schemaLoadWarnings];
167
+ const errors = [];
168
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
169
+ return {
170
+ valid: false,
171
+ errors: [{ stage: "index", message: "Transform must be a JSON object." }],
172
+ warnings,
173
+ };
174
+ }
175
+ // ---- Stage 1: Index (root shape) schema ----
176
+ if (indexValidator) {
177
+ const valid = indexValidator(input);
178
+ if (!valid) {
179
+ for (const err of indexValidator.errors ?? []) {
180
+ const formatted = formatAjvError(err);
181
+ formatted.stage = "index";
182
+ errors.push(formatted);
183
+ }
184
+ }
185
+ }
186
+ else {
187
+ warnings.push("Index schema not available; root shape validation skipped.");
188
+ }
189
+ // Determine operation type
190
+ const rawType = String(input.type ?? "").trim();
191
+ // Resolve rule-backed ops: type=rule with known operation attribute
192
+ let opType = rawType;
193
+ if (rawType === "rule" && typeof input.attributes?.operation === "string") {
194
+ const ruleBacked = ["generateRandomString", "getEndOfString", "getReferenceIdentityAttribute"];
195
+ if (ruleBacked.includes(input.attributes.operation)) {
196
+ opType = input.attributes.operation;
197
+ }
198
+ }
199
+ const docSlug = TYPE_TO_DOC_SLUG[opType] ?? TYPE_TO_DOC_SLUG[rawType];
200
+ const docUrl = docSlug ? `${DOCS_BASE}/${docSlug}/` : undefined;
201
+ // ---- Stage 2: Operation-specific schema ----
202
+ const opValidator = operationValidators[opType] ?? operationValidators[rawType];
203
+ if (opValidator) {
204
+ const valid = opValidator(input);
205
+ if (!valid) {
206
+ for (const err of opValidator.errors ?? []) {
207
+ const formatted = formatAjvError(err);
208
+ formatted.stage = "operation";
209
+ errors.push(formatted);
210
+ }
211
+ }
212
+ }
213
+ else if (rawType) {
214
+ warnings.push(`No operation-specific schema found for type '${opType}'. Only root-level validation was performed.`);
215
+ }
216
+ // Deduplicate errors by message+path
217
+ const seen = new Set();
218
+ const deduped = errors.filter((e) => {
219
+ const key = `${e.stage}:${e.path ?? ""}:${e.message}`;
220
+ if (seen.has(key))
221
+ return false;
222
+ seen.add(key);
223
+ return true;
224
+ });
225
+ return {
226
+ valid: deduped.length === 0,
227
+ errors: deduped,
228
+ warnings,
229
+ operation_type: opType || undefined,
230
+ doc_url: docUrl,
231
+ };
232
+ }
233
+ export function getOperationSchema(type) {
234
+ const filename = SCHEMA_FILE_MAP[type];
235
+ if (!filename)
236
+ return null;
237
+ try {
238
+ const schemaPath = join(SCHEMAS_DIR, filename);
239
+ return JSON.parse(readFileSync(schemaPath, "utf-8"));
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ export function listSchemaCoverage() {
246
+ return Object.keys(SCHEMA_FILE_MAP).map((type) => ({
247
+ type,
248
+ hasSchema: Boolean(operationValidators[type]),
249
+ }));
250
+ }
@@ -0,0 +1,23 @@
1
+ // src/util/diff.ts
2
+ import * as fjp from "fast-json-patch";
3
+ /**
4
+ * ESM-safe "compare" extraction.
5
+ * Some builds expose compare as named export, others hang it off default.
6
+ */
7
+ function getCompare() {
8
+ const anyMod = fjp;
9
+ return (anyMod.compare ||
10
+ anyMod.default?.compare ||
11
+ (() => {
12
+ throw new Error("fast-json-patch: compare() not found. Check installed version.");
13
+ }));
14
+ }
15
+ const compare = getCompare();
16
+ /**
17
+ * Returns JSON Patch ops from before -> after (safe for null/undefined).
18
+ */
19
+ export function jsonPatch(before, after) {
20
+ const a = before ?? {};
21
+ const b = after ?? {};
22
+ return compare(a, b);
23
+ }