@uipath/data-fabric-tool 1.198.0 → 1.199.0-preview.104

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.
@@ -0,0 +1,614 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import {
3
+ type E2EManifest,
4
+ MANIFEST_PATH,
5
+ } from "../../../tests/e2e-shared/global-setup";
6
+ import {
7
+ EXEC_TIMEOUT,
8
+ extractJson,
9
+ localCliRawWithArgs,
10
+ readManifest,
11
+ SETUP_TIMEOUT,
12
+ } from "../../../tests/e2e-shared/helpers";
13
+
14
+ const manifest = readManifest<E2EManifest>(MANIFEST_PATH);
15
+ const { tempDir } = manifest.sharedInstall;
16
+
17
+ // Owning e2e suite for scenarios/data-fabric-tool/entities-lifecycle.md.
18
+ //
19
+ // Every S<n> in that scenario file maps one-to-one to an it() here (see
20
+ // scenarios/README.md — "Scenario ↔ e2e mapping"). Same runtime pattern as
21
+ // packages/integrationservice-tool/tests: `beforeAll` probes DF access, derives
22
+ // read fixtures from what the tenant already has, and creates a scratch write
23
+ // fixture with a unique name. Live scenarios skip via `ctx.skip()` when the
24
+ // probe was denied or the derived fixture is missing. Pre-network guard
25
+ // scenarios always run. `afterAll` cleans up the scratch entity.
26
+ //
27
+ // The shared install's client-credentials login is enough — DF accepts
28
+ // application tokens (unlike IS design-time). No user-auth bridge needed.
29
+
30
+ // noExplicitAny is off for test files (biome.json); e2e parses dynamic CLI JSON.
31
+ type Json = any;
32
+
33
+ const AUTH_MARKERS = [
34
+ "forbidden",
35
+ "403",
36
+ "401",
37
+ "unauthorized",
38
+ "not logged in",
39
+ "auth_token",
40
+ "authentication",
41
+ "insufficient_scope",
42
+ ];
43
+
44
+ function isAuthDenial(res: { stdout: string; exitCode: number }): boolean {
45
+ if (res.exitCode === 0) return false;
46
+ const parsed = tryParse(res.stdout);
47
+ const hay =
48
+ `${parsed.Message ?? ""} ${parsed.Instructions ?? ""}`.toLowerCase();
49
+ return AUTH_MARKERS.some((m) => hay.includes(m));
50
+ }
51
+
52
+ function runDf(argv: string[]): { stdout: string; exitCode: number } {
53
+ const res = localCliRawWithArgs(tempDir, [
54
+ "df",
55
+ ...argv,
56
+ "--output",
57
+ "json",
58
+ ]);
59
+ return { stdout: res.stdout, exitCode: res.exitCode };
60
+ }
61
+
62
+ function cli(argv: string[]): Json {
63
+ const res = runDf(argv);
64
+ if (res.exitCode !== 0) {
65
+ throw new Error(
66
+ `[df ${argv.join(" ")}] exited ${res.exitCode}\n${res.stdout}`,
67
+ );
68
+ }
69
+ return extractJson(res.stdout) as Json;
70
+ }
71
+
72
+ function tryParse(stdout: string): Json {
73
+ try {
74
+ return extractJson(stdout) as Json;
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ function expectFailure(
81
+ result: { stdout: string; exitCode: number },
82
+ label: string,
83
+ ): Json {
84
+ if (result.exitCode !== 1) {
85
+ throw new Error(
86
+ `[${label}] expected exit 1, got ${result.exitCode}. stdout:\n${result.stdout}`,
87
+ );
88
+ }
89
+ const parsed = extractJson(result.stdout) as Json;
90
+ expect(parsed.Result, label).toBe("Failure");
91
+ return parsed;
92
+ }
93
+
94
+ function runOrc(argv: string[]): { stdout: string; exitCode: number } {
95
+ const res = localCliRawWithArgs(tempDir, [
96
+ "or",
97
+ ...argv,
98
+ "--output",
99
+ "json",
100
+ ]);
101
+ return { stdout: res.stdout, exitCode: res.exitCode };
102
+ }
103
+
104
+ // Runtime-derived fixtures.
105
+ let dfAccessDenied = false;
106
+ let anyEntity: Json | undefined;
107
+ let scratchEntityId: string | undefined;
108
+ // Scratch folder + folder-scoped entity for S3 — created via `uip or folders
109
+ // create`, matches the repo-wide pattern used by every folder-scoped e2e
110
+ // (see e.g. orchestrator-tool/tests/assets.e2e.test.ts).
111
+ let scratchFolderKey: string | undefined;
112
+ let folderScopedEntityId: string | undefined;
113
+ // Unique-per-run suffix. Repo convention (see orchestrator-tool/tests/*)
114
+ // uses Date.now(); process.pid alone can repeat across sequential runs and
115
+ // collide with orphans a prior failed run left behind.
116
+ const RUN_ID = `${process.pid}_${Date.now()}`;
117
+ const scratchName = `E2eDfEntities${RUN_ID}`;
118
+ const folderScopedEntityName = `E2eDfEntitiesFolder${RUN_ID}`;
119
+ const scratchFolderName = `e2e-df-entities-folder-${RUN_ID}`;
120
+
121
+ describe("df entities — entities lifecycle", () => {
122
+ beforeAll(() => {
123
+ const probe = runDf(["entities", "list"]);
124
+ if (probe.exitCode !== 0) {
125
+ dfAccessDenied = isAuthDenial(probe);
126
+ if (!dfAccessDenied) {
127
+ console.warn(
128
+ `[df entities e2e] Probe failed for a non-auth reason; live scenarios will run and assert.\n${probe.stdout}`,
129
+ );
130
+ }
131
+ return;
132
+ }
133
+ const list = extractJson(probe.stdout) as Json;
134
+ const entities: Json[] = Array.isArray(list.Data) ? list.Data : [];
135
+ anyEntity = entities[0];
136
+
137
+ // Scratch tenant-level entity — doubles as the write-scope probe.
138
+ // Read passed but the token lacks entity-write scope → mark
139
+ // dfAccessDenied so every live test soft-skips together instead of
140
+ // one write test blowing up mid-run.
141
+ const create = runDf([
142
+ "entities",
143
+ "create",
144
+ scratchName,
145
+ "--body",
146
+ '{"fields":[{"fieldName":"note","type":"STRING"}]}',
147
+ ]);
148
+ if (isAuthDenial(create)) {
149
+ dfAccessDenied = true;
150
+ return;
151
+ }
152
+ if (create.exitCode === 0) {
153
+ scratchEntityId = (extractJson(create.stdout) as Json).Data?.Id;
154
+ }
155
+
156
+ // Scratch folder + folder-scoped entity for S3. Failure here
157
+ // (Orchestrator not enabled or no folder-create permission) leaves S3
158
+ // in ctx.skip() — the rest of the suite is unaffected.
159
+ const createFolder = runOrc(["folders", "create", scratchFolderName]);
160
+ if (createFolder.exitCode !== 0) return;
161
+ scratchFolderKey = (extractJson(createFolder.stdout) as Json).Data?.Key;
162
+ if (!scratchFolderKey) return;
163
+
164
+ const createFolderEntity = runDf([
165
+ "entities",
166
+ "create",
167
+ folderScopedEntityName,
168
+ "--folder-key",
169
+ scratchFolderKey,
170
+ "--body",
171
+ '{"fields":[{"fieldName":"note","type":"STRING"}]}',
172
+ ]);
173
+ if (createFolderEntity.exitCode === 0) {
174
+ folderScopedEntityId = (
175
+ extractJson(createFolderEntity.stdout) as Json
176
+ ).Data?.Id;
177
+ }
178
+ }, SETUP_TIMEOUT);
179
+
180
+ afterAll(() => {
181
+ if (scratchEntityId) {
182
+ runDf([
183
+ "entities",
184
+ "delete",
185
+ scratchEntityId,
186
+ "--yes",
187
+ "--reason",
188
+ "e2e cleanup",
189
+ ]);
190
+ }
191
+ if (folderScopedEntityId && scratchFolderKey) {
192
+ runDf([
193
+ "entities",
194
+ "delete",
195
+ folderScopedEntityId,
196
+ "--folder-key",
197
+ scratchFolderKey,
198
+ "--yes",
199
+ "--reason",
200
+ "e2e cleanup",
201
+ ]);
202
+ }
203
+ if (scratchFolderKey) {
204
+ runOrc(["folders", "delete", scratchFolderKey, "--yes"]);
205
+ }
206
+ });
207
+
208
+ // ── S1 ── list, DF access needed ────────────────────────────────────────
209
+ it(
210
+ "S1: list tenant-level entities",
211
+ (ctx) => {
212
+ if (dfAccessDenied) return ctx.skip();
213
+ const out = cli(["entities", "list"]);
214
+ expect(out.Result).toBe("Success");
215
+ expect(out.Code).toBe("EntityList");
216
+ expect(Array.isArray(out.Data)).toBe(true);
217
+ },
218
+ EXEC_TIMEOUT,
219
+ );
220
+
221
+ // ── S2 ── list --native-only ────────────────────────────────────────────
222
+ it(
223
+ "S2: list only native entities",
224
+ (ctx) => {
225
+ if (dfAccessDenied) return ctx.skip();
226
+ const out = cli(["entities", "list", "--native-only"]);
227
+ expect(out.Result).toBe("Success");
228
+ expect(out.Code).toBe("EntityList");
229
+ for (const e of out.Data as Json[]) {
230
+ const ext = Array.isArray(e.ExternalFields)
231
+ ? e.ExternalFields.length
232
+ : 0;
233
+ expect(ext).toBe(0);
234
+ }
235
+ },
236
+ EXEC_TIMEOUT,
237
+ );
238
+
239
+ // ── S3 ── list --folder-key, uses scratch folder fixture ────────────────
240
+ it(
241
+ "S3: list entities from a specific folder",
242
+ (ctx) => {
243
+ if (dfAccessDenied) return ctx.skip();
244
+ if (!scratchFolderKey || !folderScopedEntityId) {
245
+ return ctx.skip();
246
+ }
247
+ const out = cli([
248
+ "entities",
249
+ "list",
250
+ "--folder-key",
251
+ scratchFolderKey,
252
+ ]);
253
+ expect(out.Result).toBe("Success");
254
+ expect(out.Code).toBe("EntityList");
255
+ for (const e of out.Data as Json[]) {
256
+ expect(e.FolderId).toBe(scratchFolderKey);
257
+ }
258
+ const ids = (out.Data as Json[]).map((e) => e.Id);
259
+ expect(ids).toContain(folderScopedEntityId);
260
+ },
261
+ EXEC_TIMEOUT,
262
+ );
263
+
264
+ // ── S4 ── list --include-folders ────────────────────────────────────────
265
+ it(
266
+ "S4: list tenant plus every visible folder in one call",
267
+ (ctx) => {
268
+ if (dfAccessDenied) return ctx.skip();
269
+ const out = cli(["entities", "list", "--include-folders"]);
270
+ expect(out.Result).toBe("Success");
271
+ expect(out.Code).toBe("EntityList");
272
+ expect(Array.isArray(out.Data)).toBe(true);
273
+ },
274
+ EXEC_TIMEOUT,
275
+ );
276
+
277
+ // ── S5 ── folder-scope guard, pre-network ───────────────────────────────
278
+ it(
279
+ "S5: --folder-key and --include-folders are mutually exclusive",
280
+ () => {
281
+ const parsed = expectFailure(
282
+ runDf([
283
+ "entities",
284
+ "list",
285
+ "--folder-key",
286
+ "pre-network",
287
+ "--include-folders",
288
+ ]),
289
+ "list mutex",
290
+ );
291
+ expect(parsed.Message).toBe(
292
+ "--folder-key and --include-folders are mutually exclusive",
293
+ );
294
+ expect(parsed.Instructions.length).toBeGreaterThan(0);
295
+ },
296
+ EXEC_TIMEOUT,
297
+ );
298
+
299
+ // ── S6 ── get by id, uses runtime fixture ───────────────────────────────
300
+ it(
301
+ "S6: get an entity schema by id",
302
+ (ctx) => {
303
+ if (dfAccessDenied) return ctx.skip();
304
+ if (!anyEntity) return ctx.skip();
305
+ const out = cli(["entities", "get", anyEntity.Id]);
306
+ expect(out.Result).toBe("Success");
307
+ expect(out.Code).toBe("EntitySchema");
308
+ expect(out.Data.Id).toBe(anyEntity.Id);
309
+ expect(Array.isArray(out.Data.Fields)).toBe(true);
310
+ },
311
+ EXEC_TIMEOUT,
312
+ );
313
+
314
+ // ── S7 ── create, transient scratch cleaned up in the same test ─────────
315
+ it(
316
+ "S7: create an entity from an inline body",
317
+ (ctx) => {
318
+ if (dfAccessDenied) return ctx.skip();
319
+ const name = `${scratchName}S7`;
320
+ const created = cli([
321
+ "entities",
322
+ "create",
323
+ name,
324
+ "--body",
325
+ '{"fields":[{"fieldName":"amount","type":"DECIMAL","decimalPrecision":2}]}',
326
+ ]);
327
+ try {
328
+ expect(created.Result).toBe("Success");
329
+ expect(created.Code).toBe("EntityCreated");
330
+ expect(typeof created.Data.Id).toBe("string");
331
+ } finally {
332
+ if (created.Data?.Id) {
333
+ runDf([
334
+ "entities",
335
+ "delete",
336
+ created.Data.Id,
337
+ "--yes",
338
+ "--reason",
339
+ "S7 cleanup",
340
+ ]);
341
+ }
342
+ }
343
+ },
344
+ EXEC_TIMEOUT,
345
+ );
346
+
347
+ // ── S8 ── create rejects malformed, pre-network ─────────────────────────
348
+ it(
349
+ "S8: create rejects malformed payloads before the API call",
350
+ () => {
351
+ const rows: Array<{
352
+ label: string;
353
+ body: string;
354
+ message: string;
355
+ }> = [
356
+ {
357
+ label: "non-object body",
358
+ body: "[]",
359
+ message: "Entity definition must be a JSON object",
360
+ },
361
+ {
362
+ label: "missing fields array",
363
+ body: '{"displayName":"X"}',
364
+ message: "Entity definition must include a 'fields' array",
365
+ },
366
+ {
367
+ label: "field without fieldName",
368
+ body: '{"fields":[{"type":"STRING"}]}',
369
+ message: "Each field must include a 'fieldName' string",
370
+ },
371
+ {
372
+ label: "unknown field type",
373
+ body: '{"fields":[{"fieldName":"amount","type":"NOT_A_TYPE"}]}',
374
+ message: "Invalid field type in fields",
375
+ },
376
+ ];
377
+
378
+ for (const row of rows) {
379
+ const parsed = expectFailure(
380
+ runDf(["entities", "create", "Widget", "--body", row.body]),
381
+ row.label,
382
+ );
383
+ expect(parsed.Message, row.label).toBe(row.message);
384
+ }
385
+ },
386
+ EXEC_TIMEOUT,
387
+ );
388
+
389
+ // ── S9 ── update composition on a transient scratch ─────────────────────
390
+ it(
391
+ "S9: update adds, edits, and removes fields in one call",
392
+ (ctx) => {
393
+ if (dfAccessDenied) return ctx.skip();
394
+ const name = `${scratchName}S9`;
395
+ const created = cli([
396
+ "entities",
397
+ "create",
398
+ name,
399
+ "--body",
400
+ '{"fields":[{"fieldName":"old_col","type":"STRING"}]}',
401
+ ]);
402
+ const id: string = created.Data.Id;
403
+ try {
404
+ const schema = cli(["entities", "get", id]);
405
+ const oldField = (schema.Data.Fields as Json[]).find(
406
+ (f) => f.Name === "old_col",
407
+ );
408
+ if (!oldField) throw new Error("scratch missing 'old_col'");
409
+ const body = JSON.stringify({
410
+ addFields: [{ fieldName: "status", type: "STRING" }],
411
+ updateFields: [
412
+ { id: oldField.Id, displayName: "Old Column" },
413
+ ],
414
+ removeFields: [{ fieldName: "old_col" }],
415
+ });
416
+ const updated = cli([
417
+ "entities",
418
+ "update",
419
+ id,
420
+ "--body",
421
+ body,
422
+ "--yes",
423
+ "--reason",
424
+ "S9 composition test",
425
+ ]);
426
+ expect(updated.Result).toBe("Success");
427
+ expect(updated.Code).toBe("EntityUpdated");
428
+ expect(updated.Data.Id).toBe(id);
429
+ expect(updated.Data.RemovedFields).toContain("old_col");
430
+ expect(updated.Data.Reason).toBe("S9 composition test");
431
+ } finally {
432
+ runDf([
433
+ "entities",
434
+ "delete",
435
+ id,
436
+ "--yes",
437
+ "--reason",
438
+ "S9 cleanup",
439
+ ]);
440
+ }
441
+ },
442
+ EXEC_TIMEOUT,
443
+ );
444
+
445
+ // ── S10 ── update rejects malformed, pre-network ────────────────────────
446
+ it(
447
+ "S10: update rejects malformed payloads before the API call",
448
+ () => {
449
+ const rows: Array<{
450
+ label: string;
451
+ argv: string[];
452
+ message: string;
453
+ }> = [
454
+ {
455
+ label: "addFields missing fieldName",
456
+ argv: [
457
+ "entities",
458
+ "update",
459
+ "pre-network",
460
+ "--body",
461
+ '{"addFields":[{"type":"STRING"}]}',
462
+ ],
463
+ message:
464
+ "Each field in addFields must include a non-empty 'fieldName' string",
465
+ },
466
+ {
467
+ label: "updateFields missing id",
468
+ argv: [
469
+ "entities",
470
+ "update",
471
+ "pre-network",
472
+ "--body",
473
+ '{"updateFields":[{"displayName":"X"}]}',
474
+ ],
475
+ message:
476
+ "Each field in updateFields must include a non-empty 'id' string",
477
+ },
478
+ {
479
+ label: "duplicate fieldName in addFields",
480
+ argv: [
481
+ "entities",
482
+ "update",
483
+ "pre-network",
484
+ "--body",
485
+ '{"addFields":[{"fieldName":"dup_col","type":"STRING"},{"fieldName":"dup_col","type":"STRING"}]}',
486
+ ],
487
+ message: "Duplicate fieldName 'dup_col' in addFields",
488
+ },
489
+ {
490
+ label: "addFields ∩ removeFields conflict",
491
+ argv: [
492
+ "entities",
493
+ "update",
494
+ "pre-network",
495
+ "--body",
496
+ '{"addFields":[{"fieldName":"col","type":"STRING"}],"removeFields":[{"fieldName":"col"}]}',
497
+ "--yes",
498
+ "--reason",
499
+ "x",
500
+ ],
501
+ message:
502
+ "Field 'col' appears in both addFields and removeFields",
503
+ },
504
+ ];
505
+
506
+ for (const row of rows) {
507
+ const parsed = expectFailure(runDf(row.argv), row.label);
508
+ expect(parsed.Message, row.label).toBe(row.message);
509
+ }
510
+ },
511
+ EXEC_TIMEOUT,
512
+ );
513
+
514
+ // ── S11 ── destructive-confirmation guard on update, pre-network ────────
515
+ it(
516
+ "S11: update refuses removeFields without --yes and --reason",
517
+ () => {
518
+ const body = '{"removeFields":[{"fieldName":"old_col"}]}';
519
+
520
+ const noFlags = expectFailure(
521
+ runDf(["entities", "update", "pre-network", "--body", body]),
522
+ "no flags",
523
+ );
524
+ expect(noFlags.Message).toContain("Confirmation required");
525
+
526
+ const yesNoReason = expectFailure(
527
+ runDf([
528
+ "entities",
529
+ "update",
530
+ "pre-network",
531
+ "--body",
532
+ body,
533
+ "--yes",
534
+ ]),
535
+ "--yes without --reason",
536
+ );
537
+ expect(yesNoReason.Message).toBe(
538
+ "Reason required for destructive operation",
539
+ );
540
+
541
+ const reasonNoYes = expectFailure(
542
+ runDf([
543
+ "entities",
544
+ "update",
545
+ "pre-network",
546
+ "--body",
547
+ body,
548
+ "--reason",
549
+ "x",
550
+ ]),
551
+ "--reason without --yes",
552
+ );
553
+ expect(reasonNoYes.Message).toContain("Confirmation required");
554
+ },
555
+ EXEC_TIMEOUT,
556
+ );
557
+
558
+ // ── S12 ── destructive-confirmation guard on delete, pre-network ────────
559
+ it(
560
+ "S12: delete requires confirmation and a reason",
561
+ () => {
562
+ const noFlags = expectFailure(
563
+ runDf(["entities", "delete", "pre-network"]),
564
+ "no flags",
565
+ );
566
+ expect(noFlags.Message).toContain("Confirmation required");
567
+
568
+ const yesNoReason = expectFailure(
569
+ runDf(["entities", "delete", "pre-network", "--yes"]),
570
+ "--yes without --reason",
571
+ );
572
+ expect(yesNoReason.Message).toBe(
573
+ "Reason required for destructive operation",
574
+ );
575
+
576
+ const reasonNoYes = expectFailure(
577
+ runDf(["entities", "delete", "pre-network", "--reason", "x"]),
578
+ "--reason without --yes",
579
+ );
580
+ expect(reasonNoYes.Message).toContain("Confirmation required");
581
+ },
582
+ EXEC_TIMEOUT,
583
+ );
584
+
585
+ // ── S13 ── delete an entity, transient scratch ──────────────────────────
586
+ it(
587
+ "S13: delete an entity",
588
+ (ctx) => {
589
+ if (dfAccessDenied) return ctx.skip();
590
+ const name = `${scratchName}S13`;
591
+ const created = cli([
592
+ "entities",
593
+ "create",
594
+ name,
595
+ "--body",
596
+ '{"fields":[{"fieldName":"note","type":"STRING"}]}',
597
+ ]);
598
+ const id: string = created.Data.Id;
599
+ const deleted = cli([
600
+ "entities",
601
+ "delete",
602
+ id,
603
+ "--yes",
604
+ "--reason",
605
+ "S13 test",
606
+ ]);
607
+ expect(deleted.Result).toBe("Success");
608
+ expect(deleted.Code).toBe("EntityDeleted");
609
+ expect(deleted.Data.Id).toBe(id);
610
+ expect(deleted.Data.Reason).toBe("S13 test");
611
+ },
612
+ EXEC_TIMEOUT,
613
+ );
614
+ });