From e1e9f3d5d0aa03d4c5fd137597c214f58204a1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6ran=20Sander?= Date: Wed, 22 Nov 2023 10:27:33 +0000 Subject: [PATCH] feat: Add JWT auth option for all Ctrl-Q commands Implements #155 --- .gitignore | 3 + .release-please-manifest.json | 2 +- .vscode/launch.json | 495 +++++++++++++----- CHANGELOG.md | 38 +- .../{app.test.js => app_cert.test.js} | 4 +- src/__tests__/app_jwt.test.js | 122 +++++ src/__tests__/connection_test_cert.test.js | 65 +++ src/__tests__/connection_test_jwt.test.js | 65 +++ src/__tests__/task_import.test.js | 6 +- src/ctrl-q.js | 83 +-- src/lib/app/class_allapps.js | 422 ++++++++++----- src/lib/cmd/deletedim.js | 42 +- src/lib/cmd/deletemeasure.js | 44 +- src/lib/cmd/deletevariable.js | 56 +- src/lib/cmd/getbookmark.js | 44 +- src/lib/cmd/getdim.js | 44 +- src/lib/cmd/getmeasure.js | 44 +- src/lib/cmd/getscript.js | 45 +- src/lib/cmd/getvariable.js | 56 +- src/lib/cmd/import-masteritem-excel.js | 167 +++--- src/lib/cmd/importapp.js | 2 + src/lib/cmd/scramblefield.js | 44 +- src/lib/cmd/testconnection.js | 3 +- src/lib/util/about.js | 31 +- src/lib/util/app.js | 156 +++++- src/lib/util/assert-options.js | 43 +- src/lib/util/customproperties.js | 29 +- src/lib/util/enigma.js | 166 +++++- src/lib/util/qrs.js | 84 ++- src/lib/util/tag.js | 31 +- src/lib/util/task.js | 34 +- testdata/tasks.xlsx | Bin 27912 -> 35125 bytes 32 files changed, 1826 insertions(+), 644 deletions(-) rename src/__tests__/{app.test.js => app_cert.test.js} (96%) create mode 100644 src/__tests__/app_jwt.test.js create mode 100644 src/__tests__/connection_test_cert.test.js create mode 100644 src/__tests__/connection_test_jwt.test.js diff --git a/.gitignore b/.gitignore index 0215164..41e616a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,11 @@ tasks_.json tasks2.csv tasks2.xlsx tasks2.json +tasks_all.csv +tasks_all.xlsx # Directories used during unit testing +qvfs qvfs_1 qvfs_2 qvfs_3 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0613328..987586e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"3.14.0","src":"3.12.0"} +{ ".": "3.14.0", "src": "3.12.0" } diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a2aeec..e84315d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,42 +17,59 @@ // ------------------------------------ // "args": [ // "task-custom-property-set", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // "--task-id", - // "e3b27f50-b1c0-4879-88fc-c7cdd9c1cf3e", - // "7552d9fc-d1bb-4975-9a38-18357de531ea", - // // "82bc3e66-c899-4e44-b52f-552145da5ee0", + // "82bc3e66-c899-4e44-b52f-552145da5ee0", + // "5748afa9-3abe-43ab-bb1f-127c48ced075", + // "5520e710-91ad-41d2-aeb6-434cafbf366b", // // "5748afa9-3abe-43ab-bb1f-127c48ced075", // // "5520e710-91ad-41d2-aeb6-434cafbf366b", - // // "--task-tag", + // "--task-tag", // // "Test data", // // "api1", + // "apiCreaeted", // "--custom-property-name", - // // "Department", + // "Department", // // "Department2", // // "DemoApp", - // "ReportMonth", + // // "ReportMonth", + // "--custom-property-value", - // "2023 Jan", - // // "Finance", - // // "Sales", - // "--qvf-overwrite", + // // "2023 Jan", + // "Finance", + // "Sales", + // "--update-mode", // // "append", // "replace", - // // "--dry-run" + + // "--overwrite", + + // "--dry-run" // ] // ------------------------------------ @@ -60,14 +77,25 @@ // ------------------------------------ // "args": [ // "task-import", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -79,7 +107,7 @@ // "--file-name", // "testdata/tasks.xlsx", // "--sheet-name", - // "7" + // "1" // // "--import-app", // // "--import-app-sheet-name", @@ -100,69 +128,80 @@ // ------------------------------------ // Import tasks from CSV file // ------------------------------------ - "args": [ - "task-import", - "--auth-type", - "cert", + // "args": [ + // "task-import", + // "--auth-type", + // "cert", - // p2w1 - "--host", - "192.168.100.109", - "--auth-cert-file", - // "./cert/client.pem", - "../../code/secret/pro2win1-nopwd/client.pem", - "--auth-cert-key-file", - "../../code/secret/pro2win1-nopwd/client_key.pem", - // "./cert/client_key.pem", - "--auth-user-dir", - "LAB", - "--auth-user-id", - "goran", + // // p2w1 + // "--host", + // "192.168.100.109", + // "--auth-cert-file", + // // "./cert/client.pem", + // "../../code/secret/pro2win1-nopwd/client.pem", + // "--auth-cert-key-file", + // "../../code/secret/pro2win1-nopwd/client_key.pem", + // // "./cert/client_key.pem", + // "--auth-user-dir", + // "LAB", + // "--auth-user-id", + // "goran", - // Parallels - // "--host", - // "10.211.55.15", - // "--auth-cert-file", - // "../../code/secret/winsrv.local/client.pem", - // "--auth-cert-key-file", - // "../../code/secret/winsrv.local/client_key.pem", - // "--auth-user-dir", - // "winsrv1", - // "--auth-user-id", - // "goran", + // // Parallels + // // "--host", + // // "10.211.55.15", + // // "--auth-cert-file", + // // "../../code/secret/winsrv.local/client.pem", + // // "--auth-cert-key-file", + // // "../../code/secret/winsrv.local/client_key.pem", + // // "--auth-user-dir", + // // "winsrv1", + // // "--auth-user-id", + // // "goran", - "--file-type", - "csv", + // "--file-type", + // "csv", - "--file-name", - // "tasks2source.csv", - // "task-chain.csv", - // "testdata/reload-tasks.csv", - // "./tasks_all.csv", - "./testdata/tasks-1.csv" + // "--file-name", + // // "tasks2source.csv", + // // "task-chain.csv", + // // "testdata/reload-tasks.csv", + // // "./tasks_all.csv", + // "./testdata/tasks-1.csv" - // "--qvf-overwrite", - // "no", + // // "--qvf-overwrite", + // // "no", - // "--limit-import-count", - // "2", + // // "--limit-import-count", + // // "2", - // "--dry-run" - ] + // // "--dry-run" + // ] // ------------------------------------ // Export apps to QVF files // ------------------------------------ // "args": [ // "app-export", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -206,37 +245,48 @@ // ------------------------------------ // Import apps from Excel file // ------------------------------------ - // "args": [ - // "app-import", - // "--auth-type", - // "cert", - // "--host", - // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", - // "--auth-user-dir", - // "LAB", - // "--auth-user-id", - // "goran", + "args": [ + "app-import", + "--host", + "192.168.100.109", - // // "--file-type", - // // "excel", + "--port", + // "4747", + "443", - // "--file-name", - // "testdata/tasks.xlsx", - // "--sheet-name", - // "App-import", + "--virtual-proxy", + "jwt", + "--auth-type", + "jwt", + "--auth-jwt", + "", - // // "--limit-import-count", - // // "2", + // "--auth-cert-file", + // "./cert/client.pem", + // "--auth-cert-key-file", + // "./cert/client_key.pem", + + "--auth-user-dir", + "LAB", + "--auth-user-id", + "goran", - // "--sleep-app-upload", - // "500" + // "--file-type", + // "excel", - // // "--dry-run" - // ] + "--file-name", + "testdata/tasks.xlsx", + "--sheet-name", + "App import", + + // "--limit-import-count", + // "2", + + "--sleep-app-upload", + "500" + + // "--dry-run" + ] // ------------------------------------ // Get task tree @@ -283,20 +333,31 @@ // ] // ------------------------------------ - // Get reload tasks as table + // Get tasks as table // ------------------------------------ // "args": [ // "task-get", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "../../code/secret/pro2win1-nopwd/client.pem", - // // "./cert/client.pem", - // "--auth-cert-key-file", - // "../../code/secret/pro2win1-nopwd/client_key.pem", - // // "./cert/client_key.pem", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + + // // "--auth-cert-file", + // // "../../code/secret/pro2win1-nopwd/client.pem", + // // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "../../code/secret/pro2win1-nopwd/client_key.pem", + // // // "./cert/client_key.pem", + // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -304,15 +365,15 @@ // // "--task-type", // // "reload", - // "ext-program", + // // "ext-program", // // "--task-id", // // "afc250bc-28c3-49a2-8d63-80966749abe3", // // "5748afa9-3abe-43ab-bb1f-127c48ced075", // // "5520e710-91ad-41d2-aeb6-434cafbf366b", // "--output-format", - // // "table", - // "tree", + // "table", + // // "tree", // "--output-dest", // "screen", @@ -327,8 +388,8 @@ // // "--text-color", // // "no", - // // "--table-details", - // // "common", + // "--table-details", + // "common", // // "lastexecution", // // "tag", // // "customproperty", @@ -337,7 +398,7 @@ // ] // ------------------------------------ - // Get reload tasks as CSV/Excel/JSON file + // Get tasks as CSV/Excel/JSON file // ------------------------------------ // "args": [ // "task-get", @@ -438,19 +499,38 @@ // "cert", // "--host", // "192.168.100.109", - // "--app-id", - // "2933711d-6638-41d4-a2d2-6dd2d965208b", // Ctrl-Q CLI app - // // "a3e0f5d2-000a-464f-998d-33d333b175d7", // Thumbnail demo app + + // "--port", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + + // "--app-id", + // "2933711d-6638-41d4-a2d2-6dd2d965208b", // Ctrl-Q CLI app + // // "a3e0f5d2-000a-464f-998d-33d333b175d7", // Thumbnail demo app + // "--file-type", // "excel", // "--file", // "./testdata/ctrl-q-master-items.xlsx", // "--sheet", // "Sales", + // "--col-ref-by", // "name", // "--col-item-type", @@ -469,8 +549,11 @@ // "Color", // "--col-master-item-per-value-color", // "Per value color", - // // "--sleep-between-imports", + + // "--sleep-between-imports", // // "1000", + // "0", + // // "--limit-import-count", // // "5", // "--log-level", @@ -484,15 +567,27 @@ // "master-item-measure-get", // "--host", // "192.168.100.109", + + // "--port", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "a3e0f5d2-000a-464f-998d-33d333b175d7", // // "2933711d-6638-41d4-a2d2-6dd2d965208b", - // "--id-type", - // "id", - // "--master-item", - // "e392f53f-17b5-4ede-8fd1-f64b4fae630f", + // // "--id-type", + // // "id", + // // "--master-item", + // // "e392f53f-17b5-4ede-8fd1-f64b4fae630f", // "--output-format", // "table", + // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -504,13 +599,23 @@ // ------------------------------------ // "args": [ // "master-item-measure-delete", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", + + // "--port", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "2933711d-6638-41d4-a2d2-6dd2d965208b", // "--auth-user-dir", + // "LAB", // "--auth-user-id", // "goran", @@ -529,11 +634,24 @@ // "master-item-dim-get", // "--host", // "192.168.100.109", + + // "--port", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // // "a3e0f5d2-000a-464f-998d-33d333b175d7", // "2933711d-6638-41d4-a2d2-6dd2d965208b", + // "--output-format", // "table", + // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -545,16 +663,27 @@ // ------------------------------------ // "args": [ // "master-item-dim-delete", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", + + // "--port", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "2933711d-6638-41d4-a2d2-6dd2d965208b", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // "--id-type", // "name", // "--master-item", @@ -569,18 +698,34 @@ // "variable-get", // "--host", // "192.168.100.109", + // "--qrs-port", - // "4242", + // // "4242", + // "443", + + // "--engine-port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // // "a3e0f5d2-000a-464f-998d-33d333b175d7", // "2933711d-6638-41d4-a2d2-6dd2d965208b", // // "--app-tag", // // "Ctrl-Q variable 1", // // "Ctrl-Q variable 2", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // // "--id-type", // // "id", // // "--variable", @@ -594,8 +739,10 @@ // // // "TimestampFormat", // // // "var1", // // "vVar1", + // "--output-format", // "table", + // // "--log-level", // // "info" // ] @@ -603,30 +750,45 @@ // ------------------------------------ // Delete variables // ------------------------------------ - // "args": [ + // "args": [ // "variable-delete", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", - // // "--qrs-port", + + // "--qrs-port", // // "4242", + // "443", + + // "--engine-port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // // "a3e0f5d2-000a-464f-998d-33d333b175d7", // "2933711d-6638-41d4-a2d2-6dd2d965208b", // // "--app-tag", // // "Ctrl-Q variable 1", // // "Ctrl-Q variable 2", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // // "--id-type", // // "id", // // "--variable", // // "2a70741b-6fdc-4252-a30d-7a2b2b4403a7", // // "2a70741b-6fdc-4252-a30d-7a2b2b4403a6", // // "2a70741b-6fdc-4252-a30d-7a2b2b4403a8", + // // "--id-type", // // "name", // // "--variable", @@ -643,16 +805,28 @@ // ------------------------------------ // "args": [ // "bookmark-get", - // "--auth-type", - // "cert", // "--host", // "192.168.100.109", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "2933711d-6638-41d4-a2d2-6dd2d965208b", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // "--id-type", // "id", // // "--bookmark", @@ -757,15 +931,30 @@ // "field-scramble", // "--host", // "192.168.100.109", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "2933711d-6638-41d4-a2d2-6dd2d965208b", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // "--field-name", // "Dim4", // "Dim2", + // "--new-app-name", // "'New scrambled app'" // ] @@ -774,17 +963,31 @@ // Get script // ------------------------------------ // "args": [ - // "getscript", + // "script-get", // "--host", // "192.168.100.109", + + // "--port", + // // "4747", + // "443", + + // "--virtual-proxy", + // "jwt", + // "--auth-type", + // "jwt", + // "--auth-jwt", + // "", + // "--app-id", // "deba4bcf-47e4-472e-97b2-4fe8d6498e11", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", + // "--log-level", - // "verbose" + // "info" // ] // ------------------------------------ @@ -794,12 +997,32 @@ // "connection-test", // "--host", // "192.168.100.109", + // // "--port", + // // "443", + + // // "--virtual-proxy", + // // "/jwt", + // // "/jwt", + + // // "--auth-type", + // // "jwt", + // // "cert", + // // "--host", + // // "192.168.100.109", + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // // "--auth-jwt", + // // "....", + // "--auth-user-dir", // "LAB", // "--auth-user-id", // "goran", - // "--log-level", - // "info" + + // // "--log-level", + // // "info" // ] } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 64264d0..d636cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,38 +2,34 @@ ## [3.14.0](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.13.2...v3.14.0) (2023-11-19) - ### Features -* **connection-test:** Add command to test connection to Sense server ([328886e](https://github.com/ptarmiganlabs/ctrl-q/commit/328886e9ec92a553a732ed6f7f0d17bacfd9b2dc)), closes [#328](https://github.com/ptarmiganlabs/ctrl-q/issues/328) -* **docs:** Move all documentation to ctrl-q.ptarmiganlabs.com ([e60dc31](https://github.com/ptarmiganlabs/ctrl-q/commit/e60dc31c27c889524e0e634ac03c6517285783d6)) -* **task-get:** Simplify --table-details option ([2cbd470](https://github.com/ptarmiganlabs/ctrl-q/commit/2cbd4704213ba4a043662b462e980f7a47152553)), closes [#345](https://github.com/ptarmiganlabs/ctrl-q/issues/345) -* **task-get:** Sort tasks in task tree alphabetically using task name ([ca96d4c](https://github.com/ptarmiganlabs/ctrl-q/commit/ca96d4cb0648b71f437bfe457855e9adbc2f0a9d)) -* **task-import:** Support external program tasks when importing tasks ([8060a1b](https://github.com/ptarmiganlabs/ctrl-q/commit/8060a1bda84a685ef150c8e46d8adb0d13cd3f46)) - +- **connection-test:** Add command to test connection to Sense server ([328886e](https://github.com/ptarmiganlabs/ctrl-q/commit/328886e9ec92a553a732ed6f7f0d17bacfd9b2dc)), closes [#328](https://github.com/ptarmiganlabs/ctrl-q/issues/328) +- **docs:** Move all documentation to ctrl-q.ptarmiganlabs.com ([e60dc31](https://github.com/ptarmiganlabs/ctrl-q/commit/e60dc31c27c889524e0e634ac03c6517285783d6)) +- **task-get:** Simplify --table-details option ([2cbd470](https://github.com/ptarmiganlabs/ctrl-q/commit/2cbd4704213ba4a043662b462e980f7a47152553)), closes [#345](https://github.com/ptarmiganlabs/ctrl-q/issues/345) +- **task-get:** Sort tasks in task tree alphabetically using task name ([ca96d4c](https://github.com/ptarmiganlabs/ctrl-q/commit/ca96d4cb0648b71f437bfe457855e9adbc2f0a9d)) +- **task-import:** Support external program tasks when importing tasks ([8060a1b](https://github.com/ptarmiganlabs/ctrl-q/commit/8060a1bda84a685ef150c8e46d8adb0d13cd3f46)) ### Bug Fixes -* Fix broken CI badge in readme file ([57cfae9](https://github.com/ptarmiganlabs/ctrl-q/commit/57cfae9933fdc71f24172d488aae10a0ba6e924a)) -* **task-get:** --table-details wo parameters now return all task details in table ([1a9a587](https://github.com/ptarmiganlabs/ctrl-q/commit/1a9a587a641eac2aa7328f39421218b3973c8f83)), closes [#332](https://github.com/ptarmiganlabs/ctrl-q/issues/332) -* **task-get:** --task-type option is now invalid for task trees ([1bddb6a](https://github.com/ptarmiganlabs/ctrl-q/commit/1bddb6a1dc685e50f6636a6192d49b59a8ed7837)) -* **task-get:** Add better debug logging when showing task trees ([c66ab77](https://github.com/ptarmiganlabs/ctrl-q/commit/c66ab77fd88cfa2c3a336f10a040dc18a94c49c4)) -* **task-get:** No more duplicate, top-level schema tasks in task tree, for a specfic task. ([d3fe908](https://github.com/ptarmiganlabs/ctrl-q/commit/d3fe9087758ed0476552f2bf2ef86bf77693d0b8)), closes [#333](https://github.com/ptarmiganlabs/ctrl-q/issues/333) -* **task-import:** Correctly handle upstream ext pgm tasks in reload task composite events ([53e076b](https://github.com/ptarmiganlabs/ctrl-q/commit/53e076b5742a2412cffb9176431b2476af996b75)), closes [#331](https://github.com/ptarmiganlabs/ctrl-q/issues/331) -* **task-import:** Importing tasks from CSV file no longer gives "premature close" ([e06f1a9](https://github.com/ptarmiganlabs/ctrl-q/commit/e06f1a9dc6499d93ddc192e720c46c1e3af2da7c)), closes [#323](https://github.com/ptarmiganlabs/ctrl-q/issues/323) - +- Fix broken CI badge in readme file ([57cfae9](https://github.com/ptarmiganlabs/ctrl-q/commit/57cfae9933fdc71f24172d488aae10a0ba6e924a)) +- **task-get:** --table-details wo parameters now return all task details in table ([1a9a587](https://github.com/ptarmiganlabs/ctrl-q/commit/1a9a587a641eac2aa7328f39421218b3973c8f83)), closes [#332](https://github.com/ptarmiganlabs/ctrl-q/issues/332) +- **task-get:** --task-type option is now invalid for task trees ([1bddb6a](https://github.com/ptarmiganlabs/ctrl-q/commit/1bddb6a1dc685e50f6636a6192d49b59a8ed7837)) +- **task-get:** Add better debug logging when showing task trees ([c66ab77](https://github.com/ptarmiganlabs/ctrl-q/commit/c66ab77fd88cfa2c3a336f10a040dc18a94c49c4)) +- **task-get:** No more duplicate, top-level schema tasks in task tree, for a specfic task. ([d3fe908](https://github.com/ptarmiganlabs/ctrl-q/commit/d3fe9087758ed0476552f2bf2ef86bf77693d0b8)), closes [#333](https://github.com/ptarmiganlabs/ctrl-q/issues/333) +- **task-import:** Correctly handle upstream ext pgm tasks in reload task composite events ([53e076b](https://github.com/ptarmiganlabs/ctrl-q/commit/53e076b5742a2412cffb9176431b2476af996b75)), closes [#331](https://github.com/ptarmiganlabs/ctrl-q/issues/331) +- **task-import:** Importing tasks from CSV file no longer gives "premature close" ([e06f1a9](https://github.com/ptarmiganlabs/ctrl-q/commit/e06f1a9dc6499d93ddc192e720c46c1e3af2da7c)), closes [#323](https://github.com/ptarmiganlabs/ctrl-q/issues/323) ### Miscellaneous -* Add unit tests ([50bbae1](https://github.com/ptarmiganlabs/ctrl-q/commit/50bbae1548d59f27cc51f6bafff16483a8ee2c0c)) -* **deps:** update actions/setup-node action to v4 ([aea1d71](https://github.com/ptarmiganlabs/ctrl-q/commit/aea1d713f2ed36ece503d1e3a11282d9bc269d17)) -* **deps:** Update dependencies ([5351e29](https://github.com/ptarmiganlabs/ctrl-q/commit/5351e296bd373f43d7b58ccd6e430a199721398b)) -* **deps:** Update dependencies to stay safe and secure ([568a25f](https://github.com/ptarmiganlabs/ctrl-q/commit/568a25fa6c7b34bdf430dc080e776fed2e261a74)) - +- Add unit tests ([50bbae1](https://github.com/ptarmiganlabs/ctrl-q/commit/50bbae1548d59f27cc51f6bafff16483a8ee2c0c)) +- **deps:** update actions/setup-node action to v4 ([aea1d71](https://github.com/ptarmiganlabs/ctrl-q/commit/aea1d713f2ed36ece503d1e3a11282d9bc269d17)) +- **deps:** Update dependencies ([5351e29](https://github.com/ptarmiganlabs/ctrl-q/commit/5351e296bd373f43d7b58ccd6e430a199721398b)) +- **deps:** Update dependencies to stay safe and secure ([568a25f](https://github.com/ptarmiganlabs/ctrl-q/commit/568a25fa6c7b34bdf430dc080e776fed2e261a74)) ### Documentation -* Add more task import examples ([0ca7363](https://github.com/ptarmiganlabs/ctrl-q/commit/0ca7363038f635a0361cee95c1a8a1145e62be1a)) +- Add more task import examples ([0ca7363](https://github.com/ptarmiganlabs/ctrl-q/commit/0ca7363038f635a0361cee95c1a8a1145e62be1a)) ## [3.13.2](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.13.1...v3.13.2) (2023-10-06) diff --git a/src/__tests__/app.test.js b/src/__tests__/app_cert.test.js similarity index 96% rename from src/__tests__/app.test.js rename to src/__tests__/app_cert.test.js index d4389d2..11526f7 100644 --- a/src/__tests__/app.test.js +++ b/src/__tests__/app_cert.test.js @@ -37,7 +37,7 @@ const nonExistingAppId1 = '9f0d0e02-cccc-bbbb-aaaa-3e9a4d0c8a3d'; const tag1 = 'Test data'; // Get one app by ID -describe('getAppById', () => { +describe('getAppById (cert auth)', () => { test('existing app ID', async () => { const result = await getAppById(existingAppId1, options); expect(result.id).toBe(existingAppId1); @@ -50,7 +50,7 @@ describe('getAppById', () => { }); // Get one or more apps by ID and/or tag -describe('getApps', () => { +describe('getApps (cert auth)', () => { test('one app ID, no tags', async () => { const result = await getApps(options, [existingAppId1]); // error(`Result: ${JSON.stringify(result)}`); diff --git a/src/__tests__/app_jwt.test.js b/src/__tests__/app_jwt.test.js new file mode 100644 index 0000000..e16f549 --- /dev/null +++ b/src/__tests__/app_jwt.test.js @@ -0,0 +1,122 @@ +/* eslint-disable no-console */ +const { test, expect, describe } = require('@jest/globals'); + +const { getApps, getAppById, appExistById, deleteAppById } = require('../lib/util/app'); +const { importAppFromFile } = require('../lib/cmd/importapp'); +const { sleep } = require('../globals'); + +const options = { + logLevel: process.env.CTRL_Q_LOG_LEVEL || 'info', + authType: process.env.CTRL_Q_AUTH_TYPE || 'cert', + host: process.env.CTRL_Q_HOST || '', + port: process.env.CTRL_Q_PORT || '4242', + schemaVersion: process.env.CTRL_Q_SCHEMA_VERSION || '12.612.0', + virtualProxy: process.env.CTRL_Q_VIRTUAL_PROXY || '', + secure: process.env.CTRL_Q_SECURE || true, + authUserDir: process.env.CTRL_Q_AUTH_USER_DIR || '', + authUserId: process.env.CTRL_Q_AUTH_USER_ID || '', + authJwt: process.env.CTRL_Q_AUTH_JWT || '', +}; + +const defaultTestTimeout = process.env.CTRL_Q_TEST_TIMEOUT || 600000; // 10 minute default timeout +jest.setTimeout(defaultTestTimeout); + +// Mock logger +// global.console = { +// log: jest.fn(), +// info: jest.fn(), +// error: jest.fn(), +// }; + +// Define existing and non-existing tasks +const existingAppId1 = 'c840670c-7178-4a5e-8409-ba2da69127e2'; +const existingAppId2 = '3a6c9a53-cb8d-42f3-a8ee-c083c1f8ed8e'; +const nonExistingAppId1 = '9f0d0e02-cccc-bbbb-aaaa-3e9a4d0c8a3d'; +const tag1 = 'Test data'; + +// Get one app by ID +describe('getAppById (JWT auth)', () => { + options.authType = 'jwt'; + options.port = '443'; + options.virtualProxy = 'jwt'; + + test('existing app ID', async () => { + const result = await getAppById(existingAppId1, options); + expect(result.id).toBe(existingAppId1); + }); + + test('non-existing app ID', async () => { + const result = await getAppById(nonExistingAppId1, options); + expect(result).toBe(false); + }); +}); + +// Get one or more apps by ID and/or tag +describe('getApps (JWT auth)', () => { + options.authType = 'jwt'; + options.port = '443'; + options.virtualProxy = 'jwt'; + + test('one app ID, no tags', async () => { + const result = await getApps(options, [existingAppId1]); + + expect(result.length).toBe(1); + expect(result[0].id).toBe(existingAppId1); + }); + + test('two app IDs, no tags', async () => { + const result = await getApps(options, [existingAppId1, existingAppId2]); + + expect(result.length).toBe(2); + }); + + test('no app IDs, one tag', async () => { + const result = await getApps(options, [], [tag1]); + + expect(result.length).toBeGreaterThan(0); + }); +}); + +// Delete an app given a valid app ID +describe('deleteAppById (JWT auth)', () => { + options.authType = 'jwt'; + options.port = '443'; + options.virtualProxy = 'jwt'; + options.fileType = 'excel'; + options.fileName = 'testdata/tasks.xlsx'; + options.sheetName = 'App import'; + options.sleepAppUpload = '500'; + + test('upload a few apps, then delete them', async () => { + // Upload apps + let result = await importAppFromFile(options); + expect(result.appList.length).toBeGreaterThan(0); + + // console.log('Sleeping 10 seconds'); + // await sleep(10000); + // console.log('Done sleeping'); + + // Loop over all apps in the appList + for (let i = 0; i < result.appList.length; ) { + // id of uploaded app + const appId = result.appList[i].appComplete.createdAppId; + // console.log(`App ID: ${appId}`); + + // Check if app exists + // eslint-disable-next-line no-await-in-loop + const appExists = await appExistById(appId, options); + + if (appExists) { + // Delete app + // eslint-disable-next-line no-await-in-loop + const resultDelete = await deleteAppById(appId, options); + expect(resultDelete).toBe(true); + } else { + // App does not exist + expect(appExists).toBe(false); + // console.log(`App ${appId} does not exist in Sense. Skipping delete.`); + } + i += 1; + } + }); +}); diff --git a/src/__tests__/connection_test_cert.test.js b/src/__tests__/connection_test_cert.test.js new file mode 100644 index 0000000..2224c51 --- /dev/null +++ b/src/__tests__/connection_test_cert.test.js @@ -0,0 +1,65 @@ +/* eslint-disable no-console */ +const { test, expect, describe } = require('@jest/globals'); + +const { testConnection } = require('../lib/cmd/testconnection'); + +const options = { + logLevel: process.env.CTRL_Q_LOG_LEVEL || 'info', + authType: process.env.CTRL_Q_AUTH_TYPE, + authCertFile: process.env.CTRL_Q_AUTH_CERT_FILE || './cert/client.pem', + authCertKeyFile: process.env.CTRL_Q_AUTH_CERT_KEY_FILE || './cert/client_key.pem', + host: process.env.CTRL_Q_HOST || '', + port: process.env.CTRL_Q_PORT || '', + virtualProxy: process.env.CTRL_Q_VIRTUAL_PROXY || '', + secure: process.env.CTRL_Q_SECURE || true, + authUserDir: process.env.CTRL_Q_AUTH_USER_DIR || '', + authUserId: process.env.CTRL_Q_AUTH_USER_ID || '', + authJwt: process.env.CTRL_Q_AUTH_JWT || '', +}; + +const defaultTestTimeout = process.env.CTRL_Q_TEST_TIMEOUT || 120000; // 2 minute default timeout +console.log(`Jest timeout: ${defaultTestTimeout}`); +jest.setTimeout(defaultTestTimeout); + +// Connection test using cert auth +describe('connection test (cert auth)', () => { + options.authType = 'cert'; + options.port = '4242'; + + test('Verify parameters', async () => { + expect(options.authType).toBe('cert'); + expect(options.authCertFile).not.toHaveLength(0); + expect(options.authCertKeyFile).not.toHaveLength(0); + expect(options.host).not.toHaveLength(0); + expect(options.authUserDir).not.toHaveLength(0); + expect(options.authUserId).not.toHaveLength(0); + }); + + /** + * Do connection test + * VP = + * Should succeed + */ + test('do connection test (virtual proxy=)', async () => { + options.virtualProxy = ''; + const result = await testConnection(options); + + // Result should be a JSON object + expect(result).toBeInstanceOf(Object); + expect(result.schemaPath).toBe('About'); + }); + + /** + * Do connection test + * VP = '/' + * Should succeed + */ + test('do connection test (virtual proxy=/)', async () => { + options.virtualProxy = '/'; + const result = await testConnection(options); + + // Result should be a JSON object + expect(result).toBeInstanceOf(Object); + expect(result.schemaPath).toBe('About'); + }); +}); diff --git a/src/__tests__/connection_test_jwt.test.js b/src/__tests__/connection_test_jwt.test.js new file mode 100644 index 0000000..3815757 --- /dev/null +++ b/src/__tests__/connection_test_jwt.test.js @@ -0,0 +1,65 @@ +/* eslint-disable no-console */ +const { test, expect, describe } = require('@jest/globals'); + +const { testConnection } = require('../lib/cmd/testconnection'); + +const options = { + logLevel: process.env.CTRL_Q_LOG_LEVEL || 'info', + authType: process.env.CTRL_Q_AUTH_TYPE, + authCertFile: process.env.CTRL_Q_AUTH_CERT_FILE || './cert/client.pem', + authCertKeyFile: process.env.CTRL_Q_AUTH_CERT_KEY_FILE || './cert/client_key.pem', + host: process.env.CTRL_Q_HOST || '', + port: process.env.CTRL_Q_PORT || '', + virtualProxy: process.env.CTRL_Q_VIRTUAL_PROXY || '', + secure: process.env.CTRL_Q_SECURE || true, + authUserDir: process.env.CTRL_Q_AUTH_USER_DIR || '', + authUserId: process.env.CTRL_Q_AUTH_USER_ID || '', + authJwt: process.env.CTRL_Q_AUTH_JWT || '', +}; + +const defaultTestTimeout = process.env.CTRL_Q_TEST_TIMEOUT || 120000; // 2 minute default timeout +console.log(`Jest timeout: ${defaultTestTimeout}`); +jest.setTimeout(defaultTestTimeout); + +// Connection test using JWT auth +describe('connection test (JWT auth)', () => { + options.authType = 'jwt'; + options.port = '443'; + options.virtualProxy = 'jwt'; + + test('Verify parameters', async () => { + expect(options.host).not.toHaveLength(0); + expect(options.port).not.toHaveLength(0); + expect(options.authJwt).not.toHaveLength(0); + }); + + /** + * Do connection test + * VP = 'jwt' + * Should succeed + */ + test('do connection test (virtual proxy=jwt)', async () => { + options.virtualProxy = 'jwt'; + + const result = await testConnection(options); + + // Result should be a JSON object + expect(result).toBeInstanceOf(Object); + expect(result.schemaPath).toBe('About'); + }); + + /** + * Do connection test + * VP = 'jwt' + * Should succeed + */ + test('do connection test (virtual proxy=/jwt)', async () => { + options.virtualProxy = '/jwt'; + + const result = await testConnection(options); + + // Result should be a JSON object + expect(result).toBeInstanceOf(Object); + expect(result.schemaPath).toBe('About'); + }); +}); diff --git a/src/__tests__/task_import.test.js b/src/__tests__/task_import.test.js index 1083f0f..ca4ec22 100644 --- a/src/__tests__/task_import.test.js +++ b/src/__tests__/task_import.test.js @@ -3,8 +3,8 @@ const { test, expect, describe } = require('@jest/globals'); const { importTaskFromFile } = require('../lib/cmd/importtask'); -const { getTaskById, deleteExternalProgramTaskById, deleteReloadTaskById, } = require('../lib/util/task'); -const { mapTaskType, } = require('../lib/util/lookups'); +const { getTaskById, deleteExternalProgramTaskById, deleteReloadTaskById } = require('../lib/util/task'); +const { mapTaskType } = require('../lib/util/lookups'); const options = { logLevel: process.env.CTRL_Q_LOG_LEVEL || 'info', @@ -96,7 +96,7 @@ describe('import task', () => { for (let i = 0; i < result.length; i += 1) { const task = result[i]; const { taskId } = task; - + await deleteReloadTaskById(taskId, options); } }); diff --git a/src/ctrl-q.js b/src/ctrl-q.js index 5194a50..00e4139 100644 --- a/src/ctrl-q.js +++ b/src/ctrl-q.js @@ -141,7 +141,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -149,10 +149,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('-t, --file-type ', 'source file type').choices(['excel']).default('excel')) .requiredOption('--file ', 'file containing master item definitions') @@ -228,7 +229,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -236,10 +237,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--id-type ', 'type of identifier passed in the --master-item option').choices(['id', 'name']).default('name') @@ -261,7 +263,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -269,10 +271,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('--id-type ', 'type of identifier passed in the --master-item option').choices(['id', 'name'])) .option('--master-item ', 'names or IDs of master measures to be deleted. Multiple IDs should be space separated') @@ -292,7 +295,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -300,10 +303,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .requiredOption('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .requiredOption('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .requiredOption('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--id-type ', 'type of identifier passed in the --master-item option').choices(['id', 'name']).default('name') @@ -325,7 +329,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -333,10 +337,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('--id-type ', 'type of identifier passed in the --master-item option').choices(['id', 'name'])) .option('--master-item ', 'names or IDs of master dimensions to be deleted. Multiple IDs should be space separated') @@ -357,8 +362,8 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--engine-port ', 'Qlik Sense server engine port', '4747') - .option('--qrs-port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--engine-port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') + .option('--qrs-port ', 'Qlik Sense repository service (QRS) port (usually 4747 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .option('--app-id ', 'Qlik Sense app ID(s) to get variables from') .option('--app-tag ', 'Qlik Sense app tag(s) to get variables') @@ -367,10 +372,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--id-type ', 'type of identifier passed in the --variable option').choices(['id', 'name']).default('name') @@ -392,8 +398,8 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--engine-port ', 'Qlik Sense server engine port', '4747') - .option('--qrs-port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--engine-port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') + .option('--qrs-port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .option('--app-id ', 'Qlik Sense app ID(s) to get variables from') .option('--app-tag ', 'Qlik Sense app tag(s) to get variables') @@ -402,10 +408,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--id-type ', 'type of identifier passed in the --variable option').choices(['id', 'name']).default('name') @@ -427,7 +434,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -435,10 +442,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .requiredOption('--field-name ', 'name of field(s) to be scrambled') .requiredOption('--new-app-name ', 'name of new app that will contain scrambled data'); @@ -457,7 +465,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -465,10 +473,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') - .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem'); + .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server'); // Get bookmark command program @@ -484,7 +493,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense server engine port', '4747') + .option('--port ', 'Qlik Sense server engine port (usually 4747 for cert auth, 443 for jwt auth)', '4747') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--app-id ', 'Qlik Sense app ID') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') @@ -492,10 +501,11 @@ const program = new Command(); .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--id-type ', 'type of bookmark identifier passed in the --bookmark option') @@ -532,17 +542,18 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('--task-type ', 'type of tasks to include').choices(['reload', 'ext-program'])) .option('--task-id ', 'use task IDs to select which tasks to retrieve. Only allowed when --output-format=table') @@ -586,17 +597,18 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption( new Option('--task-type ', 'type of tasks to list').choices(['reload']).default(['reload']) @@ -633,17 +645,18 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('-t, --file-type ', 'source file type').choices(['excel', 'csv']).default('excel')) .requiredOption('--file-name ', 'file containing task definitions') @@ -684,17 +697,18 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .addOption(new Option('-t, --file-type ', 'source file type').choices(['excel']).default('excel')) .requiredOption('--file-name ', 'file containing app definitions') @@ -726,17 +740,18 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense repository service (QRS) port (usually 4242 for cert auth, 443 for jwt auth)', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) .requiredOption('--auth-user-dir ', 'user directory for user to connect with') .requiredOption('--auth-user-id ', 'user ID for user to connect with') - .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert', 'jwt']).default('cert')) .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + .option('--auth-jwt ', 'JSON Web Token (JWT) to use for authentication with Qlik Sense server') .option('--app-id ', 'use app IDs to select which apps to export') .option('--app-tag ', 'use app tags to select which apps to export') @@ -786,7 +801,7 @@ const program = new Command(); new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') ) .requiredOption('--host ', 'Qlik Sense server IP/FQDN') - .option('--port ', 'Qlik Sense repository service (QRS) port', '4242') + .option('--port ', 'Qlik Sense proxy service port', '4242') .option('--schema-version ', 'Qlik Sense engine schema version', '12.612.0') .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix', '') .requiredOption('--secure ', 'connection to Qlik Sense engine is via https', true) diff --git a/src/lib/app/class_allapps.js b/src/lib/app/class_allapps.js index 50ce93b..548ccf5 100644 --- a/src/lib/app/class_allapps.js +++ b/src/lib/app/class_allapps.js @@ -26,9 +26,12 @@ class QlikSenseApps { this.appList = []; this.options = options; - // Make sure certificates exist - this.fileCert = path.resolve(execPath, options.authCertFile); - this.fileCertKey = path.resolve(execPath, options.authCertKeyFile); + // Should cerrificates be used for authentication? + if (options.authType === 'cert') { + // Make sure certificates exist + this.fileCert = path.resolve(execPath, options.authCertFile); + this.fileCertKey = path.resolve(execPath, options.authCertKeyFile); + } // Map that will connect app counter from Excel file with ID an app gets after import to QSEoW this.appCounterIdMap = new Map(); @@ -107,22 +110,38 @@ class QlikSenseApps { } logger.debug(`GET APPS FROM QSEOW: QRS query filter (incl ids, tags): ${filter}`); + // Should cerrificates be used for authentication? let axiosConfig; - if (filter === '') { - axiosConfig = await setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: '/qrs/app/full', - }); - } else { - axiosConfig = await setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: '/qrs/app/full', - queryParameters: [{ name: 'filter', value: filter }], - }); + if (this.options.authType === 'cert') { + if (filter === '') { + axiosConfig = await setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: '/qrs/app/full', + }); + } else { + axiosConfig = await setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: '/qrs/app/full', + queryParameters: [{ name: 'filter', value: filter }], + }); + } + } else if (this.options.authType === 'jwt') { + if (filter === '') { + axiosConfig = await setupQRSConnection(this.options, { + method: 'get', + path: '/qrs/app/full', + }); + } else { + axiosConfig = await setupQRSConnection(this.options, { + method: 'get', + path: '/qrs/app/full', + queryParameters: [{ name: 'filter', value: filter }], + }); + } } const result = await axios.request(axiosConfig); @@ -343,6 +362,9 @@ class QlikSenseApps { process.exit(1); } + // Save id of created app + currentApp.createdAppId = uploadedAppId; + // Update tags, custom properties and owner of uploaded app // eslint-disable-next-line no-await-in-loop const result = await this.updateUploadedApp(currentApp, uploadedAppId); @@ -353,6 +375,7 @@ class QlikSenseApps { // eslint-disable-next-line no-await-in-loop const { streamId, streamName } = await this.getStreamInfo(currentApp); + let tmpAppId; // Do we know which stream to publish to? Publish if so! if (streamId) { @@ -374,19 +397,29 @@ class QlikSenseApps { }` ); + // Keep record of published app + currentApp.publishStatus = 'published'; + // Add mapping between app counter and the id of published app - const tmpAppId = `newapp-${currentApp.appCounter}`; + tmpAppId = `newapp-${currentApp.appCounter}`; this.appCounterIdMap.set(tmpAppId, result2.publishedApp.id); - - // eslint-disable-next-line no-await-in-loop - await this.addApp(currentApp, tmpAppId); } else { logger.error( `(${appRow[0][appFileColumnHeaders.appCounter.pos]}) Failed publishing app "${ currentApp.name }" to stream "${streamName}"` ); + + // Keep record of failed app + currentApp.publishStatus = 'failed'; + + // Add mapping between app counter and the id of uploaded, not published app + tmpAppId = `newapp-${currentApp.appCounter}`; + this.appCounterIdMap.set(tmpAppId, uploadedAppId); } + + // eslint-disable-next-line no-await-in-loop + await this.addApp(currentApp, tmpAppId); } else if (currentApp.appPublishToStreamOption === 'publish-another') { // eslint-disable-next-line no-await-in-loop const result2 = await this.streamAppPublishAnother( @@ -403,19 +436,29 @@ class QlikSenseApps { }" published to stream "${streamName}". Id of published app: ${result2.publishedApp.id}` ); + // Keep record of published app + currentApp.publishStatus = 'published'; + // Add mapping between app counter and the id of published app - const tmpAppId = `newapp-${currentApp.appCounter}`; + tmpAppId = `newapp-${currentApp.appCounter}`; this.appCounterIdMap.set(tmpAppId, result2.publishedApp.id); - - // eslint-disable-next-line no-await-in-loop - await this.addApp(currentApp, tmpAppId); } else { logger.error( `(${appRow[0][appFileColumnHeaders.appCounter.pos]}) Failed publishing app "${ currentApp.name }" to stream "${streamName}"` ); + + // Keep record of failed app + currentApp.publishStatus = 'failed'; + + // Add mapping between app counter and the id of uploaded, not published app + tmpAppId = `newapp-${currentApp.appCounter}`; + this.appCounterIdMap.set(tmpAppId, uploadedAppId); } + + // eslint-disable-next-line no-await-in-loop + await this.addApp(currentApp, tmpAppId); } else if (currentApp.appPublishToStreamOption === 'delete-publish') { // eslint-disable-next-line no-await-in-loop const result2 = await this.streamAppDeletePublish( @@ -435,18 +478,29 @@ class QlikSenseApps { }` ); + // Keep record of published app + currentApp.publishStatus = 'published'; + // Add mapping between app counter and the id of published app - const tmpAppId = `newapp-${currentApp.appCounter}`; + tmpAppId = `newapp-${currentApp.appCounter}`; this.appCounterIdMap.set(tmpAppId, result2.publishedApp.id); - - // eslint-disable-next-line no-await-in-loop - await this.addApp(currentApp, tmpAppId); - } else + } else { logger.error( `(${appRow[0][appFileColumnHeaders.appCounter.pos]}) Failed publishing app "${ currentApp.name }" to stream "${streamName}"` ); + + // Keep record of failed app + currentApp.publishStatus = 'failed'; + + // Add mapping between app counter and the id of uploaded, not published app + tmpAppId = `newapp-${currentApp.appCounter}`; + this.appCounterIdMap.set(tmpAppId, uploadedAppId); + } + + // eslint-disable-next-line no-await-in-loop + await this.addApp(currentApp, tmpAppId); } else { logger.error( `(${appRow[0][appFileColumnHeaders.appCounter.pos]}) Invalid publish option specified for app "${ @@ -462,6 +516,16 @@ class QlikSenseApps { currentApp.appPublishToStream }". The uploaded app is still present in the QMC (id=${uploadedAppId}).` ); + + // Keep record of failed app + currentApp.publishStatus = 'failed'; + + // Add mapping between app counter and the id of uploaded, but not published app + const tmpAppId = `newapp-${currentApp.appCounter}`; + this.appCounterIdMap.set(tmpAppId, uploadedAppId); + + // eslint-disable-next-line no-await-in-loop + await this.addApp(currentApp, tmpAppId); } } else { // No, do not publish to stream after app upload @@ -471,6 +535,9 @@ class QlikSenseApps { }" uploaded to QSEoW, but not published to any stream.` ); + // Keep record of publish status + currentApp.publishStatus = 'unpublished'; + // Add mapping between app counter and the new id of imported app const tmpAppId = `newapp-${currentApp.appCounter}`; this.appCounterIdMap.set(tmpAppId, uploadedAppId); @@ -491,13 +558,22 @@ class QlikSenseApps { // Function to update tags, custom properties and owner of uploaded app async updateUploadedApp(newApp, uploadedAppId) { try { - // Get info about just uploaded app - const axiosConfigUploadedApp = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app/${uploadedAppId}`, - }); + // Should cerrificates be used for authentication? + let axiosConfigUploadedApp; + if (this.options.authType === 'cert') { + // Get info about just uploaded app + axiosConfigUploadedApp = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app/${uploadedAppId}`, + }); + } else if (this.options.authType === 'jwt') { + axiosConfigUploadedApp = setupQRSConnection(this.options, { + method: 'get', + path: `/qrs/app/${uploadedAppId}`, + }); + } const appUploaded2 = await axios.request(axiosConfigUploadedApp); if (appUploaded2.status !== 200) { @@ -523,13 +599,24 @@ class QlikSenseApps { `userDirectory eq '${newApp.appOwnerUserDirectory}' and userId eq '${newApp.appOwnerUserId}'` ); - const axiosConfigUser = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: '/qrs/user', - queryParameters: [{ name: 'filter', value: filter }], - }); + // Should cerrificates be used for authentication? + let axiosConfigUser; + if (this.options.authType === 'cert') { + // Get info about just uploaded app + axiosConfigUser = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: '/qrs/user', + queryParameters: [{ name: 'filter', value: filter }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfigUser = setupQRSConnection(this.options, { + method: 'get', + path: '/qrs/user', + queryParameters: [{ name: 'filter', value: filter }], + }); + } const userResult = await axios.request(axiosConfigUser); const userResponse = JSON.parse(userResult.data); @@ -559,17 +646,28 @@ class QlikSenseApps { // Pause for a while to let Sense repository catch up await sleep(1000); - - // Uppdate app with tags, custom properties and app owner - const axiosConfig2 = setupQRSConnection(this.options, { - method: 'put', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app/${app.id}`, - body: app, - }); - + // console.log(this.options) + // Should cerrificates be used for authentication? + let axiosConfig2; + if (this.options.authType === 'cert') { + // Uppdate app with tags, custom properties and app owner + axiosConfig2 = setupQRSConnection(this.options, { + method: 'put', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app/${app.id}`, + body: app, + }); + } else if (this.options.authType === 'jwt') { + axiosConfig2 = setupQRSConnection(this.options, { + method: 'put', + path: `/qrs/app/${app.id}`, + body: app, + }); + } + // console.log(axiosConfig2) const result2 = await axios.request(axiosConfig2); + // console.log('b1') if (result2.status === 200) { logger.debug(`Update of imported app wrt tags, custom properties and owner was successful.`); return true; @@ -600,7 +698,7 @@ class QlikSenseApps { logger.debug(`(${appCounter}) PUBLISH APP publish-replace: Starting`); // Get info about the created app - const appInfo = await getAppById(uploadedAppId); + const appInfo = await getAppById(uploadedAppId, this.options); // Check if there is an app with the same name in the target stream const matchingAppsInStream = await this.appsInStreamCount(appInfo.name, streamName); @@ -612,7 +710,7 @@ class QlikSenseApps { if (result.res === true) { // Get info about the published app - result.publishedApp = await getAppById(uploadedAppId); + result.publishedApp = await getAppById(uploadedAppId, this.options); } else { result.publishedApp = null; } @@ -644,10 +742,10 @@ class QlikSenseApps { // ); // Delete the uploaded app - await deleteAppById(uploadedAppId); + await deleteAppById(uploadedAppId, this.options); // } - const publishedApp = await getAppById(appInStream.id); + const publishedApp = await getAppById(appInStream.id, this.options); result = { res: true, publishedApp }; } else { // Something went wrong @@ -692,7 +790,7 @@ class QlikSenseApps { if (result.res === true) { // Get info about the published app - result.publishedApp = await getAppById(uploadedAppId); + result.publishedApp = await getAppById(uploadedAppId, this.options); } else { result.publishedApp = null; } @@ -727,7 +825,7 @@ class QlikSenseApps { if (result.res === true) { // Get info about the published app - result.publishedApp = await getAppById(uploadedAppId); + result.publishedApp = await getAppById(uploadedAppId, this.options); } else { result.publishedApp = null; } @@ -747,14 +845,14 @@ class QlikSenseApps { const appInStream = await this.getAppInStream(streamName, appName); // Delete the app in the target stream - await deleteAppById(appInStream.id); + await deleteAppById(appInStream.id, this.options); // Do the normal publish result.res = await this.appPublishNormal(streamId, uploadedAppId, appName); if (result.res === true) { // Get info about the published app - result.publishedApp = await getAppById(uploadedAppId); + result.publishedApp = await getAppById(uploadedAppId, this.options); } else { result.publishedApp = null; } @@ -790,14 +888,24 @@ class QlikSenseApps { { name: 'name', value: appName }, ]; - // Build QRS query - const axiosConfig = setupQRSConnection(this.options, { - method: 'put', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app/${appId}/publish`, - queryParameters, - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'put', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app/${appId}/publish`, + queryParameters, + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'put', + path: `/qrs/app/${appId}/publish`, + queryParameters, + }); + } // Execute QRS query const result = await axios.request(axiosConfig); @@ -830,14 +938,24 @@ class QlikSenseApps { // Define query parameters const queryParameters = [{ name: 'app', value: targetAppId }]; - // Build QRS query - const axiosConfig = setupQRSConnection(this.options, { - method: 'put', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app/${sourceAppId}/replace`, - queryParameters, - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'put', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app/${sourceAppId}/replace`, + queryParameters, + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'put', + path: `/qrs/app/${sourceAppId}/replace`, + queryParameters, + }); + } // Execute QRS query const result = await axios.request(axiosConfig); @@ -885,13 +1003,24 @@ class QlikSenseApps { filter = encodeURIComponent(`stream.name eq '${streamName}' and name eq '${appName}'`); } - const axiosConfig = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app`, - queryParameters: [{ name: 'filter', value: filter }], - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app`, + queryParameters: [{ name: 'filter', value: filter }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + path: `/qrs/app`, + queryParameters: [{ name: 'filter', value: filter }], + }); + } // Execute QRS query const result = await axios.request(axiosConfig); @@ -925,13 +1054,24 @@ class QlikSenseApps { // Build QRS query const filter = encodeURIComponent(`stream.name eq '${streamName}' and name eq '${appName}'`); - const axiosConfig = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app`, - queryParameters: [{ name: 'filter', value: filter }], - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app`, + queryParameters: [{ name: 'filter', value: filter }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + path: `/qrs/app`, + queryParameters: [{ name: 'filter', value: filter }], + }); + } // Execute QRS query const result = await axios.request(axiosConfig); @@ -981,12 +1121,22 @@ class QlikSenseApps { // If so check if the GUID represents a stream if (validate(uploadedAppInfo.appPublishToStream)) { // It's a valid GUID - axiosConfigPublish = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/stream/${uploadedAppInfo.appPublishToStream}`, - }); + + // Should cerrificates be used for authentication? + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfigPublish = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/stream/${uploadedAppInfo.appPublishToStream}`, + }); + } else if (this.options.authType === 'jwt') { + axiosConfigPublish = setupQRSConnection(this.options, { + method: 'get', + path: `/qrs/stream/${uploadedAppInfo.appPublishToStream}`, + }); + } resultPublish = await axios.request(axiosConfigPublish); if (resultPublish.status === 200) { @@ -1000,13 +1150,23 @@ class QlikSenseApps { // Provided stream name is not a GUID, make sure only one stream exists with this name, then get its GUID const filter = encodeURIComponent(`name eq '${uploadedAppInfo.appPublishToStream}'`); - axiosConfigPublish = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: '/qrs/stream', - queryParameters: [{ name: 'filter', value: filter }], - }); + // Should cerrificates be used for authentication? + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfigPublish = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: '/qrs/stream', + queryParameters: [{ name: 'filter', value: filter }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfigPublish = setupQRSConnection(this.options, { + method: 'get', + path: '/qrs/stream', + queryParameters: [{ name: 'filter', value: filter }], + }); + } resultPublish = await axios.request(axiosConfigPublish); if (resultPublish.status === 200) { @@ -1159,13 +1319,24 @@ class QlikSenseApps { const exportToken = uuidv4(); const excludeData = this.options.excludeAppData === 'true' ? 'true' : 'false'; - const axiosConfig = setupQRSConnection(this.options, { - method: 'post', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: `/qrs/app/${app.id}/export/${exportToken}`, - queryParameters: [{ name: 'skipData', value: excludeData }], - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'post', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: `/qrs/app/${app.id}/export/${exportToken}`, + queryParameters: [{ name: 'skipData', value: excludeData }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'post', + path: `/qrs/app/${app.id}/export/${exportToken}`, + queryParameters: [{ name: 'skipData', value: excludeData }], + }); + } const result = await axios.request(axiosConfig); logger.verbose(`Export app step 1 result: [${result.status}] ${result.statusText}`); @@ -1264,13 +1435,24 @@ class QlikSenseApps { } else { writer = fs2.createWriteStream(fileName); - const axiosConfig = setupQRSConnection(this.options, { - method: 'get', - fileCert: this.fileCert, - fileCertKey: this.fileCertKey, - path: urlPath, - queryParameters: [{ name: paramName, value: paramValue }], - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (this.options.authType === 'cert') { + // Build QRS query + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + fileCert: this.fileCert, + fileCertKey: this.fileCertKey, + path: urlPath, + queryParameters: [{ name: paramName, value: paramValue }], + }); + } else if (this.options.authType === 'jwt') { + axiosConfig = setupQRSConnection(this.options, { + method: 'get', + path: urlPath, + queryParameters: [{ name: paramName, value: paramValue }], + }); + } axiosConfig.responseType = 'stream'; diff --git a/src/lib/cmd/deletedim.js b/src/lib/cmd/deletedim.js index 10783cc..d71c68f 100644 --- a/src/lib/cmd/deletedim.js +++ b/src/lib/cmd/deletedim.js @@ -1,6 +1,6 @@ const enigma = require('enigma.js'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); // Variable to keep track of how many dimensions have been deleted @@ -21,18 +21,40 @@ const deleteMasterDimension = async (options) => { logger.info('Delete master dimensions'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - session.on('traffic:sent', (data) => console.log('sent:', data)); - session.on('traffic:received', (data) => console.log('received:', data)); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/deletemeasure.js b/src/lib/cmd/deletemeasure.js index 3fe22b9..f0507c9 100644 --- a/src/lib/cmd/deletemeasure.js +++ b/src/lib/cmd/deletemeasure.js @@ -1,6 +1,6 @@ const enigma = require('enigma.js'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); // Variable to keep track of how many measures have been deleted @@ -21,20 +21,40 @@ const deleteMasterMeasure = async (options) => { logger.info('Delete master measures'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/deletevariable.js b/src/lib/cmd/deletevariable.js index c0e40d1..626801d 100644 --- a/src/lib/cmd/deletevariable.js +++ b/src/lib/cmd/deletevariable.js @@ -2,7 +2,7 @@ /* eslint-disable no-await-in-loop */ const enigma = require('enigma.js'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { getApps } = require('../util/app'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); @@ -22,23 +22,45 @@ const deleteVariable = async (options) => { // Get IDs of all apps that should be processed const apps = await getApps(options, options.appId, options.appTag); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); + + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } + + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } for (const app of apps) { logger.info('------------------------'); logger.info(`Deleting variables in app ${app.id} "${app.name}"`); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - session.on('traffic:sent', (data) => console.log('sent:', data)); - session.on('traffic:received', (data) => console.log('received:', data)); - } - const global = await session.open(); - - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); - const doc = await global.openDoc(app.id, '', '', '', true); logger.verbose(`Opened app ${app.id} "${app.name}".`); @@ -129,8 +151,12 @@ const deleteVariable = async (options) => { } } } - // Close app session - await doc.session.close(); + } + + if ((await session.close()) === true) { + logger.verbose(`Closed session after getting master item measures in app ${options.appId} on host ${options.host}`); + } else { + logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); } } catch (err) { logger.error(`DELETE VARIABLE: ${err.stack}`); diff --git a/src/lib/cmd/getbookmark.js b/src/lib/cmd/getbookmark.js index 3d1fd5b..f490cf5 100644 --- a/src/lib/cmd/getbookmark.js +++ b/src/lib/cmd/getbookmark.js @@ -1,7 +1,7 @@ const enigma = require('enigma.js'); const { table } = require('table'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); const consoleTableConfig = { @@ -48,20 +48,40 @@ const getBookmark = async (options) => { logger.info('Get bookmarks'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/getdim.js b/src/lib/cmd/getdim.js index a66447a..c55b2dd 100644 --- a/src/lib/cmd/getdim.js +++ b/src/lib/cmd/getdim.js @@ -3,7 +3,7 @@ const enigma = require('enigma.js'); const { table } = require('table'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); const consoleTableConfig = { @@ -51,20 +51,40 @@ const getMasterDimension = async (options) => { logger.info('Get master dimensions'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/getmeasure.js b/src/lib/cmd/getmeasure.js index 1d5bfeb..14a75dc 100644 --- a/src/lib/cmd/getmeasure.js +++ b/src/lib/cmd/getmeasure.js @@ -2,7 +2,7 @@ const enigma = require('enigma.js'); const { table } = require('table'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); const consoleTableConfig = { @@ -49,20 +49,40 @@ const getMasterMeasure = async (options) => { logger.info('Get master measures'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/getscript.js b/src/lib/cmd/getscript.js index 9317411..07f4ac5 100644 --- a/src/lib/cmd/getscript.js +++ b/src/lib/cmd/getscript.js @@ -1,5 +1,6 @@ const enigma = require('enigma.js'); -const { setupEnigmaConnection } = require('../util/enigma'); + +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); /** @@ -17,20 +18,40 @@ const getScript = async (options) => { logger.verbose('Get app script'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + // Set up logging of websocket traffic + addTrafficLogging(session, options); + + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } + + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/getvariable.js b/src/lib/cmd/getvariable.js index 1afbdd5..7a8853f 100644 --- a/src/lib/cmd/getvariable.js +++ b/src/lib/cmd/getvariable.js @@ -3,7 +3,7 @@ const enigma = require('enigma.js'); const { table } = require('table'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { getApps } = require('../util/app'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); @@ -52,23 +52,45 @@ const getVariable = async (options) => { // Get IDs of all apps that should be processed const apps = await getApps(options, options.appId, options.appTag); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; + + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } + + // Set up logging of websocket traffic + addTrafficLogging(session, options); + + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } + + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } let allVariables = []; let subsetVariables = []; for (const app of apps) { - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - session.on('traffic:sent', (data) => console.log('sent:', data)); - session.on('traffic:received', (data) => console.log('received:', data)); - } - const global = await session.open(); - - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); - // Open app without data const doc = await global.openDoc(app.id, '', '', '', true); logger.verbose(`Opened app ${app.id}, "${app.name}".`); @@ -104,7 +126,7 @@ const getVariable = async (options) => { allVariables = allVariables.concat({ appId: app.id, appName: app.name, variables: appVariablesLayout.qVariableList.qItems }); // Close app session - doc.session.close(); + // doc.session.close(); } if (options.variable === undefined) { @@ -218,6 +240,12 @@ const getVariable = async (options) => { } else { logger.error('Undefined --output-format option'); } + + if ((await session.close()) === true) { + logger.verbose(`Closed session after getting master item measures in app ${options.appId} on host ${options.host}`); + } else { + logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); + } } catch (err) { logger.error(`GET VARIABLE: ${err.stack}`); } diff --git a/src/lib/cmd/import-masteritem-excel.js b/src/lib/cmd/import-masteritem-excel.js index dadec6f..c2c950f 100644 --- a/src/lib/cmd/import-masteritem-excel.js +++ b/src/lib/cmd/import-masteritem-excel.js @@ -4,7 +4,7 @@ const enigma = require('enigma.js'); const xlsx = require('node-xlsx').default; const uuidCreate = require('uuid').v4; -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath, verifyFileExists, sleep } = require('../../globals'); let importCount = 0; @@ -78,46 +78,6 @@ const createColorMap = async (app, colorMapId, newPerValueColorMap) => { return colorMapId; }; -// Function to add logging of session's websocket traffic -const addTrafficLogging = (session, options) => { - if (options.logLevel === 'silly') { - session.on('traffic:sent', (data) => console.log('sent:', data)); - - session.on('traffic:received', (data) => { - console.log('received:', data); - if (data?.result?.qReturn) { - console.log(`qReturn: ${JSON.stringify(data.result.qReturn, null, 2)}`); - } - - if (data?.result?.qInfo) { - console.log(`qInfo: ${JSON.stringify(data.result.qInfo, null, 2)}`); - } - - if (data?.change?.length > 1) { - console.log(`change length > 1: ${JSON.stringify(data.change, null, 2)}`); - - console.log('received:', data); - if (data?.result?.qReturn) { - console.log(`qReturn: ${JSON.stringify(data.result.qReturn, null, 2)}`); - } - - if (data?.result?.qInfo) { - console.log(`qInfo: ${JSON.stringify(data.result.qInfo, null, 2)}`); - } - } - }); - - session.on('notification:*', (eventName, data) => { - console.log(`SESSION EVENT=${eventName}: `, data); - }); - - session.on('closed', (code, message) => { - console.log(`SESSION CLOSED, code=${code}, message="${message}"`); - process.exit(1); - }); - } -}; - // Create master dimension using Enigma.js const createDimension = async (options, app, dimensionDefRow, colPos, newPerValueColorMap, newDimensionColor, importLimit) => { // Create a new master dimension in the app @@ -582,7 +542,6 @@ const updateMeasure = async (options, existingMeasure, app, measureDefRow, colPo delete measureData.qMeasure.coloring.gradient; } - logger.verbose(`Updating existing measure "${measureData.qMetaDef.title}"`); logger.debug(`Measure data: ${JSON.stringify(measureData, null, 2)}`); // Update existing measure with new data @@ -716,7 +675,7 @@ const validateMasterMeasureFields = (masterItemDefRow, colPos) => { // Take 10 items at a time from defintions array and creates master items for them. // Repeat until all definitions have been processed. -const createMasterItems = async (masterItemDefs, options, colPos, existingMeasures, existingDimensions) => { +const createMasterItems = async (masterItemDefs, options, colPos, existingMeasures, existingDimensions, session) => { const masterItemDefinitions = masterItemDefs.slice(); // Remove header row @@ -744,13 +703,29 @@ const createMasterItems = async (masterItemDefs, options, colPos, existingMeasur const masterItemBatch = masterItemDefinitions.splice(0, 10); // Create new session to Sense engine - const configEnigma = await setupEnigmaConnection(options); - const session = await enigma.create(configEnigma); - - // Set up logging of websocket traffic - addTrafficLogging(session, options); - - const global = await session.open(); + // let configEnigma; + // let session; + // try { + // console.log('A1'); + // configEnigma = await setupEnigmaConnection(options, sessionId); + // console.log('A2'); + // session = await enigma.create(configEnigma); + // console.log('A3'); + // } catch (err) { + // logger.error(`Error creating session to server ${options.host}: ${err}`); + // process.exit(1); + // } + + // // Set up logging of websocket traffic + // addTrafficLogging(session, options); + + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } const engineVersion = await global.engineVersion(); logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); @@ -963,11 +938,17 @@ const createMasterItems = async (masterItemDefs, options, colPos, existingMeasur } } - if ((await session.close()) === true) { - logger.verbose(`Closed session after adding/updating master items in app ${options.appId} on host ${options.host}`); - } else { - logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); - } + // console.log('session.close 1'); + // if ((await session.close()) === true) { + // logger.verbose(`Closed session after adding/updating 10 master items in app ${options.appId} on host ${options.host}`); + + // // Wait 2 sec before creating a new session + // // This will help avoiding the 5 concurrent session limit + // logger.debug(`Sleeping for 2 s`); + // await sleep(2000); + // } else { + // logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); + // } } }; @@ -1030,22 +1011,45 @@ const importMasterItemFromExcel = async (options) => { // --col-master-item-color const colPosMasterItemPerValueColor = getColumnPos(options, options.colMasterItemPerValueColor, sheet.data[0]); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; - const session = enigma.create(configEnigma); + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); + } // Set up logging of websocket traffic addTrafficLogging(session, options); - const global = await session.open(); + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } + // console.log('B6'); const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); - + // console.log('B7'); // Get list of all existing master dimensions and measures // https://help.qlik.com/en-US/sense-developer/May2021/APIs/EngineAPI/definitions-NxLibraryDimensionDef.html const dimensionCall = { @@ -1088,13 +1092,19 @@ const importMasterItemFromExcel = async (options) => { const measuresLayout = await measuresModel.getLayout(); // Close session - if ((await session.close()) === true) { - logger.verbose( - `Closed session after reading list of existing master measure & dimensions in app ${options.appId} on host ${options.host}` - ); - } else { - logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); - } + // console.log('session.close 2'); + // if ((await session.close()) === true) { + // logger.verbose( + // `Closed session after reading list of existing master measure & dimensions in app ${options.appId} on host ${options.host}` + // ); + + // // Wait 2 sec before continuing + // // This will help avoiding the 5 concurrent session limit + // logger.debug(`Sleeping for 2 s`); + // await sleep(2000); + // } else { + // logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); + // } // Loop through rows in Excel file, extracting data for rows flagged as master items // let importCount = 0; @@ -1113,28 +1123,29 @@ const importMasterItemFromExcel = async (options) => { colPosMasterItemPerValueColor, }, measuresLayout.qMeasureList.qItems, - dimsLayout.qDimensionList.qItems + dimsLayout.qDimensionList.qItems, + session ); } logger.info(`Imported ${importCount} master items from Excel file ${options.file}`); // const resSave = await app.doSave(); - - // if ((await session.close()) === true) { - // logger.verbose(`Closed session after adding/updating master items in app ${options.appId} on host ${options.host}`); - // } else { - // logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); - // } + // console.log('session.close 1'); + if ((await session.close()) === true) { + logger.verbose(`Closed session after adding/updating master items in app ${options.appId} on host ${options.host}`); + } else { + logger.error(`Error closing session for app ${options.appId} on host ${options.host}`); + } } catch (err) { logger.error(err.stack); } }; -const importMasterItemFromFile = (options) => { +const importMasterItemFromFile = async (options) => { if (options.fileType === 'excel') { // Source file type is Excel - importMasterItemFromExcel(options); + await importMasterItemFromExcel(options); } }; diff --git a/src/lib/cmd/importapp.js b/src/lib/cmd/importapp.js index 3967040..f2093de 100644 --- a/src/lib/cmd/importapp.js +++ b/src/lib/cmd/importapp.js @@ -78,7 +78,9 @@ const importAppFromFile = async (options) => { // Import apps specified in Excel file const importedApps = await qlikSenseApps.importAppsFromFiles(appsFromFile, tagsExisting, cpExisting); logger.debug(`Imported apps:\n${JSON.stringify(importedApps, null, 2)}`); + return importedApps; } + return false; } catch (err) { logger.error(`IMPORT APP: ${err.stack}`); } diff --git a/src/lib/cmd/scramblefield.js b/src/lib/cmd/scramblefield.js index c4ad59b..df0991d 100644 --- a/src/lib/cmd/scramblefield.js +++ b/src/lib/cmd/scramblefield.js @@ -1,6 +1,6 @@ const enigma = require('enigma.js'); -const { setupEnigmaConnection } = require('../util/enigma'); +const { setupEnigmaConnection, addTrafficLogging } = require('../util/enigma'); const { logger, setLoggingLevel, isPkg, execPath } = require('../../globals'); /** @@ -18,20 +18,40 @@ const scrambleField = async (options) => { logger.info('Scramble field'); logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - // Configure Enigma.js - const configEnigma = await setupEnigmaConnection(options); + // Session ID to use when connecting to the Qlik Sense server + const sessionId = 'ctrlq'; - const session = enigma.create(configEnigma); - if (options.logLevel === 'silly') { - // eslint-disable-next-line no-console - session.on('traffic:sent', (data) => console.log('sent:', data)); - // eslint-disable-next-line no-console - session.on('traffic:received', (data) => console.log('received:', data)); + // Create new session to Sense engine + let configEnigma; + let session; + try { + configEnigma = await setupEnigmaConnection(options, sessionId); + session = await enigma.create(configEnigma); + logger.verbose(`Created session to server ${options.host}.`); + } catch (err) { + logger.error(`Error creating session to server ${options.host}: ${err}`); + process.exit(1); } - const global = await session.open(); - const engineVersion = await global.engineVersion(); - logger.verbose(`Created session to server ${options.host}, engine version is ${engineVersion.qComponentVersion}.`); + // Set up logging of websocket traffic + addTrafficLogging(session, options); + + let global; + try { + global = await session.open(); + } catch (err) { + logger.error(`Error opening session to server ${options.host}: ${err}`); + process.exit(1); + } + + let engineVersion; + try { + engineVersion = await global.engineVersion(); + logger.verbose(`Server ${options.host} has engine version ${engineVersion.qComponentVersion}.`); + } catch (err) { + logger.error(`Error getting engine version from server ${options.host}: ${err}`); + process.exit(1); + } const app = await global.openDoc(options.appId, '', '', '', false); logger.verbose(`Opened app ${options.appId}.`); diff --git a/src/lib/cmd/testconnection.js b/src/lib/cmd/testconnection.js index 0ac9fe1..9b7a719 100644 --- a/src/lib/cmd/testconnection.js +++ b/src/lib/cmd/testconnection.js @@ -22,7 +22,8 @@ const testConnection = async (options) => { logger.info(`Successfully connected to Qlik Sense server ${options.host} on port ${options.port}`); logger.info(`Qlik Sense repository build version: ${aboutInfo.buildVersion}`); logger.info(`Qlik Sense repository build date: ${aboutInfo.buildDate}`); - return true; + + return aboutInfo; } catch (err) { logger.error(`EXPORT APP: ${err.stack}`); return false; diff --git a/src/lib/util/about.js b/src/lib/util/about.js index 950bdfb..9a70d01 100644 --- a/src/lib/util/about.js +++ b/src/lib/util/about.js @@ -8,20 +8,25 @@ function getAboutFromQseow(options) { return new Promise((resolve, reject) => { logger.verbose(`Getting about info from QSEoW...`); - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - const axiosConfig = setupQRSConnection(options, { - method: 'get', - fileCert, - fileCertKey, - path: '/qrs/about', - // headers: { - // 'Accept': 'application/json', - // 'Content-Type': 'application/json; charset=utf-8', - // }, - }); + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/about', + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/about', + }); + } logger.debug(`About to get about info from QSEoW`); diff --git a/src/lib/util/app.js b/src/lib/util/app.js index 27d7bf3..857e630 100644 --- a/src/lib/util/app.js +++ b/src/lib/util/app.js @@ -58,16 +58,18 @@ async function getApps(options, idArray, tagArray) { } logger.debug(`GET APPS: QRS query filter (incl ids, tags): ${filter}`); - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - let axiosConfig; if (filter === '') { // No apps matching the provided app IDs and tags. Error! logger.error('GET APPS: No apps matching the provided app IDs and and tags. Exiting.'); process.exit(1); - } else { + } + // Should cerrificates be used for authentication? + else if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + axiosConfig = setupQRSConnection(options, { method: 'get', fileCert, @@ -75,6 +77,12 @@ async function getApps(options, idArray, tagArray) { path: '/qrs/app/full', queryParameters: [{ name: 'filter', value: filter }], }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/app/full', + queryParameters: [{ name: 'filter', value: filter }], + }); } const result = await axios.request(axiosConfig); @@ -110,16 +118,25 @@ async function getAppById(appId, optionsParam) { return false; } - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - const axiosConfig = setupQRSConnection(options, { - method: 'get', - fileCert, - fileCertKey, - path: `/qrs/app/${appId}`, - }); + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: `/qrs/app/${appId}`, + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: `/qrs/app/${appId}`, + }); + } const result = await axios.request(axiosConfig); logger.debug(`GET APP BY ID: Result=${result.status}`); @@ -129,11 +146,11 @@ async function getAppById(appId, optionsParam) { logger.debug(`GET APP BY ID: App details: ${app}`); if (app && app?.id) { - // Yes, the task exists + // Yes, the app exists logger.verbose(`App exists: ID=${app.id}. App name="${app.name}"`); return app; } - // No, the task does not exist + // No, the app does not exist logger.verbose(`App does not exist: ID=${appId}`); } @@ -151,23 +168,38 @@ async function getAppById(appId, optionsParam) { } // Function to delete app given app ID -async function deleteAppById(appId) { +async function deleteAppById(appId, options) { + // Ensuire options are specified. Exit if not + if (!options) { + logger.error(`DELETE APP: No options specified. Exiting.`); + process.exit(1); + } + try { logger.debug(`DELETE APP: Starting delete app from QSEoW for app id ${appId}`); // Get CLI options - const cliOptions = getCliOptions(); + // const cliOptions = getCliOptions(); - // Make sure certificates exist - const fileCert = path.resolve(execPath, cliOptions.authCertFile); - const fileCertKey = path.resolve(execPath, cliOptions.authCertKeyFile); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - const axiosConfig = setupQRSConnection(cliOptions, { - method: 'delete', - fileCert, - fileCertKey, - path: `/qrs/app/${appId}`, - }); + axiosConfig = setupQRSConnection(options, { + method: 'delete', + fileCert, + fileCertKey, + path: `/qrs/app/${appId}`, + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'delete', + path: `/qrs/app/${appId}`, + }); + } const result = await axios.request(axiosConfig); logger.debug(`DELETE APP: Result=result.status`); @@ -190,8 +222,78 @@ async function deleteAppById(appId) { } } +// Check if an app with a given id exists +async function appExistById(appId, options) { + try { + logger.debug(`Checking if app with id ${appId} exists in QSEoW`); + + // Is the app ID a valid GUID? + if (!validate(appId)) { + logger.error(`APP EXIST BY ID: App ID ${appId} is not a valid GUID.`); + + return false; + } + + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/app', + queryParameters: [{ name: 'filter', value: encodeURI(`id eq ${appId}`) }], + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/app', + queryParameters: [{ name: 'filter', value: encodeURI(`id eq ${appId}`) }], + }); + } + + const result = await axios.request(axiosConfig); + logger.debug(`APP EXIST BY ID: Result=${result.status}`); + + if (result.status === 200) { + const apps = JSON.parse(result.data); + logger.debug(`APP EXIST BY ID: App details: ${JSON.stringify(apps)}`); + + if (apps.length === 1 && apps[0].id) { + // Yes, the app exists + logger.verbose(`App exists: ID=${apps[0].id}. App name="${apps[0].name}"`); + + return true; + } + + if (apps.length > 1) { + logger.error(`More than one app with ID ${appId} found. Should not be possible. Exiting.`); + process.exit(1); + } else { + return false; + } + } + + return false; + } catch (err) { + logger.error(`APP EXIST BY ID: ${err}`); + + // Show stack trace if available + if (err?.stack) { + logger.error(`APP EXIST BY ID:\n ${err.stack}`); + } + + return false; + } +} + module.exports = { getApps, getAppById, deleteAppById, + appExistById, }; diff --git a/src/lib/util/assert-options.js b/src/lib/util/assert-options.js index b530919..4efea24 100644 --- a/src/lib/util/assert-options.js +++ b/src/lib/util/assert-options.js @@ -18,24 +18,33 @@ const sharedParamAssertOptions = async (options) => { logger.debug(`authCertFile: ${options.authCertFile}`); logger.debug(`authCertKeyFile: ${options.authCertKeyFile}`); - // Verify that certificate files exists (if specified) - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - - const fileCertExists = await verifyFileExists(fileCert); - if (fileCertExists === false) { - logger.error(`Missing certificate file ${fileCert}. Aborting`); - process.exit(1); - } else { - logger.verbose(`Certificate file ${fileCert} found`); - } + // If certificate authentication is used: certs and user dir/id must be present. + if (options.authType === 'cert') { + // Verify that certificate files exists (if specified) + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + const fileCertExists = await verifyFileExists(fileCert); + if (fileCertExists === false) { + logger.error(`Missing certificate file ${fileCert}. Aborting`); + process.exit(1); + } else { + logger.verbose(`Certificate file ${fileCert} found`); + } - const fileCertKeyExists = await verifyFileExists(fileCertKey); - if (fileCertKeyExists === false) { - logger.error(`Missing certificate key file ${fileCertKey}. Aborting`); - process.exit(1); - } else { - logger.verbose(`Certificate key file ${fileCertKey} found`); + const fileCertKeyExists = await verifyFileExists(fileCertKey); + if (fileCertKeyExists === false) { + logger.error(`Missing certificate key file ${fileCertKey}. Aborting`); + process.exit(1); + } else { + logger.verbose(`Certificate key file ${fileCertKey} found`); + } + } else if (options.authType === 'jwt') { + // Verify that --auth-jwt parameter is specified + if (options.authJwt === undefined || !options.authJwt) { + logger.error('Mandatory option --auth-jwt is missing. Use it to specify the JWT token to use for authentication.'); + process.exit(1); + } } }; diff --git a/src/lib/util/customproperties.js b/src/lib/util/customproperties.js index 5b13954..5043063 100644 --- a/src/lib/util/customproperties.js +++ b/src/lib/util/customproperties.js @@ -8,16 +8,25 @@ function getCustomPropertiesFromQseow(options) { return new Promise((resolve, reject) => { logger.verbose(`Getting custom properties from QSEoW...`); - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - - const axiosConfig = setupQRSConnection(options, { - method: 'get', - fileCert, - fileCertKey, - path: '/qrs/custompropertydefinition/full', - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/custompropertydefinition/full', + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/custompropertydefinition/full', + }); + } axios .request(axiosConfig) diff --git a/src/lib/util/enigma.js b/src/lib/util/enigma.js index bd72de6..60b62c3 100644 --- a/src/lib/util/enigma.js +++ b/src/lib/util/enigma.js @@ -4,39 +4,155 @@ const path = require('path'); const { logger, execPath, readCert } = require('../../globals'); -const setupEnigmaConnection = async (options) => { +const setupEnigmaConnection = async (options, sessionId) => { logger.debug('Prepping for Enigma connection...'); - logger.verbose('Verify that cert files exists'); - - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - // eslint-disable-next-line global-require, import/no-dynamic-require const qixSchema = require(`enigma.js/schemas/${options.schemaVersion}`); - return { - schema: qixSchema, - url: SenseUtilities.buildUrl({ - host: options.host, - port: options.enginePort !== undefined ? options.enginePort : options.port, - prefix: options.virtualProxy, - secure: options.secure, - appId: options.appId, - }), - createSocket: (url) => - new WebSocket(url, { - key: readCert(fileCertKey), - cert: readCert(fileCert), - headers: { - 'X-Qlik-User': `UserDirectory=${options.authUserDir};UserId=${options.authUserId}`, - }, - rejectUnauthorized: false, + let enigmaConfig; + // Should certificates be used for authentication? + if (options.authType === 'cert') { + logger.verbose(`Using certificates for authentication with Enigma`); + + logger.verbose('Verify that cert files exists'); + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + if (!fileCert || !fileCertKey) { + logger.error(`Certificate file(s) not found when setting up Enigma connection`); + process.exit(1); + } + + // Set up Enigma configuration + // buildUrl docs: https://github.com/qlik-oss/enigma.js/blob/master/docs/api.md#senseutilitiesbuildurlconfig + enigmaConfig = { + schema: qixSchema, + url: SenseUtilities.buildUrl({ + host: options.host, + port: options.enginePort !== undefined ? options.enginePort : options.port, + prefix: options.virtualProxy, + route: 'app/engineData', + secure: options.secure, + appId: options.appId, }), - protocol: { delta: true }, - }; + createSocket: (url) => + new WebSocket(url, { + key: readCert(fileCertKey), + cert: readCert(fileCert), + headers: { + 'X-Qlik-User': `UserDirectory=${options.authUserDir};UserId=${options.authUserId}`, + }, + rejectUnauthorized: false, + }), + protocol: { delta: true }, + }; + } else if (options.authType === 'jwt') { + logger.verbose(`Using JWT for authentication with Enigma`); + + try { + logger.verbose('Building Enigma config using sessionId: ', sessionId); + + // Set up Enigma configuration + // buildUrl docs: https://github.com/qlik-oss/enigma.js/blob/master/docs/api.md#senseutilitiesbuildurlconfig + enigmaConfig = { + schema: qixSchema, + url: SenseUtilities.buildUrl({ + host: options.host, + port: options.enginePort !== undefined ? options.enginePort : options.port, + prefix: options.virtualProxy, + route: 'app/engineData', + secure: options.secure, + appId: options.appId, + ttl: 5, + identity: sessionId || undefined, + }), + createSocket: (url) => + new WebSocket(url, { + headers: { + Authorization: `Bearer ${options.authJwt}`, + }, + rejectUnauthorized: false, + }), + protocol: { delta: true }, + }; + } catch (err) { + logger.error(`Error when setting up Enigma connection: ${err}`); + process.exit(1); + } + } + + return enigmaConfig; +}; + +// Function to add logging of session's websocket traffic +const addTrafficLogging = (session, options) => { + session.on('notification:*', (eventName, data) => { + // console.log(`SESSION EVENT=${eventName}: `, data); + + if (eventName === 'EVENT=OnAuthenticationInformation') { + // Authentication successful + logger.verbose(`Session event "${eventName}": ${JSON.stringify(data, null, 2)}`); + } else if (eventName === 'OnConnected') { + // Session created + logger.verbose(`Session event "${eventName}": ${JSON.stringify(data, null, 2)}`); + } else if (eventName === 'OnMaxParallelSessionsExceeded') { + // Too many concurrent sessions + logger.error(`Session event "${eventName}": ${JSON.stringify(data, null, 2)}`); + } else { + logger.verbose(`Session event "${eventName}": ${JSON.stringify(data, null, 2)}`); + } + }); + + session.on('closed', (code, message) => { + logger.verbose(`Session closed`); + // logger.verbose (`Session closed, code=${code}, message="${message}"`); + // console.log(JSON.stringify(code, null, 2)); + }); + + session.on('opened', (code, message) => { + logger.verbose(`SESSION opened, code=${code}, message="${message}"`); + }); + + if (options.logLevel === 'silly') { + session.on('traffic:sent', (data) => console.log('sent:', data)); + + session.on('traffic:received', (data) => { + console.log('received:', data); + if (data?.result?.qReturn) { + console.log(`qReturn: ${JSON.stringify(data.result.qReturn, null, 2)}`); + } + + if (data?.result?.qInfo) { + console.log(`qInfo: ${JSON.stringify(data.result.qInfo, null, 2)}`); + } + + if (data?.change?.length > 1) { + console.log(`change length > 1: ${JSON.stringify(data.change, null, 2)}`); + + console.log('received:', data); + if (data?.result?.qReturn) { + console.log(`qReturn: ${JSON.stringify(data.result.qReturn, null, 2)}`); + } + + if (data?.result?.qInfo) { + console.log(`qInfo: ${JSON.stringify(data.result.qInfo, null, 2)}`); + } + } + }); + + session.on('notification:*', (eventName, data) => { + console.log(`SESSION EVENT=${eventName}: `, data); + }); + + session.on('closed', (code, message) => { + console.log(`SESSION CLOSED, code=${code}, message="${message}"`); + process.exit(1); + }); + } }; module.exports = { setupEnigmaConnection, + addTrafficLogging, }; diff --git a/src/lib/util/qrs.js b/src/lib/util/qrs.js index 1ce9afa..2638a33 100644 --- a/src/lib/util/qrs.js +++ b/src/lib/util/qrs.js @@ -16,32 +16,76 @@ const setupQRSConnection = (options, param) => { process.exit(1); } - const httpsAgent = new https.Agent({ - rejectUnauthorized: false, - cert: readCert(param.fileCert), - key: readCert(param.fileCertKey), - }); - // Port is specified slightly differently for different Ctrl-Q commands const port = options.qrsPort === undefined ? options.port : options.qrsPort; // Set up Sense repository service configuration const xrfKey = generateXrfKey(); - const axiosConfig = { - url: `${param.path}?xrfkey=${xrfKey}`, - method: param.method.toLowerCase(), - baseURL: `https://${options.host}:${port}`, - headers: { - 'x-qlik-xrfkey': xrfKey, - 'X-Qlik-User': `UserDirectory=${options.authUserDir};UserId=${options.authUserId}`, - }, - responseType: 'application/json', - responseEncoding: 'utf8', - httpsAgent, - timeout: 60000, - // passphrase: "YYY" - }; + // Sanitize virtual proxy prefix + // If the virtual proxy proxydoes not start with a slash, add it + // If the virtual proxy ends with a slash, remove it + let newVirtualProxy = options.virtualProxy; + if (options.virtualProxy) { + newVirtualProxy = options.virtualProxy.replace(/\/$/, ''); + + // If the virtual proxy length is longer than 1 and does not start with a slash, add it + if (newVirtualProxy.length > 1 && !newVirtualProxy.startsWith('/')) { + newVirtualProxy = `/${newVirtualProxy}`; + } + } + + // If param.path starts with a slash, remove it + let newPath = param.path; + if (param.path) { + newPath = param.path.replace(/^\//, ''); + } + + let axiosConfig; + // Should cerrificates be used for authentication? + if (options.authType === 'cert') { + logger.verbose(`Using certificates for authentication with QRS`); + + const httpsAgent = new https.Agent({ + rejectUnauthorized: false, + cert: readCert(param.fileCert), + key: readCert(param.fileCertKey), + }); + + axiosConfig = { + url: `${newVirtualProxy}/${newPath}?xrfkey=${xrfKey}`, + method: param.method.toLowerCase(), + baseURL: `https://${options.host}:${port}`, + headers: { + 'x-qlik-xrfkey': xrfKey, + 'X-Qlik-User': `UserDirectory=${options.authUserDir};UserId=${options.authUserId}`, + }, + responseType: 'application/json', + responseEncoding: 'utf8', + httpsAgent, + timeout: 60000, + }; + } else if (options.authType === 'jwt') { + logger.verbose(`Using JWT for authentication with QRS`); + + const httpsAgent = new https.Agent({ + rejectUnauthorized: false, + }); + + axiosConfig = { + url: `${newVirtualProxy}/${newPath}?xrfkey=${xrfKey}`, + method: param.method.toLowerCase(), + baseURL: `https://${options.host}:${port}`, + headers: { + 'x-qlik-xrfkey': xrfKey, + Authorization: `Bearer ${options.authJwt}`, + }, + responseType: 'application/json', + responseEncoding: 'utf8', + httpsAgent, + timeout: 60000, + }; + } // Add message body (if any) if (param.body) { diff --git a/src/lib/util/tag.js b/src/lib/util/tag.js index 6d0602b..745ef2a 100644 --- a/src/lib/util/tag.js +++ b/src/lib/util/tag.js @@ -8,20 +8,25 @@ function getTagsFromQseow(options) { return new Promise((resolve, reject) => { logger.verbose(`Getting tags from QSEoW...`); - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - const axiosConfig = setupQRSConnection(options, { - method: 'get', - fileCert, - fileCertKey, - path: '/qrs/tag/full', - // headers: { - // 'Accept': 'application/json', - // 'Content-Type': 'application/json; charset=utf-8', - // }, - }); + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/tag/full', + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/tag/full', + }); + } logger.debug(`About to retrieve tags from QRS API.`); diff --git a/src/lib/util/task.js b/src/lib/util/task.js index c4602e3..6c1077b 100644 --- a/src/lib/util/task.js +++ b/src/lib/util/task.js @@ -28,18 +28,28 @@ async function taskExistById(taskId, optionsParam) { return false; } - // Make sure certificates exist - const fileCert = path.resolve(execPath, options.authCertFile); - const fileCertKey = path.resolve(execPath, options.authCertKeyFile); - - // const filter = encodeURI(`name eq '👍😎 updateSheetThumbnail'`); - const axiosConfig = setupQRSConnection(options, { - method: 'get', - fileCert, - fileCertKey, - path: '/qrs/task', - queryParameters: [{ name: 'filter', value: encodeURI(`id eq ${taskId}`) }], - }); + // Should cerrificates be used for authentication? + let axiosConfig; + if (options.authType === 'cert') { + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + // const filter = encodeURI(`name eq '👍😎 updateSheetThumbnail'`); + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/task', + queryParameters: [{ name: 'filter', value: encodeURI(`id eq ${taskId}`) }], + }); + } else if (options.authType === 'jwt') { + axiosConfig = setupQRSConnection(options, { + method: 'get', + path: '/qrs/task', + queryParameters: [{ name: 'filter', value: encodeURI(`id eq ${taskId}`) }], + }); + } const result = await axios.request(axiosConfig); logger.debug(`TASK EXIST BY ID: Result=${result.status}`); diff --git a/testdata/tasks.xlsx b/testdata/tasks.xlsx index ef0c8509b81dc627938235471d2450351caaa524..b67c38431909e014842ccc11fa5a621bfe545600 100644 GIT binary patch delta 17615 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%0D0{jrKmbCm(a``w1kEF* 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 RKglsb|2Lp}so(!>{Vy(VaC86w delta 9389 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