From 96452a9f829653bd58c1bdef7d3abb6307f407d9 Mon Sep 17 00:00:00 2001 From: mariana-andruk-envision Date: Fri, 19 Jun 2026 17:46:20 +0300 Subject: [PATCH] test: backend unit-test coverage (689 cases across 14 packages) Adds backend unit tests across 14 packages using the existing mocha+esmock harness (same dist-import pattern as the current common/tests suite). Tests and test-script wiring only; no source changes. Each test was validated by building the service and running it individually; only tests passing green are included. Per-package: common 175, api-gateway 115, guardian-service 94, policy-service 67, interfaces 161, auth-service 19, worker-service 14, analytics-service 13, ai-service 10, topic-listener 7, notification 7, logger 4, queue 3. Adds mocha test scripts to interfaces, ai-service, logger-service, notification-service, topic-listener-service. --- ai-service/package.json | 3 +- ai-service/tests/api-response.test.mjs | 42 + ai-service/tests/config.test.mjs | 8 + .../tests/files-manager-helper.test.mjs | 177 +++ ai-service/tests/files-manager-io.test.mjs | 165 ++ ai-service/tests/files-manager.test.mjs | 186 +++ ai-service/tests/general-helper.test.mjs | 84 + ai-service/tests/mongo-constants.test.mjs | 14 + ai-service/tests/openai-connect.test.mjs | 60 + ai-service/tests/openai-helper-chain.test.mjs | 16 + ai-service/tests/suggestions.test.mjs | 30 + .../analytics-services-coverage.test.mjs | 425 +++++ .../tests/analytics-services-smoke.test.mjs | 132 ++ .../tests/analytics-utils.test.mjs | 109 ++ analytics-service/tests/dto-shape.test.mjs | 76 + .../tests/entity-decorators.test.mjs | 71 + .../tests/entity-init-hooks.test.mjs | 95 ++ .../tests/entity-init-state.test.mjs | 74 + .../tests/interfaces-enums.test.mjs | 93 ++ analytics-service/tests/migration.test.mjs | 58 + .../tests/report-data-dto.test.mjs | 80 + .../tests/swagger-config.test.mjs | 42 + analytics-service/tests/table.test.mjs | 60 + analytics-service/tests/tasks.test.mjs | 83 + .../tests/auth/auth-user-decorator.test.mjs | 42 + api-gateway/tests/auth/roles-guard.test.mjs | 84 + .../tests/authorization-helper.test.js | 102 ++ api-gateway/tests/cache-utils.test.js | 56 + api-gateway/tests/dto/_dto-helper.mjs | 36 + .../tests/dto/accounts-response-dto.test.mjs | 124 ++ .../tests/dto/analytics-compare-dto.test.mjs | 256 +++ api-gateway/tests/dto/analytics-dto.test.mjs | 204 +++ .../dto/analytics-search-blocks-dto.test.mjs | 197 +++ api-gateway/tests/dto/demo-dto.test.mjs | 69 + api-gateway/tests/dto/errors-dto.test.mjs | 114 ++ .../tests/dto/external-policies-dto.test.mjs | 73 + api-gateway/tests/dto/formulas-dto.test.mjs | 93 ++ api-gateway/tests/dto/mock-dto.test.mjs | 148 ++ .../tests/dto/policies-core-dto.test.mjs | 143 ++ .../tests/dto/policies-misc-dto.test.mjs | 130 ++ .../tests/dto/policy-comments-dto.test.mjs | 127 ++ .../tests/dto/policy-labels-dto.test.mjs | 125 ++ .../tests/dto/policy-statistics-dto.test.mjs | 102 ++ api-gateway/tests/dto/profiles-dto.test.mjs | 136 ++ api-gateway/tests/dto/record-dto.test.mjs | 121 ++ .../relayer-policy-parameters-dto.test.mjs | 79 + .../tests/dto/schema-rules-tag-dto.test.mjs | 108 ++ api-gateway/tests/dto/schemas-dto.test.mjs | 183 +++ .../tests/dto/suggestions-dto.test.mjs | 80 + api-gateway/tests/dto/token-dto.test.mjs | 77 + .../tests/dto/worker-permissions-dto.test.mjs | 78 + api-gateway/tests/entity-owner.test.mjs | 55 + api-gateway/tests/filename-sanitizer.test.js | 54 + .../tests/helpers/artifact-constants.test.mjs | 20 + .../tests/helpers/cache-provider.test.mjs | 154 ++ .../decorators/file-param-decorator.test.mjs | 41 + .../match-validator-decorator.test.mjs | 59 + .../singleton-decorator-extra.test.mjs | 52 + .../decorators/user-param-decorator.test.mjs | 43 + .../tests/helpers/entity-owner.test.mjs | 36 + .../helpers/find-replace-entities.test.mjs | 50 + .../helpers/guardians-contracts.test.mjs | 240 +++ .../helpers/guardians-documents.test.mjs | 250 +++ .../helpers/guardians-modules-tools.test.mjs | 375 +++++ .../tests/helpers/guardians-policies.test.mjs | 222 +++ .../helpers/guardians-schemas-extra.test.mjs | 659 ++++++++ .../tests/helpers/guardians-schemas.test.mjs | 301 ++++ .../guardians-tags-themes-record.test.mjs | 419 +++++ .../tests/helpers/guardians-tokens.test.mjs | 197 +++ .../helpers/guardians-wipe-retire.test.mjs | 691 +++++++++ .../multipart-interceptor.test.mjs | 211 +++ .../interceptors/multipart-types.test.mjs | 28 + .../tests/helpers/logger-providers.test.mjs | 92 ++ .../tests/helpers/match-validator.test.mjs | 21 + api-gateway/tests/helpers/meeco.test.mjs | 158 ++ .../tests/helpers/module-constants.test.mjs | 18 + .../tests/helpers/mongo-constants.test.mjs | 13 + .../tests/helpers/parse-integer.test.mjs | 32 + .../helpers/performance-interceptor.test.mjs | 75 + .../tests/helpers/policy-constants.test.mjs | 18 + .../helpers/policy-engine-blocks.test.mjs | 428 +++++ .../tests/helpers/providers-index.test.mjs | 39 + .../tests/helpers/routes-constants.test.mjs | 18 + .../tests/helpers/schema-constants.test.mjs | 17 + .../tests/helpers/schema-utils-extra.test.mjs | 119 ++ .../tests/helpers/schema-utils.test.mjs | 54 + .../helpers/singleton-decorator.test.mjs | 24 + .../tests/helpers/stream-to-buffer.test.mjs | 33 + .../tests/helpers/task-manager.test.mjs | 343 +++++ .../tests/helpers/token-constants.test.mjs | 15 + .../tests/helpers/tool-constants.test.mjs | 16 + .../helpers/utils-parent-internal.test.mjs | 118 ++ api-gateway/tests/helpers/utils.test.mjs | 101 ++ .../tests/interceptor-cache-utils.test.js | 54 + .../tests/interceptor-multipart.test.js | 64 + ...multipart-performance-interceptor.test.mjs | 445 ++++++ api-gateway/tests/match-validator.test.js | 61 + .../middlewares/accounts-schemas.test.mjs | 102 ++ .../tests/middlewares/csv-examples.test.mjs | 74 + .../tests/middlewares/examples.test.mjs | 99 ++ .../middlewares-index-barrel.test.mjs | 41 + .../tests/middlewares/page-header.test.mjs | 25 + .../middlewares/schemas-accounts.test.mjs | 134 ++ .../schemas-notifications.test.mjs | 73 + .../middlewares/schemas-settings.test.mjs | 62 + .../middlewares/string-or-number.test.mjs | 59 + .../middlewares/string-or-object.test.mjs | 51 + .../middlewares/validate-runtime.test.mjs | 163 ++ .../tests/middlewares/validation.test.mjs | 153 ++ .../roles-and-location-guard-extra.test.mjs | 99 ++ api-gateway/tests/roles-guard-matrix.test.mjs | 225 +++ api-gateway/tests/roles-guard.test.js | 120 ++ .../tests/service/_controller-harness.mjs | 85 + .../service/analytics-controller.test.mjs | 210 +++ api-gateway/tests/service/contract.test.mjs | 340 ++++ ...ials-ipfs-suggestions-controllers.test.mjs | 422 +++++ .../external-policy-controller.test.mjs | 231 +++ api-gateway/tests/service/formulas.test.mjs | 217 +++ ...dule-tool-tags-themes-controllers.test.mjs | 767 +++++++++ api-gateway/tests/service/module.test.mjs | 231 +++ ...ermissions-websockets-controllers.test.mjs | 262 ++++ .../tests/service/policy-comments.test.mjs | 192 +++ .../tests/service/policy-labels.test.mjs | 231 +++ ...ry-trustchains-wizard-controllers.test.mjs | 380 +++++ .../tests/service/policy-statistics.test.mjs | 195 +++ .../relayer-external-controllers.test.mjs | 208 +++ .../tests/service/tags-controller.test.mjs | 254 +++ api-gateway/tests/service/tool.test.mjs | 264 ++++ api-gateway/tests/singleton.test.js | 52 + api-gateway/tests/stream-to-buffer.test.js | 32 + api-gateway/tests/utils.test.js | 163 ++ .../tests/validate-middleware.test.mjs | 83 + .../tests/validation-middleware.test.js | 83 + .../tests/validation/dto-analytics.test.mjs | 1055 +++++++++++++ .../tests/validation/dto-misc.test.mjs | 550 +++++++ .../dto-mock-external-formulas.test.mjs | 488 ++++++ .../validation/dto-nested-enum-gaps.test.mjs | 575 +++++++ .../tests/validation/dto-policies.test.mjs | 677 ++++++++ .../dto-schemas-profiles-record.test.mjs | 710 +++++++++ .../validation/old-descriptions.test.mjs | 26 + .../validation/validation-pipes.test.mjs | 226 +++ auth-service/tests/_handler-harness.mjs | 191 +++ .../tests/account-service-handlers.test.mjs | 108 ++ auth-service/tests/bitstring.test.mjs | 56 + .../tests/credentials-assertions.test.mjs | 43 + ...credentials-validation-assertions.test.mjs | 87 ++ .../credentials-validation-bitstring.test.mjs | 105 ++ .../credentials-validation-extra.test.mjs | 257 ++++ auth-service/tests/cryppo.test.mjs | 111 ++ auth-service/tests/entity-init-hooks.test.mjs | 188 +++ .../tests/import-keys-from-database.test.mjs | 139 ++ auth-service/tests/meeco-api.test.mjs | 314 ++++ auth-service/tests/meeco-service.test.mjs | 226 +++ auth-service/tests/mongo-constants.test.mjs | 15 + .../tests/password-constants.test.mjs | 29 + .../tests/relayer-accounts-handlers.test.mjs | 68 + .../tests/role-service-handlers.test.mjs | 73 + .../tests/user-password-branches.test.mjs | 67 + .../tests/user-password-validate.test.mjs | 54 + auth-service/tests/user-password.test.mjs | 88 ++ .../tests/validate-config-jwt-tokens.test.mjs | 128 ++ .../base-integration.test.mjs | 176 +++ .../common-variables.test.mjs | 52 + .../console-transport-edge.test.mjs | 217 +++ .../console-transport.test.mjs | 130 ++ .../context-helper/context-helper.test.mjs | 91 ++ .../context-helper/set-context.test.mjs | 59 + .../custom-csv-parser-edge.test.mjs | 28 + .../custom-csv-parser.test.mjs | 35 + .../database-server-static.test.mjs | 22 + .../db-helper-constants.test.mjs | 37 + .../db-helper/aggregation-filters.test.mjs | 106 ++ .../db-helper/db-helper-gridfs.test.mjs | 97 ++ .../document-aggregation-filters.test.mjs | 145 ++ .../db-naming-strategy-edge.test.mjs | 22 + .../unit-tests/db-naming-strategy.test.mjs | 28 + common/tests/unit-tests/do-nothing.test.mjs | 11 + .../empty-notifier/empty-notifier.test.mjs | 62 + .../encrypt-utils/encrypt-utils.test.mjs | 46 + .../encrypt-vc-helper.test.mjs | 56 + .../entity/document-draft-hooks.test.mjs | 98 ++ .../entity/entity-file-hooks.test.mjs | 204 +++ .../unit-tests/enum-message-action.test.mjs | 24 + .../unit-tests/enum-message-type.test.mjs | 18 + .../environment/environment.test.mjs | 144 ++ .../fix-connection-string-edge.test.mjs | 24 + .../fix-connection-string.test.mjs | 31 + ...ate-config-integration-block-edge.test.mjs | 26 + ...generate-config-integration-block.test.mjs | 51 + .../unit-tests/generate-tls-options.test.mjs | 38 + .../generate-tls-options.test.mjs | 65 + .../hashing/hashing-encrypt-edge.test.mjs | 374 +++++ .../tests/unit-tests/hashing/hashing.test.mjs | 53 + .../hedera-environment/environment.test.mjs | 79 + .../hedera-modules/contract-message.test.mjs | 93 ++ .../hedera-modules/did-url.test.mjs | 51 + .../comment-discussion-messages.test.mjs | 197 +++ .../message/did-message-extra.test.mjs | 134 ++ .../message/label-document-message.test.mjs | 123 ++ .../message/label-message-extra.test.mjs | 179 +++ .../message/message-base.test.mjs | 187 +++ .../message/message-classes-coverage.test.mjs | 337 ++++ .../message/message-serializers.test.mjs | 378 +++++ .../message/message-server-parsers.test.mjs | 180 +++ .../policy-diff-message-extra.test.mjs | 178 +++ .../policy-record-message-extra.test.mjs | 201 +++ .../schema-package-message-extra.test.mjs | 246 +++ .../statistic-assessment-message.test.mjs | 123 ++ .../message/statistic-message-extra.test.mjs | 165 ++ .../policy-action-message-full.test.mjs | 77 + .../policy-action-message.test.mjs | 38 + .../policy-artifacts-messages.test.mjs | 126 ++ .../registration-message.test.mjs | 89 ++ .../hedera-modules/role-vc-messages.test.mjs | 126 ++ .../hedera-modules/tag-sync-messages.test.mjs | 88 ++ .../hedera-modules/token-message.test.mjs | 104 ++ .../tool-module-formula-messages.test.mjs | 103 ++ .../did/common-did-document-coverage.test.mjs | 110 ++ .../vcjs/did/common-did-document.test.mjs | 139 ++ .../vcjs/did/common-did.test.mjs | 61 + .../vcjs/did/did-document-fixtures.test.mjs | 181 +++ .../vcjs/did/did-extras-coverage.test.mjs | 122 ++ .../vcjs/did/did-types-properties.test.mjs | 77 + .../vcjs/did/document-context.test.mjs | 72 + .../vcjs/did/document-service.test.mjs | 37 + .../vcjs/did/hedera-did.test.mjs | 77 + .../vcjs/did/hedera-ed25519-generate.test.mjs | 143 ++ .../vcjs/did/hedera-methods.test.mjs | 95 ++ .../vcjs/did/verification-method.test.mjs | 118 ++ .../vcjs/vc-document-extra.test.mjs | 250 +++ .../vcjs/vc-document-fixtures.test.mjs | 146 ++ .../vcjs/vc-subject-extra.test.mjs | 157 ++ .../vcjs/vc-vp-subject-branches.test.mjs | 221 +++ .../vcjs/vcjs-coverage.test.mjs | 287 ++++ .../vcjs/vp-document-extra.test.mjs | 170 ++ .../vcjs/vp-document-fixtures.test.mjs | 108 ++ .../hedera-utils/timestamp-utils.test.mjs | 91 ++ .../helpers/settings-container.test.mjs | 361 +++++ .../formula-import-export.test.mjs | 194 +++ .../import-export-utils.test.mjs | 149 ++ .../module-import-export.test.mjs | 181 +++ .../policy-label-import-export.test.mjs | 193 +++ .../policy-statistic-import-export.test.mjs | 157 ++ .../record-import-export.test.mjs | 175 +++ .../schema-import-export.test.mjs | 133 ++ .../schema-rule-import-export.test.mjs | 166 ++ .../theme-import-export.test.mjs | 88 ++ .../import-export/tool-import-export.test.mjs | 193 +++ .../insert-variables-edge.test.mjs | 38 + .../insert-variables.test.mjs | 48 + .../integration-services.test.mjs | 123 ++ .../jwt-service-auth-guard-contract.test.mjs | 173 +++ .../jwt-service-auth-guard.test.mjs | 102 ++ .../memo-mappings/memo-mappings.test.mjs | 94 ++ .../message-action-type.test.mjs | 67 + .../unit-tests/misc/base-entity.test.mjs | 119 ++ .../misc/console-transport.test.mjs | 84 + .../unit-tests/misc/context-helper.test.mjs | 122 ++ .../misc/db-naming-strategy.test.mjs | 40 + .../tests/unit-tests/misc/dictionary.test.mjs | 70 + .../tests/unit-tests/misc/do-nothing.test.mjs | 22 + .../misc/document-entities.test.mjs | 141 ++ .../misc/document-state-lifecycle.test.mjs | 324 ++++ .../unit-tests/misc/dry-run-entity.test.mjs | 55 + .../unit-tests/misc/dynamic-role.test.mjs | 53 + .../unit-tests/misc/empty-notifier.test.mjs | 33 + .../misc/entity-defaults-2.test.mjs | 120 ++ .../misc/entity-defaults-3.test.mjs | 149 ++ .../unit-tests/misc/entity-defaults.test.mjs | 153 ++ .../tests/unit-tests/misc/expression.test.mjs | 99 ++ .../unit-tests/misc/hash-entities.test.mjs | 104 ++ .../misc/integration-block-helper.test.mjs | 58 + common/tests/unit-tests/misc/issuer.test.mjs | 82 + .../tests/unit-tests/misc/memo-map.test.mjs | 124 ++ .../unit-tests/misc/message-enums.test.mjs | 61 + .../unit-tests/misc/misc-helpers.test.mjs | 83 + .../misc/notification-step.test.mjs | 179 +++ .../misc/policy-document-entities.test.mjs | 99 ++ .../unit-tests/misc/policy-property.test.mjs | 77 + .../unit-tests/misc/restore-entities.test.mjs | 135 ++ .../unit-tests/misc/restore-entity.test.mjs | 83 + .../unit-tests/misc/serialization.test.mjs | 45 + .../tests/unit-tests/misc/sheet-name.test.mjs | 69 + .../unit-tests/misc/simple-entities.test.mjs | 78 + .../misc/singleton-decorator.test.mjs | 64 + .../unit-tests/misc/tag-indexer.test.mjs | 60 + .../unit-tests/misc/value-converters.test.mjs | 104 ++ .../tests/unit-tests/misc/vc-subject.test.mjs | 162 ++ .../mongo-transport/mongo-transport.test.mjs | 67 + .../mq-serialization/serialization.test.mjs | 50 + .../mq/codec-serialization-edge.test.mjs | Bin 0 -> 7967 bytes .../unit-tests/mq/external-channel.test.mjs | 149 ++ .../mq/large-payload-container.test.mjs | 50 + .../mq/message-broker-channel-loops.test.mjs | 313 ++++ .../unit-tests/mq/zip-codec-routing.test.mjs | 319 ++++ common/tests/unit-tests/mq/zip-codec.test.mjs | 70 + .../notification-step.test.mjs | 205 +++ .../notification/notification-events.test.mjs | 167 ++ .../pino-file-transport.test.mjs | 63 + .../pino-logger-constants.test.mjs | 30 + .../pino-logger/pino-logger-mapping.test.mjs | 228 +++ .../policy-category/policy-category.test.mjs | 105 ++ .../tests/unit-tests/policy-property.test.mjs | 49 + .../policy-property/policy-property.test.mjs | 58 + .../unit-tests/run-async/run-async.test.mjs | 48 + .../run-function-async.test.mjs | 56 + .../schema-converter-edge.test.mjs | 49 + .../schema-converter.test.mjs | 179 +++ .../schemas-context-policy-edge.test.mjs | 354 +++++ .../schemas-to-context.test.mjs | 69 + .../hcp-vault-configs.test.mjs | 75 + .../seq-transport/seq-transport.test.mjs | 105 ++ .../service-requests-base.test.mjs | 99 ++ .../singleton-decorator.test.mjs | 72 + common/tests/unit-tests/singleton.test.mjs | 38 + .../tests/unit-tests/table-file-ids.test.mjs | 63 + .../table-file-ids-edge.test.mjs | 32 + .../table-file-ids/table-file-ids.test.mjs | 100 ++ .../timestamp-utils/timestamp-utils.test.mjs | 102 ++ .../utils/common-utils-edge.test.mjs | 503 ++++++ .../validate-configuration.test.mjs | 104 ++ .../xlsx-dictionary/xlsx-dictionary.test.mjs | 138 ++ .../xlsx-expression/xlsx-expression.test.mjs | 101 ++ .../xlsx-expressions.test.mjs | 126 ++ .../generate-blocks.test.mjs | 150 ++ .../xlsx-result/xlsx-result.test.mjs | 156 ++ .../xlsx-schema-condition.test.mjs | 141 ++ .../xlsx-schema/xlsx-schema.test.mjs | 98 ++ .../xlsx-sheet-name/xlsx-sheet-name.test.mjs | 79 + .../xlsx-table-header.test.mjs | 116 ++ .../unit-tests/xlsx-table/xlsx-table.test.mjs | 149 ++ .../xlsx-tag-indexer.test.mjs | 56 + .../xlsx-converters-edge.test.mjs | 536 +++++++ .../xlsx-value-converters.test.mjs | 235 +++ .../xlsx-workbook-extra.test.mjs | 254 +++ .../xlsx-workbook/xlsx-workbook.test.mjs | 158 ++ guardian-service/tests/_handler-harness.mjs | 322 ++++ .../analytics-artifact-token-models.test.mjs | 213 +++ .../tests/unit/analytics-block-model.test.mjs | 83 + .../unit/analytics-block-tool-model.test.mjs | 123 ++ .../analytics-blocks-record-rates.test.mjs | 194 +++ .../analytics-comparator-csv-outputs.test.mjs | 122 ++ .../unit/analytics-compare-options.test.mjs | 130 ++ .../analytics-compare-policy-utils.test.mjs | 127 ++ .../analytics-csv-and-compare-utils.test.mjs | 208 +++ .../analytics-doc-policy-comparators.test.mjs | 106 ++ .../analytics-document-fields-model.test.mjs | 199 +++ .../unit/analytics-document-model.test.mjs | 191 +++ .../unit/analytics-documents-rate.test.mjs | 141 ++ ...analytics-event-blockprops-models.test.mjs | 176 +++ .../tests/unit/analytics-field-model.test.mjs | 202 +++ .../tests/unit/analytics-fields-rate.test.mjs | 164 ++ .../analytics-file-condition-models.test.mjs | 187 +++ .../unit/analytics-hash-comparator.test.mjs | 124 ++ .../tests/unit/analytics-hash-utils.test.mjs | 85 + .../tests/unit/analytics-merge-utils.test.mjs | 172 +++ .../unit/analytics-module-comparator.test.mjs | 80 + .../unit/analytics-module-model.test.mjs | 97 ++ .../analytics-multi-compare-utils.test.mjs | 97 ++ .../unit/analytics-policy-model.test.mjs | 119 ++ .../unit/analytics-properties-model.test.mjs | 176 +++ .../unit/analytics-properties-rate.test.mjs | 156 ++ .../unit/analytics-property-model.test.mjs | 223 +++ .../unit/analytics-property-type.test.mjs | 16 + .../tests/unit/analytics-rate-map.test.mjs | 132 ++ .../tests/unit/analytics-rate.test.mjs | 97 ++ .../tests/unit/analytics-rates-misc.test.mjs | 98 ++ .../unit/analytics-record-model.test.mjs | 141 ++ .../unit/analytics-report-table.test.mjs | 141 ++ .../unit/analytics-role-group-models.test.mjs | 160 ++ .../unit/analytics-root-object-rates.test.mjs | 120 ++ .../analytics-schema-document-model.test.mjs | 169 ++ .../unit/analytics-schema-model.test.mjs | 175 +++ .../unit/analytics-search-models.test.mjs | 127 ++ ...alytics-search-module-tool-models.test.mjs | 87 ++ .../analytics-search-root-policy.test.mjs | 75 + .../unit/analytics-search-utils.test.mjs | 55 + .../tests/unit/analytics-status.test.mjs | 11 + .../analytics-template-tool-model.test.mjs | 78 + .../tests/unit/analytics-tool-model.test.mjs | 103 ++ ...cs-tool-schema-record-comparators.test.mjs | 94 ++ ...ytics-topic-template-token-models.test.mjs | 137 ++ .../tests/unit/analytics-weight-type.test.mjs | 19 + .../tests/unit/api-helper.test.mjs | 88 ++ .../tests/unit/block-model-weights.test.mjs | 194 +++ .../unit/compare-comparators-csv.test.mjs | 227 +++ .../unit/compare-policy-utils-tree.test.mjs | 128 ++ .../unit/compare-utils-branches.test.mjs | 168 ++ .../tests/unit/date-prototype.test.mjs | 59 + .../unit/helpers-publish-config.test.mjs | 144 ++ .../tests/unit/import-helpers-pure.test.mjs | 138 ++ .../unit/import-helpers-schema-cache.test.mjs | 65 + .../tests/unit/import-mode.test.mjs | 19 + .../tests/unit/ipfs-task-manager.test.mjs | 87 ++ .../tests/unit/merge-utils-multi.test.mjs | 158 ++ .../tests/unit/policy-comments-utils.test.mjs | 92 ++ .../policy-converter-blockconverter.test.mjs | 71 + .../unit/policy-converter-utils.test.mjs | 56 + .../policy-converter-version-blocks.test.mjs | 199 +++ .../tests/unit/policy-data-loader.test.mjs | 139 ++ .../unit/policy-import-helper-pure.test.mjs | 188 +++ .../tests/unit/policy-labels-helpers.test.mjs | 24 + ...policy-service-channels-container.test.mjs | 103 ++ .../unit/policy-statistics-helpers.test.mjs | 110 ++ .../policy-wizard-block-builders.test.mjs | 225 +++ .../tests/unit/policy-wizard-helper.test.mjs | 54 + .../policy-wizard-report-builders.test.mjs | 74 + .../tests/unit/policy-wizard-vp-grid.test.mjs | 51 + .../property-model-system-fields.test.mjs | 178 +++ .../unit/schema-import-helper-pure.test.mjs | 98 ++ .../tests/unit/search-models.test.mjs | 349 +++++ .../tests/unit/search-utils.test.mjs | 80 + .../tests/unit/utils-formula.test.mjs | 62 + .../w2-artifact-file-event-models.test.mjs | 201 +++ .../unit/w2-compare-utils-branches.test.mjs | 246 +++ .../unit/w2-field-model-branches.test.mjs | 283 ++++ .../unit/w2-import-and-pure-helpers.test.mjs | 295 ++++ .../tests/unit/w2-model-weights.test.mjs | 247 +++ .../w2-properties-document-models.test.mjs | 240 +++ .../unit/w2-property-model-branches.test.mjs | 348 +++++ .../tests/unit/w2-rate-behaviors.test.mjs | 354 +++++ .../w2-schema-document-model-extra.test.mjs | 168 ++ .../tests/unit/w2-table-csv.test.mjs | 208 +++ .../unit/w3-policy-import-statics.test.mjs | 95 ++ .../unit/w3-schema-import-statics.test.mjs | 111 ++ .../unit/w4-hash-comparator-deep.test.mjs | 163 ++ .../unit/w4-policy-import-options.test.mjs | 116 ++ .../unit/w4-record-comparator-tables.test.mjs | 180 +++ .../tests/unit/w5-analytics-models.test.mjs | 180 +++ .../unit/w5-comparator-merge-csv.test.mjs | 163 ++ .../w5-schema-module-comparators.test.mjs | 123 ++ interfaces/package.json | 2 +- .../tests/adapter-from-deprecations.test.mjs | 34 + .../deprecation-adapter-entries.test.mjs | 138 ++ .../tests/deprecations-registry.test.mjs | 23 + .../document-generator-formats-extra.test.mjs | 98 ++ ...cument-generator-geojson-sentinel.test.mjs | 103 ++ .../tests/document-generator-subdoc.test.mjs | 96 ++ interfaces/tests/document-generator.test.mjs | 227 +++ .../document-state-status-invariants.test.mjs | 133 ++ interfaces/tests/entity-owner.test.mjs | 122 ++ interfaces/tests/enum-access.test.mjs | 13 + .../tests/enum-application-states.test.mjs | 17 + interfaces/tests/enum-approve-status.test.mjs | 13 + interfaces/tests/enum-artifact.test.mjs | 10 + .../tests/enum-assigned-entity.test.mjs | 14 + interfaces/tests/enum-auth-events.test.mjs | 55 + .../tests/enum-block-error-actions.test.mjs | 17 + interfaces/tests/enum-block-type.test.mjs | 24 + interfaces/tests/enum-config-type.test.mjs | 9 + interfaces/tests/enum-contract-param.test.mjs | 13 + interfaces/tests/enum-contract.test.mjs | 10 + interfaces/tests/enum-did-status.test.mjs | 14 + .../tests/enum-document-category.test.mjs | 14 + .../tests/enum-document-signature.test.mjs | 16 + .../tests/enum-document-status.test.mjs | 11 + interfaces/tests/enum-document-type.test.mjs | 9 + interfaces/tests/enum-entity-status.test.mjs | 13 + .../enum-external-policy-status.test.mjs | 13 + interfaces/tests/enum-geojson.test.mjs | 14 + .../tests/enum-hedera-response-code.test.mjs | 18 + interfaces/tests/enum-icon.test.mjs | 10 + .../tests/enum-integration-data.test.mjs | 18 + interfaces/tests/enum-location.test.mjs | 10 + interfaces/tests/enum-log.test.mjs | 10 + .../enum-message-api-exhaustive.test.mjs | 13 + interfaces/tests/enum-message-api.test.mjs | 107 ++ .../enum-mint-transaction-status.test.mjs | 13 + interfaces/tests/enum-module-status.test.mjs | 14 + .../tests/enum-multi-policy-type.test.mjs | 9 + .../tests/enum-notification-action.test.mjs | 11 + interfaces/tests/enum-notification.test.mjs | 10 + .../tests/enum-order-direction.test.mjs | 10 + .../tests/enum-permission-actions.test.mjs | 10 + .../tests/enum-permission-entities.test.mjs | 10 + .../enum-permissions-categories.test.mjs | 13 + .../tests/enum-permissions-list.test.mjs | 21 + interfaces/tests/enum-pino-log.test.mjs | 16 + interfaces/tests/enum-policy-action.test.mjs | 18 + .../tests/enum-policy-availability.test.mjs | 9 + .../tests/enum-policy-category-type.test.mjs | 11 + ...m-policy-engine-events-exhaustive.test.mjs | 13 + .../tests/enum-policy-engine-events.test.mjs | 69 + interfaces/tests/enum-policy-events.test.mjs | 61 + interfaces/tests/enum-policy-status.test.mjs | 14 + .../tests/enum-policy-test-status.test.mjs | 12 + interfaces/tests/enum-record.test.mjs | 11 + interfaces/tests/enum-root-state.test.mjs | 13 + .../tests/enum-schema-category.test.mjs | 11 + interfaces/tests/enum-schema-entity.test.mjs | 11 + interfaces/tests/enum-schema-status.test.mjs | 10 + .../tests/enum-script-language.test.mjs | 10 + interfaces/tests/enum-signature.test.mjs | 9 + interfaces/tests/enum-tag.test.mjs | 10 + interfaces/tests/enum-token-type.test.mjs | 9 + interfaces/tests/enum-topic-type.test.mjs | 15 + interfaces/tests/enum-unit-system.test.mjs | 9 + interfaces/tests/enum-user-group.test.mjs | 24 + interfaces/tests/enum-user-option.test.mjs | 27 + interfaces/tests/enum-user-type.test.mjs | 19 + interfaces/tests/enum-w3s-events.test.mjs | 12 + .../field-types-dictionary-extra.test.mjs | 155 ++ .../tests/field-types-dictionary.test.mjs | 79 + interfaces/tests/formula-engine.test.mjs | 72 + ...enerate-document-field-types-edge.test.mjs | 542 +++++++ interfaces/tests/generate-uuid-v4.test.mjs | 28 + interfaces/tests/geojson-context.test.mjs | 49 + .../tests/geojson-feature-collection.test.mjs | 28 + interfaces/tests/geojson-feature.test.mjs | 54 + .../geojson-geometry-collection.test.mjs | 41 + interfaces/tests/geojson-geometry.test.mjs | 27 + interfaces/tests/geojson-line-string.test.mjs | 30 + .../tests/geojson-multi-line-string.test.mjs | 29 + interfaces/tests/geojson-multi-point.test.mjs | 30 + .../tests/geojson-multi-polygon.test.mjs | 30 + interfaces/tests/geojson-polygon.test.mjs | 29 + .../tests/geojson-ref-bounding-box.test.mjs | 17 + ...ojson-ref-line-string-coordinates.test.mjs | 15 + ...ojson-ref-linear-ring-coordinates.test.mjs | 15 + .../geojson-ref-point-coordinates.test.mjs | 17 + .../geojson-ref-polygon-coordinates.test.mjs | 17 + interfaces/tests/geojson-sentinel.test.mjs | 53 + .../tests/json-to-schema-conditions.test.mjs | 185 +++ .../json-to-schema-converters-deep.test.mjs | 245 +++ .../json-to-schema-enum-expression.test.mjs | 110 ++ .../tests/json-to-schema-scalars.test.mjs | 123 ++ .../json-to-schema-style-statics.test.mjs | 110 ++ .../json-to-schema-type-statics.test.mjs | 150 ++ interfaces/tests/json-to-schema.test.mjs | 147 ++ .../tests/label-item-steps-extra.test.mjs | 224 +++ .../label-item-validators-suite.test.mjs | 332 ++++ .../tests/label-validator-suite.test.mjs | 130 ++ .../tests/label-validators-nav-tree.test.mjs | 117 ++ ...bel-validators-orchestrator-suite.test.mjs | 151 ++ interfaces/tests/model-helper-edge.test.mjs | 67 + interfaces/tests/model-helper.test.mjs | 49 + .../tests/permissions-getter-matrix.test.mjs | 109 ++ .../permissions-helper-branches.test.mjs | 205 +++ .../tests/policy-editable-field.test.mjs | 109 ++ interfaces/tests/policy-helper.test.mjs | 74 + .../tests/policy-messages-provider.test.mjs | 162 ++ .../tests/policy-messages-types.test.mjs | 32 + interfaces/tests/pure-helpers-suite.test.mjs | 134 ++ interfaces/tests/reachability-edge.test.mjs | 98 ++ .../reachability-project-raw-node.test.mjs | 131 ++ .../remove-object-properties-edge.test.mjs | 66 + .../tests/remove-object-properties.test.mjs | 39 + .../rule-document-validator-edge.test.mjs | 32 + .../rule-document-validators-extra.test.mjs | 187 +++ .../rule-item-validator-deep-suite.test.mjs | 177 +++ .../tests/rule-validator-suite.test.mjs | 482 ++++++ .../schema-engine-model-pipeline.test.mjs | 293 ++++ .../tests/schema-engine-roundtrip.test.mjs | 422 +++++ interfaces/tests/schema-helper-build.test.mjs | 90 ++ .../tests/schema-helper-comment.test.mjs | 126 ++ ...chema-helper-conditions-serialize.test.mjs | 207 +++ .../tests/schema-helper-context-misc.test.mjs | 243 +++ .../tests/schema-helper-context.test.mjs | 24 + interfaces/tests/schema-helper-deep.test.mjs | 135 ++ interfaces/tests/schema-helper-edge.test.mjs | 562 +++++++ interfaces/tests/schema-helper-extra.test.mjs | 134 ++ .../tests/schema-helper-getversion.test.mjs | 68 + interfaces/tests/schema-helper-misc.test.mjs | 63 + .../schema-helper-parse-build-deep.test.mjs | 284 ++++ .../tests/schema-helper-parse-field.test.mjs | 91 ++ interfaces/tests/schema-helper-parse.test.mjs | 84 + .../schema-helper-pure-helpers-deep.test.mjs | 366 +++++ .../schema-helper-set-version-iri.test.mjs | 58 + .../schema-helper-update-fields.test.mjs | 36 + .../schema-helper-update-version.test.mjs | 51 + .../tests/schema-helper-validate.test.mjs | 109 ++ .../schema-helper-version-ops-deep.test.mjs | 237 +++ interfaces/tests/schema-json-edge.test.mjs | 632 ++++++++ .../schema-json-error-context-deep.test.mjs | 111 ++ .../tests/schema-json-error-context.test.mjs | 86 ++ .../tests/schema-json-field-branches.test.mjs | 77 + .../schema-model-constructor-statics.test.mjs | 142 ++ .../tests/schema-model-deep-extra.test.mjs | 279 ++++ interfaces/tests/schema-model-extra.test.mjs | 117 ++ .../tests/schema-model-paths-fields.test.mjs | 130 ++ interfaces/tests/schema-model-suite.test.mjs | 137 ++ .../tests/schema-to-json-extra.test.mjs | 138 ++ .../tests/schema-to-json-getters.test.mjs | 127 ++ interfaces/tests/schema-to-json.test.mjs | 145 ++ .../tests/schema-token-model-edge.test.mjs | 499 ++++++ .../tests/sentinel-hub-context.test.mjs | 32 + .../tests/sort-objects-array-edge.test.mjs | 43 + interfaces/tests/sort-objects-array.test.mjs | 31 + ...atistic-item-validator-deep-suite.test.mjs | 157 ++ .../tests/statistic-score-data.test.mjs | 128 ++ .../tests/statistic-validator-suite.test.mjs | 170 ++ .../tests/timeout-error-formula.test.mjs | 56 + interfaces/tests/token-model-suite.test.mjs | 88 ++ logger-service/package.json | 1 + logger-service/tests/constants.test.mjs | 38 + .../tests/logger-service-guards.test.mjs | 44 + .../tests/logger-service-handlers.test.mjs | 53 + logger-service/tests/mongo-constants.test.mjs | 10 + notification-service/package.json | 1 + notification-service/tests/config.test.mjs | 8 + notification-service/tests/constants.test.mjs | 35 + .../tests/environment.test.mjs | 17 + .../tests/notification-entity.test.mjs | 24 + .../notification-service-guards.test.mjs | 59 + .../notification-service-handlers.test.mjs | 103 ++ .../tests/progress-entity.test.mjs | 69 + .../block-validator-class.test.mjs | 480 ++++++ .../events-misc-validators-branches.test.mjs | 302 ++++ .../module-tool-validator-pure.test.mjs | 250 +++ ...chema-formula-validators-branches.test.mjs | 235 +++ ...token-account-validators-branches.test.mjs | 232 +++ .../token-http-validators-branches.test.mjs | 170 ++ ...ui-structural-validators-branches.test.mjs | 277 ++++ .../unit-tests/blocks/_block-exec-harness.mjs | 120 ++ .../blocks/_block-exec-smoke.test.mjs | 35 + .../blocks/action-block-extra.test.mjs | 101 ++ .../blocks/calculate-container.test.mjs | 124 ++ .../blocks/calculate-family.test.mjs | 162 ++ .../blocks/common-block-extra.test.mjs | 102 ++ .../blocks/create-token-extra.test.mjs | 158 ++ .../blocks/custom-logic-block.test.mjs | 59 + .../blocks/doc-request-validators.test.mjs | 191 +++ .../blocks/exec-document-blocks.test.mjs | 1276 +++++++++++++++ .../blocks/exec-request-send-blocks.test.mjs | 1219 +++++++++++++++ .../unit-tests/blocks/exec-ui-blocks.test.mjs | 1370 +++++++++++++++++ .../blocks/impact-addon-extra.test.mjs | 76 + .../blocks/property-validator-blocks.test.mjs | 147 ++ .../blocks/request-button-multisign.test.mjs | 291 ++++ .../blocks/retirement-serials.test.mjs | 173 +++ .../blocks/runtime-aggregate-block.test.mjs | 159 ++ .../runtime-documents-source-addon.test.mjs | 196 +++ .../blocks/runtime-documents-source.test.mjs | 153 ++ .../runtime-dropdown-block-addon.test.mjs | 113 ++ .../runtime-external-data-block.test.mjs | 133 ++ .../runtime-filters-addon-block.test.mjs | 182 +++ .../runtime-http-request-block.test.mjs | 58 + .../blocks/runtime-pagination-addon.test.mjs | 150 ++ .../blocks/runtime-report-block.test.mjs | 182 +++ .../blocks/runtime-report-item-block.test.mjs | 174 +++ .../blocks/runtime-switch-block.test.mjs | 161 ++ .../blocks/runtime-timer-block.test.mjs | 141 ++ .../blocks/runtime-ui-getdata-blocks.test.mjs | 162 ++ ...d-reassign-revoke-split-multisign.test.mjs | 257 ++++ .../blocks/simple-and-revoke.test.mjs | 151 ++ .../blocks/token-blocks-extra.test.mjs | 292 ++++ .../unit-tests/blocks/token-family.test.mjs | 190 +++ .../helpers/common-variables-store.test.mjs | 43 + .../helpers/common-variables.test.mjs | 55 + .../custom-logic-python-packages.test.mjs | 59 + .../decorators/pure-decorators.test.mjs | 208 +++ .../unit-tests/helpers/math-group.test.mjs | 179 +++ .../helpers/messages-report.test.mjs | 162 ++ .../unit-tests/helpers/table-field.test.mjs | 346 +++++ .../interfaces/interface-enums.test.mjs | 69 + .../synchronization-service.test.mjs | 134 ++ .../associate-dissociate-token.test.mjs | 131 ++ .../policy-engine/backup-collections.test.mjs | 178 +++ .../policy-engine/file-helper.test.mjs | 170 ++ .../policy-engine/policy-action-type.test.mjs | 34 + .../policy-actions-utils.test.mjs | 112 ++ .../policy-engine/policy-user.test.mjs | 231 +++ .../policy-utils-custom-formula.test.mjs | 34 + .../policy-engine/policy-utils-extra.test.mjs | 189 +++ .../policy-utils-pure-helpers.test.mjs | 341 ++++ .../policy-engine/policy-utils-pure.test.mjs | 169 ++ .../policy-utils-schema-tags.test.mjs | 161 ++ .../policy-engine/record-utils.test.mjs | 83 + .../restore-collections.test.mjs | 183 +++ .../synchronization-service.test.mjs | 114 ++ .../policy-engine/vc-collection.test.mjs | 113 ++ .../vc-documents-utils-pure.test.mjs | 159 ++ .../record/record-utils-classes.test.mjs | 310 ++++ .../unit-tests/record/record-utils.test.mjs | 208 +++ .../tests/unit-tests/record/utils.test.mjs | 282 ++++ queue-service/tests/config.test.mjs | 8 + queue-service/tests/environment.test.mjs | 14 + queue-service/tests/task-entity.test.mjs | 52 + topic-listener-service/package.json | 3 +- .../tests/constants.test.mjs | 24 + .../tests/environment.test.mjs | 19 + topic-listener-service/tests/message.test.mjs | 132 ++ .../tests/module-imports.test.mjs | 13 + .../tests/mongo-constants.test.mjs | 10 + .../tests/mongo-initialization.test.mjs | 64 + .../tests/topic-listener-entity.test.mjs | 23 + worker-service/tests/axios-constants.test.mjs | 8 + .../tests/fireblocks-helper-dispatch.test.mjs | 203 +++ .../tests/hedera-sdk-helper-extra.test.mjs | 147 ++ .../tests/hedera-sdk-helper.test.mjs | 131 ++ .../tests/hedera-utils-dispatch.test.mjs | 151 ++ .../tests/hedera-utils-extra.test.mjs | 137 ++ worker-service/tests/hedera-utils.test.mjs | 71 + worker-service/tests/mongo-constants.test.mjs | 17 + .../tests/mongo-initialization-init.test.mjs | 66 + .../tests/mongo-initialization.test.mjs | 39 + .../tests/transaction-logger-extra.test.mjs | 123 ++ .../transaction-logger-string-size.test.mjs | 95 ++ .../tests/transaction-logger.test.mjs | 66 + .../transaction-metadata-exhaustive.test.mjs | 264 ++++ 699 files changed, 91371 insertions(+), 3 deletions(-) create mode 100644 ai-service/tests/api-response.test.mjs create mode 100644 ai-service/tests/config.test.mjs create mode 100644 ai-service/tests/files-manager-helper.test.mjs create mode 100644 ai-service/tests/files-manager-io.test.mjs create mode 100644 ai-service/tests/files-manager.test.mjs create mode 100644 ai-service/tests/general-helper.test.mjs create mode 100644 ai-service/tests/mongo-constants.test.mjs create mode 100644 ai-service/tests/openai-connect.test.mjs create mode 100644 ai-service/tests/openai-helper-chain.test.mjs create mode 100644 ai-service/tests/suggestions.test.mjs create mode 100644 analytics-service/tests/analytics-services-coverage.test.mjs create mode 100644 analytics-service/tests/analytics-services-smoke.test.mjs create mode 100644 analytics-service/tests/analytics-utils.test.mjs create mode 100644 analytics-service/tests/dto-shape.test.mjs create mode 100644 analytics-service/tests/entity-decorators.test.mjs create mode 100644 analytics-service/tests/entity-init-hooks.test.mjs create mode 100644 analytics-service/tests/entity-init-state.test.mjs create mode 100644 analytics-service/tests/interfaces-enums.test.mjs create mode 100644 analytics-service/tests/migration.test.mjs create mode 100644 analytics-service/tests/report-data-dto.test.mjs create mode 100644 analytics-service/tests/swagger-config.test.mjs create mode 100644 analytics-service/tests/table.test.mjs create mode 100644 analytics-service/tests/tasks.test.mjs create mode 100644 api-gateway/tests/auth/auth-user-decorator.test.mjs create mode 100644 api-gateway/tests/auth/roles-guard.test.mjs create mode 100644 api-gateway/tests/authorization-helper.test.js create mode 100644 api-gateway/tests/cache-utils.test.js create mode 100644 api-gateway/tests/dto/_dto-helper.mjs create mode 100644 api-gateway/tests/dto/accounts-response-dto.test.mjs create mode 100644 api-gateway/tests/dto/analytics-compare-dto.test.mjs create mode 100644 api-gateway/tests/dto/analytics-dto.test.mjs create mode 100644 api-gateway/tests/dto/analytics-search-blocks-dto.test.mjs create mode 100644 api-gateway/tests/dto/demo-dto.test.mjs create mode 100644 api-gateway/tests/dto/errors-dto.test.mjs create mode 100644 api-gateway/tests/dto/external-policies-dto.test.mjs create mode 100644 api-gateway/tests/dto/formulas-dto.test.mjs create mode 100644 api-gateway/tests/dto/mock-dto.test.mjs create mode 100644 api-gateway/tests/dto/policies-core-dto.test.mjs create mode 100644 api-gateway/tests/dto/policies-misc-dto.test.mjs create mode 100644 api-gateway/tests/dto/policy-comments-dto.test.mjs create mode 100644 api-gateway/tests/dto/policy-labels-dto.test.mjs create mode 100644 api-gateway/tests/dto/policy-statistics-dto.test.mjs create mode 100644 api-gateway/tests/dto/profiles-dto.test.mjs create mode 100644 api-gateway/tests/dto/record-dto.test.mjs create mode 100644 api-gateway/tests/dto/relayer-policy-parameters-dto.test.mjs create mode 100644 api-gateway/tests/dto/schema-rules-tag-dto.test.mjs create mode 100644 api-gateway/tests/dto/schemas-dto.test.mjs create mode 100644 api-gateway/tests/dto/suggestions-dto.test.mjs create mode 100644 api-gateway/tests/dto/token-dto.test.mjs create mode 100644 api-gateway/tests/dto/worker-permissions-dto.test.mjs create mode 100644 api-gateway/tests/entity-owner.test.mjs create mode 100644 api-gateway/tests/filename-sanitizer.test.js create mode 100644 api-gateway/tests/helpers/artifact-constants.test.mjs create mode 100644 api-gateway/tests/helpers/cache-provider.test.mjs create mode 100644 api-gateway/tests/helpers/decorators/file-param-decorator.test.mjs create mode 100644 api-gateway/tests/helpers/decorators/match-validator-decorator.test.mjs create mode 100644 api-gateway/tests/helpers/decorators/singleton-decorator-extra.test.mjs create mode 100644 api-gateway/tests/helpers/decorators/user-param-decorator.test.mjs create mode 100644 api-gateway/tests/helpers/entity-owner.test.mjs create mode 100644 api-gateway/tests/helpers/find-replace-entities.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-contracts.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-documents.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-modules-tools.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-policies.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-schemas-extra.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-schemas.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-tags-themes-record.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-tokens.test.mjs create mode 100644 api-gateway/tests/helpers/guardians-wipe-retire.test.mjs create mode 100644 api-gateway/tests/helpers/interceptors/multipart-interceptor.test.mjs create mode 100644 api-gateway/tests/helpers/interceptors/multipart-types.test.mjs create mode 100644 api-gateway/tests/helpers/logger-providers.test.mjs create mode 100644 api-gateway/tests/helpers/match-validator.test.mjs create mode 100644 api-gateway/tests/helpers/meeco.test.mjs create mode 100644 api-gateway/tests/helpers/module-constants.test.mjs create mode 100644 api-gateway/tests/helpers/mongo-constants.test.mjs create mode 100644 api-gateway/tests/helpers/parse-integer.test.mjs create mode 100644 api-gateway/tests/helpers/performance-interceptor.test.mjs create mode 100644 api-gateway/tests/helpers/policy-constants.test.mjs create mode 100644 api-gateway/tests/helpers/policy-engine-blocks.test.mjs create mode 100644 api-gateway/tests/helpers/providers-index.test.mjs create mode 100644 api-gateway/tests/helpers/routes-constants.test.mjs create mode 100644 api-gateway/tests/helpers/schema-constants.test.mjs create mode 100644 api-gateway/tests/helpers/schema-utils-extra.test.mjs create mode 100644 api-gateway/tests/helpers/schema-utils.test.mjs create mode 100644 api-gateway/tests/helpers/singleton-decorator.test.mjs create mode 100644 api-gateway/tests/helpers/stream-to-buffer.test.mjs create mode 100644 api-gateway/tests/helpers/task-manager.test.mjs create mode 100644 api-gateway/tests/helpers/token-constants.test.mjs create mode 100644 api-gateway/tests/helpers/tool-constants.test.mjs create mode 100644 api-gateway/tests/helpers/utils-parent-internal.test.mjs create mode 100644 api-gateway/tests/helpers/utils.test.mjs create mode 100644 api-gateway/tests/interceptor-cache-utils.test.js create mode 100644 api-gateway/tests/interceptor-multipart.test.js create mode 100644 api-gateway/tests/interceptors/multipart-performance-interceptor.test.mjs create mode 100644 api-gateway/tests/match-validator.test.js create mode 100644 api-gateway/tests/middlewares/accounts-schemas.test.mjs create mode 100644 api-gateway/tests/middlewares/csv-examples.test.mjs create mode 100644 api-gateway/tests/middlewares/examples.test.mjs create mode 100644 api-gateway/tests/middlewares/middlewares-index-barrel.test.mjs create mode 100644 api-gateway/tests/middlewares/page-header.test.mjs create mode 100644 api-gateway/tests/middlewares/schemas-accounts.test.mjs create mode 100644 api-gateway/tests/middlewares/schemas-notifications.test.mjs create mode 100644 api-gateway/tests/middlewares/schemas-settings.test.mjs create mode 100644 api-gateway/tests/middlewares/string-or-number.test.mjs create mode 100644 api-gateway/tests/middlewares/string-or-object.test.mjs create mode 100644 api-gateway/tests/middlewares/validate-runtime.test.mjs create mode 100644 api-gateway/tests/middlewares/validation.test.mjs create mode 100644 api-gateway/tests/roles-and-location-guard-extra.test.mjs create mode 100644 api-gateway/tests/roles-guard-matrix.test.mjs create mode 100644 api-gateway/tests/roles-guard.test.js create mode 100644 api-gateway/tests/service/_controller-harness.mjs create mode 100644 api-gateway/tests/service/analytics-controller.test.mjs create mode 100644 api-gateway/tests/service/contract.test.mjs create mode 100644 api-gateway/tests/service/credentials-ipfs-suggestions-controllers.test.mjs create mode 100644 api-gateway/tests/service/external-policy-controller.test.mjs create mode 100644 api-gateway/tests/service/formulas.test.mjs create mode 100644 api-gateway/tests/service/module-tool-tags-themes-controllers.test.mjs create mode 100644 api-gateway/tests/service/module.test.mjs create mode 100644 api-gateway/tests/service/permissions-websockets-controllers.test.mjs create mode 100644 api-gateway/tests/service/policy-comments.test.mjs create mode 100644 api-gateway/tests/service/policy-labels.test.mjs create mode 100644 api-gateway/tests/service/policy-repository-trustchains-wizard-controllers.test.mjs create mode 100644 api-gateway/tests/service/policy-statistics.test.mjs create mode 100644 api-gateway/tests/service/relayer-external-controllers.test.mjs create mode 100644 api-gateway/tests/service/tags-controller.test.mjs create mode 100644 api-gateway/tests/service/tool.test.mjs create mode 100644 api-gateway/tests/singleton.test.js create mode 100644 api-gateway/tests/stream-to-buffer.test.js create mode 100644 api-gateway/tests/utils.test.js create mode 100644 api-gateway/tests/validate-middleware.test.mjs create mode 100644 api-gateway/tests/validation-middleware.test.js create mode 100644 api-gateway/tests/validation/dto-analytics.test.mjs create mode 100644 api-gateway/tests/validation/dto-misc.test.mjs create mode 100644 api-gateway/tests/validation/dto-mock-external-formulas.test.mjs create mode 100644 api-gateway/tests/validation/dto-nested-enum-gaps.test.mjs create mode 100644 api-gateway/tests/validation/dto-policies.test.mjs create mode 100644 api-gateway/tests/validation/dto-schemas-profiles-record.test.mjs create mode 100644 api-gateway/tests/validation/old-descriptions.test.mjs create mode 100644 api-gateway/tests/validation/validation-pipes.test.mjs create mode 100644 auth-service/tests/_handler-harness.mjs create mode 100644 auth-service/tests/account-service-handlers.test.mjs create mode 100644 auth-service/tests/bitstring.test.mjs create mode 100644 auth-service/tests/credentials-assertions.test.mjs create mode 100644 auth-service/tests/credentials-validation-assertions.test.mjs create mode 100644 auth-service/tests/credentials-validation-bitstring.test.mjs create mode 100644 auth-service/tests/credentials-validation-extra.test.mjs create mode 100644 auth-service/tests/cryppo.test.mjs create mode 100644 auth-service/tests/entity-init-hooks.test.mjs create mode 100644 auth-service/tests/import-keys-from-database.test.mjs create mode 100644 auth-service/tests/meeco-api.test.mjs create mode 100644 auth-service/tests/meeco-service.test.mjs create mode 100644 auth-service/tests/mongo-constants.test.mjs create mode 100644 auth-service/tests/password-constants.test.mjs create mode 100644 auth-service/tests/relayer-accounts-handlers.test.mjs create mode 100644 auth-service/tests/role-service-handlers.test.mjs create mode 100644 auth-service/tests/user-password-branches.test.mjs create mode 100644 auth-service/tests/user-password-validate.test.mjs create mode 100644 auth-service/tests/user-password.test.mjs create mode 100644 auth-service/tests/validate-config-jwt-tokens.test.mjs create mode 100644 common/tests/unit-tests/base-integration/base-integration.test.mjs create mode 100644 common/tests/unit-tests/common-variables/common-variables.test.mjs create mode 100644 common/tests/unit-tests/console-transport/console-transport-edge.test.mjs create mode 100644 common/tests/unit-tests/console-transport/console-transport.test.mjs create mode 100644 common/tests/unit-tests/context-helper/context-helper.test.mjs create mode 100644 common/tests/unit-tests/context-helper/set-context.test.mjs create mode 100644 common/tests/unit-tests/custom-csv-parser/custom-csv-parser-edge.test.mjs create mode 100644 common/tests/unit-tests/custom-csv-parser/custom-csv-parser.test.mjs create mode 100644 common/tests/unit-tests/database-server-static/database-server-static.test.mjs create mode 100644 common/tests/unit-tests/db-helper-constants/db-helper-constants.test.mjs create mode 100644 common/tests/unit-tests/db-helper/aggregation-filters.test.mjs create mode 100644 common/tests/unit-tests/db-helper/db-helper-gridfs.test.mjs create mode 100644 common/tests/unit-tests/db-helper/document-aggregation-filters.test.mjs create mode 100644 common/tests/unit-tests/db-naming-strategy-edge.test.mjs create mode 100644 common/tests/unit-tests/db-naming-strategy.test.mjs create mode 100644 common/tests/unit-tests/do-nothing.test.mjs create mode 100644 common/tests/unit-tests/empty-notifier/empty-notifier.test.mjs create mode 100644 common/tests/unit-tests/encrypt-utils/encrypt-utils.test.mjs create mode 100644 common/tests/unit-tests/encrypt-vc-helper/encrypt-vc-helper.test.mjs create mode 100644 common/tests/unit-tests/entity/document-draft-hooks.test.mjs create mode 100644 common/tests/unit-tests/entity/entity-file-hooks.test.mjs create mode 100644 common/tests/unit-tests/enum-message-action.test.mjs create mode 100644 common/tests/unit-tests/enum-message-type.test.mjs create mode 100644 common/tests/unit-tests/environment/environment.test.mjs create mode 100644 common/tests/unit-tests/fix-connection-string/fix-connection-string-edge.test.mjs create mode 100644 common/tests/unit-tests/fix-connection-string/fix-connection-string.test.mjs create mode 100644 common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block-edge.test.mjs create mode 100644 common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block.test.mjs create mode 100644 common/tests/unit-tests/generate-tls-options.test.mjs create mode 100644 common/tests/unit-tests/generate-tls-options/generate-tls-options.test.mjs create mode 100644 common/tests/unit-tests/hashing/hashing-encrypt-edge.test.mjs create mode 100644 common/tests/unit-tests/hashing/hashing.test.mjs create mode 100644 common/tests/unit-tests/hedera-environment/environment.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/contract-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/did-url.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/comment-discussion-messages.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/did-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/label-document-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/label-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/message-base.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/message-classes-coverage.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/message-serializers.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/message-server-parsers.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/policy-diff-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/policy-record-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/schema-package-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/statistic-assessment-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/message/statistic-message-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/policy-action-message-full.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/policy-action-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/policy-artifacts-messages.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/registration-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/role-vc-messages.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/tag-sync-messages.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/token-message.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/tool-module-formula-messages.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document-coverage.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/common-did.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/did-document-fixtures.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/did-extras-coverage.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/did-types-properties.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/document-context.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/document-service.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/hedera-did.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/hedera-ed25519-generate.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/hedera-methods.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/did/verification-method.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vc-document-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vc-document-fixtures.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vc-subject-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vc-vp-subject-branches.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vcjs-coverage.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vp-document-extra.test.mjs create mode 100644 common/tests/unit-tests/hedera-modules/vcjs/vp-document-fixtures.test.mjs create mode 100644 common/tests/unit-tests/hedera-utils/timestamp-utils.test.mjs create mode 100644 common/tests/unit-tests/helpers/settings-container.test.mjs create mode 100644 common/tests/unit-tests/import-export/formula-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/import-export-utils.test.mjs create mode 100644 common/tests/unit-tests/import-export/module-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/policy-label-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/policy-statistic-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/record-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/schema-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/schema-rule-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/theme-import-export.test.mjs create mode 100644 common/tests/unit-tests/import-export/tool-import-export.test.mjs create mode 100644 common/tests/unit-tests/insert-variables/insert-variables-edge.test.mjs create mode 100644 common/tests/unit-tests/insert-variables/insert-variables.test.mjs create mode 100644 common/tests/unit-tests/integrations/integration-services.test.mjs create mode 100644 common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard-contract.test.mjs create mode 100644 common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard.test.mjs create mode 100644 common/tests/unit-tests/memo-mappings/memo-mappings.test.mjs create mode 100644 common/tests/unit-tests/message-action-type/message-action-type.test.mjs create mode 100644 common/tests/unit-tests/misc/base-entity.test.mjs create mode 100644 common/tests/unit-tests/misc/console-transport.test.mjs create mode 100644 common/tests/unit-tests/misc/context-helper.test.mjs create mode 100644 common/tests/unit-tests/misc/db-naming-strategy.test.mjs create mode 100644 common/tests/unit-tests/misc/dictionary.test.mjs create mode 100644 common/tests/unit-tests/misc/do-nothing.test.mjs create mode 100644 common/tests/unit-tests/misc/document-entities.test.mjs create mode 100644 common/tests/unit-tests/misc/document-state-lifecycle.test.mjs create mode 100644 common/tests/unit-tests/misc/dry-run-entity.test.mjs create mode 100644 common/tests/unit-tests/misc/dynamic-role.test.mjs create mode 100644 common/tests/unit-tests/misc/empty-notifier.test.mjs create mode 100644 common/tests/unit-tests/misc/entity-defaults-2.test.mjs create mode 100644 common/tests/unit-tests/misc/entity-defaults-3.test.mjs create mode 100644 common/tests/unit-tests/misc/entity-defaults.test.mjs create mode 100644 common/tests/unit-tests/misc/expression.test.mjs create mode 100644 common/tests/unit-tests/misc/hash-entities.test.mjs create mode 100644 common/tests/unit-tests/misc/integration-block-helper.test.mjs create mode 100644 common/tests/unit-tests/misc/issuer.test.mjs create mode 100644 common/tests/unit-tests/misc/memo-map.test.mjs create mode 100644 common/tests/unit-tests/misc/message-enums.test.mjs create mode 100644 common/tests/unit-tests/misc/misc-helpers.test.mjs create mode 100644 common/tests/unit-tests/misc/notification-step.test.mjs create mode 100644 common/tests/unit-tests/misc/policy-document-entities.test.mjs create mode 100644 common/tests/unit-tests/misc/policy-property.test.mjs create mode 100644 common/tests/unit-tests/misc/restore-entities.test.mjs create mode 100644 common/tests/unit-tests/misc/restore-entity.test.mjs create mode 100644 common/tests/unit-tests/misc/serialization.test.mjs create mode 100644 common/tests/unit-tests/misc/sheet-name.test.mjs create mode 100644 common/tests/unit-tests/misc/simple-entities.test.mjs create mode 100644 common/tests/unit-tests/misc/singleton-decorator.test.mjs create mode 100644 common/tests/unit-tests/misc/tag-indexer.test.mjs create mode 100644 common/tests/unit-tests/misc/value-converters.test.mjs create mode 100644 common/tests/unit-tests/misc/vc-subject.test.mjs create mode 100644 common/tests/unit-tests/mongo-transport/mongo-transport.test.mjs create mode 100644 common/tests/unit-tests/mq-serialization/serialization.test.mjs create mode 100644 common/tests/unit-tests/mq/codec-serialization-edge.test.mjs create mode 100644 common/tests/unit-tests/mq/external-channel.test.mjs create mode 100644 common/tests/unit-tests/mq/large-payload-container.test.mjs create mode 100644 common/tests/unit-tests/mq/message-broker-channel-loops.test.mjs create mode 100644 common/tests/unit-tests/mq/zip-codec-routing.test.mjs create mode 100644 common/tests/unit-tests/mq/zip-codec.test.mjs create mode 100644 common/tests/unit-tests/notification-step/notification-step.test.mjs create mode 100644 common/tests/unit-tests/notification/notification-events.test.mjs create mode 100644 common/tests/unit-tests/pino-file-transport/pino-file-transport.test.mjs create mode 100644 common/tests/unit-tests/pino-logger/pino-logger-constants.test.mjs create mode 100644 common/tests/unit-tests/pino-logger/pino-logger-mapping.test.mjs create mode 100644 common/tests/unit-tests/policy-category/policy-category.test.mjs create mode 100644 common/tests/unit-tests/policy-property.test.mjs create mode 100644 common/tests/unit-tests/policy-property/policy-property.test.mjs create mode 100644 common/tests/unit-tests/run-async/run-async.test.mjs create mode 100644 common/tests/unit-tests/run-function-async/run-function-async.test.mjs create mode 100644 common/tests/unit-tests/schema-converter/schema-converter-edge.test.mjs create mode 100644 common/tests/unit-tests/schema-converter/schema-converter.test.mjs create mode 100644 common/tests/unit-tests/schemas-to-context/schemas-context-policy-edge.test.mjs create mode 100644 common/tests/unit-tests/schemas-to-context/schemas-to-context.test.mjs create mode 100644 common/tests/unit-tests/secret-manager-type/hcp-vault-configs.test.mjs create mode 100644 common/tests/unit-tests/seq-transport/seq-transport.test.mjs create mode 100644 common/tests/unit-tests/service-requests-base/service-requests-base.test.mjs create mode 100644 common/tests/unit-tests/singleton-decorator/singleton-decorator.test.mjs create mode 100644 common/tests/unit-tests/singleton.test.mjs create mode 100644 common/tests/unit-tests/table-file-ids.test.mjs create mode 100644 common/tests/unit-tests/table-file-ids/table-file-ids-edge.test.mjs create mode 100644 common/tests/unit-tests/table-file-ids/table-file-ids.test.mjs create mode 100644 common/tests/unit-tests/timestamp-utils/timestamp-utils.test.mjs create mode 100644 common/tests/unit-tests/utils/common-utils-edge.test.mjs create mode 100644 common/tests/unit-tests/validate-configuration/validate-configuration.test.mjs create mode 100644 common/tests/unit-tests/xlsx-dictionary/xlsx-dictionary.test.mjs create mode 100644 common/tests/unit-tests/xlsx-expression/xlsx-expression.test.mjs create mode 100644 common/tests/unit-tests/xlsx-expressions/xlsx-expressions.test.mjs create mode 100644 common/tests/unit-tests/xlsx-generate-blocks/generate-blocks.test.mjs create mode 100644 common/tests/unit-tests/xlsx-result/xlsx-result.test.mjs create mode 100644 common/tests/unit-tests/xlsx-schema-condition/xlsx-schema-condition.test.mjs create mode 100644 common/tests/unit-tests/xlsx-schema/xlsx-schema.test.mjs create mode 100644 common/tests/unit-tests/xlsx-sheet-name/xlsx-sheet-name.test.mjs create mode 100644 common/tests/unit-tests/xlsx-table-header/xlsx-table-header.test.mjs create mode 100644 common/tests/unit-tests/xlsx-table/xlsx-table.test.mjs create mode 100644 common/tests/unit-tests/xlsx-tag-indexer/xlsx-tag-indexer.test.mjs create mode 100644 common/tests/unit-tests/xlsx-value-converters/xlsx-converters-edge.test.mjs create mode 100644 common/tests/unit-tests/xlsx-value-converters/xlsx-value-converters.test.mjs create mode 100644 common/tests/unit-tests/xlsx-workbook/xlsx-workbook-extra.test.mjs create mode 100644 common/tests/unit-tests/xlsx-workbook/xlsx-workbook.test.mjs create mode 100644 guardian-service/tests/_handler-harness.mjs create mode 100644 guardian-service/tests/unit/analytics-artifact-token-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-block-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-block-tool-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-blocks-record-rates.test.mjs create mode 100644 guardian-service/tests/unit/analytics-comparator-csv-outputs.test.mjs create mode 100644 guardian-service/tests/unit/analytics-compare-options.test.mjs create mode 100644 guardian-service/tests/unit/analytics-compare-policy-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-csv-and-compare-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-doc-policy-comparators.test.mjs create mode 100644 guardian-service/tests/unit/analytics-document-fields-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-document-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-documents-rate.test.mjs create mode 100644 guardian-service/tests/unit/analytics-event-blockprops-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-field-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-fields-rate.test.mjs create mode 100644 guardian-service/tests/unit/analytics-file-condition-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-hash-comparator.test.mjs create mode 100644 guardian-service/tests/unit/analytics-hash-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-merge-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-module-comparator.test.mjs create mode 100644 guardian-service/tests/unit/analytics-module-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-multi-compare-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-policy-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-properties-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-properties-rate.test.mjs create mode 100644 guardian-service/tests/unit/analytics-property-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-property-type.test.mjs create mode 100644 guardian-service/tests/unit/analytics-rate-map.test.mjs create mode 100644 guardian-service/tests/unit/analytics-rate.test.mjs create mode 100644 guardian-service/tests/unit/analytics-rates-misc.test.mjs create mode 100644 guardian-service/tests/unit/analytics-record-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-report-table.test.mjs create mode 100644 guardian-service/tests/unit/analytics-role-group-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-root-object-rates.test.mjs create mode 100644 guardian-service/tests/unit/analytics-schema-document-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-schema-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-search-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-search-module-tool-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-search-root-policy.test.mjs create mode 100644 guardian-service/tests/unit/analytics-search-utils.test.mjs create mode 100644 guardian-service/tests/unit/analytics-status.test.mjs create mode 100644 guardian-service/tests/unit/analytics-template-tool-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-tool-model.test.mjs create mode 100644 guardian-service/tests/unit/analytics-tool-schema-record-comparators.test.mjs create mode 100644 guardian-service/tests/unit/analytics-topic-template-token-models.test.mjs create mode 100644 guardian-service/tests/unit/analytics-weight-type.test.mjs create mode 100644 guardian-service/tests/unit/api-helper.test.mjs create mode 100644 guardian-service/tests/unit/block-model-weights.test.mjs create mode 100644 guardian-service/tests/unit/compare-comparators-csv.test.mjs create mode 100644 guardian-service/tests/unit/compare-policy-utils-tree.test.mjs create mode 100644 guardian-service/tests/unit/compare-utils-branches.test.mjs create mode 100644 guardian-service/tests/unit/date-prototype.test.mjs create mode 100644 guardian-service/tests/unit/helpers-publish-config.test.mjs create mode 100644 guardian-service/tests/unit/import-helpers-pure.test.mjs create mode 100644 guardian-service/tests/unit/import-helpers-schema-cache.test.mjs create mode 100644 guardian-service/tests/unit/import-mode.test.mjs create mode 100644 guardian-service/tests/unit/ipfs-task-manager.test.mjs create mode 100644 guardian-service/tests/unit/merge-utils-multi.test.mjs create mode 100644 guardian-service/tests/unit/policy-comments-utils.test.mjs create mode 100644 guardian-service/tests/unit/policy-converter-blockconverter.test.mjs create mode 100644 guardian-service/tests/unit/policy-converter-utils.test.mjs create mode 100644 guardian-service/tests/unit/policy-converter-version-blocks.test.mjs create mode 100644 guardian-service/tests/unit/policy-data-loader.test.mjs create mode 100644 guardian-service/tests/unit/policy-import-helper-pure.test.mjs create mode 100644 guardian-service/tests/unit/policy-labels-helpers.test.mjs create mode 100644 guardian-service/tests/unit/policy-service-channels-container.test.mjs create mode 100644 guardian-service/tests/unit/policy-statistics-helpers.test.mjs create mode 100644 guardian-service/tests/unit/policy-wizard-block-builders.test.mjs create mode 100644 guardian-service/tests/unit/policy-wizard-helper.test.mjs create mode 100644 guardian-service/tests/unit/policy-wizard-report-builders.test.mjs create mode 100644 guardian-service/tests/unit/policy-wizard-vp-grid.test.mjs create mode 100644 guardian-service/tests/unit/property-model-system-fields.test.mjs create mode 100644 guardian-service/tests/unit/schema-import-helper-pure.test.mjs create mode 100644 guardian-service/tests/unit/search-models.test.mjs create mode 100644 guardian-service/tests/unit/search-utils.test.mjs create mode 100644 guardian-service/tests/unit/utils-formula.test.mjs create mode 100644 guardian-service/tests/unit/w2-artifact-file-event-models.test.mjs create mode 100644 guardian-service/tests/unit/w2-compare-utils-branches.test.mjs create mode 100644 guardian-service/tests/unit/w2-field-model-branches.test.mjs create mode 100644 guardian-service/tests/unit/w2-import-and-pure-helpers.test.mjs create mode 100644 guardian-service/tests/unit/w2-model-weights.test.mjs create mode 100644 guardian-service/tests/unit/w2-properties-document-models.test.mjs create mode 100644 guardian-service/tests/unit/w2-property-model-branches.test.mjs create mode 100644 guardian-service/tests/unit/w2-rate-behaviors.test.mjs create mode 100644 guardian-service/tests/unit/w2-schema-document-model-extra.test.mjs create mode 100644 guardian-service/tests/unit/w2-table-csv.test.mjs create mode 100644 guardian-service/tests/unit/w3-policy-import-statics.test.mjs create mode 100644 guardian-service/tests/unit/w3-schema-import-statics.test.mjs create mode 100644 guardian-service/tests/unit/w4-hash-comparator-deep.test.mjs create mode 100644 guardian-service/tests/unit/w4-policy-import-options.test.mjs create mode 100644 guardian-service/tests/unit/w4-record-comparator-tables.test.mjs create mode 100644 guardian-service/tests/unit/w5-analytics-models.test.mjs create mode 100644 guardian-service/tests/unit/w5-comparator-merge-csv.test.mjs create mode 100644 guardian-service/tests/unit/w5-schema-module-comparators.test.mjs create mode 100644 interfaces/tests/adapter-from-deprecations.test.mjs create mode 100644 interfaces/tests/deprecation-adapter-entries.test.mjs create mode 100644 interfaces/tests/deprecations-registry.test.mjs create mode 100644 interfaces/tests/document-generator-formats-extra.test.mjs create mode 100644 interfaces/tests/document-generator-geojson-sentinel.test.mjs create mode 100644 interfaces/tests/document-generator-subdoc.test.mjs create mode 100644 interfaces/tests/document-generator.test.mjs create mode 100644 interfaces/tests/document-state-status-invariants.test.mjs create mode 100644 interfaces/tests/entity-owner.test.mjs create mode 100644 interfaces/tests/enum-access.test.mjs create mode 100644 interfaces/tests/enum-application-states.test.mjs create mode 100644 interfaces/tests/enum-approve-status.test.mjs create mode 100644 interfaces/tests/enum-artifact.test.mjs create mode 100644 interfaces/tests/enum-assigned-entity.test.mjs create mode 100644 interfaces/tests/enum-auth-events.test.mjs create mode 100644 interfaces/tests/enum-block-error-actions.test.mjs create mode 100644 interfaces/tests/enum-block-type.test.mjs create mode 100644 interfaces/tests/enum-config-type.test.mjs create mode 100644 interfaces/tests/enum-contract-param.test.mjs create mode 100644 interfaces/tests/enum-contract.test.mjs create mode 100644 interfaces/tests/enum-did-status.test.mjs create mode 100644 interfaces/tests/enum-document-category.test.mjs create mode 100644 interfaces/tests/enum-document-signature.test.mjs create mode 100644 interfaces/tests/enum-document-status.test.mjs create mode 100644 interfaces/tests/enum-document-type.test.mjs create mode 100644 interfaces/tests/enum-entity-status.test.mjs create mode 100644 interfaces/tests/enum-external-policy-status.test.mjs create mode 100644 interfaces/tests/enum-geojson.test.mjs create mode 100644 interfaces/tests/enum-hedera-response-code.test.mjs create mode 100644 interfaces/tests/enum-icon.test.mjs create mode 100644 interfaces/tests/enum-integration-data.test.mjs create mode 100644 interfaces/tests/enum-location.test.mjs create mode 100644 interfaces/tests/enum-log.test.mjs create mode 100644 interfaces/tests/enum-message-api-exhaustive.test.mjs create mode 100644 interfaces/tests/enum-message-api.test.mjs create mode 100644 interfaces/tests/enum-mint-transaction-status.test.mjs create mode 100644 interfaces/tests/enum-module-status.test.mjs create mode 100644 interfaces/tests/enum-multi-policy-type.test.mjs create mode 100644 interfaces/tests/enum-notification-action.test.mjs create mode 100644 interfaces/tests/enum-notification.test.mjs create mode 100644 interfaces/tests/enum-order-direction.test.mjs create mode 100644 interfaces/tests/enum-permission-actions.test.mjs create mode 100644 interfaces/tests/enum-permission-entities.test.mjs create mode 100644 interfaces/tests/enum-permissions-categories.test.mjs create mode 100644 interfaces/tests/enum-permissions-list.test.mjs create mode 100644 interfaces/tests/enum-pino-log.test.mjs create mode 100644 interfaces/tests/enum-policy-action.test.mjs create mode 100644 interfaces/tests/enum-policy-availability.test.mjs create mode 100644 interfaces/tests/enum-policy-category-type.test.mjs create mode 100644 interfaces/tests/enum-policy-engine-events-exhaustive.test.mjs create mode 100644 interfaces/tests/enum-policy-engine-events.test.mjs create mode 100644 interfaces/tests/enum-policy-events.test.mjs create mode 100644 interfaces/tests/enum-policy-status.test.mjs create mode 100644 interfaces/tests/enum-policy-test-status.test.mjs create mode 100644 interfaces/tests/enum-record.test.mjs create mode 100644 interfaces/tests/enum-root-state.test.mjs create mode 100644 interfaces/tests/enum-schema-category.test.mjs create mode 100644 interfaces/tests/enum-schema-entity.test.mjs create mode 100644 interfaces/tests/enum-schema-status.test.mjs create mode 100644 interfaces/tests/enum-script-language.test.mjs create mode 100644 interfaces/tests/enum-signature.test.mjs create mode 100644 interfaces/tests/enum-tag.test.mjs create mode 100644 interfaces/tests/enum-token-type.test.mjs create mode 100644 interfaces/tests/enum-topic-type.test.mjs create mode 100644 interfaces/tests/enum-unit-system.test.mjs create mode 100644 interfaces/tests/enum-user-group.test.mjs create mode 100644 interfaces/tests/enum-user-option.test.mjs create mode 100644 interfaces/tests/enum-user-type.test.mjs create mode 100644 interfaces/tests/enum-w3s-events.test.mjs create mode 100644 interfaces/tests/field-types-dictionary-extra.test.mjs create mode 100644 interfaces/tests/field-types-dictionary.test.mjs create mode 100644 interfaces/tests/formula-engine.test.mjs create mode 100644 interfaces/tests/generate-document-field-types-edge.test.mjs create mode 100644 interfaces/tests/generate-uuid-v4.test.mjs create mode 100644 interfaces/tests/geojson-context.test.mjs create mode 100644 interfaces/tests/geojson-feature-collection.test.mjs create mode 100644 interfaces/tests/geojson-feature.test.mjs create mode 100644 interfaces/tests/geojson-geometry-collection.test.mjs create mode 100644 interfaces/tests/geojson-geometry.test.mjs create mode 100644 interfaces/tests/geojson-line-string.test.mjs create mode 100644 interfaces/tests/geojson-multi-line-string.test.mjs create mode 100644 interfaces/tests/geojson-multi-point.test.mjs create mode 100644 interfaces/tests/geojson-multi-polygon.test.mjs create mode 100644 interfaces/tests/geojson-polygon.test.mjs create mode 100644 interfaces/tests/geojson-ref-bounding-box.test.mjs create mode 100644 interfaces/tests/geojson-ref-line-string-coordinates.test.mjs create mode 100644 interfaces/tests/geojson-ref-linear-ring-coordinates.test.mjs create mode 100644 interfaces/tests/geojson-ref-point-coordinates.test.mjs create mode 100644 interfaces/tests/geojson-ref-polygon-coordinates.test.mjs create mode 100644 interfaces/tests/geojson-sentinel.test.mjs create mode 100644 interfaces/tests/json-to-schema-conditions.test.mjs create mode 100644 interfaces/tests/json-to-schema-converters-deep.test.mjs create mode 100644 interfaces/tests/json-to-schema-enum-expression.test.mjs create mode 100644 interfaces/tests/json-to-schema-scalars.test.mjs create mode 100644 interfaces/tests/json-to-schema-style-statics.test.mjs create mode 100644 interfaces/tests/json-to-schema-type-statics.test.mjs create mode 100644 interfaces/tests/json-to-schema.test.mjs create mode 100644 interfaces/tests/label-item-steps-extra.test.mjs create mode 100644 interfaces/tests/label-item-validators-suite.test.mjs create mode 100644 interfaces/tests/label-validator-suite.test.mjs create mode 100644 interfaces/tests/label-validators-nav-tree.test.mjs create mode 100644 interfaces/tests/label-validators-orchestrator-suite.test.mjs create mode 100644 interfaces/tests/model-helper-edge.test.mjs create mode 100644 interfaces/tests/model-helper.test.mjs create mode 100644 interfaces/tests/permissions-getter-matrix.test.mjs create mode 100644 interfaces/tests/permissions-helper-branches.test.mjs create mode 100644 interfaces/tests/policy-editable-field.test.mjs create mode 100644 interfaces/tests/policy-helper.test.mjs create mode 100644 interfaces/tests/policy-messages-provider.test.mjs create mode 100644 interfaces/tests/policy-messages-types.test.mjs create mode 100644 interfaces/tests/pure-helpers-suite.test.mjs create mode 100644 interfaces/tests/reachability-edge.test.mjs create mode 100644 interfaces/tests/reachability-project-raw-node.test.mjs create mode 100644 interfaces/tests/remove-object-properties-edge.test.mjs create mode 100644 interfaces/tests/remove-object-properties.test.mjs create mode 100644 interfaces/tests/rule-document-validator-edge.test.mjs create mode 100644 interfaces/tests/rule-document-validators-extra.test.mjs create mode 100644 interfaces/tests/rule-item-validator-deep-suite.test.mjs create mode 100644 interfaces/tests/rule-validator-suite.test.mjs create mode 100644 interfaces/tests/schema-engine-model-pipeline.test.mjs create mode 100644 interfaces/tests/schema-engine-roundtrip.test.mjs create mode 100644 interfaces/tests/schema-helper-build.test.mjs create mode 100644 interfaces/tests/schema-helper-comment.test.mjs create mode 100644 interfaces/tests/schema-helper-conditions-serialize.test.mjs create mode 100644 interfaces/tests/schema-helper-context-misc.test.mjs create mode 100644 interfaces/tests/schema-helper-context.test.mjs create mode 100644 interfaces/tests/schema-helper-deep.test.mjs create mode 100644 interfaces/tests/schema-helper-edge.test.mjs create mode 100644 interfaces/tests/schema-helper-extra.test.mjs create mode 100644 interfaces/tests/schema-helper-getversion.test.mjs create mode 100644 interfaces/tests/schema-helper-misc.test.mjs create mode 100644 interfaces/tests/schema-helper-parse-build-deep.test.mjs create mode 100644 interfaces/tests/schema-helper-parse-field.test.mjs create mode 100644 interfaces/tests/schema-helper-parse.test.mjs create mode 100644 interfaces/tests/schema-helper-pure-helpers-deep.test.mjs create mode 100644 interfaces/tests/schema-helper-set-version-iri.test.mjs create mode 100644 interfaces/tests/schema-helper-update-fields.test.mjs create mode 100644 interfaces/tests/schema-helper-update-version.test.mjs create mode 100644 interfaces/tests/schema-helper-validate.test.mjs create mode 100644 interfaces/tests/schema-helper-version-ops-deep.test.mjs create mode 100644 interfaces/tests/schema-json-edge.test.mjs create mode 100644 interfaces/tests/schema-json-error-context-deep.test.mjs create mode 100644 interfaces/tests/schema-json-error-context.test.mjs create mode 100644 interfaces/tests/schema-json-field-branches.test.mjs create mode 100644 interfaces/tests/schema-model-constructor-statics.test.mjs create mode 100644 interfaces/tests/schema-model-deep-extra.test.mjs create mode 100644 interfaces/tests/schema-model-extra.test.mjs create mode 100644 interfaces/tests/schema-model-paths-fields.test.mjs create mode 100644 interfaces/tests/schema-model-suite.test.mjs create mode 100644 interfaces/tests/schema-to-json-extra.test.mjs create mode 100644 interfaces/tests/schema-to-json-getters.test.mjs create mode 100644 interfaces/tests/schema-to-json.test.mjs create mode 100644 interfaces/tests/schema-token-model-edge.test.mjs create mode 100644 interfaces/tests/sentinel-hub-context.test.mjs create mode 100644 interfaces/tests/sort-objects-array-edge.test.mjs create mode 100644 interfaces/tests/sort-objects-array.test.mjs create mode 100644 interfaces/tests/statistic-item-validator-deep-suite.test.mjs create mode 100644 interfaces/tests/statistic-score-data.test.mjs create mode 100644 interfaces/tests/statistic-validator-suite.test.mjs create mode 100644 interfaces/tests/timeout-error-formula.test.mjs create mode 100644 interfaces/tests/token-model-suite.test.mjs create mode 100644 logger-service/tests/constants.test.mjs create mode 100644 logger-service/tests/logger-service-guards.test.mjs create mode 100644 logger-service/tests/logger-service-handlers.test.mjs create mode 100644 logger-service/tests/mongo-constants.test.mjs create mode 100644 notification-service/tests/config.test.mjs create mode 100644 notification-service/tests/constants.test.mjs create mode 100644 notification-service/tests/environment.test.mjs create mode 100644 notification-service/tests/notification-entity.test.mjs create mode 100644 notification-service/tests/notification-service-guards.test.mjs create mode 100644 notification-service/tests/notification-service-handlers.test.mjs create mode 100644 notification-service/tests/progress-entity.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/block-validator-class.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/events-misc-validators-branches.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/module-tool-validator-pure.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/schema-formula-validators-branches.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/token-account-validators-branches.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/token-http-validators-branches.test.mjs create mode 100644 policy-service/tests/unit-tests/block-validators/ui-structural-validators-branches.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/_block-exec-harness.mjs create mode 100644 policy-service/tests/unit-tests/blocks/_block-exec-smoke.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/action-block-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/calculate-container.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/calculate-family.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/common-block-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/create-token-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/custom-logic-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/doc-request-validators.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/exec-document-blocks.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/exec-request-send-blocks.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/exec-ui-blocks.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/impact-addon-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/property-validator-blocks.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/request-button-multisign.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/retirement-serials.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-aggregate-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-documents-source-addon.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-documents-source.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-dropdown-block-addon.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-external-data-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-filters-addon-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-http-request-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-pagination-addon.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-report-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-report-item-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-switch-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-timer-block.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/runtime-ui-getdata-blocks.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/send-reassign-revoke-split-multisign.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/simple-and-revoke.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/token-blocks-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/blocks/token-family.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/common-variables-store.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/common-variables.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/custom-logic-python-packages.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/decorators/pure-decorators.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/math-group.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/messages-report.test.mjs create mode 100644 policy-service/tests/unit-tests/helpers/table-field.test.mjs create mode 100644 policy-service/tests/unit-tests/interfaces/interface-enums.test.mjs create mode 100644 policy-service/tests/unit-tests/multi-policy/synchronization-service.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/associate-dissociate-token.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/backup-collections.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/file-helper.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-action-type.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-actions-utils.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-user.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-utils-custom-formula.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-utils-extra.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-utils-pure-helpers.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-utils-pure.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/policy-utils-schema-tags.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/record-utils.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/restore-collections.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/synchronization-service.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/vc-collection.test.mjs create mode 100644 policy-service/tests/unit-tests/policy-engine/vc-documents-utils-pure.test.mjs create mode 100644 policy-service/tests/unit-tests/record/record-utils-classes.test.mjs create mode 100644 policy-service/tests/unit-tests/record/record-utils.test.mjs create mode 100644 policy-service/tests/unit-tests/record/utils.test.mjs create mode 100644 queue-service/tests/config.test.mjs create mode 100644 queue-service/tests/environment.test.mjs create mode 100644 queue-service/tests/task-entity.test.mjs create mode 100644 topic-listener-service/tests/constants.test.mjs create mode 100644 topic-listener-service/tests/environment.test.mjs create mode 100644 topic-listener-service/tests/message.test.mjs create mode 100644 topic-listener-service/tests/module-imports.test.mjs create mode 100644 topic-listener-service/tests/mongo-constants.test.mjs create mode 100644 topic-listener-service/tests/mongo-initialization.test.mjs create mode 100644 topic-listener-service/tests/topic-listener-entity.test.mjs create mode 100644 worker-service/tests/axios-constants.test.mjs create mode 100644 worker-service/tests/fireblocks-helper-dispatch.test.mjs create mode 100644 worker-service/tests/hedera-sdk-helper-extra.test.mjs create mode 100644 worker-service/tests/hedera-sdk-helper.test.mjs create mode 100644 worker-service/tests/hedera-utils-dispatch.test.mjs create mode 100644 worker-service/tests/hedera-utils-extra.test.mjs create mode 100644 worker-service/tests/hedera-utils.test.mjs create mode 100644 worker-service/tests/mongo-constants.test.mjs create mode 100644 worker-service/tests/mongo-initialization-init.test.mjs create mode 100644 worker-service/tests/mongo-initialization.test.mjs create mode 100644 worker-service/tests/transaction-logger-extra.test.mjs create mode 100644 worker-service/tests/transaction-logger-string-size.test.mjs create mode 100644 worker-service/tests/transaction-logger.test.mjs create mode 100644 worker-service/tests/transaction-metadata-exhaustive.test.mjs diff --git a/ai-service/package.json b/ai-service/package.json index 139c0a1dc7..3820c23ece 100644 --- a/ai-service/package.json +++ b/ai-service/package.json @@ -35,7 +35,8 @@ "debug": "nodemon dist/index.js", "dev:docker": "nodemon .", "dev": "tsc -w", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "mocha tests/**/*.test.mjs --reporter mocha-junit-reporter --reporter-options mochaFile=../test_results/ai-service.xml --exit" }, "devDependencies": { "@types/glob": "^8.1.0", diff --git a/ai-service/tests/api-response.test.mjs b/ai-service/tests/api-response.test.mjs new file mode 100644 index 0000000000..498f6dd935 --- /dev/null +++ b/ai-service/tests/api-response.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { ApiResponse, ApiResponseSubscribe } from '../dist/helpers/api-response.js'; +import { AISuggestionService } from '../dist/helpers/suggestions.js'; + +const origRegister = AISuggestionService.prototype.registerListener; +const origSubscribe = AISuggestionService.prototype.subscribe; + +afterEach(() => { + AISuggestionService.prototype.registerListener = origRegister; + AISuggestionService.prototype.subscribe = origSubscribe; +}); + +describe('ApiResponse', () => { + it('registers a listener whose wrapper delegates to the handler', async () => { + let captured = null; + AISuggestionService.prototype.registerListener = function (event, cb) { + captured = { event, cb }; + }; + const handler = async (msg) => ({ echoed: msg }); + ApiResponse('EVENT_A', handler); + assert.equal(captured.event, 'EVENT_A'); + const result = await captured.cb({ payload: 1 }); + assert.deepEqual(result, { echoed: { payload: 1 } }); + }); +}); + +describe('ApiResponseSubscribe', () => { + it('subscribes with a wrapper that awaits the handler', async () => { + let captured = null; + let handled = null; + AISuggestionService.prototype.subscribe = function (event, cb) { + captured = { event, cb }; + }; + const handler = async (msg) => { + handled = msg; + }; + ApiResponseSubscribe('EVENT_B', handler); + assert.equal(captured.event, 'EVENT_B'); + await captured.cb({ value: 2 }); + assert.deepEqual(handled, { value: 2 }); + }); +}); diff --git a/ai-service/tests/config.test.mjs b/ai-service/tests/config.test.mjs new file mode 100644 index 0000000000..96b6996761 --- /dev/null +++ b/ai-service/tests/config.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; + +describe('config bootstrap', () => { + it('loads without throwing and applies dotenv', async () => { + const mod = await import('../dist/config.js'); + assert.ok(mod); + }); +}); diff --git a/ai-service/tests/files-manager-helper.test.mjs b/ai-service/tests/files-manager-helper.test.mjs new file mode 100644 index 0000000000..e7192e021c --- /dev/null +++ b/ai-service/tests/files-manager-helper.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import { FilesManager } from '../dist/helpers/files-manager-helper.js'; + +const policy = (overrides = {}) => ({ + _id: { toString: () => 'p1' }, + name: 'Methodology Alpha', + topicDescription: undefined, + description: undefined, + typicalProjects: undefined, + applicabilityConditions: undefined, + importantParameters: undefined, + categories: [], + ...overrides, +}); + +describe('FilesManager.wordsCount', () => { + it('counts whitespace-separated words', () => { + assert.equal(FilesManager.wordsCount('one two three'), 3); + }); + + it('returns 0 for an empty/whitespace-only string', () => { + assert.equal(FilesManager.wordsCount(''), 0); + assert.equal(FilesManager.wordsCount(' '), 0); + }); + + it('collapses multiple separators', () => { + assert.equal(FilesManager.wordsCount('a b\t\nc'), 3); + }); +}); + +describe('FilesManager.getFileName', () => { + it('joins dir + name with .txt suffix', () => { + assert.equal(FilesManager.getFileName('/tmp/out', 'M1'), '/tmp/out/M1.txt'); + }); +}); + +describe('FilesManager.getNameByCategoryType', () => { + it('maps known category types to human labels', () => { + assert.equal( + FilesManager.getNameByCategoryType('APPLIED_TECHNOLOGY_TYPE'), + 'Categorization Methodologies by Applied Technology Type/Measure' + ); + assert.equal( + FilesManager.getNameByCategoryType('SECTORAL_SCOPE'), + 'Methodologies Sectoral Scope Name' + ); + assert.equal( + FilesManager.getNameByCategoryType('SUB_TYPE'), + 'Categorization Methodologies by Sub Type' + ); + }); + + it('returns empty string for unknown types', () => { + assert.equal(FilesManager.getNameByCategoryType('NOT_A_TYPE'), ''); + assert.equal(FilesManager.getNameByCategoryType(undefined), ''); + }); +}); + +describe('FilesManager.getCategoryRowByType', () => { + const cats = [ + { id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }, + { id: 'c2', type: 'SECTORAL_SCOPE', name: 'Energy' }, + ]; + + it('returns a formatted row when the policy has a matching category', () => { + const row = FilesManager.getCategoryRowByType( + policy({ categories: ['c1'] }), + cats, + 'PROJECT_SCALE', + 'methodology by scale type' + ); + assert.equal(row, '\n Methodology Alpha methodology by scale type: Small \n'); + }); + + it('returns "" when policy has no categories', () => { + const row = FilesManager.getCategoryRowByType(policy(), cats, 'PROJECT_SCALE', 'x'); + assert.equal(row, ''); + }); + + it('returns "" when no category of the requested type matches', () => { + const row = FilesManager.getCategoryRowByType( + policy({ categories: ['c2'] }), + cats, + 'PROJECT_SCALE', + 'x' + ); + assert.equal(row, ''); + }); +}); + +describe('FilesManager.getFileData', () => { + it('returns "" for a policy with no descriptive content', () => { + const result = FilesManager.getFileData(policy(), [], []); + assert.equal(result, ''); + }); + + it('prepends "Methodology name: " when there is content', () => { + const result = FilesManager.getFileData( + policy({ description: 'About methodology' }), + [], + [] + ); + assert.ok(result.startsWith('Methodology name: Methodology Alpha\n')); + assert.ok(result.includes('About methodology')); + }); + + it('appends typicalProjects, applicabilityConditions, and importantParameters', () => { + const result = FilesManager.getFileData( + policy({ + typicalProjects: 'forestry', + applicabilityConditions: 'permit', + importantParameters: { atValidation: 'X', monitored: 'Y' }, + }), + [], + [] + ); + assert.ok(result.includes('Typical projects:')); + assert.ok(result.includes('forestry')); + assert.ok(result.includes('Important conditions')); + assert.ok(result.includes('At validation:')); + assert.ok(result.includes('Monitored:')); + }); + + it('includes the description block', () => { + const result = FilesManager.getFileData( + policy({ description: 'Some description' }), + [], + ['Extra description with enough words to satisfy the helper.'] + ); + assert.ok(result.includes('Some description')); + assert.ok(result.includes('Extra description')); + }); + + it('parenthesizes the topicDescription on the methodology name line', () => { + const result = FilesManager.getFileData( + policy({ description: 'd', topicDescription: 'subtopic' }), + [], + [] + ); + assert.ok(result.startsWith('Methodology name: Methodology Alpha (subtopic)')); + }); +}); + +describe('FilesManager.getMetadataContent', () => { + const categories = [ + { id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }, + { id: 'c2', type: 'PROJECT_SCALE', name: 'Large' }, + { id: 'c3', type: 'SECTORAL_SCOPE', name: 'Energy' }, + ]; + + it('returns "" when no policies match any category', () => { + const result = FilesManager.getMetadataContent([], categories); + assert.equal(result, ''); + }); + + it('groups policies under their category headers', () => { + const policies = [ + policy({ name: 'Pa', categories: ['c1'] }), + policy({ name: 'Pb', categories: ['c2'] }), + policy({ name: 'Pc', categories: ['c3'] }), + ]; + const result = FilesManager.getMetadataContent(policies, categories); + assert.ok(result.includes('Categorization Methodologies by Scale')); + assert.ok(result.includes('Methodologies Sectoral Scope Name')); + assert.ok(result.includes('Small: Pa')); + assert.ok(result.includes('Large: Pb')); + assert.ok(result.includes('Energy: Pc')); + }); + + it('skips categories that no policy maps to', () => { + const policies = [policy({ name: 'Pa', categories: ['c1'] })]; + const result = FilesManager.getMetadataContent(policies, categories); + assert.ok(result.includes('Small: Pa')); + assert.ok(!result.includes('Large:')); + assert.ok(!result.includes('Energy:')); + }); +}); diff --git a/ai-service/tests/files-manager-io.test.mjs b/ai-service/tests/files-manager-io.test.mjs new file mode 100644 index 0000000000..8b3ac0a6d9 --- /dev/null +++ b/ai-service/tests/files-manager-io.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { FilesManager } from '../dist/helpers/files-manager-helper.js'; + +function makeLogger() { + const calls = []; + return { + calls, + info: async (msg) => calls.push(['info', msg]), + warn: async (msg) => calls.push(['warn', msg]), + error: async (msg) => calls.push(['error', msg]), + }; +} + +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +const policy = (id, name, extra = {}) => ({ + _id: { toString: () => id }, + name, + description: extra.description, + typicalProjects: extra.typicalProjects, + categories: extra.categories ?? [], + topicDescription: extra.topicDescription, + importantParameters: extra.importantParameters, + applicabilityConditions: extra.applicabilityConditions, +}); + +describe('FilesManager.checkDir', () => { + it('creates the directory when it does not exist', () => { + const base = tmpDir('fm-check-'); + const target = path.join(base, 'sub'); + assert.equal(fs.existsSync(target), false); + FilesManager.checkDir(target); + assert.equal(fs.existsSync(target), true); + }); + + it('is a no-op when the directory already exists', () => { + const base = tmpDir('fm-check2-'); + FilesManager.checkDir(base); + assert.equal(fs.existsSync(base), true); + }); +}); + +describe('FilesManager.deleteAllFilesInDirectory', () => { + it('removes every file in the directory', () => { + const base = tmpDir('fm-del-'); + fs.writeFileSync(path.join(base, 'a.txt'), '1'); + fs.writeFileSync(path.join(base, 'b.txt'), '2'); + FilesManager.deleteAllFilesInDirectory(base); + assert.deepEqual(fs.readdirSync(base), []); + }); +}); + +describe('FilesManager.generateFile', () => { + it('writes the file content and logs an info message', async () => { + const base = tmpDir('fm-gen-'); + const file = path.join(base, 'out.txt'); + const logger = makeLogger(); + await FilesManager.generateFile(file, 'hello', logger); + assert.equal(fs.readFileSync(file, 'utf8'), 'hello'); + assert.ok(logger.calls.some(([lvl, msg]) => lvl === 'info' && /was created/.test(msg))); + }); + + it('rejects when the write fails (invalid path)', async () => { + const logger = makeLogger(); + const badPath = path.join(tmpDir('fm-bad-'), 'no-such-dir', 'x.txt'); + await assert.rejects(() => FilesManager.generateFile(badPath, 'x', logger)); + }); +}); + +describe('FilesManager.generateMethodologyFiles', () => { + it('returns false when policies is falsy', async () => { + const logger = makeLogger(); + const result = await FilesManager.generateMethodologyFiles(tmpDir('fm-mf-'), null, [], [], logger); + assert.equal(result, false); + }); + + it('writes a file only for policies that produce content', async () => { + const base = tmpDir('fm-mf2-'); + const logger = makeLogger(); + const policies = [ + policy('p1', 'WithContent', { description: 'A real description here.' }), + policy('p2', 'Empty'), + ]; + await FilesManager.generateMethodologyFiles(base, policies, [], [], logger); + const files = fs.readdirSync(base); + assert.ok(files.includes('WithContent.txt')); + assert.ok(!files.includes('Empty.txt')); + }); + + it('appends descriptions exceeding the word minimum', async () => { + const base = tmpDir('fm-mf3-'); + const logger = makeLogger(); + const policies = [policy('p1', 'M', { description: 'd' })]; + const policyDescriptions = [ + { + policyId: 'p1', + descriptions: [ + 'this description has more than five words total', + 'too short', + '', + ], + }, + ]; + await FilesManager.generateMethodologyFiles(base, policies, [], policyDescriptions, logger); + const content = fs.readFileSync(path.join(base, 'M.txt'), 'utf8'); + assert.match(content, /more than five words total/); + assert.doesNotMatch(content, /too short/); + }); +}); + +describe('FilesManager.generateMetadataFile', () => { + it('writes metadata.txt when there is metadata content', async () => { + const base = tmpDir('fm-meta-'); + const logger = makeLogger(); + const categories = [{ id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }]; + const policies = [policy('p1', 'PA', { categories: ['c1'] })]; + await FilesManager.generateMetadataFile(base, policies, categories, logger); + const content = fs.readFileSync(path.join(base, 'metadata.txt'), 'utf8'); + assert.match(content, /Small: PA/); + }); + + it('writes nothing when there is no metadata content', async () => { + const base = tmpDir('fm-meta2-'); + const logger = makeLogger(); + await FilesManager.generateMetadataFile(base, [], [], logger); + assert.equal(fs.existsSync(path.join(base, 'metadata.txt')), false); + }); +}); + +describe('FilesManager.generateData', () => { + it('clears the directory and returns true on success', async () => { + const base = tmpDir('fm-data-'); + fs.writeFileSync(path.join(base, 'stale.txt'), 'old'); + const logger = makeLogger(); + const categories = [{ id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }]; + const policies = [policy('p1', 'PA', { categories: ['c1'], description: 'desc here today' })]; + const result = await FilesManager.generateData(base, policies, categories, [], logger); + assert.equal(result, true); + const files = fs.readdirSync(base); + assert.ok(!files.includes('stale.txt')); + assert.ok(files.includes('PA.txt')); + assert.ok(files.includes('metadata.txt')); + }); + + it('creates the directory first when it is missing', async () => { + const base = path.join(tmpDir('fm-data2-'), 'fresh'); + const logger = makeLogger(); + const result = await FilesManager.generateData(base, [], [], [], logger); + assert.equal(result, true); + assert.equal(fs.existsSync(base), true); + }); + + it('returns false when generation throws', async () => { + const base = tmpDir('fm-data3-'); + const logger = makeLogger(); + const notIterable = { length: 1 }; + const result = await FilesManager.generateData(base, notIterable, [], [], logger); + assert.equal(result, false); + }); +}); diff --git a/ai-service/tests/files-manager.test.mjs b/ai-service/tests/files-manager.test.mjs new file mode 100644 index 0000000000..37a8de68af --- /dev/null +++ b/ai-service/tests/files-manager.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { FilesManager } from '../dist/helpers/files-manager-helper.js'; + +const policyId = (id) => ({ toString: () => id }); + +const policy = (id, name, extra = {}) => ({ + _id: policyId(id), + name, + description: extra.description, + typicalProjects: extra.typicalProjects, + applicabilityConditions: extra.applicabilityConditions, + importantParameters: extra.importantParameters, + topicDescription: extra.topicDescription, + categories: extra.categories, +}); + +describe('FilesManager.wordsCount', () => { + it('counts whitespace-separated tokens', () => { + assert.equal(FilesManager.wordsCount('one two three'), 3); + }); + + it('collapses runs of whitespace', () => { + assert.equal(FilesManager.wordsCount('one two\t\nthree'), 3); + }); + + it('returns 0 for empty / whitespace-only strings', () => { + assert.equal(FilesManager.wordsCount(''), 0); + assert.equal(FilesManager.wordsCount(' \t '), 0); + }); + + it('counts a single word', () => { + assert.equal(FilesManager.wordsCount('lonely'), 1); + }); +}); + +describe('FilesManager.getFileName', () => { + it('appends .txt under the given dir', () => { + assert.equal(FilesManager.getFileName('/tmp/out', 'Methodology'), '/tmp/out/Methodology.txt'); + }); + + it('does not normalise weird names (caller responsibility)', () => { + // documents existing behavior + assert.equal(FilesManager.getFileName('dir', 'a/b'), 'dir/a/b.txt'); + }); +}); + +describe('FilesManager.getNameByCategoryType', () => { + it('maps APPLIED_TECHNOLOGY_TYPE', () => { + assert.match( + FilesManager.getNameByCategoryType('APPLIED_TECHNOLOGY_TYPE'), + /Applied Technology/i + ); + }); + + it('maps MITIGATION_ACTIVITY_TYPE', () => { + assert.match( + FilesManager.getNameByCategoryType('MITIGATION_ACTIVITY_TYPE'), + /Mitigation Activity/i + ); + }); + + it('maps PROJECT_SCALE', () => { + assert.match(FilesManager.getNameByCategoryType('PROJECT_SCALE'), /Scale/i); + }); + + it('maps SECTORAL_SCOPE', () => { + assert.match(FilesManager.getNameByCategoryType('SECTORAL_SCOPE'), /Sectoral/i); + }); + + it('maps SUB_TYPE', () => { + assert.match(FilesManager.getNameByCategoryType('SUB_TYPE'), /Sub Type/i); + }); + + it("returns '' for an unknown type", () => { + assert.equal(FilesManager.getNameByCategoryType('NOPE'), ''); + }); +}); + +describe('FilesManager.getCategoryRowByType', () => { + const cats = [ + { id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }, + { id: 'c2', type: 'SECTORAL_SCOPE', name: 'Energy' }, + ]; + + it('returns "" when policy has no categories', () => { + const p = policy('p1', 'X', { categories: [] }); + assert.equal(FilesManager.getCategoryRowByType(p, cats, 'PROJECT_SCALE', 'by scale'), ''); + }); + + it('returns "" when no matching category is found', () => { + const p = policy('p1', 'X', { categories: ['nope'] }); + assert.equal(FilesManager.getCategoryRowByType(p, cats, 'PROJECT_SCALE', 'by scale'), ''); + }); + + it('emits a line containing the matched category name', () => { + const p = policy('p1', 'X', { categories: ['c1'] }); + const row = FilesManager.getCategoryRowByType(p, cats, 'PROJECT_SCALE', 'by scale'); + assert.match(row, /X by scale: Small/); + }); +}); + +describe('FilesManager.getFileData', () => { + it("returns '' when no fields contribute any content", () => { + const p = policy('p1', 'Empty'); + assert.equal(FilesManager.getFileData(p, [], []), ''); + }); + + it('prefixes "Methodology name:" when content is non-empty', () => { + const p = policy('p1', 'My Methodology', { description: 'A short description.' }); + const out = FilesManager.getFileData(p, [], []); + assert.match(out, /^Methodology name: My Methodology/); + assert.match(out, /A short description\./); + }); + + it('decorates the methodology name with topicDescription when set', () => { + const p = policy('p1', 'M', { description: 'd', topicDescription: 'TopicDesc' }); + const out = FilesManager.getFileData(p, [], []); + assert.match(out, /Methodology name: M \(TopicDesc\)/); + }); + + it('appends typicalProjects, applicabilityConditions, and importantParameters when provided', () => { + const p = policy('p1', 'M', { + description: 'd', + typicalProjects: 'projects-X', + applicabilityConditions: 'conds-Y', + importantParameters: { atValidation: 'AV', monitored: 'MON' }, + }); + const out = FilesManager.getFileData(p, [], []); + assert.match(out, /Typical projects:/); + assert.match(out, /projects-X/); + assert.match(out, /Important conditions/); + assert.match(out, /conds-Y/); + assert.match(out, /Important parameters:/); + assert.match(out, /At validation:/); + assert.match(out, /AV/); + assert.match(out, /Monitored:/); + assert.match(out, /MON/); + }); + + it('appends descriptions if any are passed', () => { + const p = policy('p1', 'M', { description: 'd' }); + const out = FilesManager.getFileData(p, [], ['extra-desc-1', 'extra-desc-2']); + assert.match(out, /extra-desc-1/); + assert.match(out, /extra-desc-2/); + }); +}); + +describe('FilesManager.getMetadataContent', () => { + it('returns "" when no policies match any category', () => { + const cats = [{ id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }]; + const policies = [policy('p1', 'P1', { categories: [] })]; + assert.equal(FilesManager.getMetadataContent(policies, cats), ''); + }); + + it('groups categories by type and lists the policies under each', () => { + const cats = [ + { id: 'c1', type: 'PROJECT_SCALE', name: 'Small' }, + { id: 'c2', type: 'PROJECT_SCALE', name: 'Large' }, + { id: 'c3', type: 'SECTORAL_SCOPE', name: 'Energy' }, + ]; + const policies = [ + policy('p1', 'PA', { categories: ['c1'] }), + policy('p2', 'PB', { categories: ['c2'] }), + policy('p3', 'PC', { categories: ['c1', 'c3'] }), + ]; + const out = FilesManager.getMetadataContent(policies, cats); + // Project scale group header is included + assert.match(out, /Categorization Methodologies by Scale/); + assert.match(out, /Small: PA, PC/); + assert.match(out, /Large: PB/); + // Sectoral group header + assert.match(out, /Sectoral Scope/i); + assert.match(out, /Energy: PC/); + }); + + it('skips a category when no policy references it', () => { + const cats = [ + { id: 'c1', type: 'PROJECT_SCALE', name: 'UsedScale' }, + { id: 'c2', type: 'PROJECT_SCALE', name: 'UnusedScale' }, + ]; + const policies = [policy('p1', 'PA', { categories: ['c1'] })]; + const out = FilesManager.getMetadataContent(policies, cats); + assert.match(out, /UsedScale: PA/); + assert.doesNotMatch(out, /UnusedScale/); + }); +}); diff --git a/ai-service/tests/general-helper.test.mjs b/ai-service/tests/general-helper.test.mjs new file mode 100644 index 0000000000..5ca0a4ff64 --- /dev/null +++ b/ai-service/tests/general-helper.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { + GetMehodologiesByPolicies, + GroupCategories, +} from '../dist/helpers/general-helper.js'; + +const policy = (id, name, extra = {}) => ({ + _id: { toString: () => id }, + name, + topicDescription: extra.topicDescription, + detailsUrl: extra.detailsUrl, +}); + +describe('GetMehodologiesByPolicies', () => { + it('returns empty when no policy name appears in the response', () => { + const result = GetMehodologiesByPolicies( + 'unrelated text about cats', + [policy('p1', 'Methodology Alpha')], + ); + assert.deepEqual(result, []); + }); + + it('matches a policy whose name is mentioned in the response', () => { + const result = GetMehodologiesByPolicies( + 'See the Methodology Alpha for details.', + [ + policy('p1', 'Methodology Alpha', { + topicDescription: 'desc', + detailsUrl: 'https://example.com/a', + }), + ], + ); + assert.deepEqual(result, [ + { id: 'p1', label: 'Methodology Alpha', text: 'desc', url: 'https://example.com/a' }, + ]); + }); + + it('matches case-insensitively but on whole-word boundaries', () => { + const policies = [policy('p1', 'Alpha')]; + // Whole-word boundary: 'AlphaBeta' should NOT match because of \b. + assert.deepEqual( + GetMehodologiesByPolicies('the AlphaBeta system', policies), + [], + ); + assert.equal( + GetMehodologiesByPolicies('the Alpha system', policies).length, + 1, + ); + }); + + it('deduplicates policies even if mentioned multiple times', () => { + const result = GetMehodologiesByPolicies( + 'Alpha is good. Alpha is great. ALPHA wins.', + [policy('p1', 'Alpha')], + ); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'p1'); + }); + + it("falls back to '' for missing topicDescription / detailsUrl", () => { + const result = GetMehodologiesByPolicies( + 'Mention of Alpha here', + [policy('p1', 'Alpha')], + ); + assert.deepEqual(result, [{ id: 'p1', label: 'Alpha', text: '', url: '' }]); + }); +}); + +describe('GroupCategories', () => { + it('groups categories by their type field', () => { + const grouped = GroupCategories([ + { type: 'A', name: 'a1' }, + { type: 'B', name: 'b1' }, + { type: 'A', name: 'a2' }, + ]); + assert.equal(grouped.A.length, 2); + assert.equal(grouped.B.length, 1); + assert.equal(grouped.A[0].name, 'a1'); + }); + + it('returns an empty object for an empty list', () => { + assert.deepEqual(GroupCategories([]), {}); + }); +}); diff --git a/ai-service/tests/mongo-constants.test.mjs b/ai-service/tests/mongo-constants.test.mjs new file mode 100644 index 0000000000..905c8c8f00 --- /dev/null +++ b/ai-service/tests/mongo-constants.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { DEFAULT } from '../dist/constants/mongo.js'; +import { DEFAULT_MONGO } from '../dist/constants/index.js'; + +describe('ai-service mongo defaults', () => { + it('exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); + it('re-exports DEFAULT as DEFAULT_MONGO from the constants barrel', () => { + assert.equal(DEFAULT_MONGO, DEFAULT); + }); +}); diff --git a/ai-service/tests/openai-connect.test.mjs b/ai-service/tests/openai-connect.test.mjs new file mode 100644 index 0000000000..138dfc7086 --- /dev/null +++ b/ai-service/tests/openai-connect.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { OpenAIConnect } from '../dist/helpers/openai-helper.js'; + +const policy = (id, name, extra = {}) => ({ + _id: { toString: () => id }, + name, + topicDescription: extra.topicDescription, + detailsUrl: extra.detailsUrl, +}); + +const fakeChain = (answer) => ({ + async invoke() { + return { answer }; + }, +}); + +describe('OpenAIConnect.ask without a chain', () => { + it('returns an empty response for a null chain', async () => { + const result = await OpenAIConnect.ask(null, 'question', []); + assert.deepEqual(result, { answerBefore: '', answerAfter: '', items: [] }); + }); + + it('returns an empty response for an undefined chain', async () => { + const result = await OpenAIConnect.ask(undefined, 'question', []); + assert.deepEqual(result, { answerBefore: '', answerAfter: '', items: [] }); + }); +}); + +describe('OpenAIConnect.ask with a chain', () => { + it('uses the chain answer as answerBefore', async () => { + const result = await OpenAIConnect.ask(fakeChain('Try the Verra policy'), 'q', []); + assert.equal(result.answerBefore, 'Try the Verra policy'); + }); + + it('appends the canned answerAfter disclaimer', async () => { + const result = await OpenAIConnect.ask(fakeChain('anything'), 'q', []); + assert.match(result.answerAfter, /Guardian methodologies/); + }); + + it('resolves methodologies for policies mentioned in the answer', async () => { + const policies = [policy('1', 'Verra'), policy('2', 'GoldStandard')]; + const result = await OpenAIConnect.ask(fakeChain('Use Verra for this case'), 'q', policies); + assert.equal(result.items.length, 1); + assert.equal(result.items[0].id, '1'); + assert.equal(result.items[0].label, 'Verra'); + }); + + it('returns no items when no policy name matches', async () => { + const policies = [policy('1', 'Verra')]; + const result = await OpenAIConnect.ask(fakeChain('No methodologies here'), 'q', policies); + assert.deepEqual(result.items, []); + }); +}); + +describe('OpenAIConnect.getChain', () => { + it('is a static async factory function', () => { + assert.equal(typeof OpenAIConnect.getChain, 'function'); + assert.equal(OpenAIConnect.getChain.length, 2); + }); +}); diff --git a/ai-service/tests/openai-helper-chain.test.mjs b/ai-service/tests/openai-helper-chain.test.mjs new file mode 100644 index 0000000000..83dd5e92f3 --- /dev/null +++ b/ai-service/tests/openai-helper-chain.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { RunnableLambda } from '@langchain/core/runnables'; +import { OpenAIConnect } from '../dist/helpers/openai-helper.js'; + +describe('OpenAIConnect.getChain', () => { + it('assembles a retrieval chain from the model and vector store', async () => { + const fakeModel = RunnableLambda.from(async () => 'an answer from the model'); + const fakeRetriever = RunnableLambda.from(async () => []); + const fakeVector = { asRetriever: () => fakeRetriever }; + + const chain = await OpenAIConnect.getChain(fakeModel, fakeVector); + + assert.ok(chain); + assert.equal(typeof chain.invoke, 'function'); + }); +}); diff --git a/ai-service/tests/suggestions.test.mjs b/ai-service/tests/suggestions.test.mjs new file mode 100644 index 0000000000..1b03172a96 --- /dev/null +++ b/ai-service/tests/suggestions.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { AISuggestionService } from '../dist/helpers/suggestions.js'; + +describe('AISuggestionService', () => { + it('uses the ai-suggestions message queue and an ai-service reply subject', () => { + const svc = new AISuggestionService(); + assert.equal(svc.messageQueueName, 'ai-suggestions'); + assert.match(svc.replySubject, /^ai-service-/); + }); + + it('registerListener subscribes to the event via getMessages', () => { + const svc = new AISuggestionService(); + const subscribed = []; + svc.setConnection({ + subscribe: (subject, opts) => { + subscribed.push({ subject, opts }); + return { unsubscribe() {} }; + }, + }); + const cb = async () => 'ok'; + svc.registerListener('MY_EVENT', cb); + assert.equal(subscribed.length, 1); + assert.equal(subscribed[0].subject, 'MY_EVENT'); + assert.equal(subscribed[0].opts.queue, 'ai-suggestions'); + }); + + it('is a singleton (same instance on repeated construction)', () => { + assert.equal(new AISuggestionService(), new AISuggestionService()); + }); +}); diff --git a/analytics-service/tests/analytics-services-coverage.test.mjs b/analytics-service/tests/analytics-services-coverage.test.mjs new file mode 100644 index 0000000000..88f1110b05 --- /dev/null +++ b/analytics-service/tests/analytics-services-coverage.test.mjs @@ -0,0 +1,425 @@ +import assert from 'node:assert/strict'; +import { AnalyticsUtils } from '../dist/helpers/utils.js'; +import { AnalyticsUserService } from '../dist/analytics/user.service.js'; +import { AnalyticsTokenService } from '../dist/analytics/token.service.js'; +import { AnalyticsDocumentService } from '../dist/analytics/document.service.js'; +import { AnalyticsPolicyService } from '../dist/analytics/policy.service.js'; +import { + DatabaseServer, + RegistrationMessage, + TagMessage, + VCMessage, + VPMessage, + RoleMessage, + DIDMessage, + TopicMessage, + PolicyMessage, + ModuleMessage, + SchemaMessage, + SchemaPackageMessage, + TokenMessage, + UrlType, +} from '@guardian/common'; + +const db = { create: [], save: [], find: [], findOne: [] }; +let findResults = []; + +function resetDb() { + db.create.length = 0; + db.save.length = 0; + db.find.length = 0; + db.findOne.length = 0; + findResults = []; +} + +const DB_METHODS = ['create', 'save', 'find', 'findOne']; +const originalProto = {}; +for (const m of DB_METHODS) { originalProto[m] = DatabaseServer.prototype[m]; } +function patchDb() { + DatabaseServer.prototype.create = function (_, d) { db.create.push(d); return d; }; + DatabaseServer.prototype.save = async function (_, d) { db.save.push(d); return d; }; + DatabaseServer.prototype.find = async function (_, q) { db.find.push(q); return findResults.shift() || []; }; + DatabaseServer.prototype.findOne = async function (_, q) { db.findOne.push(q); return null; }; +} +function restoreDb() { + for (const m of DB_METHODS) { DatabaseServer.prototype[m] = originalProto[m]; } +} + +const UTIL_METHODS = ['updateStatus', 'updateProgress', 'searchMessages', 'getTokenInfo', 'unique']; +const originalUtils = {}; +for (const m of UTIL_METHODS) { originalUtils[m] = AnalyticsUtils[m]; } +function patchUtils(searchImpl) { + AnalyticsUtils.updateStatus = async (r) => r; + AnalyticsUtils.updateProgress = (r) => r; + AnalyticsUtils.unique = (arr) => arr; + AnalyticsUtils.searchMessages = searchImpl; +} +function restoreUtils() { + for (const m of UTIL_METHODS) { AnalyticsUtils[m] = originalUtils[m]; } +} + +function parsedItem(type, fields = {}) { + return { + type, + validate: () => true, + setPayer() {}, setIndex() {}, setId() {}, setTopicId() {}, + getUrlValue: () => 'cid://x', + getDocumentUrl: () => 'cid://doc', + payer: '0.0.9', id: 'ts-1', uuid: 'u-x', name: 'n', description: 'd', + owner: 'did:owner', action: 'create', topicId: '0.0.7', + ...fields, + }; +} + +function patchMessageStatic(MsgClass, type, fields = {}) { + const orig = MsgClass.fromMessageObject; + MsgClass.fromMessageObject = () => parsedItem(type, fields); + return () => { MsgClass.fromMessageObject = orig; }; +} + +const fakeReport = () => ({ uuid: 'r-1', root: '0.0.1', error: null }); + +describe('AnalyticsUserService.search (coverage)', () => { + let undo; + beforeEach(() => { resetDb(); patchDb(); }); + afterEach(() => { restoreDb(); restoreUtils(); if (undo) { undo(); undo = null; } }); + + it('parses a StandardRegistry message and persists a user row', async () => { + undo = patchMessageStatic(RegistrationMessage, 'Standard Registry', { registrantTopicId: '0.0.5', did: 'did:sr' }); + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: JSON.stringify({ type: 'Standard Registry' }), payer_account_id: '0.0.9', sequence_number: 1, id: 't1', topicId: '0.0.5' }); + return report; + }); + const out = await AnalyticsUserService.search(fakeReport(), false); + assert.equal(db.create.length, 1); + assert.equal(db.create[0].type, 'STANDARD_REGISTRY'); + assert.equal(db.create[0].did, 'did:sr'); + assert.equal(db.save.length, 1); + assert.ok(out); + }); + + it('ignores a non-JSON message body', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: 'not-json' }); + return report; + }); + const out = await AnalyticsUserService.search(fakeReport()); + assert.equal(db.create.length, 0); + assert.ok(out); + }); + + it('ignores a message whose type is not StandardRegistry', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: JSON.stringify({ type: 'Other' }) }); + return report; + }); + const out = await AnalyticsUserService.search(fakeReport()); + assert.equal(db.create.length, 0); + assert.ok(out); + }); + + it('records the error when searchMessages throws', async () => { + patchUtils(async () => { throw new Error('mirror-down'); }); + const out = await AnalyticsUserService.search(fakeReport()); + assert.match(out.error, /mirror-down/); + }); +}); + +describe('AnalyticsTokenService (coverage)', () => { + let undo; + beforeEach(() => { resetDb(); patchDb(); }); + afterEach(() => { restoreDb(); restoreUtils(); if (undo) { undo(); undo = null; } }); + + it('getTokenCache returns cache when present and not skipped', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T', balance: 5 }); + const out = await AnalyticsTokenService.getTokenCache('u', 'T', false); + assert.equal(out.balance, 5); + }); + + it('getTokenCache returns null when present but skip=true', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T' }); + const out = await AnalyticsTokenService.getTokenCache('u', 'T', true); + assert.equal(out, null); + }); + + it('getTokenCache creates a fresh cache when missing', async () => { + DatabaseServer.prototype.findOne = async () => null; + const out = await AnalyticsTokenService.getTokenCache('u', 'T'); + assert.equal(out.balance, 0); + assert.equal(out.tokenId, 'T'); + }); + + it('updateTokenCache saves through the database server', async () => { + const cache = { tokenId: 'T', balance: 1 }; + const out = await AnalyticsTokenService.updateTokenCache(cache); + assert.equal(db.save.length, 1); + assert.equal(out, cache); + }); + + it('searchBalanceByToken applies fetched token info', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T', balance: 0, topicId: null }); + patchUtils(async (r) => r); + AnalyticsUtils.getTokenInfo = async () => ({ total_supply: '42', memo: '0.0.123' }); + const report = fakeReport(); + const out = await AnalyticsTokenService.searchBalanceByToken(report, { tokenId: 'T' }, false); + const saved = db.save.find((s) => s.tokenId === 'T'); + assert.equal(saved.balance, 42); + assert.equal(saved.topicId, '0.0.123'); + assert.equal(out.error, null); + }); + + it('searchBalanceByToken records error when token info is empty', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T', balance: 0 }); + patchUtils(async (r) => r); + AnalyticsUtils.getTokenInfo = async () => null; + const out = await AnalyticsTokenService.searchBalanceByToken(fakeReport(), { tokenId: 'T' }); + assert.match(out.error, /Invalid token info/); + }); + + it('searchBalanceByToken records error when getTokenInfo throws', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T', balance: 0 }); + patchUtils(async (r) => r); + AnalyticsUtils.getTokenInfo = async () => { throw new Error('boom'); }; + const out = await AnalyticsTokenService.searchBalanceByToken(fakeReport(), { tokenId: 'T' }); + assert.match(out.error, /boom/); + }); + + it('searchBalanceByToken returns early when cache missing (skip)', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T' }); + patchUtils(async (r) => r); + const report = fakeReport(); + const out = await AnalyticsTokenService.searchBalanceByToken(report, { tokenId: 'T' }, true); + assert.equal(out, report); + assert.equal(db.save.length, 0); + }); + + it('searchTagByToken persists a Tag from a Tag message', async () => { + undo = patchMessageStatic(TagMessage, 'Tag', { uuid: 'tag-1', target: 'tgt', operation: 'op', entity: 'ent', date: 'dt' }); + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: JSON.stringify({ type: 'Tag' }) }); + return report; + }); + const out = await AnalyticsTokenService.searchTagByToken(fakeReport(), { topicId: '0.0.5' }); + assert.equal(db.create.length, 1); + assert.equal(db.create[0].tagUUID, 'tag-1'); + assert.ok(out); + }); + + it('searchTagByToken ignores a non-object string body and an unknown type', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: 'plain text' }); + await cb({ message: JSON.stringify({ type: 'Unknown' }) }); + return report; + }); + const out = await AnalyticsTokenService.searchTagByToken(fakeReport(), { topicId: '0.0.5' }); + assert.equal(db.create.length, 0); + assert.ok(out); + }); + + it('searchTagByToken records error on throw', async () => { + patchUtils(async () => { throw new Error('tag-fail'); }); + const out = await AnalyticsTokenService.searchTagByToken(fakeReport(), { topicId: '0.0.5' }); + assert.match(out.error, /tag-fail/); + }); + + it('search runs the balance and tag passes', async () => { + DatabaseServer.prototype.findOne = async () => ({ tokenId: 'T', balance: 0 }); + patchUtils(async (r) => r); + AnalyticsUtils.getTokenInfo = async () => ({ total_supply: '1', memo: '0.0.1' }); + findResults = [ + [{ tokenId: 'T' }], + [{ topicId: '0.0.5' }], + ]; + const out = await AnalyticsTokenService.search(fakeReport(), false); + assert.ok(out); + assert.equal(db.find.length, 2); + }); +}); + +describe('AnalyticsDocumentService (coverage)', () => { + let undos = []; + beforeEach(() => { resetDb(); patchDb(); }); + afterEach(() => { restoreDb(); restoreUtils(); undos.forEach((u) => u()); undos = []; }); + + const instance = { policyUUID: 'p-1', policyTopicId: '0.0.2', instanceTopicId: '0.0.3' }; + + it('persists VC documents', async () => { + undos.push(patchMessageStatic(VCMessage, 'VC-Document', { issuer: 'iss' })); + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: JSON.stringify({ type: 'VC-Document' }) }); + return report; + }); + const out = await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create[0].type, 'VC'); + assert.ok(out); + }); + + it('persists VP documents', async () => { + undos.push(patchMessageStatic(VPMessage, 'VP-Document', { issuer: 'iss' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'VP-Document' }) }); return report; }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create[0].type, 'VP'); + }); + + it('persists Role documents', async () => { + undos.push(patchMessageStatic(RoleMessage, 'Role-Document', { issuer: 'iss', role: 'r', group: 'g' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'Role-Document' }) }); return report; }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create[0].type, 'ROLE'); + assert.equal(db.create[0].role, 'r'); + }); + + it('persists DID documents', async () => { + undos.push(patchMessageStatic(DIDMessage, 'DID-Document', { did: 'did:doc' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'DID-Document' }) }); return report; }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create[0].type, 'DID'); + assert.equal(db.create[0].issuer, 'did:doc'); + }); + + it('persists child Topic when childId is present', async () => { + undos.push(patchMessageStatic(TopicMessage, 'Topic', { childId: '0.0.99' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'Topic' }) }); return report; }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create[0].topicId, '0.0.99'); + }); + + it('skips Topic message without childId', async () => { + undos.push(patchMessageStatic(TopicMessage, 'Topic', { childId: null })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'Topic' }) }); return report; }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create.length, 0); + }); + + it('ignores a non-object string body and an unknown type', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: 'plain text' }); + await cb({ message: JSON.stringify({ type: 'Unknown' }) }); + return report; + }); + await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.equal(db.create.length, 0); + }); + + it('records error on throw', async () => { + patchUtils(async () => { throw new Error('doc-fail'); }); + const out = await AnalyticsDocumentService.searchByInstance(fakeReport(), '0.0.3', instance); + assert.match(out.error, /doc-fail/); + }); + + it('searchDocuments fans out over instances and topics', async () => { + patchUtils(async (r) => r); + findResults = [ + [{ instanceTopicId: '0.0.3', policyUUID: 'p', policyTopicId: '0.0.2' }], + [{ topicId: '0.0.4' }], + ]; + const out = await AnalyticsDocumentService.searchDocuments(fakeReport(), false); + assert.ok(out); + assert.equal(db.find.length, 2); + }); +}); + +describe('AnalyticsPolicyService (coverage)', () => { + let undos = []; + beforeEach(() => { resetDb(); patchDb(); }); + afterEach(() => { restoreDb(); restoreUtils(); undos.forEach((u) => u()); undos = []; }); + + const sr = { did: 'did:sr', topicId: '0.0.5' }; + + it('searchByUser persists a sub-user from a DID message with different did', async () => { + undos.push(patchMessageStatic(DIDMessage, 'DID-Document', { did: 'did:other', topicId: '0.0.6' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'DID-Document' }) }); return report; }); + await AnalyticsPolicyService.searchByUser(fakeReport(), sr); + assert.equal(db.create[0].type, 'USER'); + assert.equal(db.create[0].did, 'did:other'); + }); + + it('searchByUser skips DID with same did as SR', async () => { + undos.push(patchMessageStatic(DIDMessage, 'DID-Document', { did: 'did:sr' })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type: 'DID-Document' }) }); return report; }); + await AnalyticsPolicyService.searchByUser(fakeReport(), sr); + assert.equal(db.create.length, 0); + }); + + it('searchByUser persists Policy/Module/Tag/Schema/SchemaPackage', async () => { + for (const [Msg, type] of [ + [PolicyMessage, 'Policy'], + [ModuleMessage, 'Module'], + [TagMessage, 'Tag'], + [SchemaMessage, 'Schema'], + [SchemaPackageMessage, 'Schema-Package'], + ]) { + resetDb(); + undos.push(patchMessageStatic(Msg, type, { policyTopicId: '0.0.8', version: '1', entity: 'e', schemas: 3 })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type }) }); return report; }); + await AnalyticsPolicyService.searchByUser(fakeReport(), sr); + assert.equal(db.create.length, 1, `expected create for ${type}`); + } + }); + + it('searchByUser ignores a non-object string body and unknown type', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: 'plain text' }); + await cb({ message: JSON.stringify({ type: 'Unknown' }) }); + return report; + }); + await AnalyticsPolicyService.searchByUser(fakeReport(), sr); + assert.equal(db.create.length, 0); + }); + + it('searchByPolicy ignores a non-object string body and unknown type', async () => { + patchUtils(async (report, topicId, skip, cb) => { + await cb({ message: 'plain text' }); + await cb({ message: JSON.stringify({ type: 'Unknown' }) }); + return report; + }); + await AnalyticsPolicyService.searchByPolicy(fakeReport(), { topicId: '0.0.2', owner: 'o' }); + assert.equal(db.create.length, 0); + }); + + it('searchByUser records error on throw', async () => { + patchUtils(async () => { throw new Error('user-fail'); }); + const out = await AnalyticsPolicyService.searchByUser(fakeReport(), sr); + assert.match(out.error, /user-fail/); + }); + + const policy = { topicId: '0.0.2', owner: 'did:owner' }; + + it('searchByPolicy persists InstancePolicy/Token/Tag/Schema/SchemaPackage', async () => { + for (const [Msg, type] of [ + [PolicyMessage, 'Instance-Policy'], + [TokenMessage, 'Token'], + [TagMessage, 'Tag'], + [SchemaMessage, 'Schema'], + [SchemaPackageMessage, 'Schema-Package'], + ]) { + resetDb(); + undos.push(patchMessageStatic(Msg, type, { instanceTopicId: '0.0.9', version: '1', tokenId: 'T', tokenName: 'N', tokenSymbol: 'S', tokenType: 'fungible', entity: 'e', schemas: 2 })); + patchUtils(async (report, topicId, skip, cb) => { await cb({ message: JSON.stringify({ type }) }); return report; }); + await AnalyticsPolicyService.searchByPolicy(fakeReport(), policy); + assert.equal(db.create.length, 1, `expected create for ${type}`); + } + }); + + it('searchByPolicy records error on throw', async () => { + patchUtils(async () => { throw new Error('policy-fail'); }); + const out = await AnalyticsPolicyService.searchByPolicy(fakeReport(), policy); + assert.match(out.error, /policy-fail/); + }); + + it('searchPolicy fans out over SR users', async () => { + patchUtils(async (r) => r); + findResults = [[{ topicId: '0.0.5', did: 'did:sr' }]]; + const out = await AnalyticsPolicyService.searchPolicy(fakeReport(), false); + assert.ok(out); + assert.equal(db.find[0].type, 'STANDARD_REGISTRY'); + }); + + it('searchInstance fans out over policies', async () => { + patchUtils(async (r) => r); + findResults = [[{ topicId: '0.0.2', owner: 'did:owner' }]]; + const out = await AnalyticsPolicyService.searchInstance(fakeReport(), false); + assert.ok(out); + assert.equal(db.find.length, 1); + }); +}); diff --git a/analytics-service/tests/analytics-services-smoke.test.mjs b/analytics-service/tests/analytics-services-smoke.test.mjs new file mode 100644 index 0000000000..91c87398d0 --- /dev/null +++ b/analytics-service/tests/analytics-services-smoke.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const dbCalls = { findOne: [], find: [], save: [], create: [], update: [] }; + +class StubDatabaseServer { + constructor() {} + async findOne(_, q) { dbCalls.findOne.push(q); return null; } + async find() { return []; } + async findAndCount() { return [[], 0]; } + create(_, d) { dbCalls.create.push(d); return d; } + async save(_, d) { dbCalls.save.push(d); return d; } + async update() { return {}; } + async remove() {} + async count() { return 0; } + async aggregate() { return []; } + static async save(_, d) { dbCalls.save.push(d); return d; } + static async update() { return {}; } +} + +class StubWorkers { + async addRetryableTask() { return { messages: [], next: null }; } + async addNonRetryableTask() { return {}; } +} + +const commonMock = { + DatabaseServer: StubDatabaseServer, + Workers: StubWorkers, + BaseEntity: class {}, + MessageType: { StandardRegistry: 'StandardRegistry', Policy: 'Policy', Token: 'Token', Schema: 'Schema' }, + RegistrationMessage: class { + static fromMessageObject() { return new this(); } + validate() { return true; } + setPayer() {} setIndex() {} setId() {} setTopicId() {} + get registrantTopicId() { return '0.0.2'; } + get did() { return 'did:hedera:0.0.1'; } + get payer() { return '0.0.3'; } + get id() { return 'm-1'; } + get action() { return 'register'; } + }, + PolicyMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, + TokenMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, + SchemaMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, + VcMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, + VpMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, + DIDMessage: class { static fromMessageObject() { return new this(); } validate() { return true; } }, +}; + +const interfacesMock = { + WorkerTaskType: new Proxy({}, { get: (_, p) => String(p) }), + NetworkOptions: {}, + NetworkType: {}, + TenantContext: { Empty: { tenantId: null } }, +}; + +const utilsPath = '../dist/helpers/utils.js'; + +async function loadUserService(utilsOverride) { + const mocks = utilsOverride ? { [utilsPath]: utilsOverride } : {}; + const mod = await esmock('../dist/analytics/user.service.js', mocks, { + '@guardian/common': commonMock, + '@guardian/interfaces': interfacesMock, + }); + return mod.AnalyticsUserService; +} + +let AnalyticsUserService, AnalyticsDocumentService, AnalyticsTokenService, AnalyticsPolicyService; +try { + ({ AnalyticsUserService } = await import('../dist/analytics/user.service.js')); +} catch (e) { console.warn('[analytics-smoke] user.service import failed:', e.message); } +try { + ({ AnalyticsDocumentService } = await import('../dist/analytics/document.service.js')); +} catch (e) { console.warn('[analytics-smoke] document.service import failed:', e.message); } +try { + ({ AnalyticsTokenService } = await import('../dist/analytics/token.service.js')); +} catch (e) { console.warn('[analytics-smoke] token.service import failed:', e.message); } +try { + ({ AnalyticsPolicyService } = await import('../dist/analytics/policy.service.js')); +} catch (e) { console.warn('[analytics-smoke] policy.service import failed:', e.message); } + +const fakeReport = () => ({ + uuid: 'r-1', root: '0.0.1', topicId: '0.0.1', + progress: 0, maxProgress: 0, status: 'NONE', steep: 'NONE', + error: '', +}); + +describe('@unit AnalyticsUserService.search (smoke)', () => { + it('imports the module and exposes search()', () => { + if (!AnalyticsUserService) { console.warn(' [skip] dist not available'); return; } + assert.equal(typeof AnalyticsUserService.search, 'function'); + }); + + it('search runs without throwing on a fresh report', async () => { + const Service = await loadUserService(); + const out = await Service.search(fakeReport()); + assert.ok(out !== undefined); + }); + + it('search catches mirror errors and records them on the report', async () => { + const Service = await loadUserService({ + AnalyticsUtils: { + updateStatus: async () => {}, + updateProgress: () => {}, + searchMessages: async () => { throw new Error('mirror-down'); }, + }, + }); + const report = fakeReport(); + const out = await Service.search(report); + assert.match(out.error || '', /mirror-down/); + }); +}); + +describe('@unit AnalyticsDocumentService (smoke)', () => { + it('module loads and exposes a class', () => { + if (!AnalyticsDocumentService) { console.warn(' [skip] dist not available'); return; } + assert.equal(typeof AnalyticsDocumentService, 'function'); + }); +}); + +describe('@unit AnalyticsTokenService (smoke)', () => { + it('module loads and exposes a class', () => { + if (!AnalyticsTokenService) { console.warn(' [skip] dist not available'); return; } + assert.equal(typeof AnalyticsTokenService, 'function'); + }); +}); + +describe('@unit AnalyticsPolicyService (smoke)', () => { + it('module loads and exposes a class', () => { + if (!AnalyticsPolicyService) { console.warn(' [skip] dist not available'); return; } + assert.equal(typeof AnalyticsPolicyService, 'function'); + }); +}); diff --git a/analytics-service/tests/analytics-utils.test.mjs b/analytics-service/tests/analytics-utils.test.mjs new file mode 100644 index 0000000000..3d0641feaf --- /dev/null +++ b/analytics-service/tests/analytics-utils.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { AnalyticsUtils } from '../dist/helpers/utils.js'; + +describe('AnalyticsUtils.topRateByCount', () => { + it('counts occurrences of array[*][field] and returns sorted top-N', () => { + const items = [ + { tag: 'a' }, { tag: 'a' }, { tag: 'b' }, + { tag: 'c' }, { tag: 'a' }, { tag: 'b' }, + ]; + const result = AnalyticsUtils.topRateByCount(items, 'tag', 2); + assert.equal(result.length, 2); + assert.equal(result[0].name, 'a'); + assert.equal(result[0].value, 3); + assert.equal(result[1].name, 'b'); + assert.equal(result[1].value, 2); + }); + + it("buckets missing/falsy field values under ''", () => { + const items = [{ tag: undefined }, { tag: null }, { tag: '' }, { tag: 'x' }]; + const result = AnalyticsUtils.topRateByCount(items, 'tag', 5); + const empty = result.find((r) => r.name === ''); + assert.ok(empty); + assert.equal(empty.value, 3); + }); + + it('returns at most `size` entries', () => { + const items = [{ k: 'a' }, { k: 'b' }, { k: 'c' }]; + assert.equal(AnalyticsUtils.topRateByCount(items, 'k', 1).length, 1); + }); + + it('returns [] for an empty input array', () => { + assert.deepEqual(AnalyticsUtils.topRateByCount([], 'k', 5), []); + }); +}); + +describe('AnalyticsUtils.topRateByValue', () => { + it('returns the array sorted descending and truncated to size', () => { + const items = [{ value: 1 }, { value: 5 }, { value: 3 }, { value: 9 }]; + const result = AnalyticsUtils.topRateByValue(items, 2); + assert.deepEqual(result.map((r) => r.value), [9, 5]); + }); + + it('returns [] when input is empty', () => { + assert.deepEqual(AnalyticsUtils.topRateByValue([], 5), []); + }); +}); + +describe('AnalyticsUtils.splitChunk', () => { + it('splits an array into chunk-sized blocks', () => { + const result = AnalyticsUtils.splitChunk([1, 2, 3, 4, 5], 2); + assert.deepEqual(result, [[1, 2], [3, 4], [5]]); + }); + + it('returns a single chunk when chunk >= array length', () => { + assert.deepEqual(AnalyticsUtils.splitChunk([1, 2], 5), [[1, 2]]); + }); + + it('returns [] for empty input', () => { + assert.deepEqual(AnalyticsUtils.splitChunk([], 3), []); + }); +}); + +describe('AnalyticsUtils.unique', () => { + it('keeps the first occurrence of each key value', () => { + const items = [ + { id: 'a', n: 1 }, + { id: 'b', n: 2 }, + { id: 'a', n: 99 }, + { id: 'c', n: 3 }, + ]; + const result = AnalyticsUtils.unique(items, 'id'); + assert.deepEqual(result.map((r) => r.id), ['a', 'b', 'c']); + assert.equal(result[0].n, 1); + }); + + it('returns the same array for already-unique input', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + assert.deepEqual(AnalyticsUtils.unique(items, 'id'), items); + }); +}); + +describe('AnalyticsUtils.compressMessages', () => { + it('passes through non-chunked messages unchanged', async () => { + const messages = [{ id: 'a' }, { id: 'b' }]; + const result = await AnalyticsUtils.compressMessages(messages); + assert.deepEqual(result, messages); + }); + + it('joins chunk parts on the root (chunk_number=1) message', async () => { + const messages = [ + { id: 'r', chunk_total: 3, chunk_id: 'g1', chunk_number: 1, message: 'foo' }, + { id: '2', chunk_total: 3, chunk_id: 'g1', chunk_number: 2, message: 'bar' }, + { id: '3', chunk_total: 3, chunk_id: 'g1', chunk_number: 3, message: 'baz' }, + ]; + const result = await AnalyticsUtils.compressMessages(messages); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'r'); + assert.equal(result[0].message, 'foobarbaz'); + }); + + it('marks message=null when any chunk is non-string', async () => { + const messages = [ + { id: 'r', chunk_total: 2, chunk_id: 'g2', chunk_number: 1, message: 'foo' }, + { id: '2', chunk_total: 2, chunk_id: 'g2', chunk_number: 2, message: { not: 'string' } }, + ]; + const result = await AnalyticsUtils.compressMessages(messages); + assert.equal(result[0].message, null); + }); +}); diff --git a/analytics-service/tests/dto-shape.test.mjs b/analytics-service/tests/dto-shape.test.mjs new file mode 100644 index 0000000000..bb84aa1146 --- /dev/null +++ b/analytics-service/tests/dto-shape.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { DashboardDTO } from '../dist/middlewares/validation/schemas/dashboard.js'; +import { ReportDTO } from '../dist/middlewares/validation/schemas/report.js'; +import { InternalServerErrorDTO } from '../dist/middlewares/validation/schemas/errors.js'; +import { ReportDataDTO, RateDTO } from '../dist/middlewares/validation/schemas/report-data.js'; + +function fieldsOf(Cls) { + // Decorated fields are not enumerable on the prototype; we assert that + // constructing the class and assigning to documented field names doesn't + // produce any thrown TypeErrors and yields enumerable own props. + const o = new Cls(); + return Object.keys(o); +} + +describe('@unit DashboardDTO', () => { + it('constructs without arguments', () => { + assert.doesNotThrow(() => new DashboardDTO()); + }); + + it('accepts uuid/root/date assignments', () => { + const d = new DashboardDTO(); + d.uuid = 'u1'; d.root = '0.0.1'; d.date = '2026-01-01'; + assert.equal(d.uuid, 'u1'); + assert.equal(d.root, '0.0.1'); + assert.equal(d.date, '2026-01-01'); + }); +}); + +describe('@unit ReportDTO', () => { + it('constructs and accepts the 9 documented fields', () => { + const r = new ReportDTO(); + const payload = { + uuid: 'u', root: '0.0.1', status: 'PROGRESS', steep: 'POLICIES', + type: 'X', progress: 1, maxProgress: 10, error: '', + }; + Object.assign(r, payload); + for (const [k, v] of Object.entries(payload)) { + assert.equal(r[k], v, `expected ReportDTO.${k} to round-trip`); + } + }); +}); + +describe('@unit InternalServerErrorDTO', () => { + it('has numeric code and string message slots', () => { + const e = new InternalServerErrorDTO(); + e.code = 500; + e.message = 'oops'; + assert.equal(e.code, 500); + assert.equal(e.message, 'oops'); + }); +}); + +describe('@unit RateDTO', () => { + it('shape is { name: string, value: number }', () => { + const r = new RateDTO(); + r.name = 'methodology-A'; + r.value = 42; + assert.equal(r.name, 'methodology-A'); + assert.equal(r.value, 42); + }); +}); + +describe('@unit ReportDataDTO', () => { + it('accepts all numeric tallies (messages, topics, users, etc.)', () => { + const d = new ReportDataDTO(); + const fields = [ + 'messages', 'topics', 'standardRegistries', 'users', + 'policies', 'instances', 'modules', 'documents', + 'vcDocuments', 'vpDocuments', 'didDocuments', + ]; + for (const f of fields) { + d[f] = 1; + assert.equal(d[f], 1, `ReportDataDTO.${f} should be assignable`); + } + }); +}); diff --git a/analytics-service/tests/entity-decorators.test.mjs b/analytics-service/tests/entity-decorators.test.mjs new file mode 100644 index 0000000000..dce2750572 --- /dev/null +++ b/analytics-service/tests/entity-decorators.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { AnalyticsDashboard } from '../dist/entity/analytics-dashboard.js'; +import { AnalyticsModule } from '../dist/entity/analytics-module.js'; +import { AnalyticsPolicy } from '../dist/entity/analytics-policy.js'; +import { AnalyticsPolicyInstance } from '../dist/entity/analytics-policy-instance.js'; +import { AnalyticsSchema } from '../dist/entity/analytics-schema.js'; +import { AnalyticsSchemaPackage } from '../dist/entity/analytics-schema-package.js'; +import { AnalyticsTag } from '../dist/entity/analytics-tag.js'; +import { AnalyticsToken } from '../dist/entity/analytics-token.js'; +import { AnalyticsTopic } from '../dist/entity/analytics-topic.js'; +import { AnalyticsUser } from '../dist/entity/analytics-user.js'; + +describe('analytics entity classes (decorator + construction)', () => { + const cases = [ + ['AnalyticsDashboard', AnalyticsDashboard], + ['AnalyticsModule', AnalyticsModule], + ['AnalyticsPolicy', AnalyticsPolicy], + ['AnalyticsPolicyInstance', AnalyticsPolicyInstance], + ['AnalyticsSchema', AnalyticsSchema], + ['AnalyticsSchemaPackage', AnalyticsSchemaPackage], + ['AnalyticsTag', AnalyticsTag], + ['AnalyticsToken', AnalyticsToken], + ['AnalyticsTopic', AnalyticsTopic], + ['AnalyticsUser', AnalyticsUser], + ]; + + for (const [name, Ctor] of cases) { + it(`${name} is a constructable class`, () => { + assert.equal(typeof Ctor, 'function'); + const instance = new Ctor(); + assert.ok(instance instanceof Ctor); + }); + + it(`${name} accepts assigned properties`, () => { + const instance = new Ctor(); + instance.uuid = 'u-1'; + assert.equal(instance.uuid, 'u-1'); + }); + } + + it('AnalyticsToken holds token-specific fields', () => { + const token = new AnalyticsToken(); + token.tokenId = '0.0.123'; + token.tokenName = 'Carbon'; + token.tokenSymbol = 'CO2'; + token.tokenType = 'fungible'; + assert.equal(token.tokenId, '0.0.123'); + assert.equal(token.tokenName, 'Carbon'); + assert.equal(token.tokenSymbol, 'CO2'); + assert.equal(token.tokenType, 'fungible'); + }); + + it('AnalyticsDashboard holds a report payload and date', () => { + const dashboard = new AnalyticsDashboard(); + const now = new Date(); + dashboard.date = now; + dashboard.report = { foo: 'bar' }; + assert.equal(dashboard.date, now); + assert.deepEqual(dashboard.report, { foo: 'bar' }); + }); + + it('AnalyticsUser holds did/account/type', () => { + const user = new AnalyticsUser(); + user.did = 'did:hedera:0.0.1'; + user.account = '0.0.2'; + user.type = 'STANDARD_REGISTRY'; + assert.equal(user.did, 'did:hedera:0.0.1'); + assert.equal(user.account, '0.0.2'); + assert.equal(user.type, 'STANDARD_REGISTRY'); + }); +}); diff --git a/analytics-service/tests/entity-init-hooks.test.mjs b/analytics-service/tests/entity-init-hooks.test.mjs new file mode 100644 index 0000000000..67a930a18b --- /dev/null +++ b/analytics-service/tests/entity-init-hooks.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { AnalyticsDocument } from '../dist/entity/analytics-document.js'; +import { AnalyticsStatus } from '../dist/entity/analytics-status.js'; +import { AnalyticsTokenCache } from '../dist/entity/analytics-token-cache.js'; +import { AnalyticsTopicCache } from '../dist/entity/analytics-topic-cache.js'; +import { DocumentType } from '../dist/interfaces/document.type.js'; +import { ReportType } from '../dist/interfaces/report.type.js'; + +describe('AnalyticsDocument.setInitState', () => { + it('defaults type to NONE', () => { + const entity = new AnalyticsDocument(); + entity.setInitState(); + assert.equal(entity.type, DocumentType.NONE); + }); + + it('keeps an explicit type', () => { + const entity = new AnalyticsDocument(); + entity.type = DocumentType.VC; + entity.setInitState(); + assert.equal(entity.type, DocumentType.VC); + }); + + it('does not touch other fields', () => { + const entity = new AnalyticsDocument(); + entity.uuid = 'u-1'; + entity.setInitState(); + assert.equal(entity.uuid, 'u-1'); + }); +}); + +describe('AnalyticsStatus.setInitState', () => { + it('defaults type to ALL', () => { + const entity = new AnalyticsStatus(); + entity.setInitState(); + assert.equal(entity.type, ReportType.ALL); + }); + + it('keeps an explicit report type', () => { + const entity = new AnalyticsStatus(); + entity.type = ReportType.TOKENS; + entity.setInitState(); + assert.equal(entity.type, ReportType.TOKENS); + }); + + it('leaves progress untouched', () => { + const entity = new AnalyticsStatus(); + entity.progress = 5; + entity.setInitState(); + assert.equal(entity.progress, 5); + }); +}); + +describe('AnalyticsTokenCache.setInitState', () => { + it('defaults balance to zero', () => { + const entity = new AnalyticsTokenCache(); + entity.setInitState(); + assert.equal(entity.balance, 0); + }); + + it('keeps a positive balance', () => { + const entity = new AnalyticsTokenCache(); + entity.balance = 12.5; + entity.setInitState(); + assert.equal(entity.balance, 12.5); + }); + + it('keeps an existing zero balance as zero', () => { + const entity = new AnalyticsTokenCache(); + entity.balance = 0; + entity.setInitState(); + assert.equal(entity.balance, 0); + }); +}); + +describe('AnalyticsTopicCache.setInitState', () => { + it('defaults index to zero', () => { + const entity = new AnalyticsTopicCache(); + entity.setInitState(); + assert.equal(entity.index, 0); + }); + + it('keeps a positive index', () => { + const entity = new AnalyticsTopicCache(); + entity.index = 42; + entity.setInitState(); + assert.equal(entity.index, 42); + }); + + it('leaves timeStamp untouched', () => { + const entity = new AnalyticsTopicCache(); + entity.timeStamp = '123.456'; + entity.setInitState(); + assert.equal(entity.timeStamp, '123.456'); + }); +}); diff --git a/analytics-service/tests/entity-init-state.test.mjs b/analytics-service/tests/entity-init-state.test.mjs new file mode 100644 index 0000000000..94baafc142 --- /dev/null +++ b/analytics-service/tests/entity-init-state.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { AnalyticsDocument } from '../dist/entity/analytics-document.js'; +import { AnalyticsStatus } from '../dist/entity/analytics-status.js'; +import { AnalyticsTokenCache } from '../dist/entity/analytics-token-cache.js'; +import { AnalyticsTopicCache } from '../dist/entity/analytics-topic-cache.js'; +import { DocumentType } from '../dist/interfaces/document.type.js'; +import { ReportType } from '../dist/interfaces/report.type.js'; + +describe('AnalyticsDocument.setInitState', () => { + it('defaults type to DocumentType.NONE when unset', () => { + const doc = new AnalyticsDocument(); + doc.setInitState(); + assert.equal(doc.type, DocumentType.NONE); + }); + + it('preserves an explicit type', () => { + const doc = new AnalyticsDocument(); + doc.type = 'VC'; + doc.setInitState(); + assert.equal(doc.type, 'VC'); + }); +}); + +describe('AnalyticsStatus.setInitState', () => { + it('defaults type to ReportType.ALL when unset', () => { + const status = new AnalyticsStatus(); + status.setInitState(); + assert.equal(status.type, ReportType.ALL); + }); + + it('preserves an explicit type', () => { + const status = new AnalyticsStatus(); + status.type = 'POLICIES'; + status.setInitState(); + assert.equal(status.type, 'POLICIES'); + }); +}); + +describe('AnalyticsTokenCache.setInitState', () => { + it('defaults balance to 0 when unset', () => { + const cache = new AnalyticsTokenCache(); + cache.setInitState(); + assert.equal(cache.balance, 0); + }); + + it('preserves a non-zero balance', () => { + const cache = new AnalyticsTokenCache(); + cache.balance = 42; + cache.setInitState(); + assert.equal(cache.balance, 42); + }); + + it('leaves a zero balance at 0', () => { + const cache = new AnalyticsTokenCache(); + cache.balance = 0; + cache.setInitState(); + assert.equal(cache.balance, 0); + }); +}); + +describe('AnalyticsTopicCache.setInitState', () => { + it('defaults index to 0 when unset', () => { + const cache = new AnalyticsTopicCache(); + cache.setInitState(); + assert.equal(cache.index, 0); + }); + + it('preserves a non-zero index', () => { + const cache = new AnalyticsTopicCache(); + cache.index = 7; + cache.setInitState(); + assert.equal(cache.index, 7); + }); +}); diff --git a/analytics-service/tests/interfaces-enums.test.mjs b/analytics-service/tests/interfaces-enums.test.mjs new file mode 100644 index 0000000000..834e5ff860 --- /dev/null +++ b/analytics-service/tests/interfaces-enums.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { ReportStatus } from '../dist/interfaces/report-status.type.js'; +import { ReportSteep } from '../dist/interfaces/report-steep.type.js'; +import { DocumentType } from '../dist/interfaces/document.type.js'; +import { UserType } from '../dist/interfaces/user.type.js'; + +describe('@unit ReportStatus enum', () => { + it('has exactly the documented values', () => { + assert.deepEqual( + Object.keys(ReportStatus).sort(), + ['ERROR', 'FINISHED', 'NONE', 'PROGRESS'], + ); + }); + + it('NONE is empty string (sentinel for "no status yet")', () => { + assert.equal(ReportStatus.NONE, ''); + }); + + it('non-NONE values are all uppercase strings matching the key', () => { + for (const [key, value] of Object.entries(ReportStatus)) { + if (key === 'NONE') continue; + assert.equal(value, key); + } + }); + + it('NONE is falsy, all others are truthy (used in `if (status)` checks)', () => { + assert.equal(Boolean(ReportStatus.NONE), false); + assert.equal(Boolean(ReportStatus.PROGRESS), true); + assert.equal(Boolean(ReportStatus.FINISHED), true); + assert.equal(Boolean(ReportStatus.ERROR), true); + }); +}); + +describe('@unit ReportSteep enum', () => { + it('contains the 5 phases of a report lifecycle', () => { + assert.deepEqual( + Object.keys(ReportSteep).sort(), + ['DOCUMENTS', 'INSTANCES', 'POLICIES', 'STANDARD_REGISTRY', 'TOKENS'], + ); + }); + + it('values match keys (string enum convention)', () => { + for (const [k, v] of Object.entries(ReportSteep)) assert.equal(v, k); + }); + + it('values are unique', () => { + const values = Object.values(ReportSteep); + assert.equal(values.length, new Set(values).size); + }); +}); + +describe('@unit DocumentType enum', () => { + it('contains NONE + VC/VP/DID/ROLE', () => { + assert.deepEqual( + Object.keys(DocumentType).sort(), + ['DID', 'NONE', 'ROLE', 'VC', 'VP'], + ); + }); + + it('NONE is "NONE" (NOT the empty string — ReportStatus.NONE is "")', () => { + assert.equal(DocumentType.NONE, 'NONE'); + assert.notEqual(DocumentType.NONE, ''); + }); +}); + +describe('@unit UserType enum', () => { + it('contains only STANDARD_REGISTRY and USER', () => { + assert.deepEqual( + Object.keys(UserType).sort(), + ['STANDARD_REGISTRY', 'USER'], + ); + }); + + it('values match keys', () => { + assert.equal(UserType.STANDARD_REGISTRY, 'STANDARD_REGISTRY'); + assert.equal(UserType.USER, 'USER'); + }); + + it('does NOT include AUDITOR or ADMIN', () => { + assert.equal(UserType.AUDITOR, undefined); + assert.equal(UserType.ADMIN, undefined); + }); +}); + +describe('@unit enum-vs-enum invariants', () => { + it('ReportStatus.NONE !== DocumentType.NONE', () => { + assert.notEqual(ReportStatus.NONE, DocumentType.NONE); + }); + + it('UserType.STANDARD_REGISTRY === ReportSteep.STANDARD_REGISTRY', () => { + assert.equal(UserType.STANDARD_REGISTRY, ReportSteep.STANDARD_REGISTRY); + }); +}); diff --git a/analytics-service/tests/migration.test.mjs b/analytics-service/tests/migration.test.mjs new file mode 100644 index 0000000000..60c9386453 --- /dev/null +++ b/analytics-service/tests/migration.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { ReleaseMigration } from '../dist/migrations/v2-21-0.js'; + +function makeMigration(collections) { + const m = Object.create(ReleaseMigration.prototype); + m.getCollection = (name) => collections[name]; + return m; +} + +describe('ReleaseMigration.updateIndex', () => { + it('drops the matching indexes when they exist', async () => { + const dropped = []; + const coll = (idx) => ({ + indexExists: async (name) => name === idx, + dropIndex: async (name) => { dropped.push(name); }, + }); + const m = makeMigration({ + AnalyticsDocument: coll('uuid_1'), + AnalyticsTokenCache: coll('tokenId_1'), + AnalyticsTopicCache: coll('topicId_1'), + }); + await m.up(); + assert.deepEqual(dropped.sort(), ['tokenId_1', 'topicId_1', 'uuid_1']); + }); + + it('does not drop when the indexes are absent', async () => { + const dropped = []; + const coll = () => ({ + indexExists: async () => false, + dropIndex: async (name) => { dropped.push(name); }, + }); + const m = makeMigration({ + AnalyticsDocument: coll(), + AnalyticsTokenCache: coll(), + AnalyticsTopicCache: coll(), + }); + await m.up(); + assert.equal(dropped.length, 0); + }); + + it('swallows errors thrown while inspecting a collection', async () => { + const logs = []; + const origLog = console.log; + console.log = (...a) => logs.push(a); + const failing = { + indexExists: async () => { throw new Error('index-boom'); }, + dropIndex: async () => {}, + }; + const m = makeMigration({ + AnalyticsDocument: failing, + AnalyticsTokenCache: failing, + AnalyticsTopicCache: failing, + }); + await m.up(); + console.log = origLog; + assert.equal(logs.length, 3); + }); +}); diff --git a/analytics-service/tests/report-data-dto.test.mjs b/analytics-service/tests/report-data-dto.test.mjs new file mode 100644 index 0000000000..bbc4425ec5 --- /dev/null +++ b/analytics-service/tests/report-data-dto.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { ReportDataDTO, RateDTO } from '../dist/middlewares/validation/schemas/report-data.js'; +import { DataContainerDTO } from '../dist/middlewares/validation/schemas/report-data.js'; + +describe('@unit ReportDataDTO — extended fields', () => { + it('accepts all top-rate fields (RateDTO-shaped)', () => { + const r = new ReportDataDTO(); + const rateFields = [ + 'topSRByUsers', 'topSRByPolicies', 'topTagsByLabel', + 'topAllSchemasByName', 'topSystemSchemasByName', 'topSchemasByName', + 'topModulesByName', 'topPoliciesByName', 'topVersionsByName', + 'topPoliciesByDocuments', 'topPoliciesByDID', 'topPoliciesByVC', + 'topPoliciesByVP', 'topPoliciesByRevoked', + 'topTokensByName', 'topFTokensByName', 'topNFTokensByName', + 'topFTokensByBalance', 'topNFTokensByBalance', + ]; + for (const f of rateFields) { + const rate = new RateDTO(); + rate.name = `${f}-name`; + rate.value = 42; + r[f] = rate; + assert.equal(r[f].name, `${f}-name`, `${f} should be assignable`); + assert.equal(r[f].value, 42); + } + }); + + it('accepts userTopic + fToken/nfToken split fields', () => { + const r = new ReportDataDTO(); + r.userTopic = 5; + r.tokens = 10; + r.fTokens = 7; + r.nfTokens = 3; + r.tags = 12; + r.schemas = 8; + r.systemSchemas = 4; + r.revokeDocuments = 1; + r.fTotalBalances = 1000; + r.nfTotalBalances = 50; + r.topSize = 5; + assert.equal(r.userTopic, 5); + assert.equal(r.tokens, r.fTokens + r.nfTokens); + }); + + it('round-trips through JSON without losing fields', () => { + const r = new ReportDataDTO(); + r.messages = 100; + r.topics = 10; + r.users = 5; + const rate = new RateDTO(); + rate.name = 'top'; + rate.value = 42; + r.topPoliciesByName = rate; + + const json = JSON.parse(JSON.stringify(r)); + assert.equal(json.messages, 100); + assert.equal(json.topics, 10); + assert.deepEqual(json.topPoliciesByName, { name: 'top', value: 42 }); + }); +}); + +describe('@unit DataContainerDTO', () => { + it('constructs with uuid + root + nested ReportDataDTO', () => { + const c = new DataContainerDTO(); + c.uuid = 'r-1'; + c.root = '0.0.1'; + const report = new ReportDataDTO(); + report.messages = 1; + c.report = report; + assert.equal(c.uuid, 'r-1'); + assert.equal(c.root, '0.0.1'); + assert.equal(c.report.messages, 1); + }); + + it('does not throw when report is left unset', () => { + const c = new DataContainerDTO(); + c.uuid = 'r-1'; + c.root = '0.0.1'; + assert.equal(c.report, undefined); + }); +}); diff --git a/analytics-service/tests/swagger-config.test.mjs b/analytics-service/tests/swagger-config.test.mjs new file mode 100644 index 0000000000..cc5ba9cd85 --- /dev/null +++ b/analytics-service/tests/swagger-config.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { SwaggerConfig } from '../dist/helpers/swagger-config.js'; + +describe('@unit SwaggerConfig', () => { + it('is a built DocumentBuilder object (info + servers + … fields)', () => { + assert.equal(typeof SwaggerConfig, 'object'); + assert.ok(SwaggerConfig.info, 'expected info block'); + }); + + it('title is "Guardian"', () => { + assert.equal(SwaggerConfig.info.title, 'Guardian'); + }); + + it('description references Policy Workflow Engine (the product term, not just "guardian")', () => { + assert.match(SwaggerConfig.info.description, /Policy Workflow Engine/); + }); + + it('contact email and url are envisionblockchain.com (until a rebrand updates this)', () => { + assert.equal(SwaggerConfig.info.contact.email, 'info@envisionblockchain.com'); + assert.equal(SwaggerConfig.info.contact.url, 'https://envisionblockchain.com'); + }); + + it('license is Apache 2.0 with the canonical URL', () => { + assert.equal(SwaggerConfig.info.license.name, 'Apache 2.0'); + assert.match(SwaggerConfig.info.license.url, /apache\.org\/licenses\/LICENSE-2\.0/); + }); + + it('declares at least one server with url "/" and version label', () => { + assert.ok(Array.isArray(SwaggerConfig.servers)); + assert.ok(SwaggerConfig.servers.length >= 1); + const root = SwaggerConfig.servers.find((s) => s.url === '/'); + assert.ok(root); + assert.match(root.description, /version/i); + }); + + it('does not bake in API keys or secrets in any field', () => { + const json = JSON.stringify(SwaggerConfig); + for (const re of [/api[_-]?key/i, /password/i, /token=/, /secret/i]) { + assert.equal(re.test(json), false, `Swagger config leaks ${re}`); + } + }); +}); diff --git a/analytics-service/tests/table.test.mjs b/analytics-service/tests/table.test.mjs new file mode 100644 index 0000000000..97840affd5 --- /dev/null +++ b/analytics-service/tests/table.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { Table } from '../dist/helpers/table.js'; + +describe('Table — CSV builder', () => { + it('produces a header line followed by quoted data rows', () => { + const t = new Table('test'); + t.addHeader('name').addHeader('value'); + t.add('alice').add(1); + t.addLine(); + t.add('bob').add(2); + + const csv = t.csv(); + const lines = csv.split('\r\n'); + assert.equal(lines[0], 'name,value'); + assert.equal(lines[1], '"alice","1"'); + assert.equal(lines[2], '"bob","2"'); + }); + + it('records header metadata with row=1 and col index', () => { + const t = new Table(); + t.addHeader('a').addHeader('b'); + assert.equal(t.headers.length, 2); + assert.equal(t.headers[0].row, 1); + assert.equal(t.headers[0].col, 1); + assert.equal(t.headers[1].col, 2); + }); + + it("converts falsy values to '' (toString returns '' for null/undefined/0/'')", () => { + const t = new Table(); + t.add(null).add(undefined).add(0).add(''); + const csv = t.csv(); + // No headers → starts with a leading newline then the data row. + const dataRow = csv.split('\r\n').pop(); + assert.equal(dataRow, '"","","",""'); + }); + + it('clear() resets headers and data', () => { + const t = new Table(); + t.addHeader('h').add('x'); + t.clear(); + assert.equal(t.headers.length, 0); + assert.equal(t.buffer.length, 1); + assert.equal(t.buffer[0].length, 0); + }); + + it('exposes the buffer for direct inspection', () => { + const t = new Table(); + t.add('a'); + t.addLine(); + t.add('b'); + assert.equal(t.buffer.length, 2); + assert.equal(t.buffer[0][0], 'a'); + assert.equal(t.buffer[1][0], 'b'); + }); + + it('preserves the supplied table name', () => { + const t = new Table('analytics-export'); + assert.equal(t.name, 'analytics-export'); + }); +}); diff --git a/analytics-service/tests/tasks.test.mjs b/analytics-service/tests/tasks.test.mjs new file mode 100644 index 0000000000..d0b5c2ce94 --- /dev/null +++ b/analytics-service/tests/tasks.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { Tasks } from '../dist/helpers/tasks.js'; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +describe('Tasks.start', () => { + it('runs the callback once per item, in order', async () => { + const items = [1, 2, 3, 4]; + const seen = []; + const t = new Tasks(items, async (n) => { seen.push(n); }); + await t.start(); + assert.deepEqual(seen, items); + }); + + it('is a no-op for an empty list', async () => { + let count = 0; + const t = new Tasks([], async () => { count++; }); + await t.start(); + assert.equal(count, 0); + }); + + it('propagates an error thrown by the callback', async () => { + const t = new Tasks([1, 2, 3], async (n) => { + if (n === 2) throw new Error('boom'); + }); + await assert.rejects(() => t.start(), /boom/); + }); +}); + +describe('Tasks.run (parallel workers)', () => { + it('processes every truthy item exactly once across workers', async () => { + // items must be truthy: the underlying loop terminates on a falsy next() + const items = Array.from({ length: 20 }, (_, i) => i + 1); + const seen = new Set(); + const t = new Tasks(items, async (n) => { + // small async gap so multiple workers actually overlap + await tick(); + assert.ok(!seen.has(n), `item ${n} processed twice`); + seen.add(n); + }); + await t.run(4); + assert.equal(seen.size, items.length); + }); + + it('stops at the first falsy item (documented limitation of next())', async () => { + // 0 is falsy and terminates the loop; subsequent items are skipped + const items = [1, 2, 0, 3, 4]; + const seen = []; + const t = new Tasks(items, async (n) => { seen.push(n); }); + await t.start(); + assert.deepEqual(seen, [1, 2]); + }); + + it('actually runs callbacks concurrently when count > 1', async () => { + const items = [1, 2, 3, 4, 5, 6]; + let inFlight = 0; + let maxInFlight = 0; + const t = new Tasks(items, async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await tick(); + inFlight--; + }); + await t.run(3); + assert.ok(maxInFlight >= 2, + `expected concurrent execution but maxInFlight was ${maxInFlight}`); + }); + + it('is a no-op when count is 0', async () => { + let calls = 0; + const t = new Tasks([1, 2, 3], async () => { calls++; }); + await t.run(0); + assert.equal(calls, 0); + }); + + it('does not exceed the item count even when count > items.length', async () => { + const items = [1, 2]; + let calls = 0; + const t = new Tasks(items, async () => { calls++; }); + await t.run(10); + assert.equal(calls, 2); + }); +}); diff --git a/api-gateway/tests/auth/auth-user-decorator.test.mjs b/api-gateway/tests/auth/auth-user-decorator.test.mjs new file mode 100644 index 0000000000..30fad36631 --- /dev/null +++ b/api-gateway/tests/auth/auth-user-decorator.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'; +import { AuthUser } from '../../dist/auth/authorization-helper.js'; + +function extractFactory(decoratorFactory) { + class Probe { + handler(_param) {} + } + const paramDecorator = decoratorFactory('user'); + paramDecorator(Probe.prototype, 'handler', 0); + const meta = Reflect.getMetadata(ROUTE_ARGS_METADATA, Probe.constructor, 'handler') + || Reflect.getMetadata(ROUTE_ARGS_METADATA, Probe, 'handler') + || Reflect.getMetadata(ROUTE_ARGS_METADATA, Probe.prototype.constructor, 'handler'); + const entry = Object.values(meta)[0]; + return entry.factory; +} + +function fakeContext(request) { + return { + switchToHttp: () => ({ getRequest: () => request }), + }; +} + +describe('AuthUser param decorator factory', () => { + it('returns the request.user from the execution context', () => { + const factory = extractFactory(AuthUser); + assert.equal(typeof factory, 'function'); + const user = { id: 'u1', username: 'alice' }; + assert.deepEqual(factory('user', fakeContext({ user })), user); + }); + + it('returns undefined when the request has no user', () => { + const factory = extractFactory(AuthUser); + assert.equal(factory('user', fakeContext({})), undefined); + }); + + it('ignores the data argument and always returns request.user', () => { + const factory = extractFactory(AuthUser); + const user = { id: 'u2' }; + assert.deepEqual(factory(undefined, fakeContext({ user })), user); + }); +}); diff --git a/api-gateway/tests/auth/roles-guard.test.mjs b/api-gateway/tests/auth/roles-guard.test.mjs new file mode 100644 index 0000000000..f509eca951 --- /dev/null +++ b/api-gateway/tests/auth/roles-guard.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { RolesGuard, RolesAndLocationGuard } from '../../dist/auth/roles-guard.js'; + +const makeContext = (user) => ({ + switchToHttp: () => ({ getRequest: () => ({ user }) }), + getHandler: () => 'handler', + getClass: () => 'class', +}); + +const makeReflector = (map) => ({ get: (key) => map[key] }); + +describe('RolesGuard', () => { + it('allows access when no permissions are required', () => { + const guard = new RolesGuard(makeReflector({})); + assert.equal(guard.canActivate(makeContext({ permissions: [] })), true); + }); + + it('allows access when the permissions metadata is an empty array', () => { + const guard = new RolesGuard(makeReflector({ permissions: [] })); + assert.equal(guard.canActivate(makeContext({ permissions: ['x'] })), true); + }); + + it('allows access when the user holds a required permission', () => { + const guard = new RolesGuard(makeReflector({ permissions: ['p_a', 'p_b'] })); + assert.equal(guard.canActivate(makeContext({ permissions: ['p_b'] })), true); + }); + + it('denies access when the user lacks every required permission', () => { + const guard = new RolesGuard(makeReflector({ permissions: ['p_a'] })); + assert.equal(guard.canActivate(makeContext({ permissions: ['p_z'] })), false); + }); + + it('denies access when there is no user', () => { + const guard = new RolesGuard(makeReflector({ permissions: ['p_a'] })); + assert.equal(guard.canActivate(makeContext(undefined)), false); + }); + + it('denies access when the user has no permissions array', () => { + const guard = new RolesGuard(makeReflector({ permissions: ['p_a'] })); + assert.equal(guard.canActivate(makeContext({})), false); + }); +}); + +describe('RolesAndLocationGuard', () => { + it('allows access when neither permissions nor locations are required', () => { + const guard = new RolesAndLocationGuard(makeReflector({})); + assert.equal(guard.canActivate(makeContext({})), true); + }); + + it('denies access when there is no user but a requirement exists', () => { + const guard = new RolesAndLocationGuard(makeReflector({ permissions: ['p'] })); + assert.equal(guard.canActivate(makeContext(undefined)), false); + }); + + it('denies access when the user location is not in the allowed locations', () => { + const guard = new RolesAndLocationGuard(makeReflector({ locations: ['LOCAL'], permissions: ['p'] })); + assert.equal(guard.canActivate(makeContext({ location: 'REMOTE', permissions: ['p'] })), false); + }); + + it('allows access when location matches and permission is held', () => { + const guard = new RolesAndLocationGuard(makeReflector({ locations: ['LOCAL'], permissions: ['p'] })); + assert.equal(guard.canActivate(makeContext({ location: 'LOCAL', permissions: ['p'] })), true); + }); + + it('denies access when location matches but permission is missing', () => { + const guard = new RolesAndLocationGuard(makeReflector({ locations: ['LOCAL'], permissions: ['p'] })); + assert.equal(guard.canActivate(makeContext({ location: 'LOCAL', permissions: ['other'] })), false); + }); + + it('allows access on a location-only requirement when the location matches', () => { + const guard = new RolesAndLocationGuard(makeReflector({ locations: ['LOCAL'] })); + assert.equal(guard.canActivate(makeContext({ location: 'LOCAL' })), true); + }); + + it('denies access on a location-only requirement when the location differs', () => { + const guard = new RolesAndLocationGuard(makeReflector({ locations: ['LOCAL'] })); + assert.equal(guard.canActivate(makeContext({ location: 'REMOTE' })), false); + }); + + it('denies access on a permission-only requirement when the user lacks permissions', () => { + const guard = new RolesAndLocationGuard(makeReflector({ permissions: ['p'] })); + assert.equal(guard.canActivate(makeContext({})), false); + }); +}); diff --git a/api-gateway/tests/authorization-helper.test.js b/api-gateway/tests/authorization-helper.test.js new file mode 100644 index 0000000000..f447424b5c --- /dev/null +++ b/api-gateway/tests/authorization-helper.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { checkPermission, permissionHelper } from '../dist/auth/authorization-helper.js'; + +const STANDARD = 'STANDARD_REGISTRY'; +const USER = 'USER'; + +describe('checkPermission', () => { + it('returns a function', () => { + assert.equal(typeof checkPermission(STANDARD), 'function'); + }); + + it('resolves silently when the user has one of the allowed roles', async () => { + const guard = checkPermission(STANDARD, USER); + await guard({ role: USER }); + await guard({ role: STANDARD }); + }); + + it('throws Forbidden (403) when the user has a role not in the list', async () => { + const guard = checkPermission(STANDARD); + await assert.rejects(guard({ role: USER }), (err) => { + assert.ok(err instanceof HttpException); + assert.equal(err.getStatus(), HttpStatus.FORBIDDEN); + return true; + }); + }); + + it('throws Forbidden when the user has no role at all', async () => { + const guard = checkPermission(STANDARD); + await assert.rejects(guard({}), (err) => { + assert.equal(err.getStatus(), HttpStatus.FORBIDDEN); + return true; + }); + }); + + it('throws Unauthorized (401) when the user is missing', async () => { + const guard = checkPermission(STANDARD); + await assert.rejects(guard(null), (err) => { + assert.equal(err.getStatus(), HttpStatus.UNAUTHORIZED); + return true; + }); + await assert.rejects(guard(undefined), (err) => { + assert.equal(err.getStatus(), HttpStatus.UNAUTHORIZED); + return true; + }); + }); +}); + +describe('permissionHelper', () => { + it('calls next() when the user role is allowed', async () => { + let nextCalled = 0; + const middleware = permissionHelper(STANDARD, USER); + await middleware( + { user: { role: USER } }, + {}, + () => { nextCalled++; } + ); + assert.equal(nextCalled, 1); + }); + + it('throws Forbidden when role is not in the allowed list', async () => { + const middleware = permissionHelper(STANDARD); + await assert.rejects( + middleware({ user: { role: USER } }, {}, () => {}), + (err) => { + assert.equal(err.getStatus(), HttpStatus.FORBIDDEN); + return true; + } + ); + }); + + it('throws Forbidden when req.user has no role', async () => { + const middleware = permissionHelper(STANDARD); + await assert.rejects( + middleware({ user: {} }, {}, () => {}), + (err) => { + assert.equal(err.getStatus(), HttpStatus.FORBIDDEN); + return true; + } + ); + }); + + it('throws Unauthorized when there is no user on the request', async () => { + const middleware = permissionHelper(STANDARD); + await assert.rejects( + middleware({}, {}, () => {}), + (err) => { + assert.equal(err.getStatus(), HttpStatus.UNAUTHORIZED); + return true; + } + ); + }); + + it('does not call next() when the role check fails', async () => { + let nextCalled = 0; + const middleware = permissionHelper(STANDARD); + await assert.rejects( + middleware({ user: { role: USER } }, {}, () => { nextCalled++; }) + ); + assert.equal(nextCalled, 0); + }); +}); diff --git a/api-gateway/tests/cache-utils.test.js b/api-gateway/tests/cache-utils.test.js new file mode 100644 index 0000000000..235e3337c5 --- /dev/null +++ b/api-gateway/tests/cache-utils.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { getHash } from '../dist/helpers/interceptors/utils/hash.js'; +import { getCacheKey } from '../dist/helpers/interceptors/utils/cache.js'; + +describe('getHash', () => { + it('produces an md5 hex digest (32 chars)', () => { + const hash = getHash({ id: 'u1', did: 'did:1' }); + assert.match(hash, /^[0-9a-f]{32}$/); + }); + + it('depends only on user.id and user.did', () => { + const a = getHash({ id: 'u1', did: 'did:1', extra: 'ignored' }); + const b = getHash({ id: 'u1', did: 'did:1' }); + assert.equal(a, b); + }); + + it('produces different hashes for different users', () => { + const a = getHash({ id: 'u1', did: 'did:1' }); + const b = getHash({ id: 'u2', did: 'did:2' }); + assert.notEqual(a, b); + }); + + it('handles null/undefined as a stable hash', () => { + const a = getHash(null); + const b = getHash(undefined); + // Both serialize to {id: undefined, did: undefined} → identical hash. + assert.equal(a, b); + }); +}); + +describe('getCacheKey', () => { + it('prefixes each route, decodes URI, and appends user hash', () => { + const keys = getCacheKey(['/api%20path', '/foo'], { id: 'u', did: 'd' }, 'CACHE:'); + assert.equal(keys.length, 2); + assert.match(keys[0], /^CACHE:\/api path:[0-9a-f]{32}$/); + assert.match(keys[1], /^CACHE:\/foo:[0-9a-f]{32}$/); + }); + + it('falls back to original route when decodeURI throws', () => { + // Lone surrogate %ZZ is invalid → decodeURI throws → keep as-is. + const [key] = getCacheKey(['/bad%ZZ'], null, 'CACHE:'); + assert.match(key, /^CACHE:\/bad%ZZ:[0-9a-f]{32}$/); + }); + + it('uses default TAG prefix when not specified', () => { + const [key] = getCacheKey(['/foo'], null); + // Default prefix non-empty. + assert.ok(key.includes('/foo')); + }); + + it('produces different keys for different users on the same route', () => { + const [a] = getCacheKey(['/foo'], { id: 'u1', did: 'd1' }); + const [b] = getCacheKey(['/foo'], { id: 'u2', did: 'd2' }); + assert.notEqual(a, b); + }); +}); diff --git a/api-gateway/tests/dto/_dto-helper.mjs b/api-gateway/tests/dto/_dto-helper.mjs new file mode 100644 index 0000000000..c8155fb16e --- /dev/null +++ b/api-gateway/tests/dto/_dto-helper.mjs @@ -0,0 +1,36 @@ +import { validateSync } from 'class-validator'; + +export const make = (Cls, props) => Object.assign(new Cls(), props); + +export const errorsFor = (Cls, props) => validateSync(make(Cls, props), { whitelist: false }); + +export const constraintKeys = (errs, property) => { + const out = []; + const walk = (list, prefix) => { + for (const e of list) { + const path = prefix ? `${prefix}.${e.property}` : e.property; + if (e.constraints) { + for (const k of Object.keys(e.constraints)) { + out.push({ property: path, key: k }); + } + } + if (e.children && e.children.length) { + walk(e.children, path); + } + } + }; + walk(errs, ''); + if (property) { + return out.filter((o) => o.property === property).map((o) => o.key); + } + return out; +}; + +export const hasConstraint = (errs, property, key) => constraintKeys(errs, property).includes(key); + +export const hasError = (errs, property) => { + const flat = constraintKeys(errs); + return flat.some((o) => o.property === property); +}; + +export const isClean = (errs) => errs.length === 0; diff --git a/api-gateway/tests/dto/accounts-response-dto.test.mjs b/api-gateway/tests/dto/accounts-response-dto.test.mjs new file mode 100644 index 0000000000..9ba8468114 --- /dev/null +++ b/api-gateway/tests/dto/accounts-response-dto.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + PermissionGroupResponseDTO, + AccountsResponseDTO, + AccountsLoginResponseDTO, + AccountsSessionResponseDTO, + LoginSuccessResponseDTO, + LoginOTPRequiredResponseDTO, +} from '../../dist/middlewares/validation/schemas/accounts.js'; + +describe('PermissionGroupResponseDTO', () => { + it('accepts a valid group', () => { + assert.equal(isClean(errorsFor(PermissionGroupResponseDTO, { uuid: 'u', roleId: 'r', roleName: 'Role', owner: 'did' })), true); + }); + + it('accepts a null owner', () => { + assert.equal(isClean(errorsFor(PermissionGroupResponseDTO, { uuid: 'u', roleId: 'r', roleName: 'Role', owner: null })), true); + }); + + it('requires roleId', () => { + assert.equal(hasConstraint(errorsFor(PermissionGroupResponseDTO, { uuid: 'u', roleName: 'Role' }), 'roleId', 'isString'), true); + }); + + it('rejects a non-string uuid', () => { + assert.equal(hasConstraint(errorsFor(PermissionGroupResponseDTO, { uuid: 5, roleId: 'r', roleName: 'Role' }), 'uuid', 'isString'), true); + }); +}); + +describe('AccountsResponseDTO', () => { + const valid = { id: '1', username: 'user', role: 'USER' }; + + it('accepts a minimal account', () => { + assert.equal(isClean(errorsFor(AccountsResponseDTO, valid)), true); + }); + + it('accepts optional permissions and location', () => { + assert.equal(isClean(errorsFor(AccountsResponseDTO, { ...valid, permissions: ['a'], permissionsGroup: [], location: 'local' })), true); + }); + + it('rejects non-string permission entries', () => { + assert.equal(hasConstraint(errorsFor(AccountsResponseDTO, { ...valid, permissions: [1] }), 'permissions', 'isString'), true); + }); + + it('rejects a non-array permissions', () => { + assert.equal(hasConstraint(errorsFor(AccountsResponseDTO, { ...valid, permissions: 'x' }), 'permissions', 'isArray'), true); + }); + + it('rejects a non-string location', () => { + assert.equal(hasConstraint(errorsFor(AccountsResponseDTO, { ...valid, location: 7 }), 'location', 'isString'), true); + }); +}); + +describe('AccountsLoginResponseDTO', () => { + const valid = { username: 'user', did: 'did', role: 'USER', refreshToken: 'token' }; + + it('accepts a valid login response', () => { + assert.equal(isClean(errorsFor(AccountsLoginResponseDTO, valid)), true); + }); + + it('accepts an optional weakPassword flag', () => { + assert.equal(isClean(errorsFor(AccountsLoginResponseDTO, { ...valid, weakPassword: false })), true); + }); + + it('rejects a non-boolean weakPassword', () => { + assert.equal(hasConstraint(errorsFor(AccountsLoginResponseDTO, { ...valid, weakPassword: 'no' }), 'weakPassword', 'isBoolean'), true); + }); + + it('requires refreshToken', () => { + const { refreshToken, ...rest } = valid; + assert.equal(hasError(errorsFor(AccountsLoginResponseDTO, rest), 'refreshToken'), true); + }); +}); + +describe('AccountsSessionResponseDTO', () => { + it('accepts a minimal session', () => { + assert.equal(isClean(errorsFor(AccountsSessionResponseDTO, { id: '1', username: 'user', role: 'USER' })), true); + }); + + it('accepts optional did and hederaAccountId', () => { + const errs = errorsFor(AccountsSessionResponseDTO, { id: '1', username: 'user', role: 'USER', did: 'd', hederaAccountId: '0.0.1' }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-string hederaAccountId', () => { + const errs = errorsFor(AccountsSessionResponseDTO, { id: '1', username: 'user', role: 'USER', hederaAccountId: 5 }); + assert.equal(hasConstraint(errs, 'hederaAccountId', 'isString'), true); + }); + + it('requires username', () => { + assert.equal(hasError(errorsFor(AccountsSessionResponseDTO, { id: '1', role: 'USER' }), 'username'), true); + }); +}); + +describe('LoginSuccessResponseDTO', () => { + const valid = { did: 'd', refreshToken: 't', role: 'USER', username: 'user', weakPassword: 'false' }; + + it('accepts a valid response', () => { + assert.equal(isClean(errorsFor(LoginSuccessResponseDTO, valid)), true); + }); + + it('rejects a boolean weakPassword', () => { + assert.equal(hasConstraint(errorsFor(LoginSuccessResponseDTO, { ...valid, weakPassword: false }), 'weakPassword', 'isString'), true); + }); + + it('requires did', () => { + const { did, ...rest } = valid; + assert.equal(hasError(errorsFor(LoginSuccessResponseDTO, rest), 'did'), true); + }); +}); + +describe('LoginOTPRequiredResponseDTO', () => { + it('accepts a valid response', () => { + assert.equal(isClean(errorsFor(LoginOTPRequiredResponseDTO, { success: false, otprequired: true })), true); + }); + + it('rejects a non-boolean success', () => { + assert.equal(hasConstraint(errorsFor(LoginOTPRequiredResponseDTO, { success: 'no', otprequired: true }), 'success', 'isBoolean'), true); + }); + + it('rejects a non-boolean otprequired', () => { + assert.equal(hasConstraint(errorsFor(LoginOTPRequiredResponseDTO, { success: true, otprequired: 1 }), 'otprequired', 'isBoolean'), true); + }); +}); diff --git a/api-gateway/tests/dto/analytics-compare-dto.test.mjs b/api-gateway/tests/dto/analytics-compare-dto.test.mjs new file mode 100644 index 0000000000..30aed15f08 --- /dev/null +++ b/api-gateway/tests/dto/analytics-compare-dto.test.mjs @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict'; +import { make, errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + ComparePoliciesItemDTO, + ComparePoliciesColumnDTO, + ComparePoliciesPropertyValueDTO, + ComparePoliciesBlockSideDTO, + ComparePoliciesRateEntryDTO, + CompareModulesItemDTO, + CompareModulesSectionDTO, + CompareSchemasItemDTO, + CompareSchemasSectionDTO, + CompareSchemasDTO, +} from '../../dist/middlewares/validation/schemas/analytics.js'; + +const validColumn = () => make(ComparePoliciesColumnDTO, { + name: 'left_name', label: 'Name', type: 'string', +}); + +const validSchemaItem = () => make(CompareSchemasItemDTO, { + id: 'db-id', name: 'Schema', description: 'd', uuid: 'u', version: '1', iri: 'schema:iri', +}); + +const makeSection = () => make(CompareSchemasSectionDTO, { columns: [validColumn()], report: [] }); + +describe('ComparePoliciesItemDTO', () => { + const base = () => ({ id: 'i', name: 'n', description: 'd', type: 'id' }); + + it('accepts a minimal valid item', () => { + assert.equal(isClean(errorsFor(ComparePoliciesItemDTO, base())), true); + }); + + it('accepts optional instanceTopicId and version', () => { + assert.equal(isClean(errorsFor(ComparePoliciesItemDTO, { + ...base(), instanceTopicId: '0.0.1', version: '2', + })), true); + }); + + for (const field of ['id', 'name', 'description', 'type']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesItemDTO, { ...base(), [field]: 1 }), field, 'isString'), true); + }); + } + + it('rejects a non-string instanceTopicId', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesItemDTO, { ...base(), instanceTopicId: 9 }), 'instanceTopicId', 'isString'), true); + }); + + it('rejects a non-string version', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesItemDTO, { ...base(), version: 9 }), 'version', 'isString'), true); + }); +}); + +describe('ComparePoliciesColumnDTO', () => { + it('accepts a valid column without display', () => { + assert.equal(isClean(errorsFor(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't' })), true); + }); + + it('accepts a valid column with display', () => { + assert.equal(isClean(errorsFor(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't', display: 'Rate' })), true); + }); + + for (const field of ['name', 'label', 'type']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't', [field]: 0 }), field, 'isString'), true); + }); + } + + it('rejects a non-string display', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't', display: 1 }), 'display', 'isString'), true); + }); +}); + +describe('ComparePoliciesPropertyValueDTO', () => { + const base = () => ({ name: 'onErrorAction', lvl: 1, path: 'onErrorAction', type: 'property' }); + + it('accepts a valid property value', () => { + assert.equal(isClean(errorsFor(ComparePoliciesPropertyValueDTO, base())), true); + }); + + it('accepts an arbitrary value payload', () => { + assert.equal(isClean(errorsFor(ComparePoliciesPropertyValueDTO, { ...base(), value: { nested: [1, 2] } })), true); + }); + + it('rejects a non-number lvl', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesPropertyValueDTO, { ...base(), lvl: 'one' }), 'lvl', 'isNumber'), true); + }); + + it('rejects a non-string path', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesPropertyValueDTO, { ...base(), path: 4 }), 'path', 'isString'), true); + }); + + it('rejects a missing name', () => { + const props = base(); + delete props.name; + assert.equal(hasError(errorsFor(ComparePoliciesPropertyValueDTO, props), 'name'), true); + }); +}); + +describe('ComparePoliciesBlockSideDTO', () => { + const base = () => ({ + index: 1, + blockType: 'interfaceContainerBlock', + tag: 'Block_1', + properties: [make(ComparePoliciesPropertyValueDTO, { name: 'p', lvl: 1, path: 'p', type: 'property' })], + events: [], + }); + + it('accepts a valid block side', () => { + assert.equal(isClean(errorsFor(ComparePoliciesBlockSideDTO, base())), true); + }); + + it('rejects a non-number index', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesBlockSideDTO, { ...base(), index: 'x' }), 'index', 'isNumber'), true); + }); + + it('rejects a non-array properties', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesBlockSideDTO, { ...base(), properties: {} }), 'properties', 'isArray'), true); + }); + + it('rejects a non-array events', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesBlockSideDTO, { ...base(), events: 'x' }), 'events', 'isArray'), true); + }); + + it('flags an invalid nested property entry', () => { + const bad = make(ComparePoliciesPropertyValueDTO, { name: 'p', lvl: 'NaN', path: 'p', type: 'property' }); + assert.equal(hasConstraint(errorsFor(ComparePoliciesBlockSideDTO, { ...base(), properties: [bad] }), 'properties.0.lvl', 'isNumber'), true); + }); +}); + +describe('ComparePoliciesRateEntryDTO', () => { + const base = () => ({ type: 'FULL', totalRate: 100, items: [] }); + + it('accepts a minimal valid entry', () => { + assert.equal(isClean(errorsFor(ComparePoliciesRateEntryDTO, base())), true); + }); + + it('accepts optional name, path and lvl', () => { + assert.equal(isClean(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), name: 'type', path: 'uiMetaData.type', lvl: 2 })), true); + }); + + it('accepts null entries inside items', () => { + assert.equal(isClean(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), items: [null, { a: 1 }] })), true); + }); + + it('rejects a non-number totalRate', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), totalRate: '100' }), 'totalRate', 'isNumber'), true); + }); + + it('rejects a non-array items', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), items: 'x' }), 'items', 'isArray'), true); + }); + + it('rejects a non-number lvl', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), lvl: 'two' }), 'lvl', 'isNumber'), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(ComparePoliciesRateEntryDTO, { ...base(), name: 7 }), 'name', 'isString'), true); + }); +}); + +describe('CompareModulesItemDTO', () => { + it('accepts a valid item', () => { + assert.equal(isClean(errorsFor(CompareModulesItemDTO, { id: 'i', name: 'Module_1', description: 'd' })), true); + }); + + for (const field of ['id', 'name', 'description']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(CompareModulesItemDTO, { id: 'i', name: 'n', description: 'd', [field]: 3 }), field, 'isString'), true); + }); + } +}); + +describe('CompareModulesSectionDTO', () => { + it('accepts a valid section', () => { + assert.equal(isClean(errorsFor(CompareModulesSectionDTO, { columns: [validColumn()], report: [] })), true); + }); + + it('rejects a non-array columns', () => { + assert.equal(hasConstraint(errorsFor(CompareModulesSectionDTO, { columns: {}, report: [] }), 'columns', 'isArray'), true); + }); + + it('rejects a non-array report', () => { + assert.equal(hasConstraint(errorsFor(CompareModulesSectionDTO, { columns: [validColumn()], report: 'x' }), 'report', 'isArray'), true); + }); + + it('flags an invalid nested column', () => { + const bad = make(ComparePoliciesColumnDTO, { name: 1, label: 'l', type: 't' }); + assert.equal(hasConstraint(errorsFor(CompareModulesSectionDTO, { columns: [bad], report: [] }), 'columns.0.name', 'isString'), true); + }); +}); + +describe('CompareSchemasItemDTO', () => { + it('accepts a valid item without optionals', () => { + assert.equal(isClean(errorsFor(CompareSchemasItemDTO, { + id: 'i', name: 'n', description: 'd', uuid: 'u', version: '1', iri: 'schema:iri', + })), true); + }); + + it('accepts optional topicId and policy', () => { + assert.equal(isClean(errorsFor(CompareSchemasItemDTO, { + id: 'i', name: 'n', description: 'd', uuid: 'u', version: '1', iri: 'iri', + topicId: '0.0.1', policy: { id: 'p' }, + })), true); + }); + + for (const field of ['uuid', 'version', 'iri']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(CompareSchemasItemDTO, { + id: 'i', name: 'n', description: 'd', uuid: 'u', version: '1', iri: 'iri', [field]: 0, + }), field, 'isString'), true); + }); + } + + it('rejects a non-object policy', () => { + assert.equal(hasConstraint(errorsFor(CompareSchemasItemDTO, { + id: 'i', name: 'n', description: 'd', uuid: 'u', version: '1', iri: 'iri', policy: 'x', + }), 'policy', 'isObject'), true); + }); +}); + +describe('CompareSchemasDTO', () => { + it('accepts a fully valid comparison', () => { + const props = { + left: validSchemaItem(), + right: validSchemaItem(), + total: 44, + fields: makeSection(), + }; + assert.equal(isClean(errorsFor(CompareSchemasDTO, props)), true); + }); + + it('rejects a non-number total', () => { + const errs = errorsFor(CompareSchemasDTO, { + left: validSchemaItem(), right: validSchemaItem(), total: 'x', fields: makeSection(), + }); + assert.equal(hasConstraint(errs, 'total', 'isNumber'), true); + }); + + it('rejects a non-object left', () => { + const errs = errorsFor(CompareSchemasDTO, { + left: 'x', right: validSchemaItem(), total: 1, fields: makeSection(), + }); + assert.equal(hasConstraint(errs, 'left', 'isObject'), true); + }); + + it('flags an invalid nested left item', () => { + const bad = validSchemaItem(); + bad.uuid = 5; + const errs = errorsFor(CompareSchemasDTO, { + left: bad, right: validSchemaItem(), total: 1, fields: makeSection(), + }); + assert.equal(hasConstraint(errs, 'left.uuid', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/analytics-dto.test.mjs b/api-gateway/tests/dto/analytics-dto.test.mjs new file mode 100644 index 0000000000..4007914fa6 --- /dev/null +++ b/api-gateway/tests/dto/analytics-dto.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + CompareFileDTO, + FilterPolicyDTO, + FilterPoliciesDTO, + FilterSchemaDTO, + CompareSchemasByIdsRequestDTO, + CompareSchemasByListRequestDTO, + FilterModulesDTO, + CompareDocumentsByIdsRequestDTO, + CompareDocumentsByListRequestDTO, + CompareToolsByListRequestDTO, + FilterSearchPoliciesDTO, + FilterSearchBlocksDTO, + SearchPoliciesDTO, +} from '../../dist/middlewares/validation/schemas/analytics.dto.js'; + +describe('CompareFileDTO', () => { + it('accepts a valid file', () => { + assert.equal(isClean(errorsFor(CompareFileDTO, { id: 'a', name: 'File', value: 'base64' })), true); + }); + + it('requires value', () => { + assert.equal(hasConstraint(errorsFor(CompareFileDTO, { id: 'a', name: 'File' }), 'value', 'isString'), true); + }); + + it('requires name', () => { + assert.equal(hasError(errorsFor(CompareFileDTO, { id: 'a', value: 'v' }), 'name'), true); + }); +}); + +describe('FilterPolicyDTO', () => { + it('accepts an id filter', () => { + assert.equal(isClean(errorsFor(FilterPolicyDTO, { type: 'id', value: 'abc' })), true); + }); + + it('requires type', () => { + assert.equal(hasConstraint(errorsFor(FilterPolicyDTO, { value: 'abc' }), 'type', 'isString'), true); + }); +}); + +describe('FilterPoliciesDTO', () => { + it('accepts an empty filter', () => { + assert.equal(isClean(errorsFor(FilterPoliciesDTO, {})), true); + }); + + it('accepts string level options', () => { + assert.equal(isClean(errorsFor(FilterPoliciesDTO, { idLvl: '1', eventsLvl: 1, propLvl: '2', childrenLvl: 0 })), true); + }); + + it('rejects a boolean idLvl', () => { + assert.equal(hasError(errorsFor(FilterPoliciesDTO, { idLvl: true }), 'idLvl'), true); + }); + + it('rejects a non-array policyIds', () => { + assert.equal(hasConstraint(errorsFor(FilterPoliciesDTO, { policyIds: 'x' }), 'policyIds', 'isArray'), true); + }); + + it('rejects a non-string policyId1', () => { + assert.equal(hasConstraint(errorsFor(FilterPoliciesDTO, { policyId1: 5 }), 'policyId1', 'isString'), true); + }); +}); + +describe('FilterSchemaDTO', () => { + it('accepts a schema id filter', () => { + assert.equal(isClean(errorsFor(FilterSchemaDTO, { type: 'id', value: 'abc' })), true); + }); + + it('accepts a policy as string', () => { + assert.equal(isClean(errorsFor(FilterSchemaDTO, { type: 'policy-message', value: 'abc', policy: 'msg' })), true); + }); + + it('accepts a policy as object', () => { + assert.equal(isClean(errorsFor(FilterSchemaDTO, { type: 'policy-file', value: 'abc', policy: { id: '1' } })), true); + }); + + it('rejects a numeric policy', () => { + assert.equal(hasError(errorsFor(FilterSchemaDTO, { type: 'id', value: 'abc', policy: 5 }), 'policy'), true); + }); +}); + +describe('CompareSchemasByIdsRequestDTO', () => { + it('accepts two schema ids', () => { + assert.equal(isClean(errorsFor(CompareSchemasByIdsRequestDTO, { schemaId1: 'a', schemaId2: 'b' })), true); + }); + + it('accepts a numeric idLvl', () => { + assert.equal(isClean(errorsFor(CompareSchemasByIdsRequestDTO, { schemaId1: 'a', schemaId2: 'b', idLvl: 0 })), true); + }); + + it('requires schemaId2', () => { + assert.equal(hasConstraint(errorsFor(CompareSchemasByIdsRequestDTO, { schemaId1: 'a' }), 'schemaId2', 'isString'), true); + }); +}); + +describe('CompareSchemasByListRequestDTO', () => { + it('accepts a schemas array', () => { + assert.equal(isClean(errorsFor(CompareSchemasByListRequestDTO, { schemas: [] })), true); + }); + + it('rejects a non-array schemas', () => { + assert.equal(hasConstraint(errorsFor(CompareSchemasByListRequestDTO, { schemas: 'x' }), 'schemas', 'isArray'), true); + }); +}); + +describe('FilterModulesDTO', () => { + it('accepts two module ids', () => { + assert.equal(isClean(errorsFor(FilterModulesDTO, { moduleId1: 'a', moduleId2: 'b' })), true); + }); + + it('requires moduleId1', () => { + assert.equal(hasConstraint(errorsFor(FilterModulesDTO, { moduleId2: 'b' }), 'moduleId1', 'isString'), true); + }); +}); + +describe('CompareDocumentsByIdsRequestDTO', () => { + it('accepts two document ids', () => { + assert.equal(isClean(errorsFor(CompareDocumentsByIdsRequestDTO, { documentId1: 'a', documentId2: 'b' })), true); + }); + + it('requires documentId2', () => { + assert.equal(hasConstraint(errorsFor(CompareDocumentsByIdsRequestDTO, { documentId1: 'a' }), 'documentId2', 'isString'), true); + }); +}); + +describe('CompareDocumentsByListRequestDTO', () => { + it('accepts at least two document ids', () => { + assert.equal(isClean(errorsFor(CompareDocumentsByListRequestDTO, { documentIds: ['a', 'b'] })), true); + }); + + it('rejects a single document id', () => { + assert.equal(hasConstraint(errorsFor(CompareDocumentsByListRequestDTO, { documentIds: ['a'] }), 'documentIds', 'arrayMinSize'), true); + }); + + it('rejects a non-array documentIds', () => { + assert.equal(hasConstraint(errorsFor(CompareDocumentsByListRequestDTO, { documentIds: 'a' }), 'documentIds', 'isArray'), true); + }); +}); + +describe('CompareToolsByListRequestDTO', () => { + it('accepts at least two tool ids', () => { + assert.equal(isClean(errorsFor(CompareToolsByListRequestDTO, { toolIds: ['a', 'b', 'c'] })), true); + }); + + it('rejects a single tool id', () => { + assert.equal(hasConstraint(errorsFor(CompareToolsByListRequestDTO, { toolIds: ['a'] }), 'toolIds', 'arrayMinSize'), true); + }); +}); + +describe('FilterSearchPoliciesDTO', () => { + it('accepts an empty filter', () => { + assert.equal(isClean(errorsFor(FilterSearchPoliciesDTO, {})), true); + }); + + it('accepts numeric thresholds', () => { + const errs = errorsFor(FilterSearchPoliciesDTO, { minVcCount: 1, minVpCount: 2, minTokensCount: 3, threshold: 50 }); + assert.equal(isClean(errs), true); + }); + + it('rejects a string minVcCount', () => { + assert.equal(hasConstraint(errorsFor(FilterSearchPoliciesDTO, { minVcCount: '1' }), 'minVcCount', 'isNumber'), true); + }); + + it('rejects a non-string text', () => { + assert.equal(hasConstraint(errorsFor(FilterSearchPoliciesDTO, { text: 5 }), 'text', 'isString'), true); + }); + + it('rejects a non-array toolMessageIds', () => { + assert.equal(hasConstraint(errorsFor(FilterSearchPoliciesDTO, { toolMessageIds: 'x' }), 'toolMessageIds', 'isArray'), true); + }); +}); + +describe('FilterSearchBlocksDTO', () => { + it('accepts a valid search', () => { + assert.equal(isClean(errorsFor(FilterSearchBlocksDTO, { id: 'a', config: {} })), true); + }); + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(FilterSearchBlocksDTO, { id: 'a', config: 'x' }), 'config', 'isObject'), true); + }); + + it('requires id', () => { + assert.equal(hasConstraint(errorsFor(FilterSearchBlocksDTO, { config: {} }), 'id', 'isString'), true); + }); +}); + +describe('SearchPoliciesDTO', () => { + it('accepts a result array', () => { + assert.equal(isClean(errorsFor(SearchPoliciesDTO, { result: [] })), true); + }); + + it('accepts a null-free target object', () => { + assert.equal(isClean(errorsFor(SearchPoliciesDTO, { target: {}, result: [] })), true); + }); + + it('rejects a non-array result', () => { + assert.equal(hasConstraint(errorsFor(SearchPoliciesDTO, { result: {} }), 'result', 'isArray'), true); + }); + + it('rejects a non-object target', () => { + assert.equal(hasConstraint(errorsFor(SearchPoliciesDTO, { target: 'x', result: [] }), 'target', 'isObject'), true); + }); +}); diff --git a/api-gateway/tests/dto/analytics-search-blocks-dto.test.mjs b/api-gateway/tests/dto/analytics-search-blocks-dto.test.mjs new file mode 100644 index 0000000000..9097037261 --- /dev/null +++ b/api-gateway/tests/dto/analytics-search-blocks-dto.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { make, errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + SearchBlocksDTO, + SearchBlocksNodeDTO, + SearchBlocksPairDTO, + SearchBlocksChainDTO, +} from '../../dist/middlewares/validation/schemas/analytics.js'; + +const validNode = () => make(SearchBlocksNodeDTO, { + id: 'node-1', + tag: 'pp_grid_sr', + blockType: 'interfaceDocumentsSourceBlock', + config: { a: 1 }, + path: [0, 1, 0], +}); + +const validPair = () => make(SearchBlocksPairDTO, { + hash: 100, + source: validNode(), + filter: validNode(), +}); + +const validChain = () => make(SearchBlocksChainDTO, { + hash: 12099, + target: validNode(), + pairs: [validPair()], +}); + +describe('SearchBlocksNodeDTO', () => { + it('accepts a fully valid node', () => { + assert.equal(isClean(errorsFor(SearchBlocksNodeDTO, { + id: 'n', tag: 't', blockType: 'b', config: {}, path: [], + })), true); + }); + + for (const field of ['id', 'tag', 'blockType']) { + it(`rejects a non-string ${field}`, () => { + const errs = errorsFor(SearchBlocksNodeDTO, { + id: 'n', tag: 't', blockType: 'b', config: {}, path: [], [field]: 5, + }); + assert.equal(hasConstraint(errs, field, 'isString'), true); + }); + } + + it('rejects a non-object config', () => { + const errs = errorsFor(SearchBlocksNodeDTO, { + id: 'n', tag: 't', blockType: 'b', config: 'x', path: [], + }); + assert.equal(hasConstraint(errs, 'config', 'isObject'), true); + }); + + it('rejects a non-array path', () => { + const errs = errorsFor(SearchBlocksNodeDTO, { + id: 'n', tag: 't', blockType: 'b', config: {}, path: 'x', + }); + assert.equal(hasConstraint(errs, 'path', 'isArray'), true); + }); + + it('rejects path entries that are not numbers', () => { + const errs = errorsFor(SearchBlocksNodeDTO, { + id: 'n', tag: 't', blockType: 'b', config: {}, path: [0, 'one'], + }); + assert.equal(hasConstraint(errs, 'path', 'isNumber'), true); + }); + + it('rejects a missing id', () => { + const errs = errorsFor(SearchBlocksNodeDTO, { + tag: 't', blockType: 'b', config: {}, path: [], + }); + assert.equal(hasError(errs, 'id'), true); + }); +}); + +describe('SearchBlocksPairDTO', () => { + it('accepts a valid pair', () => { + assert.equal(isClean(errorsFor(SearchBlocksPairDTO, { + hash: 1, source: validNode(), filter: validNode(), + })), true); + }); + + it('rejects a non-number hash', () => { + const errs = errorsFor(SearchBlocksPairDTO, { + hash: 'x', source: validNode(), filter: validNode(), + }); + assert.equal(hasConstraint(errs, 'hash', 'isNumber'), true); + }); + + it('flags an invalid nested source node', () => { + const bad = validNode(); + bad.id = 7; + const errs = errorsFor(SearchBlocksPairDTO, { + hash: 1, source: bad, filter: validNode(), + }); + assert.equal(hasConstraint(errs, 'source.id', 'isString'), true); + }); + + it('flags an invalid nested filter node', () => { + const bad = validNode(); + bad.path = 'not-array'; + const errs = errorsFor(SearchBlocksPairDTO, { + hash: 1, source: validNode(), filter: bad, + }); + assert.equal(hasConstraint(errs, 'filter.path', 'isArray'), true); + }); + + it('rejects a missing hash', () => { + const errs = errorsFor(SearchBlocksPairDTO, { + source: validNode(), filter: validNode(), + }); + assert.equal(hasError(errs, 'hash'), true); + }); +}); + +describe('SearchBlocksChainDTO', () => { + it('accepts a valid chain', () => { + assert.equal(isClean(errorsFor(SearchBlocksChainDTO, { + hash: 1, target: validNode(), pairs: [validPair()], + })), true); + }); + + it('accepts an empty pairs array', () => { + assert.equal(isClean(errorsFor(SearchBlocksChainDTO, { + hash: 1, target: validNode(), pairs: [], + })), true); + }); + + it('rejects a non-number hash', () => { + const errs = errorsFor(SearchBlocksChainDTO, { + hash: {}, target: validNode(), pairs: [], + }); + assert.equal(hasConstraint(errs, 'hash', 'isNumber'), true); + }); + + it('rejects a non-array pairs', () => { + const errs = errorsFor(SearchBlocksChainDTO, { + hash: 1, target: validNode(), pairs: 'x', + }); + assert.equal(hasConstraint(errs, 'pairs', 'isArray'), true); + }); + + it('flags an invalid pair inside the array', () => { + const bad = validPair(); + bad.hash = 'oops'; + const errs = errorsFor(SearchBlocksChainDTO, { + hash: 1, target: validNode(), pairs: [bad], + }); + assert.equal(hasConstraint(errs, 'pairs.0.hash', 'isNumber'), true); + }); +}); + +describe('SearchBlocksDTO', () => { + const validProps = () => ({ + name: 'CDM AMS-III.AR Policy', + description: 'desc', + version: '1', + owner: 'did:hedera:testnet:abc', + topicId: '0.0.1', + messageId: '1706823227.586179534', + hash: 12099, + chains: [validChain()], + }); + + it('accepts a fully valid payload', () => { + assert.equal(isClean(errorsFor(SearchBlocksDTO, validProps())), true); + }); + + for (const field of ['name', 'description', 'version', 'owner', 'topicId', 'messageId']) { + it(`rejects a non-string ${field}`, () => { + const errs = errorsFor(SearchBlocksDTO, { ...validProps(), [field]: 42 }); + assert.equal(hasConstraint(errs, field, 'isString'), true); + }); + } + + it('rejects a non-number hash', () => { + const errs = errorsFor(SearchBlocksDTO, { ...validProps(), hash: 'x' }); + assert.equal(hasConstraint(errs, 'hash', 'isNumber'), true); + }); + + it('rejects a non-array chains', () => { + const errs = errorsFor(SearchBlocksDTO, { ...validProps(), chains: {} }); + assert.equal(hasConstraint(errs, 'chains', 'isArray'), true); + }); + + it('flags an invalid chain entry', () => { + const bad = validChain(); + bad.hash = null; + const errs = errorsFor(SearchBlocksDTO, { ...validProps(), chains: [bad] }); + assert.equal(hasConstraint(errs, 'chains.0.hash', 'isNumber'), true); + }); + + it('rejects a missing name', () => { + const props = validProps(); + delete props.name; + assert.equal(hasError(errorsFor(SearchBlocksDTO, props), 'name'), true); + }); +}); diff --git a/api-gateway/tests/dto/demo-dto.test.mjs b/api-gateway/tests/dto/demo-dto.test.mjs new file mode 100644 index 0000000000..2030f6e70c --- /dev/null +++ b/api-gateway/tests/dto/demo-dto.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + DemoKeyResponseDTO, + PolicyRoleDTO, + RegisteredUserDTO, +} from '../../dist/middlewares/validation/schemas/demo.js'; + +describe('DemoKeyResponseDTO', () => { + it('accepts a valid key response', () => { + assert.equal(isClean(errorsFor(DemoKeyResponseDTO, { id: '0.0.1', key: '302e' })), true); + }); + + it('requires id', () => { + assert.equal(hasConstraint(errorsFor(DemoKeyResponseDTO, { key: 'k' }), 'id', 'isString'), true); + }); + + it('rejects a non-string key', () => { + assert.equal(hasConstraint(errorsFor(DemoKeyResponseDTO, { id: '0.0.1', key: 5 }), 'key', 'isString'), true); + }); +}); + +describe('PolicyRoleDTO', () => { + it('accepts a valid policy role', () => { + assert.equal(isClean(errorsFor(PolicyRoleDTO, { name: 'Policy', version: '1.0.0', role: 'VVB' })), true); + }); + + it('requires version', () => { + assert.equal(hasConstraint(errorsFor(PolicyRoleDTO, { name: 'Policy', role: 'VVB' }), 'version', 'isString'), true); + }); + + it('rejects a non-string role', () => { + assert.equal(hasConstraint(errorsFor(PolicyRoleDTO, { name: 'n', version: 'v', role: 1 }), 'role', 'isString'), true); + }); +}); + +describe('RegisteredUserDTO', () => { + const valid = { did: 'did:hedera:1', username: 'user', role: 'STANDARD_REGISTRY', policyRoles: [] }; + + it('accepts a valid registered user', () => { + assert.equal(isClean(errorsFor(RegisteredUserDTO, valid)), true); + }); + + it('accepts an optional parent', () => { + assert.equal(isClean(errorsFor(RegisteredUserDTO, { ...valid, parent: 'did:hedera:2' })), true); + }); + + it('rejects an empty username', () => { + assert.equal(hasConstraint(errorsFor(RegisteredUserDTO, { ...valid, username: '' }), 'username', 'isNotEmpty'), true); + }); + + it('rejects a non-array policyRoles', () => { + assert.equal(hasConstraint(errorsFor(RegisteredUserDTO, { ...valid, policyRoles: {} }), 'policyRoles', 'isArray'), true); + }); + + it('rejects a non-string parent', () => { + assert.equal(hasConstraint(errorsFor(RegisteredUserDTO, { ...valid, parent: 7 }), 'parent', 'isString'), true); + }); + + it('requires did', () => { + const { did, ...rest } = valid; + assert.equal(hasError(errorsFor(RegisteredUserDTO, rest), 'did'), true); + }); + + it('requires role', () => { + const { role, ...rest } = valid; + assert.equal(hasError(errorsFor(RegisteredUserDTO, rest), 'role'), true); + }); +}); diff --git a/api-gateway/tests/dto/errors-dto.test.mjs b/api-gateway/tests/dto/errors-dto.test.mjs new file mode 100644 index 0000000000..c9dc1b7bb3 --- /dev/null +++ b/api-gateway/tests/dto/errors-dto.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + InternalServerErrorDTO, + ServiceUnavailableErrorDTO, + UnprocessableEntityErrorDTO, + UnauthorizedErrorDTO, + ForbiddenErrorDTO, + ConflictErrorDTO, + NotFoundErrorDTO, + BadRequestErrorDTO, +} from '../../dist/middlewares/validation/schemas/errors.js'; + +describe('InternalServerErrorDTO', () => { + it('accepts a valid error', () => { + assert.equal(isClean(errorsFor(InternalServerErrorDTO, { statusCode: 500, message: 'boom' })), true); + }); + + it('rejects a non-number statusCode', () => { + assert.equal(hasConstraint(errorsFor(InternalServerErrorDTO, { statusCode: '500', message: 'm' }), 'statusCode', 'isNumber'), true); + }); + + it('requires message', () => { + assert.equal(hasConstraint(errorsFor(InternalServerErrorDTO, { statusCode: 500 }), 'message', 'isString'), true); + }); +}); + +describe('ServiceUnavailableErrorDTO', () => { + it('accepts a valid error', () => { + assert.equal(isClean(errorsFor(ServiceUnavailableErrorDTO, { statusCode: 503, message: 'down' })), true); + }); + + it('rejects a string statusCode', () => { + assert.equal(hasConstraint(errorsFor(ServiceUnavailableErrorDTO, { statusCode: 'x', message: 'm' }), 'statusCode', 'isNumber'), true); + }); +}); + +describe('UnprocessableEntityErrorDTO', () => { + it('accepts a string message', () => { + assert.equal(isClean(errorsFor(UnprocessableEntityErrorDTO, { statusCode: 422, message: 'bad' })), true); + }); + + it('accepts an array message', () => { + assert.equal(isClean(errorsFor(UnprocessableEntityErrorDTO, { statusCode: 422, message: ['a', 'b'] })), true); + }); + + it('accepts an optional error label', () => { + assert.equal(isClean(errorsFor(UnprocessableEntityErrorDTO, { statusCode: 422, message: 'm', error: 'Unprocessable Entity' })), true); + }); + + it('rejects a non-string error label', () => { + assert.equal(hasConstraint(errorsFor(UnprocessableEntityErrorDTO, { statusCode: 422, message: 'm', error: 1 }), 'error', 'isString'), true); + }); +}); + +describe('UnauthorizedErrorDTO', () => { + it('accepts a valid error', () => { + assert.equal(isClean(errorsFor(UnauthorizedErrorDTO, { statusCode: 401, message: 'Unauthorized' })), true); + }); + + it('flags both fields when empty', () => { + const errs = errorsFor(UnauthorizedErrorDTO, {}); + assert.equal(hasError(errs, 'statusCode'), true); + assert.equal(hasError(errs, 'message'), true); + }); +}); + +describe('ForbiddenErrorDTO', () => { + it('accepts a valid error without label', () => { + assert.equal(isClean(errorsFor(ForbiddenErrorDTO, { statusCode: 403, message: 'Forbidden resource' })), true); + }); + + it('accepts an optional error label', () => { + assert.equal(isClean(errorsFor(ForbiddenErrorDTO, { statusCode: 403, message: 'm', error: 'Forbidden' })), true); + }); + + it('rejects a non-string error label', () => { + assert.equal(hasConstraint(errorsFor(ForbiddenErrorDTO, { statusCode: 403, message: 'm', error: {} }), 'error', 'isString'), true); + }); +}); + +describe('ConflictErrorDTO', () => { + it('accepts a valid error', () => { + assert.equal(isClean(errorsFor(ConflictErrorDTO, { statusCode: 409, message: 'Conflict' })), true); + }); + + it('rejects a non-string message', () => { + assert.equal(hasConstraint(errorsFor(ConflictErrorDTO, { statusCode: 409, message: 1 }), 'message', 'isString'), true); + }); +}); + +describe('NotFoundErrorDTO', () => { + it('accepts a valid error', () => { + assert.equal(isClean(errorsFor(NotFoundErrorDTO, { statusCode: 404, message: 'missing' })), true); + }); + + it('requires statusCode', () => { + assert.equal(hasConstraint(errorsFor(NotFoundErrorDTO, { message: 'm' }), 'statusCode', 'isNumber'), true); + }); +}); + +describe('BadRequestErrorDTO', () => { + it('accepts a string message', () => { + assert.equal(isClean(errorsFor(BadRequestErrorDTO, { statusCode: 400, message: 'bad' })), true); + }); + + it('accepts an array message', () => { + assert.equal(isClean(errorsFor(BadRequestErrorDTO, { statusCode: 400, message: ['a'] })), true); + }); + + it('rejects a non-string error label', () => { + assert.equal(hasConstraint(errorsFor(BadRequestErrorDTO, { statusCode: 400, message: 'm', error: 0 }), 'error', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/external-policies-dto.test.mjs b/api-gateway/tests/dto/external-policies-dto.test.mjs new file mode 100644 index 0000000000..0c2d4e20c7 --- /dev/null +++ b/api-gateway/tests/dto/external-policies-dto.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, isClean } from './_dto-helper.mjs'; +import { + ExternalPolicyDTO, + PolicyRequestDTO, + PolicyRequestCountDTO, +} from '../../dist/middlewares/validation/schemas/external-policies.dto.js'; + +describe('ExternalPolicyDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(ExternalPolicyDTO, {})), true); + }); + + it('accepts a fully populated payload', () => { + const errs = errorsFor(ExternalPolicyDTO, { + uuid: 'u', name: 'n', description: 'd', version: '1.0.0', topicId: '0.0.1', + instanceTopicId: '0.0.2', messageId: 'm', policyTag: 't', owner: 'o', + status: 'NEW', username: 'user', + }); + assert.equal(isClean(errs), true); + }); + + for (const field of ['uuid', 'name', 'description', 'version', 'topicId', 'instanceTopicId', 'messageId', 'policyTag', 'owner', 'status', 'username']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(ExternalPolicyDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('PolicyRequestDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyRequestDTO, {})), true); + }); + + it('accepts a populated payload', () => { + const errs = errorsFor(PolicyRequestDTO, { + uuid: 'u', type: 'ACTION', messageId: 'm', startMessageId: 's', status: 'NEW', + lastStatus: 'NEW', accountId: '0.0.1', sender: '0.0.2', owner: 'o', topicId: '0.0.3', + document: {}, policyId: 'p', blockTag: 'b', policyMessageId: 'pm', loaded: true, + }); + assert.equal(isClean(errs), true); + }); + + for (const field of ['uuid', 'type', 'messageId', 'startMessageId', 'status', 'lastStatus', 'accountId', 'sender', 'owner', 'topicId', 'policyId', 'blockTag', 'policyMessageId']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyRequestDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(PolicyRequestDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + + it('rejects a non-boolean loaded', () => { + assert.equal(hasConstraint(errorsFor(PolicyRequestDTO, { loaded: 'x' }), 'loaded', 'isBoolean'), true); + }); +}); + +describe('PolicyRequestCountDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyRequestCountDTO, {})), true); + }); + + it('accepts valid numeric counts', () => { + assert.equal(isClean(errorsFor(PolicyRequestCountDTO, { requestsCount: 1, actionsCount: 2, delayCount: 0, total: 3 })), true); + }); + + for (const field of ['requestsCount', 'actionsCount', 'delayCount', 'total']) { + it(`rejects a non-number ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyRequestCountDTO, { [field]: 'x' }), field, 'isNumber'), true); + }); + } +}); diff --git a/api-gateway/tests/dto/formulas-dto.test.mjs b/api-gateway/tests/dto/formulas-dto.test.mjs new file mode 100644 index 0000000000..d708eef5a0 --- /dev/null +++ b/api-gateway/tests/dto/formulas-dto.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + FormulaDTO, + FormulaRelationshipsDTO, + FormulasOptionsDTO, + FormulasDataDTO, +} from '../../dist/middlewares/validation/schemas/formulas.dto.js'; + +describe('FormulaDTO', () => { + it('accepts a minimal valid formula (name only)', () => { + assert.equal(isClean(errorsFor(FormulaDTO, { name: 'f' })), true); + }); + + it('accepts a fully populated formula', () => { + const errs = errorsFor(FormulaDTO, { + id: 'i', uuid: 'u', name: 'n', description: 'd', creator: 'c', owner: 'o', + messageId: 'm', policyId: 'p', policyTopicId: '0.0.1', policyInstanceTopicId: '0.0.2', + status: 'DRAFT', config: {}, + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing name', () => { + assert.equal(hasError(errorsFor(FormulaDTO, {}), 'name'), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(FormulaDTO, { name: 5 }), 'name', 'isString'), true); + }); + + for (const field of ['uuid', 'description', 'creator', 'owner', 'messageId', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'status']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(FormulaDTO, { name: 'n', [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(FormulaDTO, { name: 'n', config: 'x' }), 'config', 'isObject'), true); + }); +}); + +describe('FormulaRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(FormulaRelationshipsDTO, {})), true); + }); + + it('rejects a non-object policy', () => { + assert.equal(hasConstraint(errorsFor(FormulaRelationshipsDTO, { policy: 'x' }), 'policy', 'isObject'), true); + }); + + it('rejects a non-array schemas', () => { + assert.equal(hasConstraint(errorsFor(FormulaRelationshipsDTO, { schemas: 'x' }), 'schemas', 'isArray'), true); + }); + + it('rejects a non-object formulas', () => { + assert.equal(hasConstraint(errorsFor(FormulaRelationshipsDTO, { formulas: 'x' }), 'formulas', 'isObject'), true); + }); +}); + +describe('FormulasOptionsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(FormulasOptionsDTO, {})), true); + }); + + for (const field of ['policyId', 'schemaId', 'documentId', 'parentId']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(FormulasOptionsDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('FormulasDataDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(FormulasDataDTO, {})), true); + }); + + it('rejects a non-array formulas', () => { + assert.equal(hasConstraint(errorsFor(FormulasDataDTO, { formulas: 'x' }), 'formulas', 'isArray'), true); + }); + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(FormulasDataDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(FormulasDataDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); + + it('rejects a non-array schemas', () => { + assert.equal(hasConstraint(errorsFor(FormulasDataDTO, { schemas: 'x' }), 'schemas', 'isArray'), true); + }); +}); diff --git a/api-gateway/tests/dto/mock-dto.test.mjs b/api-gateway/tests/dto/mock-dto.test.mjs new file mode 100644 index 0000000000..dec08e456a --- /dev/null +++ b/api-gateway/tests/dto/mock-dto.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, isClean } from './_dto-helper.mjs'; +import { + MockBlockConfigDTO, + MockConfigDTO, + MockIpfsDataDTO, + MockTopicTransactionDTO, + MockMessageTransactionDTO, + MockTopicDataDTO, + MockTokenDataDTO, + MockRequestConfigDTO, + MockDataDTO, + MockIpfsRequestDTO, +} from '../../dist/middlewares/validation/schemas/mock.dto.js'; + +describe('MockBlockConfigDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(MockBlockConfigDTO, {})), true); + }); + + it('accepts a valid block config', () => { + assert.equal(isClean(errorsFor(MockBlockConfigDTO, { uuid: 'u', enabled: true })), true); + }); + + it('rejects a non-boolean enabled', () => { + assert.equal(hasConstraint(errorsFor(MockBlockConfigDTO, { enabled: 'yes' }), 'enabled', 'isBoolean'), true); + }); + + it('rejects a non-string uuid', () => { + assert.equal(hasConstraint(errorsFor(MockBlockConfigDTO, { uuid: 5 }), 'uuid', 'isString'), true); + }); +}); + +describe('MockConfigDTO', () => { + it('accepts a valid config', () => { + assert.equal(isClean(errorsFor(MockConfigDTO, { enabled: false, blocks: [] })), true); + }); + + it('rejects a non-array blocks', () => { + assert.equal(hasConstraint(errorsFor(MockConfigDTO, { blocks: {} }), 'blocks', 'isArray'), true); + }); +}); + +describe('MockIpfsDataDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(MockIpfsDataDTO, {})), true); + }); + + it('rejects a non-string cid', () => { + assert.equal(hasConstraint(errorsFor(MockIpfsDataDTO, { cid: 5 }), 'cid', 'isString'), true); + }); +}); + +describe('MockTopicTransactionDTO', () => { + it('accepts a valid topic transaction', () => { + const errs = errorsFor(MockTopicTransactionDTO, { id: '0.0.1', memo: 'memo', payer_account_id: '0.0.2', topic_id: '0.0.3' }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-string payer_account_id', () => { + assert.equal(hasConstraint(errorsFor(MockTopicTransactionDTO, { payer_account_id: 5 }), 'payer_account_id', 'isString'), true); + }); +}); + +describe('MockMessageTransactionDTO', () => { + it('accepts a valid message transaction', () => { + const errs = errorsFor(MockMessageTransactionDTO, { + consensus_timestamp: '1', + id: '2', + message: 'base64', + payer_account_id: '0.0.1', + sequence_number: 3, + topicId: '0.0.2', + topic_id: '0.0.2', + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-number sequence_number', () => { + assert.equal(hasConstraint(errorsFor(MockMessageTransactionDTO, { sequence_number: 'x' }), 'sequence_number', 'isNumber'), true); + }); + + it('rejects a non-string message', () => { + assert.equal(hasConstraint(errorsFor(MockMessageTransactionDTO, { message: 1 }), 'message', 'isString'), true); + }); +}); + +describe('MockTopicDataDTO', () => { + it('accepts a valid topic data', () => { + assert.equal(isClean(errorsFor(MockTopicDataDTO, { topicId: '0.0.1', topic: {}, messages: [] })), true); + }); + + it('rejects a non-object topic', () => { + assert.equal(hasConstraint(errorsFor(MockTopicDataDTO, { topic: 'x' }), 'topic', 'isObject'), true); + }); + + it('rejects a non-array messages', () => { + assert.equal(hasConstraint(errorsFor(MockTopicDataDTO, { messages: {} }), 'messages', 'isArray'), true); + }); +}); + +describe('MockTokenDataDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(MockTokenDataDTO, {})), true); + }); + + it('rejects a non-boolean admin_key', () => { + assert.equal(hasConstraint(errorsFor(MockTokenDataDTO, { admin_key: 'x' }), 'admin_key', 'isBoolean'), true); + }); +}); + +describe('MockRequestConfigDTO', () => { + it('accepts a valid request config', () => { + assert.equal(isClean(errorsFor(MockRequestConfigDTO, { method: 'GET', responseType: 'JSON', url: 'http://localhost/' })), true); + }); + + it('rejects a non-string method', () => { + assert.equal(hasConstraint(errorsFor(MockRequestConfigDTO, { method: 1 }), 'method', 'isString'), true); + }); +}); + +describe('MockDataDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(MockDataDTO, {})), true); + }); + + it('accepts arrays for all sections', () => { + assert.equal(isClean(errorsFor(MockDataDTO, { ipfs: [], topics: [], tokens: [], api: [], users: [] })), true); + }); + + it('rejects a non-array ipfs', () => { + assert.equal(hasConstraint(errorsFor(MockDataDTO, { ipfs: {} }), 'ipfs', 'isArray'), true); + }); + + it('rejects a non-array users', () => { + assert.equal(hasConstraint(errorsFor(MockDataDTO, { users: 'x' }), 'users', 'isArray'), true); + }); +}); + +describe('MockIpfsRequestDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(MockIpfsRequestDTO, {})), true); + }); + + it('rejects a non-string cid', () => { + assert.equal(hasConstraint(errorsFor(MockIpfsRequestDTO, { cid: 1 }), 'cid', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/policies-core-dto.test.mjs b/api-gateway/tests/dto/policies-core-dto.test.mjs new file mode 100644 index 0000000000..16bb0f94bf --- /dev/null +++ b/api-gateway/tests/dto/policies-core-dto.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean, make } from './_dto-helper.mjs'; +import { + PolicyTestDTO, + PolicyToolDTO, + PolicyImportantParametersDTO, + PolicyDTO, + PolicyPreviewDTO, + PoliciesValidationDTO, +} from '../../dist/middlewares/validation/schemas/policies.dto.js'; + +describe('PolicyTestDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(PolicyTestDTO, {})), true); + }); + + it('accepts a fully populated test', () => { + const errs = errorsFor(PolicyTestDTO, { + id: '1', + uuid: 'u', + name: 'n', + policyId: 'p', + owner: 'o', + status: 'New', + date: '2024-01-01', + duration: 10, + progress: 50, + resultId: 'r', + result: {}, + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(PolicyTestDTO, { name: 5 }), 'name', 'isString'), true); + }); + + it('rejects a non-number duration', () => { + assert.equal(hasConstraint(errorsFor(PolicyTestDTO, { duration: 'x' }), 'duration', 'isNumber'), true); + }); + + it('rejects a non-object result', () => { + assert.equal(hasConstraint(errorsFor(PolicyTestDTO, { result: 'x' }), 'result', 'isObject'), true); + }); +}); + +describe('PolicyToolDTO', () => { + it('accepts a valid tool reference', () => { + const errs = errorsFor(PolicyToolDTO, { name: 'Tool', version: '1.0.0', topicId: '0.0.1', messageId: 'm' }); + assert.equal(isClean(errs), true); + }); + + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(PolicyToolDTO, {})), true); + }); + + it('rejects a non-string version', () => { + assert.equal(hasConstraint(errorsFor(PolicyToolDTO, { version: 1 }), 'version', 'isString'), true); + }); + + it('rejects a non-string topicId', () => { + assert.equal(hasConstraint(errorsFor(PolicyToolDTO, { topicId: 1 }), 'topicId', 'isString'), true); + }); +}); + +describe('PolicyDTO', () => { + it('accepts an empty policy', () => { + assert.equal(isClean(errorsFor(PolicyDTO, {})), true); + }); + + it('accepts valid nested importantParameters', () => { + const nested = make(PolicyImportantParametersDTO, { atValidation: 'a', monitored: 'b' }); + assert.equal(isClean(errorsFor(PolicyDTO, { importantParameters: nested })), true); + }); + + it('rejects invalid nested importantParameters values', () => { + const nested = make(PolicyImportantParametersDTO, { atValidation: 5 }); + const errs = errorsFor(PolicyDTO, { importantParameters: nested }); + assert.equal(hasConstraint(errs, 'importantParameters.atValidation', 'isString'), true); + }); + + it('rejects a non-array tools value', () => { + assert.equal(hasConstraint(errorsFor(PolicyDTO, { tools: 'x' }), 'tools', 'isArray'), true); + }); + + it('rejects a non-boolean originalChanged', () => { + assert.equal(hasConstraint(errorsFor(PolicyDTO, { originalChanged: 'yes' }), 'originalChanged', 'isBoolean'), true); + }); + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(PolicyDTO, { config: 'x' }), 'config', 'isObject'), true); + }); + + it('rejects a non-array userRoles', () => { + assert.equal(hasConstraint(errorsFor(PolicyDTO, { userRoles: 'Installer' }), 'userRoles', 'isArray'), true); + }); + + it('rejects a non-string status', () => { + assert.equal(hasConstraint(errorsFor(PolicyDTO, { status: 7 }), 'status', 'isString'), true); + }); +}); + +describe('PolicyPreviewDTO', () => { + it('accepts a valid preview', () => { + const errs = errorsFor(PolicyPreviewDTO, { module: {}, messageId: '0.0.1' }); + assert.equal(isClean(errs), true); + }); + + it('requires messageId', () => { + assert.equal(hasConstraint(errorsFor(PolicyPreviewDTO, { module: {} }), 'messageId', 'isString'), true); + }); + + it('rejects a non-object module', () => { + assert.equal(hasConstraint(errorsFor(PolicyPreviewDTO, { module: 'x', messageId: 'm' }), 'module', 'isObject'), true); + }); + + it('rejects a non-array schemas', () => { + const errs = errorsFor(PolicyPreviewDTO, { module: {}, messageId: 'm', schemas: 'x' }); + assert.equal(hasConstraint(errs, 'schemas', 'isArray'), true); + }); +}); + +describe('PoliciesValidationDTO', () => { + it('accepts a valid validation result', () => { + const errs = errorsFor(PoliciesValidationDTO, { policies: [], isValid: true, errors: {} }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-array policies', () => { + const errs = errorsFor(PoliciesValidationDTO, { policies: {}, isValid: true, errors: {} }); + assert.equal(hasConstraint(errs, 'policies', 'isArray'), true); + }); + + it('rejects a non-boolean isValid', () => { + const errs = errorsFor(PoliciesValidationDTO, { policies: [], isValid: 'true', errors: {} }); + assert.equal(hasConstraint(errs, 'isValid', 'isBoolean'), true); + }); + + it('requires errors object', () => { + const errs = errorsFor(PoliciesValidationDTO, { policies: [], isValid: false }); + assert.equal(hasError(errs, 'errors'), true); + }); +}); diff --git a/api-gateway/tests/dto/policies-misc-dto.test.mjs b/api-gateway/tests/dto/policies-misc-dto.test.mjs new file mode 100644 index 0000000000..248ee41921 --- /dev/null +++ b/api-gateway/tests/dto/policies-misc-dto.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + IgnoreRuleDTO, + PolicyVersionDTO, + PolicyCategoryDTO, + DeleteSavepointsDTO, + DeleteSavepointsResultDTO, + DebugBlockDataDTO, +} from '../../dist/middlewares/validation/schemas/policies.dto.js'; + +describe('IgnoreRuleDTO', () => { + it('accepts an empty rule', () => { + assert.equal(isClean(errorsFor(IgnoreRuleDTO, {})), true); + }); + + it('accepts severity warning', () => { + assert.equal(isClean(errorsFor(IgnoreRuleDTO, { severity: 'warning' })), true); + }); + + it('accepts severity info', () => { + assert.equal(isClean(errorsFor(IgnoreRuleDTO, { severity: 'info' })), true); + }); + + it('rejects an unknown severity', () => { + assert.equal(hasConstraint(errorsFor(IgnoreRuleDTO, { severity: 'error' }), 'severity', 'isIn'), true); + }); + + it('rejects a non-string code', () => { + assert.equal(hasConstraint(errorsFor(IgnoreRuleDTO, { code: 5 }), 'code', 'isString'), true); + }); + + it('rejects a non-string contains', () => { + assert.equal(hasConstraint(errorsFor(IgnoreRuleDTO, { contains: {} }), 'contains', 'isString'), true); + }); +}); + +describe('PolicyVersionDTO', () => { + it('accepts a valid version', () => { + assert.equal(isClean(errorsFor(PolicyVersionDTO, { policyVersion: '1.0.0' })), true); + }); + + it('requires policyVersion', () => { + assert.equal(hasConstraint(errorsFor(PolicyVersionDTO, {}), 'policyVersion', 'isString'), true); + }); + + it('rejects a non-boolean recordingEnabled', () => { + const errs = errorsFor(PolicyVersionDTO, { policyVersion: '1.0.0', recordingEnabled: 'yes' }); + assert.equal(hasConstraint(errs, 'recordingEnabled', 'isBoolean'), true); + }); + + it('rejects a non-string policyAvailability', () => { + const errs = errorsFor(PolicyVersionDTO, { policyVersion: '1.0.0', policyAvailability: 5 }); + assert.equal(hasConstraint(errs, 'policyAvailability', 'isString'), true); + }); +}); + +describe('PolicyCategoryDTO', () => { + it('accepts a valid category', () => { + assert.equal(isClean(errorsFor(PolicyCategoryDTO, { name: 'Large-Scale', type: 'PROJECT_SCALE' })), true); + }); + + it('requires name', () => { + assert.equal(hasConstraint(errorsFor(PolicyCategoryDTO, { type: 't' }), 'name', 'isString'), true); + }); + + it('requires type', () => { + assert.equal(hasConstraint(errorsFor(PolicyCategoryDTO, { name: 'n' }), 'type', 'isString'), true); + }); +}); + +describe('DeleteSavepointsDTO', () => { + it('accepts a valid request', () => { + assert.equal(isClean(errorsFor(DeleteSavepointsDTO, { savepointIds: ['a', 'b'] })), true); + }); + + it('accepts the guard bypass flag', () => { + const errs = errorsFor(DeleteSavepointsDTO, { savepointIds: ['a'], skipCurrentSavepointGuard: true }); + assert.equal(isClean(errs), true); + }); + + it('rejects an empty savepointIds array', () => { + assert.equal(hasConstraint(errorsFor(DeleteSavepointsDTO, { savepointIds: [] }), 'savepointIds', 'arrayNotEmpty'), true); + }); + + it('rejects non-string savepoint ids', () => { + assert.equal(hasConstraint(errorsFor(DeleteSavepointsDTO, { savepointIds: [1] }), 'savepointIds', 'isString'), true); + }); + + it('requires savepointIds', () => { + assert.equal(hasError(errorsFor(DeleteSavepointsDTO, {}), 'savepointIds'), true); + }); + + it('rejects a non-boolean skipCurrentSavepointGuard', () => { + const errs = errorsFor(DeleteSavepointsDTO, { savepointIds: ['a'], skipCurrentSavepointGuard: 1 }); + assert.equal(hasConstraint(errs, 'skipCurrentSavepointGuard', 'isBoolean'), true); + }); +}); + +describe('DeleteSavepointsResultDTO', () => { + it('accepts a valid result', () => { + assert.equal(isClean(errorsFor(DeleteSavepointsResultDTO, { hardDeletedIds: ['a'] })), true); + }); + + it('accepts an empty result list', () => { + assert.equal(isClean(errorsFor(DeleteSavepointsResultDTO, { hardDeletedIds: [] })), true); + }); + + it('rejects a non-array hardDeletedIds', () => { + assert.equal(hasConstraint(errorsFor(DeleteSavepointsResultDTO, { hardDeletedIds: 'x' }), 'hardDeletedIds', 'isArray'), true); + }); +}); + +describe('DebugBlockDataDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(DebugBlockDataDTO, {})), true); + }); + + it('accepts a valid block data', () => { + assert.equal(isClean(errorsFor(DebugBlockDataDTO, { input: 'RunEvent', output: 'RunEvent', type: 'json', document: { a: 1 } })), true); + }); + + it('rejects a non-string input', () => { + assert.equal(hasConstraint(errorsFor(DebugBlockDataDTO, { input: 5 }), 'input', 'isString'), true); + }); + + it('rejects a non-string type', () => { + assert.equal(hasConstraint(errorsFor(DebugBlockDataDTO, { type: [] }), 'type', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/policy-comments-dto.test.mjs b/api-gateway/tests/dto/policy-comments-dto.test.mjs new file mode 100644 index 0000000000..ac26caa30e --- /dev/null +++ b/api-gateway/tests/dto/policy-comments-dto.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + PolicyCommentUserDTO, + PolicyCommentRelationshipDTO, + NewPolicyDiscussionDTO, + NewPolicyCommentDTO, + PolicyCommentSearchDTO, + PolicyCommentCountDTO, +} from '../../dist/middlewares/validation/schemas/policy-comments.dto.js'; + +describe('PolicyCommentUserDTO', () => { + it('accepts a valid user entry', () => { + assert.equal(isClean(errorsFor(PolicyCommentUserDTO, { label: 'l', value: 'v', type: 'role' })), true); + }); + + it('accepts a user entry with roles', () => { + assert.equal(isClean(errorsFor(PolicyCommentUserDTO, { label: 'l', value: 'v', type: 'user', roles: ['Administrator'] })), true); + }); + + for (const field of ['label', 'value', 'type']) { + it(`rejects a missing ${field}`, () => { + const base = { label: 'l', value: 'v', type: 'role' }; + delete base[field]; + assert.equal(hasError(errorsFor(PolicyCommentUserDTO, base), field), true); + }); + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyCommentUserDTO, { label: 'l', value: 'v', type: 'role', [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-array roles', () => { + assert.equal(hasConstraint(errorsFor(PolicyCommentUserDTO, { label: 'l', value: 'v', type: 'user', roles: 'x' }), 'roles', 'isArray'), true); + }); +}); + +describe('PolicyCommentRelationshipDTO', () => { + it('accepts a valid relationship', () => { + assert.equal(isClean(errorsFor(PolicyCommentRelationshipDTO, { label: 'l', value: 'v' })), true); + }); + + it('rejects a missing label', () => { + assert.equal(hasError(errorsFor(PolicyCommentRelationshipDTO, { value: 'v' }), 'label'), true); + }); + + it('rejects a non-string value', () => { + assert.equal(hasConstraint(errorsFor(PolicyCommentRelationshipDTO, { label: 'l', value: 5 }), 'value', 'isString'), true); + }); +}); + +describe('NewPolicyDiscussionDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(NewPolicyDiscussionDTO, {})), true); + }); + + it('accepts a populated discussion', () => { + const errs = errorsFor(NewPolicyDiscussionDTO, { + name: 'n', field: 'f', fieldName: 'fn', parent: 'p', privacy: 'public', + roles: ['r'], users: ['u'], relationships: ['rel'], + }); + assert.equal(isClean(errs), true); + }); + + for (const field of ['name', 'field', 'fieldName', 'parent', 'privacy']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(NewPolicyDiscussionDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + for (const field of ['roles', 'users', 'relationships']) { + it(`rejects a non-array ${field}`, () => { + assert.equal(hasConstraint(errorsFor(NewPolicyDiscussionDTO, { [field]: 'x' }), field, 'isArray'), true); + }); + } +}); + +describe('NewPolicyCommentDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(NewPolicyCommentDTO, {})), true); + }); + + it('accepts a populated comment', () => { + assert.equal(isClean(errorsFor(NewPolicyCommentDTO, { anchor: 'a', recipients: ['r'], fields: ['f'], text: 't', files: ['x'] })), true); + }); + + for (const field of ['anchor', 'text']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(NewPolicyCommentDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + for (const field of ['recipients', 'fields', 'files']) { + it(`rejects a non-array ${field}`, () => { + assert.equal(hasConstraint(errorsFor(NewPolicyCommentDTO, { [field]: 'x' }), field, 'isArray'), true); + }); + } +}); + +describe('PolicyCommentSearchDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyCommentSearchDTO, {})), true); + }); + + it('accepts valid search filters', () => { + assert.equal(isClean(errorsFor(PolicyCommentSearchDTO, { search: 's', field: 'f', lt: 'a', gt: 'b' })), true); + }); + + for (const field of ['search', 'field', 'lt', 'gt']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyCommentSearchDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('PolicyCommentCountDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyCommentCountDTO, {})), true); + }); + + it('accepts a valid count', () => { + assert.equal(isClean(errorsFor(PolicyCommentCountDTO, { fields: { a: 1 }, count: 3 })), true); + }); + + it('rejects a non-number count', () => { + assert.equal(hasConstraint(errorsFor(PolicyCommentCountDTO, { count: 'x' }), 'count', 'isNumber'), true); + }); +}); diff --git a/api-gateway/tests/dto/policy-labels-dto.test.mjs b/api-gateway/tests/dto/policy-labels-dto.test.mjs new file mode 100644 index 0000000000..a5124cd19f --- /dev/null +++ b/api-gateway/tests/dto/policy-labels-dto.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + PolicyLabelDTO, + PolicyLabelRelationshipsDTO, + PolicyLabelDocumentDTO, + PolicyLabelDocumentRelationshipsDTO, + PolicyLabelComponentsDTO, + PolicyLabelFiltersDTO, +} from '../../dist/middlewares/validation/schemas/policy-labels.dto.js'; + +describe('PolicyLabelDTO', () => { + it('accepts a minimal valid label (name only)', () => { + assert.equal(isClean(errorsFor(PolicyLabelDTO, { name: 'lbl' })), true); + }); + + it('accepts a fully populated label', () => { + const errs = errorsFor(PolicyLabelDTO, { + id: 'i', uuid: 'u', name: 'n', description: 'd', creator: 'c', owner: 'o', + topicId: '0.0.1', messageId: 'm', policyId: 'p', policyTopicId: '0.0.2', + policyInstanceTopicId: '0.0.3', status: 'DRAFT', config: {}, + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing name', () => { + assert.equal(hasError(errorsFor(PolicyLabelDTO, {}), 'name'), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDTO, { name: 1 }), 'name', 'isString'), true); + }); + + for (const field of ['uuid', 'description', 'creator', 'owner', 'topicId', 'messageId', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'status']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDTO, { name: 'n', [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDTO, { name: 'n', config: 'x' }), 'config', 'isObject'), true); + }); +}); + +describe('PolicyLabelRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyLabelRelationshipsDTO, {})), true); + }); + + it('rejects a non-object policy', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelRelationshipsDTO, { policy: 'x' }), 'policy', 'isObject'), true); + }); + + it('rejects a non-array policySchemas', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelRelationshipsDTO, { policySchemas: 'x' }), 'policySchemas', 'isArray'), true); + }); + + it('rejects a non-array documentsSchemas', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelRelationshipsDTO, { documentsSchemas: 'x' }), 'documentsSchemas', 'isArray'), true); + }); +}); + +describe('PolicyLabelDocumentDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyLabelDocumentDTO, {})), true); + }); + + for (const field of ['id', 'definitionId', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'topicId', 'creator', 'owner', 'messageId', 'target']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDocumentDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDocumentDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDocumentDTO, { document: 'x' }), 'document', 'isObject'), true); + }); +}); + +describe('PolicyLabelDocumentRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyLabelDocumentRelationshipsDTO, {})), true); + }); + + it('rejects a non-object target', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDocumentRelationshipsDTO, { target: 'x' }), 'target', 'isObject'), true); + }); + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelDocumentRelationshipsDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); +}); + +describe('PolicyLabelComponentsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyLabelComponentsDTO, {})), true); + }); + + it('rejects a non-array statistics', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelComponentsDTO, { statistics: 'x' }), 'statistics', 'isArray'), true); + }); + + it('rejects a non-array labels', () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelComponentsDTO, { labels: 'x' }), 'labels', 'isArray'), true); + }); +}); + +describe('PolicyLabelFiltersDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyLabelFiltersDTO, {})), true); + }); + + it('accepts valid filters', () => { + assert.equal(isClean(errorsFor(PolicyLabelFiltersDTO, { text: 'n', owner: 'o', components: 'all' })), true); + }); + + for (const field of ['text', 'owner', 'components']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyLabelFiltersDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); diff --git a/api-gateway/tests/dto/policy-statistics-dto.test.mjs b/api-gateway/tests/dto/policy-statistics-dto.test.mjs new file mode 100644 index 0000000000..d6f268577b --- /dev/null +++ b/api-gateway/tests/dto/policy-statistics-dto.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + StatisticDefinitionDTO, + StatisticAssessmentDTO, + StatisticAssessmentRelationshipsDTO, + StatisticDefinitionRelationshipsDTO, +} from '../../dist/middlewares/validation/schemas/policy-statistics.dto.js'; + +describe('StatisticDefinitionDTO', () => { + it('accepts a minimal valid definition (name only)', () => { + assert.equal(isClean(errorsFor(StatisticDefinitionDTO, { name: 'stat' })), true); + }); + + it('accepts a fully populated definition', () => { + const errs = errorsFor(StatisticDefinitionDTO, { + id: 'i', uuid: 'u', name: 'n', description: 'd', creator: 'c', owner: 'o', + topicId: '0.0.1', messageId: 'm', policyId: 'p', policyTopicId: '0.0.2', + policyInstanceTopicId: '0.0.3', status: 'DRAFT', config: {}, + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing name', () => { + assert.equal(hasError(errorsFor(StatisticDefinitionDTO, {}), 'name'), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionDTO, { name: 1 }), 'name', 'isString'), true); + }); + + for (const field of ['uuid', 'description', 'creator', 'owner', 'topicId', 'messageId', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'status']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionDTO, { name: 'n', [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionDTO, { name: 'n', config: 'x' }), 'config', 'isObject'), true); + }); +}); + +describe('StatisticAssessmentDTO', () => { + it('accepts an empty payload (all optional)', () => { + assert.equal(isClean(errorsFor(StatisticAssessmentDTO, {})), true); + }); + + it('accepts a populated assessment', () => { + const errs = errorsFor(StatisticAssessmentDTO, { + id: 'i', definitionId: 'd', policyId: 'p', policyTopicId: '0.0.1', + policyInstanceTopicId: '0.0.2', topicId: '0.0.3', creator: 'c', owner: 'o', + messageId: 'm', target: 't', relationships: ['r'], document: {}, + }); + assert.equal(isClean(errs), true); + }); + + for (const field of ['id', 'definitionId', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'topicId', 'creator', 'owner', 'messageId', 'target']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(StatisticAssessmentDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(StatisticAssessmentDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(StatisticAssessmentDTO, { document: 'x' }), 'document', 'isObject'), true); + }); +}); + +describe('StatisticAssessmentRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(StatisticAssessmentRelationshipsDTO, {})), true); + }); + + it('rejects a non-object target', () => { + assert.equal(hasConstraint(errorsFor(StatisticAssessmentRelationshipsDTO, { target: 'x' }), 'target', 'isObject'), true); + }); + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(StatisticAssessmentRelationshipsDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); +}); + +describe('StatisticDefinitionRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(StatisticDefinitionRelationshipsDTO, {})), true); + }); + + it('rejects a non-object policy', () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionRelationshipsDTO, { policy: 'x' }), 'policy', 'isObject'), true); + }); + + it('rejects a non-array schemas', () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionRelationshipsDTO, { schemas: 'x' }), 'schemas', 'isArray'), true); + }); + + it('rejects a non-object schema', () => { + assert.equal(hasConstraint(errorsFor(StatisticDefinitionRelationshipsDTO, { schema: 'x' }), 'schema', 'isObject'), true); + }); +}); diff --git a/api-gateway/tests/dto/profiles-dto.test.mjs b/api-gateway/tests/dto/profiles-dto.test.mjs new file mode 100644 index 0000000000..fe0562427b --- /dev/null +++ b/api-gateway/tests/dto/profiles-dto.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + UserDTO, + ProfileDidDocumentRecordDTO, + ProfileVcDocumentDTO, + ProfileDTO, + PolicyKeyDTO, + PolicyKeyConfigDTO, +} from '../../dist/middlewares/validation/schemas/profiles.dto.js'; + +const validUser = { username: 'u', role: 'USER', permissionsGroup: [], permissions: ['x'] }; + +describe('UserDTO', () => { + it('accepts a valid user', () => { + assert.equal(isClean(errorsFor(UserDTO, validUser)), true); + }); + + it('accepts a user with optional did/parent/hederaAccountId', () => { + assert.equal(isClean(errorsFor(UserDTO, { ...validUser, did: 'd', parent: 'p', hederaAccountId: '0.0.1' })), true); + }); + + it('rejects a non-string username', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, username: 5 }), 'username', 'isString'), true); + }); + + it('rejects a non-string role', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, role: 5 }), 'role', 'isString'), true); + }); + + it('rejects a non-array permissions', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, permissions: 'x' }), 'permissions', 'isArray'), true); + }); + + it('rejects a non-array permissionsGroup', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, permissionsGroup: 'x' }), 'permissionsGroup', 'isArray'), true); + }); + + it('rejects a non-string did', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, did: 5 }), 'did', 'isString'), true); + }); + + it('rejects a non-string hederaAccountId', () => { + assert.equal(hasConstraint(errorsFor(UserDTO, { ...validUser, hederaAccountId: 5 }), 'hederaAccountId', 'isString'), true); + }); +}); + +describe('ProfileDidDocumentRecordDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(ProfileDidDocumentRecordDTO, {})), true); + }); + + for (const field of ['createDate', 'updateDate', 'did', 'status', 'messageId', 'topicId', 'id']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(ProfileDidDocumentRecordDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('ProfileVcDocumentDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(ProfileVcDocumentDTO, {})), true); + }); + + it('accepts valid documentFileId and tableFileIds', () => { + assert.equal(isClean(errorsFor(ProfileVcDocumentDTO, { documentFileId: 'x', tableFileIds: ['a', 'b'] })), true); + }); + + it('rejects a non-string documentFileId', () => { + assert.equal(hasConstraint(errorsFor(ProfileVcDocumentDTO, { documentFileId: 5 }), 'documentFileId', 'isString'), true); + }); + + it('rejects a non-array tableFileIds', () => { + assert.equal(hasConstraint(errorsFor(ProfileVcDocumentDTO, { tableFileIds: 'x' }), 'tableFileIds', 'isArray'), true); + }); + + it('rejects non-string elements in tableFileIds', () => { + assert.equal(hasConstraint(errorsFor(ProfileVcDocumentDTO, { tableFileIds: [1, 2] }), 'tableFileIds', 'isString'), true); + }); +}); + +describe('ProfileDTO', () => { + it('accepts an empty payload (inherited fields are not required because validateSync skips undefined)', () => { + assert.equal(isClean(errorsFor(ProfileDTO, validUser)), true); + }); + + it('accepts valid boolean and enum fields', () => { + assert.equal(isClean(errorsFor(ProfileDTO, { ...validUser, confirmed: true, failed: false, location: 'local' })), true); + }); + + it('rejects a non-boolean confirmed', () => { + assert.equal(hasConstraint(errorsFor(ProfileDTO, { ...validUser, confirmed: 'x' }), 'confirmed', 'isBoolean'), true); + }); + + it('rejects a non-boolean failed', () => { + assert.equal(hasConstraint(errorsFor(ProfileDTO, { ...validUser, failed: 'x' }), 'failed', 'isBoolean'), true); + }); + + it('rejects an out-of-enum location', () => { + assert.equal(hasConstraint(errorsFor(ProfileDTO, { ...validUser, location: 'mars' }), 'location', 'isEnum'), true); + }); + + it('rejects a non-string topicId', () => { + assert.equal(hasConstraint(errorsFor(ProfileDTO, { ...validUser, topicId: 5 }), 'topicId', 'isString'), true); + }); +}); + +describe('PolicyKeyDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(PolicyKeyDTO, {})), true); + }); + + for (const field of ['id', 'createDate', 'updateDate', 'messageId', 'owner', 'policyName', 'key']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(PolicyKeyDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('PolicyKeyConfigDTO', () => { + it('accepts a valid config', () => { + assert.equal(isClean(errorsFor(PolicyKeyConfigDTO, { messageId: 'm' })), true); + }); + + it('rejects a missing messageId', () => { + assert.equal(hasError(errorsFor(PolicyKeyConfigDTO, {}), 'messageId'), true); + }); + + it('rejects a non-string messageId', () => { + assert.equal(hasConstraint(errorsFor(PolicyKeyConfigDTO, { messageId: 5 }), 'messageId', 'isString'), true); + }); + + it('rejects a non-string key', () => { + assert.equal(hasConstraint(errorsFor(PolicyKeyConfigDTO, { messageId: 'm', key: 5 }), 'key', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/record-dto.test.mjs b/api-gateway/tests/dto/record-dto.test.mjs new file mode 100644 index 0000000000..4c1e381d8f --- /dev/null +++ b/api-gateway/tests/dto/record-dto.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + RecordStatusDTO, + RecordActionDTO, + ResultDocumentDTO, + ResultInfoDTO, + RunningResultDTO, + RunningDetailsDTO, +} from '../../dist/middlewares/validation/schemas/record.js'; + +describe('RecordStatusDTO', () => { + it('accepts a valid status', () => { + const errs = errorsFor(RecordStatusDTO, { type: 'Recording', policyId: 'p', uuid: 'u', status: 'New' }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing type', () => { + const errs = errorsFor(RecordStatusDTO, { policyId: 'p', uuid: 'u', status: 'New' }); + assert.equal(hasConstraint(errs, 'type', 'isNotEmpty'), true); + }); + + it('rejects an empty status', () => { + const errs = errorsFor(RecordStatusDTO, { type: 'Recording', policyId: 'p', uuid: 'u', status: '' }); + assert.equal(hasConstraint(errs, 'status', 'isNotEmpty'), true); + }); + + it('rejects a non-string policyId', () => { + const errs = errorsFor(RecordStatusDTO, { type: 'Recording', policyId: 1, uuid: 'u', status: 'New' }); + assert.equal(hasConstraint(errs, 'policyId', 'isString'), true); + }); +}); + +describe('RecordActionDTO', () => { + const valid = { uuid: 'u', policyId: 'p', method: 'POST', action: 'CreateDID', time: 't', user: 'd', target: 'tag' }; + + it('accepts a valid action', () => { + assert.equal(isClean(errorsFor(RecordActionDTO, valid)), true); + }); + + it('accepts an empty action string', () => { + assert.equal(isClean(errorsFor(RecordActionDTO, { ...valid, action: '' })), true); + }); + + it('rejects a missing uuid', () => { + const { uuid, ...rest } = valid; + assert.equal(hasError(errorsFor(RecordActionDTO, rest), 'uuid'), true); + }); + + it('rejects an empty method', () => { + assert.equal(hasConstraint(errorsFor(RecordActionDTO, { ...valid, method: '' }), 'method', 'isNotEmpty'), true); + }); + + it('rejects a non-string target', () => { + assert.equal(hasConstraint(errorsFor(RecordActionDTO, { ...valid, target: 7 }), 'target', 'isString'), true); + }); +}); + +describe('ResultDocumentDTO', () => { + const valid = { type: 'VC', schema: 's', rate: '100%', documents: {} }; + + it('accepts a valid document result', () => { + assert.equal(isClean(errorsFor(ResultDocumentDTO, valid)), true); + }); + + it('rejects a non-object documents', () => { + assert.equal(hasConstraint(errorsFor(ResultDocumentDTO, { ...valid, documents: 'x' }), 'documents', 'isObject'), true); + }); + + it('rejects a missing rate', () => { + const { rate, ...rest } = valid; + assert.equal(hasError(errorsFor(ResultDocumentDTO, rest), 'rate'), true); + }); +}); + +describe('ResultInfoDTO', () => { + it('accepts valid counters', () => { + assert.equal(isClean(errorsFor(ResultInfoDTO, { tokens: 1, documents: 5 })), true); + }); + + it('rejects a non-number tokens', () => { + assert.equal(hasConstraint(errorsFor(ResultInfoDTO, { tokens: 'x', documents: 5 }), 'tokens', 'isNumber'), true); + }); + + it('rejects a non-number documents', () => { + assert.equal(hasConstraint(errorsFor(ResultInfoDTO, { tokens: 1, documents: null }), 'documents', 'isNumber'), true); + }); +}); + +describe('RunningResultDTO', () => { + it('accepts a valid result', () => { + const errs = errorsFor(RunningResultDTO, { info: { tokens: 1, documents: 1 }, total: 5, documents: [] }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-array documents', () => { + const errs = errorsFor(RunningResultDTO, { info: {}, total: 5, documents: {} }); + assert.equal(hasConstraint(errs, 'documents', 'isArray'), true); + }); + + it('rejects a non-number total', () => { + const errs = errorsFor(RunningResultDTO, { info: {}, total: '5', documents: [] }); + assert.equal(hasConstraint(errs, 'total', 'isNumber'), true); + }); +}); + +describe('RunningDetailsDTO', () => { + const valid = { left: {}, right: {}, total: 10, documents: {} }; + + it('accepts valid details', () => { + assert.equal(isClean(errorsFor(RunningDetailsDTO, valid)), true); + }); + + it('rejects a non-object left', () => { + assert.equal(hasConstraint(errorsFor(RunningDetailsDTO, { ...valid, left: 'x' }), 'left', 'isObject'), true); + }); + + it('rejects a non-object right', () => { + assert.equal(hasConstraint(errorsFor(RunningDetailsDTO, { ...valid, right: 1 }), 'right', 'isObject'), true); + }); +}); diff --git a/api-gateway/tests/dto/relayer-policy-parameters-dto.test.mjs b/api-gateway/tests/dto/relayer-policy-parameters-dto.test.mjs new file mode 100644 index 0000000000..30b37cba3e --- /dev/null +++ b/api-gateway/tests/dto/relayer-policy-parameters-dto.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, isClean } from './_dto-helper.mjs'; +import { + RelayerAccountDTO, + NewRelayerAccountDTO, +} from '../../dist/middlewares/validation/schemas/relayer-account.dto.js'; +import { PolicyParametersDTO } from '../../dist/middlewares/validation/schemas/policy-parameters.dto.js'; + +describe('RelayerAccountDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(RelayerAccountDTO, {})), true); + }); + + it('accepts a fully populated account', () => { + const errs = errorsFor(RelayerAccountDTO, { + id: '1', + name: 'Account', + username: 'user', + owner: 'did:hedera:1', + parent: 'did:hedera:2', + account: '0.0.1', + createDate: '2024-01-01', + updateDate: '2024-01-02', + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(RelayerAccountDTO, { name: 1 }), 'name', 'isString'), true); + }); + + it('rejects a non-string account', () => { + assert.equal(hasConstraint(errorsFor(RelayerAccountDTO, { account: {} }), 'account', 'isString'), true); + }); + + it('rejects a non-string parent', () => { + assert.equal(hasConstraint(errorsFor(RelayerAccountDTO, { parent: 5 }), 'parent', 'isString'), true); + }); +}); + +describe('NewRelayerAccountDTO', () => { + it('accepts an empty instance', () => { + assert.equal(isClean(errorsFor(NewRelayerAccountDTO, {})), true); + }); + + it('accepts a valid new account', () => { + assert.equal(isClean(errorsFor(NewRelayerAccountDTO, { name: 'n', account: '0.0.1', key: 'k' })), true); + }); + + it('rejects a non-string key', () => { + assert.equal(hasConstraint(errorsFor(NewRelayerAccountDTO, { key: 5 }), 'key', 'isString'), true); + }); + + it('rejects a non-string account', () => { + assert.equal(hasConstraint(errorsFor(NewRelayerAccountDTO, { account: 1 }), 'account', 'isString'), true); + }); +}); + +describe('PolicyParametersDTO', () => { + it('accepts a minimal valid instance', () => { + assert.equal(isClean(errorsFor(PolicyParametersDTO, { policyId: 'p' })), true); + }); + + it('requires policyId', () => { + assert.equal(hasConstraint(errorsFor(PolicyParametersDTO, {}), 'policyId', 'isString'), true); + }); + + it('rejects a non-boolean updated', () => { + assert.equal(hasConstraint(errorsFor(PolicyParametersDTO, { policyId: 'p', updated: 'x' }), 'updated', 'isBoolean'), true); + }); + + it('rejects a non-array config', () => { + assert.equal(hasConstraint(errorsFor(PolicyParametersDTO, { policyId: 'p', config: {} }), 'config', 'isArray'), true); + }); + + it('accepts an empty config array', () => { + assert.equal(isClean(errorsFor(PolicyParametersDTO, { policyId: 'p', config: [] })), true); + }); +}); diff --git a/api-gateway/tests/dto/schema-rules-tag-dto.test.mjs b/api-gateway/tests/dto/schema-rules-tag-dto.test.mjs new file mode 100644 index 0000000000..d8f3e81aed --- /dev/null +++ b/api-gateway/tests/dto/schema-rules-tag-dto.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + SchemaRuleDTO, + SchemaRuleOptionsDTO, + SchemaRuleRelationshipsDTO, + SchemaRuleDataDTO, +} from '../../dist/middlewares/validation/schemas/schema-rules.dto.js'; +import { TagDTO } from '../../dist/middlewares/validation/schemas/tag.dto.js'; + +describe('SchemaRuleDTO', () => { + it('accepts a minimal valid rule (name only)', () => { + assert.equal(isClean(errorsFor(SchemaRuleDTO, { name: 'rule' })), true); + }); + + it('accepts a fully populated rule', () => { + const errs = errorsFor(SchemaRuleDTO, { + id: 'id', uuid: 'u', name: 'rule', description: 'd', creator: 'did', + owner: 'did', policyId: 'p', policyTopicId: '0.0.1', policyInstanceTopicId: '0.0.2', + status: 'DRAFT', config: { a: 1 }, + }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing name', () => { + assert.equal(hasError(errorsFor(SchemaRuleDTO, {}), 'name'), true); + }); + + it('rejects a non-string name', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDTO, { name: 5 }), 'name', 'isString'), true); + }); + + for (const field of ['uuid', 'description', 'creator', 'owner', 'policyId', 'policyTopicId', 'policyInstanceTopicId', 'status']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDTO, { name: 'n', [field]: 7 }), field, 'isString'), true); + }); + } + + it('rejects a non-object config', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDTO, { name: 'n', config: 'x' }), 'config', 'isObject'), true); + }); + + it('accepts an omitted optional config', () => { + assert.equal(isClean(errorsFor(SchemaRuleDTO, { name: 'n' })), true); + }); +}); + +describe('SchemaRuleOptionsDTO', () => { + it('accepts an empty payload (all optional)', () => { + assert.equal(isClean(errorsFor(SchemaRuleOptionsDTO, {})), true); + }); + + it('accepts valid string ids', () => { + assert.equal(isClean(errorsFor(SchemaRuleOptionsDTO, { policyId: 'p', schemaId: 's', documentId: 'd', parentId: 'pa' })), true); + }); + + for (const field of ['policyId', 'schemaId', 'documentId', 'parentId']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleOptionsDTO, { [field]: 9 }), field, 'isString'), true); + }); + } +}); + +describe('SchemaRuleRelationshipsDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(SchemaRuleRelationshipsDTO, {})), true); + }); + + it('rejects a non-object policy', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleRelationshipsDTO, { policy: 'x' }), 'policy', 'isObject'), true); + }); + + it('rejects a non-array schemas', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleRelationshipsDTO, { schemas: 'x' }), 'schemas', 'isArray'), true); + }); +}); + +describe('SchemaRuleDataDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(SchemaRuleDataDTO, {})), true); + }); + + it('rejects a non-object rules', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDataDTO, { rules: 'x' }), 'rules', 'isObject'), true); + }); + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDataDTO, { document: 5 }), 'document', 'isObject'), true); + }); + + it('rejects a non-array relationships', () => { + assert.equal(hasConstraint(errorsFor(SchemaRuleDataDTO, { relationships: 'x' }), 'relationships', 'isArray'), true); + }); +}); + +describe('TagDTO', () => { + it('accepts an empty instance (only inheritTags decorated)', () => { + assert.equal(isClean(errorsFor(TagDTO, {})), true); + }); + + it('accepts a valid inheritTags', () => { + assert.equal(isClean(errorsFor(TagDTO, { inheritTags: true })), true); + }); + + it('rejects a non-boolean inheritTags', () => { + assert.equal(hasConstraint(errorsFor(TagDTO, { inheritTags: 'yes' }), 'inheritTags', 'isBoolean'), true); + }); +}); diff --git a/api-gateway/tests/dto/schemas-dto.test.mjs b/api-gateway/tests/dto/schemas-dto.test.mjs new file mode 100644 index 0000000000..54dbe6a8b2 --- /dev/null +++ b/api-gateway/tests/dto/schemas-dto.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + SchemaDTO, + SchemaParentDTO, + SchemaListAllItemDTO, + SchemaWithSubSchemasDTO, + SchemaPushCopyRequestDTO, + SchemaImportDuplicatesRequestDTO, + SystemSchemaDTO, + ExportSchemaDTO, + VersionSchemaDTO, + MessageSchemaDTO, +} from '../../dist/middlewares/validation/schemas/schemas.dto.js'; + +describe('SchemaDTO', () => { + it('accepts an empty payload (all optional)', () => { + assert.equal(isClean(errorsFor(SchemaDTO, {})), true); + }); + + it('accepts a populated schema', () => { + const errs = errorsFor(SchemaDTO, { + id: 'i', uuid: 'u', name: 'n', description: 'd', entity: 'POLICY', iri: '#x', + status: 'DRAFT', topicId: '0.0.1', version: '1.0.0', creator: 'c', owner: 'o', + messageId: 'm', category: 'POLICY', documentURL: 'ipfs://x', contextURL: 'ipfs://y', + document: {}, context: {}, + }); + assert.equal(isClean(errs), true); + }); + + for (const field of ['id', 'uuid', 'name', 'description', 'entity', 'iri', 'status', 'topicId', 'version', 'creator', 'owner', 'messageId', 'category', 'documentURL', 'contextURL']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaDTO, { [field]: 5 }), field, 'isString'), true); + }); + } + + it('rejects a non-object document', () => { + assert.equal(hasConstraint(errorsFor(SchemaDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + + it('rejects a non-object context', () => { + assert.equal(hasConstraint(errorsFor(SchemaDTO, { context: 'x' }), 'context', 'isObject'), true); + }); +}); + +describe('SchemaParentDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(SchemaParentDTO, {})), true); + }); + + for (const field of ['id', 'name', 'status', 'version', 'sourceVersion', 'category']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaParentDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('SchemaListAllItemDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(SchemaListAllItemDTO, {})), true); + }); + + for (const field of ['id', 'name', 'description', 'status', 'version', 'sourceVersion', 'topicId', 'category']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaListAllItemDTO, { [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('SchemaWithSubSchemasDTO', () => { + it('accepts an empty payload', () => { + assert.equal(isClean(errorsFor(SchemaWithSubSchemasDTO, {})), true); + }); +}); + +describe('SchemaPushCopyRequestDTO', () => { + it('accepts a valid request', () => { + assert.equal(isClean(errorsFor(SchemaPushCopyRequestDTO, { topicId: '0.0.1', name: 'n', iri: '#x', copyNested: true })), true); + }); + + for (const field of ['topicId', 'name', 'iri']) { + it(`rejects a missing ${field}`, () => { + const base = { topicId: '0.0.1', name: 'n', iri: '#x', copyNested: true }; + delete base[field]; + assert.equal(hasError(errorsFor(SchemaPushCopyRequestDTO, base), field), true); + }); + it(`rejects an empty ${field}`, () => { + assert.equal(hasConstraint(errorsFor(SchemaPushCopyRequestDTO, { topicId: '0.0.1', name: 'n', iri: '#x', copyNested: true, [field]: '' }), field, 'isNotEmpty'), true); + }); + } + + it('rejects a non-boolean copyNested', () => { + assert.equal(hasConstraint(errorsFor(SchemaPushCopyRequestDTO, { topicId: '0.0.1', name: 'n', iri: '#x', copyNested: 'x' }), 'copyNested', 'isBoolean'), true); + }); +}); + +describe('SchemaImportDuplicatesRequestDTO', () => { + it('accepts a valid request', () => { + assert.equal(isClean(errorsFor(SchemaImportDuplicatesRequestDTO, { policyId: 'p', schemaNames: ['a'] })), true); + }); + + it('rejects a missing policyId', () => { + assert.equal(hasError(errorsFor(SchemaImportDuplicatesRequestDTO, { schemaNames: [] }), 'policyId'), true); + }); + + it('rejects an empty policyId', () => { + assert.equal(hasConstraint(errorsFor(SchemaImportDuplicatesRequestDTO, { policyId: '', schemaNames: [] }), 'policyId', 'isNotEmpty'), true); + }); + + it('rejects a non-array schemaNames', () => { + assert.equal(hasConstraint(errorsFor(SchemaImportDuplicatesRequestDTO, { policyId: 'p', schemaNames: 'x' }), 'schemaNames', 'isArray'), true); + }); +}); + +describe('SystemSchemaDTO', () => { + it('accepts a valid STANDARD_REGISTRY schema', () => { + assert.equal(isClean(errorsFor(SystemSchemaDTO, { name: 'n', entity: 'STANDARD_REGISTRY' })), true); + }); + + it('accepts a valid USER schema', () => { + assert.equal(isClean(errorsFor(SystemSchemaDTO, { name: 'n', entity: 'USER' })), true); + }); + + it('rejects an out-of-enum entity', () => { + assert.equal(hasConstraint(errorsFor(SystemSchemaDTO, { name: 'n', entity: 'AUDITOR' }), 'entity', 'isIn'), true); + }); + + it('rejects a missing name', () => { + assert.equal(hasError(errorsFor(SystemSchemaDTO, { entity: 'USER' }), 'name'), true); + }); + + it('rejects an empty name', () => { + assert.equal(hasConstraint(errorsFor(SystemSchemaDTO, { name: '', entity: 'USER' }), 'name', 'isNotEmpty'), true); + }); +}); + +describe('ExportSchemaDTO', () => { + it('accepts a valid export', () => { + assert.equal(isClean(errorsFor(ExportSchemaDTO, { id: 'i', name: 'n' })), true); + }); + + it('rejects a missing id', () => { + assert.equal(hasError(errorsFor(ExportSchemaDTO, { name: 'n' }), 'id'), true); + }); + + it('rejects an empty name', () => { + assert.equal(hasConstraint(errorsFor(ExportSchemaDTO, { id: 'i', name: '' }), 'name', 'isNotEmpty'), true); + }); + + for (const field of ['description', 'version', 'owner', 'messageId']) { + it(`rejects a non-string ${field}`, () => { + assert.equal(hasConstraint(errorsFor(ExportSchemaDTO, { id: 'i', name: 'n', [field]: 5 }), field, 'isString'), true); + }); + } +}); + +describe('VersionSchemaDTO', () => { + it('accepts a valid version', () => { + assert.equal(isClean(errorsFor(VersionSchemaDTO, { version: '1.0.0' })), true); + }); + + it('rejects a missing version', () => { + assert.equal(hasError(errorsFor(VersionSchemaDTO, {}), 'version'), true); + }); + + it('rejects an empty version', () => { + assert.equal(hasConstraint(errorsFor(VersionSchemaDTO, { version: '' }), 'version', 'isNotEmpty'), true); + }); +}); + +describe('MessageSchemaDTO', () => { + it('accepts a valid messageId', () => { + assert.equal(isClean(errorsFor(MessageSchemaDTO, { messageId: 'm' })), true); + }); + + it('rejects a missing messageId', () => { + assert.equal(hasError(errorsFor(MessageSchemaDTO, {}), 'messageId'), true); + }); + + it('rejects a non-string messageId', () => { + assert.equal(hasConstraint(errorsFor(MessageSchemaDTO, { messageId: 5 }), 'messageId', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/suggestions-dto.test.mjs b/api-gateway/tests/dto/suggestions-dto.test.mjs new file mode 100644 index 0000000000..989b8c073d --- /dev/null +++ b/api-gateway/tests/dto/suggestions-dto.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + SuggestionsInputDTO, + SuggestionsOutputDTO, + SuggestionsConfigItemDTO, + SuggestionsConfigDTO, +} from '../../dist/middlewares/validation/schemas/suggestions.js'; + +describe('SuggestionsInputDTO', () => { + it('accepts a valid block type', () => { + assert.equal(isClean(errorsFor(SuggestionsInputDTO, { blockType: 'interfaceContainerBlock' })), true); + }); + + it('rejects a missing blockType', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsInputDTO, {}), 'blockType', 'isNotEmpty'), true); + }); + + it('rejects an empty blockType', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsInputDTO, { blockType: '' }), 'blockType', 'isNotEmpty'), true); + }); + + it('rejects a non-string blockType', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsInputDTO, { blockType: 1 }), 'blockType', 'isString'), true); + }); + + it('does not validate children', () => { + assert.equal(isClean(errorsFor(SuggestionsInputDTO, { blockType: 'a', children: 'anything' })), true); + }); +}); + +describe('SuggestionsOutputDTO', () => { + it('accepts valid output', () => { + assert.equal(isClean(errorsFor(SuggestionsOutputDTO, { next: 'a', nested: 'b' })), true); + }); + + it('requires next', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsOutputDTO, { nested: 'b' }), 'next', 'isString'), true); + }); + + it('rejects a non-string nested', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsOutputDTO, { next: 'a', nested: 5 }), 'nested', 'isString'), true); + }); +}); + +describe('SuggestionsConfigItemDTO', () => { + it('accepts a valid Policy item', () => { + assert.equal(isClean(errorsFor(SuggestionsConfigItemDTO, { id: 'a', type: 'Policy', index: 0 })), true); + }); + + it('accepts a valid Module item', () => { + assert.equal(isClean(errorsFor(SuggestionsConfigItemDTO, { id: 'a', type: 'Module', index: 2 })), true); + }); + + it('rejects an unknown type', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsConfigItemDTO, { id: 'a', type: 'Tool', index: 0 }), 'type', 'isEnum'), true); + }); + + it('rejects a non-integer index', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsConfigItemDTO, { id: 'a', type: 'Policy', index: 1.5 }), 'index', 'isInt'), true); + }); + + it('rejects an empty id', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsConfigItemDTO, { id: '', type: 'Policy', index: 0 }), 'id', 'isNotEmpty'), true); + }); + + it('rejects a missing id', () => { + assert.equal(hasError(errorsFor(SuggestionsConfigItemDTO, { type: 'Policy', index: 0 }), 'id'), true); + }); +}); + +describe('SuggestionsConfigDTO', () => { + it('accepts an items array', () => { + assert.equal(isClean(errorsFor(SuggestionsConfigDTO, { items: [] })), true); + }); + + it('rejects a non-array items', () => { + assert.equal(hasConstraint(errorsFor(SuggestionsConfigDTO, { items: {} }), 'items', 'isArray'), true); + }); +}); diff --git a/api-gateway/tests/dto/token-dto.test.mjs b/api-gateway/tests/dto/token-dto.test.mjs new file mode 100644 index 0000000000..94f04cc75f --- /dev/null +++ b/api-gateway/tests/dto/token-dto.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, hasError, isClean } from './_dto-helper.mjs'; +import { + TransferTokenDTO, +} from '../../dist/middlewares/validation/schemas/token.dto.js'; + +describe('TransferTokenDTO', () => { + it('accepts a valid fungible transfer', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', amount: 10 }); + assert.equal(isClean(errs), true); + }); + + it('accepts a valid NFT transfer with serial numbers', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', serialNumbers: [1, 2, 3] }); + assert.equal(isClean(errs), true); + }); + + it('accepts a transfer with an optional memo', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', amount: 5, memo: 'note' }); + assert.equal(isClean(errs), true); + }); + + it('rejects a missing targetAccount', () => { + const errs = errorsFor(TransferTokenDTO, { amount: 10 }); + assert.equal(hasError(errs, 'targetAccount'), true); + }); + + it('flags targetAccount as not a string', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: 123, amount: 10 }); + assert.equal(hasConstraint(errs, 'targetAccount', 'isString'), true); + }); + + it('flags targetAccount as empty', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '', amount: 10 }); + assert.equal(hasConstraint(errs, 'targetAccount', 'isNotEmpty'), true); + }); + + it('requires amount when no serial numbers are given', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1' }); + assert.equal(hasError(errs, 'amount'), true); + }); + + it('rejects a non-positive amount', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', amount: 0 }); + assert.equal(hasConstraint(errs, 'amount', 'isPositive'), true); + }); + + it('rejects a non-number amount', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', amount: 'x' }); + assert.equal(hasConstraint(errs, 'amount', 'isNumber'), true); + }); + + it('rejects a non-array serialNumbers', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', serialNumbers: 5 }); + assert.equal(hasConstraint(errs, 'serialNumbers', 'isArray'), true); + }); + + it('rejects an empty serialNumbers array', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', serialNumbers: [] }); + assert.equal(hasConstraint(errs, 'serialNumbers', 'arrayMinSize'), true); + }); + + it('rejects non-integer serial numbers', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', serialNumbers: [1.5] }); + assert.equal(hasConstraint(errs, 'serialNumbers', 'isInt'), true); + }); + + it('rejects serial numbers below the minimum', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', serialNumbers: [0] }); + assert.equal(hasConstraint(errs, 'serialNumbers', 'min'), true); + }); + + it('rejects a non-string memo', () => { + const errs = errorsFor(TransferTokenDTO, { targetAccount: '0.0.1', amount: 1, memo: 5 }); + assert.equal(hasConstraint(errs, 'memo', 'isString'), true); + }); +}); diff --git a/api-gateway/tests/dto/worker-permissions-dto.test.mjs b/api-gateway/tests/dto/worker-permissions-dto.test.mjs new file mode 100644 index 0000000000..a51293e4f8 --- /dev/null +++ b/api-gateway/tests/dto/worker-permissions-dto.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { errorsFor, hasConstraint, isClean } from './_dto-helper.mjs'; +import { WorkersTasksDTO } from '../../dist/middlewares/validation/schemas/worker-tasks.dto.js'; +import { RoleDTO, AssignPolicyDTO } from '../../dist/middlewares/validation/schemas/permissions.dto.js'; + +const validWorker = { + createDate: '2020-01-01', + done: true, + id: 'id', + isRetryableTask: false, + processedTime: '2020-01-01', + sent: true, + taskId: 'task', + type: 'send-hedera', + updateDate: '2020-01-01', +}; + +describe('WorkersTasksDTO', () => { + it('accepts a fully valid task', () => { + assert.equal(isClean(errorsFor(WorkersTasksDTO, validWorker)), true); + }); + + for (const field of ['createDate', 'id', 'processedTime', 'taskId', 'type', 'updateDate']) { + it(`rejects a non-string ${field}`, () => { + const errs = errorsFor(WorkersTasksDTO, { ...validWorker, [field]: 123 }); + assert.equal(hasConstraint(errs, field, 'isString'), true); + }); + } + + for (const field of ['done', 'isRetryableTask', 'sent']) { + it(`rejects a non-boolean ${field}`, () => { + const errs = errorsFor(WorkersTasksDTO, { ...validWorker, [field]: 'yes' }); + assert.equal(hasConstraint(errs, field, 'isBoolean'), true); + }); + } + + it('flags missing required string fields', () => { + const errs = errorsFor(WorkersTasksDTO, { done: true, isRetryableTask: false, sent: true }); + assert.equal(hasConstraint(errs, 'createDate', 'isString'), true); + assert.equal(hasConstraint(errs, 'taskId', 'isString'), true); + }); +}); + +describe('RoleDTO', () => { + it('accepts optional boolean flags', () => { + assert.equal(isClean(errorsFor(RoleDTO, { default: true, readonly: false })), true); + }); + + it('accepts an empty instance (optional flags omitted)', () => { + assert.equal(isClean(errorsFor(RoleDTO, {})), true); + }); + + it('rejects a non-boolean default', () => { + assert.equal(hasConstraint(errorsFor(RoleDTO, { default: 'x' }), 'default', 'isBoolean'), true); + }); + + it('rejects a non-boolean readonly', () => { + assert.equal(hasConstraint(errorsFor(RoleDTO, { readonly: 1 }), 'readonly', 'isBoolean'), true); + }); +}); + +describe('AssignPolicyDTO', () => { + it('accepts a valid assignment', () => { + assert.equal(isClean(errorsFor(AssignPolicyDTO, { policyIds: ['a', 'b'], assign: true })), true); + }); + + it('accepts an empty policyIds array', () => { + assert.equal(isClean(errorsFor(AssignPolicyDTO, { policyIds: [], assign: false })), true); + }); + + it('rejects a non-array policyIds', () => { + assert.equal(hasConstraint(errorsFor(AssignPolicyDTO, { policyIds: 'x', assign: true }), 'policyIds', 'isArray'), true); + }); + + it('rejects a non-boolean assign', () => { + assert.equal(hasConstraint(errorsFor(AssignPolicyDTO, { policyIds: [], assign: 'yes' }), 'assign', 'isBoolean'), true); + }); +}); diff --git a/api-gateway/tests/entity-owner.test.mjs b/api-gateway/tests/entity-owner.test.mjs new file mode 100644 index 0000000000..452b731580 --- /dev/null +++ b/api-gateway/tests/entity-owner.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { EntityOwner } from '../dist/helpers/entity-owner.js'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +describe('EntityOwner (api-gateway subclass)', () => { + it('constructs without a user (anonymous owner)', () => { + const owner = new EntityOwner(); + assert.ok(owner instanceof EntityOwner); + }); + + it('constructs without a user passed as undefined', () => { + const owner = new EntityOwner(undefined); + assert.ok(owner instanceof EntityOwner); + }); + + it('constructs when the user has a did', () => { + const owner = new EntityOwner({ id: 'u1', did: 'did:hedera:1', username: 'bob' }); + assert.equal(owner.username, 'bob'); + }); + + it('exposes the user id on the owner', () => { + const owner = new EntityOwner({ id: 'u1', did: 'did:1', username: 'bob' }); + assert.equal(owner.id, 'u1'); + }); + + it('throws an HttpException when the user has no did', () => { + assert.throws(() => new EntityOwner({ id: 'u1', username: 'bob' }), HttpException); + }); + + it('throws with UNPROCESSABLE_ENTITY (422) status when did is missing', () => { + try { + new EntityOwner({ id: 'u1', username: 'bob' }); + assert.fail('expected throw'); + } catch (err) { + assert.equal(err.getStatus(), HttpStatus.UNPROCESSABLE_ENTITY); + } + }); + + it('throws with the "User is not registered." message', () => { + try { + new EntityOwner({ id: 'u1' }); + assert.fail('expected throw'); + } catch (err) { + assert.equal(err.message, 'User is not registered.'); + } + }); + + it('throws when did is an empty string (falsy)', () => { + assert.throws(() => new EntityOwner({ id: 'u1', did: '' }), HttpException); + }); + + it('does not throw when did is present even if other fields are absent', () => { + assert.doesNotThrow(() => new EntityOwner({ did: 'did:only' })); + }); +}); diff --git a/api-gateway/tests/filename-sanitizer.test.js b/api-gateway/tests/filename-sanitizer.test.js new file mode 100644 index 0000000000..b5db3b1da2 --- /dev/null +++ b/api-gateway/tests/filename-sanitizer.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { FilenameSanitizer } from '../dist/helpers/filename-sanitizer.js'; + +describe('FilenameSanitizer.sanitize', () => { + it('passes through a plain alphanumeric name unchanged', () => { + assert.equal(FilenameSanitizer.sanitize('document'), 'document'); + }); + + it('replaces path separators with underscores', () => { + assert.equal(FilenameSanitizer.sanitize('a/b\\c'), 'a_b_c'); + }); + + it('replaces shell-dangerous characters', () => { + assert.equal(FilenameSanitizer.sanitize('a?b*c:d|e"fh'), 'a_b_c_d_e_f_g_h'); + }); + + it('collapses whitespace and dots inside the name to underscores', () => { + assert.equal(FilenameSanitizer.sanitize('a b.txt'), 'a_b_txt'); + }); + + it('replaces ASCII control characters', () => { + const input = `evil\x00name\x1f`; + const sanitized = FilenameSanitizer.sanitize(input); + assert.equal(sanitized.includes('\x00'), false); + assert.equal(sanitized.includes('\x1f'), false); + }); + + it("replaces a name made entirely of dots ('.', '..', '...') with '_'", () => { + assert.equal(FilenameSanitizer.sanitize('...'), '_'); + }); + + it('replaces standalone Windows reserved names regardless of case', () => { + assert.equal(FilenameSanitizer.sanitize('CON'), '_'); + assert.equal(FilenameSanitizer.sanitize('LPT1'), '_'); + // Note: when the reserved name has an extension (e.g. "aux.txt"), + // the dot is replaced before the reserved-name regex runs, so + // the result becomes "aux_txt" rather than "_". Documented here. + assert.equal(FilenameSanitizer.sanitize('aux.txt'), 'aux_txt'); + }); + + it('strips trailing dots and spaces (Windows)', () => { + // `name. ` → dots/spaces replaced → trailing-cleanup adds `_` + assert.match(FilenameSanitizer.sanitize('name. '), /_$/); + }); + + it('collapses runs of underscores into a single underscore', () => { + // a//b//c → a__b__c → a_b_c + assert.equal(FilenameSanitizer.sanitize('a//b//c'), 'a_b_c'); + }); + + it('preserves digits, letters, hyphens, and underscores', () => { + assert.equal(FilenameSanitizer.sanitize('Foo-Bar_42'), 'Foo-Bar_42'); + }); +}); diff --git a/api-gateway/tests/helpers/artifact-constants.test.mjs b/api-gateway/tests/helpers/artifact-constants.test.mjs new file mode 100644 index 0000000000..bb87ca8800 --- /dev/null +++ b/api-gateway/tests/helpers/artifact-constants.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS, UN_REQUIRED_PROPS } from '../../dist/constants/artifact.js'; + +describe('api-gateway artifact constants', () => { + it('REQUIRED_PROPS lists fields that must be present on a saved artifact', () => { + assert.equal(REQUIRED_PROPS.EXTENTION, 'extention'); + assert.equal(REQUIRED_PROPS.NAME, 'name'); + assert.equal(REQUIRED_PROPS.POLICY_ID, 'policyId'); + assert.equal(REQUIRED_PROPS.TYPE, 'type'); + assert.equal(REQUIRED_PROPS.UUID, 'uuid'); + assert.equal(REQUIRED_PROPS._ID, '_id'); + }); + it('UN_REQUIRED_PROPS lists optional fields stripped on import', () => { + assert.equal(UN_REQUIRED_PROPS.CATEGORY, 'category'); + assert.equal(UN_REQUIRED_PROPS.OWNER, 'owner'); + assert.equal(UN_REQUIRED_PROPS.CREATE_DATE, 'createdDate'); + assert.equal(UN_REQUIRED_PROPS.UPDATE_DATE, 'updateDate'); + assert.equal(UN_REQUIRED_PROPS.ID, 'id'); + }); +}); diff --git a/api-gateway/tests/helpers/cache-provider.test.mjs b/api-gateway/tests/helpers/cache-provider.test.mjs new file mode 100644 index 0000000000..d5aa3ba515 --- /dev/null +++ b/api-gateway/tests/helpers/cache-provider.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +let cacheCtorArgs; + +class FakeCache { + constructor(options) { + cacheCtorArgs.push(options); + this.options = options; + } +} + +const ENV_KEYS = [ + 'HOST_CACHE', + 'PORT_CACHE', + 'USE_SENTINEL', + 'SENTINEL_HOSTS', + 'REDIS_MASTER_NAME', +]; + +beforeEach(function () { + this.timeout(60000); +}); + +const loadProvider = async (env) => { + cacheCtorArgs = []; + const saved = {}; + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } + try { + const mod = await esmock('../../dist/helpers/providers/cache-provider.js', { + ioredis: { default: FakeCache }, + }); + return mod; + } finally { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + } +}; + +describe('cacheProvider constants', () => { + it('exports CACHE_CLIENT token equal to the string "CACHE_CLIENT"', async () => { + const { CACHE_CLIENT } = await loadProvider({}); + assert.equal(CACHE_CLIENT, 'CACHE_CLIENT'); + }); + + it('provides under the CACHE_CLIENT token', async () => { + const { cacheProvider, CACHE_CLIENT } = await loadProvider({}); + assert.equal(cacheProvider.provide, CACHE_CLIENT); + }); + + it('exposes a useFactory function', async () => { + const { cacheProvider } = await loadProvider({}); + assert.equal(typeof cacheProvider.useFactory, 'function'); + }); +}); + +describe('cacheProvider direct connection branch', () => { + it('builds a direct Redis client using HOST_CACHE and numeric PORT_CACHE', async () => { + const { cacheProvider } = await loadProvider({ + HOST_CACHE: 'redis-host', + PORT_CACHE: '6380', + }); + const client = cacheProvider.useFactory(); + assert.ok(client instanceof FakeCache); + assert.deepEqual(cacheCtorArgs[0], { host: 'redis-host', port: 6380 }); + }); + + it('uses the direct branch when USE_SENTINEL is unset even if hosts exist', async () => { + const { cacheProvider } = await loadProvider({ + HOST_CACHE: 'h', + PORT_CACHE: '1', + SENTINEL_HOSTS: 'a:1,b:2', + }); + cacheProvider.useFactory(); + assert.deepEqual(cacheCtorArgs[0], { host: 'h', port: 1 }); + }); + + it('uses the direct branch when USE_SENTINEL is "true" but no sentinel hosts are configured', async () => { + const { cacheProvider } = await loadProvider({ + USE_SENTINEL: 'true', + HOST_CACHE: 'h2', + PORT_CACHE: '2', + }); + cacheProvider.useFactory(); + assert.deepEqual(cacheCtorArgs[0], { host: 'h2', port: 2 }); + }); + + it('yields NaN port when PORT_CACHE is missing (pins Number(undefined) behavior)', async () => { + const { cacheProvider } = await loadProvider({ HOST_CACHE: 'h' }); + cacheProvider.useFactory(); + assert.ok(Number.isNaN(cacheCtorArgs[0].port)); + assert.equal(cacheCtorArgs[0].host, 'h'); + }); + + it('treats USE_SENTINEL values other than exactly "true" as false', async () => { + const { cacheProvider } = await loadProvider({ + USE_SENTINEL: 'TRUE', + SENTINEL_HOSTS: 'a:1', + HOST_CACHE: 'h3', + PORT_CACHE: '3', + }); + cacheProvider.useFactory(); + assert.deepEqual(cacheCtorArgs[0], { host: 'h3', port: 3 }); + }); +}); + +describe('cacheProvider sentinel branch', () => { + it('builds a sentinel client mapping host:port pairs and the master name', async () => { + const { cacheProvider } = await loadProvider({ + USE_SENTINEL: 'true', + SENTINEL_HOSTS: 's1:26379,s2:26380', + REDIS_MASTER_NAME: 'mymaster', + }); + cacheProvider.useFactory(); + assert.deepEqual(cacheCtorArgs[0], { + sentinels: [ + { host: 's1', port: 26379 }, + { host: 's2', port: 26380 }, + ], + name: 'mymaster', + }); + }); + + it('passes name as undefined when REDIS_MASTER_NAME is unset', async () => { + const { cacheProvider } = await loadProvider({ + USE_SENTINEL: 'true', + SENTINEL_HOSTS: 's1:1', + }); + cacheProvider.useFactory(); + assert.equal(cacheCtorArgs[0].name, undefined); + assert.deepEqual(cacheCtorArgs[0].sentinels, [{ host: 's1', port: 1 }]); + }); + + it('parses a sentinel host that has no port to NaN port', async () => { + const { cacheProvider } = await loadProvider({ + USE_SENTINEL: 'true', + SENTINEL_HOSTS: 'only-host', + }); + cacheProvider.useFactory(); + assert.equal(cacheCtorArgs[0].sentinels[0].host, 'only-host'); + assert.ok(Number.isNaN(cacheCtorArgs[0].sentinels[0].port)); + }); +}); diff --git a/api-gateway/tests/helpers/decorators/file-param-decorator.test.mjs b/api-gateway/tests/helpers/decorators/file-param-decorator.test.mjs new file mode 100644 index 0000000000..b19239e8f8 --- /dev/null +++ b/api-gateway/tests/helpers/decorators/file-param-decorator.test.mjs @@ -0,0 +1,41 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'; +import { UploadedFiles } from '../../../dist/helpers/decorators/file.js'; + +const extractFactory = (paramDecorator) => { + class Probe { handler(arg) { return arg; } } + paramDecorator()(Probe.prototype, 'handler', 0); + const meta = Reflect.getOwnMetadata(ROUTE_ARGS_METADATA, Probe, 'handler'); + const key = Object.keys(meta)[0]; + return meta[key].factory; +}; + +const ctxWith = (request) => ({ switchToHttp: () => ({ getRequest: () => request }) }); + +describe('UploadedFiles param decorator factory', () => { + const factory = extractFactory(UploadedFiles); + + it('returns req.storedFiles from the http context', async () => { + const files = [{ filename: 'a.csv' }, { filename: 'b.csv' }]; + assert.deepEqual(await factory(undefined, ctxWith({ storedFiles: files })), files); + }); + + it('returns undefined when no files were stored', async () => { + assert.equal(await factory(undefined, ctxWith({})), undefined); + }); + + it('returns null when storedFiles is explicitly null', async () => { + assert.equal(await factory(undefined, ctxWith({ storedFiles: null })), null); + }); + + it('resolves a promise (factory is async)', () => { + const result = factory(undefined, ctxWith({ storedFiles: [] })); + assert.ok(typeof result.then === 'function'); + }); + + it('returns an empty array unchanged', async () => { + const empty = []; + assert.equal(await factory(undefined, ctxWith({ storedFiles: empty })), empty); + }); +}); diff --git a/api-gateway/tests/helpers/decorators/match-validator-decorator.test.mjs b/api-gateway/tests/helpers/decorators/match-validator-decorator.test.mjs new file mode 100644 index 0000000000..280bc74079 --- /dev/null +++ b/api-gateway/tests/helpers/decorators/match-validator-decorator.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { validateSync } from 'class-validator'; +import { Match, MatchConstraint } from '../../../dist/helpers/decorators/match.validator.js'; + +describe('MatchConstraint.validate edge cases', () => { + const c = new MatchConstraint(); + + it('treats two undefined values as matching', () => { + assert.equal(c.validate(undefined, { object: {}, constraints: ['other'] }), true); + }); + + it('treats null vs undefined as not matching', () => { + assert.equal(c.validate(null, { object: { other: undefined }, constraints: ['other'] }), false); + }); + + it('matches identical object references but not structural equals', () => { + const shared = { a: 1 }; + assert.equal(c.validate(shared, { object: { other: shared }, constraints: ['other'] }), true); + assert.equal(c.validate({ a: 1 }, { object: { other: { a: 1 } }, constraints: ['other'] }), false); + }); + + it('matches NaN against itself as false (strict equality)', () => { + assert.equal(c.validate(NaN, { object: { other: NaN }, constraints: ['other'] }), false); + }); + + it('uses the first constraint as the related property name', () => { + const args = { object: { primary: 'v', secondary: 'v' }, constraints: ['primary', 'secondary'] }; + assert.equal(c.validate('v', args), true); + }); +}); + +describe('Match decorator registration + integration', () => { + it('registers a validator that fails on numeric vs string mismatch', () => { + class Dto { + constructor(a, b) { this.a = a; this.b = b; } + } + Match('a')(Dto.prototype, 'b'); + assert.equal(validateSync(new Dto(1, '1')).length, 1); + assert.equal(validateSync(new Dto(1, 1)).length, 0); + }); + + it('passes the custom message through validationOptions', () => { + class Dto2 { + constructor(a, b) { this.a = a; this.b = b; } + } + Match('a', { message: 'fields must match' })(Dto2.prototype, 'b'); + const errors = validateSync(new Dto2('x', 'y')); + assert.equal(errors.length, 1); + assert.equal(errors[0].constraints.Match, 'fields must match'); + }); + + it('registers the constraint against the target constructor', () => { + class Dto3 { + constructor(a, b) { this.a = a; this.b = b; } + } + Match('a')(Dto3.prototype, 'b'); + assert.equal(validateSync(new Dto3('same', 'same')).length, 0); + }); +}); diff --git a/api-gateway/tests/helpers/decorators/singleton-decorator-extra.test.mjs b/api-gateway/tests/helpers/decorators/singleton-decorator-extra.test.mjs new file mode 100644 index 0000000000..b669b6f915 --- /dev/null +++ b/api-gateway/tests/helpers/decorators/singleton-decorator-extra.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { Singleton } from '../../../dist/helpers/decorators/singleton.js'; + +describe('Singleton decorator additional behaviour', () => { + it('passes through constructor arguments on first construction only', () => { + const C = Singleton(class C { constructor(x) { this.x = x; } }); + const a = new C('first'); + const b = new C('second'); + assert.equal(a.x, 'first'); + assert.equal(b.x, 'first'); + assert.equal(a, b); + }); + + it('returns an instance that is instanceof the decorated class', () => { + const C = Singleton(class C {}); + assert.ok(new C() instanceof C); + }); + + it('keeps singletons isolated across two separately decorated classes', () => { + const A = Singleton(class A {}); + const B = Singleton(class B {}); + assert.notEqual(new A(), new B()); + assert.ok(new A() instanceof A); + assert.ok(new B() instanceof B); + }); + + it('constructs a fresh instance for each subclass invocation (prototype mismatch path)', () => { + const Base = Singleton(class Base { constructor() { this.tag = 'base'; } }); + class Child extends Base { constructor() { super(); this.tag = 'child'; } } + const c1 = new Child(); + const c2 = new Child(); + assert.notEqual(c1, c2); + assert.equal(c1.tag, 'child'); + }); + + it('the base singleton is independent from subclass instances', () => { + const Base = Singleton(class Base {}); + class Child extends Base {} + const base = new Base(); + const child = new Child(); + assert.notEqual(base, child); + assert.equal(new Base(), base); + }); + + it('preserves state set on the cached instance across constructions', () => { + const C = Singleton(class C { constructor() { this.count = 0; } }); + const a = new C(); + a.count = 5; + const b = new C(); + assert.equal(b.count, 5); + }); +}); diff --git a/api-gateway/tests/helpers/decorators/user-param-decorator.test.mjs b/api-gateway/tests/helpers/decorators/user-param-decorator.test.mjs new file mode 100644 index 0000000000..0c2a37dfbe --- /dev/null +++ b/api-gateway/tests/helpers/decorators/user-param-decorator.test.mjs @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'; +import { User } from '../../../dist/helpers/decorators/user.js'; + +const extractFactory = (paramDecorator) => { + class Probe { handler(arg) { return arg; } } + paramDecorator()(Probe.prototype, 'handler', 0); + const meta = Reflect.getOwnMetadata(ROUTE_ARGS_METADATA, Probe, 'handler'); + const key = Object.keys(meta)[0]; + return meta[key].factory; +}; + +const ctxWith = (request) => ({ switchToHttp: () => ({ getRequest: () => request }) }); + +describe('User param decorator factory', () => { + const factory = extractFactory(User); + + it('returns request.user from the http context', () => { + const user = { id: 'u1', role: 'USER' }; + assert.deepEqual(factory(undefined, ctxWith({ user })), user); + }); + + it('returns undefined when the request has no user', () => { + assert.equal(factory(undefined, ctxWith({})), undefined); + }); + + it('returns null when request.user is null', () => { + assert.equal(factory(undefined, ctxWith({ user: null })), null); + }); + + it('ignores the data argument and returns the same user', () => { + const user = { id: 'u2' }; + assert.deepEqual(factory('whatever', ctxWith({ user })), user); + }); + + it('reads the request via switchToHttp().getRequest()', () => { + let called = 0; + const ctx = { switchToHttp: () => { called++; return { getRequest: () => ({ user: { id: 9 } }) }; } }; + assert.deepEqual(factory(undefined, ctx), { id: 9 }); + assert.equal(called, 1); + }); +}); diff --git a/api-gateway/tests/helpers/entity-owner.test.mjs b/api-gateway/tests/helpers/entity-owner.test.mjs new file mode 100644 index 0000000000..33c012f424 --- /dev/null +++ b/api-gateway/tests/helpers/entity-owner.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { HttpException } from '@nestjs/common'; +import { EntityOwner } from '../../dist/helpers/entity-owner.js'; + +describe('EntityOwner constructor', () => { + it('constructs with no argument', () => { + assert.doesNotThrow(() => new EntityOwner()); + }); + + it('constructs with a null user', () => { + assert.doesNotThrow(() => new EntityOwner(null)); + }); + + it('constructs with a user that has a did', () => { + assert.doesNotThrow(() => new EntityOwner({ did: 'did:example', role: 'USER', id: '1' })); + }); + + it('exposes the creator from the user did', () => { + const owner = new EntityOwner({ did: 'did:example', role: 'USER', id: '1' }); + assert.equal(owner.creator, 'did:example'); + }); + + it('throws a 422 HttpException when the user has no did', () => { + assert.throws( + () => new EntityOwner({ role: 'USER', id: '1' }), + (e) => e instanceof HttpException && e.getStatus() === 422 + ); + }); + + it('uses the "User is not registered." message on the did-less branch', () => { + assert.throws( + () => new EntityOwner({ username: 'x' }), + (e) => e instanceof HttpException && e.message === 'User is not registered.' + ); + }); +}); diff --git a/api-gateway/tests/helpers/find-replace-entities.test.mjs b/api-gateway/tests/helpers/find-replace-entities.test.mjs new file mode 100644 index 0000000000..b7dbf632b1 --- /dev/null +++ b/api-gateway/tests/helpers/find-replace-entities.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { findAllEntities, replaceAllEntities } from '../../dist/helpers/utils.js'; + +describe('findAllEntities (api-gateway)', () => { + it('returns deduplicated values found at the named field across the children tree', () => { + const tree = { + name: 'a', + children: [ + { name: 'b' }, + { name: 'a', children: [{ name: 'c' }] }, + ], + }; + const result = findAllEntities(tree, 'name').sort(); + assert.deepEqual(result, ['a', 'b', 'c']); + }); + + it('returns [] when the named field is absent everywhere', () => { + const tree = { children: [{ children: [{}] }] }; + assert.deepEqual(findAllEntities(tree, 'tag'), []); + }); + + it('returns [] for null/undefined input', () => { + assert.deepEqual(findAllEntities(null, 'name'), []); + assert.deepEqual(findAllEntities(undefined, 'name'), []); + }); +}); + +describe('replaceAllEntities (api-gateway)', () => { + it('rewrites only matching values at the named field, preserving others', () => { + const tree = { + name: 'old', + children: [ + { name: 'old' }, + { name: 'keep', children: [{ name: 'old' }] }, + ], + }; + replaceAllEntities(tree, 'name', 'old', 'new'); + assert.equal(tree.name, 'new'); + assert.equal(tree.children[0].name, 'new'); + assert.equal(tree.children[1].name, 'keep'); + assert.equal(tree.children[1].children[0].name, 'new'); + }); + + it('is a no-op when no values match', () => { + const tree = { name: 'a', children: [{ name: 'b' }] }; + replaceAllEntities(tree, 'name', 'zz', 'new'); + assert.equal(tree.name, 'a'); + assert.equal(tree.children[0].name, 'b'); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-contracts.test.mjs b/api-gateway/tests/helpers/guardians-contracts.test.mjs new file mode 100644 index 0000000000..db85595bd7 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-contracts.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import { ContractAPI, ContractType } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; + +describe('Guardians contracts', () => { + it('getContracts defaults type to RETIRE', async () => { + const { g, calls } = make(); + await g.getContracts(owner); + assert.deepEqual(calls[0], [ContractAPI.GET_CONTRACTS, { owner, pageIndex: undefined, pageSize: undefined, type: ContractType.RETIRE }]); + }); + + it('getContracts honors explicit type and paging', async () => { + const { g, calls } = make(); + await g.getContracts(owner, ContractType.WIPE, 2, 50); + assert.deepEqual(calls[0], [ContractAPI.GET_CONTRACTS, { owner, pageIndex: 2, pageSize: 50, type: ContractType.WIPE }]); + }); + + it('createContract forwards description and type', async () => { + const { g, calls } = make(); + await g.createContract(owner, 'desc', ContractType.RETIRE); + assert.deepEqual(calls[0], [ContractAPI.CREATE_CONTRACT, { owner, description: 'desc', type: ContractType.RETIRE }]); + }); + + it('createContractV2 forwards description and type', async () => { + const { g, calls } = make(); + await g.createContractV2(owner, 'desc', ContractType.WIPE); + assert.deepEqual(calls[0], [ContractAPI.CREATE_CONTRACT_V2, { owner, description: 'desc', type: ContractType.WIPE }]); + }); + + it('importContract forwards contractId and description', async () => { + const { g, calls } = make(); + await g.importContract(owner, '0.0.1', 'd'); + assert.deepEqual(calls[0], [ContractAPI.IMPORT_CONTRACT, { owner, contractId: '0.0.1', description: 'd' }]); + }); + + it('checkContractPermissions forwards id and owner', async () => { + const { g, calls } = make(); + await g.checkContractPermissions(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.CONTRACT_PERMISSIONS, { id: 'id-1', owner }]); + }); + + it('removeContract forwards owner and id', async () => { + const { g, calls } = make(); + await g.removeContract(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_CONTRACT, { owner, id: 'id-1' }]); + }); + + it('getWipeRequests forwards args', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner, 'c1', 1, 10); + assert.deepEqual(calls[0], [ContractAPI.GET_WIPE_REQUESTS, { owner, contractId: 'c1', pageIndex: 1, pageSize: 10 }]); + }); + + it('enableWipeRequests forwards owner and id', async () => { + const { g, calls } = make(); + await g.enableWipeRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.ENABLE_WIPE_REQUESTS, { owner, id: 'id-1' }]); + }); + + it('disableWipeRequests forwards owner and id', async () => { + const { g, calls } = make(); + await g.disableWipeRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.DISABLE_WIPE_REQUESTS, { owner, id: 'id-1' }]); + }); + + it('approveWipeRequest forwards requestId', async () => { + const { g, calls } = make(); + await g.approveWipeRequest(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.APPROVE_WIPE_REQUEST, { owner, requestId: 'r1' }]); + }); + + it('rejectWipeRequest defaults ban to false', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.REJECT_WIPE_REQUEST, { owner, requestId: 'r1', ban: false }]); + }); + + it('rejectWipeRequest honors ban true', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1', true); + assert.equal(calls[0][1].ban, true); + }); + + it('clearWipeRequests forwards optional hederaId', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.CLEAR_WIPE_REQUESTS, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('clearWipeRequests without hederaId sends undefined', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1'); + assert.equal(calls[0][1].hederaId, undefined); + }); + + it('addWipeAdmin forwards hederaId', async () => { + const { g, calls } = make(); + await g.addWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_WIPE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('removeWipeAdmin forwards hederaId', async () => { + const { g, calls } = make(); + await g.removeWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_WIPE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('addWipeManager forwards hederaId', async () => { + const { g, calls } = make(); + await g.addWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_WIPE_MANAGER, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('removeWipeManager forwards hederaId', async () => { + const { g, calls } = make(); + await g.removeWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_WIPE_MANAGER, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('addWipeWiper forwards optional tokenId', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.deepEqual(calls[0], [ContractAPI.ADD_WIPE_WIPER, { owner, id: 'id-1', hederaId: '0.0.5', tokenId: 'T' }]); + }); + + it('addWipeWiper without tokenId sends undefined', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5'); + assert.equal(calls[0][1].tokenId, undefined); + }); + + it('removeWipeWiper forwards optional tokenId', async () => { + const { g, calls } = make(); + await g.removeWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_WIPE_WIPER, { owner, id: 'id-1', hederaId: '0.0.5', tokenId: 'T' }]); + }); + + it('syncRetirePools forwards owner and id', async () => { + const { g, calls } = make(); + await g.syncRetirePools(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.SYNC_RETIRE_POOLS, { owner, id: 'id-1' }]); + }); + + it('getRetireRequests forwards args', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner, 'c1', 1, 10); + assert.deepEqual(calls[0], [ContractAPI.GET_RETIRE_REQUESTS, { owner, contractId: 'c1', pageIndex: 1, pageSize: 10 }]); + }); + + it('getRetirePools forwards tokens', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner, ['T'], 'c1', 1, 10); + assert.deepEqual(calls[0], [ContractAPI.GET_RETIRE_POOLS, { owner, contractId: 'c1', pageIndex: 1, pageSize: 10, tokens: ['T'] }]); + }); + + it('clearRetireRequests forwards owner and id', async () => { + const { g, calls } = make(); + await g.clearRetireRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.CLEAR_RETIRE_REQUESTS, { owner, id: 'id-1' }]); + }); + + it('clearRetirePools forwards owner and id', async () => { + const { g, calls } = make(); + await g.clearRetirePools(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.CLEAR_RETIRE_POOLS, { owner, id: 'id-1' }]); + }); + + it('setRetirePool forwards options', async () => { + const { g, calls } = make(); + const options = { tokens: [], immediately: true }; + await g.setRetirePool(owner, 'id-1', options); + assert.deepEqual(calls[0], [ContractAPI.SET_RETIRE_POOLS, { owner, id: 'id-1', options }]); + }); + + it('unsetRetirePool forwards poolId', async () => { + const { g, calls } = make(); + await g.unsetRetirePool(owner, 'p1'); + assert.deepEqual(calls[0], [ContractAPI.UNSET_RETIRE_POOLS, { owner, poolId: 'p1' }]); + }); + + it('unsetRetireRequest forwards requestId', async () => { + const { g, calls } = make(); + await g.unsetRetireRequest(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.UNSET_RETIRE_REQUEST, { owner, requestId: 'r1' }]); + }); + + it('retire forwards poolId and tokens', async () => { + const { g, calls } = make(); + await g.retire(owner, 'p1', [{ token: 'T' }]); + assert.deepEqual(calls[0], [ContractAPI.RETIRE, { owner, poolId: 'p1', tokens: [{ token: 'T' }] }]); + }); + + it('approveRetire forwards requestId', async () => { + const { g, calls } = make(); + await g.approveRetire(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.APPROVE_RETIRE, { owner, requestId: 'r1' }]); + }); + + it('cancelRetire forwards requestId', async () => { + const { g, calls } = make(); + await g.cancelRetire(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.CANCEL_RETIRE, { owner, requestId: 'r1' }]); + }); + + it('addRetireAdmin forwards hederaId', async () => { + const { g, calls } = make(); + await g.addRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_RETIRE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('removeRetireAdmin forwards hederaId', async () => { + const { g, calls } = make(); + await g.removeRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_RETIRE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + + it('getRetireVCs forwards paging', async () => { + const { g, calls } = make(); + await g.getRetireVCs(owner, 1, 10); + assert.deepEqual(calls[0], [ContractAPI.GET_RETIRE_VCS, { owner, pageIndex: 1, pageSize: 10 }]); + }); + + it('getRetireVCsFromIndexer forwards contractTopicId', async () => { + const { g, calls } = make(); + await g.getRetireVCsFromIndexer(owner, 'topic-1'); + assert.deepEqual(calls[0], [ContractAPI.GET_RETIRE_VCS_FROM_INDEXER, { owner, contractTopicId: 'topic-1' }]); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-documents.test.mjs b/api-gateway/tests/helpers/guardians-documents.test.mjs new file mode 100644 index 0000000000..e9a28b6ab3 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-documents.test.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const user = { id: 'u1', did: 'did:u' }; + +describe('Guardians documents and ipfs', () => { + it('getVcDocuments forwards user and params', async () => { + const { g, calls } = make(); + await g.getVcDocuments(user, { type: 'X' }); + assert.deepEqual(calls[0], [MessageAPI.GET_VC_DOCUMENTS, { user, params: { type: 'X' } }]); + }); + + it('getVpDocuments forwards user and params', async () => { + const { g, calls } = make(); + await g.getVpDocuments(user, { owner: 'o' }); + assert.deepEqual(calls[0], [MessageAPI.GET_VP_DOCUMENTS, { user, params: { owner: 'o' } }]); + }); + + it('getVpDocuments without params sends undefined', async () => { + const { g, calls } = make(); + await g.getVpDocuments(user); + assert.equal(calls[0][1].params, undefined); + }); + + it('getChain forwards user and id', async () => { + const { g, calls } = make(); + await g.getChain(user, 'hash-1'); + assert.deepEqual(calls[0], [MessageAPI.GET_CHAIN, { user, id: 'hash-1' }]); + }); + + it('uploadArtifact forwards owner artifact parentId', async () => { + const { g, calls } = make(); + await g.uploadArtifact({ a: 1 }, owner, 'p1'); + assert.deepEqual(calls[0], [MessageAPI.UPLOAD_ARTIFACT, { owner, artifact: { a: 1 }, parentId: 'p1' }]); + }); + + it('getArtifacts forwards user and options', async () => { + const { g, calls } = make(); + await g.getArtifacts(user, { f: 1 }); + assert.deepEqual(calls[0], [MessageAPI.GET_ARTIFACTS, { user, options: { f: 1 } }]); + }); + + it('getArtifactsV2 forwards user and options', async () => { + const { g, calls } = make(); + await g.getArtifactsV2(user, { f: 1 }); + assert.deepEqual(calls[0], [MessageAPI.GET_ARTIFACTS_V2, { user, options: { f: 1 } }]); + }); + + it('deleteArtifact forwards owner and artifactId', async () => { + const { g, calls } = make(); + await g.deleteArtifact('a1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_ARTIFACT, { owner, artifactId: 'a1' }]); + }); + + it('addFileIpfs forwards user and buffer', async () => { + const { g, calls } = make(); + await g.addFileIpfs(user, 'data'); + assert.deepEqual(calls[0], [MessageAPI.IPFS_ADD_FILE, { user, buffer: 'data' }]); + }); + + it('addFileIpfsDirect forwards user and buffer', async () => { + const { g, calls } = make(); + await g.addFileIpfsDirect(user, 'data'); + assert.deepEqual(calls[0], [MessageAPI.IPFS_ADD_FILE_DIRECT, { user, buffer: 'data' }]); + }); + + it('deleteIpfsCid forwards user and cid', async () => { + const { g, calls } = make(); + await g.deleteIpfsCid(user, 'cid-1'); + assert.deepEqual(calls[0], [MessageAPI.IPFS_DELETE_CID, { user, cid: 'cid-1' }]); + }); + + it('addFileToDryRunStorage forwards user buffer policyId', async () => { + const { g, calls } = make(); + await g.addFileToDryRunStorage(user, 'data', 'p1'); + assert.deepEqual(calls[0], [MessageAPI.ADD_FILE_DRY_RUN_STORAGE, { user, buffer: 'data', policyId: 'p1' }]); + }); + + it('getFileIpfs forwards responseType', async () => { + const { g, calls } = make(); + await g.getFileIpfs(user, 'cid-1', 'json'); + assert.deepEqual(calls[0], [MessageAPI.IPFS_GET_FILE, { user, cid: 'cid-1', responseType: 'json' }]); + }); + + it('getFileFromDryRunStorage forwards args', async () => { + const { g, calls } = make(); + await g.getFileFromDryRunStorage(user, 'cid-1', 'raw'); + assert.deepEqual(calls[0], [MessageAPI.GET_FILE_DRY_RUN_STORAGE, { user, cid: 'cid-1', responseType: 'raw' }]); + }); + + it('compareDocuments forwards full level set with type moved into payload', async () => { + const { g, calls } = make(); + await g.compareDocuments(user, 'doc', ['i1'], 1, 2, 3, 4, 5, 6); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_DOCUMENTS, { + type: 'doc', user, ids: ['i1'], eventsLvl: 1, propLvl: 2, childrenLvl: 3, idLvl: 4, keyLvl: 5, refLvl: 6 + }]); + }); + + it('compareVPDocuments forwards full level set', async () => { + const { g, calls } = make(); + await g.compareVPDocuments(user, 'doc', ['i1'], 1, 2, 3, 4, 5, 6); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_VP_DOCUMENTS, { + type: 'doc', user, ids: ['i1'], eventsLvl: 1, propLvl: 2, childrenLvl: 3, idLvl: 4, keyLvl: 5, refLvl: 6 + }]); + }); + + it('compareTools forwards levels without key/ref', async () => { + const { g, calls } = make(); + await g.compareTools(user, 'tool', ['i1'], 1, 2, 3, 4); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_TOOLS, { + type: 'tool', user, ids: ['i1'], eventsLvl: 1, propLvl: 2, childrenLvl: 3, idLvl: 4 + }]); + }); + + it('comparePolicies nests options object', async () => { + const { g, calls } = make(); + const policies = [{ type: 'id', value: 'p1' }]; + await g.comparePolicies(owner, 'pol', policies, 1, 2, 3, 4); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_POLICIES, { + user: owner, type: 'pol', policies, options: { propLvl: 2, childrenLvl: 3, eventsLvl: 1, idLvl: 4 } + }]); + }); + + it('compareOriginalPolicies nests options object', async () => { + const { g, calls } = make(); + await g.compareOriginalPolicies(owner, 'pol', 'pid-1', 1, 2, 3, 4); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_ORIGINAL_POLICIES, { + user: owner, type: 'pol', policyId: 'pid-1', options: { propLvl: 2, childrenLvl: 3, eventsLvl: 1, idLvl: 4 } + }]); + }); + + it('compareModules forwards both module ids', async () => { + const { g, calls } = make(); + await g.compareModules(user, 'mod', 'm1', 'm2', 1, 2, 3, 4); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_MODULES, { + type: 'mod', user, moduleId1: 'm1', moduleId2: 'm2', eventsLvl: 1, propLvl: 2, childrenLvl: 3, idLvl: 4 + }]); + }); + + it('compareSchemas forwards schemas and idLvl', async () => { + const { g, calls } = make(); + const schemas = [{ type: 'id', value: 's1' }]; + await g.compareSchemas(owner, 'sch', schemas, 4); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_SCHEMAS, { user: owner, type: 'sch', schemas, idLvl: 4 }]); + }); + + it('searchPolicies forwards user and filters', async () => { + const { g, calls } = make(); + await g.searchPolicies(owner, { text: 'a' }); + assert.deepEqual(calls[0], [MessageAPI.SEARCH_POLICIES, { user: owner, filters: { text: 'a' } }]); + }); + + it('getProfile forwards user', async () => { + const { g, calls } = make(); + await g.getProfile(user); + assert.deepEqual(calls[0], [MessageAPI.GET_USER_PROFILE, { user }]); + }); + + it('getKeys forwards filters and user', async () => { + const { g, calls } = make(); + await g.getKeys(user, { pageIndex: 0, pageSize: 5 }); + assert.deepEqual(calls[0], [MessageAPI.GET_USER_KEYS, { filters: { pageIndex: 0, pageSize: 5 }, user }]); + }); + + it('generateKey forwards optional key', async () => { + const { g, calls } = make(); + await g.generateKey(user, 'm1', 'k1'); + assert.deepEqual(calls[0], [MessageAPI.GENERATE_USER_KEYS, { user, messageId: 'm1', key: 'k1' }]); + }); + + it('generateKey without key sends undefined', async () => { + const { g, calls } = make(); + await g.generateKey(user, 'm1'); + assert.equal(calls[0][1].key, undefined); + }); + + it('deleteKey forwards user and id', async () => { + const { g, calls } = make(); + await g.deleteKey(user, 'id-1'); + assert.deepEqual(calls[0], [MessageAPI.DELETE_USER_KEYS, { user, id: 'id-1' }]); + }); + + it('csvGetFile forwards user and fileId', async () => { + const { g, calls } = make(); + await g.csvGetFile('f1', user); + assert.deepEqual(calls[0], [MessageAPI.GET_FILE, { user, fileId: 'f1' }]); + }); + + it('upsertFile spreads payload alongside user', async () => { + const { g, calls } = make(); + const payload = { file: { buffer: Buffer.from('x') }, fileId: 'f1' }; + await g.upsertFile(payload, user); + assert.deepEqual(calls[0], [MessageAPI.UPSERT_FILE, { user, file: payload.file, fileId: 'f1' }]); + }); + + it('deleteGridFile forwards user and fileId', async () => { + const { g, calls } = make(); + await g.deleteGridFile(user, 'f1'); + assert.deepEqual(calls[0], [MessageAPI.DELETE_FILE, { user, fileId: 'f1' }]); + }); + + it('getRelayerAccountRelationships forwards args', async () => { + const { g, calls } = make(); + await g.getRelayerAccountRelationships('0.0.5', user, { pageIndex: 1, pageSize: 10 }); + assert.deepEqual(calls[0], [MessageAPI.GET_RELAYER_ACCOUNT_RELATIONSHIPS, { relayerAccountId: '0.0.5', user, filters: { pageIndex: 1, pageSize: 10 } }]); + }); + + it('setCredential builds payload from body with dryRun coerced', async () => { + const { g, calls } = make(); + await g.setCredential(user, 'p1', { serviceType: 'svc', dryRun: 1, fields: { a: 1 } }); + assert.deepEqual(calls[0], [MessageAPI.SET_CREDENTIAL, { user, policyId: 'p1', serviceType: 'svc', dryRun: true, fields: { a: 1 } }]); + }); + + it('setCredential coerces falsy dryRun to false', async () => { + const { g, calls } = make(); + await g.setCredential(user, null, { serviceType: 'svc', fields: {} }); + assert.equal(calls[0][1].dryRun, false); + assert.equal(calls[0][1].policyId, null); + }); + + it('getCredentials forwards optional ownerId', async () => { + const { g, calls } = make(); + await g.getCredentials(user, 'p1', 'own-1'); + assert.deepEqual(calls[0], [MessageAPI.GET_CREDENTIALS, { user, policyId: 'p1', ownerId: 'own-1' }]); + }); + + it('deleteCredential defaults dryRun false', async () => { + const { g, calls } = make(); + await g.deleteCredential(user, 'p1', 'svc'); + assert.deepEqual(calls[0], [MessageAPI.DELETE_CREDENTIAL, { user, policyId: 'p1', serviceType: 'svc', dryRun: false }]); + }); + + it('deleteCredential honors dryRun true', async () => { + const { g, calls } = make(); + await g.deleteCredential(user, 'p1', 'svc', true); + assert.equal(calls[0][1].dryRun, true); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-modules-tools.test.mjs b/api-gateway/tests/helpers/guardians-modules-tools.test.mjs new file mode 100644 index 0000000000..2cdea2d271 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-modules-tools.test.mjs @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const task = { taskId: 't1', userId: 'u1' }; + +describe('Guardians modules', () => { + it('createModule forwards module and owner', async () => { + const { g, calls } = make(); + await g.createModule({ name: 'M' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_MODULE, { module: { name: 'M' }, owner }]); + }); + + it('getModule forwards filters and owner', async () => { + const { g, calls } = make(); + await g.getModule({ f: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_MODULES, { filters: { f: 1 }, owner }]); + }); + + it('getModuleV2 forwards filters and owner', async () => { + const { g, calls } = make(); + await g.getModuleV2({ f: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_MODULES_V2, { filters: { f: 1 }, owner }]); + }); + + it('deleteModule forwards uuid and owner', async () => { + const { g, calls } = make(); + await g.deleteModule('u1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_MODULES, { uuid: 'u1', owner }]); + }); + + it('getMenuModule forwards owner', async () => { + const { g, calls } = make(); + await g.getMenuModule(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_MENU_MODULES, { owner }]); + }); + + it('updateModule forwards uuid module owner', async () => { + const { g, calls } = make(); + await g.updateModule('u1', { name: 'M' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_MODULES, { uuid: 'u1', module: { name: 'M' }, owner }]); + }); + + it('getModuleById forwards uuid and owner', async () => { + const { g, calls } = make(); + await g.getModuleById('u1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_MODULE, { uuid: 'u1', owner }]); + }); + + it('exportModuleFile returns base64 decoded buffer', async () => { + const b64 = Buffer.from('moduledata').toString('base64'); + const { g, calls } = make(b64); + const res = await g.exportModuleFile('u1', owner); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'moduledata'); + assert.deepEqual(calls[0], [MessageAPI.MODULE_EXPORT_FILE, { uuid: 'u1', owner }]); + }); + + it('exportModuleMessage forwards uuid and owner', async () => { + const { g, calls } = make(); + await g.exportModuleMessage('u1', owner); + assert.deepEqual(calls[0], [MessageAPI.MODULE_EXPORT_MESSAGE, { uuid: 'u1', owner }]); + }); + + it('importModuleFile forwards zip and owner', async () => { + const { g, calls } = make(); + await g.importModuleFile({ z: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.MODULE_IMPORT_FILE, { zip: { z: 1 }, owner }]); + }); + + it('importModuleMessage forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.importModuleMessage('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.MODULE_IMPORT_MESSAGE, { messageId: 'm1', owner }]); + }); + + it('previewModuleFile forwards zip and owner', async () => { + const { g, calls } = make(); + await g.previewModuleFile({ z: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.MODULE_IMPORT_FILE_PREVIEW, { zip: { z: 1 }, owner }]); + }); + + it('previewModuleMessage forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.previewModuleMessage('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.MODULE_IMPORT_MESSAGE_PREVIEW, { messageId: 'm1', owner }]); + }); + + it('publishModule forwards uuid owner module', async () => { + const { g, calls } = make(); + await g.publishModule('u1', owner, { name: 'M' }); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_MODULES, { uuid: 'u1', owner, module: { name: 'M' } }]); + }); + + it('validateModule forwards owner and module', async () => { + const { g, calls } = make(); + await g.validateModule(owner, { name: 'M' }); + assert.deepEqual(calls[0], [MessageAPI.VALIDATE_MODULES, { owner, module: { name: 'M' } }]); + }); +}); + +describe('Guardians tools', () => { + it('createTool forwards tool and owner', async () => { + const { g, calls } = make(); + await g.createTool({ name: 'T' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TOOL, { tool: { name: 'T' }, owner }]); + }); + + it('createToolAsync forwards task', async () => { + const { g, calls } = make(); + await g.createToolAsync({ name: 'T' }, owner, task); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TOOL_ASYNC, { tool: { name: 'T' }, owner, task }]); + }); + + it('getTools forwards filters and owner', async () => { + const { g, calls } = make(); + await g.getTools({ f: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TOOLS, { filters: { f: 1 }, owner }]); + }); + + it('getToolsV2 forwards fields filters owner', async () => { + const { g, calls } = make(); + await g.getToolsV2(['a'], { f: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TOOLS_V2, { fields: ['a'], filters: { f: 1 }, owner }]); + }); + + it('deleteTool forwards id and owner', async () => { + const { g, calls } = make(); + await g.deleteTool('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_TOOL, { id: 'id-1', owner }]); + }); + + it('getToolById forwards id and owner', async () => { + const { g, calls } = make(); + await g.getToolById('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TOOL, { id: 'id-1', owner }]); + }); + + it('updateTool forwards id tool owner', async () => { + const { g, calls } = make(); + await g.updateTool('id-1', { name: 'T' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_TOOL, { id: 'id-1', tool: { name: 'T' }, owner }]); + }); + + it('publishTool forwards id owner body', async () => { + const { g, calls } = make(); + await g.publishTool('id-1', owner, { v: 1 }); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_TOOL, { id: 'id-1', owner, body: { v: 1 } }]); + }); + + it('publishToolAsync forwards id owner body task', async () => { + const { g, calls } = make(); + await g.publishToolAsync('id-1', owner, { v: 1 }, task); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_TOOL_ASYNC, { id: 'id-1', owner, body: { v: 1 }, task }]); + }); + + it('dryRunTool forwards id and owner', async () => { + const { g, calls } = make(); + await g.dryRunTool('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.DRY_RUN_TOOL, { id: 'id-1', owner }]); + }); + + it('draftTool forwards id and owner', async () => { + const { g, calls } = make(); + await g.draftTool('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.DRAFT_TOOL, { id: 'id-1', owner }]); + }); + + it('validateTool forwards owner and tool', async () => { + const { g, calls } = make(); + await g.validateTool(owner, { name: 'T' }); + assert.deepEqual(calls[0], [MessageAPI.VALIDATE_TOOL, { owner, tool: { name: 'T' } }]); + }); + + it('getMenuTool forwards owner', async () => { + const { g, calls } = make(); + await g.getMenuTool(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_MENU_TOOLS, { owner }]); + }); + + it('checkTool forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.checkTool('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.CHECK_TOOL, { messageId: 'm1', owner }]); + }); + + it('exportToolFile returns base64 decoded buffer', async () => { + const b64 = Buffer.from('tooldata').toString('base64'); + const { g, calls } = make(b64); + const res = await g.exportToolFile('id-1', owner); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'tooldata'); + assert.deepEqual(calls[0], [MessageAPI.TOOL_EXPORT_FILE, { id: 'id-1', owner }]); + }); + + it('exportToolMessage forwards id and owner', async () => { + const { g, calls } = make(); + await g.exportToolMessage('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.TOOL_EXPORT_MESSAGE, { id: 'id-1', owner }]); + }); + + it('importToolFile forwards optional metadata', async () => { + const { g, calls } = make(); + await g.importToolFile({ z: 1 }, owner, { m: 1 }); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_FILE, { zip: { z: 1 }, owner, metadata: { m: 1 } }]); + }); + + it('importToolFile without metadata sends undefined', async () => { + const { g, calls } = make(); + await g.importToolFile({ z: 1 }, owner); + assert.equal(calls[0][1].metadata, undefined); + }); + + it('importToolMessage forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.importToolMessage('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_MESSAGE, { messageId: 'm1', owner }]); + }); + + it('previewToolFile forwards zip and owner', async () => { + const { g, calls } = make(); + await g.previewToolFile({ z: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_FILE_PREVIEW, { zip: { z: 1 }, owner }]); + }); + + it('previewToolMessage forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.previewToolMessage('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_MESSAGE_PREVIEW, { messageId: 'm1', owner }]); + }); + + it('importToolFileAsync forwards task and metadata', async () => { + const { g, calls } = make(); + await g.importToolFileAsync({ z: 1 }, owner, task, { m: 1 }); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_FILE_ASYNC, { zip: { z: 1 }, owner, task, metadata: { m: 1 } }]); + }); + + it('importToolMessageAsync forwards messageId owner task', async () => { + const { g, calls } = make(); + await g.importToolMessageAsync('m1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.TOOL_IMPORT_MESSAGE_ASYNC, { messageId: 'm1', owner, task }]); + }); +}); + +describe('Guardians tags and themes', () => { + const user = { id: 'u1' }; + + it('getSentinelApiKey forwards user', async () => { + const { g, calls } = make(); + await g.getSentinelApiKey(user); + assert.deepEqual(calls[0], [MessageAPI.GET_SENTINEL_API_KEY, { user }]); + }); + + it('createTag forwards tag and owner', async () => { + const { g, calls } = make(); + await g.createTag({ name: 'T' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TAG, { tag: { name: 'T' }, owner }]); + }); + + it('getTags forwards entity targets linkedItems', async () => { + const { g, calls } = make(); + await g.getTags(owner, 'ENT', ['t1'], ['l1']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAGS, { owner, entity: 'ENT', targets: ['t1'], linkedItems: ['l1'] }]); + }); + + it('deleteTag forwards uuid and owner', async () => { + const { g, calls } = make(); + await g.deleteTag('u1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_TAG, { uuid: 'u1', owner }]); + }); + + it('exportTags forwards entity targets', async () => { + const { g, calls } = make(); + await g.exportTags(owner, 'ENT', ['t1']); + assert.deepEqual(calls[0], [MessageAPI.EXPORT_TAGS, { owner, entity: 'ENT', targets: ['t1'], linkedItems: undefined }]); + }); + + it('getTagCache forwards args', async () => { + const { g, calls } = make(); + await g.getTagCache(owner, 'ENT', ['t1'], ['l1']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_CACHE, { owner, entity: 'ENT', targets: ['t1'], linkedItems: ['l1'] }]); + }); + + it('synchronizationTags forwards single target', async () => { + const { g, calls } = make(); + await g.synchronizationTags(owner, 'ENT', 't1', ['l1']); + assert.deepEqual(calls[0], [MessageAPI.GET_SYNCHRONIZATION_TAGS, { owner, entity: 'ENT', target: 't1', linkedItems: ['l1'] }]); + }); + + it('getTagSchemas forwards paging', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner, 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS, { owner, pageIndex: 1, pageSize: 10 }]); + }); + + it('getTagSchemasV2 forwards fields and paging', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, ['a'], 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS_V2, { fields: ['a'], owner, pageIndex: 1, pageSize: 10 }]); + }); + + it('createTagSchema forwards item and owner', async () => { + const { g, calls } = make(); + await g.createTagSchema({ name: 'S' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TAG_SCHEMA, { item: { name: 'S' }, owner }]); + }); + + it('publishTagSchema forwards id version owner', async () => { + const { g, calls } = make(); + await g.publishTagSchema('id-1', '1.0', owner); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_TAG_SCHEMA, { id: 'id-1', version: '1.0', owner }]); + }); + + it('getPublishedTagSchemas forwards user', async () => { + const { g, calls } = make(); + await g.getPublishedTagSchemas(user); + assert.deepEqual(calls[0], [MessageAPI.GET_PUBLISHED_TAG_SCHEMAS, { user }]); + }); + + it('createTheme forwards theme and owner', async () => { + const { g, calls } = make(); + await g.createTheme({ name: 'X' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_THEME, { theme: { name: 'X' }, owner }]); + }); + + it('updateTheme forwards themeId theme owner', async () => { + const { g, calls } = make(); + await g.updateTheme('t1', { name: 'X' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_THEME, { themeId: 't1', theme: { name: 'X' }, owner }]); + }); + + it('getThemes forwards owner', async () => { + const { g, calls } = make(); + await g.getThemes(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_THEMES, { owner }]); + }); + + it('getThemeById forwards themeId and owner', async () => { + const { g, calls } = make(); + await g.getThemeById('t1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_THEME, { themeId: 't1', owner }]); + }); + + it('deleteTheme forwards themeId and owner', async () => { + const { g, calls } = make(); + await g.deleteTheme('t1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_THEME, { themeId: 't1', owner }]); + }); + + it('importThemeFile forwards zip and owner', async () => { + const { g, calls } = make(); + await g.importThemeFile({ z: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.THEME_IMPORT_FILE, { zip: { z: 1 }, owner }]); + }); + + it('exportThemeFile returns base64 decoded buffer', async () => { + const b64 = Buffer.from('themedata').toString('base64'); + const { g, calls } = make(b64); + const res = await g.exportThemeFile('t1', owner); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'themedata'); + assert.deepEqual(calls[0], [MessageAPI.THEME_EXPORT_FILE, { themeId: 't1', owner }]); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-policies.test.mjs b/api-gateway/tests/helpers/guardians-policies.test.mjs new file mode 100644 index 0000000000..878a71ba13 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-policies.test.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const user = { id: 'u1', did: 'did:u' }; +const task = { taskId: 't1', userId: 'u1' }; + +describe('Guardians wizard and branding', () => { + it('wizardPolicyCreate forwards owner and config', async () => { + const { g, calls } = make(); + await g.wizardPolicyCreate({ c: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.WIZARD_POLICY_CREATE, { owner, config: { c: 1 } }]); + }); + + it('wizardPolicyCreateAsync forwards task', async () => { + const { g, calls } = make(); + await g.wizardPolicyCreateAsync({ c: 1 }, owner, task); + assert.deepEqual(calls[0], [MessageAPI.WIZARD_POLICY_CREATE_ASYNC, { owner, config: { c: 1 }, task }]); + }); + + it('wizardPolicyCreateAsyncNew forwards saveState and task', async () => { + const { g, calls } = make(); + await g.wizardPolicyCreateAsyncNew({ c: 1 }, owner, true, task); + assert.deepEqual(calls[0], [MessageAPI.WIZARD_POLICY_CREATE_ASYNC, { owner, config: { c: 1 }, saveState: true, task }]); + }); + + it('wizardGetPolicyConfig forwards policyId config owner', async () => { + const { g, calls } = make(); + await g.wizardGetPolicyConfig('p1', { c: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.WIZARD_GET_POLICY_CONFIG, { policyId: 'p1', config: { c: 1 }, owner }]); + }); + + it('setBranding forwards user and config', async () => { + const { g, calls } = make(); + await g.setBranding(user, '{"k":1}'); + assert.deepEqual(calls[0], [MessageAPI.STORE_BRANDING, { user, config: '{"k":1}' }]); + }); + + it('getBranding sends only the subject', async () => { + const { g, calls } = make(); + await g.getBranding(); + assert.equal(calls[0][0], MessageAPI.GET_BRANDING); + assert.equal(calls[0][1], undefined); + }); +}); + +describe('Guardians suggestions and blocks', () => { + it('policySuggestions forwards user and input', async () => { + const { g, calls } = make(); + await g.policySuggestions({ s: 1 }, user); + assert.deepEqual(calls[0], [MessageAPI.SUGGESTIONS, { user, suggestionsInput: { s: 1 } }]); + }); + + it('setPolicySuggestionsConfig forwards items and user', async () => { + const { g, calls } = make(); + await g.setPolicySuggestionsConfig([{ id: 'a' }], user); + assert.deepEqual(calls[0], [MessageAPI.SET_SUGGESTIONS_CONFIG, { items: [{ id: 'a' }], user }]); + }); + + it('getPolicySuggestionsConfig forwards user', async () => { + const { g, calls } = make(); + await g.getPolicySuggestionsConfig(user); + assert.deepEqual(calls[0], [MessageAPI.GET_SUGGESTIONS_CONFIG, { user }]); + }); + + it('searchBlocks forwards config blockId user', async () => { + const { g, calls } = make(); + await g.searchBlocks({ c: 1 }, 'b1', user); + assert.deepEqual(calls[0], [MessageAPI.SEARCH_BLOCKS, { config: { c: 1 }, blockId: 'b1', user }]); + }); +}); + +describe('Guardians recording and run-record', () => { + it('startRecording forwards policyId owner options', async () => { + const { g, calls } = make(); + await g.startRecording('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.START_RECORDING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('stopRecording returns base64 decoded buffer', async () => { + const b64 = Buffer.from('recording').toString('base64'); + const { g, calls } = make(b64); + const res = await g.stopRecording('p1', owner, { o: 1 }); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'recording'); + assert.deepEqual(calls[0], [MessageAPI.STOP_RECORDING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('getRecordedActions forwards policyId and owner', async () => { + const { g, calls } = make(); + await g.getRecordedActions('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORDED_ACTIONS, { policyId: 'p1', owner }]); + }); + + it('getRecordStatus forwards policyId and owner', async () => { + const { g, calls } = make(); + await g.getRecordStatus('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_STATUS, { policyId: 'p1', owner }]); + }); + + it('runRecord forwards options', async () => { + const { g, calls } = make(); + await g.runRecord('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RUN_RECORD, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('stopRunning forwards options', async () => { + const { g, calls } = make(); + await g.stopRunning('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.STOP_RUNNING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('getRecordResults forwards policyId and owner', async () => { + const { g, calls } = make(); + await g.getRecordResults('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_RESULTS, { policyId: 'p1', owner }]); + }); + + it('getRecordDetails forwards policyId and owner', async () => { + const { g, calls } = make(); + await g.getRecordDetails('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_DETAILS, { policyId: 'p1', owner }]); + }); + + it('getRecordActionDocuments forwards recordActionId', async () => { + const { g, calls } = make(); + await g.getRecordActionDocuments('p1', 'a1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_ACTION_DOCUMENTS, { policyId: 'p1', recordActionId: 'a1', owner }]); + }); + + it('fastForward forwards options', async () => { + const { g, calls } = make(); + await g.fastForward('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.FAST_FORWARD, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('retryStep forwards options', async () => { + const { g, calls } = make(); + await g.retryStep('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RECORD_RETRY_STEP, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('skipStep forwards options', async () => { + const { g, calls } = make(); + await g.skipStep('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RECORD_SKIP_STEP, { policyId: 'p1', owner, options: { o: 1 } }]); + }); +}); + +describe('Guardians external policies', () => { + it('getExternalPolicyRequest forwards filters and owner', async () => { + const { g, calls } = make(); + await g.getExternalPolicyRequest({ f: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_EXTERNAL_POLICY_REQUEST, { filters: { f: 1 }, owner }]); + }); + + it('previewExternalPolicy forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.previewExternalPolicy('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.PREVIEW_EXTERNAL_POLICY, { messageId: 'm1', owner }]); + }); + + it('importExternalPolicy forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.importExternalPolicy('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_EXTERNAL_POLICY, { messageId: 'm1', owner }]); + }); + + it('approveExternalPolicyAsync forwards task', async () => { + const { g, calls } = make(); + await g.approveExternalPolicyAsync('m1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.APPROVE_EXTERNAL_POLICY_ASYNC, { messageId: 'm1', owner, task }]); + }); + + it('rejectExternalPolicyAsync forwards task', async () => { + const { g, calls } = make(); + await g.rejectExternalPolicyAsync('m1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.REJECT_EXTERNAL_POLICY_ASYNC, { messageId: 'm1', owner, task }]); + }); + + it('approveExternalPolicy forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.approveExternalPolicy('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.APPROVE_EXTERNAL_POLICY, { messageId: 'm1', owner }]); + }); + + it('rejectExternalPolicy forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.rejectExternalPolicy('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.REJECT_EXTERNAL_POLICY, { messageId: 'm1', owner }]); + }); + + it('groupExternalPolicyRequests forwards filters and owner', async () => { + const { g, calls } = make(); + const filters = { full: true, pageIndex: 0, pageSize: 5 }; + await g.groupExternalPolicyRequests(filters, owner); + assert.deepEqual(calls[0], [MessageAPI.GROUP_EXTERNAL_POLICY_REQUESTS, { filters, owner }]); + }); + + it('disconnectPolicy forwards messageId full owner', async () => { + const { g, calls } = make(); + await g.disconnectPolicy('m1', true, owner); + assert.deepEqual(calls[0], [MessageAPI.DISCONNECT_EXTERNAL_POLICY, { messageId: 'm1', full: true, owner }]); + }); + + it('deletePolicy forwards messageId and owner', async () => { + const { g, calls } = make(); + await g.deletePolicy('m1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_EXTERNAL_POLICY, { messageId: 'm1', owner }]); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-schemas-extra.test.mjs b/api-gateway/tests/helpers/guardians-schemas-extra.test.mjs new file mode 100644 index 0000000000..b407be79c5 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-schemas-extra.test.mjs @@ -0,0 +1,659 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const user = { id: 'u1', did: 'did:u' }; +const task = { taskId: 't1', userId: 'u1' }; + +describe('@unit Guardians schemas extra', () => { + describe('tag schemas', () => { + it('getTagSchemas forwards owner and paging', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner, 2, 25); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS, { owner, pageIndex: 2, pageSize: 25 }]); + }); + + it('getTagSchemas with only owner sends undefined paging', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS, { owner, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getTagSchemas returns the broker response', async () => { + const { g } = make({ items: [1], count: 1 }); + const res = await g.getTagSchemas(owner, 0, 10); + assert.deepEqual(res, { items: [1], count: 1 }); + }); + + it('getTagSchemas uses GET_TAG_SCHEMAS subject', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner, 1, 1); + assert.equal(calls[0][0], MessageAPI.GET_TAG_SCHEMAS); + }); + + it('getTagSchemasV2 forwards fields owner and paging', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, ['a', 'b'], 3, 30); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS_V2, { fields: ['a', 'b'], owner, pageIndex: 3, pageSize: 30 }]); + }); + + it('getTagSchemasV2 with only fields and owner sends undefined paging', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, ['x']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS_V2, { fields: ['x'], owner, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getTagSchemasV2 places fields before owner in payload', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, ['f'], 1, 2); + assert.deepEqual(Object.keys(calls[0][1]), ['fields', 'owner', 'pageIndex', 'pageSize']); + }); + + it('createTagSchema forwards item and owner', async () => { + const { g, calls } = make(); + await g.createTagSchema({ name: 'TS' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TAG_SCHEMA, { item: { name: 'TS' }, owner }]); + }); + + it('createTagSchema returns the broker response', async () => { + const { g } = make({ id: 'tag-1' }); + const res = await g.createTagSchema({ name: 'TS' }, owner); + assert.deepEqual(res, { id: 'tag-1' }); + }); + + it('publishTagSchema forwards id version owner', async () => { + const { g, calls } = make(); + await g.publishTagSchema('id-9', '2.0', owner); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_TAG_SCHEMA, { id: 'id-9', version: '2.0', owner }]); + }); + + it('publishTagSchema uses PUBLISH_TAG_SCHEMA subject', async () => { + const { g, calls } = make(); + await g.publishTagSchema('id-9', '2.0', owner); + assert.equal(calls[0][0], MessageAPI.PUBLISH_TAG_SCHEMA); + }); + + it('getPublishedTagSchemas forwards user', async () => { + const { g, calls } = make(); + await g.getPublishedTagSchemas(user); + assert.deepEqual(calls[0], [MessageAPI.GET_PUBLISHED_TAG_SCHEMAS, { user }]); + }); + + it('getPublishedTagSchemas returns the broker response', async () => { + const { g } = make([{ id: 's' }]); + const res = await g.getPublishedTagSchemas(user); + assert.deepEqual(res, [{ id: 's' }]); + }); + }); + + describe('compareSchemas', () => { + it('forwards user type schemas idLvl', async () => { + const { g, calls } = make(); + const schemas = [{ type: 'id', value: 'v1' }]; + await g.compareSchemas(owner, 'simple', schemas, '0'); + assert.deepEqual(calls[0], [MessageAPI.COMPARE_SCHEMAS, { user: owner, type: 'simple', schemas, idLvl: '0' }]); + }); + + it('accepts numeric idLvl', async () => { + const { g, calls } = make(); + await g.compareSchemas(owner, 'full', [], 1); + assert.equal(calls[0][1].idLvl, 1); + }); + + it('passes schemas array through unchanged', async () => { + const { g, calls } = make(); + const schemas = [{ type: 'policy-message', value: 'm', policy: 'p' }]; + await g.compareSchemas(owner, 'x', schemas, '2'); + assert.equal(calls[0][1].schemas, schemas); + }); + + it('renames first arg to user in payload', async () => { + const { g, calls } = make(); + await g.compareSchemas(owner, 't', [], '0'); + assert.equal(calls[0][1].user, owner); + assert.deepEqual(Object.keys(calls[0][1]), ['user', 'type', 'schemas', 'idLvl']); + }); + + it('returns the broker response', async () => { + const { g } = make({ total: 99 }); + const res = await g.compareSchemas(owner, 't', [], '0'); + assert.deepEqual(res, { total: 99 }); + }); + }); + + describe('schema rules', () => { + const rule = { id: 'r1', config: {} }; + + it('createSchemaRule forwards rule and owner', async () => { + const { g, calls } = make(); + await g.createSchemaRule(rule, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_SCHEMA_RULE, { rule, owner }]); + }); + + it('createSchemaRule returns the broker response', async () => { + const { g } = make({ id: 'r1' }); + const res = await g.createSchemaRule(rule, owner); + assert.deepEqual(res, { id: 'r1' }); + }); + + it('getSchemaRules forwards filters and owner', async () => { + const { g, calls } = make(); + await g.getSchemaRules({ policyId: 'p' }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_RULES, { filters: { policyId: 'p' }, owner }]); + }); + + it('getSchemaRules returns count response', async () => { + const { g } = make({ items: [], count: 0 }); + const res = await g.getSchemaRules({}, owner); + assert.deepEqual(res, { items: [], count: 0 }); + }); + + it('getSchemaRuleById forwards ruleId and owner', async () => { + const { g, calls } = make(); + await g.getSchemaRuleById('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_RULE, { ruleId: 'r1', owner }]); + }); + + it('getSchemaRuleById uses GET_SCHEMA_RULE subject', async () => { + const { g, calls } = make(); + await g.getSchemaRuleById('r1', owner); + assert.equal(calls[0][0], MessageAPI.GET_SCHEMA_RULE); + }); + + it('getSchemaRuleRelationships forwards ruleId and owner', async () => { + const { g, calls } = make(); + await g.getSchemaRuleRelationships('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_RULE_RELATIONSHIPS, { ruleId: 'r1', owner }]); + }); + + it('updateSchemaRule forwards ruleId rule owner', async () => { + const { g, calls } = make(); + await g.updateSchemaRule('r1', rule, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_SCHEMA_RULE, { ruleId: 'r1', rule, owner }]); + }); + + it('updateSchemaRule keeps key order ruleId rule owner', async () => { + const { g, calls } = make(); + await g.updateSchemaRule('r1', rule, owner); + assert.deepEqual(Object.keys(calls[0][1]), ['ruleId', 'rule', 'owner']); + }); + + it('deleteSchemaRule forwards ruleId and owner', async () => { + const { g, calls } = make(); + await g.deleteSchemaRule('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_SCHEMA_RULE, { ruleId: 'r1', owner }]); + }); + + it('deleteSchemaRule returns boolean response', async () => { + const { g } = make(true); + const res = await g.deleteSchemaRule('r1', owner); + assert.equal(res, true); + }); + + it('activateSchemaRule forwards ruleId and owner', async () => { + const { g, calls } = make(); + await g.activateSchemaRule('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.ACTIVATE_SCHEMA_RULE, { ruleId: 'r1', owner }]); + }); + + it('inactivateSchemaRule forwards ruleId and owner', async () => { + const { g, calls } = make(); + await g.inactivateSchemaRule('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.INACTIVATE_SCHEMA_RULE, { ruleId: 'r1', owner }]); + }); + + it('activate and inactivate use distinct subjects', async () => { + const { g, calls } = make(); + await g.activateSchemaRule('r1', owner); + await g.inactivateSchemaRule('r1', owner); + assert.equal(calls[0][0], MessageAPI.ACTIVATE_SCHEMA_RULE); + assert.equal(calls[1][0], MessageAPI.INACTIVATE_SCHEMA_RULE); + }); + + it('getSchemaRuleData forwards options and owner', async () => { + const { g, calls } = make(); + await g.getSchemaRuleData({ policyId: 'p', schemaId: 's' }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_RULE_DATA, { options: { policyId: 'p', schemaId: 's' }, owner }]); + }); + + it('getSchemaRuleData returns array response', async () => { + const { g } = make([{ id: 'd1' }]); + const res = await g.getSchemaRuleData({}, owner); + assert.deepEqual(res, [{ id: 'd1' }]); + }); + + it('importSchemaRule forwards zip policyId owner', async () => { + const { g, calls } = make(); + const zip = { buf: 1 }; + await g.importSchemaRule(zip, 'p1', owner); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_SCHEMA_RULE_FILE, { zip, policyId: 'p1', owner }]); + }); + + it('importSchemaRule keeps key order zip policyId owner', async () => { + const { g, calls } = make(); + await g.importSchemaRule({ b: 1 }, 'p1', owner); + assert.deepEqual(Object.keys(calls[0][1]), ['zip', 'policyId', 'owner']); + }); + + it('exportSchemaRule forwards ruleId and owner', async () => { + const b64 = Buffer.from('rulefile').toString('base64'); + const { g, calls } = make(b64); + await g.exportSchemaRule('r1', owner); + assert.deepEqual(calls[0], [MessageAPI.EXPORT_SCHEMA_RULE_FILE, { ruleId: 'r1', owner }]); + }); + + it('exportSchemaRule decodes base64 into a buffer', async () => { + const b64 = Buffer.from('rulefile').toString('base64'); + const { g } = make(b64); + const res = await g.exportSchemaRule('r1', owner); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'rulefile'); + }); + + it('previewSchemaRule forwards zip and owner', async () => { + const { g, calls } = make(); + const zip = { buf: 2 }; + await g.previewSchemaRule(zip, owner); + assert.deepEqual(calls[0], [MessageAPI.PREVIEW_SCHEMA_RULE_FILE, { zip, owner }]); + }); + + it('previewSchemaRule returns the broker response', async () => { + const { g } = make({ preview: true }); + const res = await g.previewSchemaRule({}, owner); + assert.deepEqual(res, { preview: true }); + }); + }); + + describe('return-value passthrough and default-param branches', () => { + it('getSchemasByOwner returns broker response', async () => { + const { g } = make({ items: [{ id: 's' }], count: 1 }); + const res = await g.getSchemasByOwner({ category: 'c' }, owner); + assert.deepEqual(res, { items: [{ id: 's' }], count: 1 }); + }); + + it('getSchemasByOwnerV2 returns broker response', async () => { + const { g } = make({ items: [], count: 0 }); + const res = await g.getSchemasByOwnerV2({}, owner); + assert.deepEqual(res, { items: [], count: 0 }); + }); + + it('getSchemasByUUID returns array response', async () => { + const { g } = make([{ uuid: 'u' }]); + const res = await g.getSchemasByUUID(owner, 'uuid-1'); + assert.deepEqual(res, [{ uuid: 'u' }]); + }); + + it('getSchemaByType omits owner key entirely when owner is empty string', async () => { + const { g, calls } = make(); + await g.getSchemaByType(user, 'TypeA', ''); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA, { user, type: 'TypeA' }]); + assert.ok(!('owner' in calls[0][1])); + }); + + it('getSchemaByType includes owner when truthy', async () => { + const { g, calls } = make(); + await g.getSchemaByType(user, 'TypeA', 'own-x'); + assert.ok('owner' in calls[0][1]); + }); + + it('getSchemaById and getSchemaByType share the GET_SCHEMA subject', async () => { + const { g, calls } = make(); + await g.getSchemaById(user, 'id-1'); + await g.getSchemaByType(user, 'T'); + assert.equal(calls[0][0], MessageAPI.GET_SCHEMA); + assert.equal(calls[1][0], MessageAPI.GET_SCHEMA); + }); + + it('getSchemaTreePlantUML omits one explicit flag and keeps others default', async () => { + const { g, calls } = make(); + await g.getSchemaTreePlantUML('id-1', owner, true, true); + assert.deepEqual(calls[0][1], { + id: 'id-1', owner, includeFields: true, includeFormulas: true, includeDependencies: false + }); + }); + + it('importSchemasByMessages returns broker response', async () => { + const { g } = make([{ doc: 1 }]); + const res = await g.importSchemasByMessages(['m1'], owner, 't'); + assert.deepEqual(res, [{ doc: 1 }]); + }); + + it('importSchemasByFile returns schemasMap errors shape', async () => { + const { g } = make({ schemasMap: [1], errors: [] }); + const res = await g.importSchemasByFile({ f: 1 }, owner, 't'); + assert.deepEqual(res, { schemasMap: [1], errors: [] }); + }); + + it('importSchemasByFileAsync without schemasIds sends undefined', async () => { + const { g, calls } = make(); + await g.importSchemasByFileAsync({ f: 1 }, owner, 't', task); + assert.equal(calls[0][1].schemasIds, undefined); + assert.ok('schemasIds' in calls[0][1]); + }); + + it('previewSchemasByFile returns the same array reference and does not call broker', async () => { + const { g, calls } = make(); + const files = [{ id: 1 }, { id: 2 }]; + const res = await g.previewSchemasByFile(files); + assert.equal(res, files); + assert.equal(calls.length, 0); + }); + + it('previewSchemasByFile returns empty array unchanged', async () => { + const { g } = make(); + const files = []; + const res = await g.previewSchemasByFile(files); + assert.equal(res, files); + }); + + it('getSchemasDublicates with names and owner but no policyId', async () => { + const { g, calls } = make(); + await g.getSchemasDublicates(['n1'], owner); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_CHECK_FOR_DUBLICATES, { schemaNames: ['n1'], owner, policyId: undefined }]); + }); + + it('createSchema returns broker response', async () => { + const { g } = make([{ id: 'new' }]); + const res = await g.createSchema({ name: 'S' }, owner); + assert.deepEqual(res, [{ id: 'new' }]); + }); + + it('copySchemaAsync forwards copyNested false', async () => { + const { g, calls } = make(); + await g.copySchemaAsync('iri-1', 'topic-1', 'name-1', owner, task, false); + assert.equal(calls[0][1].copyNested, false); + }); + + it('copySchemaAsync keeps payload key order', async () => { + const { g, calls } = make(); + await g.copySchemaAsync('iri-1', 'topic-1', 'name-1', owner, task, true); + assert.deepEqual(Object.keys(calls[0][1]), ['iri', 'topicId', 'name', 'task', 'owner', 'copyNested']); + }); + + it('deleteSchema explicit includeChildren false', async () => { + const { g, calls } = make(); + await g.deleteSchema('s1', owner, task, false); + assert.equal(calls[0][1].includeChildren, false); + }); + + it('deleteSchemasByIds honors includeChildren true', async () => { + const { g, calls } = make(); + await g.deleteSchemasByIds(['s1'], owner, task, true); + assert.equal(calls[0][1].includeChildren, true); + }); + + it('deleteSchemasByTopic returns broker response', async () => { + const { g } = make(false); + const res = await g.deleteSchemasByTopic('topic-1', owner); + assert.equal(res, false); + }); + + it('publishSchema returns broker response', async () => { + const { g } = make({ status: 'PUBLISHED' }); + const res = await g.publishSchema('id-1', '1.0', owner); + assert.deepEqual(res, { status: 'PUBLISHED' }); + }); + + it('exportSchemas returns broker response', async () => { + const { g } = make([{ id: 'a' }]); + const res = await g.exportSchemas(['a'], owner); + assert.deepEqual(res, [{ id: 'a' }]); + }); + + it('createSystemSchema returns broker response', async () => { + const { g } = make({ id: 'sys' }); + const res = await g.createSystemSchema({ name: 'S' }, owner); + assert.deepEqual(res, { id: 'sys' }); + }); + + it('getSystemSchemas forwards undefined paging when omitted', async () => { + const { g, calls } = make(); + await g.getSystemSchemas(user); + assert.deepEqual(calls[0], [MessageAPI.GET_SYSTEM_SCHEMAS, { user, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getSystemSchemasV2 forwards undefined paging when omitted', async () => { + const { g, calls } = make(); + await g.getSystemSchemasV2(user, ['f']); + assert.deepEqual(calls[0], [MessageAPI.GET_SYSTEM_SCHEMAS_V2, { user, fields: ['f'], pageIndex: undefined, pageSize: undefined }]); + }); + + it('activeSchema returns broker response', async () => { + const { g } = make({ active: true }); + const res = await g.activeSchema('id-1', owner); + assert.deepEqual(res, { active: true }); + }); + + it('getSchemaByEntity uses GET_SYSTEM_SCHEMA subject', async () => { + const { g, calls } = make(); + await g.getSchemaByEntity(user, 'ENT'); + assert.equal(calls[0][0], MessageAPI.GET_SYSTEM_SCHEMA); + }); + + it('getListSchemas returns array response', async () => { + const { g } = make([{ id: 'l' }]); + const res = await g.getListSchemas(owner); + assert.deepEqual(res, [{ id: 'l' }]); + }); + + it('getSubSchemas keeps payload key order topicId owner category', async () => { + const { g, calls } = make(); + await g.getSubSchemas('cat', 'topic-1', owner); + assert.deepEqual(Object.keys(calls[0][1]), ['topicId', 'owner', 'category']); + }); + }); + + describe('xlsx and template variants', () => { + it('exportSchemasXlsx decodes empty broker payload to empty buffer', async () => { + const { g } = make(''); + const res = await g.exportSchemasXlsx(owner, ['a']); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.length, 0); + }); + + it('importSchemasByXlsx keeps payload key order owner xlsx topicId', async () => { + const { g, calls } = make(); + const xlsx = new ArrayBuffer(4); + await g.importSchemasByXlsx(owner, 'topic-1', xlsx); + assert.deepEqual(Object.keys(calls[0][1]), ['owner', 'xlsx', 'topicId']); + }); + + it('importSchemasByXlsxAsync without schemasIds sends undefined', async () => { + const { g, calls } = make(); + const xlsx = new ArrayBuffer(4); + await g.importSchemasByXlsxAsync(owner, 'topic-1', xlsx, task); + assert.equal(calls[0][1].schemasIds, undefined); + assert.ok('schemasIds' in calls[0][1]); + }); + + it('previewSchemasByFileXlsx returns broker response', async () => { + const { g } = make({ preview: 1 }); + const xlsx = new ArrayBuffer(4); + const res = await g.previewSchemasByFileXlsx(owner, xlsx); + assert.deepEqual(res, { preview: 1 }); + }); + + it('getFileTemplate returns broker response', async () => { + const { g } = make('csv-content'); + const res = await g.getFileTemplate(owner, 'file.csv'); + assert.equal(res, 'csv-content'); + }); + + it('getFileTemplate keeps payload key order owner filename', async () => { + const { g, calls } = make(); + await g.getFileTemplate(owner, 'file.csv'); + assert.deepEqual(Object.keys(calls[0][1]), ['owner', 'filename']); + }); + }); + + describe('async/task variants pass the task object through', () => { + it('createSchemaAsync forwards the task reference', async () => { + const { g, calls } = make(); + await g.createSchemaAsync({ name: 'S' }, owner, task); + assert.equal(calls[0][1].task, task); + assert.equal(calls[0][0], MessageAPI.CREATE_SCHEMA_ASYNC); + }); + + it('publishSchemaAsync forwards the task reference', async () => { + const { g, calls } = make(); + await g.publishSchemaAsync('id-1', '1.0', owner, task); + assert.equal(calls[0][1].task, task); + }); + + it('previewSchemasByMessagesAsync forwards the task reference', async () => { + const { g, calls } = make(); + await g.previewSchemasByMessagesAsync(owner, ['m1'], task); + assert.equal(calls[0][1].task, task); + assert.equal(calls[0][0], MessageAPI.PREVIEW_SCHEMA_ASYNC); + }); + + it('importSchemasByMessagesAsync keeps payload key order', async () => { + const { g, calls } = make(); + await g.importSchemasByMessagesAsync(['m1'], owner, 't', task, ['s1']); + assert.deepEqual(Object.keys(calls[0][1]), ['messageIds', 'owner', 'topicId', 'task', 'schemasIds']); + }); + + it('importSchemasByFileAsync keeps payload key order', async () => { + const { g, calls } = make(); + await g.importSchemasByFileAsync({ f: 1 }, owner, 't', task, ['s']); + assert.deepEqual(Object.keys(calls[0][1]), ['files', 'owner', 'topicId', 'task', 'schemasIds']); + }); + + it('importSchemasByXlsxAsync keeps payload key order', async () => { + const { g, calls } = make(); + await g.importSchemasByXlsxAsync(owner, 't', new ArrayBuffer(2), task, ['s']); + assert.deepEqual(Object.keys(calls[0][1]), ['owner', 'xlsx', 'topicId', 'task', 'schemasIds']); + }); + + it('createSchemaAsync returns broker response', async () => { + const { g } = make({ taskId: 't1' }); + const res = await g.createSchemaAsync({}, owner, task); + assert.deepEqual(res, { taskId: 't1' }); + }); + + it('publishSchemaAsync returns broker response', async () => { + const { g } = make({ taskId: 't1' }); + const res = await g.publishSchemaAsync('id', 'v', owner, task); + assert.deepEqual(res, { taskId: 't1' }); + }); + }); + + describe('subject identity for gap methods', () => { + it('compareSchemas uses COMPARE_SCHEMAS', async () => { + const { g, calls } = make(); + await g.compareSchemas(owner, 't', [], '0'); + assert.equal(calls[0][0], MessageAPI.COMPARE_SCHEMAS); + }); + + it('createSchemaRule uses CREATE_SCHEMA_RULE', async () => { + const { g, calls } = make(); + await g.createSchemaRule({}, owner); + assert.equal(calls[0][0], MessageAPI.CREATE_SCHEMA_RULE); + }); + + it('getSchemaRules uses GET_SCHEMA_RULES', async () => { + const { g, calls } = make(); + await g.getSchemaRules({}, owner); + assert.equal(calls[0][0], MessageAPI.GET_SCHEMA_RULES); + }); + + it('getSchemaRuleRelationships uses GET_SCHEMA_RULE_RELATIONSHIPS', async () => { + const { g, calls } = make(); + await g.getSchemaRuleRelationships('r', owner); + assert.equal(calls[0][0], MessageAPI.GET_SCHEMA_RULE_RELATIONSHIPS); + }); + + it('updateSchemaRule uses UPDATE_SCHEMA_RULE', async () => { + const { g, calls } = make(); + await g.updateSchemaRule('r', {}, owner); + assert.equal(calls[0][0], MessageAPI.UPDATE_SCHEMA_RULE); + }); + + it('deleteSchemaRule uses DELETE_SCHEMA_RULE', async () => { + const { g, calls } = make(); + await g.deleteSchemaRule('r', owner); + assert.equal(calls[0][0], MessageAPI.DELETE_SCHEMA_RULE); + }); + + it('getSchemaRuleData uses GET_SCHEMA_RULE_DATA', async () => { + const { g, calls } = make(); + await g.getSchemaRuleData({}, owner); + assert.equal(calls[0][0], MessageAPI.GET_SCHEMA_RULE_DATA); + }); + + it('importSchemaRule uses IMPORT_SCHEMA_RULE_FILE', async () => { + const { g, calls } = make(); + await g.importSchemaRule({}, 'p', owner); + assert.equal(calls[0][0], MessageAPI.IMPORT_SCHEMA_RULE_FILE); + }); + + it('exportSchemaRule uses EXPORT_SCHEMA_RULE_FILE', async () => { + const { g, calls } = make(Buffer.from('x').toString('base64')); + await g.exportSchemaRule('r', owner); + assert.equal(calls[0][0], MessageAPI.EXPORT_SCHEMA_RULE_FILE); + }); + + it('previewSchemaRule uses PREVIEW_SCHEMA_RULE_FILE', async () => { + const { g, calls } = make(); + await g.previewSchemaRule({}, owner); + assert.equal(calls[0][0], MessageAPI.PREVIEW_SCHEMA_RULE_FILE); + }); + + it('createTagSchema uses CREATE_TAG_SCHEMA', async () => { + const { g, calls } = make(); + await g.createTagSchema({}, owner); + assert.equal(calls[0][0], MessageAPI.CREATE_TAG_SCHEMA); + }); + + it('getTagSchemasV2 uses GET_TAG_SCHEMAS_V2', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, []); + assert.equal(calls[0][0], MessageAPI.GET_TAG_SCHEMAS_V2); + }); + + it('getPublishedTagSchemas uses GET_PUBLISHED_TAG_SCHEMAS', async () => { + const { g, calls } = make(); + await g.getPublishedTagSchemas(user); + assert.equal(calls[0][0], MessageAPI.GET_PUBLISHED_TAG_SCHEMAS); + }); + }); + + describe('single broker call per method', () => { + it('schema-rule methods each issue exactly one sendMessage', async () => { + const { g, calls } = make(); + await g.createSchemaRule({}, owner); + await g.getSchemaRules({}, owner); + await g.getSchemaRuleById('r', owner); + await g.getSchemaRuleRelationships('r', owner); + await g.updateSchemaRule('r', {}, owner); + await g.deleteSchemaRule('r', owner); + await g.activateSchemaRule('r', owner); + await g.inactivateSchemaRule('r', owner); + await g.getSchemaRuleData({}, owner); + await g.importSchemaRule({}, 'p', owner); + await g.previewSchemaRule({}, owner); + assert.equal(calls.length, 11); + }); + + it('tag-schema methods each issue exactly one sendMessage', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner); + await g.getTagSchemasV2(owner, []); + await g.createTagSchema({}, owner); + await g.publishTagSchema('i', 'v', owner); + await g.getPublishedTagSchemas(user); + assert.equal(calls.length, 5); + }); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-schemas.test.mjs b/api-gateway/tests/helpers/guardians-schemas.test.mjs new file mode 100644 index 0000000000..d994a7e1b9 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-schemas.test.mjs @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const user = { id: 'u1', did: 'did:u' }; +const task = { taskId: 't1', userId: 'u1' }; + +describe('Guardians schemas', () => { + it('getSchemasByOwner forwards options and owner', async () => { + const { g, calls } = make(); + await g.getSchemasByOwner({ category: 'c' }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMAS, { options: { category: 'c' }, owner }]); + }); + + it('getSchemasByOwnerV2 forwards options and owner', async () => { + const { g, calls } = make(); + await g.getSchemasByOwnerV2({ category: 'c' }, owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMAS_V2, { options: { category: 'c' }, owner }]); + }); + + it('getSchemasByUUID forwards owner and uuid', async () => { + const { g, calls } = make(); + await g.getSchemasByUUID(owner, 'uuid-1'); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMAS_BY_UUID, { owner, uuid: 'uuid-1' }]); + }); + + it('getSchemaByType includes owner when present', async () => { + const { g, calls } = make(); + await g.getSchemaByType(user, 'TypeA', 'own-x'); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA, { user, type: 'TypeA', owner: 'own-x' }]); + }); + + it('getSchemaByType omits owner when absent', async () => { + const { g, calls } = make(); + await g.getSchemaByType(user, 'TypeA'); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA, { user, type: 'TypeA' }]); + }); + + it('getSchemaById forwards user and id', async () => { + const { g, calls } = make(); + await g.getSchemaById(user, 'id-1'); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA, { user, id: 'id-1' }]); + }); + + it('getSchemaParents forwards id and owner', async () => { + const { g, calls } = make(); + await g.getSchemaParents('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_PARENTS, { id: 'id-1', owner }]); + }); + + it('getSchemaDeletionPreview forwards schemaIds and owner', async () => { + const { g, calls } = make(); + await g.getSchemaDeletionPreview(['a', 'b'], owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_DELETION_PREVIEW, { schemaIds: ['a', 'b'], owner }]); + }); + + it('getSchemaTree forwards id and owner', async () => { + const { g, calls } = make(); + await g.getSchemaTree('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_TREE, { id: 'id-1', owner }]); + }); + + it('getSchemaTreePlantUML uses defaults', async () => { + const { g, calls } = make(); + await g.getSchemaTreePlantUML('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_TREE_PLANTUML, { + id: 'id-1', owner, includeFields: true, includeFormulas: false, includeDependencies: false + }]); + }); + + it('getSchemaTreePlantUML forwards explicit flags', async () => { + const { g, calls } = make(); + await g.getSchemaTreePlantUML('id-1', owner, false, true, true); + assert.deepEqual(calls[0], [MessageAPI.GET_SCHEMA_TREE_PLANTUML, { + id: 'id-1', owner, includeFields: false, includeFormulas: true, includeDependencies: true + }]); + }); + + it('importSchemasByMessages forwards args', async () => { + const { g, calls } = make(); + await g.importSchemasByMessages(['m1'], owner, 'topic-1'); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_SCHEMAS_BY_MESSAGES, { messageIds: ['m1'], owner, topicId: 'topic-1' }]); + }); + + it('importSchemasByMessagesAsync forwards task and optional schemasIds', async () => { + const { g, calls } = make(); + await g.importSchemasByMessagesAsync(['m1'], owner, 'topic-1', task, ['s1']); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_SCHEMAS_BY_MESSAGES_ASYNC, { messageIds: ['m1'], owner, topicId: 'topic-1', task, schemasIds: ['s1'] }]); + }); + + it('importSchemasByMessagesAsync without schemasIds sends undefined', async () => { + const { g, calls } = make(); + await g.importSchemasByMessagesAsync(['m1'], owner, 'topic-1', task); + assert.deepEqual(calls[0][1].schemasIds, undefined); + }); + + it('importSchemasByFile forwards args', async () => { + const { g, calls } = make(); + await g.importSchemasByFile({ f: 1 }, owner, 'topic-1'); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_SCHEMAS_BY_FILE, { files: { f: 1 }, owner, topicId: 'topic-1' }]); + }); + + it('importSchemasByFileAsync forwards task and schemasIds', async () => { + const { g, calls } = make(); + await g.importSchemasByFileAsync({ f: 1 }, owner, 'topic-1', task, ['s']); + assert.deepEqual(calls[0], [MessageAPI.IMPORT_SCHEMAS_BY_FILE_ASYNC, { files: { f: 1 }, owner, topicId: 'topic-1', task, schemasIds: ['s'] }]); + }); + + it('previewSchemasByMessages forwards args', async () => { + const { g, calls } = make(); + await g.previewSchemasByMessages(owner, ['m1']); + assert.deepEqual(calls[0], [MessageAPI.PREVIEW_SCHEMA, { owner, messageIds: ['m1'] }]); + }); + + it('previewSchemasByMessagesAsync forwards task', async () => { + const { g, calls } = make(); + await g.previewSchemasByMessagesAsync(owner, ['m1'], task); + assert.deepEqual(calls[0], [MessageAPI.PREVIEW_SCHEMA_ASYNC, { owner, messageIds: ['m1'], task }]); + }); + + it('previewSchemasByFile returns files directly without sendMessage', async () => { + const { g, calls } = make(); + const files = [{ id: 1 }]; + const res = await g.previewSchemasByFile(files); + assert.equal(res, files); + assert.equal(calls.length, 0); + }); + + it('getSchemasDublicates forwards args', async () => { + const { g, calls } = make(); + await g.getSchemasDublicates(['n1'], owner, 'p1'); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_CHECK_FOR_DUBLICATES, { schemaNames: ['n1'], owner, policyId: 'p1' }]); + }); + + it('getSchemasDublicates with only names', async () => { + const { g, calls } = make(); + await g.getSchemasDublicates(['n1']); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_CHECK_FOR_DUBLICATES, { schemaNames: ['n1'], owner: undefined, policyId: undefined }]); + }); + + it('createSchema forwards item and owner', async () => { + const { g, calls } = make(); + await g.createSchema({ name: 'S' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_SCHEMA, { item: { name: 'S' }, owner }]); + }); + + it('createSchemaAsync forwards task', async () => { + const { g, calls } = make(); + await g.createSchemaAsync({ name: 'S' }, owner, task); + assert.deepEqual(calls[0], [MessageAPI.CREATE_SCHEMA_ASYNC, { item: { name: 'S' }, owner, task }]); + }); + + it('copySchemaAsync forwards args in expected order', async () => { + const { g, calls } = make(); + await g.copySchemaAsync('iri-1', 'topic-1', 'name-1', owner, task, true); + assert.deepEqual(calls[0], [MessageAPI.COPY_SCHEMA_ASYNC, { iri: 'iri-1', topicId: 'topic-1', name: 'name-1', task, owner, copyNested: true }]); + }); + + it('updateSchema forwards item and owner', async () => { + const { g, calls } = make(); + await g.updateSchema({ id: 'S' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_SCHEMA, { item: { id: 'S' }, owner }]); + }); + + it('deleteSchema wraps schemaId into array with default includeChildren false', async () => { + const { g, calls } = make(); + await g.deleteSchema('s1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.DELETE_SCHEMAS, { schemaIds: ['s1'], owner, task, includeChildren: false }]); + }); + + it('deleteSchema honors includeChildren true', async () => { + const { g, calls } = make(); + await g.deleteSchema('s1', owner, task, true); + assert.equal(calls[0][1].includeChildren, true); + }); + + it('deleteSchemasByTopic forwards args', async () => { + const { g, calls } = make(); + await g.deleteSchemasByTopic('topic-1', owner); + assert.deepEqual(calls[0], [MessageAPI.DELETE_SCHEMAS_BY_TOPIC, { topicId: 'topic-1', owner }]); + }); + + it('deleteSchemasByIds default includeChildren false', async () => { + const { g, calls } = make(); + await g.deleteSchemasByIds(['s1', 's2'], owner, task); + assert.deepEqual(calls[0], [MessageAPI.DELETE_SCHEMAS, { schemaIds: ['s1', 's2'], owner, task, includeChildren: false }]); + }); + + it('publishSchema forwards args', async () => { + const { g, calls } = make(); + await g.publishSchema('id-1', '1.0', owner); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_SCHEMA, { id: 'id-1', version: '1.0', owner }]); + }); + + it('publishSchemaAsync forwards task', async () => { + const { g, calls } = make(); + await g.publishSchemaAsync('id-1', '1.0', owner, task); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_SCHEMA_ASYNC, { id: 'id-1', version: '1.0', owner, task }]); + }); + + it('exportSchemas forwards ids and owner', async () => { + const { g, calls } = make(); + await g.exportSchemas(['a'], owner); + assert.deepEqual(calls[0], [MessageAPI.EXPORT_SCHEMAS, { ids: ['a'], owner }]); + }); + + it('getUserRoles forwards did', async () => { + const { g, calls } = make(); + await g.getUserRoles('did:x'); + assert.deepEqual(calls[0], [MessageAPI.GET_USER_ROLES, { did: 'did:x' }]); + }); + + it('createSystemSchema forwards item and owner', async () => { + const { g, calls } = make(); + await g.createSystemSchema({ name: 'S' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_SYSTEM_SCHEMA, { item: { name: 'S' }, owner }]); + }); + + it('getSystemSchemas forwards paging', async () => { + const { g, calls } = make(); + await g.getSystemSchemas(user, 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_SYSTEM_SCHEMAS, { user, pageIndex: 1, pageSize: 10 }]); + }); + + it('getSystemSchemasV2 forwards fields and paging', async () => { + const { g, calls } = make(); + await g.getSystemSchemasV2(user, ['f'], 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_SYSTEM_SCHEMAS_V2, { user, fields: ['f'], pageIndex: 1, pageSize: 10 }]); + }); + + it('activeSchema forwards id and owner', async () => { + const { g, calls } = make(); + await g.activeSchema('id-1', owner); + assert.deepEqual(calls[0], [MessageAPI.ACTIVE_SCHEMA, { id: 'id-1', owner }]); + }); + + it('getSchemaByEntity forwards user and entity', async () => { + const { g, calls } = make(); + await g.getSchemaByEntity(user, 'ENT'); + assert.deepEqual(calls[0], [MessageAPI.GET_SYSTEM_SCHEMA, { user, entity: 'ENT' }]); + }); + + it('getListSchemas forwards owner', async () => { + const { g, calls } = make(); + await g.getListSchemas(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_LIST_SCHEMAS, { owner }]); + }); + + it('getSubSchemas forwards category topicId owner', async () => { + const { g, calls } = make(); + await g.getSubSchemas('cat', 'topic-1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_SUB_SCHEMAS, { topicId: 'topic-1', owner, category: 'cat' }]); + }); + + it('exportSchemasXlsx returns base64 decoded buffer', async () => { + const b64 = Buffer.from('hello').toString('base64'); + const { g, calls } = make(b64); + const res = await g.exportSchemasXlsx(owner, ['a']); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'hello'); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_EXPORT_XLSX, { ids: ['a'], owner }]); + }); + + it('importSchemasByXlsx forwards args', async () => { + const { g, calls } = make(); + const xlsx = new ArrayBuffer(4); + await g.importSchemasByXlsx(owner, 'topic-1', xlsx); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_XLSX, { owner, xlsx, topicId: 'topic-1' }]); + }); + + it('importSchemasByXlsxAsync forwards task and schemasIds', async () => { + const { g, calls } = make(); + const xlsx = new ArrayBuffer(4); + await g.importSchemasByXlsxAsync(owner, 'topic-1', xlsx, task, ['s']); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_XLSX_ASYNC, { owner, xlsx, topicId: 'topic-1', task, schemasIds: ['s'] }]); + }); + + it('previewSchemasByFileXlsx forwards args', async () => { + const { g, calls } = make(); + const xlsx = new ArrayBuffer(4); + await g.previewSchemasByFileXlsx(owner, xlsx); + assert.deepEqual(calls[0], [MessageAPI.SCHEMA_IMPORT_XLSX_PREVIEW, { owner, xlsx }]); + }); + + it('getFileTemplate forwards owner and filename', async () => { + const { g, calls } = make(); + await g.getFileTemplate(owner, 'file.csv'); + assert.deepEqual(calls[0], [MessageAPI.GET_TEMPLATE, { owner, filename: 'file.csv' }]); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-tags-themes-record.test.mjs b/api-gateway/tests/helpers/guardians-tags-themes-record.test.mjs new file mode 100644 index 0000000000..1587a906e5 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-tags-themes-record.test.mjs @@ -0,0 +1,419 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const user = { id: 'u1', did: 'did:u' }; + +describe('@unit Guardians tags', () => { + it('createTag forwards tag and owner', async () => { + const { g, calls } = make({ uuid: 'x' }); + const res = await g.createTag({ name: 'T' }, owner); + assert.deepEqual(res, { uuid: 'x' }); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TAG, { tag: { name: 'T' }, owner }]); + }); + + it('getTags forwards owner entity targets and linkedItems', async () => { + const { g, calls } = make([{ id: 1 }]); + const res = await g.getTags(owner, 'PolicyDocument', ['t1', 't2'], ['l1']); + assert.deepEqual(res, [{ id: 1 }]); + assert.deepEqual(calls[0], [MessageAPI.GET_TAGS, { owner, entity: 'PolicyDocument', targets: ['t1', 't2'], linkedItems: ['l1'] }]); + }); + + it('getTags sends undefined linkedItems by default', async () => { + const { g, calls } = make(); + await g.getTags(owner, 'PolicyDocument', ['t1']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAGS, { owner, entity: 'PolicyDocument', targets: ['t1'], linkedItems: undefined }]); + }); + + it('deleteTag forwards uuid and owner', async () => { + const { g, calls } = make(true); + const res = await g.deleteTag('uuid-1', owner); + assert.equal(res, true); + assert.deepEqual(calls[0], [MessageAPI.DELETE_TAG, { uuid: 'uuid-1', owner }]); + }); + + it('exportTags forwards owner entity targets and linkedItems', async () => { + const { g, calls } = make([{ id: 2 }]); + await g.exportTags(owner, 'PolicyDocument', ['t1'], ['l1']); + assert.deepEqual(calls[0], [MessageAPI.EXPORT_TAGS, { owner, entity: 'PolicyDocument', targets: ['t1'], linkedItems: ['l1'] }]); + }); + + it('exportTags sends undefined linkedItems by default', async () => { + const { g, calls } = make(); + await g.exportTags(owner, 'PolicyDocument', ['t1']); + assert.equal(calls[0][1].linkedItems, undefined); + }); + + it('getTagCache forwards owner entity targets and linkedItems', async () => { + const { g, calls } = make([{ id: 3 }]); + await g.getTagCache(owner, 'PolicyDocument', ['t1'], ['l1']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_CACHE, { owner, entity: 'PolicyDocument', targets: ['t1'], linkedItems: ['l1'] }]); + }); + + it('getTagCache sends undefined linkedItems by default', async () => { + const { g, calls } = make(); + await g.getTagCache(owner, 'PolicyDocument', ['t1']); + assert.equal(calls[0][1].linkedItems, undefined); + }); + + it('synchronizationTags forwards owner entity target and linkedItems', async () => { + const { g, calls } = make([{ id: 4 }]); + await g.synchronizationTags(owner, 'PolicyDocument', 'target-1', ['l1']); + assert.deepEqual(calls[0], [MessageAPI.GET_SYNCHRONIZATION_TAGS, { owner, entity: 'PolicyDocument', target: 'target-1', linkedItems: ['l1'] }]); + }); + + it('synchronizationTags sends undefined linkedItems by default', async () => { + const { g, calls } = make(); + await g.synchronizationTags(owner, 'PolicyDocument', 'target-1'); + assert.equal(calls[0][1].linkedItems, undefined); + }); + + it('synchronizationTags uses target singular key not targets', async () => { + const { g, calls } = make(); + await g.synchronizationTags(owner, 'PolicyDocument', 'target-1'); + assert.equal(calls[0][1].target, 'target-1'); + assert.ok(!('targets' in calls[0][1])); + }); + + it('getTagSchemas forwards owner and paging', async () => { + const { g, calls } = make({ items: [], count: 0 }); + await g.getTagSchemas(owner, 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS, { owner, pageIndex: 1, pageSize: 10 }]); + }); + + it('getTagSchemas sends undefined paging by default', async () => { + const { g, calls } = make(); + await g.getTagSchemas(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS, { owner, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getTagSchemasV2 forwards fields owner and paging', async () => { + const { g, calls } = make({ items: [], count: 0 }); + await g.getTagSchemasV2(owner, ['a', 'b'], 2, 20); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS_V2, { fields: ['a', 'b'], owner, pageIndex: 2, pageSize: 20 }]); + }); + + it('getTagSchemasV2 sends undefined paging by default', async () => { + const { g, calls } = make(); + await g.getTagSchemasV2(owner, ['a']); + assert.deepEqual(calls[0], [MessageAPI.GET_TAG_SCHEMAS_V2, { fields: ['a'], owner, pageIndex: undefined, pageSize: undefined }]); + }); + + it('createTagSchema forwards item and owner', async () => { + const { g, calls } = make({ id: 's' }); + await g.createTagSchema({ name: 'S' }, owner); + assert.deepEqual(calls[0], [MessageAPI.CREATE_TAG_SCHEMA, { item: { name: 'S' }, owner }]); + }); + + it('publishTagSchema forwards id version and owner', async () => { + const { g, calls } = make({ id: 's' }); + await g.publishTagSchema('s1', '1.0.0', owner); + assert.deepEqual(calls[0], [MessageAPI.PUBLISH_TAG_SCHEMA, { id: 's1', version: '1.0.0', owner }]); + }); + + it('getPublishedTagSchemas forwards user', async () => { + const { g, calls } = make([{ id: 's' }]); + await g.getPublishedTagSchemas(user); + assert.deepEqual(calls[0], [MessageAPI.GET_PUBLISHED_TAG_SCHEMAS, { user }]); + }); +}); + +describe('@unit Guardians themes', () => { + it('createTheme forwards theme and owner', async () => { + const { g, calls } = make({ id: 'th' }); + const res = await g.createTheme({ name: 'Th' }, owner); + assert.deepEqual(res, { id: 'th' }); + assert.deepEqual(calls[0], [MessageAPI.CREATE_THEME, { theme: { name: 'Th' }, owner }]); + }); + + it('updateTheme forwards themeId theme and owner', async () => { + const { g, calls } = make({ id: 'th' }); + await g.updateTheme('th1', { name: 'Th2' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_THEME, { themeId: 'th1', theme: { name: 'Th2' }, owner }]); + }); + + it('getThemes forwards owner', async () => { + const { g, calls } = make([{ id: 'th' }]); + await g.getThemes(owner); + assert.deepEqual(calls[0], [MessageAPI.GET_THEMES, { owner }]); + }); + + it('getThemeById forwards themeId and owner', async () => { + const { g, calls } = make({ id: 'th' }); + await g.getThemeById('th1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_THEME, { themeId: 'th1', owner }]); + }); + + it('deleteTheme forwards themeId and owner', async () => { + const { g, calls } = make(true); + const res = await g.deleteTheme('th1', owner); + assert.equal(res, true); + assert.deepEqual(calls[0], [MessageAPI.DELETE_THEME, { themeId: 'th1', owner }]); + }); + + it('importThemeFile forwards zip and owner', async () => { + const { g, calls } = make({ id: 'th' }); + await g.importThemeFile({ z: 1 }, owner); + assert.deepEqual(calls[0], [MessageAPI.THEME_IMPORT_FILE, { zip: { z: 1 }, owner }]); + }); + + it('exportThemeFile returns base64 decoded buffer', async () => { + const b64 = Buffer.from('theme-data').toString('base64'); + const { g, calls } = make(b64); + const res = await g.exportThemeFile('th1', owner); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'theme-data'); + assert.deepEqual(calls[0], [MessageAPI.THEME_EXPORT_FILE, { themeId: 'th1', owner }]); + }); +}); + +describe('@unit Guardians branding', () => { + it('setBranding forwards user and config', async () => { + const { g, calls } = make({ config: '{}' }); + await g.setBranding(user, '{"a":1}'); + assert.deepEqual(calls[0], [MessageAPI.STORE_BRANDING, { user, config: '{"a":1}' }]); + }); + + it('getBranding sends subject with no data and returns result', async () => { + const { g, calls } = make({ config: '{"a":1}' }); + const res = await g.getBranding(); + assert.deepEqual(res, { config: '{"a":1}' }); + assert.equal(calls[0][0], MessageAPI.GET_BRANDING); + assert.equal(calls[0][1], undefined); + }); + + it('getBranding passes through null result', async () => { + const { g } = make(null); + const res = await g.getBranding(); + assert.equal(res, null); + }); +}); + +describe('@unit Guardians suggestions', () => { + it('policySuggestions forwards user and suggestionsInput', async () => { + const { g, calls } = make({ next: 'a', nested: 'b' }); + const res = await g.policySuggestions({ s: 1 }, user); + assert.deepEqual(res, { next: 'a', nested: 'b' }); + assert.deepEqual(calls[0], [MessageAPI.SUGGESTIONS, { user, suggestionsInput: { s: 1 } }]); + }); + + it('setPolicySuggestionsConfig forwards items and user', async () => { + const { g, calls } = make([{ id: 'p' }]); + await g.setPolicySuggestionsConfig([{ id: 'p' }], user); + assert.deepEqual(calls[0], [MessageAPI.SET_SUGGESTIONS_CONFIG, { items: [{ id: 'p' }], user }]); + }); + + it('getPolicySuggestionsConfig forwards user', async () => { + const { g, calls } = make([{ id: 'p' }]); + await g.getPolicySuggestionsConfig(user); + assert.deepEqual(calls[0], [MessageAPI.GET_SUGGESTIONS_CONFIG, { user }]); + }); +}); + +describe('@unit Guardians record', () => { + it('startRecording forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.startRecording('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.START_RECORDING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('stopRecording returns base64 decoded buffer', async () => { + const b64 = Buffer.from('record-data').toString('base64'); + const { g, calls } = make(b64); + const res = await g.stopRecording('p1', owner, { o: 1 }); + assert.ok(Buffer.isBuffer(res)); + assert.equal(res.toString(), 'record-data'); + assert.deepEqual(calls[0], [MessageAPI.STOP_RECORDING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('getRecordedActions forwards policyId and owner', async () => { + const { g, calls } = make([{ a: 1 }]); + await g.getRecordedActions('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORDED_ACTIONS, { policyId: 'p1', owner }]); + }); + + it('getRecordStatus forwards policyId and owner', async () => { + const { g, calls } = make({ status: 'RECORDING' }); + await g.getRecordStatus('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_STATUS, { policyId: 'p1', owner }]); + }); + + it('runRecord forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.runRecord('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RUN_RECORD, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('stopRunning forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.stopRunning('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.STOP_RUNNING, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('getRecordResults forwards policyId and owner', async () => { + const { g, calls } = make({ r: 1 }); + await g.getRecordResults('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_RESULTS, { policyId: 'p1', owner }]); + }); + + it('getRecordDetails forwards policyId and owner', async () => { + const { g, calls } = make({ d: 1 }); + await g.getRecordDetails('p1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_DETAILS, { policyId: 'p1', owner }]); + }); + + it('getRecordActionDocuments forwards policyId recordActionId and owner', async () => { + const { g, calls } = make([{ doc: 1 }]); + await g.getRecordActionDocuments('p1', 'ra1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_RECORD_ACTION_DOCUMENTS, { policyId: 'p1', recordActionId: 'ra1', owner }]); + }); + + it('fastForward forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.fastForward('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.FAST_FORWARD, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('retryStep forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.retryStep('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RECORD_RETRY_STEP, { policyId: 'p1', owner, options: { o: 1 } }]); + }); + + it('skipStep forwards policyId owner and options', async () => { + const { g, calls } = make(true); + await g.skipStep('p1', owner, { o: 1 }); + assert.deepEqual(calls[0], [MessageAPI.RECORD_SKIP_STEP, { policyId: 'p1', owner, options: { o: 1 } }]); + }); +}); + +describe('@unit Guardians record return-value pass-through', () => { + it('startRecording returns the canned send result', async () => { + const { g } = make({ started: true }); + const res = await g.startRecording('p1', owner, {}); + assert.deepEqual(res, { started: true }); + }); + + it('runRecord returns the canned send result', async () => { + const { g } = make({ running: true }); + const res = await g.runRecord('p1', owner, {}); + assert.deepEqual(res, { running: true }); + }); + + it('fastForward returns the canned send result', async () => { + const { g } = make({ ff: 1 }); + const res = await g.fastForward('p1', owner, {}); + assert.deepEqual(res, { ff: 1 }); + }); + + it('retryStep returns the canned send result', async () => { + const { g } = make({ retried: true }); + const res = await g.retryStep('p1', owner, {}); + assert.deepEqual(res, { retried: true }); + }); + + it('skipStep returns the canned send result', async () => { + const { g } = make({ skipped: true }); + const res = await g.skipStep('p1', owner, {}); + assert.deepEqual(res, { skipped: true }); + }); +}); + +describe('@unit Guardians tags/themes options forwarding edge cases', () => { + it('createTag passes object reference through unchanged', async () => { + const { g, calls } = make(); + const tag = { name: 'T', target: 'x' }; + await g.createTag(tag, owner); + assert.equal(calls[0][1].tag, tag); + }); + + it('getTags forwards empty targets array', async () => { + const { g, calls } = make([]); + await g.getTags(owner, 'PolicyDocument', []); + assert.deepEqual(calls[0][1].targets, []); + }); + + it('updateTheme passes theme reference through unchanged', async () => { + const { g, calls } = make(); + const theme = { name: 'Th', rules: [] }; + await g.updateTheme('th1', theme, owner); + assert.equal(calls[0][1].theme, theme); + }); + + it('importThemeFile passes zip reference through unchanged', async () => { + const { g, calls } = make(); + const zip = { buf: 1 }; + await g.importThemeFile(zip, owner); + assert.equal(calls[0][1].zip, zip); + }); + + it('setPolicySuggestionsConfig forwards empty items array', async () => { + const { g, calls } = make([]); + await g.setPolicySuggestionsConfig([], user); + assert.deepEqual(calls[0][1].items, []); + }); + + it('policySuggestions passes suggestionsInput reference through unchanged', async () => { + const { g, calls } = make({}); + const input = { a: 1 }; + await g.policySuggestions(input, user); + assert.equal(calls[0][1].suggestionsInput, input); + }); +}); + +describe('@unit Guardians single-call discipline', () => { + const cases = [ + ['createTag', g => g.createTag({}, owner)], + ['getTags', g => g.getTags(owner, 'e', [])], + ['deleteTag', g => g.deleteTag('u', owner)], + ['exportTags', g => g.exportTags(owner, 'e', [])], + ['getTagCache', g => g.getTagCache(owner, 'e', [])], + ['synchronizationTags', g => g.synchronizationTags(owner, 'e', 't')], + ['getTagSchemas', g => g.getTagSchemas(owner)], + ['getTagSchemasV2', g => g.getTagSchemasV2(owner, [])], + ['createTagSchema', g => g.createTagSchema({}, owner)], + ['publishTagSchema', g => g.publishTagSchema('i', 'v', owner)], + ['getPublishedTagSchemas', g => g.getPublishedTagSchemas(user)], + ['createTheme', g => g.createTheme({}, owner)], + ['updateTheme', g => g.updateTheme('i', {}, owner)], + ['getThemes', g => g.getThemes(owner)], + ['getThemeById', g => g.getThemeById('i', owner)], + ['deleteTheme', g => g.deleteTheme('i', owner)], + ['importThemeFile', g => g.importThemeFile({}, owner)], + ['setBranding', g => g.setBranding(user, '{}')], + ['getBranding', g => g.getBranding()], + ['policySuggestions', g => g.policySuggestions({}, user)], + ['setPolicySuggestionsConfig', g => g.setPolicySuggestionsConfig([], user)], + ['getPolicySuggestionsConfig', g => g.getPolicySuggestionsConfig(user)], + ['startRecording', g => g.startRecording('p', owner, {})], + ['getRecordedActions', g => g.getRecordedActions('p', owner)], + ['getRecordStatus', g => g.getRecordStatus('p', owner)], + ['runRecord', g => g.runRecord('p', owner, {})], + ['stopRunning', g => g.stopRunning('p', owner, {})], + ['getRecordResults', g => g.getRecordResults('p', owner)], + ['getRecordDetails', g => g.getRecordDetails('p', owner)], + ['getRecordActionDocuments', g => g.getRecordActionDocuments('p', 'ra', owner)], + ['fastForward', g => g.fastForward('p', owner, {})], + ['retryStep', g => g.retryStep('p', owner, {})], + ['skipStep', g => g.skipStep('p', owner, {})] + ]; + + for (const [name, invoke] of cases) { + it(`${name} issues exactly one sendMessage`, async () => { + const { g, calls } = make('YQ=='); + await invoke(g); + assert.equal(calls.length, 1); + }); + } +}); diff --git a/api-gateway/tests/helpers/guardians-tokens.test.mjs b/api-gateway/tests/helpers/guardians-tokens.test.mjs new file mode 100644 index 0000000000..5aee2c1391 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-tokens.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const task = { taskId: 't1', userId: 'u1' }; + +describe('Guardians tokens', () => { + it('getTokens forwards filters and owner', async () => { + const { g, calls } = make([{ tokenId: 'T' }]); + const res = await g.getTokens({ tokenId: 'T' }, owner); + assert.deepEqual(res, [{ tokenId: 'T' }]); + assert.deepEqual(calls[0], [MessageAPI.GET_TOKENS, { filters: { tokenId: 'T' }, owner }]); + }); + + it('getTokensPage forwards paging', async () => { + const { g, calls } = make(); + await g.getTokensPage(owner, 2, 50); + assert.deepEqual(calls[0], [MessageAPI.GET_TOKENS_PAGE, { owner, pageIndex: 2, pageSize: 50 }]); + }); + + it('getTokensPage with no args sends undefined values', async () => { + const { g, calls } = make(); + await g.getTokensPage(); + assert.deepEqual(calls[0], [MessageAPI.GET_TOKENS_PAGE, { owner: undefined, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getTokensPageV2 forwards fields and paging', async () => { + const { g, calls } = make(); + await g.getTokensPageV2(['a', 'b'], owner, 1, 10); + assert.deepEqual(calls[0], [MessageAPI.GET_TOKENS_PAGE_V2, { fields: ['a', 'b'], owner, pageIndex: 1, pageSize: 10 }]); + }); + + it('getTokenById forwards tokenId and owner', async () => { + const { g, calls } = make(); + await g.getTokenById('T1', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_TOKEN, { tokenId: 'T1', owner }]); + }); + + it('setToken forwards item and owner', async () => { + const { g, calls } = make(); + await g.setToken({ name: 'X' }, owner); + assert.deepEqual(calls[0], [MessageAPI.SET_TOKEN, { item: { name: 'X' }, owner }]); + }); + + it('setTokenAsync forwards token owner task', async () => { + const { g, calls } = make(); + await g.setTokenAsync({ name: 'X' }, owner, task); + assert.deepEqual(calls[0], [MessageAPI.SET_TOKEN_ASYNC, { token: { name: 'X' }, owner, task }]); + }); + + it('updateToken forwards token and owner', async () => { + const { g, calls } = make(); + await g.updateToken({ tokenId: 'T' }, owner); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_TOKEN, { token: { tokenId: 'T' }, owner }]); + }); + + it('updateTokenAsync forwards token owner task', async () => { + const { g, calls } = make(); + await g.updateTokenAsync({ tokenId: 'T' }, owner, task); + assert.deepEqual(calls[0], [MessageAPI.UPDATE_TOKEN_ASYNC, { token: { tokenId: 'T' }, owner, task }]); + }); + + it('deleteTokenAsync forwards tokenId owner task', async () => { + const { g, calls } = make(); + await g.deleteTokenAsync('T', owner, task); + assert.deepEqual(calls[0], [MessageAPI.DELETE_TOKEN_ASYNC, { tokenId: 'T', owner, task }]); + }); + + it('deleteTokensAsync forwards tokenIds owner task', async () => { + const { g, calls } = make(); + await g.deleteTokensAsync(['T1', 'T2'], owner, task); + assert.deepEqual(calls[0], [MessageAPI.DELETE_TOKENS_ASYNC, { tokenIds: ['T1', 'T2'], owner, task }]); + }); + + it('freezeToken sets freeze true', async () => { + const { g, calls } = make(); + await g.freezeToken('T', 'bob', owner); + assert.deepEqual(calls[0], [MessageAPI.FREEZE_TOKEN, { tokenId: 'T', username: 'bob', owner, freeze: true }]); + }); + + it('freezeTokenAsync sets freeze true and task', async () => { + const { g, calls } = make(); + await g.freezeTokenAsync('T', 'bob', owner, task); + assert.deepEqual(calls[0], [MessageAPI.FREEZE_TOKEN_ASYNC, { tokenId: 'T', username: 'bob', owner, freeze: true, task }]); + }); + + it('unfreezeToken sets freeze false', async () => { + const { g, calls } = make(); + await g.unfreezeToken('T', 'bob', owner); + assert.deepEqual(calls[0], [MessageAPI.FREEZE_TOKEN, { tokenId: 'T', username: 'bob', owner, freeze: false }]); + }); + + it('unfreezeTokenAsync sets freeze false and task', async () => { + const { g, calls } = make(); + await g.unfreezeTokenAsync('T', 'bob', owner, task); + assert.deepEqual(calls[0], [MessageAPI.FREEZE_TOKEN_ASYNC, { tokenId: 'T', username: 'bob', owner, freeze: false, task }]); + }); + + it('grantKycToken sets grant true', async () => { + const { g, calls } = make(); + await g.grantKycToken('T', 'bob', owner); + assert.deepEqual(calls[0], [MessageAPI.KYC_TOKEN, { tokenId: 'T', username: 'bob', owner, grant: true }]); + }); + + it('grantKycTokenAsync sets grant true and task', async () => { + const { g, calls } = make(); + await g.grantKycTokenAsync('T', 'bob', owner, task); + assert.deepEqual(calls[0], [MessageAPI.KYC_TOKEN_ASYNC, { tokenId: 'T', username: 'bob', owner, grant: true, task }]); + }); + + it('revokeKycToken sets grant false', async () => { + const { g, calls } = make(); + await g.revokeKycToken('T', 'bob', owner); + assert.deepEqual(calls[0], [MessageAPI.KYC_TOKEN, { tokenId: 'T', username: 'bob', owner, grant: false }]); + }); + + it('revokeKycTokenAsync sets grant false and task', async () => { + const { g, calls } = make(); + await g.revokeKycTokenAsync('T', 'bob', owner, task); + assert.deepEqual(calls[0], [MessageAPI.KYC_TOKEN_ASYNC, { tokenId: 'T', username: 'bob', owner, grant: false, task }]); + }); + + it('associateToken sets associate true', async () => { + const { g, calls } = make(); + await g.associateToken('T', '0.0.1', owner); + assert.deepEqual(calls[0], [MessageAPI.ASSOCIATE_TOKEN, { tokenId: 'T', accountId: '0.0.1', owner, associate: true }]); + }); + + it('associateTokenAsync sets associate true and task', async () => { + const { g, calls } = make(); + await g.associateTokenAsync('T', '0.0.1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.ASSOCIATE_TOKEN_ASYNC, { tokenId: 'T', accountId: '0.0.1', owner, associate: true, task }]); + }); + + it('dissociateToken sets associate false', async () => { + const { g, calls } = make(); + await g.dissociateToken('T', '0.0.1', owner); + assert.deepEqual(calls[0], [MessageAPI.ASSOCIATE_TOKEN, { tokenId: 'T', accountId: '0.0.1', owner, associate: false }]); + }); + + it('dissociateTokenAsync sets associate false and task', async () => { + const { g, calls } = make(); + await g.dissociateTokenAsync('T', '0.0.1', owner, task); + assert.deepEqual(calls[0], [MessageAPI.ASSOCIATE_TOKEN_ASYNC, { tokenId: 'T', accountId: '0.0.1', owner, associate: false, task }]); + }); + + it('transferToken forwards body', async () => { + const { g, calls } = make(); + const body = { targetAccount: '0.0.2', amount: 5 }; + await g.transferToken('T', body, owner); + assert.deepEqual(calls[0], [MessageAPI.TRANSFER_TOKEN, { tokenId: 'T', body, owner }]); + }); + + it('transferTokenAsync forwards body and task', async () => { + const { g, calls } = make(); + const body = { targetAccount: '0.0.2', serialNumbers: [1, 2] }; + await g.transferTokenAsync('T', body, owner, task); + assert.deepEqual(calls[0], [MessageAPI.TRANSFER_TOKEN_ASYNC, { tokenId: 'T', body, owner, task }]); + }); + + it('getInfoToken forwards args', async () => { + const { g, calls } = make(); + await g.getInfoToken('T', 'bob', owner); + assert.deepEqual(calls[0], [MessageAPI.GET_INFO_TOKEN, { tokenId: 'T', username: 'bob', owner }]); + }); + + it('getRelayerAccountInfo forwards args', async () => { + const { g, calls } = make(); + const user = { id: 'u' }; + await g.getRelayerAccountInfo('T', '0.0.9', owner, user); + assert.deepEqual(calls[0], [MessageAPI.GET_RELAYER_ACCOUNT_INFO, { tokenId: 'T', relayerAccountId: '0.0.9', owner, user }]); + }); + + it('getTokenSerials forwards args', async () => { + const { g, calls } = make([1, 2, 3]); + const res = await g.getTokenSerials(owner, 'T', 'did:x'); + assert.deepEqual(res, [1, 2, 3]); + assert.deepEqual(calls[0], [MessageAPI.GET_SERIALS, { owner, tokenId: 'T', did: 'did:x' }]); + }); + + it('getAssociatedTokens forwards paging', async () => { + const { g, calls } = make(); + await g.getAssociatedTokens(owner, 'did:x', 0, 20); + assert.deepEqual(calls[0], [MessageAPI.GET_ASSOCIATED_TOKENS, { owner, did: 'did:x', pageIndex: 0, pageSize: 20 }]); + }); +}); diff --git a/api-gateway/tests/helpers/guardians-wipe-retire.test.mjs b/api-gateway/tests/helpers/guardians-wipe-retire.test.mjs new file mode 100644 index 0000000000..754a2224d0 --- /dev/null +++ b/api-gateway/tests/helpers/guardians-wipe-retire.test.mjs @@ -0,0 +1,691 @@ +import assert from 'node:assert/strict'; +import { ContractAPI, ContractType } from '@guardian/interfaces'; +import { Guardians } from '../../dist/helpers/guardians.js'; + +function make(canned = { ok: true }) { + const g = new Guardians(undefined); + const calls = []; + g.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return canned; + }; + return { g, calls }; +} + +const owner = { creator: 'did:owner', owner: 'did:owner', id: 'o1' }; +const owner2 = { creator: 'did:other', owner: 'did:other', id: 'o2' }; + +function keys(obj) { + return Object.keys(obj).sort(); +} + +describe('Guardians wipe + retire @unit', () => { + describe('getWipeRequests', () => { + it('uses GET_WIPE_REQUESTS subject', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner, 'c1', 1, 10); + assert.equal(calls[0][0], ContractAPI.GET_WIPE_REQUESTS); + }); + it('forwards all four args', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner, 'c1', 1, 10); + assert.deepEqual(calls[0][1], { owner, contractId: 'c1', pageIndex: 1, pageSize: 10 }); + }); + it('threads owner reference', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner2, 'c1', 1, 10); + assert.equal(calls[0][1].owner, owner2); + }); + it('contractId optional defaults to undefined', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner); + assert.equal(calls[0][1].contractId, undefined); + }); + it('pageIndex and pageSize default to undefined', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner); + assert.equal(calls[0][1].pageIndex, undefined); + assert.equal(calls[0][1].pageSize, undefined); + }); + it('payload has exactly owner, contractId, pageIndex, pageSize', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner, 'c1', 1, 10); + assert.deepEqual(keys(calls[0][1]), ['contractId', 'owner', 'pageIndex', 'pageSize']); + }); + it('returns canned response', async () => { + const { g } = make([[{ user: 'u' }], 1]); + const r = await g.getWipeRequests(owner); + assert.deepEqual(r, [[{ user: 'u' }], 1]); + }); + it('sends exactly one message', async () => { + const { g, calls } = make(); + await g.getWipeRequests(owner); + assert.equal(calls.length, 1); + }); + }); + + describe('enableWipeRequests', () => { + it('uses ENABLE_WIPE_REQUESTS subject and payload', async () => { + const { g, calls } = make(); + await g.enableWipeRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.ENABLE_WIPE_REQUESTS, { owner, id: 'id-1' }]); + }); + it('payload has exactly owner and id', async () => { + const { g, calls } = make(); + await g.enableWipeRequests(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['id', 'owner']); + }); + it('forwards distinct id', async () => { + const { g, calls } = make(); + await g.enableWipeRequests(owner, 'other-id'); + assert.equal(calls[0][1].id, 'other-id'); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.enableWipeRequests(owner, 'id-1'), true); + }); + }); + + describe('disableWipeRequests', () => { + it('uses DISABLE_WIPE_REQUESTS subject and payload', async () => { + const { g, calls } = make(); + await g.disableWipeRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.DISABLE_WIPE_REQUESTS, { owner, id: 'id-1' }]); + }); + it('payload has exactly owner and id', async () => { + const { g, calls } = make(); + await g.disableWipeRequests(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.disableWipeRequests(owner, 'id-1'), false); + }); + }); + + describe('approveWipeRequest', () => { + it('uses APPROVE_WIPE_REQUEST subject and payload', async () => { + const { g, calls } = make(); + await g.approveWipeRequest(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.APPROVE_WIPE_REQUEST, { owner, requestId: 'r1' }]); + }); + it('payload has exactly owner and requestId', async () => { + const { g, calls } = make(); + await g.approveWipeRequest(owner, 'r1'); + assert.deepEqual(keys(calls[0][1]), ['owner', 'requestId']); + }); + it('forwards distinct requestId', async () => { + const { g, calls } = make(); + await g.approveWipeRequest(owner, 'rq-99'); + assert.equal(calls[0][1].requestId, 'rq-99'); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.approveWipeRequest(owner, 'r1'), true); + }); + }); + + describe('rejectWipeRequest', () => { + it('uses REJECT_WIPE_REQUEST subject', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1'); + assert.equal(calls[0][0], ContractAPI.REJECT_WIPE_REQUEST); + }); + it('ban defaults to false', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1'); + assert.deepEqual(calls[0][1], { owner, requestId: 'r1', ban: false }); + }); + it('ban true honored', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1', true); + assert.equal(calls[0][1].ban, true); + }); + it('ban false explicit honored', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1', false); + assert.equal(calls[0][1].ban, false); + }); + it('payload has exactly owner, requestId, ban', async () => { + const { g, calls } = make(); + await g.rejectWipeRequest(owner, 'r1'); + assert.deepEqual(keys(calls[0][1]), ['ban', 'owner', 'requestId']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.rejectWipeRequest(owner, 'r1', true), true); + }); + }); + + describe('clearWipeRequests', () => { + it('uses CLEAR_WIPE_REQUESTS subject', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1', '0.0.5'); + assert.equal(calls[0][0], ContractAPI.CLEAR_WIPE_REQUESTS); + }); + it('forwards hederaId when provided', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0][1], { owner, id: 'id-1', hederaId: '0.0.5' }); + }); + it('hederaId undefined when omitted', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1'); + assert.equal(calls[0][1].hederaId, undefined); + }); + it('payload always has owner, id, hederaId keys', async () => { + const { g, calls } = make(); + await g.clearWipeRequests(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.clearWipeRequests(owner, 'id-1'), true); + }); + }); + + describe('addWipeAdmin', () => { + it('uses ADD_WIPE_ADMIN subject and payload', async () => { + const { g, calls } = make(); + await g.addWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_WIPE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.addWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('forwards distinct hederaId', async () => { + const { g, calls } = make(); + await g.addWipeAdmin(owner, 'id-1', '0.0.99'); + assert.equal(calls[0][1].hederaId, '0.0.99'); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.addWipeAdmin(owner, 'id-1', '0.0.5'), true); + }); + }); + + describe('removeWipeAdmin', () => { + it('uses REMOVE_WIPE_ADMIN subject and payload', async () => { + const { g, calls } = make(); + await g.removeWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_WIPE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.removeWipeAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.removeWipeAdmin(owner, 'id-1', '0.0.5'), false); + }); + }); + + describe('addWipeManager', () => { + it('uses ADD_WIPE_MANAGER subject and payload', async () => { + const { g, calls } = make(); + await g.addWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_WIPE_MANAGER, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.addWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.addWipeManager(owner, 'id-1', '0.0.5'), true); + }); + }); + + describe('removeWipeManager', () => { + it('uses REMOVE_WIPE_MANAGER subject and payload', async () => { + const { g, calls } = make(); + await g.removeWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_WIPE_MANAGER, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.removeWipeManager(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.removeWipeManager(owner, 'id-1', '0.0.5'), false); + }); + }); + + describe('addWipeWiper', () => { + it('uses ADD_WIPE_WIPER subject', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.equal(calls[0][0], ContractAPI.ADD_WIPE_WIPER); + }); + it('forwards tokenId when provided', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.deepEqual(calls[0][1], { owner, id: 'id-1', hederaId: '0.0.5', tokenId: 'T' }); + }); + it('tokenId undefined when omitted', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5'); + assert.equal(calls[0][1].tokenId, undefined); + }); + it('payload always has owner, id, hederaId, tokenId keys', async () => { + const { g, calls } = make(); + await g.addWipeWiper(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner', 'tokenId']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.addWipeWiper(owner, 'id-1', '0.0.5', 'T'), true); + }); + }); + + describe('removeWipeWiper', () => { + it('uses REMOVE_WIPE_WIPER subject', async () => { + const { g, calls } = make(); + await g.removeWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.equal(calls[0][0], ContractAPI.REMOVE_WIPE_WIPER); + }); + it('forwards tokenId when provided', async () => { + const { g, calls } = make(); + await g.removeWipeWiper(owner, 'id-1', '0.0.5', 'T'); + assert.deepEqual(calls[0][1], { owner, id: 'id-1', hederaId: '0.0.5', tokenId: 'T' }); + }); + it('tokenId undefined when omitted', async () => { + const { g, calls } = make(); + await g.removeWipeWiper(owner, 'id-1', '0.0.5'); + assert.equal(calls[0][1].tokenId, undefined); + }); + it('payload always has owner, id, hederaId, tokenId keys', async () => { + const { g, calls } = make(); + await g.removeWipeWiper(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner', 'tokenId']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.removeWipeWiper(owner, 'id-1', '0.0.5'), false); + }); + }); + + describe('syncRetirePools', () => { + it('uses SYNC_RETIRE_POOLS subject and payload', async () => { + const { g, calls } = make(); + await g.syncRetirePools(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.SYNC_RETIRE_POOLS, { owner, id: 'id-1' }]); + }); + it('payload has exactly owner and id', async () => { + const { g, calls } = make(); + await g.syncRetirePools(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['id', 'owner']); + }); + it('returns canned sync date string', async () => { + const { g } = make('2026-01-01'); + assert.equal(await g.syncRetirePools(owner, 'id-1'), '2026-01-01'); + }); + }); + + describe('getRetireRequests', () => { + it('uses GET_RETIRE_REQUESTS subject', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner, 'c1', 1, 10); + assert.equal(calls[0][0], ContractAPI.GET_RETIRE_REQUESTS); + }); + it('forwards all four args', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner, 'c1', 1, 10); + assert.deepEqual(calls[0][1], { owner, contractId: 'c1', pageIndex: 1, pageSize: 10 }); + }); + it('contractId optional defaults to undefined', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner); + assert.equal(calls[0][1].contractId, undefined); + }); + it('paging defaults to undefined', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner); + assert.equal(calls[0][1].pageIndex, undefined); + assert.equal(calls[0][1].pageSize, undefined); + }); + it('payload has exactly owner, contractId, pageIndex, pageSize', async () => { + const { g, calls } = make(); + await g.getRetireRequests(owner); + assert.deepEqual(keys(calls[0][1]), ['contractId', 'owner', 'pageIndex', 'pageSize']); + }); + it('returns canned response', async () => { + const { g } = make([{ id: 'r' }, 1]); + assert.deepEqual(await g.getRetireRequests(owner), [{ id: 'r' }, 1]); + }); + }); + + describe('getRetirePools', () => { + it('uses GET_RETIRE_POOLS subject', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner, ['T'], 'c1', 1, 10); + assert.equal(calls[0][0], ContractAPI.GET_RETIRE_POOLS); + }); + it('forwards tokens and paging', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner, ['T'], 'c1', 1, 10); + assert.deepEqual(calls[0][1], { owner, contractId: 'c1', pageIndex: 1, pageSize: 10, tokens: ['T'] }); + }); + it('tokens optional defaults to undefined', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner); + assert.equal(calls[0][1].tokens, undefined); + }); + it('contractId optional defaults to undefined', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner); + assert.equal(calls[0][1].contractId, undefined); + }); + it('payload has exactly owner, contractId, pageIndex, pageSize, tokens', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner); + assert.deepEqual(keys(calls[0][1]), ['contractId', 'owner', 'pageIndex', 'pageSize', 'tokens']); + }); + it('forwards multi-element tokens array', async () => { + const { g, calls } = make(); + await g.getRetirePools(owner, ['A', 'B'], 'c1', 0, 5); + assert.deepEqual(calls[0][1].tokens, ['A', 'B']); + }); + it('returns canned response', async () => { + const { g } = make([{ id: 'p' }, 2]); + assert.deepEqual(await g.getRetirePools(owner), [{ id: 'p' }, 2]); + }); + }); + + describe('clearRetireRequests', () => { + it('uses CLEAR_RETIRE_REQUESTS subject and payload', async () => { + const { g, calls } = make(); + await g.clearRetireRequests(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.CLEAR_RETIRE_REQUESTS, { owner, id: 'id-1' }]); + }); + it('payload has exactly owner and id', async () => { + const { g, calls } = make(); + await g.clearRetireRequests(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.clearRetireRequests(owner, 'id-1'), true); + }); + }); + + describe('clearRetirePools', () => { + it('uses CLEAR_RETIRE_POOLS subject and payload', async () => { + const { g, calls } = make(); + await g.clearRetirePools(owner, 'id-1'); + assert.deepEqual(calls[0], [ContractAPI.CLEAR_RETIRE_POOLS, { owner, id: 'id-1' }]); + }); + it('payload has exactly owner and id', async () => { + const { g, calls } = make(); + await g.clearRetirePools(owner, 'id-1'); + assert.deepEqual(keys(calls[0][1]), ['id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.clearRetirePools(owner, 'id-1'), false); + }); + }); + + describe('setRetirePool', () => { + it('uses SET_RETIRE_POOLS subject', async () => { + const { g, calls } = make(); + await g.setRetirePool(owner, 'id-1', { tokens: [], immediately: true }); + assert.equal(calls[0][0], ContractAPI.SET_RETIRE_POOLS); + }); + it('forwards options object by reference', async () => { + const { g, calls } = make(); + const options = { tokens: [{ token: 'T', count: 1 }], immediately: false }; + await g.setRetirePool(owner, 'id-1', options); + assert.equal(calls[0][1].options, options); + }); + it('payload has exactly owner, id, options', async () => { + const { g, calls } = make(); + await g.setRetirePool(owner, 'id-1', { tokens: [], immediately: true }); + assert.deepEqual(keys(calls[0][1]), ['id', 'options', 'owner']); + }); + it('preserves immediately flag', async () => { + const { g, calls } = make(); + await g.setRetirePool(owner, 'id-1', { tokens: [], immediately: false }); + assert.equal(calls[0][1].options.immediately, false); + }); + it('returns canned pool', async () => { + const { g } = make({ poolId: 'p1' }); + assert.deepEqual(await g.setRetirePool(owner, 'id-1', { tokens: [], immediately: true }), { poolId: 'p1' }); + }); + }); + + describe('unsetRetirePool', () => { + it('uses UNSET_RETIRE_POOLS subject and payload', async () => { + const { g, calls } = make(); + await g.unsetRetirePool(owner, 'p1'); + assert.deepEqual(calls[0], [ContractAPI.UNSET_RETIRE_POOLS, { owner, poolId: 'p1' }]); + }); + it('payload has exactly owner and poolId', async () => { + const { g, calls } = make(); + await g.unsetRetirePool(owner, 'p1'); + assert.deepEqual(keys(calls[0][1]), ['owner', 'poolId']); + }); + it('forwards distinct poolId', async () => { + const { g, calls } = make(); + await g.unsetRetirePool(owner, 'pool-xyz'); + assert.equal(calls[0][1].poolId, 'pool-xyz'); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.unsetRetirePool(owner, 'p1'), true); + }); + }); + + describe('unsetRetireRequest', () => { + it('uses UNSET_RETIRE_REQUEST subject and payload', async () => { + const { g, calls } = make(); + await g.unsetRetireRequest(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.UNSET_RETIRE_REQUEST, { owner, requestId: 'r1' }]); + }); + it('payload has exactly owner and requestId', async () => { + const { g, calls } = make(); + await g.unsetRetireRequest(owner, 'r1'); + assert.deepEqual(keys(calls[0][1]), ['owner', 'requestId']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.unsetRetireRequest(owner, 'r1'), false); + }); + }); + + describe('retire', () => { + it('uses RETIRE subject', async () => { + const { g, calls } = make(); + await g.retire(owner, 'p1', [{ token: 'T' }]); + assert.equal(calls[0][0], ContractAPI.RETIRE); + }); + it('forwards poolId and tokens', async () => { + const { g, calls } = make(); + await g.retire(owner, 'p1', [{ token: 'T' }]); + assert.deepEqual(calls[0][1], { owner, poolId: 'p1', tokens: [{ token: 'T' }] }); + }); + it('payload has exactly owner, poolId, tokens', async () => { + const { g, calls } = make(); + await g.retire(owner, 'p1', []); + assert.deepEqual(keys(calls[0][1]), ['owner', 'poolId', 'tokens']); + }); + it('forwards tokens array by reference', async () => { + const { g, calls } = make(); + const tokens = [{ token: 'A' }, { token: 'B' }]; + await g.retire(owner, 'p1', tokens); + assert.equal(calls[0][1].tokens, tokens); + }); + it('forwards empty tokens array', async () => { + const { g, calls } = make(); + await g.retire(owner, 'p1', []); + assert.deepEqual(calls[0][1].tokens, []); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.retire(owner, 'p1', []), true); + }); + }); + + describe('approveRetire', () => { + it('uses APPROVE_RETIRE subject and payload', async () => { + const { g, calls } = make(); + await g.approveRetire(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.APPROVE_RETIRE, { owner, requestId: 'r1' }]); + }); + it('payload has exactly owner and requestId', async () => { + const { g, calls } = make(); + await g.approveRetire(owner, 'r1'); + assert.deepEqual(keys(calls[0][1]), ['owner', 'requestId']); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.approveRetire(owner, 'r1'), true); + }); + }); + + describe('cancelRetire', () => { + it('uses CANCEL_RETIRE subject and payload', async () => { + const { g, calls } = make(); + await g.cancelRetire(owner, 'r1'); + assert.deepEqual(calls[0], [ContractAPI.CANCEL_RETIRE, { owner, requestId: 'r1' }]); + }); + it('payload has exactly owner and requestId', async () => { + const { g, calls } = make(); + await g.cancelRetire(owner, 'r1'); + assert.deepEqual(keys(calls[0][1]), ['owner', 'requestId']); + }); + it('forwards distinct requestId', async () => { + const { g, calls } = make(); + await g.cancelRetire(owner, 'req-2'); + assert.equal(calls[0][1].requestId, 'req-2'); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.cancelRetire(owner, 'r1'), false); + }); + }); + + describe('addRetireAdmin', () => { + it('uses ADD_RETIRE_ADMIN subject and payload', async () => { + const { g, calls } = make(); + await g.addRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.ADD_RETIRE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.addRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('forwards distinct hederaId', async () => { + const { g, calls } = make(); + await g.addRetireAdmin(owner, 'id-1', '0.0.77'); + assert.equal(calls[0][1].hederaId, '0.0.77'); + }); + it('returns canned boolean', async () => { + const { g } = make(true); + assert.equal(await g.addRetireAdmin(owner, 'id-1', '0.0.5'), true); + }); + }); + + describe('removeRetireAdmin', () => { + it('uses REMOVE_RETIRE_ADMIN subject and payload', async () => { + const { g, calls } = make(); + await g.removeRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(calls[0], [ContractAPI.REMOVE_RETIRE_ADMIN, { owner, id: 'id-1', hederaId: '0.0.5' }]); + }); + it('payload has exactly owner, id, hederaId', async () => { + const { g, calls } = make(); + await g.removeRetireAdmin(owner, 'id-1', '0.0.5'); + assert.deepEqual(keys(calls[0][1]), ['hederaId', 'id', 'owner']); + }); + it('returns canned boolean', async () => { + const { g } = make(false); + assert.equal(await g.removeRetireAdmin(owner, 'id-1', '0.0.5'), false); + }); + }); + + describe('getRetireVCs', () => { + it('uses GET_RETIRE_VCS subject', async () => { + const { g, calls } = make(); + await g.getRetireVCs(owner, 1, 10); + assert.equal(calls[0][0], ContractAPI.GET_RETIRE_VCS); + }); + it('forwards paging', async () => { + const { g, calls } = make(); + await g.getRetireVCs(owner, 1, 10); + assert.deepEqual(calls[0][1], { owner, pageIndex: 1, pageSize: 10 }); + }); + it('paging defaults to undefined', async () => { + const { g, calls } = make(); + await g.getRetireVCs(owner); + assert.equal(calls[0][1].pageIndex, undefined); + assert.equal(calls[0][1].pageSize, undefined); + }); + it('payload has exactly owner, pageIndex, pageSize', async () => { + const { g, calls } = make(); + await g.getRetireVCs(owner); + assert.deepEqual(keys(calls[0][1]), ['owner', 'pageIndex', 'pageSize']); + }); + it('returns canned response', async () => { + const { g } = make([{ id: 'vc' }, 1]); + assert.deepEqual(await g.getRetireVCs(owner), [{ id: 'vc' }, 1]); + }); + }); + + describe('getRetireVCsFromIndexer', () => { + it('uses GET_RETIRE_VCS_FROM_INDEXER subject and payload', async () => { + const { g, calls } = make(); + await g.getRetireVCsFromIndexer(owner, 'topic-1'); + assert.deepEqual(calls[0], [ContractAPI.GET_RETIRE_VCS_FROM_INDEXER, { owner, contractTopicId: 'topic-1' }]); + }); + it('payload has exactly owner and contractTopicId', async () => { + const { g, calls } = make(); + await g.getRetireVCsFromIndexer(owner, 'topic-1'); + assert.deepEqual(keys(calls[0][1]), ['contractTopicId', 'owner']); + }); + it('forwards distinct contractTopicId', async () => { + const { g, calls } = make(); + await g.getRetireVCsFromIndexer(owner, '0.0.topic'); + assert.equal(calls[0][1].contractTopicId, '0.0.topic'); + }); + it('returns canned response', async () => { + const { g } = make([[{ m: 1 }], 1]); + assert.deepEqual(await g.getRetireVCsFromIndexer(owner, 't'), [[{ m: 1 }], 1]); + }); + }); + + describe('cross-cutting behavior', () => { + it('each method sends exactly one message', async () => { + const { g, calls } = make(); + await g.enableWipeRequests(owner, 'id'); + await g.clearRetirePools(owner, 'id'); + await g.retire(owner, 'p', []); + assert.equal(calls.length, 3); + }); + it('ContractType enum exposes WIPE and RETIRE', () => { + assert.equal(ContractType.WIPE, 'WIPE'); + assert.equal(ContractType.RETIRE, 'RETIRE'); + }); + it('every wipe/retire subject is a defined ContractAPI constant', () => { + const subjects = [ + 'GET_WIPE_REQUESTS', 'ENABLE_WIPE_REQUESTS', 'DISABLE_WIPE_REQUESTS', + 'APPROVE_WIPE_REQUEST', 'REJECT_WIPE_REQUEST', 'CLEAR_WIPE_REQUESTS', + 'ADD_WIPE_ADMIN', 'REMOVE_WIPE_ADMIN', 'ADD_WIPE_MANAGER', 'REMOVE_WIPE_MANAGER', + 'ADD_WIPE_WIPER', 'REMOVE_WIPE_WIPER', 'SYNC_RETIRE_POOLS', 'GET_RETIRE_REQUESTS', + 'GET_RETIRE_POOLS', 'CLEAR_RETIRE_REQUESTS', 'CLEAR_RETIRE_POOLS', 'SET_RETIRE_POOLS', + 'UNSET_RETIRE_POOLS', 'UNSET_RETIRE_REQUEST', 'RETIRE', 'APPROVE_RETIRE', + 'CANCEL_RETIRE', 'ADD_RETIRE_ADMIN', 'REMOVE_RETIRE_ADMIN', 'GET_RETIRE_VCS', + 'GET_RETIRE_VCS_FROM_INDEXER' + ]; + for (const s of subjects) { + assert.ok(typeof ContractAPI[s] === 'string', `${s} should be a string constant`); + } + }); + }); +}); diff --git a/api-gateway/tests/helpers/interceptors/multipart-interceptor.test.mjs b/api-gateway/tests/helpers/interceptors/multipart-interceptor.test.mjs new file mode 100644 index 0000000000..b0262f94fc --- /dev/null +++ b/api-gateway/tests/helpers/interceptors/multipart-interceptor.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { of, lastValueFrom } from 'rxjs'; +import { AnyFilesInterceptor } from '../../../dist/helpers/interceptors/multipart.js'; + +function buildContext(req) { + return { switchToHttp: () => ({ getRequest: () => req }) }; +} + +function buildRequest(overrides = {}) { + return { + isMultipart: () => true, + parts: overrides.parts ?? (async function* () {}), + ...overrides, + }; +} + +async function* partsFrom(items) { + for (const item of items) { + yield item; + } +} + +function filePart(overrides = {}) { + return { + type: 'file', + fieldname: 'document', + filename: 'doc.pdf', + mimetype: 'application/pdf', + encoding: '7bit', + toBuffer: async () => Buffer.from('hello'), + ...overrides, + }; +} + +function fieldPart(fieldname, value) { + return { type: 'field', fieldname, value }; +} + +describe('AnyFilesInterceptor', () => { + it('returns a NestJS mixin class (constructible)', () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + assert.equal(typeof instance.intercept, 'function'); + }); + + it('throws BAD_REQUEST 400 when the request is not multipart', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ isMultipart: () => false }); + const next = { handle: () => of('downstream') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 400); + assert.equal(err.message, 'The request should be a form-data'); + return true; + } + ); + }); + + it('collects files onto req.storedFiles and calls next.handle()', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart()]) }); + let handled = false; + const next = { handle: () => { handled = true; return of('ok'); } }; + const result = await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.equal(result, 'ok'); + assert.equal(handled, true); + assert.equal(req.storedFiles.length, 1); + assert.equal(req.storedFiles[0].fieldname, 'document'); + }); + + it('puts non-file field parts into req.body', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ + parts: () => partsFrom([fieldPart('name', 'alice'), fieldPart('age', '30')]), + }); + const next = { handle: () => of('ok') }; + await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.deepEqual(req.body, { name: 'alice', age: '30' }); + }); + + it('does not set storedFiles when there are no files', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([fieldPart('x', '1')]) }); + const next = { handle: () => of('ok') }; + await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.equal('storedFiles' in req, false); + }); + + it('skips files whose getFileFromPart returns null (empty buffer)', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ + parts: () => partsFrom([filePart({ toBuffer: async () => Buffer.alloc(0) })]), + }); + const next = { handle: () => of('ok') }; + await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.equal('storedFiles' in req, false); + }); + + it('throws UNPROCESSABLE_ENTITY 422 for a field not in allowedFields', async () => { + const Cls = AnyFilesInterceptor({ allowedFields: ['document'] }); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart({ fieldname: 'evil' })]) }); + const next = { handle: () => of('ok') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 422); + assert.match(err.message, /allowed keys: document/); + return true; + } + ); + }); + + it('accepts a field present in allowedFields', async () => { + const Cls = AnyFilesInterceptor({ allowedFields: ['document'] }); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart()]) }); + const next = { handle: () => of('ok') }; + const result = await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.equal(result, 'ok'); + assert.equal(req.storedFiles.length, 1); + }); + + it('throws UNPROCESSABLE_ENTITY 422 when a requiredField file is missing', async () => { + const Cls = AnyFilesInterceptor({ requiredFields: ['avatar'] }); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart({ fieldname: 'document' })]) }); + const next = { handle: () => of('ok') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 422); + assert.equal(err.message, 'There are no files to upload.'); + return true; + } + ); + }); + + it('passes when all requiredFields are present', async () => { + const Cls = AnyFilesInterceptor({ requiredFields: ['document'] }); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart({ fieldname: 'document' })]) }); + const next = { handle: () => of('ok') }; + const result = await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.equal(result, 'ok'); + }); + + it('wraps an error thrown during part iteration into an HttpException (preserving status)', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const boom = Object.assign(new Error('disk full'), { status: 507 }); + const req = buildRequest({ + parts: () => (async function* () { throw boom; })(), + }); + const next = { handle: () => of('ok') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 507); + assert.equal(err.message, 'disk full'); + return true; + } + ); + }); + + it('defaults wrapped-error status to BAD_REQUEST 400 when none provided', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ + parts: () => (async function* () { throw new Error('parse fail'); })(), + }); + const next = { handle: () => of('ok') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 400); + assert.equal(err.message, 'parse fail'); + return true; + } + ); + }); + + it('an allowedFields violation is caught and re-wrapped, surfacing status 422 via the catch', async () => { + const Cls = AnyFilesInterceptor({ allowedFields: ['document'] }); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart({ fieldname: 'bad' })]) }); + const next = { handle: () => of('ok') }; + await assert.rejects( + () => instance.intercept(buildContext(req), next), + (err) => { + assert.equal(err.getStatus(), 422); + return true; + } + ); + }); + + it('sets req.body even when only files (no field parts) are present', async () => { + const Cls = AnyFilesInterceptor(); + const instance = new Cls(); + const req = buildRequest({ parts: () => partsFrom([filePart()]) }); + const next = { handle: () => of('ok') }; + await lastValueFrom(await instance.intercept(buildContext(req), next)); + assert.deepEqual(req.body, {}); + }); +}); diff --git a/api-gateway/tests/helpers/interceptors/multipart-types.test.mjs b/api-gateway/tests/helpers/interceptors/multipart-types.test.mjs new file mode 100644 index 0000000000..4f28a031a6 --- /dev/null +++ b/api-gateway/tests/helpers/interceptors/multipart-types.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { MultipartOptions } from '../../../dist/helpers/interceptors/types/multipart.js'; + +describe('MultipartOptions', () => { + it('assigns all four constructor arguments', () => { + const re = /png/; + const opts = new MultipartOptions(1024, re, ['a', 'b'], ['a']); + assert.equal(opts.maxFileSize, 1024); + assert.equal(opts.fileType, re); + assert.deepEqual(opts.allowedFields, ['a', 'b']); + assert.deepEqual(opts.requiredFields, ['a']); + }); + + it('accepts a string fileType', () => { + const opts = new MultipartOptions(50, 'image/png'); + assert.equal(opts.fileType, 'image/png'); + assert.equal(opts.allowedFields, undefined); + assert.equal(opts.requiredFields, undefined); + }); + + it('leaves every field undefined when constructed with no arguments', () => { + const opts = new MultipartOptions(); + assert.equal(opts.maxFileSize, undefined); + assert.equal(opts.fileType, undefined); + assert.equal(opts.allowedFields, undefined); + assert.equal(opts.requiredFields, undefined); + }); +}); diff --git a/api-gateway/tests/helpers/logger-providers.test.mjs b/api-gateway/tests/helpers/logger-providers.test.mjs new file mode 100644 index 0000000000..772927049e --- /dev/null +++ b/api-gateway/tests/helpers/logger-providers.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const FAKE_PINO_LOGGER = class PinoLogger {}; + +let mongoInitCalls = []; +let mongoInitResult = { orm: 'mongo' }; +let pinoInitCalls = []; +let pinoInitResult = { logger: 'pino' }; + +let mongoMod; +let pinoMod; + +before(async function () { + this.timeout(60000); + mongoMod = await esmock('../../dist/helpers/providers/logger-mongo-provider.js', { + '@guardian/common': { + mongoForLoggingInitialization: (...args) => { + mongoInitCalls.push(args); + return mongoInitResult; + }, + }, + }); + pinoMod = await esmock('../../dist/helpers/providers/pino-logger-provider.js', { + '@guardian/common': { + PinoLogger: FAKE_PINO_LOGGER, + pinoLoggerInitialization: (...args) => { + pinoInitCalls.push(args); + return pinoInitResult; + }, + }, + }); +}); + +beforeEach(() => { + mongoInitCalls = []; + pinoInitCalls = []; + mongoInitResult = { orm: 'mongo' }; + pinoInitResult = { logger: 'pino' }; +}); + +describe('loggerMongoProvider', () => { + it('provides under the LOGGER_MONGO_PROVIDER token', () => { + assert.equal(mongoMod.loggerMongoProvider.provide, 'LOGGER_MONGO_PROVIDER'); + }); + + it('exposes an async useFactory', async () => { + const { loggerMongoProvider } = mongoMod; + assert.equal(typeof loggerMongoProvider.useFactory, 'function'); + const ret = loggerMongoProvider.useFactory(); + assert.ok(ret instanceof Promise); + await ret; + }); + + it('delegates to mongoForLoggingInitialization with no arguments', async () => { + const { loggerMongoProvider } = mongoMod; + const result = await loggerMongoProvider.useFactory(); + assert.equal(mongoInitCalls.length, 1); + assert.deepEqual(mongoInitCalls[0], []); + assert.equal(result, mongoInitResult); + }); + + it('does not declare an inject array', () => { + assert.equal(mongoMod.loggerMongoProvider.inject, undefined); + }); +}); + +describe('pinoLoggerProvider', () => { + it('provides under the PinoLogger class token', () => { + assert.equal(pinoMod.pinoLoggerProvider.provide, FAKE_PINO_LOGGER); + }); + + it('injects the LOGGER_MONGO_PROVIDER token', () => { + assert.deepEqual(pinoMod.pinoLoggerProvider.inject, ['LOGGER_MONGO_PROVIDER']); + }); + + it('forwards the injected db to pinoLoggerInitialization', () => { + const { pinoLoggerProvider } = pinoMod; + const fakeDb = { driver: 'mongo' }; + const result = pinoLoggerProvider.useFactory(fakeDb); + assert.equal(pinoInitCalls.length, 1); + assert.deepEqual(pinoInitCalls[0], [fakeDb]); + assert.equal(result, pinoInitResult); + }); + + it('passes through whatever pinoLoggerInitialization returns', () => { + const { pinoLoggerProvider } = pinoMod; + pinoInitResult = Symbol('logger'); + const result = pinoLoggerProvider.useFactory({}); + assert.equal(result, pinoInitResult); + }); +}); diff --git a/api-gateway/tests/helpers/match-validator.test.mjs b/api-gateway/tests/helpers/match-validator.test.mjs new file mode 100644 index 0000000000..c1082a10d7 --- /dev/null +++ b/api-gateway/tests/helpers/match-validator.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { MatchConstraint } from '../../dist/helpers/decorators/match.validator.js'; + +describe('MatchConstraint (class-validator constraint for "passwords match")', () => { + const c = new MatchConstraint(); + + it('returns true when value === related property', () => { + const args = { object: { password: 'secret123' }, constraints: ['password'] }; + assert.equal(c.validate('secret123', args), true); + }); + + it('returns false on mismatch', () => { + const args = { object: { password: 'secret123' }, constraints: ['password'] }; + assert.equal(c.validate('other', args), false); + }); + + it('returns false when related property is undefined', () => { + const args = { object: {}, constraints: ['password'] }; + assert.equal(c.validate('anything', args), false); + }); +}); diff --git a/api-gateway/tests/helpers/meeco.test.mjs b/api-gateway/tests/helpers/meeco.test.mjs new file mode 100644 index 0000000000..e90ebc0757 --- /dev/null +++ b/api-gateway/tests/helpers/meeco.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { AuthEvents } from '@guardian/interfaces'; +import { MeecoAuth } from '../../dist/helpers/meeco.js'; + +function encodeJwt(payload) { + const header = { alg: 'none', typ: 'JWT' }; + const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + return `${b64(header)}.${b64(payload)}.`; +} + +function buildApprovedSubmission(vc) { + const innerVc = encodeJwt({ vc }); + const vpToken = encodeJwt({ vp: { verifiableCredential: [innerVc] } }); + return { vpRequest: { submission: { vp_token: vpToken } } }; +} + +function freshAuth() { + const auth = new MeecoAuth(); + auth.clients = {}; + return auth; +} + +describe('api-gateway MeecoAuth static token extraction', () => { + it('extractVerifiableCredentialFromMeecoToken returns the inner vc', () => { + const vc = { credentialSubject: { firstName: 'Ada', familyName: 'Lovelace', id: 'did:1' } }; + const submission = buildApprovedSubmission(vc); + const result = MeecoAuth.extractVerifiableCredentialFromMeecoToken(submission); + assert.deepEqual(result, vc); + }); + + it('extractUserFromApprovedMeecoToken returns the credentialSubject', () => { + const credentialSubject = { firstName: 'Ada', familyName: 'Lovelace', id: 'did:1' }; + const submission = buildApprovedSubmission({ credentialSubject }); + const result = MeecoAuth.extractUserFromApprovedMeecoToken(submission); + assert.deepEqual(result, credentialSubject); + }); +}); + +describe('api-gateway MeecoAuth.createMeecoAuthRequest', () => { + it('registers the ws client and returns the redirect uri', async () => { + const auth = freshAuth(); + const calls = []; + auth.sendMessage = async (subject, data) => { + calls.push({ subject, data }); + return { redirectUri: 'https://redirect' }; + }; + const ws = { id: 'ws-1' }; + const result = await auth.createMeecoAuthRequest(ws); + assert.deepEqual(result, { redirectUri: 'https://redirect' }); + assert.equal(auth.clients['ws-1'], ws); + assert.equal(calls[0].subject, AuthEvents.MEECO_AUTH_START); + assert.deepEqual(calls[0].data, { cid: 'ws-1' }); + }); +}); + +describe('api-gateway MeecoAuth.approveSubmission', () => { + it('registers the ws client and forwards approval ids', async () => { + const auth = freshAuth(); + const calls = []; + auth.sendMessage = async (subject, data) => { + calls.push({ subject, data }); + return 'approved'; + }; + const ws = { id: 'ws-2' }; + const result = await auth.approveSubmission(ws, 'pres-req', 'sub-id'); + assert.equal(result, 'approved'); + assert.equal(auth.clients['ws-2'], ws); + assert.equal(calls[0].subject, AuthEvents.MEECO_APPROVE_SUBMISSION); + assert.deepEqual(calls[0].data, { + presentation_request_id: 'pres-req', + submission_id: 'sub-id', + cid: 'ws-2', + }); + }); +}); + +describe('api-gateway MeecoAuth.rejectSubmission', () => { + it('registers the ws client and forwards rejection ids', async () => { + const auth = freshAuth(); + const calls = []; + auth.sendMessage = async (subject, data) => { + calls.push({ subject, data }); + return 'rejected'; + }; + const ws = { id: 'ws-3' }; + const result = await auth.rejectSubmission(ws, 'pres-req', 'sub-id'); + assert.equal(result, 'rejected'); + assert.equal(auth.clients['ws-3'], ws); + assert.equal(calls[0].subject, AuthEvents.MEECO_REJECT_SUBMISSION); + assert.deepEqual(calls[0].data, { + presentation_request_id: 'pres-req', + submission_id: 'sub-id', + cid: 'ws-3', + }); + }); +}); + +describe('api-gateway MeecoAuth.registerListeners', () => { + function captureListeners(auth) { + const handlers = {}; + auth.getMessages = (subject, cb) => { handlers[subject] = cb; }; + auth.registerListeners(); + return handlers; + } + + it('registers handlers for both verify-vp subjects', () => { + const auth = freshAuth(); + const handlers = captureListeners(auth); + assert.equal(typeof handlers[AuthEvents.MEECO_VERIFY_VP], 'function'); + assert.equal(typeof handlers[AuthEvents.MEECO_VERIFY_VP_FAILED], 'function'); + }); + + it('MEECO_VERIFY_VP looks up the user, sets role, and sends to the ws', async () => { + const auth = freshAuth(); + let sent; + const ws = { id: 'cid-1', send: (payload) => { sent = JSON.parse(payload); } }; + auth.clients['cid-1'] = ws; + const sendCalls = []; + auth.sendMessage = async (subject, data) => { + sendCalls.push({ subject, data }); + return { role: 'STANDARD_USER' }; + }; + const handlers = captureListeners(auth); + const msg = { cid: 'cid-1', vc: { firstName: 'Ada', familyName: 'Lovelace', id: 'abc' } }; + await handlers[AuthEvents.MEECO_VERIFY_VP](msg); + assert.equal(sendCalls[0].subject, AuthEvents.GET_USER); + assert.equal(sendCalls[0].data.username, sendCalls[0].data.username.toLowerCase()); + assert.ok(sendCalls[0].data.username.startsWith('adalovelace')); + assert.equal(msg.role, 'STANDARD_USER'); + assert.equal(sent.event, 'MEECO_VERIFY_VP'); + assert.equal(sent.data.role, 'STANDARD_USER'); + }); + + it('MEECO_VERIFY_VP sets role to null when no user is found', async () => { + const auth = freshAuth(); + let sent; + const ws = { id: 'cid-2', send: (payload) => { sent = JSON.parse(payload); } }; + auth.clients['cid-2'] = ws; + auth.sendMessage = async () => null; + const handlers = captureListeners(auth); + const msg = { cid: 'cid-2', vc: { firstName: 'Grace', familyName: 'Hopper', id: 'xyz' } }; + await handlers[AuthEvents.MEECO_VERIFY_VP](msg); + assert.equal(msg.role, null); + assert.equal(sent.data.role, null); + }); + + it('MEECO_VERIFY_VP_FAILED forwards the failure to the ws', async () => { + const auth = freshAuth(); + let sent; + const ws = { id: 'cid-3', send: (payload) => { sent = JSON.parse(payload); } }; + auth.clients['cid-3'] = ws; + const handlers = captureListeners(auth); + const msg = { cid: 'cid-3', reason: 'denied' }; + await handlers[AuthEvents.MEECO_VERIFY_VP_FAILED](msg); + assert.equal(sent.event, 'MEECO_VERIFY_VP_FAILED'); + assert.deepEqual(sent.data, msg); + }); +}); diff --git a/api-gateway/tests/helpers/module-constants.test.mjs b/api-gateway/tests/helpers/module-constants.test.mjs new file mode 100644 index 0000000000..d4ffdddd29 --- /dev/null +++ b/api-gateway/tests/helpers/module-constants.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS, UN_REQUIRED_PROPS } from '../../dist/constants/module.js'; + +describe('api-gateway module constants', () => { + it('REQUIRED_PROPS lists the import-time fields', () => { + assert.equal(REQUIRED_PROPS.NAME, 'name'); + assert.equal(REQUIRED_PROPS.OWNER, 'owner'); + assert.equal(REQUIRED_PROPS.CREATOR, 'creator'); + assert.equal(REQUIRED_PROPS.STATUS, 'status'); + assert.equal(REQUIRED_PROPS.UUID, 'uuid'); + }); + it('UN_REQUIRED_PROPS includes config + createDate + id-style fields', () => { + assert.equal(UN_REQUIRED_PROPS.CONFIG, 'config'); + assert.equal(UN_REQUIRED_PROPS.CREATE_DATE, 'createdDate'); + assert.equal(UN_REQUIRED_PROPS.UPDATE_DATE, 'updateDate'); + assert.equal(UN_REQUIRED_PROPS.TYPE, 'type'); + }); +}); diff --git a/api-gateway/tests/helpers/mongo-constants.test.mjs b/api-gateway/tests/helpers/mongo-constants.test.mjs new file mode 100644 index 0000000000..46e39e9b17 --- /dev/null +++ b/api-gateway/tests/helpers/mongo-constants.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { DEFAULT, LOGGER_PROVIDER } from '../../dist/constants/mongo.js'; + +describe('api-gateway mongo constants', () => { + it('DEFAULT exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); + it('LOGGER_PROVIDER is the string DI token used in NestJS modules', () => { + assert.equal(LOGGER_PROVIDER, 'LOGGER_MONGO_PROVIDER'); + }); +}); diff --git a/api-gateway/tests/helpers/parse-integer.test.mjs b/api-gateway/tests/helpers/parse-integer.test.mjs new file mode 100644 index 0000000000..a38ceeb763 --- /dev/null +++ b/api-gateway/tests/helpers/parse-integer.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { parseInteger } from '../../dist/helpers/utils.js'; + +describe('parseInteger', () => { + it('parses numeric strings as base-10 integers', () => { + assert.equal(parseInteger('42'), 42); + assert.equal(parseInteger('0'), 0); + assert.equal(parseInteger('-7'), -7); + }); + + it('returns undefined for non-numeric strings', () => { + assert.equal(parseInteger('abc'), undefined); + assert.equal(parseInteger(''), undefined); + }); + + it('floors finite numeric input', () => { + assert.equal(parseInteger(3.9), 3); + assert.equal(parseInteger(-3.9), -4); + }); + + it('returns undefined for non-finite numbers', () => { + assert.equal(parseInteger(Infinity), undefined); + assert.equal(parseInteger(NaN), undefined); + }); + + it('returns undefined for non-string non-number values', () => { + assert.equal(parseInteger(null), undefined); + assert.equal(parseInteger(undefined), undefined); + assert.equal(parseInteger({}), undefined); + assert.equal(parseInteger(true), undefined); + }); +}); diff --git a/api-gateway/tests/helpers/performance-interceptor.test.mjs b/api-gateway/tests/helpers/performance-interceptor.test.mjs new file mode 100644 index 0000000000..04fc8bff15 --- /dev/null +++ b/api-gateway/tests/helpers/performance-interceptor.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { of, lastValueFrom } from 'rxjs'; +import { PerformanceInterceptor } from '../../dist/helpers/interceptors/performance.js'; + +function buildContext(request) { + return { + switchToHttp: () => ({ getRequest: () => request }), + }; +} + +describe('PerformanceInterceptor', () => { + const interceptor = new PerformanceInterceptor(); + + it('is constructible and exposes intercept', () => { + assert.equal(typeof interceptor.intercept, 'function'); + }); + + it('passes the downstream value through unchanged', async () => { + const ctx = buildContext({ url: '/api/v1/policies' }); + const next = { handle: () => of({ ok: true }) }; + const result = await lastValueFrom(interceptor.intercept(ctx, next)); + assert.deepEqual(result, { ok: true }); + }); + + it('returns an observable (has subscribe)', () => { + const ctx = buildContext({ url: '/x' }); + const next = { handle: () => of(1) }; + const out = interceptor.intercept(ctx, next); + assert.equal(typeof out.subscribe, 'function'); + }); + + it('logs the request route on emission', async () => { + const logged = []; + const original = console.log; + console.log = (msg) => logged.push(msg); + try { + const ctx = buildContext({ url: '/api/v1/tokens' }); + const next = { handle: () => of('done') }; + await lastValueFrom(interceptor.intercept(ctx, next)); + } finally { + console.log = original; + } + assert.equal(logged.length, 1); + assert.match(logged[0], /Execution time for \/api\/v1\/tokens: [\d.]+ms/); + }); + + it('tolerates a request without a url (undefined route)', async () => { + const logged = []; + const original = console.log; + console.log = (msg) => logged.push(msg); + try { + const ctx = buildContext({}); + const next = { handle: () => of(null) }; + const result = await lastValueFrom(interceptor.intercept(ctx, next)); + assert.equal(result, null); + } finally { + console.log = original; + } + assert.match(logged[0], /Execution time for undefined:/); + }); + + it('does not log before the source emits', () => { + const logged = []; + const original = console.log; + console.log = (msg) => logged.push(msg); + try { + const ctx = buildContext({ url: '/lazy' }); + const next = { handle: () => of(42) }; + interceptor.intercept(ctx, next); + } finally { + console.log = original; + } + assert.equal(logged.length, 0); + }); +}); diff --git a/api-gateway/tests/helpers/policy-constants.test.mjs b/api-gateway/tests/helpers/policy-constants.test.mjs new file mode 100644 index 0000000000..b98f7215b9 --- /dev/null +++ b/api-gateway/tests/helpers/policy-constants.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS, UN_REQUIRED_PROPS } from '../../dist/constants/policy.js'; + +describe('api-gateway policy constants', () => { + it('REQUIRED_PROPS lists identity + lifecycle fields', () => { + for (const k of ['_ID', 'UUID', 'NAME', 'DESCRIPTION', 'STATUS', 'TOPIC_ID', 'INSTANCE_TOPIC_ID', + 'VERSION', 'OWNER', 'CREATOR', 'MESSAGE_ID', 'AVAILABILITY']) { + assert.equal(typeof REQUIRED_PROPS[k], 'string'); + } + assert.equal(REQUIRED_PROPS.UUID, 'uuid'); + assert.equal(REQUIRED_PROPS.AVAILABILITY, 'availability'); + }); + it('UN_REQUIRED_PROPS strips owner/createDate/id-style fields on import', () => { + assert.equal(UN_REQUIRED_PROPS.CREATE_DATE, 'createDate'); + assert.equal(UN_REQUIRED_PROPS.OWNER, 'owner'); + assert.equal(UN_REQUIRED_PROPS.ID, 'id'); + }); +}); diff --git a/api-gateway/tests/helpers/policy-engine-blocks.test.mjs b/api-gateway/tests/helpers/policy-engine-blocks.test.mjs new file mode 100644 index 0000000000..061dd73beb --- /dev/null +++ b/api-gateway/tests/helpers/policy-engine-blocks.test.mjs @@ -0,0 +1,428 @@ +import assert from 'node:assert/strict'; +import { PolicyEngine } from '../../dist/helpers/policy-engine.js'; +import { PolicyEngineEvents } from '@guardian/interfaces'; + +function makeEngine(canned = 'CANNED') { + const pe = new PolicyEngine(undefined); + const calls = []; + pe.sendMessage = async (subject, data) => { + calls.push([subject, data]); + return typeof canned === 'function' ? canned() : canned; + }; + return { pe, calls }; +} + +const OWNER = { id: 'owner-1', did: 'did:owner' }; +const USER = { id: 'user-1', did: 'did:user' }; + +describe('PolicyEngine block data', () => { + it('getPolicyBlocks forwards POLICY_BLOCKS', async () => { + const { pe, calls } = makeEngine(); + await pe.getPolicyBlocks(USER, 'pid', { p: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.POLICY_BLOCKS, { user: USER, policyId: 'pid', params: { p: 1 } }]); + }); + + it('getBlockData forwards GET_BLOCK_DATA with params', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockData(USER, 'pid', 'bid', { p: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_BLOCK_DATA, { user: USER, blockId: 'bid', policyId: 'pid', params: { p: 1 } }]); + }); + + it('getBlockData leaves params undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockData(USER, 'pid', 'bid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_BLOCK_DATA, { user: USER, blockId: 'bid', policyId: 'pid', params: undefined }]); + }); + + it('getBlockDataByTag forwards GET_BLOCK_DATA_BY_TAG', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockDataByTag(USER, 'pid', 'tag', { p: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_BLOCK_DATA_BY_TAG, { user: USER, tag: 'tag', policyId: 'pid', params: { p: 1 } }]); + }); + + it('setBlockData forwards SET_BLOCK_DATA with defaults', async () => { + const { pe, calls } = makeEngine(); + await pe.setBlockData(USER, 'pid', 'bid', { d: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_BLOCK_DATA, { + user: USER, blockId: 'bid', policyId: 'pid', data: { d: 1 }, + syncEvents: false, history: false, timeout: undefined, waitRemotePolicy: undefined + }]); + }); + + it('setBlockData forwards all explicit flags', async () => { + const { pe, calls } = makeEngine(); + await pe.setBlockData(USER, 'pid', 'bid', { d: 1 }, true, true, 5000, true); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_BLOCK_DATA, { + user: USER, blockId: 'bid', policyId: 'pid', data: { d: 1 }, + syncEvents: true, history: true, timeout: 5000, waitRemotePolicy: true + }]); + }); + + it('setBlockDataByTag forwards SET_BLOCK_DATA_BY_TAG with defaults', async () => { + const { pe, calls } = makeEngine(); + await pe.setBlockDataByTag(USER, 'pid', 'tag', { d: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_BLOCK_DATA_BY_TAG, { + user: USER, tag: 'tag', policyId: 'pid', data: { d: 1 }, + syncEvents: false, history: false, timeout: undefined, waitRemotePolicy: undefined + }]); + }); + + it('retryMint forwards RETRY_MINT', async () => { + const { pe, calls } = makeEngine(); + await pe.retryMint(USER, 'pid', 'vp'); + assert.deepEqual(calls[0], [PolicyEngineEvents.RETRY_MINT, { user: USER, policyId: 'pid', vpMessageId: 'vp' }]); + }); + + it('getMintRequests forwards GET_MINT_REQUESTS with all filters', async () => { + const { pe, calls } = makeEngine(); + await pe.getMintRequests(OWNER, 'pid', 'OK', 'acc', 'vp', 1, 20); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_MINT_REQUESTS, { + owner: OWNER, policyId: 'pid', status: 'OK', target: 'acc', vpMessageId: 'vp', pageIndex: 1, pageSize: 20 + }]); + }); + + it('getMintRequests leaves optionals undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getMintRequests(OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_MINT_REQUESTS, { + owner: OWNER, policyId: 'pid', status: undefined, target: undefined, vpMessageId: undefined, pageIndex: undefined, pageSize: undefined + }]); + }); + + it('getBlockByTagName forwards BLOCK_BY_TAG', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockByTagName(USER, 'pid', 'tag'); + assert.deepEqual(calls[0], [PolicyEngineEvents.BLOCK_BY_TAG, { user: USER, tag: 'tag', policyId: 'pid' }]); + }); + + it('getBlockParents forwards GET_BLOCK_PARENTS', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockParents(USER, 'pid', 'bid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_BLOCK_PARENTS, { user: USER, blockId: 'bid', policyId: 'pid' }]); + }); + + it('blockAbout forwards BLOCK_ABOUT', async () => { + const { pe, calls } = makeEngine(); + await pe.blockAbout(USER); + assert.deepEqual(calls[0], [PolicyEngineEvents.BLOCK_ABOUT, { user: USER }]); + }); + + it('getNavigation forwards GET_POLICY_NAVIGATION', async () => { + const { pe, calls } = makeEngine(); + await pe.getNavigation(USER, 'pid', { p: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_NAVIGATION, { user: USER, policyId: 'pid', params: { p: 1 } }]); + }); + + it('getGroups forwards GET_POLICY_GROUPS with savepointIds', async () => { + const { pe, calls } = makeEngine(); + await pe.getGroups(USER, 'pid', ['s1']); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_GROUPS, { user: USER, policyId: 'pid', savepointIds: ['s1'] }]); + }); + + it('getGroups leaves savepointIds undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getGroups(USER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_GROUPS, { user: USER, policyId: 'pid', savepointIds: undefined }]); + }); + + it('selectGroup forwards SELECT_POLICY_GROUP', async () => { + const { pe, calls } = makeEngine(); + await pe.selectGroup(USER, 'pid', 'uuid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.SELECT_POLICY_GROUP, { user: USER, policyId: 'pid', uuid: 'uuid' }]); + }); + + it('getMultiPolicy forwards GET_MULTI_POLICY', async () => { + const { pe, calls } = makeEngine(); + await pe.getMultiPolicy(OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_MULTI_POLICY, { owner: OWNER, policyId: 'pid' }]); + }); + + it('setMultiPolicy forwards SET_MULTI_POLICY', async () => { + const { pe, calls } = makeEngine(); + await pe.setMultiPolicy(OWNER, 'pid', { d: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_MULTI_POLICY, { owner: OWNER, policyId: 'pid', data: { d: 1 } }]); + }); + + it('getTagBlockMap forwards GET_TAG_BLOCK_MAP', async () => { + const { pe, calls } = makeEngine(); + await pe.getTagBlockMap('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_TAG_BLOCK_MAP, { policyId: 'pid', owner: OWNER }]); + }); +}); + +describe('PolicyEngine external data', () => { + it('receiveExternalData forwards RECEIVE_EXTERNAL_DATA with defaults', async () => { + const { pe, calls } = makeEngine(); + await pe.receiveExternalData({ d: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.RECEIVE_EXTERNAL_DATA, { data: { d: 1 }, syncEvents: false, history: false }]); + }); + + it('receiveExternalData forwards explicit flags', async () => { + const { pe, calls } = makeEngine(); + await pe.receiveExternalData({ d: 1 }, true, true); + assert.deepEqual(calls[0], [PolicyEngineEvents.RECEIVE_EXTERNAL_DATA, { data: { d: 1 }, syncEvents: true, history: true }]); + }); + + it('receiveExternalDataCustom forwards RECEIVE_EXTERNAL_DATA_CUSTOM with defaults', async () => { + const { pe, calls } = makeEngine(); + await pe.receiveExternalDataCustom({ d: 1 }, 'pid', 'tag'); + assert.deepEqual(calls[0], [PolicyEngineEvents.RECEIVE_EXTERNAL_DATA_CUSTOM, { data: { d: 1 }, policyId: 'pid', blockTag: 'tag', syncEvents: false, history: false }]); + }); + + it('receiveExternalDataCustom forwards explicit flags', async () => { + const { pe, calls } = makeEngine(); + await pe.receiveExternalDataCustom({ d: 1 }, 'pid', 'tag', true, true); + assert.deepEqual(calls[0], [PolicyEngineEvents.RECEIVE_EXTERNAL_DATA_CUSTOM, { data: { d: 1 }, policyId: 'pid', blockTag: 'tag', syncEvents: true, history: true }]); + }); +}); + +describe('PolicyEngine virtual users and dry-run', () => { + it('getVirtualUsers forwards GET_VIRTUAL_USERS with savepointIds', async () => { + const { pe, calls } = makeEngine(); + await pe.getVirtualUsers('pid', OWNER, ['s1']); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_VIRTUAL_USERS, { policyId: 'pid', owner: OWNER, savepointIds: ['s1'] }]); + }); + + it('getVirtualUsers leaves savepointIds undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getVirtualUsers('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_VIRTUAL_USERS, { policyId: 'pid', owner: OWNER, savepointIds: undefined }]); + }); + + it('getVirtualUser forwards GET_VIRTUAL_USER', async () => { + const { pe, calls } = makeEngine(); + await pe.getVirtualUser('pid', 'did:v', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_VIRTUAL_USER, { policyId: 'pid', did: 'did:v', owner: OWNER }]); + }); + + it('createVirtualUser forwards CREATE_VIRTUAL_USER', async () => { + const { pe, calls } = makeEngine(); + await pe.createVirtualUser('pid', OWNER, ['s1']); + assert.deepEqual(calls[0], [PolicyEngineEvents.CREATE_VIRTUAL_USER, { policyId: 'pid', owner: OWNER, savepointIds: ['s1'] }]); + }); + + it('createVirtualUserV2 forwards CREATE_VIRTUAL_USER_V2', async () => { + const { pe, calls } = makeEngine(); + await pe.createVirtualUserV2('pid', OWNER, ['s1']); + assert.deepEqual(calls[0], [PolicyEngineEvents.CREATE_VIRTUAL_USER_V2, { policyId: 'pid', owner: OWNER, savepointIds: ['s1'] }]); + }); + + it('loginVirtualUser forwards SET_VIRTUAL_USER', async () => { + const { pe, calls } = makeEngine(); + await pe.loginVirtualUser('pid', 'did:v', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_VIRTUAL_USER, { policyId: 'pid', virtualDID: 'did:v', owner: OWNER }]); + }); + + it('restartDryRun forwards RESTART_DRY_RUN', async () => { + const { pe, calls } = makeEngine(); + await pe.restartDryRun({ m: 1 }, OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.RESTART_DRY_RUN, { model: { m: 1 }, owner: OWNER, policyId: 'pid' }]); + }); + + it('runBlock forwards DRY_RUN_BLOCK', async () => { + const { pe, calls } = makeEngine(); + await pe.runBlock('pid', { c: 1 }, OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.DRY_RUN_BLOCK, { policyId: 'pid', config: { c: 1 }, owner: OWNER }]); + }); + + it('getBlockHistory forwards DRY_RUN_BLOCK_HISTORY', async () => { + const { pe, calls } = makeEngine(); + await pe.getBlockHistory('pid', 'tag', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.DRY_RUN_BLOCK_HISTORY, { policyId: 'pid', tag: 'tag', owner: OWNER }]); + }); +}); + +describe('PolicyEngine savepoints', () => { + it('getSavepoints forwards GET_SAVEPOINTS', async () => { + const { pe, calls } = makeEngine(); + await pe.getSavepoints('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_SAVEPOINTS, { policyId: 'pid', owner: OWNER }]); + }); + + it('getSavepoint forwards GET_SAVEPOINT', async () => { + const { pe, calls } = makeEngine(); + await pe.getSavepoint('pid', 'sp', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_SAVEPOINT, { policyId: 'pid', owner: OWNER, savepointId: 'sp' }]); + }); + + it('getSavepointsCount forwards GET_SAVEPOINTS_COUNT with includeDeleted', async () => { + const { pe, calls } = makeEngine(); + await pe.getSavepointsCount('pid', OWNER, true); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_SAVEPOINTS_COUNT, { policyId: 'pid', owner: OWNER, includeDeleted: true }]); + }); + + it('getSavepointsCount leaves includeDeleted undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getSavepointsCount('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_SAVEPOINTS_COUNT, { policyId: 'pid', owner: OWNER, includeDeleted: undefined }]); + }); + + it('selectSavepoint forwards SELECT_SAVEPOINT', async () => { + const { pe, calls } = makeEngine(); + await pe.selectSavepoint('pid', 'sp', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.SELECT_SAVEPOINT, { policyId: 'pid', savepointId: 'sp', owner: OWNER }]); + }); + + it('createSavepoint forwards CREATE_SAVEPOINT', async () => { + const { pe, calls } = makeEngine(); + const props = { name: 'n', savepointPath: ['a'] }; + await pe.createSavepoint('pid', OWNER, props); + assert.deepEqual(calls[0], [PolicyEngineEvents.CREATE_SAVEPOINT, { policyId: 'pid', owner: OWNER, savepointProps: props }]); + }); + + it('updateSavepoint forwards UPDATE_SAVEPOINT with name', async () => { + const { pe, calls } = makeEngine(); + await pe.updateSavepoint('pid', 'sp', OWNER, 'new-name'); + assert.deepEqual(calls[0], [PolicyEngineEvents.UPDATE_SAVEPOINT, { policyId: 'pid', savepointId: 'sp', owner: OWNER, name: 'new-name' }]); + }); + + it('deleteSavepoints forwards DELETE_SAVEPOINTS', async () => { + const { pe, calls } = makeEngine(); + await pe.deleteSavepoints('pid', OWNER, ['s1', 's2'], true); + assert.deepEqual(calls[0], [PolicyEngineEvents.DELETE_SAVEPOINTS, { policyId: 'pid', owner: OWNER, savepointIds: ['s1', 's2'], skipCurrentSavepointGuard: true }]); + }); +}); + +describe('PolicyEngine mock data', () => { + it('getVirtualDocuments forwards GET_VIRTUAL_DOCUMENTS with paging', async () => { + const { pe, calls } = makeEngine(); + await pe.getVirtualDocuments('pid', 'VC', OWNER, 1, 20); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_VIRTUAL_DOCUMENTS, { policyId: 'pid', type: 'VC', owner: OWNER, pageIndex: 1, pageSize: 20 }]); + }); + + it('getVirtualDocuments leaves paging undefined when omitted', async () => { + const { pe, calls } = makeEngine(); + await pe.getVirtualDocuments('pid', 'VC', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_VIRTUAL_DOCUMENTS, { policyId: 'pid', type: 'VC', owner: OWNER, pageIndex: undefined, pageSize: undefined }]); + }); + + it('getMockConfig forwards GET_MOCK_CONFIG', async () => { + const { pe, calls } = makeEngine(); + await pe.getMockConfig('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_MOCK_CONFIG, { policyId: 'pid', owner: OWNER }]); + }); + + it('getMockData forwards GET_MOCK_DATA', async () => { + const { pe, calls } = makeEngine(); + await pe.getMockData('pid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_MOCK_DATA, { policyId: 'pid', owner: OWNER }]); + }); + + it('setMockConfig forwards SET_MOCK_CONFIG', async () => { + const { pe, calls } = makeEngine(); + await pe.setMockConfig('pid', OWNER, { c: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_MOCK_CONFIG, { policyId: 'pid', owner: OWNER, config: { c: 1 } }]); + }); + + it('updateMockData forwards SET_MOCK_DATA', async () => { + const { pe, calls } = makeEngine(); + await pe.updateMockData('pid', OWNER, { d: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.SET_MOCK_DATA, { policyId: 'pid', owner: OWNER, data: { d: 1 } }]); + }); + + it('importMock forwards IMPORT_MOCK_DATA with zip first', async () => { + const { pe, calls } = makeEngine(); + await pe.importMock('pid', OWNER, { z: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.IMPORT_MOCK_DATA, { zip: { z: 1 }, policyId: 'pid', owner: OWNER }]); + }); + + it('exportMock base64-decodes the response', async () => { + const b64 = Buffer.from('mock').toString('base64'); + const { pe, calls } = makeEngine(b64); + const buf = await pe.exportMock('pid', OWNER); + assert.equal(buf.toString(), 'mock'); + assert.deepEqual(calls[0], [PolicyEngineEvents.EXPORT_MOCK_DATA, { policyId: 'pid', owner: OWNER }]); + }); + + it('mockRequest forwards MOCK_REQUEST', async () => { + const { pe, calls } = makeEngine(); + await pe.mockRequest('pid', OWNER, 'TYPE', { c: 1 }); + assert.deepEqual(calls[0], [PolicyEngineEvents.MOCK_REQUEST, { policyId: 'pid', owner: OWNER, type: 'TYPE', config: { c: 1 } }]); + }); +}); + +describe('PolicyEngine policy tests', () => { + it('addPolicyTest forwards ADD_POLICY_TEST', async () => { + const { pe, calls } = makeEngine(); + await pe.addPolicyTest('pid', { f: 1 }, OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.ADD_POLICY_TEST, { policyId: 'pid', file: { f: 1 }, owner: OWNER }]); + }); + + it('getPolicyTest forwards GET_POLICY_TEST', async () => { + const { pe, calls } = makeEngine(); + await pe.getPolicyTest('pid', 'tid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_TEST, { policyId: 'pid', testId: 'tid', owner: OWNER }]); + }); + + it('startPolicyTest forwards START_POLICY_TEST', async () => { + const { pe, calls } = makeEngine(); + await pe.startPolicyTest('pid', 'tid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.START_POLICY_TEST, { policyId: 'pid', testId: 'tid', owner: OWNER }]); + }); + + it('stopPolicyTest forwards STOP_POLICY_TEST', async () => { + const { pe, calls } = makeEngine(); + await pe.stopPolicyTest('pid', 'tid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.STOP_POLICY_TEST, { policyId: 'pid', testId: 'tid', owner: OWNER }]); + }); + + it('deletePolicyTest forwards DELETE_POLICY_TEST', async () => { + const { pe, calls } = makeEngine(); + await pe.deletePolicyTest('pid', 'tid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.DELETE_POLICY_TEST, { policyId: 'pid', testId: 'tid', owner: OWNER }]); + }); + + it('getTestDetails forwards GET_POLICY_TEST_DETAILS', async () => { + const { pe, calls } = makeEngine(); + await pe.getTestDetails('pid', 'tid', OWNER); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_TEST_DETAILS, { policyId: 'pid', testId: 'tid', owner: OWNER }]); + }); +}); + +describe('PolicyEngine documents', () => { + it('getDocuments forwards GET_POLICY_DOCUMENTS with defaults', async () => { + const { pe, calls } = makeEngine(); + await pe.getDocuments(OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_DOCUMENTS, { + owner: OWNER, policyId: 'pid', includeDocument: false, type: undefined, pageIndex: undefined, pageSize: undefined + }]); + }); + + it('getDocuments forwards explicit args', async () => { + const { pe, calls } = makeEngine(); + await pe.getDocuments(OWNER, 'pid', true, 'VC', 2, 50); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_DOCUMENTS, { + owner: OWNER, policyId: 'pid', includeDocument: true, type: 'VC', pageIndex: 2, pageSize: 50 + }]); + }); + + it('searchDocuments forwards SEARCH_POLICY_DOCUMENTS', async () => { + const { pe, calls } = makeEngine(); + await pe.searchDocuments(OWNER, 'pid', 'txt', ['s'], ['o'], ['t'], ['r'], 1, 10); + assert.deepEqual(calls[0], [PolicyEngineEvents.SEARCH_POLICY_DOCUMENTS, { + owner: OWNER, policyId: 'pid', textSearch: 'txt', schemas: ['s'], owners: ['o'], tokens: ['t'], related: ['r'], pageIndex: 1, pageSize: 10 + }]); + }); + + it('exportDocuments base64-decodes the response', async () => { + const b64 = Buffer.from('docs').toString('base64'); + const { pe, calls } = makeEngine(b64); + const buf = await pe.exportDocuments(OWNER, 'pid', ['id1'], 'txt', ['s'], ['o'], ['t'], ['r']); + assert.equal(buf.toString(), 'docs'); + assert.deepEqual(calls[0], [PolicyEngineEvents.EXPORT_POLICY_DOCUMENTS, { + owner: OWNER, policyId: 'pid', ids: ['id1'], textSearch: 'txt', schemas: ['s'], owners: ['o'], tokens: ['t'], related: ['r'] + }]); + }); + + it('getDocumentOwners forwards GET_POLICY_OWNERS', async () => { + const { pe, calls } = makeEngine(); + await pe.getDocumentOwners(OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_OWNERS, { owner: OWNER, policyId: 'pid' }]); + }); + + it('getTokens forwards GET_POLICY_TOKENS', async () => { + const { pe, calls } = makeEngine(); + await pe.getTokens(OWNER, 'pid'); + assert.deepEqual(calls[0], [PolicyEngineEvents.GET_POLICY_TOKENS, { owner: OWNER, policyId: 'pid' }]); + }); +}); diff --git a/api-gateway/tests/helpers/providers-index.test.mjs b/api-gateway/tests/helpers/providers-index.test.mjs new file mode 100644 index 0000000000..b7394d3e91 --- /dev/null +++ b/api-gateway/tests/helpers/providers-index.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import * as providers from '../../dist/helpers/providers/index.js'; +import * as cache from '../../dist/helpers/providers/cache-provider.js'; +import * as mongo from '../../dist/helpers/providers/logger-mongo-provider.js'; +import * as pino from '../../dist/helpers/providers/pino-logger-provider.js'; + +describe('providers barrel (helpers/providers/index.js)', () => { + it('does not re-export a default binding', () => { + assert.equal(providers.default, undefined); + }); + + it('re-exports the cache-provider bindings', () => { + assert.equal(providers.cacheProvider, cache.cacheProvider); + assert.equal(providers.CACHE_CLIENT, cache.CACHE_CLIENT); + }); + + it('re-exports the mongo logger provider', () => { + assert.equal(providers.loggerMongoProvider, mongo.loggerMongoProvider); + }); + + it('re-exports the pino logger provider', () => { + assert.equal(providers.pinoLoggerProvider, pino.pinoLoggerProvider); + }); + + it('re-exports every named binding from each sub-module', () => { + for (const sub of [cache, mongo, pino]) { + for (const key of Object.keys(sub)) { + if (key === 'default') { + continue; + } + assert.ok( + Object.prototype.hasOwnProperty.call(providers, key), + `barrel is missing re-export "${key}"` + ); + assert.equal(providers[key], sub[key], `binding "${key}" should be identical`); + } + } + }); +}); diff --git a/api-gateway/tests/helpers/routes-constants.test.mjs b/api-gateway/tests/helpers/routes-constants.test.mjs new file mode 100644 index 0000000000..292a9a56f6 --- /dev/null +++ b/api-gateway/tests/helpers/routes-constants.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { PREFIXES } from '../../dist/constants/routes.js'; + +describe('api-gateway route prefix constants', () => { + it('exposes the full set of resource path prefixes', () => { + assert.equal(PREFIXES.ACCOUNTS, 'accounts'); + assert.equal(PREFIXES.ARTIFACTS, '/artifacts/'); + assert.equal(PREFIXES.MODULES, '/modules/'); + assert.equal(PREFIXES.SCHEMES, '/schemas/'); + assert.equal(PREFIXES.TOOLS, '/tools/'); + assert.equal(PREFIXES.POLICIES, '/policies/'); + assert.equal(PREFIXES.CONTRACTS, '/contracts/'); + assert.equal(PREFIXES.TAGS, '/tags/'); + assert.equal(PREFIXES.IPFS, 'ipfs'); + assert.equal(PREFIXES.PROFILES, 'profiles'); + assert.equal(PREFIXES.POLICY_COMMENTS, '/policy-comments/'); + }); +}); diff --git a/api-gateway/tests/helpers/schema-constants.test.mjs b/api-gateway/tests/helpers/schema-constants.test.mjs new file mode 100644 index 0000000000..bbb73bb43c --- /dev/null +++ b/api-gateway/tests/helpers/schema-constants.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS, UN_REQUIRED_PROPS } from '../../dist/constants/schema.js'; + +describe('api-gateway schema constants', () => { + it('REQUIRED_PROPS exposes IRI/UUID/messageId/version among others', () => { + for (const k of ['ACTIVE', 'ENTITY', 'NAME', 'OWNER', 'STATUS', 'TOPIC_ID', '_ID', 'CREATOR', + 'IRI', 'UUID', 'MESSAGE_ID', 'DESCRIPTION', 'VERSION']) { + assert.equal(typeof REQUIRED_PROPS[k], 'string'); + } + assert.equal(REQUIRED_PROPS.IRI, 'iri'); + assert.equal(REQUIRED_PROPS.MESSAGE_ID, 'messageId'); + }); + it('UN_REQUIRED_PROPS includes the heavy document/context fields stripped before listing', () => { + assert.equal(UN_REQUIRED_PROPS.DOCUMENT, 'document'); + assert.equal(UN_REQUIRED_PROPS.CONTEXT, 'context'); + }); +}); diff --git a/api-gateway/tests/helpers/schema-utils-extra.test.mjs b/api-gateway/tests/helpers/schema-utils-extra.test.mjs new file mode 100644 index 0000000000..b9da359250 --- /dev/null +++ b/api-gateway/tests/helpers/schema-utils-extra.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { SchemaUtils } from '../../dist/helpers/schema-utils.js'; +import { SchemaCategory } from '@guardian/interfaces'; + +describe('SchemaUtils.toOld edge branches', () => { + it('leaves array entries without document/context untouched', () => { + const arr = [{ name: 'a' }, { document: { x: 1 } }]; + const out = SchemaUtils.toOld(arr); + assert.equal(out, arr); + assert.equal(arr[0].name, 'a'); + assert.equal(arr[1].document, '{"x":1}'); + }); + + it('handles a single schema with only a context field', () => { + const s = { context: { c: 1 } }; + const out = SchemaUtils.toOld(s); + assert.equal(out.context, '{"c":1}'); + assert.equal(out.document, undefined); + }); + + it('handles a single schema with neither document nor context', () => { + const s = { name: 'plain' }; + const out = SchemaUtils.toOld(s); + assert.equal(out, s); + }); +}); + +describe('SchemaUtils.fromOld partial branches', () => { + it('parses document but leaves a non-string context as-is', () => { + const s = { document: '{"a":1}', context: { b: 2 } }; + const out = SchemaUtils.fromOld(s); + assert.deepEqual(out.document, { a: 1 }); + assert.deepEqual(out.context, { b: 2 }); + }); +}); + +describe('SchemaUtils.checkPermission', () => { + const user = { username: 'alice', creator: 'alice-c', owner: 'org-1' }; + + it('returns an error when the schema is missing', () => { + assert.equal(SchemaUtils.checkPermission(null, user, SchemaCategory.POLICY), 'Schema does not exist.'); + }); + + it('rejects a system schema whose creator does not match username or creator', () => { + const schema = { system: true, creator: 'bob' }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), 'Invalid creator.'); + }); + + it('allows a system schema when creator matches username', () => { + const schema = { system: true, creator: 'alice', category: SchemaCategory.SYSTEM }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), null); + }); + + it('allows a system schema when creator matches the creator field', () => { + const schema = { system: true, creator: 'alice-c', category: SchemaCategory.SYSTEM }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), null); + }); + + it('rejects a non-system schema with a mismatched owner', () => { + const schema = { system: false, owner: 'org-2', category: SchemaCategory.POLICY }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.POLICY), 'Invalid creator.'); + }); + + describe('TAG type', () => { + it('rejects a system schema', () => { + const schema = { system: true, creator: 'alice' }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.TAG), 'Schema is system.'); + }); + + it('rejects a non-tag category', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.POLICY }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.TAG), 'Invalid schema category.'); + }); + + it('allows a valid tag schema', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.TAG }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.TAG), null); + }); + }); + + describe('SYSTEM type', () => { + it('rejects a non-system schema', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.SYSTEM }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), 'Schema is not system.'); + }); + + it('rejects a system schema with a POLICY category', () => { + const schema = { system: true, creator: 'alice', category: SchemaCategory.POLICY }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), 'Invalid schema category.'); + }); + + it('rejects a system schema with a TAG category', () => { + const schema = { system: true, creator: 'alice', category: SchemaCategory.TAG }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.SYSTEM), 'Invalid schema category.'); + }); + }); + + describe('default (e.g. POLICY) type', () => { + it('rejects a system schema', () => { + const schema = { system: true, creator: 'alice' }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.POLICY), 'Schema is system.'); + }); + + it('rejects a SYSTEM category for a non-system schema', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.SYSTEM }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.POLICY), 'Invalid schema category.'); + }); + + it('rejects a TAG category for a non-system schema', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.TAG }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.POLICY), 'Invalid schema category.'); + }); + + it('allows a valid policy schema', () => { + const schema = { system: false, owner: 'org-1', category: SchemaCategory.POLICY }; + assert.equal(SchemaUtils.checkPermission(schema, user, SchemaCategory.POLICY), null); + }); + }); +}); diff --git a/api-gateway/tests/helpers/schema-utils.test.mjs b/api-gateway/tests/helpers/schema-utils.test.mjs new file mode 100644 index 0000000000..aec991e42d --- /dev/null +++ b/api-gateway/tests/helpers/schema-utils.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { SchemaUtils } from '../../dist/helpers/schema-utils.js'; + +describe('SchemaUtils.toOld', () => { + it('JSON-stringifies document and context fields on a single schema', () => { + const s = { document: { a: 1 }, context: { b: 2 } }; + const out = SchemaUtils.toOld(s); + assert.equal(typeof out.document, 'string'); + assert.equal(out.document, '{"a":1}'); + assert.equal(out.context, '{"b":2}'); + }); + + it('mutates and returns each schema in an array', () => { + const arr = [{ document: { x: 1 } }, { context: { y: 2 } }]; + const out = SchemaUtils.toOld(arr); + assert.equal(out, arr); + assert.equal(arr[0].document, '{"x":1}'); + assert.equal(arr[1].context, '{"y":2}'); + }); + + it('passes null/undefined through unchanged', () => { + assert.equal(SchemaUtils.toOld(null), null); + assert.equal(SchemaUtils.toOld(undefined), undefined); + }); +}); + +describe('SchemaUtils.fromOld', () => { + it('JSON-parses string document and context back to objects', () => { + const s = { document: '{"a":1}', context: '{"b":2}' }; + const out = SchemaUtils.fromOld(s); + assert.deepEqual(out.document, { a: 1 }); + assert.deepEqual(out.context, { b: 2 }); + }); + + it('leaves non-string document/context untouched', () => { + const s = { document: { a: 1 }, context: { b: 2 } }; + const out = SchemaUtils.fromOld(s); + assert.deepEqual(out.document, { a: 1 }); + }); + + it('handles missing schema (passes through)', () => { + assert.equal(SchemaUtils.fromOld(null), null); + assert.equal(SchemaUtils.fromOld(undefined), undefined); + }); +}); + +describe('SchemaUtils.clearIds', () => { + it('removes version/id/status/topicId/_id fields and returns the same schema reference', () => { + const s = { version: '1', id: 'i', status: 'PUBLISHED', topicId: '0.0.1', _id: 'm', name: 'keep' }; + const out = SchemaUtils.clearIds(s); + assert.equal(out, s); + assert.deepEqual(out, { name: 'keep' }); + }); +}); diff --git a/api-gateway/tests/helpers/singleton-decorator.test.mjs b/api-gateway/tests/helpers/singleton-decorator.test.mjs new file mode 100644 index 0000000000..b5eb9c4133 --- /dev/null +++ b/api-gateway/tests/helpers/singleton-decorator.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { Singleton } from '../../dist/helpers/decorators/singleton.js'; + +describe('api-gateway Singleton decorator', () => { + it('returns the same instance for repeated direct construction', () => { + const C = Singleton(class C { constructor() { this.id = Math.random(); } }); + const a = new C(); + const b = new C(); + assert.equal(a, b); + }); + it('runs the constructor only once', () => { + let count = 0; + const C = Singleton(class C { constructor() { count++; } }); + new C(); new C(); new C(); + assert.equal(count, 1); + }); + it('subclass instances are not collapsed into the base singleton', () => { + const Base = Singleton(class Base { constructor() {} }); + class Child extends Base { constructor() { super(); } } + const a = new Child(); + const b = new Child(); + assert.notEqual(a, b); + }); +}); diff --git a/api-gateway/tests/helpers/stream-to-buffer.test.mjs b/api-gateway/tests/helpers/stream-to-buffer.test.mjs new file mode 100644 index 0000000000..0d2d229c96 --- /dev/null +++ b/api-gateway/tests/helpers/stream-to-buffer.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { streamToBuffer } from '../../dist/helpers/stream-to-buffer.js'; + +describe('streamToBuffer', () => { + it('concatenates string chunks into a Buffer', async () => { + const stream = Readable.from(['hello', ' ', 'world']); + const buf = await streamToBuffer(stream); + assert.ok(Buffer.isBuffer(buf)); + assert.equal(buf.toString('utf8'), 'hello world'); + }); + + it('concatenates Buffer chunks', async () => { + const stream = Readable.from([Buffer.from([0x01, 0x02]), Buffer.from([0x03])]); + const buf = await streamToBuffer(stream); + assert.deepEqual([...buf], [1, 2, 3]); + }); + + it('resolves to an empty Buffer for an empty stream', async () => { + const stream = Readable.from([]); + const buf = await streamToBuffer(stream); + assert.equal(buf.length, 0); + }); + + it('rejects when the stream errors', async () => { + const stream = new Readable({ + read() { + process.nextTick(() => this.emit('error', new Error('boom'))); + }, + }); + await assert.rejects(streamToBuffer(stream), /boom/); + }); +}); diff --git a/api-gateway/tests/helpers/task-manager.test.mjs b/api-gateway/tests/helpers/task-manager.test.mjs new file mode 100644 index 0000000000..1c85117c65 --- /dev/null +++ b/api-gateway/tests/helpers/task-manager.test.mjs @@ -0,0 +1,343 @@ +import assert from 'node:assert/strict'; +import { TaskManager, TaskManagerChannel } from '../../dist/helpers/task-manager.js'; +import { TaskAction } from '@guardian/interfaces'; + +const captured = {}; +TaskManagerChannel.prototype.setConnection = function () { return this; }; +TaskManagerChannel.prototype.subscribe = function (subject, cb) { captured[subject] = cb; }; +TaskManagerChannel.prototype.publish = function (subject, payload) { + publishCalls.push({ subject, payload }); +}; +const publishCalls = []; + +function freshManager() { + const tm = new TaskManager(); + for (const key of Object.keys(tm.tasks)) { + delete tm.tasks[key]; + } + tm.callbacks.clear(); + tm.notifyTaskLock.clear(); + wsCalls.length = 0; + publishCalls.length = 0; + tm.wsService = { notifyTaskProgress: (t) => wsCalls.push(t) }; + tm.channel = { publish: (subject, payload) => publishCalls.push({ subject, payload }) }; + return tm; +} + +const wsCalls = []; + +describe('TaskManagerChannel', () => { + it('is a singleton-shared instance', () => { + const a = new TaskManager(); + const b = new TaskManager(); + assert.equal(a, b); + }); + + it('registerListener delegates to getMessages', () => { + const ch = new TaskManagerChannel(); + let seen = null; + ch.getMessages = (event, cb) => { seen = { event, cb }; }; + const fn = () => { }; + ch.registerListener('EVT', fn); + assert.deepEqual(seen, { event: 'EVT', cb: fn }); + }); + + it('exposes the task-queue message queue name', () => { + const ch = new TaskManagerChannel(); + assert.equal(ch.messageQueueName, 'task-queue'); + assert.ok(ch.replySubject.startsWith('task-reply-')); + }); +}); + +describe('TaskManager.start / getExpectation', () => { + it('creates a task with a known expectation and publishes it', () => { + const tm = freshManager(); + const nt = tm.start(TaskAction.CREATE_TOKEN, 'user-1'); + assert.equal(nt.action, TaskAction.CREATE_TOKEN); + assert.equal(nt.userId, 'user-1'); + assert.equal(nt.expectation, 4); + assert.ok(tm.tasks[nt.taskId]); + assert.equal(publishCalls.length, 1); + assert.equal(publishCalls[0].payload.taskId, nt.taskId); + }); + + it('defaults expectation to 2 and caches it for an unknown action', () => { + const tm = freshManager(); + const nt = tm.start('SOME_UNKNOWN_ACTION', 'user-2'); + assert.equal(nt.expectation, 2); + const nt2 = tm.start('SOME_UNKNOWN_ACTION', 'user-3'); + assert.equal(nt2.expectation, 2); + }); + +}); + +describe('TaskManager.addStatuses / addStatus', () => { + it('appends statuses to an existing task and notifies', () => { + const tm = freshManager(); + tm.tasks['t1'] = { statuses: [], userId: 'u' }; + tm.addStatuses('t1', [{ message: 'a' }, { message: 'b' }]); + assert.equal(tm.tasks['t1'].statuses.length, 2); + assert.equal(wsCalls.length, 1); + }); + + it('silently returns for an unknown task when skipIfNotFound is true', () => { + const tm = freshManager(); + assert.doesNotThrow(() => tm.addStatuses('missing', [{ message: 'x' }])); + assert.equal(wsCalls.length, 0); + }); + + it('throws for an unknown task when skipIfNotFound is false', () => { + const tm = freshManager(); + assert.throws(() => tm.addStatuses('missing', [{ message: 'x' }], false), /not found/); + }); + + it('addStatus wraps a single message into addStatuses', () => { + const tm = freshManager(); + tm.tasks['t2'] = { statuses: [], userId: 'u' }; + tm.addStatus('t2', 'hello', 'PROCESSING'); + assert.deepEqual(tm.tasks['t2'].statuses, [{ message: 'hello', type: 'PROCESSING' }]); + }); +}); + +describe('TaskManager.addInfo', () => { + it('sets info when no prior info exists', () => { + const tm = freshManager(); + tm.tasks['i1'] = { statuses: [], userId: 'u' }; + tm.addInfo('i1', { timestamp: 5, text: 'x' }); + assert.equal(tm.tasks['i1'].info.text, 'x'); + }); + + it('overwrites info with an equal-or-newer timestamp', () => { + const tm = freshManager(); + tm.tasks['i2'] = { statuses: [], userId: 'u', info: { timestamp: 5 } }; + tm.addInfo('i2', { timestamp: 9, text: 'newer' }); + assert.equal(tm.tasks['i2'].info.text, 'newer'); + }); + + it('keeps existing info when the new timestamp is older', () => { + const tm = freshManager(); + tm.tasks['i3'] = { statuses: [], userId: 'u', info: { timestamp: 9, text: 'keep' } }; + tm.addInfo('i3', { timestamp: 1, text: 'older' }); + assert.equal(tm.tasks['i3'].info.text, 'keep'); + }); + + it('sets info when existing info has no timestamp', () => { + const tm = freshManager(); + tm.tasks['i4'] = { statuses: [], userId: 'u', info: {} }; + tm.addInfo('i4', { timestamp: 1, text: 'set' }); + assert.equal(tm.tasks['i4'].info.text, 'set'); + }); + + it('silently returns for a missing task with skipIfNotFound true', () => { + const tm = freshManager(); + assert.doesNotThrow(() => tm.addInfo('nope', { timestamp: 1 })); + }); + + it('throws for a missing task with skipIfNotFound false', () => { + const tm = freshManager(); + assert.throws(() => tm.addInfo('nope', { timestamp: 1 }, false), /not found/); + }); +}); + +describe('TaskManager.addResult / addError / callbacks', () => { + it('sets result and notifies progress when no callback registered', () => { + const tm = freshManager(); + tm.tasks['r1'] = { statuses: [], userId: 'u', taskId: 'r1' }; + tm.addResult('r1', { ok: true }); + assert.deepEqual(tm.tasks['r1'].result, { ok: true }); + assert.equal(wsCalls.length, 1); + }); + + it('invokes a registered callback and cleans it up afterwards', async () => { + const tm = freshManager(); + tm.tasks['r2'] = { statuses: [], userId: 'u', taskId: 'r2' }; + let called = null; + tm.registerCallback({ taskId: 'r2' }, async (task) => { called = task; }); + tm.addResult('r2', { v: 1 }); + await new Promise((res) => setImmediate(res)); + assert.equal(called.taskId, 'r2'); + assert.equal(tm.callbacks.has('r2'), false); + }); + + it('sets error and routes through the callback path', async () => { + const tm = freshManager(); + tm.tasks['e1'] = { statuses: [], userId: 'u', taskId: 'e1' }; + let got = null; + tm.registerCallback({ taskId: 'e1' }, async (task) => { got = task.error; }); + tm.addError('e1', new Error('boom')); + await new Promise((res) => setImmediate(res)); + assert.equal(got.message, 'boom'); + }); + + it('addResult skips silently for a missing task', () => { + const tm = freshManager(); + assert.doesNotThrow(() => tm.addResult('missing', {})); + }); + + it('addResult throws for a missing task when skipIfNotFound false', () => { + const tm = freshManager(); + assert.throws(() => tm.addResult('missing', {}, false), /not found/); + }); + + it('addError skips silently for a missing task', () => { + const tm = freshManager(); + assert.doesNotThrow(() => tm.addError('missing', new Error('x'))); + }); + + it('addError throws for a missing task when skipIfNotFound false', () => { + const tm = freshManager(); + assert.throws(() => tm.addError('missing', new Error('x'), false), /not found/); + }); +}); + +describe('TaskManager.getState', () => { + it('returns the task when the user matches', () => { + const tm = freshManager(); + tm.tasks['g1'] = { userId: 'owner', statuses: [] }; + assert.equal(tm.getState('owner', 'g1'), tm.tasks['g1']); + }); + + it('skips silently when the user does not match', () => { + const tm = freshManager(); + tm.tasks['g2'] = { userId: 'owner', statuses: [] }; + assert.equal(tm.getState('other', 'g2'), undefined); + }); + + it('throws when the task is missing and skipIfNotFound is false', () => { + const tm = freshManager(); + assert.throws(() => tm.getState('u', 'missing', false), /not found/); + }); +}); + +describe('TaskManager.transferOwnership', () => { + it('reassigns the userId on an existing task', () => { + const tm = freshManager(); + tm.tasks['o1'] = { userId: 'old', statuses: [] }; + tm.transferOwnership('o1', 'new'); + assert.equal(tm.tasks['o1'].userId, 'new'); + }); + + it('does nothing for a missing task', () => { + const tm = freshManager(); + assert.doesNotThrow(() => tm.transferOwnership('missing', 'new')); + }); +}); + +describe('TaskManager.getOnboardingTask', () => { + it('returns undefined for a missing task', () => { + const tm = freshManager(); + assert.equal(tm.getOnboardingTask('missing'), undefined); + }); + + it('throws a TASK_NOT_ONBOARDING error for non-onboarding actions', () => { + const tm = freshManager(); + tm.tasks['ob1'] = { action: TaskAction.CREATE_TOKEN, taskId: 'ob1' }; + try { + tm.getOnboardingTask('ob1'); + assert.fail('should have thrown'); + } catch (err) { + assert.equal(err.code, 'TASK_NOT_ONBOARDING'); + } + }); + + it('returns a sanitized completed onboarding task', () => { + const tm = freshManager(); + tm.tasks['ob2'] = { + taskId: 'ob2', + action: TaskAction.ONBOARD_USER, + expectation: 9, + result: { secret: 'x' }, + error: null, + }; + const out = tm.getOnboardingTask('ob2'); + assert.deepEqual(out, { + taskId: 'ob2', + action: TaskAction.ONBOARD_USER, + expectation: 9, + completed: true, + failed: false, + error: null, + }); + }); + + it('returns a sanitized failed onboarding task with a fallback message', () => { + const tm = freshManager(); + tm.tasks['ob3'] = { + taskId: 'ob3', + action: TaskAction.ONBOARD_USER, + expectation: 9, + result: null, + error: {}, + }; + const out = tm.getOnboardingTask('ob3'); + assert.equal(out.completed, false); + assert.equal(out.failed, true); + assert.deepEqual(out.error, { message: 'Task failed' }); + }); + + it('surfaces the underlying error message when present', () => { + const tm = freshManager(); + tm.tasks['ob4'] = { + taskId: 'ob4', + action: TaskAction.ONBOARD_USER, + expectation: 9, + result: null, + error: { message: 'real error' }, + }; + const out = tm.getOnboardingTask('ob4'); + assert.deepEqual(out.error, { message: 'real error' }); + }); +}); + +describe('TaskManager.notifyTaskProgress rate limiting', () => { + it('debounces repeated canSkip notifications via the lock', async () => { + const tm = freshManager(); + const task = { statuses: [], userId: 'u', marker: 'n1' }; + tm.tasks['n1'] = task; + tm.addInfo('n1', { timestamp: 1 }); + tm.addInfo('n1', { timestamp: 2 }); + assert.equal(wsCalls.filter((t) => t === task).length, 0); + assert.equal(tm.notifyTaskLock.has('n1'), true); + await new Promise((res) => setTimeout(res, 1100)); + assert.equal(wsCalls.filter((t) => t === task).length, 1); + assert.equal(tm.notifyTaskLock.has('n1'), false); + }); +}); + +describe('TaskManager.setDependencies subscriptions', () => { + it('routes UPDATE_TASK_STATUS messages to info/status/result/error handlers', async () => { + const tm = freshManager(); + tm.setDependencies(tm.wsService, {}); + const handler = captured['UPDATE_TASK_STATUS']; + assert.equal(typeof handler, 'function'); + + tm.tasks['s1'] = { statuses: [], userId: 'u', taskId: 's1' }; + await handler({ taskId: 's1', info: { timestamp: 1 }, result: { ok: 1 } }); + assert.ok(tm.tasks['s1'].info); + assert.deepEqual(tm.tasks['s1'].result, { ok: 1 }); + + tm.tasks['s2'] = { statuses: [], userId: 'u', taskId: 's2' }; + await handler({ taskId: 's2', statuses: [{ message: 'm' }], error: new Error('e') }); + assert.equal(tm.tasks['s2'].statuses.length, 1); + assert.equal(tm.tasks['s2'].error.message, 'e'); + }); + + it('ignores UPDATE_TASK_STATUS messages without a taskId', async () => { + const tm = freshManager(); + tm.setDependencies(tm.wsService, {}); + const handler = captured['UPDATE_TASK_STATUS']; + const res = await handler({}); + assert.ok(res); + }); + + it('PUBLISH_TASK handler creates a task only when absent', async () => { + const tm = freshManager(); + tm.setDependencies(tm.wsService, {}); + const pub = captured['publish-task']; + await pub({ taskId: 'p1', action: TaskAction.ONBOARD_USER, userId: 'u', expectation: 9 }); + assert.ok(tm.tasks['p1']); + const first = tm.tasks['p1']; + await pub({ taskId: 'p1', action: TaskAction.ONBOARD_USER, userId: 'u', expectation: 9 }); + assert.equal(tm.tasks['p1'], first); + }); +}); diff --git a/api-gateway/tests/helpers/token-constants.test.mjs b/api-gateway/tests/helpers/token-constants.test.mjs new file mode 100644 index 0000000000..51f6450597 --- /dev/null +++ b/api-gateway/tests/helpers/token-constants.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS } from '../../dist/constants/token.js'; + +describe('api-gateway token constants', () => { + it('REQUIRED_PROPS exposes the minimal token-import surface', () => { + assert.equal(REQUIRED_PROPS.TOKEN_ID, 'tokenId'); + assert.equal(REQUIRED_PROPS.TOKEN, 'tokenName'); + assert.equal(REQUIRED_PROPS.TOKEN_SYMBOL, 'tokenSymbol'); + assert.equal(REQUIRED_PROPS.TOKEN_TYPE, 'tokenType'); + assert.equal(REQUIRED_PROPS.ENABLE_ADMIN, 'enableAdmin'); + assert.equal(REQUIRED_PROPS.POLICY_ID, 'policyId'); + assert.equal(REQUIRED_PROPS.DRAFT_TOKEN, 'draftToken'); + assert.equal(REQUIRED_PROPS._ID, '_id'); + }); +}); diff --git a/api-gateway/tests/helpers/tool-constants.test.mjs b/api-gateway/tests/helpers/tool-constants.test.mjs new file mode 100644 index 0000000000..4f7160c875 --- /dev/null +++ b/api-gateway/tests/helpers/tool-constants.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_PROPS, UN_REQUIRED_PROPS } from '../../dist/constants/tool.js'; + +describe('api-gateway tool constants', () => { + it('REQUIRED_PROPS lists name/topic/owner/messageId fields', () => { + for (const k of ['DESCRIPTION', 'NAME', 'STATUS', 'TOPIC_ID', 'VERSION', 'MESSAGE_ID', 'OWNER', 'CREATOR', '_ID']) { + assert.equal(typeof REQUIRED_PROPS[k], 'string'); + } + assert.equal(REQUIRED_PROPS.MESSAGE_ID, 'messageId'); + }); + it('UN_REQUIRED_PROPS strips the bulky document/context fields', () => { + assert.equal(UN_REQUIRED_PROPS.DOCUMENT, 'document'); + assert.equal(UN_REQUIRED_PROPS.CONTEXT, 'context'); + assert.equal(UN_REQUIRED_PROPS.DEFS, 'defs'); + }); +}); diff --git a/api-gateway/tests/helpers/utils-parent-internal.test.mjs b/api-gateway/tests/helpers/utils-parent-internal.test.mjs new file mode 100644 index 0000000000..b04e0ac058 --- /dev/null +++ b/api-gateway/tests/helpers/utils-parent-internal.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { UserRole } from '@guardian/interfaces'; +import { getParentUser, InternalException, ONLY_SR } from '../../dist/helpers/utils.js'; + +describe('getParentUser', () => { + it('is defined', () => { + assert.equal(typeof getParentUser, 'function'); + }); + + it('returns the user did for a STANDARD_REGISTRY user', () => { + const user = { role: UserRole.STANDARD_REGISTRY, did: 'did:sr:1', parent: 'did:other' }; + assert.equal(getParentUser(user), 'did:sr:1'); + }); + + it('returns the parent did for a non-SR user', () => { + const user = { role: UserRole.USER, did: 'did:user:1', parent: 'did:parent:1' }; + assert.equal(getParentUser(user), 'did:parent:1'); + }); + + it('returns undefined parent when a non-SR user has no parent', () => { + const user = { role: UserRole.USER, did: 'did:user:1' }; + assert.equal(getParentUser(user), undefined); + }); +}); + +const fakeLogger = () => { + const calls = []; + return { + calls, + error: async (...args) => { calls.push(args); }, + }; +}; + +describe('InternalException', () => { + it('is defined', () => { + assert.equal(typeof InternalException, 'function'); + }); + + it('logs the error with the API_GATEWAY tag', async () => { + const logger = fakeLogger(); + await assert.rejects(() => InternalException('boom', logger)); + assert.equal(logger.calls.length, 1); + assert.deepEqual(logger.calls[0][1], ['API_GATEWAY']); + }); + + it('passes the userId through to the logger', async () => { + const logger = fakeLogger(); + await assert.rejects(() => InternalException('boom', logger, 'user-42')); + assert.equal(logger.calls[0][2], 'user-42'); + }); + + it('defaults userId to null when omitted', async () => { + const logger = fakeLogger(); + await assert.rejects(() => InternalException('boom', logger)); + assert.equal(logger.calls[0][2], null); + }); + + it('re-throws an existing HttpException unchanged', async () => { + const logger = fakeLogger(); + const original = new HttpException('teapot', 418); + await assert.rejects( + () => InternalException(original, logger), + (err) => { + assert.equal(err, original); + assert.equal(err.getStatus(), 418); + return true; + } + ); + }); + + it('wraps a string error as a 500 HttpException', async () => { + const logger = fakeLogger(); + await assert.rejects( + () => InternalException('plain message', logger), + (err) => { + assert.ok(err instanceof HttpException); + assert.equal(err.getStatus(), HttpStatus.INTERNAL_SERVER_ERROR); + assert.equal(err.message, 'plain message'); + return true; + } + ); + }); + + it('uses the error.code as the status when present', async () => { + const logger = fakeLogger(); + const messageError = Object.assign(new Error('coded'), { code: 409 }); + await assert.rejects( + () => InternalException(messageError, logger), + (err) => { + assert.ok(err instanceof HttpException); + assert.equal(err.getStatus(), 409); + assert.equal(err.message, 'coded'); + return true; + } + ); + }); + + it('falls back to 500 when a MessageError has no code', async () => { + const logger = fakeLogger(); + const messageError = new Error('uncoded'); + await assert.rejects( + () => InternalException(messageError, logger), + (err) => { + assert.ok(err instanceof HttpException); + assert.equal(err.getStatus(), HttpStatus.INTERNAL_SERVER_ERROR); + assert.equal(err.message, 'uncoded'); + return true; + } + ); + }); +}); + +describe('ONLY_SR constant', () => { + it('mentions the Standard Registry role', () => { + assert.match(ONLY_SR, /Standard Registry/); + }); +}); diff --git a/api-gateway/tests/helpers/utils.test.mjs b/api-gateway/tests/helpers/utils.test.mjs new file mode 100644 index 0000000000..828a500531 --- /dev/null +++ b/api-gateway/tests/helpers/utils.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { HttpException } from '@nestjs/common'; +import { UserRole } from '@guardian/interfaces'; +import { + ONLY_SR, + getParentUser, + parseSavepointIdsJson, + InternalException, +} from '../../dist/helpers/utils.js'; + +describe('ONLY_SR constant', () => { + it('describes the Standard Registry restriction', () => { + assert.match(ONLY_SR, /Standard Registry role/); + }); +}); + +describe('getParentUser', () => { + it('returns the user did for a Standard Registry', () => { + assert.equal(getParentUser({ role: UserRole.STANDARD_REGISTRY, did: 'did:sr', parent: 'p' }), 'did:sr'); + }); + + it('returns the parent for a non-SR user', () => { + assert.equal(getParentUser({ role: UserRole.USER, did: 'did:u', parent: 'p' }), 'p'); + }); + + it('returns undefined parent when a non-SR user has no parent', () => { + assert.equal(getParentUser({ role: UserRole.USER, did: 'did:u' }), undefined); + }); +}); + +describe('parseSavepointIdsJson', () => { + it('returns undefined for missing input', () => { + assert.equal(parseSavepointIdsJson(), undefined); + assert.equal(parseSavepointIdsJson(undefined), undefined); + }); + + it('returns undefined for an empty string', () => { + assert.equal(parseSavepointIdsJson(''), undefined); + }); + + it('parses a JSON array of strings', () => { + assert.deepEqual(parseSavepointIdsJson('["a","b"]'), ['a', 'b']); + }); + + it('deduplicates repeated ids', () => { + assert.deepEqual(parseSavepointIdsJson('["a","b","a"]'), ['a', 'b']); + }); + + it('drops empty and whitespace-only and non-string entries', () => { + assert.deepEqual(parseSavepointIdsJson('["a",""," ",1,null]'), ['a']); + }); + + it('returns undefined when the array has no usable strings', () => { + assert.equal(parseSavepointIdsJson('[]'), undefined); + assert.equal(parseSavepointIdsJson('["",1]'), undefined); + }); + + it('throws a 400 when the JSON is invalid', () => { + assert.throws(() => parseSavepointIdsJson('not json'), (e) => e instanceof HttpException && e.getStatus() === 400); + }); + + it('throws a 400 when the JSON is not an array', () => { + assert.throws(() => parseSavepointIdsJson('"x"'), (e) => e instanceof HttpException && e.getStatus() === 400); + assert.throws(() => parseSavepointIdsJson('{"a":1}'), (e) => e instanceof HttpException && e.getStatus() === 400); + }); +}); + +describe('InternalException', () => { + const makeLogger = () => { + const calls = []; + return { calls, error: async (...args) => { calls.push(args); } }; + }; + + it('logs and rethrows an existing HttpException unchanged', async () => { + const logger = makeLogger(); + const original = new HttpException('orig', 400); + await assert.rejects(InternalException(original, logger), (e) => e === original); + assert.equal(logger.calls.length, 1); + }); + + it('wraps a string error as a 500 HttpException', async () => { + const logger = makeLogger(); + await assert.rejects(InternalException('boom', logger), (e) => e instanceof HttpException && e.getStatus() === 500 && e.message === 'boom'); + }); + + it('uses the error code when present', async () => { + const logger = makeLogger(); + await assert.rejects(InternalException({ name: 'E', message: 'm', code: 418 }, logger), (e) => e instanceof HttpException && e.getStatus() === 418); + }); + + it('falls back to 500 when no code is present', async () => { + const logger = makeLogger(); + await assert.rejects(InternalException({ name: 'E', message: 'm' }, logger), (e) => e instanceof HttpException && e.getStatus() === 500); + }); + + it('forwards the userId to the logger', async () => { + const logger = makeLogger(); + await assert.rejects(InternalException('boom', logger, 'user-1')); + assert.equal(logger.calls[0][2], 'user-1'); + }); +}); diff --git a/api-gateway/tests/interceptor-cache-utils.test.js b/api-gateway/tests/interceptor-cache-utils.test.js new file mode 100644 index 0000000000..410c14cd81 --- /dev/null +++ b/api-gateway/tests/interceptor-cache-utils.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { getCacheKey } from '../dist/helpers/interceptors/utils/cache.js'; +import { CACHE_PREFIXES } from '../dist/constants/index.js'; + +describe('getCacheKey', () => { + const user = { id: 'u1', did: 'did:hedera:1' }; + const otherUser = { id: 'u2', did: 'did:hedera:2' }; + + it('returns one entry per input route', () => { + const keys = getCacheKey(['/a', '/b', '/c'], user); + assert.equal(keys.length, 3); + }); + + it('uses the TAG prefix by default', () => { + const [key] = getCacheKey(['/a'], user); + assert.ok(key.startsWith(CACHE_PREFIXES.TAG)); + }); + + it('respects an explicit prefix', () => { + const [key] = getCacheKey(['/a'], user, CACHE_PREFIXES.CACHE); + assert.ok(key.startsWith(CACHE_PREFIXES.CACHE)); + }); + + it('produces a stable hash for the same user and route', () => { + const [a] = getCacheKey(['/x'], user); + const [b] = getCacheKey(['/x'], user); + assert.equal(a, b); + }); + + it('produces different keys for different users on the same route', () => { + const [a] = getCacheKey(['/x'], user); + const [b] = getCacheKey(['/x'], otherUser); + assert.notEqual(a, b); + }); + + it('decodes percent-encoded characters that decodeURI handles', () => { + // decodeURI decodes %20 (space) but not reserved chars like %2F. + const [encoded] = getCacheKey(['/hello%20world'], user); + const [decoded] = getCacheKey(['/hello world'], user); + assert.equal(encoded, decoded); + }); + + it('falls back to the raw route when decoding throws', () => { + const malformed = '/bad%E0%A4%A'; + const keys = getCacheKey([malformed], user); + assert.ok(keys[0].includes(malformed)); + }); + + it('treats null and undefined users equivalently', () => { + const [a] = getCacheKey(['/x'], null); + const [b] = getCacheKey(['/x'], undefined); + assert.equal(a, b); + }); +}); diff --git a/api-gateway/tests/interceptor-multipart.test.js b/api-gateway/tests/interceptor-multipart.test.js new file mode 100644 index 0000000000..92207a6374 --- /dev/null +++ b/api-gateway/tests/interceptor-multipart.test.js @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { getFileFromPart } from '../dist/helpers/interceptors/utils/multipart.js'; + +const fakePart = (overrides = {}) => ({ + filename: 'doc.pdf', + mimetype: 'application/pdf', + fieldname: 'document', + encoding: '7bit', + toBuffer: async () => Buffer.from('hello'), + ...overrides, +}); + +describe('getFileFromPart', () => { + it('returns the canonical MultipartFile shape for a complete part', async () => { + const part = fakePart(); + const out = await getFileFromPart(part); + assert.deepEqual(out, { + buffer: Buffer.from('hello'), + size: 5, + filename: 'doc.pdf', + mimetype: 'application/pdf', + fieldname: 'document', + encoding: '7bit', + originalname: 'document', + }); + }); + + it('exposes byteLength of buffer as size', async () => { + const part = fakePart({ toBuffer: async () => Buffer.alloc(1024) }); + const out = await getFileFromPart(part); + assert.equal(out.size, 1024); + }); + + it('mirrors fieldname into originalname', async () => { + const part = fakePart({ fieldname: 'avatar' }); + const out = await getFileFromPart(part); + assert.equal(out.originalname, 'avatar'); + assert.equal(out.fieldname, 'avatar'); + }); + + it('returns null when the buffer is empty (size=0)', async () => { + const part = fakePart({ toBuffer: async () => Buffer.alloc(0) }); + const out = await getFileFromPart(part); + assert.equal(out, null); + }); + + it('returns null when fieldname is missing', async () => { + const part = fakePart({ fieldname: undefined }); + const out = await getFileFromPart(part); + assert.equal(out, null); + }); + + it('returns null when fieldname is an empty string', async () => { + const part = fakePart({ fieldname: '' }); + const out = await getFileFromPart(part); + assert.equal(out, null); + }); + + it('preserves binary data byte-for-byte', async () => { + const bytes = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0xff]); + const out = await getFileFromPart(fakePart({ toBuffer: async () => bytes })); + assert.deepEqual(Array.from(out.buffer), Array.from(bytes)); + }); +}); diff --git a/api-gateway/tests/interceptors/multipart-performance-interceptor.test.mjs b/api-gateway/tests/interceptors/multipart-performance-interceptor.test.mjs new file mode 100644 index 0000000000..5b98ddb64c --- /dev/null +++ b/api-gateway/tests/interceptors/multipart-performance-interceptor.test.mjs @@ -0,0 +1,445 @@ +import assert from 'node:assert/strict'; +import { of, lastValueFrom } from 'rxjs'; +import { AnyFilesInterceptor } from '../../dist/helpers/interceptors/multipart.js'; +import { getFileFromPart } from '../../dist/helpers/interceptors/utils/multipart.js'; +import { MultipartOptions } from '../../dist/helpers/interceptors/types/multipart.js'; +import { PerformanceInterceptor } from '../../dist/helpers/interceptors/performance.js'; + +const makeContext = (req) => ({ + switchToHttp: () => ({ getRequest: () => req, getResponse: () => ({}) }), + getHandler: () => 'h', + getClass: () => 'c', +}); + +const next = (val) => ({ handle: () => of(val) }); + +const filePart = (overrides = {}) => ({ + type: 'file', + filename: 'doc.pdf', + mimetype: 'application/pdf', + fieldname: 'document', + encoding: '7bit', + toBuffer: async () => Buffer.from('hello'), + ...overrides, +}); + +const valuePart = (fieldname, value) => ({ + type: 'field', + fieldname, + value, +}); + +const multipartReq = (parts, overrides = {}) => ({ + isMultipart: () => true, + parts: async function* () { + for (const p of parts) { + yield p; + } + }, + ...overrides, +}); + +const instantiate = (options) => { + const Mixin = AnyFilesInterceptor(options); + return new Mixin(); +}; + +describe('@unit AnyFilesInterceptor.intercept', () => { + it('throws BAD_REQUEST when the request is not multipart', async () => { + const interceptor = instantiate(); + const ctx = makeContext({ isMultipart: () => false }); + await assert.rejects(() => interceptor.intercept(ctx, next('ok')), (err) => { + assert.equal(err.message, 'The request should be a form-data'); + assert.equal(err.getStatus(), 400); + return true; + }); + }); + + it('passes through to next.handle and emits the downstream value', async () => { + const interceptor = instantiate(); + const req = multipartReq([]); + const obs = await interceptor.intercept(makeContext(req), next('downstream')); + assert.equal(await lastValueFrom(obs), 'downstream'); + }); + + it('maps a single file part onto req.storedFiles', async () => { + const interceptor = instantiate(); + const req = multipartReq([filePart()]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 1); + assert.equal(req.storedFiles[0].fieldname, 'document'); + assert.equal(req.storedFiles[0].originalname, 'document'); + assert.equal(req.storedFiles[0].size, 5); + }); + + it('collects multiple file parts in order', async () => { + const interceptor = instantiate(); + const req = multipartReq([ + filePart({ fieldname: 'a', toBuffer: async () => Buffer.from('aa') }), + filePart({ fieldname: 'b', toBuffer: async () => Buffer.from('bbb') }), + ]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 2); + assert.equal(req.storedFiles[0].fieldname, 'a'); + assert.equal(req.storedFiles[1].fieldname, 'b'); + assert.equal(req.storedFiles[0].size, 2); + assert.equal(req.storedFiles[1].size, 3); + }); + + it('does not set storedFiles when there are no files', async () => { + const interceptor = instantiate(); + const req = multipartReq([valuePart('name', 'alice')]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal('storedFiles' in req, false); + }); + + it('maps non-file parts onto req.body keyed by fieldname', async () => { + const interceptor = instantiate(); + const req = multipartReq([valuePart('name', 'alice'), valuePart('age', '30')]); + await interceptor.intercept(makeContext(req), next('x')); + assert.deepEqual(req.body, { name: 'alice', age: '30' }); + }); + + it('always replaces req.body even when only files are present', async () => { + const interceptor = instantiate(); + const req = multipartReq([filePart()], { body: { stale: true } }); + await interceptor.intercept(makeContext(req), next('x')); + assert.deepEqual(req.body, {}); + }); + + it('handles a mix of file and value parts', async () => { + const interceptor = instantiate(); + const req = multipartReq([ + valuePart('title', 't'), + filePart({ fieldname: 'f1' }), + valuePart('desc', 'd'), + filePart({ fieldname: 'f2' }), + ]); + await interceptor.intercept(makeContext(req), next('x')); + assert.deepEqual(req.body, { title: 't', desc: 'd' }); + assert.equal(req.storedFiles.length, 2); + }); + + it('skips a file part whose buffer is empty (getFileFromPart returns null)', async () => { + const interceptor = instantiate(); + const req = multipartReq([ + filePart({ fieldname: 'empty', toBuffer: async () => Buffer.alloc(0) }), + filePart({ fieldname: 'real' }), + ]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 1); + assert.equal(req.storedFiles[0].fieldname, 'real'); + }); + + it('does not set storedFiles when the only file part yields null', async () => { + const interceptor = instantiate(); + const req = multipartReq([filePart({ toBuffer: async () => Buffer.alloc(0) })]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal('storedFiles' in req, false); + assert.deepEqual(req.body, {}); + }); + + it('a later value part with same name overwrites earlier in body', async () => { + const interceptor = instantiate(); + const req = multipartReq([valuePart('k', 'first'), valuePart('k', 'second')]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.body.k, 'second'); + }); + + it('stores undefined value when a field part has no value', async () => { + const interceptor = instantiate(); + const req = multipartReq([valuePart('k', undefined)]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal('k' in req.body, true); + assert.equal(req.body.k, undefined); + }); + + describe('allowedFields option', () => { + it('rejects with UNPROCESSABLE_ENTITY when a fieldname is not allowed', async () => { + const interceptor = instantiate({ allowedFields: ['document'] }); + const req = multipartReq([filePart({ fieldname: 'evil' })]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + assert.match(err.message, /allowed keys: document/); + return true; + }); + }); + + it('lists all allowed keys joined by comma-space in the message', async () => { + const interceptor = instantiate({ allowedFields: ['a', 'b', 'c'] }); + const req = multipartReq([valuePart('z', '1')]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.match(err.message, /allowed keys: a, b, c/); + return true; + }); + }); + + it('allows parts whose fieldname is in allowedFields', async () => { + const interceptor = instantiate({ allowedFields: ['document'] }); + const req = multipartReq([filePart()]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 1); + }); + + it('applies the allowed check to value parts too', async () => { + const interceptor = instantiate({ allowedFields: ['ok'] }); + const req = multipartReq([valuePart('notok', '1')]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + return true; + }); + }); + + it('an empty allowedFields array does not restrict any field', async () => { + const interceptor = instantiate({ allowedFields: [] }); + const req = multipartReq([filePart({ fieldname: 'anything' })]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 1); + }); + + it('rejection from allowedFields wraps to status 422 not the catch BAD_REQUEST', async () => { + const interceptor = instantiate({ allowedFields: ['x'] }); + const req = multipartReq([valuePart('y', '1')]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + return true; + }); + }); + }); + + describe('requiredFields option', () => { + it('rejects with UNPROCESSABLE_ENTITY when a required field is missing', async () => { + const interceptor = instantiate({ requiredFields: ['document'] }); + const req = multipartReq([]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + assert.equal(err.message, 'There are no files to upload.'); + return true; + }); + }); + + it('passes when all required file fields are present', async () => { + const interceptor = instantiate({ requiredFields: ['document'] }); + const req = multipartReq([filePart()]); + const obs = await interceptor.intercept(makeContext(req), next('done')); + assert.equal(await lastValueFrom(obs), 'done'); + }); + + it('rejects when a required field only arrives as a value part not a file', async () => { + const interceptor = instantiate({ requiredFields: ['document'] }); + const req = multipartReq([valuePart('document', 'text')]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + return true; + }); + }); + + it('requires every field in the list', async () => { + const interceptor = instantiate({ requiredFields: ['a', 'b'] }); + const req = multipartReq([filePart({ fieldname: 'a' })]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (err) => { + assert.equal(err.getStatus(), 422); + return true; + }); + }); + + it('passes when all of several required fields are present', async () => { + const interceptor = instantiate({ requiredFields: ['a', 'b'] }); + const req = multipartReq([filePart({ fieldname: 'a' }), filePart({ fieldname: 'b' })]); + await interceptor.intercept(makeContext(req), next('x')); + assert.equal(req.storedFiles.length, 2); + }); + + it('an empty requiredFields array imposes no requirement', async () => { + const interceptor = instantiate({ requiredFields: [] }); + const req = multipartReq([]); + const obs = await interceptor.intercept(makeContext(req), next('y')); + assert.equal(await lastValueFrom(obs), 'y'); + }); + }); + + describe('error handling around req.parts()', () => { + it('wraps a thrown error from the parts iterator into HttpException with its status', async () => { + const interceptor = instantiate(); + const err = new Error('boom'); + err.status = 418; + const req = { + isMultipart: () => true, + parts: async function* () { + throw err; + }, + }; + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (e) => { + assert.equal(e.message, 'boom'); + assert.equal(e.getStatus(), 418); + return true; + }); + }); + + it('defaults to BAD_REQUEST when the thrown error has no status', async () => { + const interceptor = instantiate(); + const req = { + isMultipart: () => true, + parts: async function* () { + throw new Error('no status'); + }, + }; + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (e) => { + assert.equal(e.message, 'no status'); + assert.equal(e.getStatus(), 400); + return true; + }); + }); + + it('wraps an error thrown by part.toBuffer', async () => { + const interceptor = instantiate(); + const req = multipartReq([filePart({ toBuffer: async () => { throw new Error('read fail'); } })]); + await assert.rejects(() => interceptor.intercept(makeContext(req), next('x')), (e) => { + assert.equal(e.message, 'read fail'); + assert.equal(e.getStatus(), 400); + return true; + }); + }); + }); + + it('returns the same observable instance produced by next.handle', async () => { + const interceptor = instantiate(); + const req = multipartReq([]); + const handle = of('once'); + const obs = await interceptor.intercept(makeContext(req), { handle: () => handle }); + assert.equal(obs, handle); + }); +}); + +describe('@unit getFileFromPart', () => { + it('returns the canonical MultipartFile shape', async () => { + const out = await getFileFromPart(filePart()); + assert.deepEqual(out, { + buffer: Buffer.from('hello'), + size: 5, + filename: 'doc.pdf', + mimetype: 'application/pdf', + fieldname: 'document', + encoding: '7bit', + originalname: 'document', + }); + }); + + it('returns null when size is 0', async () => { + const out = await getFileFromPart(filePart({ toBuffer: async () => Buffer.alloc(0) })); + assert.equal(out, null); + }); + + it('returns null when fieldname is missing', async () => { + const out = await getFileFromPart(filePart({ fieldname: undefined })); + assert.equal(out, null); + }); + + it('returns null when fieldname is empty string', async () => { + const out = await getFileFromPart(filePart({ fieldname: '' })); + assert.equal(out, null); + }); + + it('uses byteLength of the buffer as size', async () => { + const out = await getFileFromPart(filePart({ toBuffer: async () => Buffer.alloc(2048) })); + assert.equal(out.size, 2048); + }); +}); + +describe('@unit MultipartOptions', () => { + it('assigns all four constructor args to fields', () => { + const opts = new MultipartOptions(100, 'pdf', ['a'], ['b']); + assert.equal(opts.maxFileSize, 100); + assert.equal(opts.fileType, 'pdf'); + assert.deepEqual(opts.allowedFields, ['a']); + assert.deepEqual(opts.requiredFields, ['b']); + }); + + it('leaves fields undefined when constructed with no args', () => { + const opts = new MultipartOptions(); + assert.equal(opts.maxFileSize, undefined); + assert.equal(opts.fileType, undefined); + assert.equal(opts.allowedFields, undefined); + assert.equal(opts.requiredFields, undefined); + }); +}); + +describe('@unit PerformanceInterceptor.intercept', () => { + const withCapturedLog = async (fn) => { + const original = console.log; + const logs = []; + console.log = (...args) => logs.push(args.join(' ')); + try { + await fn(logs); + } finally { + console.log = original; + } + }; + + it('passes the downstream value through unchanged', async () => { + await withCapturedLog(async () => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({ url: '/api/v1/x' }); + const obs = interceptor.intercept(ctx, next('payload')); + assert.equal(await lastValueFrom(obs), 'payload'); + }); + }); + + it('logs an execution time line including the route url', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({ url: '/api/v1/route' }); + await lastValueFrom(interceptor.intercept(ctx, next('v'))); + assert.equal(logs.length, 1); + assert.match(logs[0], /^Execution time for \/api\/v1\/route: \d+\.\d{2}ms$/); + }); + }); + + it('does not log until the observable is subscribed', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({ url: '/lazy' }); + interceptor.intercept(ctx, next('v')); + assert.equal(logs.length, 0); + }); + }); + + it('logs undefined route when the request has no url', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({}); + await lastValueFrom(interceptor.intercept(ctx, next('v'))); + assert.match(logs[0], /^Execution time for undefined: /); + }); + }); + + it('tolerates a null request via optional chaining on url', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = { switchToHttp: () => ({ getRequest: () => null }) }; + await lastValueFrom(interceptor.intercept(ctx, next('v'))); + assert.match(logs[0], /^Execution time for undefined: /); + }); + }); + + it('formats the execution time with exactly two decimals', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({ url: '/t' }); + await lastValueFrom(interceptor.intercept(ctx, next('v'))); + const ms = logs[0].match(/: (\d+\.\d+)ms$/); + assert.ok(ms); + assert.equal(ms[1].split('.')[1].length, 2); + }); + }); + + it('emits exactly one log per subscription', async () => { + await withCapturedLog(async (logs) => { + const interceptor = new PerformanceInterceptor(); + const ctx = makeContext({ url: '/once' }); + const obs = interceptor.intercept(ctx, next('v')); + await lastValueFrom(obs); + assert.equal(logs.length, 1); + }); + }); +}); diff --git a/api-gateway/tests/match-validator.test.js b/api-gateway/tests/match-validator.test.js new file mode 100644 index 0000000000..1cd0c6919c --- /dev/null +++ b/api-gateway/tests/match-validator.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { validateSync } from 'class-validator'; +import { Match, MatchConstraint } from '../dist/helpers/decorators/match.validator.js'; + +class PasswordPair { + constructor(password, confirm) { + this.password = password; + this.confirm = confirm; + } +} + +Match('password', { message: 'must match password' })( + PasswordPair.prototype, + 'confirm' +); + +describe('Match decorator + MatchConstraint', () => { + it('passes validation when the two properties are equal', () => { + const errors = validateSync(new PasswordPair('s3cret', 's3cret')); + assert.equal(errors.length, 0); + }); + + it('fails validation when the two properties differ', () => { + const errors = validateSync(new PasswordPair('s3cret', 'other')); + assert.equal(errors.length, 1); + assert.equal(errors[0].property, 'confirm'); + }); + + it('fails when one side is undefined', () => { + const errors = validateSync(new PasswordPair('s3cret', undefined)); + assert.equal(errors.length, 1); + }); +}); + +describe('MatchConstraint.validate (direct)', () => { + const constraint = new MatchConstraint(); + + it('returns true when related property equals the value', () => { + const ok = constraint.validate('abc', { + constraints: ['other'], + object: { other: 'abc' }, + }); + assert.equal(ok, true); + }); + + it('returns false when related property differs', () => { + const ok = constraint.validate('abc', { + constraints: ['other'], + object: { other: 'xyz' }, + }); + assert.equal(ok, false); + }); + + it('uses strict equality (string vs number)', () => { + const ok = constraint.validate('1', { + constraints: ['other'], + object: { other: 1 }, + }); + assert.equal(ok, false); + }); +}); diff --git a/api-gateway/tests/middlewares/accounts-schemas.test.mjs b/api-gateway/tests/middlewares/accounts-schemas.test.mjs new file mode 100644 index 0000000000..68b1aca184 --- /dev/null +++ b/api-gateway/tests/middlewares/accounts-schemas.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { registerSchema, loginSchema } from '../../dist/middlewares/validation/schemas/accounts.js'; + +describe('loginSchema (yup)', () => { + const schema = loginSchema(); + + it('is a builder returning a yup schema', () => { + assert.equal(typeof loginSchema, 'function'); + assert.equal(typeof schema.validate, 'function'); + assert.equal(typeof schema.isValid, 'function'); + }); + + it('accepts a username + password body', async () => { + assert.equal(await schema.isValid({ body: { username: 'alice', password: 'secret' } }), true); + }); + + it('rejects a missing password', async () => { + assert.equal(await schema.isValid({ body: { username: 'alice' } }), false); + }); + + it('rejects a missing username', async () => { + assert.equal(await schema.isValid({ body: { password: 'secret' } }), false); + }); + + it('rejects an empty username', async () => { + assert.equal(await schema.isValid({ body: { username: '', password: 'secret' } }), false); + }); + + it('rejects an empty password', async () => { + assert.equal(await schema.isValid({ body: { username: 'alice', password: '' } }), false); + }); + + it('surfaces the required-password message', async () => { + await assert.rejects( + () => schema.validate({ body: { username: 'alice' } }, { abortEarly: false }), + (err) => { + assert.ok(err.errors.some((m) => /password field is required/.test(m))); + return true; + } + ); + }); +}); + +describe('registerSchema (yup)', () => { + const schema = registerSchema(); + + const validBody = { + username: 'newsr', + password: 'StrongPassword3#', + password_confirmation: 'StrongPassword3#', + role: 'STANDARD_REGISTRY', + }; + + it('is a builder returning a yup schema', () => { + assert.equal(typeof registerSchema, 'function'); + assert.equal(typeof schema.validate, 'function'); + }); + + it('accepts a complete, matching registration body', async () => { + assert.equal(await schema.isValid({ body: validBody }), true); + }); + + it('rejects a registration with mismatched password confirmation', async () => { + assert.equal( + await schema.isValid({ body: { ...validBody, password_confirmation: 'Different1#' } }), + false + ); + }); + + it('surfaces the "Passwords must match" message on mismatch', async () => { + await assert.rejects( + () => schema.validate({ body: { ...validBody, password_confirmation: 'Different1#' } }, { abortEarly: false }), + (err) => { + assert.ok(err.errors.some((m) => /Passwords must match/.test(m))); + return true; + } + ); + }); + + it('rejects a registration missing the role', async () => { + const { role, ...noRole } = validBody; + assert.equal(await schema.isValid({ body: noRole }), false); + }); + + it('rejects an unknown role value', async () => { + assert.equal(await schema.isValid({ body: { ...validBody, role: 'NOT_A_ROLE' } }), false); + }); + + it('rejects a registration missing the username', async () => { + const { username, ...noUser } = validBody; + assert.equal(await schema.isValid({ body: noUser }), false); + }); + + it('rejects a registration missing the password', async () => { + const { password, ...noPass } = validBody; + assert.equal(await schema.isValid({ body: noPass }), false); + }); + + it('builds a fresh schema instance on each call', () => { + assert.notEqual(registerSchema(), registerSchema()); + }); +}); diff --git a/api-gateway/tests/middlewares/csv-examples.test.mjs b/api-gateway/tests/middlewares/csv-examples.test.mjs new file mode 100644 index 0000000000..a352f2d0d2 --- /dev/null +++ b/api-gateway/tests/middlewares/csv-examples.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { + CsvObjectExamples, + COMPARE_POLICIES_EXPORT_CSV_RESPONSE, + COMPARE_MODULES_EXPORT_CSV_RESPONSE, + COMPARE_SCHEMAS_EXPORT_CSV_RESPONSE, + COMPARE_TOOLS_EXPORT_CSV_RESPONSE_SINGLE, + COMPARE_TOOLS_EXPORT_CSV_RESPONSE_MULTI, + COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_SINGLE, + COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_MULTI, +} from '../../dist/middlewares/validation/csv-examples.js'; + +const namedExports = { + COMPARE_POLICIES_EXPORT_CSV_RESPONSE, + COMPARE_MODULES_EXPORT_CSV_RESPONSE, + COMPARE_SCHEMAS_EXPORT_CSV_RESPONSE, + COMPARE_TOOLS_EXPORT_CSV_RESPONSE_SINGLE, + COMPARE_TOOLS_EXPORT_CSV_RESPONSE_MULTI, + COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_SINGLE, + COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_MULTI, +}; + +describe('csv-examples destructured named exports', () => { + it('exposes exactly the seven documented keys on the map', () => { + assert.deepEqual(Object.keys(CsvObjectExamples).sort(), Object.keys(namedExports).sort()); + }); + + for (const [name, value] of Object.entries(namedExports)) { + it(`${name} is a string`, () => { + assert.equal(typeof value, 'string'); + }); + + it(`${name} mirrors the same key on CsvObjectExamples`, () => { + assert.equal(value, CsvObjectExamples[name]); + }); + + it(`${name} carries the data:text/csv charset prefix`, () => { + assert.ok(value.startsWith('data:text/csv;charset=utf-8;')); + }); + } +}); + +describe('csv-examples content invariants', () => { + it('policies sample contains the policy header rows', () => { + assert.ok(COMPARE_POLICIES_EXPORT_CSV_RESPONSE.includes('"Policy 1"')); + assert.ok(COMPARE_POLICIES_EXPORT_CSV_RESPONSE.includes('"Policy 2"')); + assert.ok(COMPARE_POLICIES_EXPORT_CSV_RESPONSE.includes('"Policy Blocks"')); + }); + + it('modules sample contains module sections', () => { + assert.ok(COMPARE_MODULES_EXPORT_CSV_RESPONSE.includes('"Module 1"')); + assert.ok(COMPARE_MODULES_EXPORT_CSV_RESPONSE.includes('"Module Blocks"')); + }); + + it('schemas sample contains schema fields section', () => { + assert.ok(COMPARE_SCHEMAS_EXPORT_CSV_RESPONSE.includes('"Schema Fields"')); + }); + + it('multi tools sample is a superset prefix of the single tools sample sections', () => { + assert.ok(COMPARE_TOOLS_EXPORT_CSV_RESPONSE_MULTI.includes('"Tool 3"')); + assert.ok(!COMPARE_TOOLS_EXPORT_CSV_RESPONSE_SINGLE.includes('"Tool 3"')); + }); + + it('multi documents sample includes a third document block, single does not', () => { + assert.ok(COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_MULTI.includes('"Document 3"')); + assert.ok(!COMPARE_DOCUMENTS_EXPORT_CSV_RESPONSE_SINGLE.includes('"Document 3"')); + }); + + it('every sample ends on a Total row', () => { + for (const value of Object.values(namedExports)) { + assert.ok(/"Total"/.test(value), 'expected a Total marker'); + } + }); +}); diff --git a/api-gateway/tests/middlewares/examples.test.mjs b/api-gateway/tests/middlewares/examples.test.mjs new file mode 100644 index 0000000000..1815f0c335 --- /dev/null +++ b/api-gateway/tests/middlewares/examples.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { Examples, ObjectExamples } from '../../dist/middlewares/validation/examples.js'; +import { CsvObjectExamples } from '../../dist/middlewares/validation/csv-examples.js'; + +describe('Examples enum', () => { + it('is defined', () => { + assert.equal(typeof Examples, 'object'); + assert.notEqual(Examples, null); + }); + + it('exposes a 24-hex-character Mongo DB_ID', () => { + assert.match(Examples.DB_ID, /^[0-9a-f]{24}$/); + }); + + it('exposes a v4-shaped UUID', () => { + assert.match(Examples.UUID, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('exposes a Hedera account id', () => { + assert.match(Examples.ACCOUNT_ID, /^\d+\.\d+\.\d+$/); + }); + + it('exposes a did:hedera DID', () => { + assert.ok(Examples.DID.startsWith('did:hedera:')); + }); + + it('exposes an ipfs:// reference', () => { + assert.ok(Examples.IPFS.startsWith('ipfs://')); + }); + + it('exposes an ISO date string', () => { + assert.equal(new Date(Examples.DATE).toISOString(), Examples.DATE); + }); + + it('exposes a hex color', () => { + assert.match(Examples.COLOR, /^#[0-9a-fA-F]{6}$/); + }); + + it('exposes the SR role constant', () => { + assert.equal(Examples.ROLE_SR, 'STANDARD_REGISTRY'); + }); + + it('exposes the USER role constant', () => { + assert.equal(Examples.ROLE_USER, 'USER'); + }); + + it('exposes a numeric NUMBER value', () => { + assert.equal(typeof Examples.NUMBER, 'number'); + }); + + it('exposes a three-part JWT access token', () => { + assert.equal(Examples.ACCESS_TOKEN.split('.').length, 3); + }); + + it('exposes a three-part JWT refresh token', () => { + assert.equal(Examples.REFRESH_TOKEN.split('.').length, 3); + }); +}); + +describe('ObjectExamples map', () => { + it('is a non-empty object', () => { + assert.equal(typeof ObjectExamples, 'object'); + assert.ok(Object.keys(ObjectExamples).length > 0); + }); + + it('exposes known example keys', () => { + assert.ok(Object.prototype.hasOwnProperty.call(ObjectExamples, 'BRANDING')); + assert.ok(Object.prototype.hasOwnProperty.call(ObjectExamples, 'SCHEMA_RULE')); + }); + + it('every example value is defined', () => { + for (const [key, value] of Object.entries(ObjectExamples)) { + assert.notEqual(value, undefined, `${key} should be defined`); + } + }); + + it('has no duplicate keys (object invariant)', () => { + const keys = Object.keys(ObjectExamples); + assert.equal(keys.length, new Set(keys).size); + }); +}); + +describe('CsvObjectExamples map', () => { + it('is a non-empty object', () => { + assert.equal(typeof CsvObjectExamples, 'object'); + assert.ok(Object.keys(CsvObjectExamples).length > 0); + }); + + it('every CSV sample is a data:text/csv payload', () => { + for (const [key, value] of Object.entries(CsvObjectExamples)) { + assert.equal(typeof value, 'string', `${key} should be a string`); + assert.ok(value.startsWith('data:text/csv'), `${key} should start with data:text/csv`); + } + }); + + it('includes the policy comparison sample', () => { + assert.ok(Object.prototype.hasOwnProperty.call(CsvObjectExamples, 'COMPARE_POLICIES_EXPORT_CSV_RESPONSE')); + }); +}); diff --git a/api-gateway/tests/middlewares/middlewares-index-barrel.test.mjs b/api-gateway/tests/middlewares/middlewares-index-barrel.test.mjs new file mode 100644 index 0000000000..72633517f3 --- /dev/null +++ b/api-gateway/tests/middlewares/middlewares-index-barrel.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import * as middlewares from '../../dist/middlewares/index.js'; +import * as validation from '../../dist/middlewares/validation/index.js'; + +describe('middlewares barrel (middlewares/index.js)', () => { + it('does not re-export the default binding (export * skips default)', () => { + assert.equal(middlewares.default, undefined); + }); + + it('re-exports prepareValidationResponse', () => { + assert.equal(typeof middlewares.prepareValidationResponse, 'function'); + assert.equal(middlewares.prepareValidationResponse, validation.prepareValidationResponse); + }); + + it('re-exports the Examples enum from validation', () => { + assert.equal(middlewares.Examples, validation.Examples); + assert.equal(typeof middlewares.Examples, 'object'); + }); + + it('re-exports ObjectExamples from validation', () => { + assert.equal(middlewares.ObjectExamples, validation.ObjectExamples); + }); + + it('re-exports the pageHeader definition', () => { + assert.equal(middlewares.pageHeader, validation.pageHeader); + assert.ok(Object.prototype.hasOwnProperty.call(middlewares.pageHeader, 'X-Total-Count')); + }); + + it('re-exports every named (non-default) binding present on the validation module', () => { + for (const key of Object.keys(validation)) { + if (key === 'default') { + continue; + } + assert.ok( + Object.prototype.hasOwnProperty.call(middlewares, key), + `barrel is missing re-export "${key}"` + ); + assert.equal(middlewares[key], validation[key], `binding "${key}" should be identical`); + } + }); +}); diff --git a/api-gateway/tests/middlewares/page-header.test.mjs b/api-gateway/tests/middlewares/page-header.test.mjs new file mode 100644 index 0000000000..38a254fafc --- /dev/null +++ b/api-gateway/tests/middlewares/page-header.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { pageHeader } from '../../dist/middlewares/validation/page-header.js'; + +describe('pageHeader response-header definition', () => { + it('is defined and an object', () => { + assert.equal(typeof pageHeader, 'object'); + assert.notEqual(pageHeader, null); + }); + + it('declares the X-Total-Count header', () => { + assert.ok(Object.prototype.hasOwnProperty.call(pageHeader, 'X-Total-Count')); + }); + + it('types X-Total-Count as an integer schema', () => { + assert.equal(pageHeader['X-Total-Count'].schema.type, 'integer'); + }); + + it('carries a human-readable description', () => { + assert.equal(pageHeader['X-Total-Count'].description, 'Total items in the collection.'); + }); + + it('exposes exactly one header key', () => { + assert.equal(Object.keys(pageHeader).length, 1); + }); +}); diff --git a/api-gateway/tests/middlewares/schemas-accounts.test.mjs b/api-gateway/tests/middlewares/schemas-accounts.test.mjs new file mode 100644 index 0000000000..68707a7ba5 --- /dev/null +++ b/api-gateway/tests/middlewares/schemas-accounts.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { + registerSchema, + loginSchema, + ChangePasswordDTO, + LoginUserDTO, + RegisterUserDTO, + AccessTokenRequestDTO, + OTPConfirmDTO, +} from '../../dist/middlewares/validation/schemas/accounts.js'; + +const assignTo = (Cls, props) => Object.assign(new Cls(), props); + +describe('registerSchema (yup)', () => { + const schema = registerSchema(); + + it('accepts a complete valid body', () => { + assert.equal(schema.isValidSync({ body: { username: 'u', password: 'p', password_confirmation: 'p', role: 'STANDARD_REGISTRY' } }), true); + }); + + it('rejects mismatched password confirmation', () => { + assert.equal(schema.isValidSync({ body: { username: 'u', password: 'p', password_confirmation: 'x', role: 'STANDARD_REGISTRY' } }), false); + }); + + it('rejects a missing username', () => { + assert.equal(schema.isValidSync({ body: { password: 'p', password_confirmation: 'p', role: 'STANDARD_REGISTRY' } }), false); + }); + + it('rejects an invalid role', () => { + assert.equal(schema.isValidSync({ body: { username: 'u', password: 'p', password_confirmation: 'p', role: 'NOPE' } }), false); + }); + + it('rejects an empty body', () => { + assert.equal(schema.isValidSync({ body: {} }), false); + }); +}); + +describe('loginSchema (yup)', () => { + const schema = loginSchema(); + + it('accepts a username/password body', () => { + assert.equal(schema.isValidSync({ body: { username: 'u', password: 'p' } }), true); + }); + + it('rejects a missing password', () => { + assert.equal(schema.isValidSync({ body: { username: 'u' } }), false); + }); + + it('rejects an empty username', () => { + assert.equal(schema.isValidSync({ body: { username: '', password: 'p' } }), false); + }); +}); + +describe('ChangePasswordDTO (class-validator)', () => { + it('passes when all fields are non-empty strings', async () => { + const errs = await validate(assignTo(ChangePasswordDTO, { username: 'u', oldPassword: 'o', newPassword: 'n' })); + assert.equal(errs.length, 0); + }); + + it('reports errors for every missing field', async () => { + const errs = await validate(new ChangePasswordDTO()); + assert.equal(errs.length, 3); + }); + + it('rejects an empty username', async () => { + const errs = await validate(assignTo(ChangePasswordDTO, { username: '', oldPassword: 'o', newPassword: 'n' })); + assert.ok(errs.some((e) => e.property === 'username')); + }); +}); + +describe('LoginUserDTO (class-validator)', () => { + it('passes with username and password only', async () => { + const errs = await validate(assignTo(LoginUserDTO, { username: 'u', password: 'p' })); + assert.equal(errs.length, 0); + }); + + it('accepts optional tenantId and otp', async () => { + const errs = await validate(assignTo(LoginUserDTO, { username: 'u', password: 'p', tenantId: 't', otp: '123' })); + assert.equal(errs.length, 0); + }); + + it('rejects a non-string username', async () => { + const errs = await validate(assignTo(LoginUserDTO, { username: 5, password: 'p' })); + assert.ok(errs.some((e) => e.property === 'username')); + }); +}); + +describe('RegisterUserDTO (class-validator)', () => { + it('passes when password_confirmation matches and role is valid', async () => { + const errs = await validate(assignTo(RegisterUserDTO, { + username: 'u', password: 'p', password_confirmation: 'p', role: 'STANDARD_REGISTRY', + })); + assert.equal(errs.length, 0); + }); + + it('reports a Match error when confirmation differs', async () => { + const errs = await validate(assignTo(RegisterUserDTO, { + username: 'u', password: 'p', password_confirmation: 'x', role: 'STANDARD_REGISTRY', + })); + assert.ok(errs.some((e) => e.property === 'password_confirmation')); + }); + + it('rejects a role not in UserRole', async () => { + const errs = await validate(assignTo(RegisterUserDTO, { + username: 'u', password: 'p', password_confirmation: 'p', role: 'NOPE', + })); + assert.ok(errs.some((e) => e.property === 'role')); + }); +}); + +describe('AccessTokenRequestDTO (class-validator)', () => { + it('passes with a non-empty refreshToken', async () => { + const errs = await validate(assignTo(AccessTokenRequestDTO, { refreshToken: 'tok' })); + assert.equal(errs.length, 0); + }); + + it('rejects an empty refreshToken', async () => { + const errs = await validate(assignTo(AccessTokenRequestDTO, { refreshToken: '' })); + assert.ok(errs.some((e) => e.property === 'refreshToken')); + }); +}); + +describe('OTPConfirmDTO (class-validator)', () => { + it('passes with a string token', async () => { + const errs = await validate(assignTo(OTPConfirmDTO, { token: '000000' })); + assert.equal(errs.length, 0); + }); + + it('rejects a non-string token', async () => { + const errs = await validate(assignTo(OTPConfirmDTO, { token: 123 })); + assert.ok(errs.some((e) => e.property === 'token')); + }); +}); diff --git a/api-gateway/tests/middlewares/schemas-notifications.test.mjs b/api-gateway/tests/middlewares/schemas-notifications.test.mjs new file mode 100644 index 0000000000..47762bfea2 --- /dev/null +++ b/api-gateway/tests/middlewares/schemas-notifications.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { NotificationType, NotificationAction } from '@guardian/interfaces'; +import { + NotificationDTO, + ProgressDTO, +} from '../../dist/middlewares/validation/schemas/notifications.js'; + +const assignTo = (Cls, props) => Object.assign(new Cls(), props); +const someType = Object.values(NotificationType)[0]; +const someAction = Object.values(NotificationAction)[0]; + +describe('NotificationDTO (class-validator)', () => { + it('passes with only the required type set', async () => { + const errs = await validate(assignTo(NotificationDTO, { type: someType })); + assert.equal(errs.length, 0); + }); + + it('passes with a full set of optional fields', async () => { + const errs = await validate(assignTo(NotificationDTO, { + id: '1', title: 't', message: 'm', read: false, old: true, + type: someType, action: someAction, + })); + assert.equal(errs.length, 0); + }); + + it('rejects an invalid type enum value', async () => { + const errs = await validate(assignTo(NotificationDTO, { type: 'NOT_A_TYPE' })); + assert.ok(errs.some((e) => e.property === 'type')); + }); + + it('rejects an invalid action enum value', async () => { + const errs = await validate(assignTo(NotificationDTO, { type: someType, action: 'NOT_AN_ACTION' })); + assert.ok(errs.some((e) => e.property === 'action')); + }); + + it('rejects a non-string title', async () => { + const errs = await validate(assignTo(NotificationDTO, { type: someType, title: 5 })); + assert.ok(errs.some((e) => e.property === 'title')); + }); + + it('rejects a non-boolean read flag', async () => { + const errs = await validate(assignTo(NotificationDTO, { type: someType, read: 'yes' })); + assert.ok(errs.some((e) => e.property === 'read')); + }); +}); + +describe('ProgressDTO (class-validator)', () => { + it('passes with required action, progress, and type', async () => { + const errs = await validate(assignTo(ProgressDTO, { action: 'Publish', progress: 50, type: someType })); + assert.equal(errs.length, 0); + }); + + it('rejects a non-number progress', async () => { + const errs = await validate(assignTo(ProgressDTO, { action: 'Publish', progress: 'half', type: someType })); + assert.ok(errs.some((e) => e.property === 'progress')); + }); + + it('rejects a missing action', async () => { + const errs = await validate(assignTo(ProgressDTO, { progress: 10, type: someType })); + assert.ok(errs.some((e) => e.property === 'action')); + }); + + it('rejects an invalid type enum', async () => { + const errs = await validate(assignTo(ProgressDTO, { action: 'Publish', progress: 10, type: 'NOPE' })); + assert.ok(errs.some((e) => e.property === 'type')); + }); + + it('accepts an optional taskId string', async () => { + const errs = await validate(assignTo(ProgressDTO, { action: 'Publish', progress: 10, type: someType, taskId: 'task-1' })); + assert.equal(errs.length, 0); + }); +}); diff --git a/api-gateway/tests/middlewares/schemas-settings.test.mjs b/api-gateway/tests/middlewares/schemas-settings.test.mjs new file mode 100644 index 0000000000..be41ea5805 --- /dev/null +++ b/api-gateway/tests/middlewares/schemas-settings.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { + updateSettings, + AboutResponseDTO, + SettingsDTO, +} from '../../dist/middlewares/validation/schemas/settings.js'; + +const assignTo = (Cls, props) => Object.assign(new Cls(), props); + +describe('updateSettings (yup)', () => { + const schema = updateSettings(); + + it('accepts a complete body', () => { + assert.equal(schema.isValidSync({ body: { ipfsStorageApiKey: 'k', operatorId: '0.0.1', operatorKey: 'key' } }), true); + }); + + it('rejects a missing ipfsStorageApiKey', () => { + assert.equal(schema.isValidSync({ body: { operatorId: '0.0.1', operatorKey: 'key' } }), false); + }); + + it('rejects a missing operatorId', () => { + assert.equal(schema.isValidSync({ body: { ipfsStorageApiKey: 'k', operatorKey: 'key' } }), false); + }); + + it('rejects a missing operatorKey', () => { + assert.equal(schema.isValidSync({ body: { ipfsStorageApiKey: 'k', operatorId: '0.0.1' } }), false); + }); + + it('rejects an empty body', () => { + assert.equal(schema.isValidSync({ body: {} }), false); + }); +}); + +describe('AboutResponseDTO (class-validator)', () => { + it('passes with a string version', async () => { + const errs = await validate(assignTo(AboutResponseDTO, { version: '2.8.1' })); + assert.equal(errs.length, 0); + }); + + it('rejects a non-string version', async () => { + const errs = await validate(assignTo(AboutResponseDTO, { version: 281 })); + assert.ok(errs.some((e) => e.property === 'version')); + }); +}); + +describe('SettingsDTO (class-validator)', () => { + it('passes when all keys are non-empty strings', async () => { + const errs = await validate(assignTo(SettingsDTO, { ipfsStorageApiKey: 'k', operatorId: '0.0.1', operatorKey: 'key' })); + assert.equal(errs.length, 0); + }); + + it('reports errors for every missing key', async () => { + const errs = await validate(new SettingsDTO()); + assert.equal(errs.length, 3); + }); + + it('rejects an empty operatorKey', async () => { + const errs = await validate(assignTo(SettingsDTO, { ipfsStorageApiKey: 'k', operatorId: '0.0.1', operatorKey: '' })); + assert.ok(errs.some((e) => e.property === 'operatorKey')); + }); +}); diff --git a/api-gateway/tests/middlewares/string-or-number.test.mjs b/api-gateway/tests/middlewares/string-or-number.test.mjs new file mode 100644 index 0000000000..587e859e2e --- /dev/null +++ b/api-gateway/tests/middlewares/string-or-number.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { IsNumberOrString } from '../../dist/middlewares/validation/string-or-number.js'; + +describe('IsNumberOrString validator', () => { + const constraint = new IsNumberOrString(); + + it('is constructible and exposes validate/defaultMessage', () => { + assert.equal(typeof constraint.validate, 'function'); + assert.equal(typeof constraint.defaultMessage, 'function'); + }); + + it('accepts a number', () => { + assert.equal(constraint.validate(42, {}), true); + }); + + it('accepts zero', () => { + assert.equal(constraint.validate(0, {}), true); + }); + + it('accepts a negative number', () => { + assert.equal(constraint.validate(-7, {}), true); + }); + + it('accepts NaN (typeof number)', () => { + assert.equal(constraint.validate(NaN, {}), true); + }); + + it('accepts a string', () => { + assert.equal(constraint.validate('hello', {}), true); + }); + + it('accepts an empty string', () => { + assert.equal(constraint.validate('', {}), true); + }); + + it('rejects a boolean', () => { + assert.equal(constraint.validate(true, {}), false); + }); + + it('rejects an object', () => { + assert.equal(constraint.validate({}, {}), false); + }); + + it('rejects an array', () => { + assert.equal(constraint.validate([1, 2], {}), false); + }); + + it('rejects null', () => { + assert.equal(constraint.validate(null, {}), false); + }); + + it('rejects undefined', () => { + assert.equal(constraint.validate(undefined, {}), false); + }); + + it('returns the documented default message', () => { + assert.equal(constraint.defaultMessage({}), '($value) must be number or string'); + }); +}); diff --git a/api-gateway/tests/middlewares/string-or-object.test.mjs b/api-gateway/tests/middlewares/string-or-object.test.mjs new file mode 100644 index 0000000000..bbef8209eb --- /dev/null +++ b/api-gateway/tests/middlewares/string-or-object.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { IsStringOrObject } from '../../dist/middlewares/validation/string-or-object.js'; + +describe('IsStringOrObject validator', () => { + const constraint = new IsStringOrObject(); + + it('is constructible and exposes validate/defaultMessage', () => { + assert.equal(typeof constraint.validate, 'function'); + assert.equal(typeof constraint.defaultMessage, 'function'); + }); + + it('accepts a plain object', () => { + assert.equal(constraint.validate({ a: 1 }, {}), true); + }); + + it('accepts an empty object', () => { + assert.equal(constraint.validate({}, {}), true); + }); + + it('accepts an array (typeof object)', () => { + assert.equal(constraint.validate([1, 2, 3], {}), true); + }); + + it('accepts null (typeof object)', () => { + assert.equal(constraint.validate(null, {}), true); + }); + + it('accepts a string', () => { + assert.equal(constraint.validate('value', {}), true); + }); + + it('accepts an empty string', () => { + assert.equal(constraint.validate('', {}), true); + }); + + it('rejects a number', () => { + assert.equal(constraint.validate(123, {}), false); + }); + + it('rejects a boolean', () => { + assert.equal(constraint.validate(false, {}), false); + }); + + it('rejects undefined', () => { + assert.equal(constraint.validate(undefined, {}), false); + }); + + it('returns the documented default message', () => { + assert.equal(constraint.defaultMessage({}), '($value) must be object or string'); + }); +}); diff --git a/api-gateway/tests/middlewares/validate-runtime.test.mjs b/api-gateway/tests/middlewares/validate-runtime.test.mjs new file mode 100644 index 0000000000..4541087ef2 --- /dev/null +++ b/api-gateway/tests/middlewares/validate-runtime.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import * as yup from 'yup'; +import validate, { prepareValidationResponse } from '../../dist/middlewares/validation/index.js'; +import fieldsValidation from '../../dist/middlewares/validation/fields-validation.js'; + +const makeRes = () => ({ + code: undefined, + body: undefined, + status(c) { this.code = c; return this; }, + send(b) { this.body = b; return this; }, +}); + +describe('prepareValidationResponse edge cases', () => { + it('wraps a null error as a single-element array (no errors property)', () => { + assert.deepEqual(prepareValidationResponse(null), { type: 'ValidationError', message: [null] }); + }); + + it('wraps undefined as a single-element array', () => { + assert.deepEqual(prepareValidationResponse(undefined), { type: 'ValidationError', message: [undefined] }); + }); + + it('keeps an explicit empty errors array as the message', () => { + assert.deepEqual(prepareValidationResponse({ errors: [] }), { type: 'ValidationError', message: [] }); + }); + + it('falls back to wrapping the error when errors is an empty string (falsy)', () => { + const err = { errors: '' }; + assert.deepEqual(prepareValidationResponse(err), { type: 'ValidationError', message: [err] }); + }); + + it('passes through a populated errors array verbatim', () => { + assert.deepEqual( + prepareValidationResponse({ errors: ['x', 'y'] }, 'YupError'), + { type: 'YupError', message: ['x', 'y'] } + ); + }); +}); + +describe('validate middleware runtime branches', () => { + it('defaults type to ValidationError when the thrown error has no name', async () => { + const res = makeRes(); + let nexted = false; + const mw = validate({ validate: async () => { throw { errors: ['boom'] }; } }); + await mw({ body: {}, query: {}, params: {} }, res, () => { nexted = true; }); + assert.equal(res.code, 422); + assert.deepEqual(res.body, { type: 'ValidationError', message: ['boom'] }); + assert.equal(nexted, false); + }); + + it('wraps a thrown non-object string into the message array', async () => { + const res = makeRes(); + const mw = validate({ validate: async () => { throw 'raw failure'; } }); + await mw({ body: {}, query: {}, params: {} }, res, () => {}); + assert.equal(res.code, 422); + assert.deepEqual(res.body.message, ['raw failure']); + }); + + it('does not call next() after a validation failure', async () => { + const res = makeRes(); + let nexted = false; + const mw = validate({ validate: async () => { throw { name: 'E', errors: ['e'] }; } }); + await mw({ body: {}, query: {}, params: {} }, res, () => { nexted = true; }); + assert.equal(nexted, false); + }); + + it('returns the same res instance from the success path (next return value)', async () => { + const mw = validate({ validate: async () => undefined }); + const sentinel = Symbol('next-return'); + const result = await mw({ body: {}, query: {}, params: {} }, makeRes(), () => sentinel); + assert.equal(result, sentinel); + }); + + it('integrates with a real yup object schema and calls next on valid input', async () => { + const schema = yup.object({ body: yup.object({ name: fieldsValidation.name }) }); + let called = false; + await validate(schema)({ body: { name: 'Alice' }, query: {}, params: {} }, makeRes(), () => { called = true; }); + assert.equal(called, true); + }); + + it('integrates with a real yup object schema and responds 422 on invalid input', async () => { + const schema = yup.object({ body: yup.object({ name: fieldsValidation.name }) }); + const res = makeRes(); + await validate(schema)({ body: { name: '' }, query: {}, params: {} }, res, () => {}); + assert.equal(res.code, 422); + assert.equal(res.body.type, 'ValidationError'); + assert.ok(res.body.message.includes('The name field can not be empty')); + }); +}); + +describe('fieldsValidation uncovered branches', () => { + it('oppositeTokenId coerces a number to its string form (yup string cast)', async () => { + assert.equal(await fieldsValidation.oppositeTokenId.isValid(123), true); + assert.equal(fieldsValidation.oppositeTokenId.cast(123), '123'); + }); + + it('oppositeTokenId rejects a non-castable object with its typeError message', async () => { + try { + await fieldsValidation.oppositeTokenId.validate({}, { abortEarly: false }); + assert.fail('expected a type error'); + } catch (err) { + assert.ok(err.errors.includes('The oppositeTokenId field must be a string'), JSON.stringify(err.errors)); + } + }); + + it('baseTokenCount rejects a non-string with its typeError message', async () => { + try { + await fieldsValidation.baseTokenCount.validate({}, { abortEarly: false }); + assert.fail('expected a type error'); + } catch (err) { + assert.ok(err.errors.includes('The baseTokenCount field must be a string'), JSON.stringify(err.errors)); + } + }); + + it('oppositeTokenSerials within an object schema accepts an array of numbers', async () => { + const schema = yup.object({ + baseTokenId: fieldsValidation.baseTokenId, + oppositeTokenId: fieldsValidation.oppositeTokenId, + oppositeTokenSerials: fieldsValidation.oppositeTokenSerials, + }); + const ok = await schema.isValid({ + baseTokenId: '0.0.1', + oppositeTokenId: '0.0.2', + oppositeTokenSerials: [1, 2, 3], + }); + assert.equal(ok, true); + }); + + it('oppositeTokenSerials within an object schema accepts null', async () => { + const schema = yup.object({ + baseTokenId: fieldsValidation.baseTokenId, + oppositeTokenId: fieldsValidation.oppositeTokenId, + oppositeTokenSerials: fieldsValidation.oppositeTokenSerials, + }); + const ok = await schema.isValid({ + baseTokenId: '0.0.1', + oppositeTokenId: '0.0.2', + oppositeTokenSerials: null, + }); + assert.equal(ok, true); + }); + + it('oppositeTokenSerials rejects a non-numeric array element', async () => { + const ok = await fieldsValidation.oppositeTokenSerials.isValid(['not-a-number']); + assert.equal(ok, false); + }); + + it('baseTokenSerials coerces numeric-string elements (yup number cast)', async () => { + assert.equal(await fieldsValidation.baseTokenSerials.isValid(['1', '2']), true); + }); + + it('password_confirmation matching is order-sensitive to the password ref', async () => { + const schema = yup.object({ + password: fieldsValidation.password, + password_confirmation: fieldsValidation.password_confirmation, + }); + assert.equal(await schema.isValid({ password: 'p@ss', password_confirmation: 'p@ss' }), true); + assert.equal(await schema.isValid({ password: 'p@ss', password_confirmation: 'P@SS' }), false); + }); + + it('role accepts every value derived from Object.values(UserRole) plus ROOT_AUTHORITY', async () => { + assert.equal(await fieldsValidation.role.isValid('ROOT_AUTHORITY'), true); + }); +}); diff --git a/api-gateway/tests/middlewares/validation.test.mjs b/api-gateway/tests/middlewares/validation.test.mjs new file mode 100644 index 0000000000..32fc9f54b1 --- /dev/null +++ b/api-gateway/tests/middlewares/validation.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import * as yup from 'yup'; +import validate, { prepareValidationResponse } from '../../dist/middlewares/validation/index.js'; +import { IsNumberOrString } from '../../dist/middlewares/validation/string-or-number.js'; +import { IsStringOrObject } from '../../dist/middlewares/validation/string-or-object.js'; +import fieldsValidation from '../../dist/middlewares/validation/fields-validation.js'; +import { UserRole, SchemaEntity } from '@guardian/interfaces'; + +describe('IsNumberOrString constraint', () => { + const c = new IsNumberOrString(); + it('accepts numbers', () => assert.equal(c.validate(5), true)); + it('accepts strings', () => assert.equal(c.validate('x'), true)); + it('rejects objects', () => assert.equal(c.validate({}), false)); + it('rejects null', () => assert.equal(c.validate(null), false)); + it('rejects booleans', () => assert.equal(c.validate(true), false)); + it('exposes a default message', () => assert.match(c.defaultMessage(), /must be number or string/)); +}); + +describe('IsStringOrObject constraint', () => { + const c = new IsStringOrObject(); + it('accepts strings', () => assert.equal(c.validate('x'), true)); + it('accepts objects', () => assert.equal(c.validate({}), true)); + it('accepts null (typeof null === object)', () => assert.equal(c.validate(null), true)); + it('rejects numbers', () => assert.equal(c.validate(5), false)); + it('rejects booleans', () => assert.equal(c.validate(false), false)); + it('exposes a default message', () => assert.match(c.defaultMessage(), /must be object or string/)); +}); + +describe('prepareValidationResponse', () => { + it('wraps an errors array under message', () => { + assert.deepEqual(prepareValidationResponse({ errors: ['a', 'b'] }), { type: 'ValidationError', message: ['a', 'b'] }); + }); + it('wraps a bare error in a single-element array', () => { + assert.deepEqual(prepareValidationResponse('boom'), { type: 'ValidationError', message: ['boom'] }); + }); + it('uses the provided type', () => { + assert.equal(prepareValidationResponse({ errors: [] }, 'CustomError').type, 'CustomError'); + }); + it('defaults the type to ValidationError', () => { + assert.equal(prepareValidationResponse({ errors: [] }).type, 'ValidationError'); + }); +}); + +describe('validate middleware', () => { + const makeRes = () => ({ + code: undefined, body: undefined, + status(c) { this.code = c; return this; }, + send(b) { this.body = b; return this; } + }); + + it('calls next() when the schema resolves', async () => { + let called = false; + const mw = validate({ validate: async () => undefined }); + await mw({ body: {}, query: {}, params: {} }, makeRes(), () => { called = true; }); + assert.equal(called, true); + }); + + it('passes body/query/params with abortEarly:false to the schema', async () => { + let received; + let opts; + const mw = validate({ validate: async (data, o) => { received = data; opts = o; } }); + await mw({ body: { a: 1 }, query: { b: 2 }, params: { c: 3 } }, makeRes(), () => {}); + assert.deepEqual(received, { body: { a: 1 }, query: { b: 2 }, params: { c: 3 } }); + assert.equal(opts.abortEarly, false); + }); + + it('responds 422 with a prepared body on validation failure', async () => { + const res = makeRes(); + let nexted = false; + const err = { name: 'MyError', errors: ['bad'] }; + const mw = validate({ validate: async () => { throw err; } }); + await mw({ body: {}, query: {}, params: {} }, res, () => { nexted = true; }); + assert.equal(res.code, 422); + assert.deepEqual(res.body, { type: 'MyError', message: ['bad'] }); + assert.equal(nexted, false); + }); +}); + +describe('fieldsValidation yup schemas', () => { + const f = fieldsValidation; + + it('contractId is a required string', () => { + assert.equal(f.contractId.isValidSync('0.0.1'), true); + assert.equal(f.contractId.isValidSync(undefined), false); + }); + + it('description is required', () => { + assert.equal(f.description.isValidSync('x'), true); + assert.equal(f.description.isValidSync(undefined), false); + }); + + it('requestId is required', () => { + assert.equal(f.requestId.isValidSync('r'), true); + assert.equal(f.requestId.isValidSync(undefined), false); + }); + + it('name rejects empty and missing values', () => { + assert.equal(f.name.isValidSync('Alice'), true); + assert.equal(f.name.isValidSync(''), false); + assert.equal(f.name.isValidSync(undefined), false); + }); + + it('username rejects empty and missing values', () => { + assert.equal(f.username.isValidSync('bob'), true); + assert.equal(f.username.isValidSync(''), false); + }); + + it('password rejects empty and missing values', () => { + assert.equal(f.password.isValidSync('secret'), true); + assert.equal(f.password.isValidSync(''), false); + }); + + it('oppositeTokenId is a nullable string', () => { + assert.equal(f.oppositeTokenId.isValidSync(null), true); + assert.equal(f.oppositeTokenId.isValidSync('0.0.2'), true); + }); + + it('baseTokenSerials requires at least one numeric entry', () => { + assert.equal(f.baseTokenSerials.isValidSync([1, 2]), true); + assert.equal(f.baseTokenSerials.isValidSync([]), false); + assert.equal(f.baseTokenSerials.isValidSync(undefined), false); + }); + + it('entity accepts only STANDARD_REGISTRY or USER', () => { + assert.equal(f.entity.isValidSync(SchemaEntity.STANDARD_REGISTRY), true); + assert.equal(f.entity.isValidSync(SchemaEntity.USER), true); + assert.equal(f.entity.isValidSync('SOMETHING_ELSE'), false); + }); + + it('role accepts UserRole values and ROOT_AUTHORITY', () => { + assert.equal(f.role.isValidSync('ROOT_AUTHORITY'), true); + assert.equal(f.role.isValidSync(Object.values(UserRole)[0]), true); + assert.equal(f.role.isValidSync('NOT_A_ROLE'), false); + }); + + it('password_confirmation must match password within an object schema', () => { + const schema = yup.object({ password: f.password, password_confirmation: f.password_confirmation }); + assert.equal(schema.isValidSync({ password: 'a', password_confirmation: 'a' }), true); + assert.equal(schema.isValidSync({ password: 'a', password_confirmation: 'b' }), false); + }); + + it('operatorId and operatorKey are required', () => { + assert.equal(f.operatorId.isValidSync('0.0.1'), true); + assert.equal(f.operatorId.isValidSync(undefined), false); + assert.equal(f.operatorKey.isValidSync('key'), true); + assert.equal(f.operatorKey.isValidSync(undefined), false); + }); + + it('messageId is required', () => { + assert.equal(f.messageId.isValidSync('m'), true); + assert.equal(f.messageId.isValidSync(undefined), false); + }); +}); diff --git a/api-gateway/tests/roles-and-location-guard-extra.test.mjs b/api-gateway/tests/roles-and-location-guard-extra.test.mjs new file mode 100644 index 0000000000..952c4851ef --- /dev/null +++ b/api-gateway/tests/roles-and-location-guard-extra.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import 'reflect-metadata'; +import { Reflector } from '@nestjs/core'; +import { RolesAndLocationGuard } from '../dist/auth/roles-guard.js'; + +function buildContext(handler, request) { + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => handler, + getClass: () => null, + }; +} + +function withMetadata(entries) { + const fn = () => undefined; + for (const [key, value] of entries) { + Reflect.defineMetadata(key, value, fn); + } + return fn; +} + +describe('RolesAndLocationGuard: permissions-only metadata', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + + it('allows when the user holds a required permission and no location is required', () => { + const handler = withMetadata([['permissions', ['POLICY_READ']]]); + const ctx = buildContext(handler, { user: { permissions: ['POLICY_READ'] } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when the user lacks every required permission and no location is required', () => { + const handler = withMetadata([['permissions', ['POLICY_WRITE']]]); + const ctx = buildContext(handler, { user: { permissions: ['POLICY_READ'] } }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies when the user has no permissions array', () => { + const handler = withMetadata([['permissions', ['POLICY_READ']]]); + const ctx = buildContext(handler, { user: {} }); + assert.equal(guard.canActivate(ctx), false); + }); +}); + +describe('RolesAndLocationGuard: empty-array metadata is no requirement', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + + it('allows when both permissions and locations are empty arrays', () => { + const handler = withMetadata([ + ['permissions', []], + ['locations', []], + ]); + const ctx = buildContext(handler, { user: { location: 'LOCAL', permissions: [] } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('allows when permissions is empty but a matching location is required', () => { + const handler = withMetadata([ + ['permissions', []], + ['locations', ['LOCAL']], + ]); + const ctx = buildContext(handler, { user: { location: 'LOCAL' } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when permissions is empty but the location does not match', () => { + const handler = withMetadata([ + ['permissions', []], + ['locations', ['REMOTE']], + ]); + const ctx = buildContext(handler, { user: { location: 'LOCAL' } }); + assert.equal(guard.canActivate(ctx), false); + }); +}); + +describe('RolesAndLocationGuard: location gate precedes permission check', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + + it('denies on a location mismatch even when the permission would pass', () => { + const handler = withMetadata([ + ['permissions', ['POLICY_READ']], + ['locations', ['REMOTE']], + ]); + const ctx = buildContext(handler, { + user: { location: 'LOCAL', permissions: ['POLICY_READ'] }, + }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('allows when both the location matches and a permission matches', () => { + const handler = withMetadata([ + ['permissions', ['POLICY_READ', 'POLICY_WRITE']], + ['locations', ['LOCAL', 'REMOTE']], + ]); + const ctx = buildContext(handler, { + user: { location: 'REMOTE', permissions: ['POLICY_WRITE'] }, + }); + assert.equal(guard.canActivate(ctx), true); + }); +}); diff --git a/api-gateway/tests/roles-guard-matrix.test.mjs b/api-gateway/tests/roles-guard-matrix.test.mjs new file mode 100644 index 0000000000..6d5d48d9a2 --- /dev/null +++ b/api-gateway/tests/roles-guard-matrix.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import 'reflect-metadata'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard, RolesAndLocationGuard } from '../dist/auth/roles-guard.js'; +import { Permissions, LocationType, UserRole } from '@guardian/interfaces'; + +function buildContext(handler, request) { + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => handler, + getClass: () => null, + }; +} + +function handlerWith(entries) { + const fn = () => undefined; + for (const [key, value] of entries) { + Reflect.defineMetadata(key, value, fn); + } + return fn; +} + +const ROLES = [ + UserRole.STANDARD_REGISTRY, + UserRole.USER, + UserRole.AUDITOR, + UserRole.ADMIN, + UserRole.TENANT_ADMIN, + UserRole.TENANT_OPERATOR, +]; + +describe('RolesGuard: permission-held vs required matrix', () => { + const guard = new RolesGuard(new Reflector()); + const required = [Permissions.POLICIES_POLICY_READ, Permissions.POLICIES_POLICY_CREATE]; + + for (const role of ROLES) { + it(`allows ${role} who holds one required permission`, () => { + const handler = handlerWith([['permissions', required]]); + const ctx = buildContext(handler, { user: { role, permissions: [Permissions.POLICIES_POLICY_CREATE] } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it(`denies ${role} who holds only an unrelated permission`, () => { + const handler = handlerWith([['permissions', required]]); + const ctx = buildContext(handler, { user: { role, permissions: [Permissions.TOKENS_TOKEN_READ] } }); + assert.equal(guard.canActivate(ctx), false); + }); + } + + it('allows when the user holds every required permission', () => { + const handler = handlerWith([['permissions', required]]); + const ctx = buildContext(handler, { user: { permissions: required } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('allows when the user holds extra permissions beyond the required one', () => { + const handler = handlerWith([['permissions', [Permissions.POLICIES_POLICY_READ]]]); + const ctx = buildContext(handler, { + user: { permissions: [Permissions.TOKENS_TOKEN_READ, Permissions.POLICIES_POLICY_READ] }, + }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when required is a single permission the user lacks', () => { + const handler = handlerWith([['permissions', [Permissions.PERMISSIONS_ROLE_MANAGE]]]); + const ctx = buildContext(handler, { user: { permissions: [Permissions.POLICIES_POLICY_READ] } }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('allows when required is a single permission the user holds', () => { + const handler = handlerWith([['permissions', [Permissions.PERMISSIONS_ROLE_MANAGE]]]); + const ctx = buildContext(handler, { user: { permissions: [Permissions.PERMISSIONS_ROLE_MANAGE] } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('treats undefined metadata as no requirement (allow)', () => { + const handler = handlerWith([]); + const ctx = buildContext(handler, { user: { permissions: [] } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('treats null metadata as no requirement (allow)', () => { + const handler = handlerWith([['permissions', null]]); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), true); + }); + + it('treats a non-array (object) metadata value as no requirement (allow)', () => { + const handler = handlerWith([['permissions', { not: 'array' }]]); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies a user with an empty permissions array when a permission is required', () => { + const handler = handlerWith([['permissions', [Permissions.POLICIES_POLICY_READ]]]); + const ctx = buildContext(handler, { user: { permissions: [] } }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies a user whose permissions field is undefined when a permission is required', () => { + const handler = handlerWith([['permissions', [Permissions.POLICIES_POLICY_READ]]]); + const ctx = buildContext(handler, { user: { role: UserRole.USER } }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies when user is null and a permission is required', () => { + const handler = handlerWith([['permissions', [Permissions.POLICIES_POLICY_READ]]]); + const ctx = buildContext(handler, { user: null }); + assert.equal(guard.canActivate(ctx), false); + }); +}); + +describe('RolesAndLocationGuard: location matrix', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + const allLocations = [LocationType.LOCAL, LocationType.REMOTE]; + + for (const allowed of allLocations) { + for (const userLoc of allLocations) { + const expected = allowed === userLoc; + it(`location-only: allowed=${allowed} user=${userLoc} -> ${expected}`, () => { + const handler = handlerWith([['locations', [allowed]]]); + const ctx = buildContext(handler, { user: { location: userLoc } }); + assert.equal(guard.canActivate(ctx), expected); + }); + } + } + + it('allows when user location is in a multi-entry allow list', () => { + const handler = handlerWith([['locations', [LocationType.LOCAL, LocationType.REMOTE]]]); + const ctx = buildContext(handler, { user: { location: LocationType.REMOTE } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when user location is undefined and a location is required', () => { + const handler = handlerWith([['locations', [LocationType.LOCAL]]]); + const ctx = buildContext(handler, { user: {} }); + assert.equal(guard.canActivate(ctx), false); + }); +}); + +describe('RolesAndLocationGuard: combined permission + location decision matrix', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + const perms = [Permissions.POLICIES_POLICY_READ]; + const locs = [LocationType.LOCAL]; + + const cases = [ + { locOk: true, permOk: true, expected: true }, + { locOk: true, permOk: false, expected: false }, + { locOk: false, permOk: true, expected: false }, + { locOk: false, permOk: false, expected: false }, + ]; + + for (const { locOk, permOk, expected } of cases) { + it(`locOk=${locOk} permOk=${permOk} -> ${expected}`, () => { + const handler = handlerWith([ + ['permissions', perms], + ['locations', locs], + ]); + const ctx = buildContext(handler, { + user: { + location: locOk ? LocationType.LOCAL : LocationType.REMOTE, + permissions: permOk ? [Permissions.POLICIES_POLICY_READ] : [Permissions.TOKENS_TOKEN_READ], + }, + }); + assert.equal(guard.canActivate(ctx), expected); + }); + } + + it('denies on location mismatch even before evaluating a held permission', () => { + const handler = handlerWith([ + ['permissions', [Permissions.POLICIES_POLICY_READ]], + ['locations', [LocationType.REMOTE]], + ]); + const ctx = buildContext(handler, { + user: { location: LocationType.LOCAL, permissions: [Permissions.POLICIES_POLICY_READ] }, + }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies when location matches but permissions field is undefined', () => { + const handler = handlerWith([ + ['permissions', [Permissions.POLICIES_POLICY_READ]], + ['locations', [LocationType.LOCAL]], + ]); + const ctx = buildContext(handler, { user: { location: LocationType.LOCAL } }); + assert.equal(guard.canActivate(ctx), false); + }); +}); + +describe('RolesAndLocationGuard: no metadata / no user branches', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + + it('allows when neither permissions nor locations metadata is present', () => { + const handler = handlerWith([]); + const ctx = buildContext(handler, { user: null }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('allows when both metadata values are empty arrays', () => { + const handler = handlerWith([ + ['permissions', []], + ['locations', []], + ]); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies a missing user when only permissions metadata is present', () => { + const handler = handlerWith([['permissions', [Permissions.POLICIES_POLICY_READ]]]); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies a missing user when only locations metadata is present', () => { + const handler = handlerWith([['locations', [LocationType.LOCAL]]]); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), false); + }); + + it('allows location-only metadata with a matching user even without permissions field', () => { + const handler = handlerWith([['locations', [LocationType.LOCAL]]]); + const ctx = buildContext(handler, { user: { location: LocationType.LOCAL } }); + assert.equal(guard.canActivate(ctx), true); + }); +}); diff --git a/api-gateway/tests/roles-guard.test.js b/api-gateway/tests/roles-guard.test.js new file mode 100644 index 0000000000..0ea383cc30 --- /dev/null +++ b/api-gateway/tests/roles-guard.test.js @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import 'reflect-metadata'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard, RolesAndLocationGuard } from '../dist/auth/roles-guard.js'; + +function buildContext(handler, request) { + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => handler, + getClass: () => null, + }; +} + +function withMetadata(key, value) { + const fn = () => undefined; + Reflect.defineMetadata(key, value, fn); + return fn; +} + +function withMultipleMetadata(entries) { + const fn = () => undefined; + for (const [key, value] of entries) { + Reflect.defineMetadata(key, value, fn); + } + return fn; +} + +describe('RolesGuard', () => { + const guard = new RolesGuard(new Reflector()); + + it('allows when no permissions metadata is set', () => { + const handler = () => undefined; + const ctx = buildContext(handler, { user: null }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('allows when the user has at least one of the required permissions', () => { + const handler = withMetadata('permissions', ['POLICY_READ', 'POLICY_WRITE']); + const ctx = buildContext(handler, { + user: { permissions: ['POLICY_READ'] }, + }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when the user has none of the required permissions', () => { + const handler = withMetadata('permissions', ['POLICY_WRITE']); + const ctx = buildContext(handler, { + user: { permissions: ['POLICY_READ'] }, + }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies when the user is missing entirely', () => { + const handler = withMetadata('permissions', ['POLICY_READ']); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), false); + }); + + it('denies when the user has no permissions array', () => { + const handler = withMetadata('permissions', ['POLICY_READ']); + const ctx = buildContext(handler, { user: {} }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('treats an empty permissions array as no requirement (allow)', () => { + const handler = withMetadata('permissions', []); + const ctx = buildContext(handler, { user: null }); + assert.equal(guard.canActivate(ctx), true); + }); +}); + +describe('RolesAndLocationGuard', () => { + const guard = new RolesAndLocationGuard(new Reflector()); + + it('allows when neither permissions nor locations metadata is set', () => { + const handler = () => undefined; + const ctx = buildContext(handler, { user: { location: 'LOCAL' } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when the user is missing and any metadata is present', () => { + const handler = withMetadata('locations', ['LOCAL']); + const ctx = buildContext(handler, {}); + assert.equal(guard.canActivate(ctx), false); + }); + + it("denies when the user's location is not in the allowed list", () => { + const handler = withMetadata('locations', ['REMOTE']); + const ctx = buildContext(handler, { user: { location: 'LOCAL' } }); + assert.equal(guard.canActivate(ctx), false); + }); + + it('allows when location matches and no permissions are required', () => { + const handler = withMetadata('locations', ['LOCAL']); + const ctx = buildContext(handler, { user: { location: 'LOCAL' } }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('allows when location matches and the user has a required permission', () => { + const handler = withMultipleMetadata([ + ['permissions', ['POLICY_READ']], + ['locations', ['LOCAL']], + ]); + const ctx = buildContext(handler, { + user: { location: 'LOCAL', permissions: ['POLICY_READ'] }, + }); + assert.equal(guard.canActivate(ctx), true); + }); + + it('denies when location matches but the user lacks every required permission', () => { + const handler = withMultipleMetadata([ + ['permissions', ['POLICY_WRITE']], + ['locations', ['LOCAL']], + ]); + const ctx = buildContext(handler, { + user: { location: 'LOCAL', permissions: ['POLICY_READ'] }, + }); + assert.equal(guard.canActivate(ctx), false); + }); +}); diff --git a/api-gateway/tests/service/_controller-harness.mjs b/api-gateway/tests/service/_controller-harness.mjs new file mode 100644 index 0000000000..876c3e37b6 --- /dev/null +++ b/api-gateway/tests/service/_controller-harness.mjs @@ -0,0 +1,85 @@ +import esmock from 'esmock'; + +export function makeUser(over = {}) { + return { + id: 'user-1', + did: 'did:hedera:user', + username: 'alice', + tenantId: 't1', + tenantContext: { tenantId: 't1' }, + permissions: [], + ...over + }; +} + +export function makeRes() { + const headers = {}; + let sent; + const res = { + headers, + sent, + header(name, value) { headers[name] = value; return res; }, + code(c) { res._code = c; return res; }, + send(payload) { res._sent = payload; return { __sent: true, payload }; } + }; + return res; +} + +export function makeReq(over = {}) { + return { url: '/api/v1/x', params: {}, body: {}, ...over }; +} + +export function makeCacheService() { + const calls = { invalidate: [], invalidateAllTagsByPrefixes: [] }; + return { + calls, + async invalidate(key) { calls.invalidate.push(key); }, + async invalidateAllTagsByPrefixes(p) { calls.invalidateAllTagsByPrefixes.push(p); } + }; +} + +export function makeLogger() { + const errors = []; + return { errors, error(...a) { errors.push(a); } }; +} + +export class FakeEntityOwner { + constructor(user) { + this.user = user; + this.owner = user?.did || 'owner-did'; + this.creator = user?.did || 'owner-did'; + } +} + +export async function internalExceptionRethrow(error) { + if (error && typeof error.getStatus === 'function') { + throw error; + } + if (typeof error === 'string') { + const e = new Error(error); + e.getStatus = () => 500; + throw e; + } + const e = new Error(error?.message || 'error'); + e.getStatus = () => (error?.code || 500); + throw e; +} + +export const guardiansInterfaces = { + Permissions: new Proxy({}, { get: (_t, p) => String(p) }), + PolicyStatus: { PUBLISH: 'PUBLISH', VIEW: 'VIEW', DRAFT: 'DRAFT' }, + TaskAction: new Proxy({}, { get: (_t, p) => String(p) }), + UserPermissions: { + has: (user, perms) => { + const list = Array.isArray(perms) ? perms : [perms]; + const have = user?.permissions || []; + return list.some((p) => have.includes(p)); + } + }, + SchemaCategory: { MODULE: 'MODULE', POLICY: 'POLICY', TOOL: 'TOOL' }, + SchemaHelper: { updateOwner: () => undefined } +}; + +export async function loadController(distPath, overrides = {}) { + return await esmock(distPath, overrides); +} diff --git a/api-gateway/tests/service/analytics-controller.test.mjs b/api-gateway/tests/service/analytics-controller.test.mjs new file mode 100644 index 0000000000..3a8c996709 --- /dev/null +++ b/api-gateway/tests/service/analytics-controller.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const ANALYTICS_DIST = '../../dist/api/service/analytics.js'; +const authMock = { Auth: () => () => undefined, AuthUser: () => () => undefined }; +const middlewaresMock = new Proxy({}, { get: (t, p) => (p === 'Examples' || p === 'ObjectExamples') ? new Proxy({}, { get: () => 'ex' }) : class {} }); + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:u', tenantContext: { tenantId: 't1' }, ...extra }; +} +const is422 = (e) => { assert.equal(e.getStatus(), 422); return true; }; + +describe('AnalyticsApi', function () { + this.timeout(60000); + let AnalyticsApi; + let guardiansImpl; + + before(async () => { + ({ AnalyticsApi } = await esmock(ANALYTICS_DIST, { + '@guardian/interfaces': { + EntityOwner: class { constructor(user) { this.creator = user?.did; } }, + Permissions: new Proxy({}, { get: () => 'p' }), + }, + '#middlewares': middlewaresMock, + '#auth': authMock, + '@guardian/common': { PinoLogger: class {} }, + '#helpers': { + Guardians: class { + constructor(tc) { this.tc = tc; } + async searchPolicies(...a) { return guardiansImpl.searchPolicies(...a); } + async comparePolicies(...a) { return guardiansImpl.comparePolicies(...a); } + async compareOriginalPolicies(...a) { return guardiansImpl.compareOriginalPolicies(...a); } + async compareModules(...a) { return guardiansImpl.compareModules(...a); } + async compareSchemas(...a) { return guardiansImpl.compareSchemas(...a); } + async compareDocuments(...a) { return guardiansImpl.compareDocuments(...a); } + async compareTools(...a) { return guardiansImpl.compareTools(...a); } + async searchBlocks(...a) { return guardiansImpl.searchBlocks(...a); } + async getIndexerAvailability(...a) { return guardiansImpl.getIndexerAvailability(...a); } + }, + ONLY_SR: '', + InternalException: async (e) => { throw e; }, + }, + })); + }); + + function makeApi() { return new AnalyticsApi({ error: () => undefined }); } + + it('searchPolicies forwards the filters to guardians', async () => { + let seen; + guardiansImpl = { searchPolicies: async (owner, filters) => { seen = filters; return [{ id: 'p' }]; } }; + const out = await makeApi().searchPolicies(makeUser(), { text: 'x' }); + assert.deepEqual(out, [{ id: 'p' }]); + assert.deepEqual(seen, { text: 'x' }); + }); + + it('searchPolicies rethrows via InternalException', async () => { + guardiansImpl = { searchPolicies: async () => { throw new Error('sp'); } }; + await assert.rejects(makeApi().searchPolicies(makeUser(), {}), /sp/); + }); + + it('comparePolicies throws 422 with no filters', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().comparePolicies(makeUser(), null), is422); + }); + + it('comparePolicies builds an id list from policyId1/policyId2', async () => { + let seenPolicies; + guardiansImpl = { comparePolicies: async (owner, type, policies) => { seenPolicies = policies; return { ok: true }; } }; + await makeApi().comparePolicies(makeUser(), { policyId1: 'a', policyId2: 'b' }); + assert.deepEqual(seenPolicies, [{ type: 'id', value: 'a' }, { type: 'id', value: 'b' }]); + }); + + it('comparePolicies accepts a policies array of length > 1', async () => { + let seenPolicies; + guardiansImpl = { comparePolicies: async (owner, type, policies) => { seenPolicies = policies; return {}; } }; + await makeApi().comparePolicies(makeUser(), { policies: ['x', 'y'] }); + assert.deepEqual(seenPolicies, ['x', 'y']); + }); + + it('comparePolicies maps a policyIds array to typed ids', async () => { + let seenPolicies; + guardiansImpl = { comparePolicies: async (owner, type, policies) => { seenPolicies = policies; return {}; } }; + await makeApi().comparePolicies(makeUser(), { policyIds: ['p1', 'p2'] }); + assert.deepEqual(seenPolicies, [{ type: 'id', value: 'p1' }, { type: 'id', value: 'p2' }]); + }); + + it('comparePolicies forwards comparison levels', async () => { + let args; + guardiansImpl = { comparePolicies: async (...a) => { args = a; return {}; } }; + await makeApi().comparePolicies(makeUser(), { policyId1: 'a', policyId2: 'b', eventsLvl: 1, propLvl: 2, childrenLvl: 3, idLvl: 4 }); + assert.deepEqual(args.slice(3), [1, 2, 3, 4]); + }); + + it('compareOriginalPolicy forwards the policyId and levels', async () => { + let args; + guardiansImpl = { compareOriginalPolicies: async (...a) => { args = a; return { r: 1 }; } }; + const out = await makeApi().compareOriginalPolicy(makeUser(), 'pol-1', { eventsLvl: 5 }); + assert.deepEqual(out, { r: 1 }); + assert.equal(args[2], 'pol-1'); + assert.equal(args[3], 5); + }); + + it('compareModules throws 422 when either module id is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().compareModules(makeUser(), { moduleId1: 'a' }), is422); + }); + + it('compareModules forwards both module ids', async () => { + let args; + guardiansImpl = { compareModules: async (...a) => { args = a; return {}; } }; + await makeApi().compareModules(makeUser(), { moduleId1: 'm1', moduleId2: 'm2' }); + assert.equal(args[2], 'm1'); + assert.equal(args[3], 'm2'); + }); + + it('compareSchemas throws 422 without valid schema ids', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().compareSchemas(makeUser(), {}), is422); + }); + + it('compareSchemas builds ids from schemaId1/schemaId2', async () => { + let seenSchemas; + guardiansImpl = { compareSchemas: async (owner, type, schemas) => { seenSchemas = schemas; return {}; } }; + await makeApi().compareSchemas(makeUser(), { schemaId1: 's1', schemaId2: 's2' }); + assert.deepEqual(seenSchemas, [{ type: 'id', value: 's1' }, { type: 'id', value: 's2' }]); + }); + + it('compareDocuments throws 422 without valid document ids', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().compareDocuments(makeUser(), { documentId1: 'a' }), is422); + }); + + it('compareDocuments forwards a two-element id list with keyLvl/refLvl of 0', async () => { + let args; + guardiansImpl = { compareDocuments: async (...a) => { args = a; return {}; } }; + await makeApi().compareDocuments(makeUser(), { documentId1: 'a', documentId2: 'b' }); + assert.deepEqual(args[2], ['a', 'b']); + assert.equal(args[7], 0); + assert.equal(args[8], 0); + }); + + it('compareTools throws 422 without valid tool ids', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().compareTools(makeUser(), { toolId1: 'a' }), is422); + }); + + it('compareTools accepts a toolIds array', async () => { + let args; + guardiansImpl = { compareTools: async (...a) => { args = a; return {}; } }; + await makeApi().compareTools(makeUser(), { toolIds: ['t1', 't2'] }); + assert.deepEqual(args[2], ['t1', 't2']); + }); + + it('comparePoliciesExport passes the export type through', async () => { + let seenType; + guardiansImpl = { comparePolicies: async (owner, type) => { seenType = type; return 'csv'; } }; + const out = await makeApi().comparePoliciesExport(makeUser(), { policyId1: 'a', policyId2: 'b' }, 'csv'); + assert.equal(out, 'csv'); + assert.equal(seenType, 'csv'); + }); + + it('compareModulesExport throws 422 without two module ids', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().compareModulesExport(makeUser(), {}, 'csv'), is422); + }); + + it('compareSchemasExport passes the type through', async () => { + let seenType; + guardiansImpl = { compareSchemas: async (owner, type) => { seenType = type; return 'csv'; } }; + await makeApi().compareSchemasExport(makeUser(), { schemaId1: 'a', schemaId2: 'b' }, 'csv'); + assert.equal(seenType, 'csv'); + }); + + it('compareDocumentsExport passes the type through', async () => { + let seenType; + guardiansImpl = { compareDocuments: async (user, type) => { seenType = type; return 'csv'; } }; + await makeApi().compareDocumentsExport(makeUser(), { documentId1: 'a', documentId2: 'b' }, 'csv'); + assert.equal(seenType, 'csv'); + }); + + it('compareToolsExport passes the type through', async () => { + let seenType; + guardiansImpl = { compareTools: async (user, type) => { seenType = type; return 'csv'; } }; + await makeApi().compareToolsExport(makeUser(), { toolId1: 'a', toolId2: 'b' }, 'csv'); + assert.equal(seenType, 'csv'); + }); + + it('searchBlocks throws 422 when id or config is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().searchBlocks(makeUser(), { id: 'x' }), is422); + }); + + it('searchBlocks forwards config, id and user', async () => { + let seen; + guardiansImpl = { searchBlocks: async (config, id, user) => { seen = { config, id }; return ['b']; } }; + const out = await makeApi().searchBlocks(makeUser(), { id: 'i', config: { c: 1 } }); + assert.deepEqual(out, ['b']); + assert.deepEqual(seen, { config: { c: 1 }, id: 'i' }); + }); + + it('checkIndexerAvailability returns the availability result', async () => { + guardiansImpl = { getIndexerAvailability: async () => true }; + assert.equal(await makeApi().checkIndexerAvailability(makeUser()), true); + }); + + it('checkIndexerAvailability rethrows via InternalException', async () => { + guardiansImpl = { getIndexerAvailability: async () => { throw new Error('idx'); } }; + await assert.rejects(makeApi().checkIndexerAvailability(makeUser()), /idx/); + }); +}); diff --git a/api-gateway/tests/service/contract.test.mjs b/api-gateway/tests/service/contract.test.mjs new file mode 100644 index 0000000000..e10635ed45 --- /dev/null +++ b/api-gateway/tests/service/contract.test.mjs @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeReq, makeCacheService, makeLogger, + FakeEntityOwner, internalExceptionRethrow, loadController +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/contract.js'; + +let stub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } +} +const methods = [ + 'getContracts', 'createContract', 'createContractV2', 'importContract', 'checkContractPermissions', + 'removeContract', 'getWipeRequests', 'enableWipeRequests', 'disableWipeRequests', 'approveWipeRequest', + 'rejectWipeRequest', 'clearWipeRequests', 'addWipeAdmin', 'removeWipeAdmin', 'addWipeManager', + 'removeWipeManager', 'addWipeWiper', 'removeWipeWiper', 'syncRetirePools', 'getRetireRequests', + 'getRetirePools', 'clearRetireRequests', 'clearRetirePools', 'setRetirePool', 'unsetRetirePool', + 'unsetRetireRequest', 'retire', 'approveRetire', 'cancelRetire', 'addRetireAdmin', 'removeRetireAdmin', + 'getRetireVCs', 'getRetireVCsFromIndexer' +]; +for (const m of methods) { + FakeGuardians.prototype[m] = function (...a) { return stub[m](...a); }; +} + +async function load() { + return loadController(DIST, { + '#helpers': { + Guardians: FakeGuardians, EntityOwner: FakeEntityOwner, CacheService: class {}, + InternalException: internalExceptionRethrow, getCacheKey: (t) => `k:${t.join('|')}`, + UseCache: () => () => undefined + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {} } + }); +} + +function makeApi(Api) { const cache = makeCacheService(); return { api: new Api(cache, makeLogger()), cache }; } + +describe('ContractsApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ ContractsApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + getContracts: async () => [[{ id: 'c1' }], 2], + createContract: async () => ({ created: true }), + createContractV2: async () => ({ createdV2: true }), + importContract: async () => ({ imported: true }), + checkContractPermissions: async () => ({ perm: true }), + removeContract: async () => ({ removed: true }), + getWipeRequests: async () => [[{ w: 1 }], 5], + enableWipeRequests: async () => ({ enabled: true }), + disableWipeRequests: async () => ({ disabled: true }), + approveWipeRequest: async () => ({ approved: true }), + rejectWipeRequest: async () => ({ rejected: true }), + clearWipeRequests: async () => ({ cleared: true }), + addWipeAdmin: async () => ({ a: 1 }), + removeWipeAdmin: async () => ({ a: 2 }), + addWipeManager: async () => ({ a: 3 }), + removeWipeManager: async () => ({ a: 4 }), + addWipeWiper: async () => ({ a: 5 }), + removeWipeWiper: async () => ({ a: 6 }), + syncRetirePools: async () => ({ synced: true }), + getRetireRequests: async () => [[{ r: 1 }], 3], + getRetirePools: async () => [[{ p: 1 }], 4], + clearRetireRequests: async () => ({ c: 1 }), + clearRetirePools: async () => ({ c: 2 }), + setRetirePool: async () => ({ set: true }), + unsetRetirePool: async () => ({ unset: true }), + unsetRetireRequest: async () => ({ unsetReq: true }), + retire: async () => ({ retired: true }), + approveRetire: async () => ({ approvedRetire: true }), + cancelRetire: async () => ({ canceled: true }), + addRetireAdmin: async () => ({ ra: 1 }), + removeRetireAdmin: async () => ({ ra: 2 }), + getRetireVCs: async () => [[{ vc: 1 }], 8], + getRetireVCsFromIndexer: async () => [[{ vc: 2 }], 9] + }; + }); + + it('getContracts sets count header from tuple', async () => { + const { api } = makeApi(Api); + const res = makeRes(); + await api.getContracts(makeUser(), res, 'wipe', 0, 10); + assert.equal(res.headers['X-Total-Count'], 2); + }); + + it('getContracts passes owner+type+paging', async () => { + let seen; + stub.getContracts = async (owner, type, pi, ps) => { seen = { type, pi, ps, owner }; return [[], 0]; }; + const { api } = makeApi(Api); + await api.getContracts(makeUser(), makeRes(), 'retire', 2, 25); + assert.equal(seen.type, 'retire'); + assert.equal(seen.pi, 2); + assert.ok(seen.owner instanceof FakeEntityOwner); + }); + + it('createContract extracts description+type and invalidates cache', async () => { + let seen; + stub.createContract = async (owner, desc, type) => { seen = { desc, type }; return {}; }; + const { api, cache } = makeApi(Api); + await api.createContract(makeUser(), { description: 'd', type: 'wipe' }, makeReq()); + assert.deepEqual(seen, { desc: 'd', type: 'wipe' }); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('createContractV2 delegates to createContractV2', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.createContractV2(makeUser(), { description: 'd', type: 't' }, makeReq()), { createdV2: true }); + }); + + it('importContract extracts contractId+description', async () => { + let seen; + stub.importContract = async (owner, contractId, desc) => { seen = { contractId, desc }; return {}; }; + const { api } = makeApi(Api); + await api.importContract(makeUser(), { contractId: 'C1', description: 'd' }); + assert.deepEqual(seen, { contractId: 'C1', desc: 'd' }); + }); + + it('contractPermissions delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.contractPermissions(makeUser(), 'c1'), { perm: true }); + }); + + it('removeContract delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.removeContract(makeUser(), 'c1'), { removed: true }); + }); + + it('getWipeRequests sets count header', async () => { + const { api } = makeApi(Api); + const res = makeRes(); + await api.getWipeRequests(makeUser(), res, 'c1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 5); + }); + + it('enableWipeRequests delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.enableWipeRequests(makeUser(), 'c1'), { enabled: true }); + }); + + it('disableWipeRequests delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.disableWipeRequests(makeUser(), 'c1'), { disabled: true }); + }); + + it('approveWipeRequest delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.approveWipeRequest(makeUser(), 'req1'), { approved: true }); + }); + + it('rejectWipeRequest converts ban="true" to boolean true', async () => { + let seen; + stub.rejectWipeRequest = async (owner, requestId, ban) => { seen = ban; return {}; }; + const { api } = makeApi(Api); + await api.rejectWipeRequest(makeUser(), 'req1', 'true'); + assert.equal(seen, true); + }); + + it('rejectWipeRequest treats other values as false', async () => { + let seen; + stub.rejectWipeRequest = async (owner, requestId, ban) => { seen = ban; return {}; }; + const { api } = makeApi(Api); + await api.rejectWipeRequest(makeUser(), 'req1', 'no'); + assert.equal(seen, false); + }); + + it('clearWipeRequests delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.clearWipeRequests(makeUser(), 'c1'), { cleared: true }); + }); + + it('clearWipeRequestsWithHederaId passes hederaId', async () => { + let seen; + stub.clearWipeRequests = async (owner, contractId, hederaId) => { seen = hederaId; return {}; }; + const { api } = makeApi(Api); + await api.clearWipeRequestsWithHederaId(makeUser(), 'c1', '0.0.5'); + assert.equal(seen, '0.0.5'); + }); + + it('wipeAddAdmin delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.wipeAddAdmin(makeUser(), 'c1', '0.0.1'), { a: 1 }); + }); + + it('wipeRemoveAdmin delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.wipeRemoveAdmin(makeUser(), 'c1', '0.0.1'), { a: 2 }); + }); + + it('wipeAddManager delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.wipeAddManager(makeUser(), 'c1', '0.0.1'), { a: 3 }); + }); + + it('wipeRemoveManager delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.wipeRemoveManager(makeUser(), 'c1', '0.0.1'), { a: 4 }); + }); + + it('wipeAddWiper delegates without tokenId', async () => { + let seen; + stub.addWipeWiper = async (owner, cid, hid, tokenId) => { seen = tokenId; return { a: 5 }; }; + const { api } = makeApi(Api); + await api.wipeAddWiper(makeUser(), 'c1', '0.0.1'); + assert.equal(seen, undefined); + }); + + it('wipeAddWiperWithToken passes tokenId', async () => { + let seen; + stub.addWipeWiper = async (owner, cid, hid, tokenId) => { seen = tokenId; return {}; }; + const { api } = makeApi(Api); + await api.wipeAddWiperWithToken(makeUser(), 'c1', '0.0.1', 'tok1'); + assert.equal(seen, 'tok1'); + }); + + it('wipeRemoveWiper delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.wipeRemoveWiper(makeUser(), 'c1', '0.0.1'), { a: 6 }); + }); + + it('wipeRemoveWiperWithToken passes tokenId', async () => { + let seen; + stub.removeWipeWiper = async (owner, cid, hid, tokenId) => { seen = tokenId; return {}; }; + const { api } = makeApi(Api); + await api.wipeRemoveWiperWithToken(makeUser(), 'c1', '0.0.1', 'tok2'); + assert.equal(seen, 'tok2'); + }); + + it('retireSyncPools delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.retireSyncPools(makeUser(), 'c1'), { synced: true }); + }); + + it('getRetireRequests sets count header', async () => { + const { api } = makeApi(Api); + const res = makeRes(); + await api.getRetireRequests(makeUser(), res, 'c1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 3); + }); + + it('getRetirePools splits tokens csv', async () => { + let seen; + stub.getRetirePools = async (owner, tokens) => { seen = tokens; return [[], 0]; }; + const { api } = makeApi(Api); + await api.getRetirePools(makeUser(), makeRes(), 'c1', 'a,b,c', 0, 10); + assert.deepEqual(seen, ['a', 'b', 'c']); + }); + + it('getRetirePools handles undefined tokens', async () => { + let seen = 'x'; + stub.getRetirePools = async (owner, tokens) => { seen = tokens; return [[], 0]; }; + const { api } = makeApi(Api); + await api.getRetirePools(makeUser(), makeRes(), 'c1', undefined, 0, 10); + assert.equal(seen, undefined); + }); + + it('clearRetireRequests delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.clearRetireRequests(makeUser(), 'c1'), { c: 1 }); + }); + + it('clearRetirePools delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.clearRetirePools(makeUser(), 'c1'), { c: 2 }); + }); + + it('setRetirePool passes body', async () => { + let seen; + stub.setRetirePool = async (owner, cid, body) => { seen = body; return {}; }; + const { api } = makeApi(Api); + await api.setRetirePool(makeUser(), 'c1', { tokens: [] }); + assert.deepEqual(seen, { tokens: [] }); + }); + + it('unsetRetirePool delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.unsetRetirePool(makeUser(), 'p1'), { unset: true }); + }); + + it('unsetRetireRequest delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.unsetRetireRequest(makeUser(), 'r1'), { unsetReq: true }); + }); + + it('retire throws when body is not an array', async () => { + const { api } = makeApi(Api); + await assert.rejects(api.retire(makeUser(), 'p1', { not: 'array' })); + }); + + it('retire delegates when body is array', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.retire(makeUser(), 'p1', [{ count: 1 }]), { retired: true }); + }); + + it('approveRetire delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.approveRetire(makeUser(), 'r1'), { approvedRetire: true }); + }); + + it('cancelRetireRequest delegates to cancelRetire', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.cancelRetireRequest(makeUser(), 'r1'), { canceled: true }); + }); + + it('retireAddAdmin delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.retireAddAdmin(makeUser(), 'c1', '0.0.1'), { ra: 1 }); + }); + + it('retireRemoveAdmin delegates', async () => { + const { api } = makeApi(Api); + assert.deepEqual(await api.retireRemoveAdmin(makeUser(), 'c1', '0.0.1'), { ra: 2 }); + }); + + it('getRetireVCs sets count header', async () => { + const { api } = makeApi(Api); + const res = makeRes(); + await api.getRetireVCs(makeUser(), res, 0, 10); + assert.equal(res.headers['X-Total-Count'], 8); + }); + + it('getRetireVCsFromIndexer passes contractTopicId', async () => { + let seen; + stub.getRetireVCsFromIndexer = async (owner, topic) => { seen = topic; return [[], 0]; }; + const { api } = makeApi(Api); + await api.getRetireVCsFromIndexer(makeUser(), makeRes(), 'topic-1'); + assert.equal(seen, 'topic-1'); + }); + + it('getContracts rethrows error', async () => { + stub.getContracts = async () => { throw new Error('x'); }; + const { api } = makeApi(Api); + await assert.rejects(api.getContracts(makeUser(), makeRes(), 'wipe', 0, 10)); + }); +}); diff --git a/api-gateway/tests/service/credentials-ipfs-suggestions-controllers.test.mjs b/api-gateway/tests/service/credentials-ipfs-suggestions-controllers.test.mjs new file mode 100644 index 0000000000..69dddd66f1 --- /dev/null +++ b/api-gateway/tests/service/credentials-ipfs-suggestions-controllers.test.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const CREDENTIALS_DIST = '../../dist/api/service/credentials.js'; +const IPFS_DIST = '../../dist/api/service/ipfs.js'; +const SUGGESTIONS_DIST = '../../dist/api/service/suggestions.js'; + +let guardiansStub; + +class MockGuardians { + constructor(tenantContext) { + this.tenantContext = tenantContext; + } + getCredentials(...a) { return guardiansStub.getCredentials(...a); } + setCredential(...a) { return guardiansStub.setCredential(...a); } + deleteCredential(...a) { return guardiansStub.deleteCredential(...a); } + addFileIpfs(...a) { return guardiansStub.addFileIpfs(...a); } + addFileIpfsDirect(...a) { return guardiansStub.addFileIpfsDirect(...a); } + addFileToDryRunStorage(...a) { return guardiansStub.addFileToDryRunStorage(...a); } + getFileIpfs(...a) { return guardiansStub.getFileIpfs(...a); } + getFileFromDryRunStorage(...a) { return guardiansStub.getFileFromDryRunStorage(...a); } + deleteIpfsCid(...a) { return guardiansStub.deleteIpfsCid(...a); } + policySuggestions(...a) { return guardiansStub.policySuggestions(...a); } + setPolicySuggestionsConfig(...a) { return guardiansStub.setPolicySuggestionsConfig(...a); } + getPolicySuggestionsConfig(...a) { return guardiansStub.getPolicySuggestionsConfig(...a); } +} + +const middlewaresMock = new Proxy({}, { + get: (_t, p) => { + if (p === 'Examples') { return new Proxy({}, { get: () => 'ex' }); } + if (p === 'ONLY_SR') { return ' sr'; } + return class {}; + } +}); + +const helpersMock = { + Guardians: MockGuardians, + InternalException: async (error) => { throw error; }, + CacheService: class {}, + getCacheKey: (keys) => `ck:${(keys || []).join('|')}`, + UseCache: () => () => undefined, + ONLY_SR: ' sr', +}; + +const authMock = { + Auth: () => () => undefined, + AuthUser: () => () => undefined, + checkPermission: () => () => undefined, +}; + +const constantsMock = { + CACHE: { LONG_TTL: 60 }, + PREFIXES: { IPFS: 'ipfs' }, +}; + +const commonMock = { + PinoLogger: class {}, + RunFunctionAsync: (fn) => fn(), +}; + +function makeUser(over = {}) { + return { id: 'user-1', did: 'did:u', tenantContext: { tenantId: 't1' }, ...over }; +} + +function makeLogger() { + return { error: async () => undefined }; +} + +function makeCache() { + const calls = []; + return { calls, invalidate: async (key) => { calls.push(key); } }; +} + +const statusIs = (code) => (e) => { assert.equal(e.getStatus ? e.getStatus() : e.status, code); return true; }; + +describe('CredentialsApi', function () { + this.timeout(180000); + + let CredentialsApi; + + before(async () => { + ({ CredentialsApi } = await esmock(CREDENTIALS_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#middlewares': middlewaresMock, + '#constants': constantsMock, + '@guardian/common': commonMock, + })); + }); + + beforeEach(() => { + guardiansStub = {}; + }); + + function api() { return new CredentialsApi(makeLogger()); } + + it('getServiceSchemas returns the static schema list', async () => { + const out = await api().getServiceSchemas(makeUser()); + assert.ok(Array.isArray(out)); + }); + + it('getUserGlobalCredentials calls getCredentials(user, null)', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return [{ c: 1 }]; }; + const u = makeUser(); + const out = await api().getUserGlobalCredentials(u); + assert.deepEqual(out, [{ c: 1 }]); + assert.deepEqual(seen, [u, null]); + }); + + it('getUserGlobalCredentials maps proxy error via InternalException', async () => { + guardiansStub.getCredentials = async () => { const e = new Error('x'); e.getStatus = () => 500; throw e; }; + await assert.rejects(api().getUserGlobalCredentials(makeUser()), statusIs(500)); + }); + + it('setUserGlobalCredential calls setCredential(user, null, body)', async () => { + let seen; + guardiansStub.setCredential = async (...a) => { seen = a; return { ok: true }; }; + const u = makeUser(); + const out = await api().setUserGlobalCredential({ apiKey: 'k' }, u); + assert.deepEqual(out, { ok: true }); + assert.deepEqual(seen, [u, null, { apiKey: 'k' }]); + }); + + it('deleteUserGlobalCredential maps dryRun string "true" to boolean true', async () => { + let seen; + guardiansStub.deleteCredential = async (...a) => { seen = a; return null; }; + const u = makeUser(); + await api().deleteUserGlobalCredential('svc', 'true', u); + assert.deepEqual(seen, [u, null, 'svc', true]); + }); + + it('deleteUserGlobalCredential maps a non-"true" dryRun to boolean false', async () => { + let seen; + guardiansStub.deleteCredential = async (...a) => { seen = a; return null; }; + await api().deleteUserGlobalCredential('svc', 'false', makeUser()); + assert.equal(seen[3], false); + }); + + it('getUserPolicyCredentials forwards the policyId', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return []; }; + const u = makeUser(); + await api().getUserPolicyCredentials('pol-1', u); + assert.deepEqual(seen, [u, 'pol-1']); + }); + + it('setUserPolicyCredential forwards policyId and body', async () => { + let seen; + guardiansStub.setCredential = async (...a) => { seen = a; return { ok: 1 }; }; + const u = makeUser(); + await api().setUserPolicyCredential('pol-2', { v: 1 }, u); + assert.deepEqual(seen, [u, 'pol-2', { v: 1 }]); + }); + + it('deleteUserPolicyCredential forwards policyId, serviceType and dryRun', async () => { + let seen; + guardiansStub.deleteCredential = async (...a) => { seen = a; return null; }; + const u = makeUser(); + await api().deleteUserPolicyCredential('pol-3', 'svc', 'true', u); + assert.deepEqual(seen, [u, 'pol-3', 'svc', true]); + }); + + it('getSrGlobalCredentialsForUser returns [] when user has no parent', async () => { + guardiansStub.getCredentials = async () => { throw new Error('should not be called'); }; + const out = await api().getSrGlobalCredentialsForUser(makeUser({ parent: undefined })); + assert.deepEqual(out, []); + }); + + it('getSrGlobalCredentialsForUser calls getCredentials(user, null, parent) when parent set', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return [{ c: 2 }]; }; + const u = makeUser({ parent: 'sr-did' }); + const out = await api().getSrGlobalCredentialsForUser(u); + assert.deepEqual(out, [{ c: 2 }]); + assert.deepEqual(seen, [u, null, 'sr-did']); + }); + + it('getSrPolicyCredentialsForUser returns [] when user has no parent', async () => { + const out = await api().getSrPolicyCredentialsForUser('pol', makeUser()); + assert.deepEqual(out, []); + }); + + it('getSrPolicyCredentialsForUser calls getCredentials(user, policyId, parent)', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return []; }; + const u = makeUser({ parent: 'sr-did' }); + await api().getSrPolicyCredentialsForUser('pol-4', u); + assert.deepEqual(seen, [u, 'pol-4', 'sr-did']); + }); + + it('getSrGlobalCredentials calls getCredentials(user, null)', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return ['g']; }; + const u = makeUser(); + const out = await api().getSrGlobalCredentials(u); + assert.deepEqual(out, ['g']); + assert.deepEqual(seen, [u, null]); + }); + + it('setSrGlobalCredential calls setCredential(user, null, body)', async () => { + let seen; + guardiansStub.setCredential = async (...a) => { seen = a; return {}; }; + const u = makeUser(); + await api().setSrGlobalCredential({ s: 1 }, u); + assert.deepEqual(seen, [u, null, { s: 1 }]); + }); + + it('deleteSrGlobalCredential maps dryRun and forwards serviceType', async () => { + let seen; + guardiansStub.deleteCredential = async (...a) => { seen = a; return null; }; + const u = makeUser(); + await api().deleteSrGlobalCredential('svc', 'true', u); + assert.deepEqual(seen, [u, null, 'svc', true]); + }); + + it('getSrPolicyCredentials forwards policyId', async () => { + let seen; + guardiansStub.getCredentials = async (...a) => { seen = a; return []; }; + const u = makeUser(); + await api().getSrPolicyCredentials('pol-5', u); + assert.deepEqual(seen, [u, 'pol-5']); + }); + + it('setSrPolicyCredential forwards policyId and body', async () => { + let seen; + guardiansStub.setCredential = async (...a) => { seen = a; return {}; }; + const u = makeUser(); + await api().setSrPolicyCredential('pol-6', { b: 2 }, u); + assert.deepEqual(seen, [u, 'pol-6', { b: 2 }]); + }); + + it('deleteSrPolicyCredential forwards policyId, serviceType, dryRun', async () => { + let seen; + guardiansStub.deleteCredential = async (...a) => { seen = a; return null; }; + const u = makeUser(); + await api().deleteSrPolicyCredential('pol-7', 'svc', 'no', u); + assert.deepEqual(seen, [u, 'pol-7', 'svc', false]); + }); + + it('setSrPolicyCredential maps proxy error via InternalException', async () => { + guardiansStub.setCredential = async () => { const e = new Error('x'); e.getStatus = () => 503; throw e; }; + await assert.rejects(api().setSrPolicyCredential('p', {}, makeUser()), statusIs(503)); + }); +}); + +describe('IpfsApi', function () { + this.timeout(180000); + + let IpfsApi; + + before(async () => { + ({ IpfsApi } = await esmock(IPFS_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#middlewares': middlewaresMock, + '#constants': constantsMock, + '@guardian/common': commonMock, + })); + }); + + beforeEach(() => { + guardiansStub = {}; + }); + + function api(cache) { return new IpfsApi(cache || makeCache(), makeLogger()); } + function makeReq(over = {}) { return { url: '/api/v1/ipfs/file', user: { id: 'user-1' }, ...over }; } + + it('postFile throws 422 when the body is empty', async () => { + await assert.rejects(api().postFile({}, makeUser(), makeReq()), statusIs(422)); + }); + + it('postFile uploads the body and returns the cid as JSON', async () => { + let seen; + guardiansStub.addFileIpfs = async (...a) => { seen = a; return { cid: 'CID1' }; }; + const u = makeUser(); + const body = { 0: 1, 1: 2 }; + const out = await api().postFile(body, u, makeReq()); + assert.equal(out, JSON.stringify('CID1')); + assert.deepEqual(seen, [u, body]); + }); + + it('postFile invalidates the cache after upload', async () => { + guardiansStub.addFileIpfs = async () => ({ cid: 'CID1' }); + const cache = makeCache(); + await api(cache).postFile({ 0: 1 }, makeUser(), makeReq()); + assert.equal(cache.calls.length, 1); + }); + + it('postFile throws 400 when no cid is returned', async () => { + guardiansStub.addFileIpfs = async () => ({ cid: null }); + await assert.rejects(api().postFile({ 0: 1 }, makeUser(), makeReq()), statusIs(400)); + }); + + it('postFileDirect uploads via addFileIpfsDirect', async () => { + let seen; + guardiansStub.addFileIpfsDirect = async (...a) => { seen = a; return { cid: 'CID2' }; }; + const u = makeUser(); + const out = await api().postFileDirect({ 0: 9 }, u, makeReq()); + assert.equal(out, JSON.stringify('CID2')); + assert.deepEqual(seen, [u, { 0: 9 }]); + }); + + it('postFileDirect throws 422 on empty body', async () => { + await assert.rejects(api().postFileDirect({}, makeUser(), makeReq()), statusIs(422)); + }); + + it('postFileDirect throws 400 when no cid is returned', async () => { + guardiansStub.addFileIpfsDirect = async () => ({ cid: undefined }); + await assert.rejects(api().postFileDirect({ 0: 1 }, makeUser(), makeReq()), statusIs(400)); + }); + + it('postFileDryRun forwards body and policyId to dry-run storage', async () => { + let seen; + guardiansStub.addFileToDryRunStorage = async (...a) => { seen = a; return { cid: 'DRY' }; }; + const u = makeUser(); + const out = await api().postFileDryRun('pol-1', { 0: 1 }, u, makeReq()); + assert.equal(out, JSON.stringify('DRY')); + assert.deepEqual(seen, [u, { 0: 1 }, 'pol-1']); + }); + + it('postFileDryRun throws 422 on empty body', async () => { + await assert.rejects(api().postFileDryRun('pol-1', {}, makeUser(), makeReq()), statusIs(422)); + }); + + it('getFile returns a StreamableFile when proxy returns a Buffer payload', async () => { + let seen; + guardiansStub.getFileIpfs = async (...a) => { seen = a; return { type: 'Buffer', data: [1, 2, 3] }; }; + const u = makeUser(); + const out = await api().getFile(u, 'cid-x'); + assert.ok(out); + assert.deepEqual(seen, [u, 'cid-x', 'raw']); + }); + + it('getFile throws 404 when the payload is not a Buffer', async () => { + guardiansStub.getFileIpfs = async () => ({ type: 'NotBuffer' }); + await assert.rejects(api().getFile(makeUser(), 'cid-x'), statusIs(404)); + }); + + it('getFileDryRun returns a StreamableFile for a Buffer payload', async () => { + let seen; + guardiansStub.getFileFromDryRunStorage = async (...a) => { seen = a; return { type: 'Buffer', data: [1] }; }; + const u = makeUser(); + const out = await api().getFileDryRun(u, 'cid-d'); + assert.ok(out); + assert.deepEqual(seen, [u, 'cid-d', 'raw']); + }); + + it('getFileDryRun throws 404 when the payload is not a Buffer', async () => { + guardiansStub.getFileFromDryRunStorage = async () => ({ type: 'x' }); + await assert.rejects(api().getFileDryRun(makeUser(), 'cid-d'), statusIs(404)); + }); + + it('deleteFile calls deleteIpfsCid and invalidates the cache', async () => { + let seen; + guardiansStub.deleteIpfsCid = async (...a) => { seen = a; }; + const u = makeUser(); + const cache = makeCache(); + await api(cache).deleteFile('cid-del', u, makeReq()); + assert.deepEqual(seen, [u, 'cid-del']); + assert.equal(cache.calls.length, 1); + }); + + it('deleteFile maps proxy errors via InternalException', async () => { + guardiansStub.deleteIpfsCid = async () => { const e = new Error('x'); e.getStatus = () => 500; throw e; }; + await assert.rejects(api().deleteFile('cid', makeUser(), makeReq()), statusIs(500)); + }); +}); + +describe('SuggestionsApi', function () { + this.timeout(180000); + + let SuggestionsApi; + + before(async () => { + ({ SuggestionsApi } = await esmock(SUGGESTIONS_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#middlewares': middlewaresMock, + '@guardian/common': commonMock, + })); + }); + + beforeEach(() => { + guardiansStub = {}; + }); + + function api() { return new SuggestionsApi(); } + + it('policySuggestions forwards body and user to the proxy', async () => { + let seen; + guardiansStub.policySuggestions = async (...a) => { seen = a; return { next: 'n', nested: 'm' }; }; + const u = makeUser(); + const body = { blockType: 'interfaceContainerBlock' }; + const out = await api().policySuggestions(u, body); + assert.deepEqual(out, { next: 'n', nested: 'm' }); + assert.deepEqual(seen, [body, u]); + }); + + it('policySuggestions propagates proxy errors (no InternalException wrapper)', async () => { + guardiansStub.policySuggestions = async () => { throw new Error('boom'); }; + await assert.rejects(api().policySuggestions(makeUser(), {}), /boom/); + }); + + it('setPolicySuggestionsConfig forwards body.items and wraps the result in { items }', async () => { + let seen; + guardiansStub.setPolicySuggestionsConfig = async (...a) => { seen = a; return [{ id: '1' }]; }; + const u = makeUser(); + const out = await api().setPolicySuggestionsConfig(u, { items: [{ id: '1', type: 'block', index: 0 }] }); + assert.deepEqual(out, { items: [{ id: '1' }] }); + assert.deepEqual(seen, [[{ id: '1', type: 'block', index: 0 }], u]); + }); + + it('getPolicySuggestionsConfig wraps the proxy result in { items }', async () => { + let seen; + guardiansStub.getPolicySuggestionsConfig = async (...a) => { seen = a; return [{ id: '2' }]; }; + const u = makeUser(); + const out = await api().getPolicySuggestionsConfig(u); + assert.deepEqual(out, { items: [{ id: '2' }] }); + assert.deepEqual(seen, [u]); + }); +}); diff --git a/api-gateway/tests/service/external-policy-controller.test.mjs b/api-gateway/tests/service/external-policy-controller.test.mjs new file mode 100644 index 0000000000..f7d66c1491 --- /dev/null +++ b/api-gateway/tests/service/external-policy-controller.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const EP_DIST = '../../dist/api/service/external-policy.js'; +const middlewaresMock = new Proxy({}, { get: (t, p) => p === 'Examples' ? new Proxy({}, { get: () => 'ex' }) : (p === 'pageHeader' ? {} : class {}) }); + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:u', tenantContext: { tenantId: 't1' }, ...extra }; +} +function makeRes() { + const calls = {}; + const res = { + header: (k, v) => { calls.header = { k, v }; return { send: (b) => { calls.sent = b; return calls; } }; }, + send: (b) => { calls.sent = b; return calls; }, + }; + return { res, calls }; +} +const is422 = (e) => { assert.equal(e.getStatus(), 422); return true; }; +const is404 = (e) => { assert.equal(e.getStatus(), 404); return true; }; + +describe('ExternalPoliciesApi', function () { + this.timeout(60000); + let ExternalPoliciesApi; + let guardiansImpl; + let engineImpl; + let userPermissionsHas; + let runRan; + + before(async () => { + ({ ExternalPoliciesApi } = await esmock(EP_DIST, { + '@guardian/common': { PinoLogger: class {}, RunFunctionAsync: (fn) => { runRan = true; fn(); } }, + '@guardian/interfaces': { + LocationType: new Proxy({}, { get: () => 'l' }), + Permissions: new Proxy({}, { get: () => 'p' }), + TaskAction: new Proxy({}, { get: () => 'a' }), + UserPermissions: { has: (...a) => userPermissionsHas(...a) }, + }, + '#middlewares': middlewaresMock, + '#helpers': { + Guardians: class { + constructor(tc) { this.tc = tc; } + async groupExternalPolicyRequests(...a) { return guardiansImpl.groupExternalPolicyRequests(...a); } + async previewExternalPolicy(...a) { return guardiansImpl.previewExternalPolicy(...a); } + async importExternalPolicy(...a) { return guardiansImpl.importExternalPolicy(...a); } + async getExternalPolicyRequest(...a) { return guardiansImpl.getExternalPolicyRequest(...a); } + async approveExternalPolicyAsync(...a) { return guardiansImpl.approveExternalPolicyAsync(...a); } + async rejectExternalPolicyAsync(...a) { return guardiansImpl.rejectExternalPolicyAsync(...a); } + async approveExternalPolicy(...a) { return guardiansImpl.approveExternalPolicy(...a); } + async rejectExternalPolicy(...a) { return guardiansImpl.rejectExternalPolicy(...a); } + async disconnectPolicy(...a) { return guardiansImpl.disconnectPolicy(...a); } + async deletePolicy(...a) { return guardiansImpl.deletePolicy(...a); } + }, + InternalException: async (e) => { throw e; }, + EntityOwner: class { constructor(user) { this.owner = user?.did; } }, + TaskManager: class { start() { return { taskId: 't1' }; } addError() {} }, + PolicyEngine: class { + constructor(tc) { this.tc = tc; } + async getRemoteRequests(...a) { return engineImpl.getRemoteRequests(...a); } + async approveRemoteRequest(...a) { return engineImpl.approveRemoteRequest(...a); } + async rejectRemoteRequest(...a) { return engineImpl.rejectRemoteRequest(...a); } + async cancelRemoteRequest(...a) { return engineImpl.cancelRemoteRequest(...a); } + async loadRemoteRequest(...a) { return engineImpl.loadRemoteRequest(...a); } + async getRemoteRequestsCount(...a) { return engineImpl.getRemoteRequestsCount(...a); } + async getRequestDocument(...a) { return engineImpl.getRequestDocument(...a); } + }, + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined, AuthAndLocation: () => () => undefined }, + })); + }); + + function makeApi() { userPermissionsHas = () => false; runRan = false; return new ExternalPoliciesApi({ error: () => undefined }); } + + it('getExternalPolicies sends grouped requests with the count header', async () => { + guardiansImpl = { groupExternalPolicyRequests: async () => ({ items: [{ id: 'a' }], count: 2 }) }; + const { res, calls } = makeRes(); + await makeApi().getExternalPolicies(makeUser(), res, 0, 20); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 2 }); + assert.deepEqual(calls.sent, [{ id: 'a' }]); + }); + + it('getExternalPolicies passes the full flag based on UPDATE permission', async () => { + let seenOptions; + guardiansImpl = { groupExternalPolicyRequests: async (options) => { seenOptions = options; return { items: [], count: 0 }; } }; + const api = makeApi(); + userPermissionsHas = () => true; + const { res } = makeRes(); + await api.getExternalPolicies(makeUser(), res, 1, 10); + assert.equal(seenOptions.full, true); + assert.equal(seenOptions.pageIndex, 1); + }); + + it('previewExternalPolicy throws 422 when messageId is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().previewExternalPolicy(makeUser(), {}), is422); + }); + + it('previewExternalPolicy forwards the messageId', async () => { + let seen; + guardiansImpl = { previewExternalPolicy: async (id) => { seen = id; return { preview: true }; } }; + const out = await makeApi().previewExternalPolicy(makeUser(), { messageId: 'm1' }); + assert.deepEqual(out, { preview: true }); + assert.equal(seen, 'm1'); + }); + + it('importExternalPolicy throws 422 without a messageId', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().importExternalPolicy(makeUser(), {}), is422); + }); + + it('importExternalPolicy imports by messageId', async () => { + guardiansImpl = { importExternalPolicy: async (id) => ({ imported: id }) }; + assert.deepEqual(await makeApi().importExternalPolicy(makeUser(), { messageId: 'm2' }), { imported: 'm2' }); + }); + + it('approveExternalPolicyAsync throws 422 with no id', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().approveExternalPolicyAsync(makeUser(), ''), is422); + }); + + it('approveExternalPolicyAsync throws 404 when the item is missing', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => null }; + await assert.rejects(makeApi().approveExternalPolicyAsync(makeUser(), 'm1'), is404); + }); + + it('approveExternalPolicyAsync starts a task when the item exists', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => ({ id: 'x' }), approveExternalPolicyAsync: async () => undefined }; + const out = await makeApi().approveExternalPolicyAsync(makeUser(), 'm1'); + assert.equal(out.taskId, 't1'); + assert.equal(runRan, true); + }); + + it('rejectExternalPolicyAsync throws 404 when the item is missing', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => null }; + await assert.rejects(makeApi().rejectExternalPolicyAsync(makeUser(), 'm1'), is404); + }); + + it('rejectExternalPolicyAsync starts a task when the item exists', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => ({ id: 'x' }), rejectExternalPolicyAsync: async () => undefined }; + const out = await makeApi().rejectExternalPolicyAsync(makeUser(), 'm1'); + assert.equal(out.taskId, 't1'); + }); + + it('approveExternalPolicy throws 404 when the item is missing', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => null }; + await assert.rejects(makeApi().approveExternalPolicy(makeUser(), 'm1'), is404); + }); + + it('approveExternalPolicy approves an existing item', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => ({ id: 'x' }), approveExternalPolicy: async () => 'approved' }; + assert.equal(await makeApi().approveExternalPolicy(makeUser(), 'm1'), 'approved'); + }); + + it('rejectExternalPolicy rejects an existing item', async () => { + guardiansImpl = { getExternalPolicyRequest: async () => ({ id: 'x' }), rejectExternalPolicy: async () => 'rejected' }; + assert.equal(await makeApi().rejectExternalPolicy(makeUser(), 'm1'), 'rejected'); + }); + + it('disconnectPolicy coerces the full string flag to a boolean', async () => { + let seenFull; + guardiansImpl = { disconnectPolicy: async (id, full) => { seenFull = full; return true; } }; + await makeApi().disconnectPolicy(makeUser(), 'm1', 'true'); + assert.equal(seenFull, true); + }); + + it('disconnectPolicy passes false for a non-true flag', async () => { + let seenFull; + guardiansImpl = { disconnectPolicy: async (id, full) => { seenFull = full; return true; } }; + await makeApi().disconnectPolicy(makeUser(), 'm1', 'no'); + assert.equal(seenFull, false); + }); + + it('deletePolicy deletes by messageId', async () => { + let seen; + guardiansImpl = { deletePolicy: async (id) => { seen = id; return true; } }; + await makeApi().deletePolicy(makeUser(), 'm9'); + assert.equal(seen, 'm9'); + }); + + it('getRemoteRequests builds filters and sends items with count', async () => { + let seenOptions; + engineImpl = { getRemoteRequests: async (options) => { seenOptions = options; return { items: ['r'], count: 1 }; } }; + const { res, calls } = makeRes(); + await makeApi().getRemoteRequests(makeUser(), res, 0, 20, 'pol', 'NEW', 'TypeX'); + assert.deepEqual(seenOptions.filters, { policyId: 'pol', status: 'NEW', type: 'TypeX' }); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 1 }); + }); + + it('approveRemoteRequest throws 422 without an id', async () => { + engineImpl = {}; + await assert.rejects(makeApi().approveRemoteRequest(makeUser(), ''), is422); + }); + + it('approveRemoteRequest approves via the engine', async () => { + engineImpl = { approveRemoteRequest: async (id) => ({ approved: id }) }; + assert.deepEqual(await makeApi().approveRemoteRequest(makeUser(), 'm1'), { approved: 'm1' }); + }); + + it('rejectRemoteRequest rejects via the engine', async () => { + engineImpl = { rejectRemoteRequest: async (id) => ({ rejected: id }) }; + assert.deepEqual(await makeApi().rejectRemoteRequest(makeUser(), 'm1'), { rejected: 'm1' }); + }); + + it('cancelRemoteRequest cancels via the engine', async () => { + engineImpl = { cancelRemoteRequest: async (id) => ({ cancelled: id }) }; + assert.deepEqual(await makeApi().cancelRemoteRequest(makeUser(), 'm1'), { cancelled: 'm1' }); + }); + + it('loadRemoteRequest throws 422 without an id', async () => { + engineImpl = {}; + await assert.rejects(makeApi().loadRemoteRequest(makeUser(), ''), is422); + }); + + it('loadRemoteRequest loads via the engine', async () => { + engineImpl = { loadRemoteRequest: async (id) => ({ loaded: id }) }; + assert.deepEqual(await makeApi().loadRemoteRequest(makeUser(), 'm1'), { loaded: 'm1' }); + }); + + it('getRemoteRequestsCount sends the engine count result', async () => { + engineImpl = { getRemoteRequestsCount: async () => ({ count: 5 }) }; + const { res, calls } = makeRes(); + await makeApi().getRemoteRequestsCount(makeUser(), res, 'pol'); + assert.deepEqual(calls.sent, { count: 5 }); + }); + + it('getRequestDocument sends the document result', async () => { + engineImpl = { getRequestDocument: async () => ({ doc: 1 }) }; + const { res, calls } = makeRes(); + await makeApi().getRequestDocument(makeUser(), res, 'start-1'); + assert.deepEqual(calls.sent, { doc: 1 }); + }); +}); diff --git a/api-gateway/tests/service/formulas.test.mjs b/api-gateway/tests/service/formulas.test.mjs new file mode 100644 index 0000000000..0767cd6f56 --- /dev/null +++ b/api-gateway/tests/service/formulas.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeLogger, FakeEntityOwner, + internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/formulas.js'; + +let guardiansStub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } + createFormula(...a) { return guardiansStub.createFormula(...a); } + getFormulas(...a) { return guardiansStub.getFormulas(...a); } + getFormulaById(...a) { return guardiansStub.getFormulaById(...a); } + updateFormula(...a) { return guardiansStub.updateFormula(...a); } + deleteFormula(...a) { return guardiansStub.deleteFormula(...a); } + getFormulaRelationships(...a) { return guardiansStub.getFormulaRelationships(...a); } + importFormula(...a) { return guardiansStub.importFormula(...a); } + exportFormula(...a) { return guardiansStub.exportFormula(...a); } + previewFormula(...a) { return guardiansStub.previewFormula(...a); } + draftFormula(...a) { return guardiansStub.draftFormula(...a); } + dryRunFormula(...a) { return guardiansStub.dryRunFormula(...a); } + publishFormula(...a) { return guardiansStub.publishFormula(...a); } + getFormulasData(...a) { return guardiansStub.getFormulasData(...a); } +} + +async function load() { + return loadController(DIST, { + '#helpers': { + Guardians: FakeGuardians, + EntityOwner: FakeEntityOwner, + InternalException: internalExceptionRethrow + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {} }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(FormulasApi) { return new FormulasApi(makeLogger()); } + +describe('FormulasApi controller logic', function () { + this.timeout(60000); + let FormulasApi; + before(async () => { ({ FormulasApi } = await load()); }); + + beforeEach(() => { + guardiansStub = { + createFormula: async (formula, owner) => ({ created: true, formula, owner }), + getFormulas: async () => ({ items: [{ id: 'f1' }], count: 3 }), + getFormulaById: async () => ({ id: 'f1' }), + updateFormula: async () => ({ updated: true }), + deleteFormula: async () => ({ deleted: true }), + getFormulaRelationships: async () => ({ rel: true }), + importFormula: async () => ({ imported: true }), + exportFormula: async () => Buffer.from('zip'), + previewFormula: async () => ({ preview: true }), + draftFormula: async () => ({ draft: true }), + dryRunFormula: async () => ({ dry: true }), + publishFormula: async () => ({ published: true }), + getFormulasData: async () => ({ data: true }) + }; + }); + + it('createFormula throws 422 when formula missing', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.createFormula(makeUser(), null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createFormula delegates with owner', async () => { + const api = makeApi(FormulasApi); + const out = await api.createFormula(makeUser(), { name: 'F' }); + assert.equal(out.created, true); + assert.ok(out.owner instanceof FakeEntityOwner); + }); + + it('getFormulas writes X-Total-Count and sends items', async () => { + const api = makeApi(FormulasApi); + const res = makeRes(); + const out = await api.getFormulas(makeUser(), res, 0, 20, 'pol1'); + assert.equal(res.headers['X-Total-Count'], 3); + assert.deepEqual(out.payload, [{ id: 'f1' }]); + }); + + it('getFormulas passes paging+policyId to guardians', async () => { + let seen; + guardiansStub.getFormulas = async (opts) => { seen = opts; return { items: [], count: 0 }; }; + const api = makeApi(FormulasApi); + await api.getFormulas(makeUser(), makeRes(), 1, 50, 'pol9'); + assert.deepEqual(seen, { policyId: 'pol9', pageIndex: 1, pageSize: 50 }); + }); + + it('getFormulaById throws 422 without id', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.getFormulaById(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getFormulaById delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.getFormulaById(makeUser(), 'f1'), { id: 'f1' }); + }); + + it('updateFormula throws 422 without id', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.updateFormula(makeUser(), '', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('updateFormula throws 404 when oldItem missing', async () => { + guardiansStub.getFormulaById = async () => null; + const api = makeApi(FormulasApi); + await assert.rejects(api.updateFormula(makeUser(), 'f1', {}), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('updateFormula delegates when found', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.updateFormula(makeUser(), 'f1', { x: 1 }), { updated: true }); + }); + + it('deleteFormula throws 422 without id', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.deleteFormula(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deleteFormula delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.deleteFormula(makeUser(), 'f1'), { deleted: true }); + }); + + it('getSchemaRuleRelationships throws 422 without id', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.getSchemaRuleRelationships(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getSchemaRuleRelationships delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.getSchemaRuleRelationships(makeUser(), 'f1'), { rel: true }); + }); + + it('importFormula delegates with zip+policyId+owner', async () => { + let seen; + guardiansStub.importFormula = async (zip, policyId, owner) => { seen = { zip, policyId, owner }; return { ok: 1 }; }; + const api = makeApi(FormulasApi); + await api.importFormula(makeUser(), 'pol1', { buffer: 1 }); + assert.equal(seen.policyId, 'pol1'); + assert.ok(seen.owner instanceof FakeEntityOwner); + }); + + it('exportFormula sets zip headers and sends file', async () => { + const api = makeApi(FormulasApi); + const res = makeRes(); + await api.exportFormula(makeUser(), 'f1', res); + assert.equal(res.headers['Content-type'], 'application/zip'); + assert.match(res.headers['Content-disposition'], /attachment; filename=theme_/); + }); + + it('previewFormula delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.previewFormula(makeUser(), { a: 1 }), { preview: true }); + }); + + it('draftFormula throws 422 without id', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.draftFormula(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('draftFormula throws 404 when not found', async () => { + guardiansStub.getFormulaById = async () => null; + const api = makeApi(FormulasApi); + await assert.rejects(api.draftFormula(makeUser(), 'f1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('draftFormula delegates when found', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.draftFormula(makeUser(), 'f1'), { draft: true }); + }); + + it('dryRunFormula throws 404 when not found', async () => { + guardiansStub.getFormulaById = async () => null; + const api = makeApi(FormulasApi); + await assert.rejects(api.dryRunFormula(makeUser(), 'f1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('dryRunFormula delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.dryRunFormula(makeUser(), 'f1'), { dry: true }); + }); + + it('publishFormula throws 404 when not found', async () => { + guardiansStub.getFormulaById = async () => null; + const api = makeApi(FormulasApi); + await assert.rejects(api.publishFormula(makeUser(), 'f1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('publishFormula delegates', async () => { + const api = makeApi(FormulasApi); + assert.deepEqual(await api.publishFormula(makeUser(), 'f1'), { published: true }); + }); + + it('getSchemaRuleData throws 422 without options', async () => { + const api = makeApi(FormulasApi); + await assert.rejects(api.getSchemaRuleData(makeUser(), null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getSchemaRuleData returns null without execute/manage permission', async () => { + const api = makeApi(FormulasApi); + const out = await api.getSchemaRuleData(makeUser({ permissions: [] }), { x: 1 }); + assert.equal(out, null); + }); + + it('getSchemaRuleData delegates when permitted', async () => { + const api = makeApi(FormulasApi); + const out = await api.getSchemaRuleData(makeUser({ permissions: ['POLICIES_POLICY_EXECUTE'] }), { x: 1 }); + assert.deepEqual(out, { data: true }); + }); +}); diff --git a/api-gateway/tests/service/module-tool-tags-themes-controllers.test.mjs b/api-gateway/tests/service/module-tool-tags-themes-controllers.test.mjs new file mode 100644 index 0000000000..45a52c0fd9 --- /dev/null +++ b/api-gateway/tests/service/module-tool-tags-themes-controllers.test.mjs @@ -0,0 +1,767 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const MODULE_DIST = '../../dist/api/service/module.js'; +const TOOL_DIST = '../../dist/api/service/tool.js'; +const TAGS_DIST = '../../dist/api/service/tags.js'; +const THEMES_DIST = '../../dist/api/service/themes.js'; + +let guardiansStub; +let taskManagerStub; + +class MockGuardians { + constructor(tenantContext) { + this.tenantContext = tenantContext; + MockGuardians.lastTenantContext = tenantContext; + } +} +const guardianMethods = [ + 'createModule', 'getModule', 'getModuleV2', 'getSchemasByOwner', 'createSchema', + 'deleteModule', 'getMenuModule', 'getModuleById', 'updateModule', 'exportModuleMessage', + 'importModuleMessage', 'previewModuleMessage', 'publishModule', 'validateModule', + 'createTool', 'getTools', 'getToolsV2', 'deleteTool', 'getToolById', 'updateTool', + 'publishTool', 'dryRunTool', 'draftTool', 'validateTool', 'exportToolMessage', + 'previewToolMessage', 'importToolMessage', 'getMenuTool', 'checkTool', + 'createTag', 'getTags', 'getTagCache', 'synchronizationTags', 'deleteTag', + 'getTagSchemas', 'getTagSchemasV2', 'createTagSchema', 'getSchemaById', 'deleteSchema', + 'updateSchema', 'publishTagSchema', 'getPublishedTagSchemas', + 'createTheme', 'getThemeById', 'updateTheme', 'deleteTheme', 'getThemes', 'importThemeFile', +]; +for (const m of guardianMethods) { + MockGuardians.prototype[m] = function (...args) { return guardiansStub[m](...args); }; +} + +class MockTaskManager { + start(...args) { return taskManagerStub.start(...args); } + addError(...args) { return taskManagerStub.addError(...args); } +} + +const schemaUtilsStub = { + toOld: (x) => x, + fromOld: () => undefined, + clearIds: () => undefined, + checkPermission: () => null, +}; + +const helpersMock = { + Guardians: MockGuardians, + EntityOwner: class { constructor(user) { this.user = user; this.owner = user?.did; this.creator = user?.did; } }, + InternalException: async (error) => { throw error; }, + CacheService: class {}, + getCacheKey: (tags) => `cache:${tags.join('|')}`, + UseCache: () => () => undefined, + ONLY_SR: ' Only SR.', + TaskManager: MockTaskManager, + ServiceError: class extends Error {}, + SchemaUtils: schemaUtilsStub, + RunFunctionAsync: undefined, + MultipartFile: class {}, + UploadedFiles: () => () => undefined, + AnyFilesInterceptor: () => class {}, +}; + +const commonMock = { + PinoLogger: class {}, + RunFunctionAsync: (fn) => { fn(); }, +}; + +function loadController(dist) { + return esmock(dist, { + '#helpers': helpersMock, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined, checkPermission: () => () => undefined }, + '#constants': { + PREFIXES: { MODULES: 'modules/', SCHEMES: 'schemas/', TAGS: 'tags/', THEMES: 'themes/' }, + CACHE_TAG: { MODULE: 'MODULE' }, + CACHE_PREFIXES: { TAG: 'tag' }, + MODULE_REQUIRED_PROPS: { a: 'name', b: 'uuid' }, + TOOL_REQUIRED_PROPS: { a: 'name', b: 'uuid' }, + SCHEMA_REQUIRED_PROPS: { a: 'name', b: 'uuid' }, + }, + '#middlewares': { + ModuleDTO: class {}, ExportMessageDTO: class {}, ImportMessageDTO: class {}, + ModuleImportFileResponseDTO: class {}, ModulePublishResponseDTO: class {}, + ModulePreviewDTO: class {}, SchemaDTO: class {}, ModuleValidationDTO: class {}, + Examples: {}, pageHeader: {}, InternalServerErrorDTO: class {}, + ObjectExamples: {}, UnprocessableEntityErrorDTO: class {}, Response451_DTO: class {}, + CreateToolDTO: class {}, TaskDTO: class {}, ToolDTO: class {}, + ToolDryRunResponseDTO: class {}, ToolExportMessageDTO: class {}, + ToolImportResponseDTO: class {}, ToolListV1ItemDTO: class {}, + ToolListV2ItemDTO: class {}, ToolMenuItemDTO: class {}, ToolPreviewDTO: class {}, + ToolPublishResponseDTO: class {}, ToolValidationDTO: class {}, ToolVersionDTO: class {}, + ForbiddenErrorDTO: class {}, TagDTO: class {}, TagFilterDTO: class {}, + TagMapDTO: class {}, ThemeDTO: class {}, + }, + '@guardian/common': commonMock, + }); +} + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:hedera:user', tenantContext: { tenantId: 't1' }, ...extra }; +} + +function makeRes() { + const headers = {}; + const res = { + header(k, v) { headers[k] = v; return res; }, + send(payload) { res.sent = payload; return payload; }, + headers, + }; + return res; +} + +function makeApi(ApiClass) { + const invalidated = []; + const cacheService = { + invalidate: async (key) => { invalidated.push(key); }, + invalidateAllTagsByPrefixes: async (prefixes) => { invalidated.push(prefixes); }, + }; + const logger = { error: () => undefined }; + const api = new ApiClass(cacheService, logger); + api.__invalidated = invalidated; + return api; +} + +let ModulesApi, ToolsApi, TagsApi, ThemesApi; + +before(async function () { + this.timeout(300000); + ({ ModulesApi } = await loadController(MODULE_DIST)); + ({ ToolsApi } = await loadController(TOOL_DIST)); + ({ TagsApi } = await loadController(TAGS_DIST)); + ({ ThemesApi } = await loadController(THEMES_DIST)); +}); + +beforeEach(() => { + guardiansStub = {}; + taskManagerStub = { + start: () => ({ taskId: 'task-1' }), + addError: () => undefined, + }; +}); + +describe('ModulesApi', function () { + this.timeout(300000); + + it('postModules forwards config to createModule with EntityOwner', async () => { + let seen; + guardiansStub.createModule = async (module, owner) => { seen = { module, owner }; return { id: 'm1' }; }; + const api = makeApi(ModulesApi); + const body = { config: { blockType: 'module' }, name: 'M' }; + const res = await api.postModules(makeUser(), body, { url: '/api/v1/modules' }); + assert.deepEqual(res, { id: 'm1' }); + assert.equal(seen.module, body); + assert.equal(seen.owner.user.id, 'user-1'); + }); + + it('postModules rejects invalid config with 422', async () => { + const api = makeApi(ModulesApi); + await assert.rejects( + api.postModules(makeUser(), { config: { blockType: 'x' } }, { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('postModules maps proxy error via InternalException', async () => { + guardiansStub.createModule = async () => { throw new Error('boom'); }; + const api = makeApi(ModulesApi); + await assert.rejects( + api.postModules(makeUser(), { config: { blockType: 'module' } }, { url: '/u' }), + /boom/ + ); + }); + + it('getModules passes pagination to getModule and sets X-Total-Count', async () => { + let seen; + guardiansStub.getModule = async (options, owner) => { seen = { options, owner }; return { items: [1, 2], count: 7 }; }; + const api = makeApi(ModulesApi); + const res = makeRes(); + await api.getModules(makeUser(), res, 1, 20); + assert.deepEqual(seen.options, { pageIndex: 1, pageSize: 20 }); + assert.equal(res.headers['X-Total-Count'], 7); + assert.deepEqual(res.sent, [1, 2]); + }); + + it('getModules maps error via InternalException', async () => { + guardiansStub.getModule = async () => { throw new Error('listfail'); }; + const api = makeApi(ModulesApi); + await assert.rejects(api.getModules(makeUser(), makeRes(), 0, 10), /listfail/); + }); + + it('getModulesV2 sends fields plus pagination to getModuleV2', async () => { + let seen; + guardiansStub.getModuleV2 = async (options, owner) => { seen = options; return { items: [], count: 0 }; }; + const api = makeApi(ModulesApi); + await api.getModulesV2(makeUser(), makeRes(), 2, 5); + assert.deepEqual(seen.fields, ['name', 'uuid']); + assert.equal(seen.pageIndex, 2); + assert.equal(seen.pageSize, 5); + }); + + it('deleteModule forwards uuid + owner to deleteModule', async () => { + let seen; + guardiansStub.deleteModule = async (uuid, owner) => { seen = { uuid, owner }; return true; }; + const api = makeApi(ModulesApi); + const res = await api.deleteModule(makeUser(), 'uuid-1', { url: '/u', params: { uuid: 'uuid-1' } }); + assert.equal(res, true); + assert.equal(seen.uuid, 'uuid-1'); + assert.equal(seen.owner.user.id, 'user-1'); + }); + + it('deleteModule throws on empty uuid (mapped via InternalException)', async () => { + const api = makeApi(ModulesApi); + await assert.rejects( + api.deleteModule(makeUser(), '', { url: '/u', params: {} }), + /Invalid uuid/ + ); + }); + + it('getMenu forwards EntityOwner to getMenuModule', async () => { + let seen; + guardiansStub.getMenuModule = async (owner) => { seen = owner; return [{ id: 'menu' }]; }; + const api = makeApi(ModulesApi); + const res = await api.getMenu(makeUser()); + assert.deepEqual(res, [{ id: 'menu' }]); + assert.equal(seen.user.id, 'user-1'); + }); + + it('getModule forwards uuid to getModuleById', async () => { + let seen; + guardiansStub.getModuleById = async (uuid, owner) => { seen = { uuid, owner }; return { id: 'm' }; }; + const api = makeApi(ModulesApi); + const res = await api.getModule(makeUser(), 'uuid-9'); + assert.deepEqual(res, { id: 'm' }); + assert.equal(seen.uuid, 'uuid-9'); + }); + + it('getModule rejects empty uuid with 422', async () => { + const api = makeApi(ModulesApi); + await assert.rejects( + api.getModule(makeUser(), ''), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('putModule forwards uuid, body and owner to updateModule', async () => { + let seen; + guardiansStub.updateModule = async (uuid, module, owner) => { seen = { uuid, module, owner }; return { id: 'u' }; }; + const api = makeApi(ModulesApi); + const body = { config: { blockType: 'module' } }; + const res = await api.putModule(makeUser(), 'uuid-2', body, { url: '/u', params: { uuid: 'uuid-2' } }); + assert.deepEqual(res, { id: 'u' }); + assert.equal(seen.uuid, 'uuid-2'); + assert.equal(seen.module, body); + }); + + it('putModule throws 422 for invalid config (outside try)', async () => { + const api = makeApi(ModulesApi); + await assert.rejects( + api.putModule(makeUser(), 'uuid-2', { config: { blockType: 'x' } }, { url: '/u', params: {} }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('moduleExportMessage forwards uuid to exportModuleMessage', async () => { + let seen; + guardiansStub.exportModuleMessage = async (uuid, owner) => { seen = uuid; return { messageId: 'mid' }; }; + const api = makeApi(ModulesApi); + const res = await api.moduleExportMessage(makeUser(), 'uuid-3'); + assert.deepEqual(res, { messageId: 'mid' }); + assert.equal(seen, 'uuid-3'); + }); + + it('moduleImportMessage forwards messageId to importModuleMessage', async () => { + let seen; + guardiansStub.importModuleMessage = async (messageId, owner) => { seen = messageId; return { id: 'imported' }; }; + const api = makeApi(ModulesApi); + const res = await api.moduleImportMessage(makeUser(), { messageId: 'msg-1' }, { url: '/u' }); + assert.deepEqual(res, { id: 'imported' }); + assert.equal(seen, 'msg-1'); + }); + + it('moduleImportMessage throws 422 when messageId missing', async () => { + const api = makeApi(ModulesApi); + await assert.rejects( + api.moduleImportMessage(makeUser(), {}, { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('moduleImportMessagePreview forwards messageId to previewModuleMessage', async () => { + let seen; + guardiansStub.previewModuleMessage = async (messageId, owner) => { seen = messageId; return { preview: true }; }; + const api = makeApi(ModulesApi); + const res = await api.moduleImportMessagePreview(makeUser(), { messageId: 'msg-2' }, { url: '/u' }); + assert.deepEqual(res, { preview: true }); + assert.equal(seen, 'msg-2'); + }); + + it('publishModule forwards uuid, owner and module to publishModule', async () => { + let seen; + guardiansStub.publishModule = async (uuid, owner, module) => { seen = { uuid, owner, module }; return { isValid: true }; }; + const api = makeApi(ModulesApi); + const body = { x: 1 }; + const res = await api.publishModule(makeUser(), 'uuid-4', body, { url: '/u', params: { uuid: 'uuid-4' } }); + assert.deepEqual(res, { isValid: true }); + assert.equal(seen.uuid, 'uuid-4'); + assert.equal(seen.module, body); + }); + + it('validateModule forwards owner and module to validateModule', async () => { + let seen; + guardiansStub.validateModule = async (owner, module) => { seen = { owner, module }; return { results: {} }; }; + const api = makeApi(ModulesApi); + const body = { config: {} }; + const res = await api.validateModule(makeUser(), body); + assert.deepEqual(res, { results: {} }); + assert.equal(seen.module, body); + assert.equal(seen.owner.user.id, 'user-1'); + }); +}); + +describe('ToolsApi', function () { + this.timeout(300000); + + it('createNewTool forwards tool + owner to createTool', async () => { + let seen; + guardiansStub.createTool = async (tool, owner) => { seen = { tool, owner }; return { id: 't1' }; }; + const api = makeApi(ToolsApi); + const tool = { config: { blockType: 'tool' } }; + const res = await api.createNewTool(makeUser(), tool, {}); + assert.deepEqual(res, { id: 't1' }); + assert.equal(seen.tool, tool); + assert.equal(seen.owner.user.id, 'user-1'); + }); + + it('createNewTool rejects invalid config with 422', async () => { + const api = makeApi(ToolsApi); + await assert.rejects( + api.createNewTool(makeUser(), { config: { blockType: 'x' } }, {}), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('createNewTool maps proxy error via InternalException', async () => { + guardiansStub.createTool = async () => { throw new Error('toolboom'); }; + const api = makeApi(ToolsApi); + await assert.rejects( + api.createNewTool(makeUser(), { config: { blockType: 'tool' } }, {}), + /toolboom/ + ); + }); + + it('getTools forwards pagination to getTools and sets count header', async () => { + let seen; + guardiansStub.getTools = async (options, owner) => { seen = options; return { items: ['a'], count: 3 }; }; + const api = makeApi(ToolsApi); + const res = makeRes(); + await api.getTools(makeUser(), res, 0, 10); + assert.deepEqual(seen, { pageIndex: 0, pageSize: 10 }); + assert.equal(res.headers['X-Total-Count'], 3); + assert.deepEqual(res.sent, ['a']); + }); + + it('getToolsV2 forwards fields and search/tag filters', async () => { + let seen; + guardiansStub.getToolsV2 = async (fields, options, owner) => { seen = { fields, options }; return { items: [], count: 0 }; }; + const api = makeApi(ToolsApi); + await api.getToolsV2(makeUser(), makeRes(), 1, 5, 'query', 'mytag'); + assert.deepEqual(seen.fields, ['name', 'uuid']); + assert.deepEqual(seen.options, { pageIndex: 1, pageSize: 5, search: 'query', tag: 'mytag' }); + }); + + it('deleteTool forwards id + owner to deleteTool', async () => { + let seen; + guardiansStub.deleteTool = async (id, owner) => { seen = { id, owner }; return true; }; + const api = makeApi(ToolsApi); + const res = await api.deleteTool(makeUser(), 'id-1', {}); + assert.equal(res, true); + assert.equal(seen.id, 'id-1'); + }); + + it('deleteTool rejects empty id with 422', async () => { + const api = makeApi(ToolsApi); + await assert.rejects( + api.deleteTool(makeUser(), '', {}), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('getToolById forwards id to getToolById', async () => { + let seen; + guardiansStub.getToolById = async (id, owner) => { seen = id; return { id: 'x' }; }; + const api = makeApi(ToolsApi); + const res = await api.getToolById(makeUser(), 'id-2'); + assert.deepEqual(res, { id: 'x' }); + assert.equal(seen, 'id-2'); + }); + + it('getToolById rejects empty id with 422', async () => { + const api = makeApi(ToolsApi); + await assert.rejects( + api.getToolById(makeUser(), ''), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('updateTool forwards id, tool, owner to updateTool', async () => { + let seen; + guardiansStub.updateTool = async (id, tool, owner) => { seen = { id, tool, owner }; return { id: 'u' }; }; + const api = makeApi(ToolsApi); + const tool = { config: { blockType: 'tool' } }; + const res = await api.updateTool(makeUser(), 'id-3', tool, {}); + assert.deepEqual(res, { id: 'u' }); + assert.equal(seen.id, 'id-3'); + assert.equal(seen.tool, tool); + }); + + it('updateTool throws 422 for invalid config (outside try)', async () => { + const api = makeApi(ToolsApi); + await assert.rejects( + api.updateTool(makeUser(), 'id-3', { config: { blockType: 'x' } }, {}), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('publishTool forwards id, owner and body to publishTool', async () => { + let seen; + guardiansStub.publishTool = async (id, owner, body) => { seen = { id, owner, body }; return { isValid: true }; }; + const api = makeApi(ToolsApi); + const body = { toolVersion: '1.0.0' }; + const res = await api.publishTool(makeUser(), 'id-4', body, {}); + assert.deepEqual(res, { isValid: true }); + assert.equal(seen.id, 'id-4'); + assert.equal(seen.body, body); + }); + + it('dryRunPolicy forwards id + owner to dryRunTool', async () => { + let seen; + guardiansStub.dryRunTool = async (id, owner) => { seen = id; return { isValid: true }; }; + const api = makeApi(ToolsApi); + const res = await api.dryRunPolicy(makeUser(), 'id-5', {}); + assert.deepEqual(res, { isValid: true }); + assert.equal(seen, 'id-5'); + }); + + it('draftPolicy forwards id + owner to draftTool', async () => { + let seen; + guardiansStub.draftTool = async (id, owner) => { seen = id; return true; }; + const api = makeApi(ToolsApi); + const res = await api.draftPolicy(makeUser(), 'id-6', {}); + assert.equal(res, true); + assert.equal(seen, 'id-6'); + }); + + it('validateTool forwards owner + tool to validateTool', async () => { + let seen; + guardiansStub.validateTool = async (owner, tool) => { seen = { owner, tool }; return { results: {} }; }; + const api = makeApi(ToolsApi); + const tool = { config: {} }; + const res = await api.validateTool(makeUser(), tool); + assert.deepEqual(res, { results: {} }); + assert.equal(seen.tool, tool); + }); + + it('toolExportMessage forwards id to exportToolMessage', async () => { + let seen; + guardiansStub.exportToolMessage = async (id, owner) => { seen = id; return { messageId: 'm' }; }; + const api = makeApi(ToolsApi); + const res = await api.toolExportMessage(makeUser(), 'id-7'); + assert.deepEqual(res, { messageId: 'm' }); + assert.equal(seen, 'id-7'); + }); + + it('toolImportMessagePreview forwards messageId to previewToolMessage', async () => { + let seen; + guardiansStub.previewToolMessage = async (messageId, owner) => { seen = messageId; return { preview: true }; }; + const api = makeApi(ToolsApi); + const res = await api.toolImportMessagePreview(makeUser(), { messageId: 'mid-1' }); + assert.deepEqual(res, { preview: true }); + assert.equal(seen, 'mid-1'); + }); + + it('toolImportMessagePreview throws 422 when messageId missing', async () => { + const api = makeApi(ToolsApi); + await assert.rejects( + api.toolImportMessagePreview(makeUser(), {}), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('toolImportMessage forwards messageId to importToolMessage', async () => { + let seen; + guardiansStub.importToolMessage = async (messageId, owner) => { seen = messageId; return { id: 'imp' }; }; + const api = makeApi(ToolsApi); + const res = await api.toolImportMessage(makeUser(), { messageId: 'mid-2' }); + assert.deepEqual(res, { id: 'imp' }); + assert.equal(seen, 'mid-2'); + }); + + it('getMenu forwards owner to getMenuTool', async () => { + let seen; + guardiansStub.getMenuTool = async (owner) => { seen = owner; return [{ id: 'menu' }]; }; + const api = makeApi(ToolsApi); + const res = await api.getMenu(makeUser()); + assert.deepEqual(res, [{ id: 'menu' }]); + assert.equal(seen.user.id, 'user-1'); + }); + + it('checkTool forwards messageId to checkTool', async () => { + let seen; + guardiansStub.checkTool = async (messageId, owner) => { seen = messageId; return true; }; + const api = makeApi(ToolsApi); + const res = await api.checkTool(makeUser(), 'mid-3'); + assert.equal(res, true); + assert.equal(seen, 'mid-3'); + }); +}); + +describe('TagsApi', function () { + this.timeout(300000); + + it('setTags forwards body + owner to createTag', async () => { + let seen; + guardiansStub.createTag = async (body, owner) => { seen = { body, owner }; return { id: 'tag1' }; }; + const api = makeApi(TagsApi); + const body = { name: 'tag', entity: 'PolicyDocument' }; + const res = await api.setTags(makeUser(), body, { url: '/u' }); + assert.deepEqual(res, { id: 'tag1' }); + assert.equal(seen.body, body); + }); + + it('setTags maps proxy error via InternalException', async () => { + guardiansStub.createTag = async () => { throw new Error('tagboom'); }; + const api = makeApi(TagsApi); + await assert.rejects( + api.setTags(makeUser(), { name: 'x' }, { url: '/u' }), + /tagboom/ + ); + }); + + it('searchTags builds a tag map keyed by localTarget for a single target', async () => { + guardiansStub.getTags = async (owner, entity, targets) => [ + { localTarget: 'tgt-1', name: 'a' }, + { localTarget: 'tgt-1', name: 'b' }, + ]; + guardiansStub.getTagCache = async () => [{ localTarget: 'tgt-1', date: '2026-01-01' }]; + const api = makeApi(TagsApi); + const res = await api.searchTags( + makeUser(), { entity: 'PolicyDocument', target: 'tgt-1' }, { url: '/u' } + ); + assert.equal(res['tgt-1'].tags.length, 2); + assert.equal(res['tgt-1'].refreshDate, '2026-01-01'); + assert.equal(res['tgt-1'].entity, 'PolicyDocument'); + }); + + it('searchTags throws 422 when entity missing', async () => { + const api = makeApi(TagsApi); + await assert.rejects( + api.searchTags(makeUser(), { target: 'x' }, { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('searchTags throws 422 when no target nor targets', async () => { + const api = makeApi(TagsApi); + await assert.rejects( + api.searchTags(makeUser(), { entity: 'PolicyDocument' }, { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('synchronizationTags forwards entity/target to synchronizationTags and stamps refreshDate', async () => { + let seen; + guardiansStub.synchronizationTags = async (owner, entity, target) => { seen = { entity, target }; return [{ name: 't' }]; }; + const api = makeApi(TagsApi); + const res = await api.synchronizationTags( + makeUser(), { entity: 'PolicyDocument', target: 'tgt-2' }, { url: '/u' } + ); + assert.equal(seen.entity, 'PolicyDocument'); + assert.equal(seen.target, 'tgt-2'); + assert.deepEqual(res.tags, [{ name: 't' }]); + assert.equal(typeof res.refreshDate, 'string'); + }); + + it('synchronizationTags throws 422 when target is not a string', async () => { + const api = makeApi(TagsApi); + await assert.rejects( + api.synchronizationTags(makeUser(), { entity: 'PolicyDocument', target: 123 }, { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('deleteTag forwards uuid + owner to deleteTag', async () => { + let seen; + guardiansStub.deleteTag = async (uuid, owner) => { seen = uuid; return true; }; + const api = makeApi(TagsApi); + const res = await api.deleteTag(makeUser(), 'uuid-x', { url: '/u' }); + assert.equal(res, true); + assert.equal(seen, 'uuid-x'); + }); + + it('deleteTag rejects empty uuid with 422', async () => { + const api = makeApi(TagsApi); + await assert.rejects( + api.deleteTag(makeUser(), '', { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('getSchemas forwards pagination to getTagSchemas and sets count', async () => { + let seen; + guardiansStub.getTagSchemas = async (owner, pageIndex, pageSize) => { seen = { pageIndex, pageSize }; return { items: [{ owner: 'did:hedera:user' }], count: 4 }; }; + const api = makeApi(TagsApi); + const res = makeRes(); + const req = {}; + await api.getSchemas(makeUser(), req, res, 0, 25); + assert.deepEqual(seen, { pageIndex: 0, pageSize: 25 }); + assert.equal(res.headers['X-Total-Count'], 4); + }); + + it('getPublished forwards user to getPublishedTagSchemas', async () => { + let seen; + guardiansStub.getPublishedTagSchemas = async (user) => { seen = user; return [{ id: 's' }]; }; + const api = makeApi(TagsApi); + const res = await api.getPublished(makeUser()); + assert.deepEqual(res, [{ id: 's' }]); + assert.equal(seen.id, 'user-1'); + }); + + it('publishTag forwards schemaId + version to publishTagSchema', async () => { + let seen; + guardiansStub.getSchemaById = async () => ({ id: 's' }); + guardiansStub.publishTagSchema = async (schemaId, version, owner) => { seen = { schemaId, version }; return { id: 's', status: 'PUBLISHED' }; }; + const api = makeApi(TagsApi); + const res = await api.publishTag(makeUser(), 'schema-1', { url: '/u' }); + assert.equal(seen.schemaId, 'schema-1'); + assert.equal(seen.version, '1.0.0'); + assert.equal(res.status, 'PUBLISHED'); + }); + + it('publishTag throws 403 when permission check fails', async () => { + guardiansStub.getSchemaById = async () => ({ id: 's' }); + const localHelpers = { ...helpersMock, SchemaUtils: { ...schemaUtilsStub, checkPermission: () => 'no permission' } }; + const { TagsApi: LocalTagsApi } = await esmock(TAGS_DIST, { + '#helpers': localHelpers, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined, checkPermission: () => () => undefined }, + '#constants': { PREFIXES: { TAGS: 'tags/', SCHEMES: 'schemas/' }, SCHEMA_REQUIRED_PROPS: {} }, + '#middlewares': { + Examples: {}, ForbiddenErrorDTO: class {}, InternalServerErrorDTO: class {}, + ObjectExamples: {}, SchemaDTO: class {}, TagDTO: class {}, TagFilterDTO: class {}, + TagMapDTO: class {}, TaskDTO: class {}, UnprocessableEntityErrorDTO: class {}, + pageHeader: {}, Response451_DTO: class {}, + }, + '@guardian/common': commonMock, + }); + const api = makeApi(LocalTagsApi); + await assert.rejects( + api.publishTag(makeUser(), 'schema-1', { url: '/u' }), + (err) => { assert.equal(err.getStatus(), 403); return true; } + ); + }); + + it('updateSchema forwards updated schema + owner to updateSchema', async () => { + let seen; + guardiansStub.getSchemaById = async () => ({ id: 's' }); + guardiansStub.updateSchema = async (schema, owner) => { seen = { schema, owner }; return [{ id: 's' }]; }; + const api = makeApi(TagsApi); + const newSchema = { id: 'schema-2', document: { $id: '#schema-2' } }; + const res = await api.updateSchema(makeUser(), 'schema-2', newSchema, { url: '/u' }); + assert.deepEqual(res, [{ id: 's' }]); + assert.equal(seen.schema, newSchema); + }); +}); + +describe('ThemesApi', function () { + this.timeout(300000); + + it('setThemes forwards theme + owner to createTheme', async () => { + let seen; + guardiansStub.createTheme = async (theme, owner) => { seen = { theme, owner }; return { id: 'th1' }; }; + const api = makeApi(ThemesApi); + const theme = { name: 'dark' }; + const res = await api.setThemes(makeUser(), theme, { url: '/u', user: makeUser() }); + assert.deepEqual(res, { id: 'th1' }); + assert.equal(seen.theme, theme); + assert.equal(seen.owner.user.id, 'user-1'); + }); + + it('setThemes maps proxy error via InternalException', async () => { + guardiansStub.createTheme = async () => { throw new Error('themeboom'); }; + const api = makeApi(ThemesApi); + await assert.rejects( + api.setThemes(makeUser(), { name: 'x' }, { url: '/u' }), + /themeboom/ + ); + }); + + it('updateTheme forwards themeId, theme, owner to updateTheme', async () => { + let seen; + guardiansStub.getThemeById = async () => ({ id: 'old' }); + guardiansStub.updateTheme = async (themeId, theme, owner) => { seen = { themeId, theme, owner }; return { id: 'upd' }; }; + const api = makeApi(ThemesApi); + const theme = { name: 'dark2' }; + const res = await api.updateTheme(makeUser(), 'theme-1', theme, { url: '/u', params: { themeId: 'theme-1' } }); + assert.deepEqual(res, { id: 'upd' }); + assert.equal(seen.themeId, 'theme-1'); + assert.equal(seen.theme, theme); + }); + + it('updateTheme throws 422 on empty themeId', async () => { + const api = makeApi(ThemesApi); + await assert.rejects( + api.updateTheme(makeUser(), '', { name: 'x' }, { url: '/u', params: {} }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('updateTheme throws 404 when theme not found', async () => { + guardiansStub.getThemeById = async () => null; + const api = makeApi(ThemesApi); + await assert.rejects( + api.updateTheme(makeUser(), 'theme-1', { name: 'x' }, { url: '/u', params: { themeId: 'theme-1' } }), + (err) => { assert.equal(err.getStatus(), 404); return true; } + ); + }); + + it('deleteTheme forwards themeId + owner to deleteTheme', async () => { + let seen; + guardiansStub.deleteTheme = async (themeId, owner) => { seen = themeId; return true; }; + const api = makeApi(ThemesApi); + const res = await api.deleteTheme(makeUser(), 'theme-2', { url: '/u', params: { themeId: 'theme-2' } }); + assert.equal(res, true); + assert.equal(seen, 'theme-2'); + }); + + it('deleteTheme rejects empty themeId with 422', async () => { + const api = makeApi(ThemesApi); + await assert.rejects( + api.deleteTheme(makeUser(), '', { url: '/u', params: {} }), + (err) => { assert.equal(err.getStatus(), 422); return true; } + ); + }); + + it('getThemes returns guardian themes for a user with did', async () => { + let seen; + guardiansStub.getThemes = async (owner) => { seen = owner; return [{ id: 'th' }]; }; + const api = makeApi(ThemesApi); + const res = await api.getThemes(makeUser()); + assert.deepEqual(res, [{ id: 'th' }]); + assert.equal(seen.user.id, 'user-1'); + }); + + it('getThemes returns empty array when user has no did', async () => { + const api = makeApi(ThemesApi); + const res = await api.getThemes(makeUser({ did: undefined })); + assert.deepEqual(res, []); + }); + + it('importTheme forwards zip + owner to importThemeFile', async () => { + let seen; + guardiansStub.importThemeFile = async (zip, owner) => { seen = { zip, owner }; return { id: 'imp' }; }; + const api = makeApi(ThemesApi); + const zip = Buffer.from('zip'); + const res = await api.importTheme(makeUser(), zip, { url: '/u' }); + assert.deepEqual(res, { id: 'imp' }); + assert.equal(seen.zip, zip); + }); +}); diff --git a/api-gateway/tests/service/module.test.mjs b/api-gateway/tests/service/module.test.mjs new file mode 100644 index 0000000000..c0e0a6a090 --- /dev/null +++ b/api-gateway/tests/service/module.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeReq, makeCacheService, makeLogger, + FakeEntityOwner, internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/module.js'; + +let stub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } + createModule(...a) { return stub.createModule(...a); } + getModule(...a) { return stub.getModule(...a); } + getModuleV2(...a) { return stub.getModuleV2(...a); } + getSchemasByOwner(...a) { return stub.getSchemasByOwner(...a); } + createSchema(...a) { return stub.createSchema(...a); } + deleteModule(...a) { return stub.deleteModule(...a); } + getMenuModule(...a) { return stub.getMenuModule(...a); } + getModuleById(...a) { return stub.getModuleById(...a); } + updateModule(...a) { return stub.updateModule(...a); } + exportModuleFile(...a) { return stub.exportModuleFile(...a); } + exportModuleMessage(...a) { return stub.exportModuleMessage(...a); } + importModuleMessage(...a) { return stub.importModuleMessage(...a); } + importModuleFile(...a) { return stub.importModuleFile(...a); } + previewModuleMessage(...a) { return stub.previewModuleMessage(...a); } + previewModuleFile(...a) { return stub.previewModuleFile(...a); } + publishModule(...a) { return stub.publishModule(...a); } + validateModule(...a) { return stub.validateModule(...a); } +} + +const SchemaUtils = { + toOld: (x) => x, + fromOld: () => undefined, + clearIds: () => undefined +}; + +async function load() { + return loadController(DIST, { + '#helpers': { + Guardians: FakeGuardians, SchemaUtils, UseCache: () => () => undefined, + InternalException: internalExceptionRethrow, EntityOwner: FakeEntityOwner, + CacheService: class {}, getCacheKey: (t) => `k:${t.join('|')}` + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#constants': { CACHE_TAG: { MODULE: 'module' }, MODULE_REQUIRED_PROPS: { a: 'id' }, PREFIXES: { MODULES: 'modules/', SCHEMES: 'schemas/' } }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {} }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(Api) { const cache = makeCacheService(); return { api: new Api(cache, makeLogger()), cache }; } + +describe('ModulesApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ ModulesApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + createModule: async () => ({ created: true }), + getModule: async () => ({ items: [{ m: 1 }], count: 4 }), + getModuleV2: async () => ({ items: [{ m: 2 }], count: 8 }), + getSchemasByOwner: async () => ({ items: [{ id: 's1', owner: 'owner-did' }], count: 2 }), + createSchema: async () => ([{ id: 'newschema' }]), + deleteModule: async () => ({ deleted: true }), + getMenuModule: async () => ([{ menu: 1 }]), + getModuleById: async () => ({ id: 'mod1' }), + updateModule: async () => ({ updated: true }), + exportModuleFile: async () => Buffer.from('zip'), + exportModuleMessage: async () => ({ messageId: 'mid' }), + importModuleMessage: async () => ({ imported: true }), + importModuleFile: async () => ({ importedFile: true }), + previewModuleMessage: async () => ({ preview: true }), + previewModuleFile: async () => ({ previewFile: true }), + publishModule: async () => ({ published: true }), + validateModule: async () => ({ valid: true }) + }; + }); + + const goodConfig = { config: { blockType: 'module' } }; + + it('postModules throws 422 with invalid config', async () => { + await assert.rejects(makeApi(Api).api.postModules(makeUser(), { config: { blockType: 'x' } }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('postModules creates module and invalidates cache', async () => { + const { api, cache } = makeApi(Api); + const out = await api.postModules(makeUser(), goodConfig, makeReq()); + assert.deepEqual(out, { created: true }); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('getModules sets count header', async () => { + const res = makeRes(); + await makeApi(Api).api.getModules(makeUser(), res, 0, 10); + assert.equal(res.headers['X-Total-Count'], 4); + }); + + it('getModules passes paging options', async () => { + let seen; + stub.getModule = async (o) => { seen = o; return { items: [], count: 0 }; }; + await makeApi(Api).api.getModules(makeUser(), makeRes(), 3, 15); + assert.deepEqual(seen, { pageIndex: 3, pageSize: 15 }); + }); + + it('getModulesV2 sets count header and fields', async () => { + let seen; + stub.getModuleV2 = async (o) => { seen = o; return { items: [], count: 8 }; }; + const res = makeRes(); + await makeApi(Api).api.getModulesV2(makeUser(), res, 0, 10); + assert.deepEqual(seen.fields, ['id']); + }); + + it('getModuleSchemas sets count header and req.locals', async () => { + const req = makeReq(); + const res = makeRes(); + await makeApi(Api).api.getModuleSchemas(makeUser(), req, res, 0, 10, 'topic'); + assert.equal(res.headers['X-Total-Count'], 2); + assert.ok(Array.isArray(req.locals)); + }); + + it('getModuleSchemas marks readonly when owner differs', async () => { + stub.getSchemasByOwner = async () => ({ items: [{ id: 's', owner: 'other-did', readonly: false }], count: 1 }); + const req = makeReq(); + await makeApi(Api).api.getModuleSchemas(makeUser(), req, makeRes(), 0, 10, 't'); + assert.equal(req.locals[0].readonly, true); + }); + + it('getModuleSchemas wraps error in 500', async () => { + stub.getSchemasByOwner = async () => { throw new Error('boom'); }; + await assert.rejects(makeApi(Api).api.getModuleSchemas(makeUser(), makeReq(), makeRes(), 0, 10, 't'), (e) => { assert.equal(e.getStatus(), 500); return true; }); + }); + + it('postSchemas throws 500 when schema missing', async () => { + await assert.rejects(makeApi(Api).api.postSchemas(makeUser(), null, makeReq()), (e) => { assert.equal(e.getStatus(), 500); return true; }); + }); + + it('postSchemas creates schema and invalidates cache', async () => { + const { api, cache } = makeApi(Api); + const out = await api.postSchemas(makeUser(), { name: 's' }, makeReq()); + assert.deepEqual(out, [{ id: 'newschema' }]); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('deleteModule throws when uuid missing', async () => { + await assert.rejects(makeApi(Api).api.deleteModule(makeUser(), '', makeReq())); + }); + + it('deleteModule delegates and invalidates cache', async () => { + const { api, cache } = makeApi(Api); + const out = await api.deleteModule(makeUser(), 'uuid1', makeReq()); + assert.deepEqual(out, { deleted: true }); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('getMenu delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getMenu(makeUser()), [{ menu: 1 }]); + }); + + it('getModule throws 422 without uuid', async () => { + await assert.rejects(makeApi(Api).api.getModule(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getModule delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getModule(makeUser(), 'uuid1'), { id: 'mod1' }); + }); + + it('putModule throws 422 without uuid', async () => { + await assert.rejects(makeApi(Api).api.putModule(makeUser(), '', goodConfig, makeReq({ params: {} })), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('putModule throws 422 with invalid config', async () => { + await assert.rejects(makeApi(Api).api.putModule(makeUser(), 'uuid1', { config: { blockType: 'x' } }, makeReq({ params: { uuid: 'uuid1' } })), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('putModule delegates when valid', async () => { + const { api } = makeApi(Api); + const out = await api.putModule(makeUser(), 'uuid1', goodConfig, makeReq({ params: { uuid: 'uuid1' } })); + assert.deepEqual(out, { updated: true }); + }); + + it('moduleExportFile sets zip headers', async () => { + const res = makeRes(); + await makeApi(Api).api.moduleExportFile(makeUser(), 'uuid1', res); + assert.equal(res.headers['Content-type'], 'application/zip'); + }); + + it('moduleExportMessage delegates', async () => { + assert.deepEqual(await makeApi(Api).api.moduleExportMessage(makeUser(), 'uuid1'), { messageId: 'mid' }); + }); + + it('moduleImportMessage throws 422 without messageId', async () => { + await assert.rejects(makeApi(Api).api.moduleImportMessage(makeUser(), {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('moduleImportMessage delegates messageId', async () => { + let seen; + stub.importModuleMessage = async (msg) => { seen = msg; return {}; }; + await makeApi(Api).api.moduleImportMessage(makeUser(), { messageId: 'M1' }, makeReq()); + assert.equal(seen, 'M1'); + }); + + it('moduleImportFile delegates', async () => { + assert.deepEqual(await makeApi(Api).api.moduleImportFile(makeUser(), { b: 1 }, makeReq()), { importedFile: true }); + }); + + it('moduleImportMessagePreview throws 422 without messageId', async () => { + await assert.rejects(makeApi(Api).api.moduleImportMessagePreview(makeUser(), {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('moduleImportMessagePreview delegates', async () => { + assert.deepEqual(await makeApi(Api).api.moduleImportMessagePreview(makeUser(), { messageId: 'M' }, makeReq()), { preview: true }); + }); + + it('moduleImportFilePreview delegates', async () => { + assert.deepEqual(await makeApi(Api).api.moduleImportFilePreview(makeUser(), { b: 1 }, makeReq()), { previewFile: true }); + }); + + it('publishModule delegates and invalidates cache', async () => { + const { api, cache } = makeApi(Api); + const out = await api.publishModule(makeUser(), 'uuid1', {}, makeReq({ params: { uuid: 'uuid1' } })); + assert.deepEqual(out, { published: true }); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('validateModule delegates', async () => { + assert.deepEqual(await makeApi(Api).api.validateModule(makeUser(), {}), { valid: true }); + }); +}); diff --git a/api-gateway/tests/service/permissions-websockets-controllers.test.mjs b/api-gateway/tests/service/permissions-websockets-controllers.test.mjs new file mode 100644 index 0000000000..1b15e13aea --- /dev/null +++ b/api-gateway/tests/service/permissions-websockets-controllers.test.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const PERM_DIST = '../../dist/api/service/permissions.js'; +const authMock = { Auth: () => () => undefined, AuthUser: () => () => undefined }; +const middlewaresMock = new Proxy({}, { get: (t, p) => (p === 'Examples' || p === 'ObjectExamples') ? new Proxy({}, { get: () => 'ex' }) : (p === 'pageHeader' ? {} : class {}) }); + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:owner', parent: null, tenantContext: { tenantId: 't1' }, ...extra }; +} +function makeRes() { + const calls = {}; + const res = { header: (k, v) => { calls.header = { k, v }; return { send: (b) => { calls.sent = b; return calls; } }; } }; + return { res, calls }; +} +const is404 = (e) => { assert.equal(e.getStatus(), 404); return true; }; + +describe('PermissionsApi', function () { + this.timeout(60000); + let PermissionsApi; + let usersImpl; + let guardiansImpl; + let userPermissionsHas; + + before(async () => { + ({ PermissionsApi } = await esmock(PERM_DIST, { + '@guardian/common': { PinoLogger: class {} }, + '@guardian/interfaces': { + AssignedEntityType: { Policy: 'Policy' }, + Permissions: new Proxy({}, { get: () => 'p' }), + PolicyStatus: new Proxy({}, { get: () => 's' }), + UserPermissions: { has: (...a) => userPermissionsHas(...a) }, + }, + '#middlewares': middlewaresMock, + '#auth': authMock, + '#helpers': { + CacheService: class {}, + EntityOwner: class { constructor(user) { this.creator = user?.did; } }, + getCacheKey: (keys) => `ck:${keys.join('|')}`, + Guardians: class { + constructor(tc) { this.tc = tc; } + async createRole(...a) { return guardiansImpl.createRole(...a); } + async updateRole(...a) { return guardiansImpl.updateRole(...a); } + async deleteRole(...a) { return guardiansImpl.deleteRole(...a); } + async setRole(...a) { return guardiansImpl.setRole(...a); } + async assignedEntities(...a) { return guardiansImpl.assignedEntities(...a); } + async getAssignedPolicies(...a) { return guardiansImpl.getAssignedPolicies(...a); } + async assignEntity(...a) { return guardiansImpl.assignEntity(...a); } + async delegateEntity(...a) { return guardiansImpl.delegateEntity(...a); } + }, + InternalException: async (e) => { throw e; }, + Users: class { + constructor(tc) { this.tc = tc; } + async getPermissions(...a) { return usersImpl.getPermissions(...a); } + async getRoles(...a) { return usersImpl.getRoles(...a); } + async createRole(...a) { return usersImpl.createRole(...a); } + async getRoleById(...a) { return usersImpl.getRoleById(...a); } + async updateRole(...a) { return usersImpl.updateRole(...a); } + async refreshUserPermissions(...a) { return usersImpl.refreshUserPermissions(...a); } + async deleteRole(...a) { return usersImpl.deleteRole(...a); } + async setDefaultRole(...a) { return usersImpl.setDefaultRole(...a); } + async getWorkers(...a) { return usersImpl.getWorkers(...a); } + async getUserPermissions(...a) { return usersImpl.getUserPermissions(...a); } + async updateUserRole(...a) { return usersImpl.updateUserRole(...a); } + async delegateUserRole(...a) { return usersImpl.delegateUserRole(...a); } + }, + }, + '../../dist/api/service/websockets.js': { WebSocketsService: class { constructor() {} updatePermissions() {} } }, + '#constants': { + CACHE_PREFIXES: { TAG: 'tag' }, + PREFIXES: { PROFILES: 'profiles', ACCOUNTS: 'accounts' }, + }, + })); + }); + + function makeApi() { + userPermissionsHas = () => true; + const cacheService = { invalidate: async () => undefined, invalidateAllTagsByPrefixes: async () => undefined }; + return new PermissionsApi(cacheService, { error: () => undefined }); + } + + it('getPermissions returns the user permissions', async () => { + usersImpl = { getPermissions: async (id) => [`perm-${id}`] }; + assert.deepEqual(await makeApi().getPermissions(makeUser({ id: 'u9' })), ['perm-u9']); + }); + + it('getRoles sets the total-count header and sends items', async () => { + usersImpl = { getRoles: async () => ({ items: [{ id: 'r' }], count: 4 }) }; + const { res, calls } = makeRes(); + await makeApi().getRoles(makeUser(), res, 'role', 0, 20); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 4 }); + assert.deepEqual(calls.sent, [{ id: 'r' }]); + }); + + it('getRoles sets onlyOwn true when the user lacks PERMISSIONS_ROLE_READ', async () => { + let seenOptions; + usersImpl = { getRoles: async (options) => { seenOptions = options; return { items: [], count: 0 }; } }; + const api = makeApi(); + userPermissionsHas = () => false; + const { res } = makeRes(); + await api.getRoles(makeUser(), res, null, 0, 20); + assert.equal(seenOptions.onlyOwn, true); + }); + + it('getRoles uses parent as owner when present', async () => { + let seenOptions; + usersImpl = { getRoles: async (options) => { seenOptions = options; return { items: [], count: 0 }; } }; + const { res } = makeRes(); + await makeApi().getRoles(makeUser({ parent: 'did:parent' }), res, null, 0, 20); + assert.equal(seenOptions.owner, 'did:parent'); + }); + + it('createRole creates the role in both Users and Guardians', async () => { + let guardianRole; + usersImpl = { createRole: async () => ({ id: 'role-1' }) }; + guardiansImpl = { createRole: async (role) => { guardianRole = role; } }; + const out = await makeApi().createRole(makeUser(), { name: 'R' }); + assert.deepEqual(out, { id: 'role-1' }); + assert.deepEqual(guardianRole, { id: 'role-1' }); + }); + + it('updateRole throws 404 when the role does not exist', async () => { + usersImpl = { getRoleById: async () => null }; + await assert.rejects(makeApi().updateRole(makeUser(), 'r1', {}), is404); + }); + + it('updateRole updates, refreshes permissions and invalidates cache', async () => { + let invalidated = false; + usersImpl = { + getRoleById: async () => ({ id: 'r1' }), + updateRole: async () => ({ id: 'r1', updated: true }), + refreshUserPermissions: async () => [{ id: 'u' }], + }; + guardiansImpl = { updateRole: async () => undefined }; + const cacheService = { invalidate: async () => undefined, invalidateAllTagsByPrefixes: async () => { invalidated = true; } }; + const api = new PermissionsApi(cacheService, { error: () => undefined }); + userPermissionsHas = () => true; + const out = await api.updateRole(makeUser(), 'r1', { name: 'X' }); + assert.deepEqual(out, { id: 'r1', updated: true }); + assert.equal(invalidated, true); + }); + + it('deleteModule throws 422 when id is missing', async () => { + usersImpl = {}; + await assert.rejects(makeApi().deleteModule(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deleteModule deletes and refreshes permissions', async () => { + usersImpl = { deleteRole: async () => ({ deleted: true }), refreshUserPermissions: async () => [] }; + guardiansImpl = { deleteRole: async () => undefined }; + const out = await makeApi().deleteModule(makeUser(), 'r1'); + assert.deepEqual(out, { deleted: true }); + }); + + it('setDefaultRole forwards the body id', async () => { + let seen; + usersImpl = { setDefaultRole: async (id) => { seen = id; return { ok: true }; } }; + const out = await makeApi().setDefaultRole(makeUser(), { id: 'role-x' }); + assert.deepEqual(out, { ok: true }); + assert.equal(seen, 'role-x'); + }); + + it('getUsers excludes the requester via did $ne and decorates assigned entities', async () => { + let seenOptions; + usersImpl = { getWorkers: async (options) => { seenOptions = options; return { items: [{ did: 'did:a' }], count: 1 }; } }; + guardiansImpl = { assignedEntities: async (user, did) => [`ent-${did}`] }; + const { res, calls } = makeRes(); + await makeApi().getUsers(makeUser({ did: 'did:owner' }), res, 0, 20, 'role', 'ACTIVE', 'name'); + assert.deepEqual(seenOptions.filters.did, { $ne: 'did:owner' }); + assert.deepEqual(calls.sent[0].assignedEntities, ['ent-did:a']); + }); + + it('getUser throws 404 when the row parent does not match the owner', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'someone-else', did: 'did:x' }) }; + await assert.rejects(makeApi().getUser(makeUser({ did: 'did:owner' }), 'name'), is404); + }); + + it('getUser throws 404 when the row is the requester', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'did:owner', did: 'did:owner' }) }; + await assert.rejects(makeApi().getUser(makeUser({ did: 'did:owner' }), 'name'), is404); + }); + + it('getUser returns the row when valid', async () => { + const row = { parent: 'did:owner', did: 'did:other' }; + usersImpl = { getUserPermissions: async () => row }; + assert.deepEqual(await makeApi().getUser(makeUser({ did: 'did:owner' }), 'name'), row); + }); + + it('updateUser throws 404 when the target user is invalid', async () => { + usersImpl = { getUserPermissions: async () => null }; + await assert.rejects(makeApi().updateUser(makeUser(), 'name', {}, { url: '/x' }), is404); + }); + + it('updateUser updates role and sets it on guardians', async () => { + let setRoleArg; + usersImpl = { + getUserPermissions: async () => ({ parent: 'did:owner', did: 'did:other' }), + updateUserRole: async () => ({ did: 'did:other', role: 'NEW' }), + }; + guardiansImpl = { setRole: async (result) => { setRoleArg = result; } }; + const out = await makeApi().updateUser(makeUser({ did: 'did:owner' }), 'name', { role: 'NEW' }, { url: '/x' }); + assert.equal(out.role, 'NEW'); + assert.deepEqual(setRoleArg, { did: 'did:other', role: 'NEW' }); + }); + + it('getAssignedPolicies throws 404 for an unknown target', async () => { + usersImpl = { getUserPermissions: async () => null }; + const { res } = makeRes(); + await assert.rejects(makeApi().getAssignedPolicies(makeUser(), res, 'name', 0, 20, 'ACTIVE'), is404); + }); + + it('getAssignedPolicies returns policies with count header', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'did:owner', did: 'did:t' }) }; + guardiansImpl = { getAssignedPolicies: async () => ({ policies: [{ id: 'p' }], count: 2 }) }; + const { res, calls } = makeRes(); + await makeApi().getAssignedPolicies(makeUser({ did: 'did:owner' }), res, 'name', 0, 20, 'ACTIVE'); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 2 }); + assert.deepEqual(calls.sent, [{ id: 'p' }]); + }); + + it('assignPolicy throws 404 for an invalid target', async () => { + usersImpl = { getUserPermissions: async () => null }; + await assert.rejects(makeApi().assignPolicy(makeUser(), 'name', { policyIds: [], assign: true }), is404); + }); + + it('assignPolicy forwards policyIds/assign to guardians.assignEntity', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'did:owner', did: 'did:t' }) }; + let seen; + guardiansImpl = { assignEntity: async (user, type, policyIds, assign, did) => { seen = { type, policyIds, assign, did }; return true; } }; + await makeApi().assignPolicy(makeUser({ did: 'did:owner' }), 'name', { policyIds: ['p1'], assign: true }); + assert.deepEqual(seen, { type: 'Policy', policyIds: ['p1'], assign: true, did: 'did:t' }); + }); + + it('delegateRole throws 404 when target parent does not match the user parent', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'other', did: 'did:t' }) }; + await assert.rejects(makeApi().delegateRole(makeUser({ parent: 'did:p' }), 'name', {}), is404); + }); + + it('delegateRole delegates and sets the role on guardians', async () => { + usersImpl = { + getUserPermissions: async () => ({ parent: 'did:p', did: 'did:t' }), + delegateUserRole: async () => ({ delegated: true }), + }; + guardiansImpl = { setRole: async () => undefined }; + const out = await makeApi().delegateRole(makeUser({ parent: 'did:p', did: 'did:owner' }), 'name', {}); + assert.deepEqual(out, { delegated: true }); + }); + + it('delegatePolicy throws 404 for an invalid target', async () => { + usersImpl = { getUserPermissions: async () => null }; + await assert.rejects(makeApi().delegatePolicy(makeUser({ parent: 'did:p' }), 'name', {}), is404); + }); + + it('delegatePolicy forwards to guardians.delegateEntity', async () => { + usersImpl = { getUserPermissions: async () => ({ parent: 'did:p', did: 'did:t' }) }; + let seen; + guardiansImpl = { delegateEntity: async (user, type, policyIds, assign, did) => { seen = { policyIds, assign, did }; return 'ok'; } }; + const out = await makeApi().delegatePolicy(makeUser({ parent: 'did:p', did: 'did:owner' }), 'name', { policyIds: ['p2'], assign: false }); + assert.equal(out, 'ok'); + assert.deepEqual(seen, { policyIds: ['p2'], assign: false, did: 'did:t' }); + }); +}); diff --git a/api-gateway/tests/service/policy-comments.test.mjs b/api-gateway/tests/service/policy-comments.test.mjs new file mode 100644 index 0000000000..59b1f6ee27 --- /dev/null +++ b/api-gateway/tests/service/policy-comments.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeReq, makeCacheService, makeLogger, + internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/policy-comments.js'; + +let stub; + +class FakePolicyEngine { + constructor(tc) { this.tc = tc; } + getPolicyUsers(...a) { return stub.getPolicyUsers(...a); } + getDocumentRelationships(...a) { return stub.getDocumentRelationships(...a); } + getDocumentSchemas(...a) { return stub.getDocumentSchemas(...a); } + getPolicyDiscussions(...a) { return stub.getPolicyDiscussions(...a); } + createPolicyDiscussion(...a) { return stub.createPolicyDiscussion(...a); } + createPolicyComment(...a) { return stub.createPolicyComment(...a); } + getPolicyComments(...a) { return stub.getPolicyComments(...a); } + getPolicyCommentsCount(...a) { return stub.getPolicyCommentsCount(...a); } + addFileIpfs(...a) { return stub.addFileIpfs(...a); } + getFileIpfs(...a) { return stub.getFileIpfs(...a); } + getDiscussionKey(...a) { return stub.getDiscussionKey(...a); } +} + +async function load() { + return loadController(DIST, { + '#helpers': { + CacheService: class {}, getCacheKey: (t) => `k:${t.join('|')}`, + InternalException: internalExceptionRethrow, PolicyEngine: FakePolicyEngine, + UseCache: () => () => undefined + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#constants': { PREFIXES: { POLICY_COMMENTS: 'policy-comments/' } }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {} }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(Api) { const cache = makeCacheService(); return { api: new Api(cache, makeLogger()), cache }; } + +describe('PolicyCommentsApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ PolicyCommentsApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + getPolicyUsers: async () => [{ u: 1 }], + getDocumentRelationships: async () => ({ rel: true }), + getDocumentSchemas: async () => ([{ s: 1 }]), + getPolicyDiscussions: async () => ([{ d: 1 }]), + createPolicyDiscussion: async () => ({ created: true }), + createPolicyComment: async () => ({ comment: true }), + getPolicyComments: async () => ({ comments: [{ c: 1 }], count: 5 }), + getPolicyCommentsCount: async () => ({ count: 3 }), + addFileIpfs: async () => ({ cid: 'CID1' }), + getFileIpfs: async () => ({ type: 'Buffer', data: [1, 2, 3] }), + getDiscussionKey: async () => ({ type: 'Buffer', data: [4, 5] }) + }; + }); + + it('getUsers throws 422 without policyId', async () => { + await assert.rejects(makeApi(Api).api.getUsers(makeUser(), '', 'doc1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getUsers delegates to engine', async () => { + const out = await makeApi(Api).api.getUsers(makeUser(), 'pol1', 'doc1'); + assert.deepEqual(out, [{ u: 1 }]); + }); + + it('getRelationships throws 422 without policyId', async () => { + await assert.rejects(makeApi(Api).api.getRelationships(makeUser(), '', 'doc1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getRelationships delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getRelationships(makeUser(), 'pol1', 'doc1'), { rel: true }); + }); + + it('getSchemas throws 422 without policyId', async () => { + await assert.rejects(makeApi(Api).api.getSchemas(makeUser(), '', 'doc1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getSchemas delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getSchemas(makeUser(), 'pol1', 'doc1'), [{ s: 1 }]); + }); + + it('getDiscussions throws 422 without policyId', async () => { + await assert.rejects(makeApi(Api).api.getDiscussions(makeUser(), '', 'doc1', 's', 'f', false), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getDiscussions passes search/field/audit params', async () => { + let seen; + stub.getPolicyDiscussions = async (user, pol, doc, params) => { seen = params; return []; }; + await makeApi(Api).api.getDiscussions(makeUser(), 'pol1', 'doc1', 'srch', 'fld', false); + assert.equal(seen.search, 'srch'); + assert.equal(seen.field, 'fld'); + assert.equal(seen.audit, false); + }); + + it('getDiscussions sets audit true when readonly+audit permission', async () => { + let seen; + stub.getPolicyDiscussions = async (user, pol, doc, params) => { seen = params; return []; }; + const user = makeUser({ permissions: ['POLICIES_POLICY_AUDIT'] }); + await makeApi(Api).api.getDiscussions(user, 'pol1', 'doc1', '', '', true); + assert.equal(seen.audit, true); + }); + + it('createDiscussion delegates', async () => { + assert.deepEqual(await makeApi(Api).api.createDiscussion(makeUser(), 'pol1', 'doc1', { t: 1 }), { created: true }); + }); + + it('createDiscussion maps error to 422', async () => { + stub.createPolicyDiscussion = async () => { throw new Error('bad'); }; + await assert.rejects(makeApi(Api).api.createDiscussion(makeUser(), 'pol1', 'doc1', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createPolicyComment delegates with discussionId', async () => { + let seen; + stub.createPolicyComment = async (user, pol, doc, discId) => { seen = discId; return { comment: true }; }; + await makeApi(Api).api.createPolicyComment(makeUser(), 'pol1', 'doc1', 'disc1', {}); + assert.equal(seen, 'disc1'); + }); + + it('createPolicyComment maps error to 422', async () => { + stub.createPolicyComment = async () => { throw new Error('bad'); }; + await assert.rejects(makeApi(Api).api.createPolicyComment(makeUser(), 'pol1', 'doc1', 'disc1', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getPolicyComments sets count header and sends comments', async () => { + const res = makeRes(); + const out = await makeApi(Api).api.getPolicyComments(makeUser(), 'pol1', 'doc1', 'disc1', {}, res, false); + assert.equal(res.headers['X-Total-Count'], 5); + assert.deepEqual(out.payload, [{ c: 1 }]); + }); + + it('getPolicyComments merges audit flag into body', async () => { + let seen; + stub.getPolicyComments = async (user, pol, doc, disc, body) => { seen = body; return { comments: [], count: 0 }; }; + const user = makeUser({ permissions: ['POLICIES_POLICY_AUDIT'] }); + await makeApi(Api).api.getPolicyComments(user, 'pol1', 'doc1', 'disc1', { page: 1 }, makeRes(), true); + assert.equal(seen.audit, true); + assert.equal(seen.page, 1); + }); + + it('getPolicyCommentsCount delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getPolicyCommentsCount(makeUser(), 'pol1', 'doc1'), { count: 3 }); + }); + + it('getPolicyCommentsCount maps error to 422', async () => { + stub.getPolicyCommentsCount = async () => { throw new Error('bad'); }; + await assert.rejects(makeApi(Api).api.getPolicyCommentsCount(makeUser(), 'pol1', 'doc1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('postFile throws 422 when body empty', async () => { + await assert.rejects(makeApi(Api).api.postFile({}, makeUser(), 'pol1', 'doc1', 'disc1', makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('postFile throws 400 when cid missing', async () => { + stub.addFileIpfs = async () => ({ cid: null }); + await assert.rejects(makeApi(Api).api.postFile({ file: 1 }, makeUser(), 'pol1', 'doc1', 'disc1', makeReq()), (e) => { assert.equal(e.getStatus(), 400); return true; }); + }); + + it('postFile returns JSON cid and invalidates cache', async () => { + const { api, cache } = makeApi(Api); + const out = await api.postFile({ file: 1 }, makeUser(), 'pol1', 'doc1', 'disc1', makeReq()); + assert.equal(out, JSON.stringify('CID1')); + assert.equal(cache.calls.invalidate.length, 1); + }); + + it('getFile throws 404 when result is not Buffer', async () => { + stub.getFileIpfs = async () => ({ type: 'other' }); + await assert.rejects(makeApi(Api).api.getFile(makeUser(), 'pol1', 'doc1', 'disc1', 'cid'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('getFile returns a StreamableFile when Buffer', async () => { + const out = await makeApi(Api).api.getFile(makeUser(), 'pol1', 'doc1', 'disc1', 'cid'); + assert.ok(out); + assert.equal(out.constructor.name, 'StreamableFile'); + }); + + it('getKey throws 404 when result is not Buffer', async () => { + stub.getDiscussionKey = async () => ({ type: 'other' }); + await assert.rejects(makeApi(Api).api.getKey(makeUser(), 'pol1', 'doc1', 'disc1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('getKey returns a StreamableFile when Buffer', async () => { + const out = await makeApi(Api).api.getKey(makeUser(), 'pol1', 'doc1', 'disc1'); + assert.equal(out.constructor.name, 'StreamableFile'); + }); +}); diff --git a/api-gateway/tests/service/policy-labels.test.mjs b/api-gateway/tests/service/policy-labels.test.mjs new file mode 100644 index 0000000000..c972aa3e2f --- /dev/null +++ b/api-gateway/tests/service/policy-labels.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeLogger, FakeEntityOwner, + internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/policy-labels.js'; + +let stub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } + createPolicyLabel(...a) { return stub.createPolicyLabel(...a); } + getPolicyLabels(...a) { return stub.getPolicyLabels(...a); } + getPolicyLabelById(...a) { return stub.getPolicyLabelById(...a); } + updatePolicyLabel(...a) { return stub.updatePolicyLabel(...a); } + deletePolicyLabel(...a) { return stub.deletePolicyLabel(...a); } + publishPolicyLabel(...a) { return stub.publishPolicyLabel(...a); } + publishPolicyLabelAsync(...a) { return stub.publishPolicyLabelAsync(...a); } + getPolicyLabelRelationships(...a) { return stub.getPolicyLabelRelationships(...a); } + importPolicyLabel(...a) { return stub.importPolicyLabel(...a); } + exportPolicyLabel(...a) { return stub.exportPolicyLabel(...a); } + previewPolicyLabel(...a) { return stub.previewPolicyLabel(...a); } + searchComponents(...a) { return stub.searchComponents(...a); } + getPolicyLabelTokens(...a) { return stub.getPolicyLabelTokens(...a); } + getPolicyLabelTokenDocuments(...a) { return stub.getPolicyLabelTokenDocuments(...a); } + createLabelDocument(...a) { return stub.createLabelDocument(...a); } + getLabelDocuments(...a) { return stub.getLabelDocuments(...a); } + getLabelDocument(...a) { return stub.getLabelDocument(...a); } + getLabelDocumentRelationships(...a) { return stub.getLabelDocumentRelationships(...a); } +} + +class FakeTaskManager { + start(action, userId) { return { taskId: 'task-1', action, userId }; } + addError() {} + registerCallback() {} +} + +async function load() { + return loadController(DIST, { + '#helpers': { + Guardians: FakeGuardians, EntityOwner: FakeEntityOwner, + InternalException: internalExceptionRethrow, TaskManager: FakeTaskManager + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {}, RunFunctionAsync: () => undefined }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(Api) { return new Api(makeLogger()); } + +describe('PolicyLabelsApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ PolicyLabelsApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + createPolicyLabel: async (l, o) => ({ created: true, owner: o }), + getPolicyLabels: async () => ({ items: [{ id: 'l1' }], count: 6 }), + getPolicyLabelById: async () => ({ id: 'l1' }), + updatePolicyLabel: async () => ({ updated: true }), + deletePolicyLabel: async () => ({ deleted: true }), + publishPolicyLabel: async () => ({ published: true }), + publishPolicyLabelAsync: async () => ({}), + getPolicyLabelRelationships: async () => ({ rel: true }), + importPolicyLabel: async () => ({ imported: true }), + exportPolicyLabel: async () => Buffer.from('zip'), + previewPolicyLabel: async () => ({ preview: true }), + searchComponents: async () => ({ search: true }), + getPolicyLabelTokens: async () => ({ items: [{ t: 1 }], count: 3 }), + getPolicyLabelTokenDocuments: async () => ({ doc: true }), + createLabelDocument: async () => ({ labelCreated: true }), + getLabelDocuments: async () => ({ items: [{ d: 1 }], count: 9 }), + getLabelDocument: async () => ({ id: 'd1' }), + getLabelDocumentRelationships: async () => ({ rel: true }) + }; + }); + + it('createPolicyLabel throws 422 without label', async () => { + await assert.rejects(makeApi(Api).createPolicyLabel(makeUser(), null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createPolicyLabel delegates with owner', async () => { + const out = await makeApi(Api).createPolicyLabel(makeUser(), { n: 1 }); + assert.ok(out.owner instanceof FakeEntityOwner); + }); + + it('getPolicyLabels sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getPolicyLabels(makeUser(), res, 0, 10, 'topic'); + assert.equal(res.headers['X-Total-Count'], 6); + }); + + it('getPolicyLabels passes filter options', async () => { + let seen; + stub.getPolicyLabels = async (o) => { seen = o; return { items: [], count: 0 }; }; + await makeApi(Api).getPolicyLabels(makeUser(), makeRes(), 1, 7, 'top1'); + assert.deepEqual(seen, { policyInstanceTopicId: 'top1', pageIndex: 1, pageSize: 7 }); + }); + + it('getPolicyLabelById throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getPolicyLabelById(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getPolicyLabelById delegates', async () => { + assert.deepEqual(await makeApi(Api).getPolicyLabelById(makeUser(), 'l1'), { id: 'l1' }); + }); + + it('updatePolicyLabel throws 404 when not found', async () => { + stub.getPolicyLabelById = async () => null; + await assert.rejects(makeApi(Api).updatePolicyLabel(makeUser(), 'l1', {}), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('updatePolicyLabel delegates when found', async () => { + assert.deepEqual(await makeApi(Api).updatePolicyLabel(makeUser(), 'l1', {}), { updated: true }); + }); + + it('deletePolicyLabel throws 422 without id', async () => { + await assert.rejects(makeApi(Api).deletePolicyLabel(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deletePolicyLabel delegates', async () => { + assert.deepEqual(await makeApi(Api).deletePolicyLabel(makeUser(), 'l1'), { deleted: true }); + }); + + it('publishPolicyLabel throws 404 when not found', async () => { + stub.getPolicyLabelById = async () => null; + await assert.rejects(makeApi(Api).publishPolicyLabel(makeUser(), 'l1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('publishPolicyLabel delegates', async () => { + assert.deepEqual(await makeApi(Api).publishPolicyLabel(makeUser(), 'l1'), { published: true }); + }); + + it('publishPolicyLabelAsync throws 422 without id', async () => { + await assert.rejects(makeApi(Api).publishPolicyLabelAsync(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('publishPolicyLabelAsync throws 404 when not found', async () => { + stub.getPolicyLabelById = async () => null; + await assert.rejects(makeApi(Api).publishPolicyLabelAsync(makeUser(), 'l1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('publishPolicyLabelAsync returns a task', async () => { + const out = await makeApi(Api).publishPolicyLabelAsync(makeUser(), 'l1'); + assert.equal(out.taskId, 'task-1'); + }); + + it('getPolicyLabelRelationships throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getPolicyLabelRelationships(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getPolicyLabelRelationships delegates', async () => { + assert.deepEqual(await makeApi(Api).getPolicyLabelRelationships(makeUser(), 'l1'), { rel: true }); + }); + + it('importPolicyLabel delegates with zip+policyId', async () => { + let seen; + stub.importPolicyLabel = async (zip, policyId) => { seen = policyId; return {}; }; + await makeApi(Api).importPolicyLabel(makeUser(), 'pol1', { b: 1 }); + assert.equal(seen, 'pol1'); + }); + + it('exportPolicyLabel sets zip headers', async () => { + const res = makeRes(); + await makeApi(Api).exportPolicyLabel(makeUser(), 'l1', res); + assert.equal(res.headers['Content-type'], 'application/zip'); + }); + + it('previewPolicyLabel delegates', async () => { + assert.deepEqual(await makeApi(Api).previewPolicyLabel(makeUser(), {}), { preview: true }); + }); + + it('searchComponents delegates', async () => { + assert.deepEqual(await makeApi(Api).searchComponents(makeUser(), {}), { search: true }); + }); + + it('getPolicyLabelTokens sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getPolicyLabelTokens(makeUser(), res, 'l1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 3); + }); + + it('getPolicyLabelDocument delegates with documentId+definitionId', async () => { + let seen; + stub.getPolicyLabelTokenDocuments = async (documentId, definitionId) => { seen = { documentId, definitionId }; return {}; }; + await makeApi(Api).getPolicyLabelDocument(makeUser(), 'def1', 'doc1'); + assert.deepEqual(seen, { documentId: 'doc1', definitionId: 'def1' }); + }); + + it('createStatisticDocument throws 422 without id', async () => { + await assert.rejects(makeApi(Api).createStatisticDocument(makeUser(), '', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createStatisticDocument throws 422 without document', async () => { + await assert.rejects(makeApi(Api).createStatisticDocument(makeUser(), 'l1', null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createStatisticDocument delegates', async () => { + assert.deepEqual(await makeApi(Api).createStatisticDocument(makeUser(), 'l1', { x: 1 }), { labelCreated: true }); + }); + + it('getLabelDocuments throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getLabelDocuments(makeUser(), makeRes(), '', 0, 10), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getLabelDocuments sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getLabelDocuments(makeUser(), res, 'l1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 9); + }); + + it('getLabelDocument throws 422 when ids missing', async () => { + await assert.rejects(makeApi(Api).getLabelDocument(makeUser(), 'l1', ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getLabelDocument delegates', async () => { + assert.deepEqual(await makeApi(Api).getLabelDocument(makeUser(), 'l1', 'd1'), { id: 'd1' }); + }); + + it('getStatisticAssessmentRelationships throws 422 when ids missing', async () => { + await assert.rejects(makeApi(Api).getStatisticAssessmentRelationships(makeUser(), '', 'd1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticAssessmentRelationships delegates', async () => { + assert.deepEqual(await makeApi(Api).getStatisticAssessmentRelationships(makeUser(), 'l1', 'd1'), { rel: true }); + }); +}); diff --git a/api-gateway/tests/service/policy-repository-trustchains-wizard-controllers.test.mjs b/api-gateway/tests/service/policy-repository-trustchains-wizard-controllers.test.mjs new file mode 100644 index 0000000000..8fbdeee5c3 --- /dev/null +++ b/api-gateway/tests/service/policy-repository-trustchains-wizard-controllers.test.mjs @@ -0,0 +1,380 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const POLICY_REPO_DIST = '../../dist/api/service/policy-repository.js'; +const TRUST_CHAINS_DIST = '../../dist/api/service/trust-chains.js'; +const WIZARD_DIST = '../../dist/api/service/wizard.js'; + +const middlewaresMock = new Proxy({}, { get: () => class {} }); + +let policyEngineImpl; +let guardiansImpl; +let usersImpl; +let taskManagerImpl; + +class MockPolicyEngine { + constructor(tenantContext) { this.tenantContext = tenantContext; } + async getPolicyRepositoryUsers(...a) { return policyEngineImpl.getPolicyRepositoryUsers(...a); } + async getPolicyRepositorySchemas(...a) { return policyEngineImpl.getPolicyRepositorySchemas(...a); } + async getPolicyRepositoryDocuments(...a) { return policyEngineImpl.getPolicyRepositoryDocuments(...a); } +} + +class MockGuardians { + constructor(tenantContext) { this.tenantContext = tenantContext; } + async getVpDocuments(...a) { return guardiansImpl.getVpDocuments(...a); } + async getChain(...a) { return guardiansImpl.getChain(...a); } + async wizardPolicyCreate(...a) { return guardiansImpl.wizardPolicyCreate(...a); } + async wizardPolicyCreateAsyncNew(...a) { return guardiansImpl.wizardPolicyCreateAsyncNew(...a); } + async wizardGetPolicyConfig(...a) { return guardiansImpl.wizardGetPolicyConfig(...a); } +} + +class MockUsers { + constructor(tenantContext) { this.tenantContext = tenantContext; } + async getUsersByIds(...a) { return usersImpl.getUsersByIds(...a); } +} + +class MockEntityOwner { + constructor(user) { this.user = user; this.creator = user?.did; this.owner = user?.did; } +} + +class MockTaskManager { + start(...a) { return taskManagerImpl.start(...a); } + addError(...a) { return taskManagerImpl.addError(...a); } +} + +const helpersMock = { + PolicyEngine: MockPolicyEngine, + Guardians: MockGuardians, + Users: MockUsers, + EntityOwner: MockEntityOwner, + TaskManager: MockTaskManager, + CacheService: class {}, + getCacheKey: (keys) => `ck:${keys.join('|')}`, + UseCache: () => () => undefined, + ONLY_SR: '', + InternalException: async (error) => { throw error; }, +}; + +const commonMock = { + PinoLogger: class {}, + RunFunctionAsync: async (fn, onError) => { + try { + await fn(); + } catch (error) { + if (onError) { await onError(error); } + } + }, +}; + +const authMock = { Auth: () => () => undefined, AuthUser: () => () => undefined }; +const constantsMock = { PREFIXES: {}, CACHE: {} }; + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:u', username: 'bob', tenantContext: { tenantId: 't1' }, ...extra }; +} + +function makeRes() { + const res = { _headers: {} }; + res.header = (name, value) => { res._headers[name] = value; return res; }; + res.send = (payload) => { res._sent = payload; return payload; }; + return res; +} + +const statusIs = (code) => (e) => { assert.equal(e.getStatus(), code); return true; }; +const makeLogger = () => ({ error: async () => undefined }); + +describe('api-gateway controllers: policy-repository / trust-chains / wizard', function () { + this.timeout(180000); + + let PolicyRepositoryApi; + let TrustChainsApi; + let WizardApi; + + before(async () => { + ({ PolicyRepositoryApi } = await esmock(POLICY_REPO_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#constants': constantsMock, + '#middlewares': middlewaresMock, + '@guardian/common': commonMock, + })); + ({ TrustChainsApi } = await esmock(TRUST_CHAINS_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#constants': constantsMock, + '#middlewares': middlewaresMock, + '@guardian/common': commonMock, + })); + ({ WizardApi } = await esmock(WIZARD_DIST, { + '#helpers': helpersMock, + '#auth': authMock, + '#constants': constantsMock, + '#middlewares': middlewaresMock, + '@guardian/common': commonMock, + })); + }); + + beforeEach(() => { + policyEngineImpl = {}; + guardiansImpl = {}; + usersImpl = {}; + taskManagerImpl = { + start: () => ({ taskId: 'task-1', expectation: 0 }), + addError: () => undefined, + }; + }); + + describe('PolicyRepositoryApi', function () { + function makeApi() { return new PolicyRepositoryApi(makeLogger()); } + + it('getUsers forwards user + policyId to getPolicyRepositoryUsers and returns the result', async () => { + let seen; + policyEngineImpl.getPolicyRepositoryUsers = async (user, policyId) => { + seen = { user, policyId }; + return [{ label: 'admin' }]; + }; + const u = makeUser(); + const out = await makeApi().getUsers(u, 'p-1'); + assert.deepEqual(out, [{ label: 'admin' }]); + assert.equal(seen.user, u); + assert.equal(seen.policyId, 'p-1'); + }); + + it('getUsers rejects with 422 when policyId is missing', async () => { + await assert.rejects(makeApi().getUsers(makeUser(), ''), statusIs(422)); + }); + + it('getUsers maps a proxy error via InternalException', async () => { + policyEngineImpl.getPolicyRepositoryUsers = async () => { throw new HttpLikeError('boom', 500); }; + await assert.rejects(makeApi().getUsers(makeUser(), 'p-1'), (e) => { assert.match(e.message, /boom/); return true; }); + }); + + it('getSchemas forwards user + policyId to getPolicyRepositorySchemas and returns the result', async () => { + let seen; + policyEngineImpl.getPolicyRepositorySchemas = async (user, policyId) => { + seen = { user, policyId }; + return [{ iri: '#s1' }]; + }; + const u = makeUser(); + const out = await makeApi().getSchemas(u, 'p-2'); + assert.deepEqual(out, [{ iri: '#s1' }]); + assert.equal(seen.user, u); + assert.equal(seen.policyId, 'p-2'); + }); + + it('getSchemas rejects with 422 when policyId is missing', async () => { + await assert.rejects(makeApi().getSchemas(makeUser(), undefined), statusIs(422)); + }); + + it('getSchemas maps a proxy error via InternalException', async () => { + policyEngineImpl.getPolicyRepositorySchemas = async () => { throw new HttpLikeError('schema-fail', 500); }; + await assert.rejects(makeApi().getSchemas(makeUser(), 'p-2'), (e) => { assert.match(e.message, /schema-fail/); return true; }); + }); + + it('getDocuments builds the filters object, sets X-Total-Count and sends documents', async () => { + let seen; + policyEngineImpl.getPolicyRepositoryDocuments = async (user, policyId, filters) => { + seen = { user, policyId, filters }; + return { documents: [{ id: 'd1' }], count: 7 }; + }; + const res = makeRes(); + const u = makeUser(); + const out = await makeApi().getDocuments(u, res, 'p-3', 1, 20, 'VC', 'did:owner', '#schema', true); + assert.deepEqual(out, [{ id: 'd1' }]); + assert.equal(res._headers['X-Total-Count'], 7); + assert.equal(seen.policyId, 'p-3'); + assert.deepEqual(seen.filters, { + type: 'VC', + owner: 'did:owner', + schema: '#schema', + comments: true, + pageIndex: 1, + pageSize: 20, + }); + }); + + it('getDocuments rejects with 422 when policyId is missing', async () => { + await assert.rejects(makeApi().getDocuments(makeUser(), makeRes(), '', 0, 20), statusIs(422)); + }); + + it('getDocuments maps a proxy error via InternalException', async () => { + policyEngineImpl.getPolicyRepositoryDocuments = async () => { throw new HttpLikeError('doc-fail', 500); }; + await assert.rejects( + makeApi().getDocuments(makeUser(), makeRes(), 'p-3', 0, 20), + (e) => { assert.match(e.message, /doc-fail/); return true; } + ); + }); + }); + + describe('TrustChainsApi', function () { + function makeApi() { return new TrustChainsApi(makeLogger()); } + + it('getTrustChains passes undefined filters when neither policyId nor policyOwner are given', async () => { + let seen; + guardiansImpl.getVpDocuments = async (user, options) => { + seen = { user, options }; + return { items: [{ id: 'vp1' }], count: 3 }; + }; + const res = makeRes(); + const out = await makeApi().getTrustChains(makeUser(), res, 0, 20); + assert.deepEqual(out, [{ id: 'vp1' }]); + assert.equal(res._headers['X-Total-Count'], 3); + assert.equal(seen.options.filters, undefined); + assert.equal(seen.options.pageIndex, 0); + assert.equal(seen.options.pageSize, 20); + }); + + it('getTrustChains builds a policyId filter when policyId is provided', async () => { + let seen; + guardiansImpl.getVpDocuments = async (user, options) => { seen = options; return { items: [], count: 0 }; }; + await makeApi().getTrustChains(makeUser(), makeRes(), 1, 10, 'pol-9'); + assert.deepEqual(seen.filters, { policyId: 'pol-9' }); + }); + + it('getTrustChains builds a policyOwner filter when only policyOwner is provided', async () => { + let seen; + guardiansImpl.getVpDocuments = async (user, options) => { seen = options; return { items: [], count: 0 }; }; + await makeApi().getTrustChains(makeUser(), makeRes(), undefined, undefined, undefined, 'did:owner'); + assert.deepEqual(seen.filters, { policyOwner: 'did:owner' }); + }); + + it('getTrustChains prefers policyId over policyOwner when both are provided', async () => { + let seen; + guardiansImpl.getVpDocuments = async (user, options) => { seen = options; return { items: [], count: 0 }; }; + await makeApi().getTrustChains(makeUser(), makeRes(), undefined, undefined, 'pol-1', 'did:owner'); + assert.deepEqual(seen.filters, { policyId: 'pol-1' }); + }); + + it('getTrustChains maps a proxy error via InternalException', async () => { + guardiansImpl.getVpDocuments = async () => { throw new HttpLikeError('vp-fail', 500); }; + await assert.rejects( + makeApi().getTrustChains(makeUser(), makeRes()), + (e) => { assert.match(e.message, /vp-fail/); return true; } + ); + }); + + it('getTrustChainByHash returns the chain and a userMap built from chain DIDs', async () => { + guardiansImpl.getChain = async (user, hash) => { + assert.equal(hash, 'h-1'); + return [ + { type: 'VC', document: { issuer: 'did:issuer-str' } }, + { type: 'VC', document: { issuer: { id: 'did:issuer-obj' } } }, + { type: 'DID', id: 'did:did-node' }, + { type: 'VP', document: {} }, + ]; + }; + let seenIds; + usersImpl.getUsersByIds = async (dids, id) => { + seenIds = dids; + return [{ username: 'alice', did: 'did:issuer-str' }]; + }; + const out = await makeApi().getTrustChainByHash(makeUser(), 'h-1'); + assert.deepEqual(seenIds, ['did:issuer-str', 'did:issuer-obj', 'did:did-node']); + assert.deepEqual(out.chain.length, 4); + assert.deepEqual(out.userMap, [{ username: 'alice', did: 'did:issuer-str' }]); + }); + + it('getTrustChainByHash tolerates getUsersByIds returning null (empty userMap)', async () => { + guardiansImpl.getChain = async () => [{ type: 'DID', id: 'did:x' }]; + usersImpl.getUsersByIds = async () => null; + const out = await makeApi().getTrustChainByHash(makeUser(), 'h-2'); + assert.deepEqual(out.userMap, []); + assert.equal(out.chain.length, 1); + }); + + it('getTrustChainByHash maps a proxy error via InternalException', async () => { + guardiansImpl.getChain = async () => { throw new HttpLikeError('chain-fail', 500); }; + await assert.rejects( + makeApi().getTrustChainByHash(makeUser(), 'h-3'), + (e) => { assert.match(e.message, /chain-fail/); return true; } + ); + }); + }); + + describe('WizardApi', function () { + function makeApi() { return new WizardApi(makeLogger()); } + + it('setPolicy forwards the wizard config and an EntityOwner to wizardPolicyCreate', async () => { + let seen; + guardiansImpl.wizardPolicyCreate = async (config, owner) => { + seen = { config, owner }; + return { policyId: 'p-new' }; + }; + const cfg = { policy: { name: 'X' } }; + const out = await makeApi().setPolicy(makeUser(), cfg); + assert.deepEqual(out, { policyId: 'p-new' }); + assert.equal(seen.config, cfg); + assert.ok(seen.owner instanceof MockEntityOwner); + assert.equal(seen.owner.creator, 'did:u'); + }); + + it('setPolicy maps a proxy error via InternalException', async () => { + guardiansImpl.wizardPolicyCreate = async () => { throw new HttpLikeError('create-fail', 500); }; + await assert.rejects( + makeApi().setPolicy(makeUser(), {}), + (e) => { assert.match(e.message, /create-fail/); return true; } + ); + }); + + it('setPolicyAsync starts a task and returns it immediately', async () => { + let startedAction; + taskManagerImpl.start = (action, userId) => { + startedAction = { action, userId }; + return { taskId: 'task-async', expectation: 0 }; + }; + let createArgs; + guardiansImpl.wizardPolicyCreateAsyncNew = async (config, owner, saveState, task) => { + createArgs = { config, owner, saveState, task }; + }; + const cfg = { policy: { name: 'A' } }; + const out = await makeApi().setPolicyAsync(makeUser(), { wizardConfig: cfg, saveState: true }); + assert.deepEqual(out, { taskId: 'task-async', expectation: 0 }); + assert.equal(startedAction.userId, 'user-1'); + assert.equal(createArgs.config, cfg); + assert.equal(createArgs.saveState, true); + assert.ok(createArgs.owner instanceof MockEntityOwner); + assert.deepEqual(createArgs.task, { taskId: 'task-async', expectation: 0 }); + }); + + it('setPolicyAsync records the error on the task when the async run throws', async () => { + let addedError; + taskManagerImpl.addError = (taskId, error) => { addedError = { taskId, error }; }; + guardiansImpl.wizardPolicyCreateAsyncNew = async () => { throw new Error('async-boom'); }; + await makeApi().setPolicyAsync(makeUser(), { wizardConfig: {}, saveState: false }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(addedError.taskId, 'task-1'); + assert.equal(addedError.error.code, 500); + assert.equal(addedError.error.message, 'async-boom'); + }); + + it('setPolicyConfig forwards policyId, config and an EntityOwner to wizardGetPolicyConfig', async () => { + let seen; + guardiansImpl.wizardGetPolicyConfig = async (policyId, config, owner) => { + seen = { policyId, config, owner }; + return { preview: true }; + }; + const cfg = { policy: { name: 'C' } }; + const out = await makeApi().setPolicyConfig(makeUser(), 'p-cfg', cfg); + assert.deepEqual(out, { preview: true }); + assert.equal(seen.policyId, 'p-cfg'); + assert.equal(seen.config, cfg); + assert.ok(seen.owner instanceof MockEntityOwner); + }); + + it('setPolicyConfig maps a proxy error via InternalException', async () => { + guardiansImpl.wizardGetPolicyConfig = async () => { throw new HttpLikeError('cfg-fail', 500); }; + await assert.rejects( + makeApi().setPolicyConfig(makeUser(), 'p-cfg', {}), + (e) => { assert.match(e.message, /cfg-fail/); return true; } + ); + }); + }); +}); + +class HttpLikeError extends Error { + constructor(message, status) { + super(message); + this._status = status; + } + getStatus() { return this._status; } +} diff --git a/api-gateway/tests/service/policy-statistics.test.mjs b/api-gateway/tests/service/policy-statistics.test.mjs new file mode 100644 index 0000000000..500482a2aa --- /dev/null +++ b/api-gateway/tests/service/policy-statistics.test.mjs @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeLogger, FakeEntityOwner, + internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/policy-statistics.js'; + +let stub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } + createStatisticDefinition(...a) { return stub.createStatisticDefinition(...a); } + getStatisticDefinitions(...a) { return stub.getStatisticDefinitions(...a); } + getStatisticDefinitionById(...a) { return stub.getStatisticDefinitionById(...a); } + updateStatisticDefinition(...a) { return stub.updateStatisticDefinition(...a); } + deleteStatisticDefinition(...a) { return stub.deleteStatisticDefinition(...a); } + publishStatisticDefinition(...a) { return stub.publishStatisticDefinition(...a); } + getStatisticRelationships(...a) { return stub.getStatisticRelationships(...a); } + getStatisticDocuments(...a) { return stub.getStatisticDocuments(...a); } + createStatisticAssessment(...a) { return stub.createStatisticAssessment(...a); } + getStatisticAssessments(...a) { return stub.getStatisticAssessments(...a); } + getStatisticAssessment(...a) { return stub.getStatisticAssessment(...a); } + getStatisticAssessmentRelationships(...a) { return stub.getStatisticAssessmentRelationships(...a); } + importStatisticDefinition(...a) { return stub.importStatisticDefinition(...a); } + exportStatisticDefinition(...a) { return stub.exportStatisticDefinition(...a); } + previewStatisticDefinition(...a) { return stub.previewStatisticDefinition(...a); } +} + +async function load() { + return loadController(DIST, { + '#helpers': { Guardians: FakeGuardians, EntityOwner: FakeEntityOwner, InternalException: internalExceptionRethrow }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {} }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(Api) { return new Api(makeLogger()); } + +describe('PolicyStatisticsApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ PolicyStatisticsApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + createStatisticDefinition: async (d, o) => ({ created: true, owner: o }), + getStatisticDefinitions: async () => ({ items: [{ id: 's1' }], count: 4 }), + getStatisticDefinitionById: async () => ({ id: 's1' }), + updateStatisticDefinition: async () => ({ updated: true }), + deleteStatisticDefinition: async () => ({ deleted: true }), + publishStatisticDefinition: async () => ({ published: true }), + getStatisticRelationships: async () => ({ rel: true }), + getStatisticDocuments: async () => ({ items: [{ d: 1 }], count: 2 }), + createStatisticAssessment: async () => ({ assessed: true }), + getStatisticAssessments: async () => ({ items: [{ a: 1 }], count: 7 }), + getStatisticAssessment: async () => ({ id: 'a1' }), + getStatisticAssessmentRelationships: async () => ({ rel: true }), + importStatisticDefinition: async () => ({ imported: true }), + exportStatisticDefinition: async () => Buffer.from('zip'), + previewStatisticDefinition: async () => ({ preview: true }) + }; + }); + + it('createStatisticDefinition throws 422 without definition', async () => { + await assert.rejects(makeApi(Api).createStatisticDefinition(makeUser(), null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createStatisticDefinition delegates with owner', async () => { + const out = await makeApi(Api).createStatisticDefinition(makeUser(), { n: 1 }); + assert.ok(out.owner instanceof FakeEntityOwner); + }); + + it('getStatisticDefinitions sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getStatisticDefinitions(makeUser(), res, 0, 10, 'topic'); + assert.equal(res.headers['X-Total-Count'], 4); + }); + + it('getStatisticDefinitions passes filter options', async () => { + let seen; + stub.getStatisticDefinitions = async (o) => { seen = o; return { items: [], count: 0 }; }; + await makeApi(Api).getStatisticDefinitions(makeUser(), makeRes(), 2, 5, 't9'); + assert.deepEqual(seen, { policyInstanceTopicId: 't9', pageIndex: 2, pageSize: 5 }); + }); + + it('getStatisticDefinitionById throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getStatisticDefinitionById(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticDefinitionById delegates', async () => { + assert.deepEqual(await makeApi(Api).getStatisticDefinitionById(makeUser(), 's1'), { id: 's1' }); + }); + + it('updateStatisticDefinition throws 422 without id', async () => { + await assert.rejects(makeApi(Api).updateStatisticDefinition(makeUser(), '', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('updateStatisticDefinition throws 404 when not found', async () => { + stub.getStatisticDefinitionById = async () => null; + await assert.rejects(makeApi(Api).updateStatisticDefinition(makeUser(), 's1', {}), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('updateStatisticDefinition delegates when found', async () => { + assert.deepEqual(await makeApi(Api).updateStatisticDefinition(makeUser(), 's1', {}), { updated: true }); + }); + + it('deleteStatisticDefinition throws 422 without id', async () => { + await assert.rejects(makeApi(Api).deleteStatisticDefinition(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deleteStatisticDefinition delegates', async () => { + assert.deepEqual(await makeApi(Api).deleteStatisticDefinition(makeUser(), 's1'), { deleted: true }); + }); + + it('publishStatisticDefinition throws 404 when not found', async () => { + stub.getStatisticDefinitionById = async () => null; + await assert.rejects(makeApi(Api).publishStatisticDefinition(makeUser(), 's1'), (e) => { assert.equal(e.getStatus(), 404); return true; }); + }); + + it('publishStatisticDefinition delegates', async () => { + assert.deepEqual(await makeApi(Api).publishStatisticDefinition(makeUser(), 's1'), { published: true }); + }); + + it('getStatisticRelationships throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getStatisticRelationships(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticRelationships delegates', async () => { + assert.deepEqual(await makeApi(Api).getStatisticRelationships(makeUser(), 's1'), { rel: true }); + }); + + it('getStatisticDocuments sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getStatisticDocuments(makeUser(), res, 's1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 2); + }); + + it('createStatisticAssessment throws 422 without id', async () => { + await assert.rejects(makeApi(Api).createStatisticAssessment(makeUser(), '', {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createStatisticAssessment throws 422 without assessment', async () => { + await assert.rejects(makeApi(Api).createStatisticAssessment(makeUser(), 's1', null), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createStatisticAssessment delegates', async () => { + assert.deepEqual(await makeApi(Api).createStatisticAssessment(makeUser(), 's1', { x: 1 }), { assessed: true }); + }); + + it('getStatisticAssessments throws 422 without id', async () => { + await assert.rejects(makeApi(Api).getStatisticAssessments(makeUser(), makeRes(), '', 0, 10), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticAssessments sets count header', async () => { + const res = makeRes(); + await makeApi(Api).getStatisticAssessments(makeUser(), res, 's1', 0, 10); + assert.equal(res.headers['X-Total-Count'], 7); + }); + + it('getStatisticAssessment throws 422 when ids missing', async () => { + await assert.rejects(makeApi(Api).getStatisticAssessment(makeUser(), 's1', ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticAssessment delegates', async () => { + assert.deepEqual(await makeApi(Api).getStatisticAssessment(makeUser(), 's1', 'a1'), { id: 'a1' }); + }); + + it('getStatisticAssessmentRelationships throws 422 when ids missing', async () => { + await assert.rejects(makeApi(Api).getStatisticAssessmentRelationships(makeUser(), '', 'a1'), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getStatisticAssessmentRelationships delegates', async () => { + assert.deepEqual(await makeApi(Api).getStatisticAssessmentRelationships(makeUser(), 's1', 'a1'), { rel: true }); + }); + + it('importStatisticDefinition delegates with zip+policyId', async () => { + let seen; + stub.importStatisticDefinition = async (zip, policyId) => { seen = { zip, policyId }; return {}; }; + await makeApi(Api).importStatisticDefinition(makeUser(), 'pol1', { b: 1 }); + assert.equal(seen.policyId, 'pol1'); + }); + + it('exportStatisticDefinition sets zip headers', async () => { + const res = makeRes(); + await makeApi(Api).exportStatisticDefinition(makeUser(), 's1', res); + assert.equal(res.headers['Content-type'], 'application/zip'); + }); + + it('previewStatisticDefinition delegates', async () => { + assert.deepEqual(await makeApi(Api).previewStatisticDefinition(makeUser(), { b: 1 }), { preview: true }); + }); +}); diff --git a/api-gateway/tests/service/relayer-external-controllers.test.mjs b/api-gateway/tests/service/relayer-external-controllers.test.mjs new file mode 100644 index 0000000000..ece3b80f92 --- /dev/null +++ b/api-gateway/tests/service/relayer-external-controllers.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const RELAYER_DIST = '../../dist/api/service/relayer-accounts.js'; +const EXTERNAL_DIST = '../../dist/api/service/external.js'; + +const authMock = { Auth: () => () => undefined, AuthUser: () => () => undefined }; +const middlewaresMock = new Proxy({}, { get: (t, p) => (p === 'Examples' || p === 'ObjectExamples') ? new Proxy({}, { get: () => 'ex' }) : (p === 'pageHeader' ? {} : class {}) }); + +function makeUser(extra = {}) { + return { id: 'user-1', tenantContext: { tenantId: 't1' }, ...extra }; +} +function makeRes() { + const calls = { header: null, sent: undefined }; + const res = { header: (k, v) => { calls.header = { k, v }; return { send: (b) => { calls.sent = b; return { calls }; } }; } }; + return { res, calls }; +} + +describe('RelayerAccountsApi', function () { + this.timeout(60000); + let RelayerAccountsApi; + let usersImpl; + let guardiansImpl; + let ctorTc; + + before(async () => { + ({ RelayerAccountsApi } = await esmock(RELAYER_DIST, { + '#auth': authMock, + '#helpers': { + InternalException: async (e) => { throw e; }, + Guardians: class { constructor(tc) { ctorTc = tc; } async getRelayerAccountRelationships(...a) { return guardiansImpl.getRelayerAccountRelationships(...a); } }, + Users: class { + constructor(tc) { ctorTc = tc; } + async getRelayerAccounts(...a) { return usersImpl.getRelayerAccounts(...a); } + async createRelayerAccount(...a) { return usersImpl.createRelayerAccount(...a); } + async getCurrentRelayerAccount(...a) { return usersImpl.getCurrentRelayerAccount(...a); } + async getRelayerAccountsAll(...a) { return usersImpl.getRelayerAccountsAll(...a); } + async getRelayerAccountBalance(...a) { return usersImpl.getRelayerAccountBalance(...a); } + async generateRelayerAccount(...a) { return usersImpl.generateRelayerAccount(...a); } + async getUserRelayerAccounts(...a) { return usersImpl.getUserRelayerAccounts(...a); } + }, + }, + '@guardian/common': { PinoLogger: class {} }, + '#middlewares': middlewaresMock, + })); + }); + + function makeApi() { return new RelayerAccountsApi({ error: () => undefined }); } + + it('getRelayerAccounts sets the total-count header and sends items', async () => { + usersImpl = { getRelayerAccounts: async () => ({ items: [{ id: 'a' }], count: 3 }) }; + const { res, calls } = makeRes(); + await makeApi().getRelayerAccounts(makeUser(), res, 0, 20, 'q'); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 3 }); + assert.deepEqual(calls.sent, [{ id: 'a' }]); + }); + + it('getRelayerAccounts forwards filters (search/pageIndex/pageSize)', async () => { + let seen; + usersImpl = { getRelayerAccounts: async (user, filters) => { seen = filters; return { items: [], count: 0 }; } }; + const { res } = makeRes(); + await makeApi().getRelayerAccounts(makeUser(), res, 2, 50, 'term'); + assert.deepEqual(seen, { search: 'term', pageIndex: 2, pageSize: 50 }); + }); + + it('getRelayerAccounts rethrows via InternalException', async () => { + usersImpl = { getRelayerAccounts: async () => { throw new Error('list'); } }; + const { res } = makeRes(); + await assert.rejects(makeApi().getRelayerAccounts(makeUser(), res, 0, 0), /list/); + }); + + it('createRelayerAccount forwards the body to users', async () => { + let seen; + usersImpl = { createRelayerAccount: async (user, body) => { seen = body; return { id: 'new' }; } }; + const out = await makeApi().createRelayerAccount(makeUser(), { name: 'acc' }); + assert.deepEqual(out, { id: 'new' }); + assert.deepEqual(seen, { name: 'acc' }); + }); + + it('createRelayerAccount stamps 422 onto errors before rethrowing', async () => { + usersImpl = { createRelayerAccount: async () => { throw new Error('bad'); } }; + await assert.rejects(makeApi().createRelayerAccount(makeUser(), {}), (e) => { assert.equal(e.code, 422); return true; }); + }); + + it('getCurrentRelayerAccount returns the current account', async () => { + usersImpl = { getCurrentRelayerAccount: async () => ({ current: true }) }; + assert.deepEqual(await makeApi().getCurrentRelayerAccount(makeUser()), { current: true }); + }); + + it('getRelayerAccountsAll returns all accounts', async () => { + usersImpl = { getRelayerAccountsAll: async () => [{ id: 1 }, { id: 2 }] }; + assert.deepEqual(await makeApi().getRelayerAccountsAll(makeUser()), [{ id: 1 }, { id: 2 }]); + }); + + it('getRelayerAccountBalance forwards the account param', async () => { + let seen; + usersImpl = { getRelayerAccountBalance: async (user, account) => { seen = account; return '100'; } }; + const out = await makeApi().getRelayerAccountBalance(makeUser(), '0.0.5'); + assert.equal(out, '100'); + assert.equal(seen, '0.0.5'); + }); + + it('generateRelayerAccount stamps 422 onto errors before rethrowing', async () => { + usersImpl = { generateRelayerAccount: async () => { throw new Error('gen'); } }; + await assert.rejects(makeApi().generateRelayerAccount(makeUser()), (e) => { assert.equal(e.code, 422); return true; }); + }); + + it('generateRelayerAccount returns the generated account', async () => { + usersImpl = { generateRelayerAccount: async () => ({ generated: true }) }; + assert.deepEqual(await makeApi().generateRelayerAccount(makeUser()), { generated: true }); + }); + + it('getUserRelayerAccounts sets the total-count header and sends items', async () => { + usersImpl = { getUserRelayerAccounts: async () => ({ items: ['x'], count: 9 }) }; + const { res, calls } = makeRes(); + await makeApi().getUserRelayerAccounts(makeUser(), res, 0, 10, ''); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 9 }); + assert.deepEqual(calls.sent, ['x']); + }); + + it('getRelayerAccountRelationships uses Guardians and forwards the account id', async () => { + let seen; + guardiansImpl = { getRelayerAccountRelationships: async (id, user, filters) => { seen = { id, filters }; return { items: ['r'], count: 1 }; } }; + const { res, calls } = makeRes(); + await makeApi().getRelayerAccountRelationships(makeUser(), res, 'acc-1', 1, 5); + assert.equal(seen.id, 'acc-1'); + assert.deepEqual(seen.filters, { pageIndex: 1, pageSize: 5 }); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 1 }); + }); + + it('getRelayerAccountRelationships rethrows via InternalException', async () => { + guardiansImpl = { getRelayerAccountRelationships: async () => { throw new Error('rel'); } }; + const { res } = makeRes(); + await assert.rejects(makeApi().getRelayerAccountRelationships(makeUser(), res, 'x', 0, 0), /rel/); + }); +}); + +describe('ExternalApi', function () { + this.timeout(60000); + let ExternalApi; + let engineImpl; + + before(async () => { + ({ ExternalApi } = await esmock(EXTERNAL_DIST, { + '#helpers': { + InternalException: async (e) => { throw e; }, + PolicyEngine: class { + constructor(tc) { this.tc = tc; } + async receiveExternalDataCustom(...a) { return engineImpl.receiveExternalDataCustom(...a); } + async receiveExternalData(...a) { return engineImpl.receiveExternalData(...a); } + }, + }, + '#middlewares': middlewaresMock, + '@guardian/common': { PinoLogger: class {} }, + })); + }); + + function makeApi() { return new ExternalApi({ error: () => undefined }); } + + it('receiveExternalDataCustom forwards document, policyId and blockTag', async () => { + let seen; + engineImpl = { receiveExternalDataCustom: async (doc, policyId, blockTag) => { seen = { doc, policyId, blockTag }; return { ok: true }; } }; + const out = await makeApi().receiveExternalDataCustom('p1', 'tag1', { d: 1 }); + assert.deepEqual(out, { ok: true }); + assert.deepEqual(seen, { doc: { d: 1 }, policyId: 'p1', blockTag: 'tag1' }); + }); + + it('receiveExternalDataCustom rethrows via InternalException', async () => { + engineImpl = { receiveExternalDataCustom: async () => { throw new Error('c'); } }; + await assert.rejects(makeApi().receiveExternalDataCustom('p', 't', {}), /c/); + }); + + it('receiveExternalData forwards the document', async () => { + let seen; + engineImpl = { receiveExternalData: async (doc) => { seen = doc; return 'done'; } }; + const out = await makeApi().receiveExternalData({ d: 2 }); + assert.equal(out, 'done'); + assert.deepEqual(seen, { d: 2 }); + }); + + it('receiveExternalDataCustomWithSyncEvents enables sync events and passes history flag', async () => { + let seen; + engineImpl = { receiveExternalDataCustom: async (doc, policyId, blockTag, sync, history) => { seen = { sync, history }; return {}; } }; + await makeApi().receiveExternalDataCustomWithSyncEvents('p', 't', 'yes', {}); + assert.equal(seen.sync, true); + assert.equal(seen.history, true); + }); + + it('receiveExternalDataCustomWithSyncEvents coerces a falsy history to false', async () => { + let seen; + engineImpl = { receiveExternalDataCustom: async (doc, policyId, blockTag, sync, history) => { seen = history; return {}; } }; + await makeApi().receiveExternalDataCustomWithSyncEvents('p', 't', '', {}); + assert.equal(seen, false); + }); + + it('receiveExternalDataWithSyncEvents enables sync events and passes history flag', async () => { + let seen; + engineImpl = { receiveExternalData: async (doc, sync, history) => { seen = { sync, history }; return {}; } }; + await makeApi().receiveExternalDataWithSyncEvents('1', {}); + assert.equal(seen.sync, true); + assert.equal(seen.history, true); + }); + + it('receiveExternalDataWithSyncEvents rethrows via InternalException', async () => { + engineImpl = { receiveExternalData: async () => { throw new Error('s'); } }; + await assert.rejects(makeApi().receiveExternalDataWithSyncEvents('1', {}), /s/); + }); +}); diff --git a/api-gateway/tests/service/tags-controller.test.mjs b/api-gateway/tests/service/tags-controller.test.mjs new file mode 100644 index 0000000000..cbf2629b95 --- /dev/null +++ b/api-gateway/tests/service/tags-controller.test.mjs @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const TAGS_DIST = '../../dist/api/service/tags.js'; +const authMock = { Auth: () => () => undefined, AuthUser: () => () => undefined }; +const middlewaresMock = new Proxy({}, { get: (t, p) => (p === 'Examples' || p === 'ObjectExamples') ? new Proxy({}, { get: () => 'ex' }) : (p === 'pageHeader' ? {} : class {}) }); + +function makeUser(extra = {}) { + return { id: 'user-1', did: 'did:u', role: 'STANDARD_REGISTRY', permissions: [], tenantContext: { tenantId: 't1' }, ...extra }; +} +function makeReq(extra = {}) { return { url: '/tags', user: makeUser(), params: {}, ...extra }; } +function makeRes() { + const calls = {}; + const res = { header: (k, v) => { calls.header = { k, v }; return { send: (b) => { calls.sent = b; return calls; } }; } }; + return { res, calls }; +} + +describe('TagsApi', function () { + this.timeout(60000); + let TagsApi; + let guardiansImpl; + let invalidated; + let runRan; + + before(async () => { + ({ TagsApi } = await esmock(TAGS_DIST, { + '@guardian/common': { PinoLogger: class {}, RunFunctionAsync: (fn) => { runRan = true; fn(); } }, + '@guardian/interfaces': { + Permissions: { POLICIES_POLICY_TAG: 'POLICIES_POLICY_TAG' }, + SchemaCategory: { TAG: 'TAG' }, + SchemaHelper: { updateOwner: () => undefined, checkSchemaKey: () => undefined }, + TagType: { PolicyBlock: 'PolicyBlock' }, + TaskAction: { DELETE_SCHEMAS: 'DELETE_SCHEMAS' }, + UserRole: { USER: 'USER' }, + }, + '#middlewares': middlewaresMock, + '#auth': authMock, + '#helpers': { + ONLY_SR: '', + SchemaUtils: { + toOld: (x) => x, + fromOld: () => undefined, + clearIds: () => undefined, + checkPermission: (...a) => guardiansImpl.checkPermission ? guardiansImpl.checkPermission(...a) : null, + }, + Guardians: class { + constructor(tc) { this.tc = tc; } + async createTag(...a) { return guardiansImpl.createTag(...a); } + async getTags(...a) { return guardiansImpl.getTags(...a); } + async getTagCache(...a) { return guardiansImpl.getTagCache(...a); } + async deleteTag(...a) { return guardiansImpl.deleteTag(...a); } + async synchronizationTags(...a) { return guardiansImpl.synchronizationTags(...a); } + async getTagSchemas(...a) { return guardiansImpl.getTagSchemas(...a); } + async getTagSchemasV2(...a) { return guardiansImpl.getTagSchemasV2(...a); } + async createTagSchema(...a) { return guardiansImpl.createTagSchema(...a); } + async getSchemaById(...a) { return guardiansImpl.getSchemaById(...a); } + async deleteSchema(...a) { return guardiansImpl.deleteSchema(...a); } + async updateSchema(...a) { return guardiansImpl.updateSchema(...a); } + async publishTagSchema(...a) { return guardiansImpl.publishTagSchema(...a); } + async getPublishedTagSchemas(...a) { return guardiansImpl.getPublishedTagSchemas(...a); } + }, + InternalException: async (e) => { throw e; }, + EntityOwner: class { constructor(user) { this.creator = user?.did; } }, + CacheService: class {}, + getCacheKey: (keys) => `ck:${keys.join('|')}`, + UseCache: () => () => undefined, + TaskManager: class { start(action, id) { return { taskId: 't1', action, id }; } addError() {} }, + }, + '#constants': { PREFIXES: { TAGS: 'tags/', SCHEMES: 'schemes/' }, SCHEMA_REQUIRED_PROPS: { a: 'a' } }, + })); + }); + + function makeApi() { + invalidated = []; + runRan = false; + const cacheService = { invalidate: async (k) => { invalidated.push(k); } }; + return new TagsApi(cacheService, { error: () => undefined }); + } + + it('setTags creates a tag and invalidates the schemas cache', async () => { + let seen; + guardiansImpl = { createTag: async (body, owner) => { seen = { body, owner }; return { id: 'tag1' }; } }; + const out = await makeApi().setTags(makeUser(), { entity: 'Other', name: 'x' }, makeReq()); + assert.deepEqual(out, { id: 'tag1' }); + assert.equal(invalidated.length, 1); + assert.deepEqual(seen.body, { entity: 'Other', name: 'x' }); + }); + + it('setTags forbids a plain USER without POLICIES_POLICY_TAG on a PolicyBlock tag', async () => { + guardiansImpl = { createTag: async () => ({}) }; + await assert.rejects( + makeApi().setTags(makeUser({ role: 'USER', permissions: [] }), { entity: 'PolicyBlock' }, makeReq()), + (e) => { assert.equal(e.getStatus(), 403); return true; } + ); + }); + + it('setTags allows a USER that holds POLICIES_POLICY_TAG', async () => { + guardiansImpl = { createTag: async () => ({ id: 'ok' }) }; + const out = await makeApi().setTags( + makeUser({ role: 'USER', permissions: ['POLICIES_POLICY_TAG'] }), + { entity: 'PolicyBlock' }, + makeReq() + ); + assert.deepEqual(out, { id: 'ok' }); + }); + + it('searchTags throws 422 when entity is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().searchTags(makeUser(), { target: 'x' }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('searchTags throws 422 when neither target nor targets are valid', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().searchTags(makeUser(), { entity: 'E' }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('searchTags throws 422 when target is not a string', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().searchTags(makeUser(), { entity: 'E', target: 123 }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('searchTags groups tags by localTarget and attaches refresh dates', async () => { + guardiansImpl = { + getTags: async () => [ + { localTarget: 'A', name: 't1' }, + { localTarget: 'A', name: 't2' }, + { localTarget: 'B', name: 't3' }, + ], + getTagCache: async () => [{ localTarget: 'A', date: '2020' }], + }; + const out = await makeApi().searchTags(makeUser(), { entity: 'E', target: 'A' }, makeReq()); + assert.equal(out.A.tags.length, 2); + assert.equal(out.A.refreshDate, '2020'); + assert.equal(out.B.tags.length, 1); + }); + + it('searchTags accepts a targets array', async () => { + let seenTargets; + guardiansImpl = { + getTags: async (owner, entity, targets) => { seenTargets = targets; return []; }, + getTagCache: async () => [], + }; + await makeApi().searchTags(makeUser(), { entity: 'E', targets: ['x', 'y'] }, makeReq()); + assert.deepEqual(seenTargets, ['x', 'y']); + }); + + it('deleteTag throws 422 when uuid is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().deleteTag(makeUser(), '', makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deleteTag deletes and invalidates cache', async () => { + let seen; + guardiansImpl = { deleteTag: async (uuid, owner) => { seen = uuid; return true; } }; + const out = await makeApi().deleteTag(makeUser(), 'uuid-1', makeReq()); + assert.equal(out, true); + assert.equal(seen, 'uuid-1'); + assert.equal(invalidated.length, 1); + }); + + it('synchronizationTags throws 422 without an entity', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().synchronizationTags(makeUser(), { target: 'x' }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('synchronizationTags throws 422 when target is not a string', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().synchronizationTags(makeUser(), { entity: 'E', target: 5 }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('synchronizationTags returns entity, target, tags and an ISO refresh date', async () => { + guardiansImpl = { synchronizationTags: async () => [{ id: 't' }] }; + const out = await makeApi().synchronizationTags(makeUser(), { entity: 'E', target: 'A' }, makeReq()); + assert.equal(out.entity, 'E'); + assert.equal(out.target, 'A'); + assert.deepEqual(out.tags, [{ id: 't' }]); + assert.match(out.refreshDate, /\d{4}-\d{2}-\d{2}T/); + }); + + it('getSchemas sets total-count header and marks readonly for foreign owners', async () => { + guardiansImpl = { getTagSchemas: async () => ({ items: [{ owner: 'other' }, { owner: 'did:u' }], count: 2 }) }; + const { res, calls } = makeRes(); + await makeApi().getSchemas(makeUser(), makeReq(), res, 0, 20); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 2 }); + assert.equal(calls.sent[0].readonly, true); + assert.equal(calls.sent[1].readonly, false); + }); + + it('getSchemasV2 sets total-count header', async () => { + guardiansImpl = { getTagSchemasV2: async () => ({ items: [{ owner: 'did:u' }], count: 1 }) }; + const { res, calls } = makeRes(); + await makeApi().getSchemasV2(makeUser(), makeReq(), res, 0, 20); + assert.deepEqual(calls.header, { k: 'X-Total-Count', v: 1 }); + }); + + it('postSchemas throws 422 when newSchema is missing', async () => { + guardiansImpl = {}; + await assert.rejects(makeApi().postSchemas(makeUser(), null, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('postSchemas sets the TAG category and creates the schema', async () => { + let created; + guardiansImpl = { createTagSchema: async (schema) => { created = schema; return { id: 's' }; } }; + const out = await makeApi().postSchemas(makeUser(), { name: 'S' }, makeReq()); + assert.deepEqual(out, { id: 's' }); + assert.equal(created.category, 'TAG'); + }); + + it('deleteSchema throws 403 when permission is denied', async () => { + guardiansImpl = { getSchemaById: async () => ({ id: 's' }), checkPermission: () => 'no access' }; + await assert.rejects(makeApi().deleteSchema(makeUser(), 's1', makeReq()), (e) => { assert.equal(e.getStatus(), 403); return true; }); + }); + + it('deleteSchema starts a delete task and returns true', async () => { + guardiansImpl = { getSchemaById: async () => ({}), checkPermission: () => null, deleteSchema: async () => undefined }; + const out = await makeApi().deleteSchema(makeUser(), 's1', makeReq()); + assert.equal(out, true); + assert.equal(runRan, true); + }); + + it('updateSchema throws 403 when permission is denied', async () => { + guardiansImpl = { getSchemaById: async () => ({}), checkPermission: () => 'denied' }; + await assert.rejects(makeApi().updateSchema(makeUser(), 's1', { id: 's1' }, makeReq()), (e) => { assert.equal(e.getStatus(), 403); return true; }); + }); + + it('updateSchema updates when permission is granted', async () => { + guardiansImpl = { getSchemaById: async () => ({}), checkPermission: () => null, updateSchema: async () => ({ updated: true }) }; + const out = await makeApi().updateSchema(makeUser(), 's1', { id: 's1' }, makeReq()); + assert.deepEqual(out, { updated: true }); + }); + + it('publishTag throws 403 when permission is denied', async () => { + guardiansImpl = { getSchemaById: async () => ({}), checkPermission: () => 'denied' }; + await assert.rejects(makeApi().publishTag(makeUser(), 's1', makeReq()), (e) => { assert.equal(e.getStatus(), 403); return true; }); + }); + + it('publishTag publishes with version 1.0.0', async () => { + let seenVersion; + guardiansImpl = { getSchemaById: async () => ({}), checkPermission: () => null, publishTagSchema: async (id, version) => { seenVersion = version; return { published: true }; } }; + const out = await makeApi().publishTag(makeUser(), 's1', makeReq()); + assert.deepEqual(out, { published: true }); + assert.equal(seenVersion, '1.0.0'); + }); + + it('getPublished returns the published tag schemas', async () => { + guardiansImpl = { getPublishedTagSchemas: async () => [{ id: 'p' }] }; + assert.deepEqual(await makeApi().getPublished(makeUser()), [{ id: 'p' }]); + }); + + it('getPublished rethrows via InternalException', async () => { + guardiansImpl = { getPublishedTagSchemas: async () => { throw new Error('pub'); } }; + await assert.rejects(makeApi().getPublished(makeUser()), /pub/); + }); +}); diff --git a/api-gateway/tests/service/tool.test.mjs b/api-gateway/tests/service/tool.test.mjs new file mode 100644 index 0000000000..41b8c534d7 --- /dev/null +++ b/api-gateway/tests/service/tool.test.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { + makeUser, makeRes, makeReq, makeCacheService, makeLogger, + FakeEntityOwner, internalExceptionRethrow, loadController, guardiansInterfaces +} from './_controller-harness.mjs'; + +const DIST = '../../dist/api/service/tool.js'; + +let stub; + +class FakeGuardians { + constructor(tc) { this.tc = tc; } + createTool(...a) { return stub.createTool(...a); } + createToolAsync(...a) { return stub.createToolAsync(...a); } + getTools(...a) { return stub.getTools(...a); } + getToolsV2(...a) { return stub.getToolsV2(...a); } + deleteTool(...a) { return stub.deleteTool(...a); } + getToolById(...a) { return stub.getToolById(...a); } + updateTool(...a) { return stub.updateTool(...a); } + publishTool(...a) { return stub.publishTool(...a); } + publishToolAsync(...a) { return stub.publishToolAsync(...a); } + dryRunTool(...a) { return stub.dryRunTool(...a); } + draftTool(...a) { return stub.draftTool(...a); } + validateTool(...a) { return stub.validateTool(...a); } + exportToolFile(...a) { return stub.exportToolFile(...a); } + exportToolMessage(...a) { return stub.exportToolMessage(...a); } + previewToolMessage(...a) { return stub.previewToolMessage(...a); } + importToolMessage(...a) { return stub.importToolMessage(...a); } + previewToolFile(...a) { return stub.previewToolFile(...a); } + importToolFile(...a) { return stub.importToolFile(...a); } + importToolMessageAsync(...a) { return stub.importToolMessageAsync(...a); } + getMenuTool(...a) { return stub.getMenuTool(...a); } + checkTool(...a) { return stub.checkTool(...a); } +} + +class FakeTaskManager { + start(action, userId) { return { taskId: 'task-1', action, userId }; } + addError() {} +} + +async function load() { + return loadController(DIST, { + '#helpers': { + UseCache: () => () => undefined, TaskManager: FakeTaskManager, Guardians: FakeGuardians, + InternalException: internalExceptionRethrow, ONLY_SR: '', UploadedFiles: () => () => undefined, + AnyFilesInterceptor: () => class {}, EntityOwner: FakeEntityOwner, CacheService: class {} + }, + '#auth': { Auth: () => () => undefined, AuthUser: () => () => undefined }, + '#constants': { CACHE_PREFIXES: { TAG: 'tag' }, TOOL_REQUIRED_PROPS: { a: 'id' } }, + '#middlewares': new Proxy({}, { get: () => class {} }), + '@guardian/common': { PinoLogger: class {}, RunFunctionAsync: () => undefined }, + '@guardian/interfaces': guardiansInterfaces + }); +} + +function makeApi(Api) { const cache = makeCacheService(); return { api: new Api(cache, makeLogger()), cache }; } + +describe('ToolsApi controller logic', function () { + this.timeout(60000); + let Api; + before(async () => { ({ ToolsApi: Api } = await load()); }); + + beforeEach(() => { + stub = { + createTool: async () => ({ created: true }), + createToolAsync: async () => ({}), + getTools: async () => ({ items: [{ t: 1 }], count: 5 }), + getToolsV2: async () => ({ items: [{ t: 2 }], count: 9 }), + deleteTool: async () => ({ deleted: true }), + getToolById: async () => ({ id: 't1' }), + updateTool: async () => ({ updated: true }), + publishTool: async () => ({ published: true }), + publishToolAsync: async () => ({}), + dryRunTool: async () => ({ dry: true }), + draftTool: async () => ({ draft: true }), + validateTool: async () => ({ valid: true }), + exportToolFile: async () => Buffer.from('zip'), + exportToolMessage: async () => ({ messageId: 'mid' }), + previewToolMessage: async () => ({ preview: true }), + importToolMessage: async () => ({ imported: true }), + previewToolFile: async () => ({ previewFile: true }), + importToolFile: async () => ({ importedFile: true }), + importToolMessageAsync: async () => ({}), + getMenuTool: async () => ([{ menu: 1 }]), + checkTool: async () => ({ checked: true }) + }; + }); + + const goodTool = { config: { blockType: 'tool' } }; + + it('createNewTool throws 422 with invalid config', async () => { + await assert.rejects(makeApi(Api).api.createNewTool(makeUser(), { config: { blockType: 'x' } }, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createNewTool delegates and invalidates prefixes', async () => { + const { api, cache } = makeApi(Api); + const out = await api.createNewTool(makeUser(), goodTool, makeReq()); + assert.deepEqual(out, { created: true }); + assert.equal(cache.calls.invalidateAllTagsByPrefixes.length, 1); + }); + + it('createNewToolAsync throws 422 with invalid config', async () => { + await assert.rejects(makeApi(Api).api.createNewToolAsync(makeUser(), {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('createNewToolAsync returns a task', async () => { + const out = await makeApi(Api).api.createNewToolAsync(makeUser(), goodTool, makeReq()); + assert.equal(out.taskId, 'task-1'); + }); + + it('getTools sets count header', async () => { + const res = makeRes(); + await makeApi(Api).api.getTools(makeUser(), res, 0, 10); + assert.equal(res.headers['X-Total-Count'], 5); + }); + + it('getTools passes paging options', async () => { + let seen; + stub.getTools = async (o) => { seen = o; return { items: [], count: 0 }; }; + await makeApi(Api).api.getTools(makeUser(), makeRes(), 2, 20); + assert.deepEqual(seen, { pageIndex: 2, pageSize: 20 }); + }); + + it('getToolsV2 passes search/tag/fields', async () => { + let seenFields, seenOpts; + stub.getToolsV2 = async (fields, opts) => { seenFields = fields; seenOpts = opts; return { items: [], count: 9 }; }; + await makeApi(Api).api.getToolsV2(makeUser(), makeRes(), 0, 10, 'srch', 'tag1'); + assert.deepEqual(seenFields, ['id']); + assert.equal(seenOpts.search, 'srch'); + assert.equal(seenOpts.tag, 'tag1'); + }); + + it('deleteTool throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.deleteTool(makeUser(), '', makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('deleteTool delegates and invalidates prefixes', async () => { + const { api, cache } = makeApi(Api); + await api.deleteTool(makeUser(), 't1', makeReq()); + assert.equal(cache.calls.invalidateAllTagsByPrefixes.length, 1); + }); + + it('getToolById throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.getToolById(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('getToolById delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getToolById(makeUser(), 't1'), { id: 't1' }); + }); + + it('updateTool throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.updateTool(makeUser(), '', goodTool, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('updateTool throws 422 with invalid config', async () => { + await assert.rejects(makeApi(Api).api.updateTool(makeUser(), 't1', {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('updateTool delegates when valid', async () => { + assert.deepEqual(await makeApi(Api).api.updateTool(makeUser(), 't1', goodTool, makeReq()), { updated: true }); + }); + + it('publishTool throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.publishTool(makeUser(), '', {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('publishTool delegates with body', async () => { + let seen; + stub.publishTool = async (id, owner, body) => { seen = { id, body }; return { published: true }; }; + await makeApi(Api).api.publishTool(makeUser(), 't1', { v: 2 }, makeReq()); + assert.deepEqual(seen, { id: 't1', body: { v: 2 } }); + }); + + it('publishToolAsync throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.publishToolAsync(makeUser(), '', {}, makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('publishToolAsync returns task', async () => { + const out = await makeApi(Api).api.publishToolAsync(makeUser(), 't1', {}, makeReq()); + assert.equal(out.taskId, 'task-1'); + }); + + it('dryRunPolicy throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.dryRunPolicy(makeUser(), '', makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('dryRunPolicy delegates', async () => { + assert.deepEqual(await makeApi(Api).api.dryRunPolicy(makeUser(), 't1', makeReq()), { dry: true }); + }); + + it('draftPolicy throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.draftPolicy(makeUser(), '', makeReq()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('draftPolicy delegates', async () => { + assert.deepEqual(await makeApi(Api).api.draftPolicy(makeUser(), 't1', makeReq()), { draft: true }); + }); + + it('validateTool delegates', async () => { + assert.deepEqual(await makeApi(Api).api.validateTool(makeUser(), {}), { valid: true }); + }); + + it('toolExportFile throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.toolExportFile(makeUser(), '', makeRes()), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('toolExportFile sets zip headers', async () => { + const res = makeRes(); + await makeApi(Api).api.toolExportFile(makeUser(), 't1', res); + assert.equal(res.headers['Content-type'], 'application/zip'); + }); + + it('toolExportMessage throws 422 without id', async () => { + await assert.rejects(makeApi(Api).api.toolExportMessage(makeUser(), ''), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('toolExportMessage delegates', async () => { + assert.deepEqual(await makeApi(Api).api.toolExportMessage(makeUser(), 't1'), { messageId: 'mid' }); + }); + + it('toolImportMessagePreview throws 422 without messageId', async () => { + await assert.rejects(makeApi(Api).api.toolImportMessagePreview(makeUser(), {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('toolImportMessagePreview delegates', async () => { + assert.deepEqual(await makeApi(Api).api.toolImportMessagePreview(makeUser(), { messageId: 'M' }), { preview: true }); + }); + + it('toolImportMessage throws 422 without messageId', async () => { + await assert.rejects(makeApi(Api).api.toolImportMessage(makeUser(), {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('toolImportMessage delegates', async () => { + assert.deepEqual(await makeApi(Api).api.toolImportMessage(makeUser(), { messageId: 'M' }), { imported: true }); + }); + + it('toolImportFilePreview delegates', async () => { + assert.deepEqual(await makeApi(Api).api.toolImportFilePreview(makeUser(), { b: 1 }), { previewFile: true }); + }); + + it('toolImportFile delegates', async () => { + assert.deepEqual(await makeApi(Api).api.toolImportFile(makeUser(), { b: 1 }, makeReq()), { importedFile: true }); + }); + + it('toolImportMessageAsync throws 422 without messageId', async () => { + await assert.rejects(makeApi(Api).api.toolImportMessageAsync(makeUser(), {}), (e) => { assert.equal(e.getStatus(), 422); return true; }); + }); + + it('toolImportMessageAsync returns task', async () => { + const out = await makeApi(Api).api.toolImportMessageAsync(makeUser(), { messageId: 'M' }); + assert.equal(out.taskId, 'task-1'); + }); + + it('getMenu delegates', async () => { + assert.deepEqual(await makeApi(Api).api.getMenu(makeUser()), [{ menu: 1 }]); + }); + + it('checkTool delegates messageId', async () => { + let seen; + stub.checkTool = async (messageId) => { seen = messageId; return { checked: true }; }; + await makeApi(Api).api.checkTool(makeUser(), 'M9'); + assert.equal(seen, 'M9'); + }); +}); diff --git a/api-gateway/tests/singleton.test.js b/api-gateway/tests/singleton.test.js new file mode 100644 index 0000000000..5f235fcb39 --- /dev/null +++ b/api-gateway/tests/singleton.test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { Singleton } from '../dist/helpers/decorators/singleton.js'; + +function decorate(cls) { + return Singleton(cls); +} + +describe('Singleton decorator', () => { + it('returns the same instance for repeated `new` calls', () => { + class Holder { + constructor() { + this.id = Math.random(); + } + } + const Wrapped = decorate(Holder); + const a = new Wrapped(); + const b = new Wrapped(); + assert.equal(a, b); + assert.equal(a.id, b.id); + }); + + it('preserves constructor arguments on the first construction', () => { + class Tagged { + constructor(label) { + this.label = label; + } + } + const Wrapped = decorate(Tagged); + const first = new Wrapped('first'); + const second = new Wrapped('second-ignored'); + assert.equal(first.label, 'first'); + assert.equal(second, first); + }); + + it('produces independent singletons per decorated class', () => { + class A {} + class B {} + const WA = decorate(A); + const WB = decorate(B); + assert.notEqual(new WA(), new WB()); + }); + + it('returns a fresh instance when constructed via a subclass', () => { + class Base {} + const WrappedBase = decorate(Base); + class Derived extends WrappedBase {} + const baseInstance = new WrappedBase(); + const derivedInstance = new Derived(); + assert.notEqual(baseInstance, derivedInstance); + assert.ok(derivedInstance instanceof Derived); + }); +}); diff --git a/api-gateway/tests/stream-to-buffer.test.js b/api-gateway/tests/stream-to-buffer.test.js new file mode 100644 index 0000000000..e4f32f917f --- /dev/null +++ b/api-gateway/tests/stream-to-buffer.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'stream'; +import { streamToBuffer } from '../dist/helpers/stream-to-buffer.js'; + +describe('streamToBuffer', () => { + it('concatenates chunks emitted by the stream', async () => { + const stream = Readable.from([Buffer.from('hello '), Buffer.from('world')]); + const buf = await streamToBuffer(stream); + assert.equal(buf.toString('utf8'), 'hello world'); + }); + + it('coerces non-Buffer chunks into Buffers', async () => { + const stream = Readable.from(['ab', 'cd']); + const buf = await streamToBuffer(stream); + assert.equal(buf.toString('utf8'), 'abcd'); + }); + + it('resolves to an empty Buffer for an empty stream', async () => { + const stream = Readable.from([]); + const buf = await streamToBuffer(stream); + assert.equal(buf.length, 0); + }); + + it('rejects when the stream errors', async () => { + const stream = new Readable({ + read() { + this.destroy(new Error('boom')); + }, + }); + await assert.rejects(streamToBuffer(stream), /boom/); + }); +}); diff --git a/api-gateway/tests/utils.test.js b/api-gateway/tests/utils.test.js new file mode 100644 index 0000000000..644fd4c15c --- /dev/null +++ b/api-gateway/tests/utils.test.js @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { + findAllEntities, + replaceAllEntities, + parseInteger, + parseSavepointIdsJson, + ONLY_SR, +} from '../dist/helpers/utils.js'; + +describe('findAllEntities', () => { + it('finds top-level matches', () => { + const obj = { id: 'A' }; + assert.deepEqual(findAllEntities(obj, 'id'), ['A']); + }); + + it('descends through children[]', () => { + const obj = { + id: 'root', + children: [ + { id: 'a' }, + { id: 'b', children: [{ id: 'c' }] }, + ], + }; + const ids = findAllEntities(obj, 'id').sort(); + assert.deepEqual(ids, ['a', 'b', 'c', 'root']); + }); + + it('deduplicates duplicate values', () => { + const obj = { + id: 'X', + children: [{ id: 'X' }, { id: 'Y' }, { id: 'X' }], + }; + const ids = findAllEntities(obj, 'id').sort(); + assert.deepEqual(ids, ['X', 'Y']); + }); + + it('returns empty array when the field is absent everywhere', () => { + const obj = { foo: 1, children: [{ foo: 2 }] }; + assert.deepEqual(findAllEntities(obj, 'id'), []); + }); + + it('safely handles a null root', () => { + assert.deepEqual(findAllEntities(null, 'id'), []); + }); +}); + +describe('replaceAllEntities', () => { + it('replaces matching values at top level', () => { + const obj = { name: 'old' }; + replaceAllEntities(obj, 'name', 'old', 'new'); + assert.equal(obj.name, 'new'); + }); + + it('replaces matching values inside children[]', () => { + const obj = { + name: 'a', + children: [ + { name: 'old' }, + { name: 'b', children: [{ name: 'old' }] }, + ], + }; + replaceAllEntities(obj, 'name', 'old', 'new'); + assert.equal(obj.children[0].name, 'new'); + assert.equal(obj.children[1].children[0].name, 'new'); + // Untouched + assert.equal(obj.name, 'a'); + assert.equal(obj.children[1].name, 'b'); + }); + + it('does nothing when no value matches oldValue', () => { + const obj = { name: 'x', children: [{ name: 'y' }] }; + replaceAllEntities(obj, 'name', 'z', 'w'); + assert.equal(obj.name, 'x'); + assert.equal(obj.children[0].name, 'y'); + }); +}); + +describe('parseInteger', () => { + it('parses a numeric string to an integer', () => { + assert.equal(parseInteger('42'), 42); + }); + + it('parses a leading-numeric string (Number.parseInt semantics)', () => { + assert.equal(parseInteger('42abc'), 42); + }); + + it('returns undefined for non-numeric strings', () => { + assert.equal(parseInteger('abc'), undefined); + assert.equal(parseInteger(''), undefined); + }); + + it('floors finite numeric input', () => { + assert.equal(parseInteger(7.9), 7); + assert.equal(parseInteger(-3.2), -4); + }); + + it('returns undefined for NaN/Infinity', () => { + assert.equal(parseInteger(NaN), undefined); + assert.equal(parseInteger(Infinity), undefined); + assert.equal(parseInteger(-Infinity), undefined); + }); + + it('returns undefined for non-string/non-number', () => { + assert.equal(parseInteger(null), undefined); + assert.equal(parseInteger(undefined), undefined); + assert.equal(parseInteger({}), undefined); + assert.equal(parseInteger([1]), undefined); + assert.equal(parseInteger(true), undefined); + }); +}); + +describe('parseSavepointIdsJson', () => { + it('returns undefined when input is empty/null/undefined', () => { + assert.equal(parseSavepointIdsJson(undefined), undefined); + assert.equal(parseSavepointIdsJson(null), undefined); + assert.equal(parseSavepointIdsJson(''), undefined); + }); + + it('parses a JSON array of strings', () => { + assert.deepEqual(parseSavepointIdsJson('["a","b","c"]'), ['a', 'b', 'c']); + }); + + it('filters out empty strings and whitespace-only entries', () => { + assert.deepEqual(parseSavepointIdsJson('["a",""," ","b"]'), ['a', 'b']); + }); + + it('filters out non-string entries', () => { + assert.deepEqual(parseSavepointIdsJson('["a", 1, true, null, "b"]'), ['a', 'b']); + }); + + it('deduplicates while preserving the first-seen order', () => { + assert.deepEqual(parseSavepointIdsJson('["a","b","a","c","b"]'), ['a', 'b', 'c']); + }); + + it('returns undefined when the parsed array contains no usable strings', () => { + assert.equal(parseSavepointIdsJson('[]'), undefined); + assert.equal(parseSavepointIdsJson('["", " ", null]'), undefined); + }); + + it('throws an HttpException on malformed JSON', () => { + assert.throws( + () => parseSavepointIdsJson('not-json'), + /JSON array of strings/ + ); + }); + + it('throws an HttpException when JSON is valid but not an array', () => { + assert.throws( + () => parseSavepointIdsJson('{"foo":"bar"}'), + /JSON array of strings/ + ); + assert.throws( + () => parseSavepointIdsJson('"a"'), + /JSON array of strings/ + ); + }); +}); + +describe('ONLY_SR constant', () => { + it('mentions Standard Registry role', () => { + assert.match(ONLY_SR, /Standard Registry/); + }); +}); diff --git a/api-gateway/tests/validate-middleware.test.mjs b/api-gateway/tests/validate-middleware.test.mjs new file mode 100644 index 0000000000..d540b3752e --- /dev/null +++ b/api-gateway/tests/validate-middleware.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import * as yup from 'yup'; +import validate from '../dist/middlewares/validation/index.js'; + +function buildReq({ body = {}, query = {}, params = {} } = {}) { + return { body, query, params }; +} + +function buildRes() { + const res = { + statusCode: null, + payload: null, + status(code) { + this.statusCode = code; + return this; + }, + send(payload) { + this.payload = payload; + return this; + }, + }; + return res; +} + +describe('validate middleware', () => { + const schema = yup.object({ + body: yup.object({ + name: yup.string().required('The name field is required'), + }), + }); + + it('calls next() when the schema passes', async () => { + const handler = validate(schema); + let nextCalled = false; + const res = buildRes(); + await handler(buildReq({ body: { name: 'ok' } }), res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + assert.equal(res.statusCode, null); + }); + + it('responds 422 and does not call next() when the schema fails', async () => { + const handler = validate(schema); + let nextCalled = false; + const res = buildRes(); + await handler(buildReq({ body: {} }), res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 422); + }); + + it('places yup error messages on the 422 response message array', async () => { + const handler = validate(schema); + const res = buildRes(); + await handler(buildReq({ body: {} }), res, () => {}); + assert.ok(Array.isArray(res.payload.message)); + assert.ok(res.payload.message.includes('The name field is required')); + }); + + it('uses the yup error name (ValidationError) as the response type', async () => { + const handler = validate(schema); + const res = buildRes(); + await handler(buildReq({ body: {} }), res, () => {}); + assert.equal(res.payload.type, 'ValidationError'); + }); + + it('validates body, query, and params together', async () => { + const combined = yup.object({ + query: yup.object({ page: yup.string().required('page required') }), + }); + const handler = validate(combined); + const res = buildRes(); + let nextCalled = false; + await handler(buildReq({ query: {} }), res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 422); + assert.ok(res.payload.message.includes('page required')); + }); +}); diff --git a/api-gateway/tests/validation-middleware.test.js b/api-gateway/tests/validation-middleware.test.js new file mode 100644 index 0000000000..3b649838b3 --- /dev/null +++ b/api-gateway/tests/validation-middleware.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { prepareValidationResponse } from '../dist/middlewares/validation/index.js'; +import { IsNumberOrString } from '../dist/middlewares/validation/string-or-number.js'; +import { IsStringOrObject } from '../dist/middlewares/validation/string-or-object.js'; + +describe('prepareValidationResponse', () => { + it('uses err.errors when present', () => { + const out = prepareValidationResponse({ errors: ['a', 'b'] }); + assert.deepEqual(out, { type: 'ValidationError', message: ['a', 'b'] }); + }); + + it('falls back to wrapping the error itself when no .errors', () => { + const err = 'oops'; + const out = prepareValidationResponse(err); + assert.deepEqual(out, { type: 'ValidationError', message: ['oops'] }); + }); + + it('respects an explicit type override', () => { + const out = prepareValidationResponse({ errors: ['x'] }, 'CustomType'); + assert.equal(out.type, 'CustomType'); + }); + + it('wraps null/undefined errors in a single-element array', () => { + assert.deepEqual( + prepareValidationResponse(null).message, + [null] + ); + }); +}); + +describe('IsNumberOrString constraint', () => { + const c = new IsNumberOrString(); + + it('accepts strings (incl. empty)', () => { + assert.equal(c.validate('', {}), true); + assert.equal(c.validate('hello', {}), true); + }); + + it('accepts numbers (incl. 0 and NaN)', () => { + assert.equal(c.validate(0, {}), true); + assert.equal(c.validate(-1.5, {}), true); + assert.equal(c.validate(NaN, {}), true); + }); + + it('rejects booleans, null, undefined, objects, arrays', () => { + assert.equal(c.validate(true, {}), false); + assert.equal(c.validate(null, {}), false); + assert.equal(c.validate(undefined, {}), false); + assert.equal(c.validate({}, {}), false); + assert.equal(c.validate([], {}), false); + }); + + it('produces a default message that mentions value type', () => { + const msg = c.defaultMessage({}); + assert.ok(/must be number or string/.test(msg)); + }); +}); + +describe('IsStringOrObject constraint', () => { + const c = new IsStringOrObject(); + + it('accepts strings and objects (incl. arrays — typeof "object")', () => { + assert.equal(c.validate('hi', {}), true); + assert.equal(c.validate({ a: 1 }, {}), true); + assert.equal(c.validate([1, 2], {}), true); + }); + + it('accepts null because typeof null === "object"', () => { + // Document the actual behavior — null is treated as object by typeof. + assert.equal(c.validate(null, {}), true); + }); + + it('rejects numbers, booleans, undefined', () => { + assert.equal(c.validate(1, {}), false); + assert.equal(c.validate(true, {}), false); + assert.equal(c.validate(undefined, {}), false); + }); + + it('produces a default message that mentions value type', () => { + const msg = c.defaultMessage({}); + assert.ok(/must be object or string/.test(msg)); + }); +}); diff --git a/api-gateway/tests/validation/dto-analytics.test.mjs b/api-gateway/tests/validation/dto-analytics.test.mjs new file mode 100644 index 0000000000..caab34828e --- /dev/null +++ b/api-gateway/tests/validation/dto-analytics.test.mjs @@ -0,0 +1,1055 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + SearchBlocksDTO, + SearchBlocksNodeDTO, + SearchBlocksPairDTO, + SearchBlocksChainDTO, + ComparePoliciesItemDTO, + ComparePoliciesColumnDTO, + ComparePoliciesPropertyValueDTO, + ComparePoliciesBlockSideDTO, + ComparePoliciesRateEntryDTO, + ComparePoliciesBlocksReportRowDTO, + ComparePoliciesPropsReportRowDTO, + ComparePoliciesBlocksSectionDTO, + ComparePoliciesDTO, + ComparePoliciesMultiDTO, + CompareModulesItemDTO, + CompareModulesSectionDTO, + CompareModulesDTO, + CompareSchemasItemDTO, + CompareSchemasDTO, + CompareDocumentItemDTO, + CompareDocumentsDTO, + CompareDocumentsMultiDTO, + CompareDocumentsV2DTO, + CompareToolItemDTO, + CompareToolsDTO, + CompareToolsMultiDTO +} from '../../dist/middlewares/validation/schemas/analytics.js'; +import { + CompareFileDTO, + FilterPolicyDTO, + FilterPoliciesDTO, + CompareOriginalPolicyFilterDTO, + FilterSchemaDTO, + FilterSchemasDTO, + CompareSchemasByIdsRequestDTO, + CompareSchemasByListRequestDTO, + FilterModulesDTO, + FilterDocumentsDTO, + CompareDocumentsByIdsRequestDTO, + CompareDocumentsByListRequestDTO, + FilterToolsDTO, + CompareToolsByIdsRequestDTO, + CompareToolsByListRequestDTO, + FilterSearchPoliciesDTO, + FilterSearchBlocksDTO, + SearchPolicyDTO, + SearchPoliciesDTO +} from '../../dist/middlewares/validation/schemas/analytics.dto.js'; + +const run = (Dto, input) => validate(plainToInstance(Dto, input)); + +const props = (errors) => errors.map((e) => e.property); + +const keys = (errors, property) => { + const found = errors.find((e) => e.property === property); + return found && found.constraints ? Object.keys(found.constraints) : []; +}; + +const childProps = (errors, property) => { + const found = errors.find((e) => e.property === property); + return found && found.children ? found.children.map((c) => c.property) : []; +}; + +describe('analytics SearchBlocksNodeDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 'node-1', + tag: 'pp_grid_sr', + blockType: 'interfaceDocumentsSourceBlock', + config: { foo: 'bar' }, + path: [0, 1, 0, 0] + }); + assert.equal(errors.length, 0); + }); + + it('reports all required fields when empty', async () => { + const errors = await run(SearchBlocksNodeDTO, {}); + assert.deepEqual(props(errors).sort(), ['blockType', 'config', 'id', 'path', 'tag']); + assert.deepEqual(keys(errors, 'id'), ['isString']); + assert.deepEqual(keys(errors, 'config'), ['isObject']); + assert.deepEqual(keys(errors, 'path').sort(), ['isArray', 'isNumber']); + }); + + it('rejects non-string id, tag, blockType', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 1, tag: 2, blockType: 3, config: {}, path: [] + }); + assert.deepEqual(keys(errors, 'id'), ['isString']); + assert.deepEqual(keys(errors, 'tag'), ['isString']); + assert.deepEqual(keys(errors, 'blockType'), ['isString']); + }); + + it('rejects non-object config', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 'a', tag: 't', blockType: 'b', config: 'notobj', path: [] + }); + assert.deepEqual(keys(errors, 'config'), ['isObject']); + }); + + it('rejects path elements that are not numbers', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 'a', tag: 't', blockType: 'b', config: {}, path: ['x', 1] + }); + assert.deepEqual(keys(errors, 'path'), ['isNumber']); + }); + + it('rejects path that is not an array with isArray only', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 'a', tag: 't', blockType: 'b', config: {}, path: 5 + }); + assert.deepEqual(keys(errors, 'path'), ['isArray']); + }); + + it('accepts an empty path array', async () => { + const errors = await run(SearchBlocksNodeDTO, { + id: 'a', tag: 't', blockType: 'b', config: {}, path: [] + }); + assert.equal(errors.length, 0); + }); +}); + +describe('analytics SearchBlocksPairDTO @unit', () => { + const node = { id: 'a', tag: 't', blockType: 'b', config: {}, path: [0] }; + + it('accepts a fully valid payload', async () => { + const errors = await run(SearchBlocksPairDTO, { hash: 100, source: node, filter: node }); + assert.equal(errors.length, 0); + }); + + it('rejects non-number hash', async () => { + const errors = await run(SearchBlocksPairDTO, { hash: 'x', source: node, filter: node }); + assert.deepEqual(keys(errors, 'hash'), ['isNumber']); + }); + + it('nests errors of an invalid source node under children', async () => { + const errors = await run(SearchBlocksPairDTO, { hash: 1, source: {}, filter: node }); + assert.ok(props(errors).includes('source')); + assert.deepEqual(childProps(errors, 'source').sort(), ['blockType', 'config', 'id', 'path', 'tag']); + }); +}); + +describe('analytics SearchBlocksChainDTO @unit', () => { + const node = { id: 'a', tag: 't', blockType: 'b', config: {}, path: [0] }; + + it('accepts a fully valid payload', async () => { + const errors = await run(SearchBlocksChainDTO, { hash: 1, target: node, pairs: [] }); + assert.equal(errors.length, 0); + }); + + it('flags non-number hash, nested target and non-array pairs', async () => { + const errors = await run(SearchBlocksChainDTO, { hash: 'no', target: {}, pairs: 'x' }); + assert.deepEqual(keys(errors, 'hash'), ['isNumber']); + assert.ok(childProps(errors, 'target').length > 0); + assert.ok(keys(errors, 'pairs').includes('isArray')); + }); + + it('reports isArray and nestedValidation when pairs is a string', async () => { + const errors = await run(SearchBlocksChainDTO, { hash: 1, target: node, pairs: 'x' }); + const k = keys(errors, 'pairs'); + assert.ok(k.includes('isArray')); + assert.ok(k.includes('nestedValidation')); + }); +}); + +describe('analytics SearchBlocksDTO @unit', () => { + const valid = { + name: 'n', description: 'd', version: '1', owner: 'o', + topicId: 't', messageId: 'm', hash: 1, chains: [] + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(SearchBlocksDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports every missing required field', async () => { + const errors = await run(SearchBlocksDTO, {}); + assert.deepEqual( + props(errors).sort(), + ['chains', 'description', 'hash', 'messageId', 'name', 'owner', 'topicId', 'version'] + ); + }); + + it('rejects non-number hash', async () => { + const errors = await run(SearchBlocksDTO, { ...valid, hash: 'x' }); + assert.deepEqual(keys(errors, 'hash'), ['isNumber']); + }); + + it('rejects non-array chains', async () => { + const errors = await run(SearchBlocksDTO, { ...valid, chains: {} }); + const k = keys(errors, 'chains'); + assert.ok(k.includes('isArray')); + }); + + it('nests errors of an invalid chain element under children', async () => { + const errors = await run(SearchBlocksDTO, { ...valid, chains: [{}] }); + assert.ok(props(errors).includes('chains')); + assert.deepEqual(childProps(errors, 'chains'), ['0']); + }); +}); + +describe('analytics ComparePoliciesItemDTO @unit', () => { + const valid = { id: 'x', name: 'p', description: '', type: 'id' }; + + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesItemDTO, valid); + assert.equal(errors.length, 0); + }); + + it('accepts when optional fields omitted', async () => { + const errors = await run(ComparePoliciesItemDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(ComparePoliciesItemDTO, {}); + assert.deepEqual(props(errors).sort(), ['description', 'id', 'name', 'type']); + }); + + it('rejects non-string optional version when present', async () => { + const errors = await run(ComparePoliciesItemDTO, { ...valid, version: 5 }); + assert.deepEqual(keys(errors, 'version'), ['isString']); + }); + + it('accepts null instanceTopicId only as a string check (null is skipped by IsOptional)', async () => { + const errors = await run(ComparePoliciesItemDTO, { ...valid, instanceTopicId: null }); + assert.equal(errors.length, 0); + }); + + it('rejects numeric instanceTopicId', async () => { + const errors = await run(ComparePoliciesItemDTO, { ...valid, instanceTopicId: 7 }); + assert.deepEqual(keys(errors, 'instanceTopicId'), ['isString']); + }); +}); + +describe('analytics ComparePoliciesColumnDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesColumnDTO, { name: 'left_name', label: 'Name', type: 'string' }); + assert.equal(errors.length, 0); + }); + + it('accepts optional display omitted', async () => { + const errors = await run(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't' }); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(ComparePoliciesColumnDTO, {}); + assert.deepEqual(props(errors).sort(), ['label', 'name', 'type']); + }); + + it('rejects non-string display when present', async () => { + const errors = await run(ComparePoliciesColumnDTO, { name: 'n', label: 'l', type: 't', display: 9 }); + assert.deepEqual(keys(errors, 'display'), ['isString']); + }); +}); + +describe('analytics ComparePoliciesPropertyValueDTO @unit', () => { + it('accepts a fully valid payload with arbitrary value', async () => { + const errors = await run(ComparePoliciesPropertyValueDTO, { + name: 'onErrorAction', lvl: 1, path: 'onErrorAction', type: 'property', value: { any: 1 } + }); + assert.equal(errors.length, 0); + }); + + it('reports required fields and number lvl when empty', async () => { + const errors = await run(ComparePoliciesPropertyValueDTO, {}); + assert.deepEqual(props(errors).sort(), ['lvl', 'name', 'path', 'type']); + assert.deepEqual(keys(errors, 'lvl'), ['isNumber']); + }); + + it('does not validate the untyped value field', async () => { + const errors = await run(ComparePoliciesPropertyValueDTO, { + name: 'n', lvl: 0, path: 'p', type: 't', value: undefined + }); + assert.equal(errors.length, 0); + }); +}); + +describe('analytics ComparePoliciesBlockSideDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesBlockSideDTO, { + index: 1, blockType: 'interfaceContainerBlock', tag: 'Block_1', properties: [], events: [] + }); + assert.equal(errors.length, 0); + }); + + it('reports required scalars and arrays when empty', async () => { + const errors = await run(ComparePoliciesBlockSideDTO, {}); + assert.deepEqual(props(errors).sort(), ['blockType', 'events', 'index', 'properties', 'tag']); + assert.deepEqual(keys(errors, 'index'), ['isNumber']); + assert.ok(keys(errors, 'properties').includes('isArray')); + }); + + it('nests errors of an invalid property element', async () => { + const errors = await run(ComparePoliciesBlockSideDTO, { + index: 0, blockType: 'b', tag: 't', properties: [{}], events: [] + }); + assert.deepEqual(childProps(errors, 'properties'), ['0']); + }); +}); + +describe('analytics ComparePoliciesRateEntryDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesRateEntryDTO, { type: 'FULL', totalRate: 100, items: [] }); + assert.equal(errors.length, 0); + }); + + it('accepts optional name, path, lvl omitted', async () => { + const errors = await run(ComparePoliciesRateEntryDTO, { type: 'FULL', totalRate: 1, items: [1, null] }); + assert.equal(errors.length, 0); + }); + + it('reports required type, totalRate, items when empty', async () => { + const errors = await run(ComparePoliciesRateEntryDTO, {}); + assert.deepEqual(props(errors).sort(), ['items', 'totalRate', 'type']); + assert.deepEqual(keys(errors, 'totalRate'), ['isNumber']); + assert.deepEqual(keys(errors, 'items'), ['isArray']); + }); + + it('rejects non-number optional lvl when present', async () => { + const errors = await run(ComparePoliciesRateEntryDTO, { type: 'F', totalRate: 1, items: [], lvl: 'x' }); + assert.deepEqual(keys(errors, 'lvl'), ['isNumber']); + }); +}); + +describe('analytics ComparePoliciesBlocksReportRowDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(ComparePoliciesBlocksReportRowDTO, {}); + assert.equal(errors.length, 0); + }); + + it('accepts a populated payload', async () => { + const errors = await run(ComparePoliciesBlocksReportRowDTO, { + lvl: 1, type: 'PARTLY', block_type: 'b', left_index: 1, total_rate: '80%', size: 3 + }); + assert.equal(errors.length, 0); + }); + + it('rejects wrong scalar types', async () => { + const errors = await run(ComparePoliciesBlocksReportRowDTO, { lvl: 'x', type: 9, size: 'big' }); + assert.deepEqual(keys(errors, 'lvl'), ['isNumber']); + assert.deepEqual(keys(errors, 'type'), ['isString']); + assert.deepEqual(keys(errors, 'size'), ['isNumber']); + }); + + it('rejects a non-object left side', async () => { + const errors = await run(ComparePoliciesBlocksReportRowDTO, { left: 'x' }); + assert.ok(keys(errors, 'left').includes('isObject')); + }); + + it('nests errors of an invalid properties rate entry', async () => { + const errors = await run(ComparePoliciesBlocksReportRowDTO, { properties: [{}] }); + assert.deepEqual(childProps(errors, 'properties'), ['0']); + }); +}); + +describe('analytics ComparePoliciesPropsReportRowDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(ComparePoliciesPropsReportRowDTO, {}); + assert.equal(errors.length, 0); + }); + + it('rejects non-string left_name and non-object left', async () => { + const errors = await run(ComparePoliciesPropsReportRowDTO, { left_name: 5, left: 'x' }); + assert.deepEqual(keys(errors, 'left_name'), ['isString']); + assert.deepEqual(keys(errors, 'left'), ['isObject']); + }); + + it('rejects non-number size', async () => { + const errors = await run(ComparePoliciesPropsReportRowDTO, { size: 'x' }); + assert.deepEqual(keys(errors, 'size'), ['isNumber']); + }); +}); + +describe('analytics ComparePoliciesBlocksSectionDTO @unit', () => { + it('accepts valid columns and report arrays', async () => { + const errors = await run(ComparePoliciesBlocksSectionDTO, { + columns: [{ name: 'n', label: 'l', type: 't' }], + report: [{}] + }); + assert.equal(errors.length, 0); + }); + + it('reports missing arrays when empty', async () => { + const errors = await run(ComparePoliciesBlocksSectionDTO, {}); + assert.deepEqual(props(errors).sort(), ['columns', 'report']); + }); + + it('nests errors of an invalid column element', async () => { + const errors = await run(ComparePoliciesBlocksSectionDTO, { columns: [{}], report: [] }); + assert.deepEqual(childProps(errors, 'columns'), ['0']); + }); +}); + +describe('analytics ComparePoliciesDTO @unit', () => { + const item = { id: 'x', name: 'p', description: '', type: 'id' }; + const section = { columns: [], report: [] }; + const valid = { + left: item, right: item, total: 66, + blocks: section, roles: section, groups: section, + topics: section, tokens: section, tools: section + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports all object fields and total when empty', async () => { + const errors = await run(ComparePoliciesDTO, {}); + assert.deepEqual( + props(errors).sort(), + ['blocks', 'groups', 'left', 'right', 'roles', 'tokens', 'tools', 'topics', 'total'] + ); + assert.deepEqual(keys(errors, 'total'), ['isNumber']); + }); + + it('rejects a non-object left', async () => { + const errors = await run(ComparePoliciesDTO, { ...valid, left: 'x' }); + assert.ok(keys(errors, 'left').includes('isObject')); + }); + + it('nests errors of an invalid nested left item', async () => { + const errors = await run(ComparePoliciesDTO, { ...valid, left: {} }); + assert.ok(childProps(errors, 'left').length > 0); + }); +}); + +describe('analytics ComparePoliciesMultiDTO @unit', () => { + const item = { id: 'x', name: 'p', description: '', type: 'id' }; + const section = { columns: [], report: [] }; + const valid = { + size: 3, left: item, rights: [item], totals: [], + blocks: section, roles: section, groups: section, + topics: section, tokens: section, tools: section + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(ComparePoliciesMultiDTO, valid); + assert.equal(errors.length, 0); + }); + + it('rejects non-number size and non-array rights', async () => { + const errors = await run(ComparePoliciesMultiDTO, { ...valid, size: 'x', rights: {} }); + assert.deepEqual(keys(errors, 'size'), ['isNumber']); + assert.ok(keys(errors, 'rights').includes('isArray')); + }); + + it('nests errors of an invalid rights element', async () => { + const errors = await run(ComparePoliciesMultiDTO, { ...valid, rights: [{}] }); + assert.deepEqual(childProps(errors, 'rights'), ['0']); + }); +}); + +describe('analytics CompareModulesItemDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareModulesItemDTO, { id: 'x', name: 'Module_1', description: 'd' }); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(CompareModulesItemDTO, {}); + assert.deepEqual(props(errors).sort(), ['description', 'id', 'name']); + }); +}); + +describe('analytics CompareModulesSectionDTO @unit', () => { + it('accepts valid columns and report', async () => { + const errors = await run(CompareModulesSectionDTO, { columns: [], report: [] }); + assert.equal(errors.length, 0); + }); + + it('reports missing arrays when empty', async () => { + const errors = await run(CompareModulesSectionDTO, {}); + assert.deepEqual(props(errors).sort(), ['columns', 'report']); + }); +}); + +describe('analytics CompareModulesDTO @unit', () => { + const item = { id: 'x', name: 'M', description: 'd' }; + const section = { columns: [], report: [] }; + const valid = { + left: item, right: item, total: 22, + blocks: section, inputEvents: section, outputEvents: section, variables: section + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareModulesDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports all object fields and total when empty', async () => { + const errors = await run(CompareModulesDTO, {}); + assert.deepEqual( + props(errors).sort(), + ['blocks', 'inputEvents', 'left', 'outputEvents', 'right', 'total', 'variables'] + ); + }); + + it('rejects non-number total', async () => { + const errors = await run(CompareModulesDTO, { ...valid, total: 'x' }); + assert.deepEqual(keys(errors, 'total'), ['isNumber']); + }); +}); + +describe('analytics CompareSchemasItemDTO @unit', () => { + const valid = { id: 'x', name: 'S', description: 'd', uuid: 'u', version: '1', iri: 'i' }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareSchemasItemDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(CompareSchemasItemDTO, {}); + assert.deepEqual(props(errors).sort(), ['description', 'id', 'iri', 'name', 'uuid', 'version']); + }); + + it('accepts optional topicId null and policy omitted', async () => { + const errors = await run(CompareSchemasItemDTO, { ...valid, topicId: null }); + assert.equal(errors.length, 0); + }); + + it('rejects non-object optional policy', async () => { + const errors = await run(CompareSchemasItemDTO, { ...valid, policy: 'x' }); + assert.deepEqual(keys(errors, 'policy'), ['isObject']); + }); +}); + +describe('analytics CompareSchemasDTO @unit', () => { + const item = { id: 'x', name: 'S', description: 'd', uuid: 'u', version: '1', iri: 'i' }; + const valid = { left: item, right: item, total: 44, fields: { columns: [], report: [] } }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareSchemasDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports object fields and total when empty', async () => { + const errors = await run(CompareSchemasDTO, {}); + assert.deepEqual(props(errors).sort(), ['fields', 'left', 'right', 'total']); + }); +}); + +describe('analytics CompareDocumentItemDTO @unit', () => { + const valid = { id: 'x', type: 'VerifiableCredential', owner: 'o' }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareDocumentItemDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(CompareDocumentItemDTO, {}); + assert.deepEqual(props(errors).sort(), ['id', 'owner', 'type']); + }); + + it('accepts optional policy null', async () => { + const errors = await run(CompareDocumentItemDTO, { ...valid, policy: null }); + assert.equal(errors.length, 0); + }); + + it('rejects non-string optional policy', async () => { + const errors = await run(CompareDocumentItemDTO, { ...valid, policy: 5 }); + assert.deepEqual(keys(errors, 'policy'), ['isString']); + }); +}); + +describe('analytics CompareDocumentsDTO @unit', () => { + const item = { id: 'x', type: 't', owner: 'o' }; + const valid = { left: item, right: item, total: 68, documents: { columns: [], report: [] } }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareDocumentsDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports object fields and total when empty', async () => { + const errors = await run(CompareDocumentsDTO, {}); + assert.deepEqual(props(errors).sort(), ['documents', 'left', 'right', 'total']); + }); + + it('does not run nested validation on left because there is no ValidateNested', async () => { + const errors = await run(CompareDocumentsDTO, { ...valid, left: {} }); + assert.equal(keys(errors, 'left').length, 0); + assert.equal(childProps(errors, 'left').length, 0); + }); +}); + +describe('analytics CompareDocumentsMultiDTO @unit', () => { + const item = { id: 'x', type: 't', owner: 'o' }; + const valid = { size: 3, left: item, rights: [item], totals: [1], documents: { columns: [], report: [] } }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareDocumentsMultiDTO, valid); + assert.equal(errors.length, 0); + }); + + it('rejects non-number size and non-array rights', async () => { + const errors = await run(CompareDocumentsMultiDTO, { ...valid, size: 'x', rights: {} }); + assert.deepEqual(keys(errors, 'size'), ['isNumber']); + assert.ok(keys(errors, 'rights').includes('isArray')); + }); +}); + +describe('analytics CompareDocumentsV2DTO @unit', () => { + it('accepts object projects and presentations', async () => { + const errors = await run(CompareDocumentsV2DTO, { projects: {}, presentations: {} }); + assert.equal(errors.length, 0); + }); + + it('reports both object fields when empty', async () => { + const errors = await run(CompareDocumentsV2DTO, {}); + assert.deepEqual(props(errors).sort(), ['presentations', 'projects']); + }); + + it('rejects non-object fields', async () => { + const errors = await run(CompareDocumentsV2DTO, { projects: 'x', presentations: 1 }); + assert.deepEqual(keys(errors, 'projects'), ['isObject']); + assert.deepEqual(keys(errors, 'presentations'), ['isObject']); + }); +}); + +describe('analytics CompareToolItemDTO @unit', () => { + const valid = { id: 'x', name: 'Tool 30' }; + + it('accepts a fully valid payload with optionals omitted', async () => { + const errors = await run(CompareToolItemDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports required id and name when empty', async () => { + const errors = await run(CompareToolItemDTO, {}); + assert.deepEqual(props(errors).sort(), ['id', 'name']); + }); + + it('accepts null optionals', async () => { + const errors = await run(CompareToolItemDTO, { ...valid, description: null, hash: null, messageId: null }); + assert.equal(errors.length, 0); + }); + + it('rejects numeric optional hash', async () => { + const errors = await run(CompareToolItemDTO, { ...valid, hash: 5 }); + assert.deepEqual(keys(errors, 'hash'), ['isString']); + }); +}); + +describe('analytics CompareToolsDTO @unit', () => { + const item = { id: 'x', name: 'T' }; + const section = { columns: [], report: [] }; + const valid = { + left: item, right: item, total: 74, + blocks: section, inputEvents: section, outputEvents: section, variables: section + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareToolsDTO, valid); + assert.equal(errors.length, 0); + }); + + it('reports object fields and total when empty', async () => { + const errors = await run(CompareToolsDTO, {}); + assert.deepEqual( + props(errors).sort(), + ['blocks', 'inputEvents', 'left', 'outputEvents', 'right', 'total', 'variables'] + ); + }); +}); + +describe('analytics CompareToolsMultiDTO @unit', () => { + const item = { id: 'x', name: 'T' }; + const section = { columns: [], report: [] }; + const valid = { + size: 3, left: item, rights: [item], totals: [1], + blocks: section, inputEvents: section, outputEvents: section, variables: section + }; + + it('accepts a fully valid payload', async () => { + const errors = await run(CompareToolsMultiDTO, valid); + assert.equal(errors.length, 0); + }); + + it('rejects non-array totals', async () => { + const errors = await run(CompareToolsMultiDTO, { ...valid, totals: 'x' }); + assert.deepEqual(keys(errors, 'totals'), ['isArray']); + }); +}); + +describe('analytics-dto CompareFileDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareFileDTO, { id: 'u', name: 'File', value: 'base64' }); + assert.equal(errors.length, 0); + }); + + it('reports required fields when empty', async () => { + const errors = await run(CompareFileDTO, {}); + assert.deepEqual(props(errors).sort(), ['id', 'name', 'value']); + }); + + it('rejects non-string fields', async () => { + const errors = await run(CompareFileDTO, { id: 1, name: 2, value: 3 }); + assert.deepEqual(keys(errors, 'id'), ['isString']); + assert.deepEqual(keys(errors, 'name'), ['isString']); + assert.deepEqual(keys(errors, 'value'), ['isString']); + }); +}); + +describe('analytics-dto FilterPolicyDTO @unit', () => { + it('accepts string type and string value', async () => { + const errors = await run(FilterPolicyDTO, { type: 'id', value: 'x' }); + assert.equal(errors.length, 0); + }); + + it('reports required type and value when empty', async () => { + const errors = await run(FilterPolicyDTO, {}); + assert.deepEqual(props(errors).sort(), ['type', 'value']); + }); + + it('rejects an object value because value is guarded only by IsString', async () => { + const errors = await run(FilterPolicyDTO, { type: 'file', value: {} }); + assert.deepEqual(keys(errors, 'value'), ['isString']); + }); +}); + +describe('analytics-dto Options via FilterPoliciesDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(FilterPoliciesDTO, { + idLvl: 0, eventsLvl: '1', propLvl: 2, childrenLvl: '0', + policyId1: 'a', policyId2: 'b', policyIds: ['a'], policies: [] + }); + assert.equal(errors.length, 0); + }); + + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(FilterPoliciesDTO, {}); + assert.equal(errors.length, 0); + }); + + it('accepts numeric idLvl', async () => { + const errors = await run(FilterPoliciesDTO, { idLvl: 1 }); + assert.equal(errors.length, 0); + }); + + it('accepts string idLvl', async () => { + const errors = await run(FilterPoliciesDTO, { idLvl: '1' }); + assert.equal(errors.length, 0); + }); + + it('rejects boolean idLvl with string-or-number constraint', async () => { + const errors = await run(FilterPoliciesDTO, { idLvl: true }); + assert.deepEqual(keys(errors, 'idLvl'), ['string-or-number']); + }); + + it('rejects object eventsLvl with string-or-number constraint', async () => { + const errors = await run(FilterPoliciesDTO, { eventsLvl: {} }); + assert.deepEqual(keys(errors, 'eventsLvl'), ['string-or-number']); + }); + + it('rejects non-string policyId1', async () => { + const errors = await run(FilterPoliciesDTO, { policyId1: 5 }); + assert.deepEqual(keys(errors, 'policyId1'), ['isString']); + }); + + it('rejects non-array policyIds', async () => { + const errors = await run(FilterPoliciesDTO, { policyIds: 'x' }); + assert.deepEqual(keys(errors, 'policyIds'), ['isArray']); + }); + + it('does not deep-validate policies array elements (no ValidateNested)', async () => { + const errors = await run(FilterPoliciesDTO, { policies: [{}] }); + assert.equal(errors.length, 0); + }); +}); + +describe('analytics-dto CompareOriginalPolicyFilterDTO @unit', () => { + it('inherits Options and accepts an empty payload', async () => { + const errors = await run(CompareOriginalPolicyFilterDTO, {}); + assert.equal(errors.length, 0); + }); + + it('rejects boolean idLvl inherited from Options', async () => { + const errors = await run(CompareOriginalPolicyFilterDTO, { idLvl: true }); + assert.deepEqual(keys(errors, 'idLvl'), ['string-or-number']); + }); +}); + +describe('analytics-dto FilterSchemaDTO @unit', () => { + it('accepts string type, value and string policy', async () => { + const errors = await run(FilterSchemaDTO, { type: 'id', value: 'x', policy: 'p' }); + assert.equal(errors.length, 0); + }); + + it('reports required type and value when empty', async () => { + const errors = await run(FilterSchemaDTO, {}); + assert.deepEqual(props(errors).sort(), ['type', 'value']); + }); + + it('accepts numeric policy is rejected by string-or-object', async () => { + const errors = await run(FilterSchemaDTO, { type: 'id', value: 'x', policy: 5 }); + assert.deepEqual(keys(errors, 'policy'), ['string-or-object']); + }); + + it('accepts object policy via string-or-object', async () => { + const errors = await run(FilterSchemaDTO, { type: 'id', value: 'x', policy: {} }); + assert.equal(errors.length, 0); + }); +}); + +describe('analytics-dto FilterSchemasDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(FilterSchemasDTO, {}); + assert.equal(errors.length, 0); + }); + + it('rejects non-array schemas and boolean idLvl', async () => { + const errors = await run(FilterSchemasDTO, { schemas: 'x', idLvl: true }); + assert.deepEqual(keys(errors, 'schemas'), ['isArray']); + assert.deepEqual(keys(errors, 'idLvl'), ['string-or-number']); + }); + + it('rejects non-string schemaId1', async () => { + const errors = await run(FilterSchemasDTO, { schemaId1: 5 }); + assert.deepEqual(keys(errors, 'schemaId1'), ['isString']); + }); +}); + +describe('analytics-dto CompareSchemasByIdsRequestDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareSchemasByIdsRequestDTO, { schemaId1: 'a', schemaId2: 'b', idLvl: '0' }); + assert.equal(errors.length, 0); + }); + + it('reports both required ids when empty', async () => { + const errors = await run(CompareSchemasByIdsRequestDTO, {}); + assert.deepEqual(props(errors).sort(), ['schemaId1', 'schemaId2']); + }); + + it('rejects boolean idLvl', async () => { + const errors = await run(CompareSchemasByIdsRequestDTO, { schemaId1: 'a', schemaId2: 'b', idLvl: true }); + assert.deepEqual(keys(errors, 'idLvl'), ['string-or-number']); + }); +}); + +describe('analytics-dto CompareSchemasByListRequestDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareSchemasByListRequestDTO, { schemas: [{ type: 'id', value: 'x' }], idLvl: '0' }); + assert.equal(errors.length, 0); + }); + + it('reports required schemas array when empty', async () => { + const errors = await run(CompareSchemasByListRequestDTO, {}); + assert.deepEqual(keys(errors, 'schemas'), ['isArray']); + }); +}); + +describe('analytics-dto FilterModulesDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(FilterModulesDTO, { moduleId1: 'a', moduleId2: 'b' }); + assert.equal(errors.length, 0); + }); + + it('reports required module ids when empty', async () => { + const errors = await run(FilterModulesDTO, {}); + assert.deepEqual(props(errors).sort(), ['moduleId1', 'moduleId2']); + }); + + it('rejects boolean idLvl inherited from Options', async () => { + const errors = await run(FilterModulesDTO, { moduleId1: 'a', moduleId2: 'b', idLvl: true }); + assert.deepEqual(keys(errors, 'idLvl'), ['string-or-number']); + }); +}); + +describe('analytics-dto FilterDocumentsDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(FilterDocumentsDTO, {}); + assert.equal(errors.length, 0); + }); + + it('rejects non-string documentId1 and non-array documentIds', async () => { + const errors = await run(FilterDocumentsDTO, { documentId1: 5, documentIds: 'x' }); + assert.deepEqual(keys(errors, 'documentId1'), ['isString']); + assert.deepEqual(keys(errors, 'documentIds'), ['isArray']); + }); +}); + +describe('analytics-dto CompareDocumentsByIdsRequestDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareDocumentsByIdsRequestDTO, { documentId1: 'a', documentId2: 'b' }); + assert.equal(errors.length, 0); + }); + + it('reports required document ids when empty', async () => { + const errors = await run(CompareDocumentsByIdsRequestDTO, {}); + assert.deepEqual(props(errors).sort(), ['documentId1', 'documentId2']); + }); +}); + +describe('analytics-dto CompareDocumentsByListRequestDTO @unit', () => { + it('accepts an array of two ids', async () => { + const errors = await run(CompareDocumentsByListRequestDTO, { documentIds: ['a', 'b'] }); + assert.equal(errors.length, 0); + }); + + it('rejects an array shorter than the minimum size', async () => { + const errors = await run(CompareDocumentsByListRequestDTO, { documentIds: ['a'] }); + assert.deepEqual(keys(errors, 'documentIds'), ['arrayMinSize']); + }); + + it('reports arrayMinSize and isArray when not an array', async () => { + const errors = await run(CompareDocumentsByListRequestDTO, { documentIds: 'a' }); + assert.deepEqual(keys(errors, 'documentIds').sort(), ['arrayMinSize', 'isArray']); + }); +}); + +describe('analytics-dto FilterToolsDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(FilterToolsDTO, {}); + assert.equal(errors.length, 0); + }); + + it('rejects non-string toolId1 and non-array toolIds', async () => { + const errors = await run(FilterToolsDTO, { toolId1: 5, toolIds: 'x' }); + assert.deepEqual(keys(errors, 'toolId1'), ['isString']); + assert.deepEqual(keys(errors, 'toolIds'), ['isArray']); + }); +}); + +describe('analytics-dto CompareToolsByIdsRequestDTO @unit', () => { + it('accepts a fully valid payload', async () => { + const errors = await run(CompareToolsByIdsRequestDTO, { toolId1: 'a', toolId2: 'b' }); + assert.equal(errors.length, 0); + }); + + it('reports required tool ids when empty', async () => { + const errors = await run(CompareToolsByIdsRequestDTO, {}); + assert.deepEqual(props(errors).sort(), ['toolId1', 'toolId2']); + }); +}); + +describe('analytics-dto CompareToolsByListRequestDTO @unit', () => { + it('accepts an array of two ids', async () => { + const errors = await run(CompareToolsByListRequestDTO, { toolIds: ['a', 'b'] }); + assert.equal(errors.length, 0); + }); + + it('rejects an array shorter than the minimum size', async () => { + const errors = await run(CompareToolsByListRequestDTO, { toolIds: ['a'] }); + assert.deepEqual(keys(errors, 'toolIds'), ['arrayMinSize']); + }); +}); + +describe('analytics-dto FilterSearchPoliciesDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(FilterSearchPoliciesDTO, {}); + assert.equal(errors.length, 0); + }); + + it('accepts a fully populated payload', async () => { + const errors = await run(FilterSearchPoliciesDTO, { + policyId: 'a', type: 'Local', owner: 'o', minVcCount: 0, minVpCount: 0, + minTokensCount: 0, text: 't', threshold: 50, toolMessageIds: ['a'], toolName: 'n', toolVersion: '1' + }); + assert.equal(errors.length, 0); + }); + + it('rejects non-number minVcCount and threshold', async () => { + const errors = await run(FilterSearchPoliciesDTO, { minVcCount: 'x', threshold: 'y' }); + assert.deepEqual(keys(errors, 'minVcCount'), ['isNumber']); + assert.deepEqual(keys(errors, 'threshold'), ['isNumber']); + }); + + it('does not enforce the documented min and max bounds on threshold', async () => { + const errors = await run(FilterSearchPoliciesDTO, { threshold: 9999 }); + assert.equal(errors.length, 0); + }); + + it('rejects non-array toolMessageIds', async () => { + const errors = await run(FilterSearchPoliciesDTO, { toolMessageIds: 'x' }); + assert.deepEqual(keys(errors, 'toolMessageIds'), ['isArray']); + }); +}); + +describe('analytics-dto FilterSearchBlocksDTO @unit', () => { + it('accepts a valid id and config object', async () => { + const errors = await run(FilterSearchBlocksDTO, { id: 'u', config: {} }); + assert.equal(errors.length, 0); + }); + + it('reports required id and config when empty', async () => { + const errors = await run(FilterSearchBlocksDTO, {}); + assert.deepEqual(props(errors).sort(), ['config', 'id']); + assert.deepEqual(keys(errors, 'config'), ['isObject']); + }); + + it('rejects non-string id and non-object config', async () => { + const errors = await run(FilterSearchBlocksDTO, { id: 1, config: 'x' }); + assert.deepEqual(keys(errors, 'id'), ['isString']); + assert.deepEqual(keys(errors, 'config'), ['isObject']); + }); +}); + +describe('analytics-dto SearchPolicyDTO @unit', () => { + it('accepts an empty payload because every field is optional', async () => { + const errors = await run(SearchPolicyDTO, {}); + assert.equal(errors.length, 0); + }); + + it('accepts a populated payload', async () => { + const errors = await run(SearchPolicyDTO, { + type: 'Local', id: 'a', topicId: 't', messageId: 'm', uuid: 'u', name: 'n', + description: 'd', version: '1.0.0', status: 'DRAFT', owner: 'o', tags: [], + vcCount: 0, vpCount: 0, tokensCount: 0, rate: 0 + }); + assert.equal(errors.length, 0); + }); + + it('rejects non-number vcCount and rate', async () => { + const errors = await run(SearchPolicyDTO, { vcCount: 'x', rate: 'y' }); + assert.deepEqual(keys(errors, 'vcCount'), ['isNumber']); + assert.deepEqual(keys(errors, 'rate'), ['isNumber']); + }); + + it('rejects non-array tags', async () => { + const errors = await run(SearchPolicyDTO, { tags: 'x' }); + assert.deepEqual(keys(errors, 'tags'), ['isArray']); + }); +}); + +describe('analytics-dto SearchPoliciesDTO @unit', () => { + it('accepts a valid result array and null target', async () => { + const errors = await run(SearchPoliciesDTO, { target: null, result: [] }); + assert.equal(errors.length, 0); + }); + + it('reports required result array when empty', async () => { + const errors = await run(SearchPoliciesDTO, {}); + assert.deepEqual(keys(errors, 'result'), ['isArray']); + }); + + it('rejects non-object target and non-array result', async () => { + const errors = await run(SearchPoliciesDTO, { target: 'x', result: 'y' }); + assert.deepEqual(keys(errors, 'target'), ['isObject']); + assert.deepEqual(keys(errors, 'result'), ['isArray']); + }); + + it('does not deep-validate result elements (no ValidateNested)', async () => { + const errors = await run(SearchPoliciesDTO, { result: [{ vcCount: 'x' }] }); + assert.equal(errors.length, 0); + }); +}); diff --git a/api-gateway/tests/validation/dto-misc.test.mjs b/api-gateway/tests/validation/dto-misc.test.mjs new file mode 100644 index 0000000000..f3dd7e7aa7 --- /dev/null +++ b/api-gateway/tests/validation/dto-misc.test.mjs @@ -0,0 +1,550 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'mocha'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + StatisticDefinitionDTO, + StatisticAssessmentDTO, + StatisticAssessmentRelationshipsDTO, + StatisticDefinitionRelationshipsDTO, +} from '../../dist/middlewares/validation/schemas/policy-statistics.dto.js'; +import { + NotificationDTO, + ProgressDTO, +} from '../../dist/middlewares/validation/schemas/notifications.js'; +import { + AboutResponseDTO, + SettingsDTO, +} from '../../dist/middlewares/validation/schemas/settings.js'; +import { + SuggestionsInputDTO, + SuggestionsOutputDTO, + SuggestionsConfigItemDTO, + SuggestionsConfigDTO, +} from '../../dist/middlewares/validation/schemas/suggestions.js'; +import { WorkersTasksDTO } from '../../dist/middlewares/validation/schemas/worker-tasks.dto.js'; +import { AssignPolicyDTO } from '../../dist/middlewares/validation/schemas/permissions.dto.js'; + +const errorsFor = async (Dto, input) => validate(plainToInstance(Dto, input)); + +const props = (errs) => errs.map((e) => e.property).sort(); + +const constraintsFor = (errs, property) => { + const found = errs.find((e) => e.property === property); + return found ? Object.keys(found.constraints || {}) : []; +}; + +describe('policy-statistics.dto @unit', () => { + describe('StatisticDefinitionDTO', () => { + it('passes with all valid fields', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { + id: 'abc', + uuid: 'u-1', + name: 'Tool name', + description: 'desc', + creator: 'did:hedera', + owner: 'did:hedera', + topicId: '0.0.1', + messageId: 'm-1', + policyId: 'p-1', + policyTopicId: '0.0.2', + policyInstanceTopicId: '0.0.3', + status: 'DRAFT', + config: { a: 1 }, + }); + assert.equal(errs.length, 0); + }); + + it('passes with only required name', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { name: 'X' }); + assert.equal(errs.length, 0); + }); + + it('fails when name is missing', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, {}); + assert.ok(constraintsFor(errs, 'name').includes('isString')); + }); + + it('fails when name is not a string', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { name: 123 }); + assert.ok(constraintsFor(errs, 'name').includes('isString')); + }); + + it('fails when uuid is not a string', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { name: 'X', uuid: 5 }); + assert.ok(constraintsFor(errs, 'uuid').includes('isString')); + }); + + it('fails when config is not an object', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { name: 'X', config: 'nope' }); + assert.ok(constraintsFor(errs, 'config').includes('isObject')); + }); + + it('omitting optionals yields no errors for them', async () => { + const errs = await errorsFor(StatisticDefinitionDTO, { name: 'X' }); + assert.equal(errs.length, 0); + }); + }); + + describe('StatisticAssessmentDTO', () => { + it('passes with valid fields', async () => { + const errs = await errorsFor(StatisticAssessmentDTO, { + id: 'i', + definitionId: 'd', + policyId: 'p', + policyTopicId: '0.0.1', + policyInstanceTopicId: '0.0.2', + topicId: '0.0.3', + creator: 'did', + owner: 'did', + messageId: 'm', + target: 'm2', + relationships: ['m3'], + document: { x: 1 }, + }); + assert.equal(errs.length, 0); + }); + + it('passes with empty object since all are optional', async () => { + const errs = await errorsFor(StatisticAssessmentDTO, {}); + assert.equal(errs.length, 0); + }); + + it('fails when relationships is not an array', async () => { + const errs = await errorsFor(StatisticAssessmentDTO, { relationships: 'no' }); + assert.ok(constraintsFor(errs, 'relationships').includes('isArray')); + }); + + it('fails when document is not an object', async () => { + const errs = await errorsFor(StatisticAssessmentDTO, { document: 7 }); + assert.ok(constraintsFor(errs, 'document').includes('isObject')); + }); + + it('fails when topicId is not a string', async () => { + const errs = await errorsFor(StatisticAssessmentDTO, { topicId: 9 }); + assert.ok(constraintsFor(errs, 'topicId').includes('isString')); + }); + }); + + describe('StatisticAssessmentRelationshipsDTO', () => { + it('passes with valid object and array', async () => { + const errs = await errorsFor(StatisticAssessmentRelationshipsDTO, { + target: { id: 1 }, + relationships: [{ id: 2 }], + }); + assert.equal(errs.length, 0); + }); + + it('passes when empty (all optional)', async () => { + const errs = await errorsFor(StatisticAssessmentRelationshipsDTO, {}); + assert.equal(errs.length, 0); + }); + + it('fails when target is not an object', async () => { + const errs = await errorsFor(StatisticAssessmentRelationshipsDTO, { target: 'x' }); + assert.ok(constraintsFor(errs, 'target').includes('isObject')); + }); + + it('fails when relationships is not an array', async () => { + const errs = await errorsFor(StatisticAssessmentRelationshipsDTO, { relationships: 1 }); + assert.ok(constraintsFor(errs, 'relationships').includes('isArray')); + }); + }); + + describe('StatisticDefinitionRelationshipsDTO', () => { + it('passes with valid policy, schemas and schema', async () => { + const errs = await errorsFor(StatisticDefinitionRelationshipsDTO, { + policy: { id: 1 }, + schemas: [{ id: 2 }], + schema: { id: 3 }, + }); + assert.equal(errs.length, 0); + }); + + it('passes when empty (all optional)', async () => { + const errs = await errorsFor(StatisticDefinitionRelationshipsDTO, {}); + assert.equal(errs.length, 0); + }); + + it('fails when policy is not an object', async () => { + const errs = await errorsFor(StatisticDefinitionRelationshipsDTO, { policy: 'x' }); + assert.ok(constraintsFor(errs, 'policy').includes('isObject')); + }); + + it('fails when schemas is not an array', async () => { + const errs = await errorsFor(StatisticDefinitionRelationshipsDTO, { schemas: 5 }); + assert.ok(constraintsFor(errs, 'schemas').includes('isArray')); + }); + }); +}); + +describe('notifications @unit', () => { + describe('NotificationDTO', () => { + it('passes with valid type and optionals', async () => { + const errs = await errorsFor(NotificationDTO, { + id: 'i', + createDate: '2020-01-01', + updateDate: '2020-01-02', + userId: 'u', + title: 't', + message: 'm', + type: 'SUCCESS', + action: 'POLICY_CONFIGURATION', + result: { a: 1 }, + read: false, + old: true, + }); + assert.equal(errs.length, 0); + }); + + it('passes with only required type', async () => { + const errs = await errorsFor(NotificationDTO, { type: 'INFO' }); + assert.equal(errs.length, 0); + }); + + it('fails when type is missing', async () => { + const errs = await errorsFor(NotificationDTO, {}); + assert.ok(constraintsFor(errs, 'type').includes('isEnum')); + }); + + it('fails when type is not in enum', async () => { + const errs = await errorsFor(NotificationDTO, { type: 'NOPE' }); + assert.ok(constraintsFor(errs, 'type').includes('isEnum')); + }); + + it('fails when action is not in enum', async () => { + const errs = await errorsFor(NotificationDTO, { type: 'INFO', action: 'BAD' }); + assert.ok(constraintsFor(errs, 'action').includes('isEnum')); + }); + + it('fails when read is not a boolean', async () => { + const errs = await errorsFor(NotificationDTO, { type: 'INFO', read: 'yes' }); + assert.ok(constraintsFor(errs, 'read').includes('isBoolean')); + }); + + it('fails when title is not a string', async () => { + const errs = await errorsFor(NotificationDTO, { type: 'INFO', title: 9 }); + assert.ok(constraintsFor(errs, 'title').includes('isString')); + }); + }); + + describe('ProgressDTO', () => { + it('passes with valid required fields', async () => { + const errs = await errorsFor(ProgressDTO, { + action: 'Publish policy', + progress: 50, + type: 'INFO', + }); + assert.equal(errs.length, 0); + }); + + it('fails when required fields are missing', async () => { + const errs = await errorsFor(ProgressDTO, {}); + assert.deepEqual(props(errs), ['action', 'progress', 'type']); + }); + + it('fails when action is not a string', async () => { + const errs = await errorsFor(ProgressDTO, { action: 1, progress: 0, type: 'INFO' }); + assert.ok(constraintsFor(errs, 'action').includes('isString')); + }); + + it('fails when progress is not a number', async () => { + const errs = await errorsFor(ProgressDTO, { action: 'a', progress: 'x', type: 'INFO' }); + assert.ok(constraintsFor(errs, 'progress').includes('isNumber')); + }); + + it('fails when type is not in enum', async () => { + const errs = await errorsFor(ProgressDTO, { action: 'a', progress: 0, type: 'X' }); + assert.ok(constraintsFor(errs, 'type').includes('isEnum')); + }); + + it('passes with optional taskId and message', async () => { + const errs = await errorsFor(ProgressDTO, { + action: 'a', + progress: 100, + type: 'WARN', + message: 'msg', + taskId: 'task-1', + }); + assert.equal(errs.length, 0); + }); + }); +}); + +describe('settings @unit', () => { + describe('AboutResponseDTO', () => { + it('passes with a version string', async () => { + const errs = await errorsFor(AboutResponseDTO, { version: '2.8.1' }); + assert.equal(errs.length, 0); + }); + + it('fails when version is missing', async () => { + const errs = await errorsFor(AboutResponseDTO, {}); + assert.ok(constraintsFor(errs, 'version').includes('isString')); + }); + + it('fails when version is not a string', async () => { + const errs = await errorsFor(AboutResponseDTO, { version: 281 }); + assert.ok(constraintsFor(errs, 'version').includes('isString')); + }); + }); + + describe('SettingsDTO', () => { + it('passes with all three non-empty strings', async () => { + const errs = await errorsFor(SettingsDTO, { + ipfsStorageApiKey: 'key', + operatorId: '0.0.1', + operatorKey: 'secret', + }); + assert.equal(errs.length, 0); + }); + + it('fails when all are missing', async () => { + const errs = await errorsFor(SettingsDTO, {}); + assert.deepEqual(props(errs), ['ipfsStorageApiKey', 'operatorId', 'operatorKey']); + }); + + it('fails isNotEmpty when ipfsStorageApiKey is empty string', async () => { + const errs = await errorsFor(SettingsDTO, { + ipfsStorageApiKey: '', + operatorId: 'o', + operatorKey: 'k', + }); + assert.ok(constraintsFor(errs, 'ipfsStorageApiKey').includes('isNotEmpty')); + }); + + it('fails isString when operatorId is a number', async () => { + const errs = await errorsFor(SettingsDTO, { + ipfsStorageApiKey: 'k', + operatorId: 1, + operatorKey: 'k', + }); + assert.ok(constraintsFor(errs, 'operatorId').includes('isString')); + }); + }); +}); + +describe('suggestions @unit', () => { + describe('SuggestionsInputDTO', () => { + it('passes with valid blockType', async () => { + const errs = await errorsFor(SuggestionsInputDTO, { blockType: 'block' }); + assert.equal(errs.length, 0); + }); + + it('fails when blockType is missing', async () => { + const errs = await errorsFor(SuggestionsInputDTO, {}); + assert.ok(constraintsFor(errs, 'blockType').includes('isNotEmpty')); + }); + + it('fails when blockType is empty string', async () => { + const errs = await errorsFor(SuggestionsInputDTO, { blockType: '' }); + assert.ok(constraintsFor(errs, 'blockType').includes('isNotEmpty')); + }); + + it('children are not validated (no decorators)', async () => { + const errs = await errorsFor(SuggestionsInputDTO, { + blockType: 'b', + children: 'not-an-array', + }); + assert.equal(errs.length, 0); + }); + }); + + describe('SuggestionsOutputDTO', () => { + it('passes with valid strings', async () => { + const errs = await errorsFor(SuggestionsOutputDTO, { next: 'a', nested: 'b' }); + assert.equal(errs.length, 0); + }); + + it('fails when next and nested are missing', async () => { + const errs = await errorsFor(SuggestionsOutputDTO, {}); + assert.deepEqual(props(errs), ['nested', 'next']); + }); + + it('fails when next is not a string', async () => { + const errs = await errorsFor(SuggestionsOutputDTO, { next: 1, nested: 'b' }); + assert.ok(constraintsFor(errs, 'next').includes('isString')); + }); + }); + + describe('SuggestionsConfigItemDTO', () => { + it('passes with valid id, enum type and int index', async () => { + const errs = await errorsFor(SuggestionsConfigItemDTO, { + id: 'x', + type: 'Policy', + index: 0, + }); + assert.equal(errs.length, 0); + }); + + it('fails when id is empty', async () => { + const errs = await errorsFor(SuggestionsConfigItemDTO, { + id: '', + type: 'Module', + index: 1, + }); + assert.ok(constraintsFor(errs, 'id').includes('isNotEmpty')); + }); + + it('fails when type is not in enum', async () => { + const errs = await errorsFor(SuggestionsConfigItemDTO, { + id: 'x', + type: 'Bad', + index: 1, + }); + assert.ok(constraintsFor(errs, 'type').includes('isEnum')); + }); + + it('fails when index is not an int', async () => { + const errs = await errorsFor(SuggestionsConfigItemDTO, { + id: 'x', + type: 'Policy', + index: 1.5, + }); + assert.ok(constraintsFor(errs, 'index').includes('isInt')); + }); + + it('fails when index is a string', async () => { + const errs = await errorsFor(SuggestionsConfigItemDTO, { + id: 'x', + type: 'Policy', + index: 'no', + }); + assert.ok(constraintsFor(errs, 'index').includes('isInt')); + }); + }); + + describe('SuggestionsConfigDTO', () => { + it('passes with an array of items', async () => { + const errs = await errorsFor(SuggestionsConfigDTO, { + items: [{ id: 'x', type: 'Policy', index: 0 }], + }); + assert.equal(errs.length, 0); + }); + + it('passes with empty array', async () => { + const errs = await errorsFor(SuggestionsConfigDTO, { items: [] }); + assert.equal(errs.length, 0); + }); + + it('fails when items is not an array', async () => { + const errs = await errorsFor(SuggestionsConfigDTO, { items: 'no' }); + assert.ok(constraintsFor(errs, 'items').includes('isArray')); + }); + + it('does not deep-validate nested items (no ValidateNested)', async () => { + const errs = await errorsFor(SuggestionsConfigDTO, { + items: [{ id: '', type: 'Bad', index: 1.5 }], + }); + assert.equal(errs.length, 0); + }); + }); +}); + +describe('worker-tasks.dto @unit', () => { + describe('WorkersTasksDTO', () => { + it('passes with all valid fields', async () => { + const errs = await errorsFor(WorkersTasksDTO, { + createDate: '2020-01-01', + done: true, + id: 'id', + isRetryableTask: false, + processedTime: '2020-01-02', + sent: true, + taskId: 'uuid', + type: 'send-hedera', + updateDate: '2020-01-03', + }); + assert.equal(errs.length, 0); + }); + + it('fails when all required fields are missing', async () => { + const errs = await errorsFor(WorkersTasksDTO, {}); + assert.deepEqual(props(errs), [ + 'createDate', + 'done', + 'id', + 'isRetryableTask', + 'processedTime', + 'sent', + 'taskId', + 'type', + 'updateDate', + ]); + }); + + it('fails when done is not a boolean', async () => { + const errs = await errorsFor(WorkersTasksDTO, { + createDate: 'd', + done: 'yes', + id: 'i', + isRetryableTask: true, + processedTime: 'p', + sent: true, + taskId: 't', + type: 'send-hedera', + updateDate: 'u', + }); + assert.ok(constraintsFor(errs, 'done').includes('isBoolean')); + }); + + it('fails when id is not a string', async () => { + const errs = await errorsFor(WorkersTasksDTO, { + createDate: 'd', + done: true, + id: 5, + isRetryableTask: true, + processedTime: 'p', + sent: true, + taskId: 't', + type: 'send-hedera', + updateDate: 'u', + }); + assert.ok(constraintsFor(errs, 'id').includes('isString')); + }); + + it('fails when sent is not a boolean', async () => { + const errs = await errorsFor(WorkersTasksDTO, { + createDate: 'd', + done: true, + id: 'i', + isRetryableTask: true, + processedTime: 'p', + sent: 1, + taskId: 't', + type: 'send-hedera', + updateDate: 'u', + }); + assert.ok(constraintsFor(errs, 'sent').includes('isBoolean')); + }); + }); +}); + +describe('permissions.dto @unit', () => { + describe('AssignPolicyDTO', () => { + it('passes with array and boolean', async () => { + const errs = await errorsFor(AssignPolicyDTO, { policyIds: ['a', 'b'], assign: true }); + assert.equal(errs.length, 0); + }); + + it('fails when policyIds and assign are missing', async () => { + const errs = await errorsFor(AssignPolicyDTO, {}); + assert.deepEqual(props(errs), ['assign', 'policyIds']); + }); + + it('fails when policyIds is not an array', async () => { + const errs = await errorsFor(AssignPolicyDTO, { policyIds: 'x', assign: true }); + assert.ok(constraintsFor(errs, 'policyIds').includes('isArray')); + }); + + it('fails when assign is not a boolean', async () => { + const errs = await errorsFor(AssignPolicyDTO, { policyIds: [], assign: 'yes' }); + assert.ok(constraintsFor(errs, 'assign').includes('isBoolean')); + }); + + it('passes with empty array', async () => { + const errs = await errorsFor(AssignPolicyDTO, { policyIds: [], assign: false }); + assert.equal(errs.length, 0); + }); + }); +}); diff --git a/api-gateway/tests/validation/dto-mock-external-formulas.test.mjs b/api-gateway/tests/validation/dto-mock-external-formulas.test.mjs new file mode 100644 index 0000000000..65765db45f --- /dev/null +++ b/api-gateway/tests/validation/dto-mock-external-formulas.test.mjs @@ -0,0 +1,488 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + MockBlockConfigDTO, + MockConfigDTO, + MockIpfsDataDTO, + MockTopicTransactionDTO, + MockMessageTransactionDTO, + MockTopicDataDTO, + MockTokenDataDTO, + MockRequestConfigDTO, + MockApiDataDTO, + MockUserDataDTO, + MockDataDTO, + MockApiRequestDTO, + MockIpfsRequestDTO, +} from '../../dist/middlewares/validation/schemas/mock.dto.js'; +import { + ExternalPolicyDTO, + PolicyRequestDTO, + PolicyRequestCountDTO, +} from '../../dist/middlewares/validation/schemas/external-policies.dto.js'; +import { + FormulaDTO, + FormulaRelationshipsDTO, + FormulasOptionsDTO, + FormulasDataDTO, +} from '../../dist/middlewares/validation/schemas/formulas.dto.js'; + +const errsFor = (Dto, input) => validate(plainToInstance(Dto, input)); + +const keysFor = (errors, property) => { + const out = []; + const walk = (list, prefix) => { + for (const e of list) { + const path = prefix ? `${prefix}.${e.property}` : e.property; + if (e.constraints) { + for (const k of Object.keys(e.constraints)) { + out.push({ property: path, key: k }); + } + } + if (e.children && e.children.length) { + walk(e.children, path); + } + } + }; + walk(errors, ''); + return property ? out.filter((o) => o.property === property).map((o) => o.key) : out; +}; + +const hasConstraint = (errors, property, key) => keysFor(errors, property).includes(key); + +describe('@unit P7 validation DTOs: mock / external-policies / formulas', () => { + describe('MockBlockConfigDTO', () => { + it('is fully valid with all optional fields', async () => { + assert.equal((await errsFor(MockBlockConfigDTO, { uuid: 'u', enabled: true })).length, 0); + }); + + it('is valid when all optional fields omitted', async () => { + assert.equal((await errsFor(MockBlockConfigDTO, {})).length, 0); + }); + + it('rejects non-string uuid', async () => { + assert.equal(hasConstraint(await errsFor(MockBlockConfigDTO, { uuid: 5 }), 'uuid', 'isString'), true); + }); + + it('rejects non-boolean enabled', async () => { + assert.equal(hasConstraint(await errsFor(MockBlockConfigDTO, { enabled: 'yes' }), 'enabled', 'isBoolean'), true); + }); + }); + + describe('MockConfigDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(MockConfigDTO, { enabled: false, blocks: [{ uuid: 'a' }] })).length, 0); + }); + + it('is valid when optional fields omitted', async () => { + assert.equal((await errsFor(MockConfigDTO, {})).length, 0); + }); + + it('rejects non-array blocks', async () => { + assert.equal(hasConstraint(await errsFor(MockConfigDTO, { blocks: {} }), 'blocks', 'isArray'), true); + }); + + it('does not deep-validate block array items (no ValidateNested)', async () => { + assert.equal((await errsFor(MockConfigDTO, { blocks: [{ uuid: 5, enabled: 'no' }] })).length, 0); + }); + }); + + describe('MockIpfsDataDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(MockIpfsDataDTO, { cid: 'c', content: 'x' })).length, 0); + }); + + it('rejects non-string cid', async () => { + assert.equal(hasConstraint(await errsFor(MockIpfsDataDTO, { cid: 5 }), 'cid', 'isString'), true); + }); + + it('rejects non-string content', async () => { + assert.equal(hasConstraint(await errsFor(MockIpfsDataDTO, { content: {} }), 'content', 'isString'), true); + }); + }); + + describe('MockTopicTransactionDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(MockTopicTransactionDTO, { id: '0.0.1', memo: 'm', payer_account_id: '0.0.2', topic_id: '0.0.3' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string payer_account_id', async () => { + assert.equal(hasConstraint(await errsFor(MockTopicTransactionDTO, { payer_account_id: 5 }), 'payer_account_id', 'isString'), true); + }); + + it('rejects non-string memo', async () => { + assert.equal(hasConstraint(await errsFor(MockTopicTransactionDTO, { memo: 1 }), 'memo', 'isString'), true); + }); + }); + + describe('MockMessageTransactionDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(MockMessageTransactionDTO, { + consensus_timestamp: '1', + id: '2', + message: 'base64', + payer_account_id: '0.0.1', + sequence_number: 3, + topicId: '0.0.2', + topic_id: '0.0.2', + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-number sequence_number', async () => { + assert.equal(hasConstraint(await errsFor(MockMessageTransactionDTO, { sequence_number: 'x' }), 'sequence_number', 'isNumber'), true); + }); + + it('rejects non-string message', async () => { + assert.equal(hasConstraint(await errsFor(MockMessageTransactionDTO, { message: 1 }), 'message', 'isString'), true); + }); + + it('accepts numeric sequence_number', async () => { + assert.equal((await errsFor(MockMessageTransactionDTO, { sequence_number: 42 })).length, 0); + }); + }); + + describe('MockTopicDataDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(MockTopicDataDTO, { topicId: '0.0.1', topic: {}, messages: [] })).length, 0); + }); + + it('rejects non-object topic', async () => { + assert.equal(hasConstraint(await errsFor(MockTopicDataDTO, { topic: 'x' }), 'topic', 'isObject'), true); + }); + + it('rejects non-array messages', async () => { + assert.equal(hasConstraint(await errsFor(MockTopicDataDTO, { messages: {} }), 'messages', 'isArray'), true); + }); + }); + + describe('MockTokenDataDTO', () => { + it('is fully valid with string fields', async () => { + const errs = await errsFor(MockTokenDataDTO, { + id: '0.0.1', + token_id: '0.0.2', + treasury_account_id: '0.0.3', + name: 'Name', + symbol: 'S', + decimals: '2', + type: 'FUNGIBLE_COMMON', + admin_key: true, + freeze_key: false, + kyc_key: true, + supply_key: false, + wipe_key: true, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-boolean admin_key', async () => { + assert.equal(hasConstraint(await errsFor(MockTokenDataDTO, { admin_key: 'x' }), 'admin_key', 'isBoolean'), true); + }); + + it('rejects non-string type', async () => { + assert.equal(hasConstraint(await errsFor(MockTokenDataDTO, { type: 1 }), 'type', 'isString'), true); + }); + + it('treats decimals as a string field, rejecting a numeric value', async () => { + assert.equal(hasConstraint(await errsFor(MockTokenDataDTO, { decimals: 2 }), 'decimals', 'isString'), true); + }); + }); + + describe('MockRequestConfigDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(MockRequestConfigDTO, { method: 'GET', responseType: 'JSON', url: 'http://localhost/' })).length, 0); + }); + + it('rejects non-string method', async () => { + assert.equal(hasConstraint(await errsFor(MockRequestConfigDTO, { method: 1 }), 'method', 'isString'), true); + }); + + it('rejects non-string url', async () => { + assert.equal(hasConstraint(await errsFor(MockRequestConfigDTO, { url: {} }), 'url', 'isString'), true); + }); + }); + + describe('MockApiDataDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(MockApiDataDTO, { request: {}, response: 'JSON' })).length, 0); + }); + + it('rejects non-object request', async () => { + assert.equal(hasConstraint(await errsFor(MockApiDataDTO, { request: 'x' }), 'request', 'isObject'), true); + }); + + it('rejects non-string response', async () => { + assert.equal(hasConstraint(await errsFor(MockApiDataDTO, { response: 1 }), 'response', 'isString'), true); + }); + }); + + describe('MockUserDataDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(MockUserDataDTO, { + username: 'u', + did: 'did:hedera:x', + hederaAccountId: '0.0.1', + hederaAccountKey: 'key', + document: {}, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string username', async () => { + assert.equal(hasConstraint(await errsFor(MockUserDataDTO, { username: 5 }), 'username', 'isString'), true); + }); + + it('rejects non-object document', async () => { + assert.equal(hasConstraint(await errsFor(MockUserDataDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + }); + + describe('MockDataDTO', () => { + it('is valid when empty', async () => { + assert.equal((await errsFor(MockDataDTO, {})).length, 0); + }); + + it('is valid with arrays for all sections', async () => { + assert.equal((await errsFor(MockDataDTO, { ipfs: [], topics: [], tokens: [], api: [], users: [] })).length, 0); + }); + + it('rejects non-array ipfs', async () => { + assert.equal(hasConstraint(await errsFor(MockDataDTO, { ipfs: {} }), 'ipfs', 'isArray'), true); + }); + + it('rejects non-array users', async () => { + assert.equal(hasConstraint(await errsFor(MockDataDTO, { users: 'x' }), 'users', 'isArray'), true); + }); + + it('does not deep-validate array items (no ValidateNested)', async () => { + assert.equal((await errsFor(MockDataDTO, { tokens: [{ admin_key: 'not-bool' }] })).length, 0); + }); + }); + + describe('MockApiRequestDTO', () => { + it('is valid for any shape (body/headers carry no decorators)', async () => { + assert.equal((await errsFor(MockApiRequestDTO, { type: 'GET', url: 'http://localhost/', body: { a: 1 }, headers: { h: 'v' } })).length, 0); + }); + + it('rejects non-string type', async () => { + assert.equal(hasConstraint(await errsFor(MockApiRequestDTO, { type: 1 }), 'type', 'isString'), true); + }); + + it('does not validate body even when a primitive', async () => { + const errs = await errsFor(MockApiRequestDTO, { body: 5, headers: 'string-headers' }); + assert.equal(keysFor(errs, 'body').length, 0); + assert.equal(keysFor(errs, 'headers').length, 0); + }); + }); + + describe('MockIpfsRequestDTO', () => { + it('is valid when empty', async () => { + assert.equal((await errsFor(MockIpfsRequestDTO, {})).length, 0); + }); + + it('rejects non-string cid', async () => { + assert.equal(hasConstraint(await errsFor(MockIpfsRequestDTO, { cid: 1 }), 'cid', 'isString'), true); + }); + }); + + describe('ExternalPolicyDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(ExternalPolicyDTO, { + uuid: 'u', + name: 'Policy', + description: 'desc', + version: '1.0.0', + topicId: '0.0.1', + instanceTopicId: '0.0.2', + messageId: 'm', + policyTag: 'tag', + owner: 'did:x', + status: 'NEW', + username: 'user', + }); + assert.equal(errs.length, 0); + }); + + it('is valid when all optional fields omitted', async () => { + assert.equal((await errsFor(ExternalPolicyDTO, {})).length, 0); + }); + + it('rejects non-string name', async () => { + assert.equal(hasConstraint(await errsFor(ExternalPolicyDTO, { name: 5 }), 'name', 'isString'), true); + }); + + it('rejects non-string status (enum not enforced, only IsString)', async () => { + assert.equal(hasConstraint(await errsFor(ExternalPolicyDTO, { status: 5 }), 'status', 'isString'), true); + }); + + it('accepts an arbitrary string status (no enum constraint)', async () => { + assert.equal((await errsFor(ExternalPolicyDTO, { status: 'NOT_A_REAL_STATUS' })).length, 0); + }); + }); + + describe('PolicyRequestDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(PolicyRequestDTO, { + uuid: 'u', + type: 'ACTION', + messageId: 'm', + startMessageId: 'sm', + status: 'NEW', + lastStatus: 'NEW', + accountId: '0.0.1', + sender: '0.0.2', + owner: 'did:x', + topicId: '0.0.3', + document: { a: 1 }, + policyId: 'p', + blockTag: 'tag', + policyMessageId: 'pm', + loaded: true, + }); + assert.equal(errs.length, 0); + }); + + it('is valid when empty', async () => { + assert.equal((await errsFor(PolicyRequestDTO, {})).length, 0); + }); + + it('rejects non-object document', async () => { + assert.equal(hasConstraint(await errsFor(PolicyRequestDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + + it('rejects non-boolean loaded', async () => { + assert.equal(hasConstraint(await errsFor(PolicyRequestDTO, { loaded: 'yes' }), 'loaded', 'isBoolean'), true); + }); + + it('rejects non-string accountId', async () => { + assert.equal(hasConstraint(await errsFor(PolicyRequestDTO, { accountId: 5 }), 'accountId', 'isString'), true); + }); + }); + + describe('PolicyRequestCountDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(PolicyRequestCountDTO, { requestsCount: 1, actionsCount: 2, delayCount: 3, total: 6 })).length, 0); + }); + + it('is valid when empty', async () => { + assert.equal((await errsFor(PolicyRequestCountDTO, {})).length, 0); + }); + + it('rejects non-number requestsCount', async () => { + assert.equal(hasConstraint(await errsFor(PolicyRequestCountDTO, { requestsCount: 'x' }), 'requestsCount', 'isNumber'), true); + }); + + it('rejects non-number total', async () => { + assert.equal(hasConstraint(await errsFor(PolicyRequestCountDTO, { total: 'x' }), 'total', 'isNumber'), true); + }); + }); + + describe('FormulaDTO', () => { + it('is fully valid', async () => { + const errs = await errsFor(FormulaDTO, { + id: 'db-id', + uuid: 'u', + name: 'Formula', + description: 'desc', + creator: 'did:x', + owner: 'did:y', + messageId: 'm', + policyId: 'p', + policyTopicId: '0.0.1', + policyInstanceTopicId: '0.0.2', + status: 'DRAFT', + config: { a: 1 }, + }); + assert.equal(errs.length, 0); + }); + + it('requires name (missing name fails isString)', async () => { + assert.equal(hasConstraint(await errsFor(FormulaDTO, {}), 'name', 'isString'), true); + }); + + it('is valid with only the required name present', async () => { + assert.equal((await errsFor(FormulaDTO, { name: 'Formula' })).length, 0); + }); + + it('rejects non-string name', async () => { + assert.equal(hasConstraint(await errsFor(FormulaDTO, { name: 5 }), 'name', 'isString'), true); + }); + + it('rejects non-object config', async () => { + assert.equal(hasConstraint(await errsFor(FormulaDTO, { name: 'F', config: 'x' }), 'config', 'isObject'), true); + }); + + it('does not validate id (no decorators)', async () => { + const errs = await errsFor(FormulaDTO, { name: 'F', id: 12345 }); + assert.equal(keysFor(errs, 'id').length, 0); + }); + }); + + describe('FormulaRelationshipsDTO', () => { + it('is fully valid (formulas validated as object, not array)', async () => { + assert.equal((await errsFor(FormulaRelationshipsDTO, { policy: {}, schemas: [], formulas: {} })).length, 0); + }); + + it('is valid when empty', async () => { + assert.equal((await errsFor(FormulaRelationshipsDTO, {})).length, 0); + }); + + it('rejects an array for formulas (decorated IsObject not IsArray)', async () => { + assert.equal(hasConstraint(await errsFor(FormulaRelationshipsDTO, { formulas: [] }), 'formulas', 'isObject'), true); + }); + + it('rejects non-object policy', async () => { + assert.equal(hasConstraint(await errsFor(FormulaRelationshipsDTO, { policy: 'x' }), 'policy', 'isObject'), true); + }); + + it('rejects non-array schemas', async () => { + assert.equal(hasConstraint(await errsFor(FormulaRelationshipsDTO, { schemas: {} }), 'schemas', 'isArray'), true); + }); + + it('validates formulas as an object, not an array (accepts a plain object)', async () => { + assert.equal((await errsFor(FormulaRelationshipsDTO, { formulas: {} })).length, 0); + }); + }); + + describe('FormulasOptionsDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(FormulasOptionsDTO, { policyId: 'p', schemaId: 's', documentId: 'd', parentId: 'pa' })).length, 0); + }); + + it('is valid when empty', async () => { + assert.equal((await errsFor(FormulasOptionsDTO, {})).length, 0); + }); + + it('rejects non-string policyId', async () => { + assert.equal(hasConstraint(await errsFor(FormulasOptionsDTO, { policyId: 5 }), 'policyId', 'isString'), true); + }); + + it('rejects non-string parentId', async () => { + assert.equal(hasConstraint(await errsFor(FormulasOptionsDTO, { parentId: {} }), 'parentId', 'isString'), true); + }); + }); + + describe('FormulasDataDTO', () => { + it('is fully valid', async () => { + assert.equal((await errsFor(FormulasDataDTO, { formulas: [], document: {}, relationships: [], schemas: [] })).length, 0); + }); + + it('is valid when empty', async () => { + assert.equal((await errsFor(FormulasDataDTO, {})).length, 0); + }); + + it('rejects non-array formulas', async () => { + assert.equal(hasConstraint(await errsFor(FormulasDataDTO, { formulas: {} }), 'formulas', 'isArray'), true); + }); + + it('rejects non-object document', async () => { + assert.equal(hasConstraint(await errsFor(FormulasDataDTO, { document: 'x' }), 'document', 'isObject'), true); + }); + + it('rejects non-array relationships', async () => { + assert.equal(hasConstraint(await errsFor(FormulasDataDTO, { relationships: {} }), 'relationships', 'isArray'), true); + }); + }); +}); diff --git a/api-gateway/tests/validation/dto-nested-enum-gaps.test.mjs b/api-gateway/tests/validation/dto-nested-enum-gaps.test.mjs new file mode 100644 index 0000000000..f82e5ce182 --- /dev/null +++ b/api-gateway/tests/validation/dto-nested-enum-gaps.test.mjs @@ -0,0 +1,575 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'mocha'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + PolicyDTO, + PolicyTestDTO, + PolicyToolDTO, + PolicyPreviewDTO, +} from '../../dist/middlewares/validation/schemas/policies.dto.js'; +import { + StatisticDefinitionDTO, + StatisticAssessmentDTO, + StatisticAssessmentRelationshipsDTO, + StatisticDefinitionRelationshipsDTO, +} from '../../dist/middlewares/validation/schemas/policy-statistics.dto.js'; +import { + SchemaDTO, + SchemaWithSubSchemasDTO, + SystemSchemaDTO, + SchemaImportDuplicatesRequestDTO, +} from '../../dist/middlewares/validation/schemas/schemas.dto.js'; +import { + PolicyLabelDTO, + PolicyLabelRelationshipsDTO, + PolicyLabelDocumentDTO, + PolicyLabelDocumentRelationshipsDTO, + PolicyLabelComponentsDTO, + PolicyLabelFiltersDTO, +} from '../../dist/middlewares/validation/schemas/policy-labels.dto.js'; +import { + SchemaRuleDTO, + SchemaRuleRelationshipsDTO, + SchemaRuleDataDTO, +} from '../../dist/middlewares/validation/schemas/schema-rules.dto.js'; +import { + MockTopicDataDTO, + MockApiDataDTO, + MockUserDataDTO, +} from '../../dist/middlewares/validation/schemas/mock.dto.js'; + +const run = (Dto, input) => validate(plainToInstance(Dto, input)); + +const keys = (errors, property) => { + const found = errors.find((e) => e.property === property); + return found && found.constraints ? Object.keys(found.constraints) : []; +}; + +const children = (errors, property) => { + const found = errors.find((e) => e.property === property); + return found && found.children ? found.children : []; +}; + +const childProps = (errors, property) => children(errors, property).map((c) => c.property); + +describe('@unit DTO nested / enum / bound gap sweep', () => { + describe('PolicyTestDTO swagger-only enum status', () => { + it('accepts a valid payload', async () => { + const errors = await run(PolicyTestDTO, { name: 'T', status: 'New', duration: 1, progress: 2 }); + assert.equal(errors.length, 0); + }); + + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(PolicyTestDTO, {})).length, 0); + }); + + it('LATENT: status enum (PolicyTestStatus) is not enforced, any string passes', async () => { + assert.equal((await run(PolicyTestDTO, { status: 'NOT_A_STATUS' })).length, 0); + }); + + it('rejects a non-string status', async () => { + assert.deepEqual(keys(await run(PolicyTestDTO, { status: 5 }), 'status'), ['isString']); + }); + + it('rejects a non-number duration', async () => { + assert.deepEqual(keys(await run(PolicyTestDTO, { duration: 'x' }), 'duration'), ['isNumber']); + }); + + it('does not validate the untyped object result', async () => { + assert.deepEqual(keys(await run(PolicyTestDTO, { result: { any: 1 } }), 'result'), []); + }); + + it('rejects non-object result', async () => { + assert.deepEqual(keys(await run(PolicyTestDTO, { result: 'x' }), 'result'), ['isObject']); + }); + }); + + describe('PolicyToolDTO', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(PolicyToolDTO, { name: 'Tool', version: '1.0.0' })).length, 0); + }); + + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(PolicyToolDTO, {})).length, 0); + }); + + it('rejects a non-string version', async () => { + assert.deepEqual(keys(await run(PolicyToolDTO, { version: 5 }), 'version'), ['isString']); + }); + }); + + describe('PolicyDTO enum and nested array gaps', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(PolicyDTO, {})).length, 0); + }); + + it('LATENT: status enum (PolicyStatus) is not enforced, any string passes', async () => { + assert.equal((await run(PolicyDTO, { status: 'NONSENSE_STATUS' })).length, 0); + }); + + it('rejects a non-string status', async () => { + assert.deepEqual(keys(await run(PolicyDTO, { status: 9 }), 'status'), ['isString']); + }); + + it('LATENT: availability enum (PolicyAvailability) is not enforced, any string passes', async () => { + assert.equal((await run(PolicyDTO, { availability: 'NOT_AVAILABLE' })).length, 0); + }); + + it('rejects a non-string availability', async () => { + assert.deepEqual(keys(await run(PolicyDTO, { availability: 1 }), 'availability'), ['isString']); + }); + + it('CONTRAST: tools IS deep-validated (ValidateNested + Type), garbage tool element flags nested errors', async () => { + const errors = await run(PolicyDTO, { tools: [{ version: 5 }] }); + assert.ok(childProps(errors, 'tools').includes('0')); + }); + + it('CONTRAST: a fully valid tools element passes', async () => { + assert.equal((await run(PolicyDTO, { tools: [{ name: 'T', version: '1.0.0' }] })).length, 0); + }); + + it('rejects a non-array tools', async () => { + assert.ok(keys(await run(PolicyDTO, { tools: {} }), 'tools').includes('isArray')); + }); + + it('LATENT: tests array is NOT deep-validated (no ValidateNested), garbage element passes', async () => { + const errors = await run(PolicyDTO, { tests: [{ status: 5, duration: 'x' }] }); + assert.equal(errors.length, 0); + }); + + it('rejects a non-array tests', async () => { + assert.ok(keys(await run(PolicyDTO, { tests: 'x' }), 'tests').includes('isArray')); + }); + + it('LATENT: policyRoles array is not deep-validated, numeric elements pass', async () => { + assert.equal((await run(PolicyDTO, { policyRoles: [1, 2, 3] })).length, 0); + }); + + it('LATENT: userRoles array is not deep-validated, object elements pass', async () => { + assert.equal((await run(PolicyDTO, { userRoles: [{}, {}] })).length, 0); + }); + + it('LATENT: policyTopics array of objects is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyDTO, { policyTopics: [{ x: 1 }, 'junk'] })).length, 0); + }); + + it('LATENT: policyTokens array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyDTO, { policyTokens: [42] })).length, 0); + }); + + it('LATENT: policyGroups array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyDTO, { policyGroups: [null] })).length, 0); + }); + + it('LATENT: policyNavigation array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyDTO, { policyNavigation: ['x'] })).length, 0); + }); + + it('LATENT: policyDocumentation array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyDTO, { policyDocumentation: [1, 2] })).length, 0); + }); + + it('rejects a non-array policyRoles', async () => { + assert.ok(keys(await run(PolicyDTO, { policyRoles: 'x' }), 'policyRoles').includes('isArray')); + }); + + it('rejects a non-array categories', async () => { + assert.ok(keys(await run(PolicyDTO, { categories: {} }), 'categories').includes('isArray')); + }); + + it('CONTRAST: editableParametersSettings IS deep-validated, garbage element flags nested errors', async () => { + const errors = await run(PolicyDTO, { editableParametersSettings: [{}] }); + assert.ok(childProps(errors, 'editableParametersSettings').includes('0')); + }); + + it('CONTRAST: importantParameters IS deep-validated (ValidateNested + Type)', async () => { + const errors = await run(PolicyDTO, { importantParameters: { atValidation: 5 } }); + const c = children(errors, 'importantParameters'); + assert.ok(c.length > 0); + }); + + it('rejects a non-object config', async () => { + assert.deepEqual(keys(await run(PolicyDTO, { config: 'x' }), 'config'), ['isObject']); + }); + + it('accepts an object userGroup', async () => { + assert.equal((await run(PolicyDTO, { userGroup: { active: true } })).length, 0); + }); + + it('rejects a non-object userGroup', async () => { + assert.deepEqual(keys(await run(PolicyDTO, { userGroup: 'x' }), 'userGroup'), ['isObject']); + }); + }); + + describe('PolicyPreviewDTO', () => { + it('accepts a valid payload', async () => { + const errors = await run(PolicyPreviewDTO, { module: {}, messageId: 'm', schemas: [], tags: [] }); + assert.equal(errors.length, 0); + }); + + it('reports required module and messageId when empty', async () => { + const errors = await run(PolicyPreviewDTO, {}); + assert.ok(keys(errors, 'module').includes('isObject')); + assert.ok(keys(errors, 'messageId').includes('isString')); + }); + + it('LATENT: module is validated as a plain object only, no deep PolicyDTO validation', async () => { + const errors = await run(PolicyPreviewDTO, { module: { status: 5 }, messageId: 'm' }); + assert.equal(errors.length, 0); + }); + + it('LATENT: schemas array of objects is not deep-validated', async () => { + const errors = await run(PolicyPreviewDTO, { module: {}, messageId: 'm', schemas: ['junk'] }); + assert.equal(errors.length, 0); + }); + }); + + describe('StatisticDefinitionDTO swagger-only enum status', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(StatisticDefinitionDTO, { name: 'S', status: 'DRAFT', config: {} })).length, 0); + }); + + it('requires name', async () => { + assert.deepEqual(keys(await run(StatisticDefinitionDTO, {}), 'name'), ['isString']); + }); + + it('LATENT: status enum (EntityStatus) is not enforced, any string passes', async () => { + assert.equal((await run(StatisticDefinitionDTO, { name: 'S', status: 'WHATEVER' })).length, 0); + }); + + it('rejects a non-object config', async () => { + assert.deepEqual(keys(await run(StatisticDefinitionDTO, { name: 'S', config: 'x' }), 'config'), ['isObject']); + }); + }); + + describe('StatisticAssessmentDTO', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(StatisticAssessmentDTO, {})).length, 0); + }); + + it('rejects a non-array relationships', async () => { + assert.ok(keys(await run(StatisticAssessmentDTO, { relationships: {} }), 'relationships').includes('isArray')); + }); + + it('LATENT: relationships array elements are not deep-validated, garbage passes', async () => { + assert.equal((await run(StatisticAssessmentDTO, { relationships: [{}, 5] })).length, 0); + }); + + it('rejects a non-object document', async () => { + assert.deepEqual(keys(await run(StatisticAssessmentDTO, { document: 'x' }), 'document'), ['isObject']); + }); + }); + + describe('StatisticAssessmentRelationshipsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(StatisticAssessmentRelationshipsDTO, { target: {}, relationships: [] })).length, 0); + }); + + it('rejects a non-object target', async () => { + assert.deepEqual(keys(await run(StatisticAssessmentRelationshipsDTO, { target: 'x' }), 'target'), ['isObject']); + }); + + it('LATENT: target (VcDocumentDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(StatisticAssessmentRelationshipsDTO, { target: { id: 5 } })).length, 0); + }); + + it('LATENT: relationships array is not deep-validated, garbage passes', async () => { + assert.equal((await run(StatisticAssessmentRelationshipsDTO, { relationships: [{}, 'x'] })).length, 0); + }); + + it('rejects a non-array relationships', async () => { + assert.ok(keys(await run(StatisticAssessmentRelationshipsDTO, { relationships: {} }), 'relationships').includes('isArray')); + }); + }); + + describe('StatisticDefinitionRelationshipsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(StatisticDefinitionRelationshipsDTO, { policy: {}, schemas: [], schema: {} })).length, 0); + }); + + it('LATENT: policy (PolicyDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(StatisticDefinitionRelationshipsDTO, { policy: { status: 5 } })).length, 0); + }); + + it('LATENT: schemas array is not deep-validated, garbage passes', async () => { + assert.equal((await run(StatisticDefinitionRelationshipsDTO, { schemas: ['junk'] })).length, 0); + }); + + it('rejects a non-object schema and non-array schemas', async () => { + const errors = await run(StatisticDefinitionRelationshipsDTO, { schema: 'x', schemas: 'y' }); + assert.deepEqual(keys(errors, 'schema'), ['isObject']); + assert.ok(keys(errors, 'schemas').includes('isArray')); + }); + }); + + describe('SchemaDTO swagger-only enums', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(SchemaDTO, {})).length, 0); + }); + + it('LATENT: entity enum (SchemaEntity) is not enforced, any string passes', async () => { + assert.equal((await run(SchemaDTO, { entity: 'NOT_AN_ENTITY' })).length, 0); + }); + + it('LATENT: status enum (SchemaStatus) is not enforced, any string passes', async () => { + assert.equal((await run(SchemaDTO, { status: 'NOT_A_STATUS' })).length, 0); + }); + + it('LATENT: category enum (SchemaCategory) is not enforced, any string passes', async () => { + assert.equal((await run(SchemaDTO, { category: 'NOT_A_CATEGORY' })).length, 0); + }); + + it('rejects a non-string entity', async () => { + assert.deepEqual(keys(await run(SchemaDTO, { entity: 5 }), 'entity'), ['isString']); + }); + + it('accepts object or string document via IsObject', async () => { + assert.equal((await run(SchemaDTO, { document: { a: 1 } })).length, 0); + }); + + it('rejects a non-object document (IsObject rejects a string despite oneOf swagger)', async () => { + assert.deepEqual(keys(await run(SchemaDTO, { document: 'inText' }), 'document'), ['isObject']); + }); + }); + + describe('SchemaWithSubSchemasDTO bare nested fields', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(SchemaWithSubSchemasDTO, {})).length, 0); + }); + + it('LATENT: schema has no IsObject constraint, any primitive passes', async () => { + assert.equal((await run(SchemaWithSubSchemasDTO, { schema: 'not-an-object' })).length, 0); + }); + + it('LATENT: schema is not deep-validated, garbage object passes', async () => { + assert.equal((await run(SchemaWithSubSchemasDTO, { schema: { entity: 5 } })).length, 0); + }); + + it('LATENT: subSchemas has no IsArray constraint, a string passes', async () => { + assert.equal((await run(SchemaWithSubSchemasDTO, { subSchemas: 'not-an-array' })).length, 0); + }); + + it('LATENT: subSchemas elements are not deep-validated, garbage passes', async () => { + assert.equal((await run(SchemaWithSubSchemasDTO, { subSchemas: [{ status: 5 }, 'junk'] })).length, 0); + }); + }); + + describe('SystemSchemaDTO enforced enum (positive contrast)', () => { + it('accepts a valid entity', async () => { + assert.equal((await run(SystemSchemaDTO, { name: 'N', entity: 'STANDARD_REGISTRY' })).length, 0); + }); + + it('CONTRAST: entity enum IS enforced via IsIn, an invalid value is rejected', async () => { + assert.ok(keys(await run(SystemSchemaDTO, { name: 'N', entity: 'BOGUS' }), 'entity').includes('isIn')); + }); + + it('reports required name and entity when empty', async () => { + const errors = await run(SystemSchemaDTO, {}); + assert.ok(keys(errors, 'name').includes('isString')); + assert.ok(keys(errors, 'entity').includes('isString')); + }); + }); + + describe('SchemaImportDuplicatesRequestDTO', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(SchemaImportDuplicatesRequestDTO, { policyId: '0.0.1', schemaNames: ['A'] })).length, 0); + }); + + it('requires policyId and schemaNames when empty', async () => { + const errors = await run(SchemaImportDuplicatesRequestDTO, {}); + assert.ok(keys(errors, 'policyId').includes('isString')); + assert.ok(keys(errors, 'schemaNames').includes('isArray')); + }); + + it('LATENT: schemaNames is IsArray only, non-string elements pass', async () => { + assert.equal((await run(SchemaImportDuplicatesRequestDTO, { policyId: 'p', schemaNames: [1, 2, {}] })).length, 0); + }); + }); + + describe('PolicyLabelDTO swagger-only enum status', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(PolicyLabelDTO, { name: 'L', status: 'DRAFT', config: {} })).length, 0); + }); + + it('requires name', async () => { + assert.deepEqual(keys(await run(PolicyLabelDTO, {}), 'name'), ['isString']); + }); + + it('LATENT: status enum (EntityStatus) is not enforced, any string passes', async () => { + assert.equal((await run(PolicyLabelDTO, { name: 'L', status: 'XX' })).length, 0); + }); + + it('rejects a non-object config', async () => { + assert.deepEqual(keys(await run(PolicyLabelDTO, { name: 'L', config: 'x' }), 'config'), ['isObject']); + }); + }); + + describe('PolicyLabelRelationshipsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(PolicyLabelRelationshipsDTO, { policy: {}, policySchemas: [], documentsSchemas: [] })).length, 0); + }); + + it('LATENT: policy (PolicyDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(PolicyLabelRelationshipsDTO, { policy: { status: 5 } })).length, 0); + }); + + it('LATENT: policySchemas array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyLabelRelationshipsDTO, { policySchemas: ['x', {}] })).length, 0); + }); + + it('rejects a non-object policy and non-array documentsSchemas', async () => { + const errors = await run(PolicyLabelRelationshipsDTO, { policy: 'x', documentsSchemas: 'y' }); + assert.deepEqual(keys(errors, 'policy'), ['isObject']); + assert.ok(keys(errors, 'documentsSchemas').includes('isArray')); + }); + }); + + describe('PolicyLabelDocumentDTO', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(PolicyLabelDocumentDTO, {})).length, 0); + }); + + it('LATENT: relationships array of message ids is IsArray only, garbage passes', async () => { + assert.equal((await run(PolicyLabelDocumentDTO, { relationships: [1, {}, null] })).length, 0); + }); + + it('rejects a non-array relationships', async () => { + assert.ok(keys(await run(PolicyLabelDocumentDTO, { relationships: {} }), 'relationships').includes('isArray')); + }); + + it('rejects a non-object document', async () => { + assert.deepEqual(keys(await run(PolicyLabelDocumentDTO, { document: 'x' }), 'document'), ['isObject']); + }); + }); + + describe('PolicyLabelDocumentRelationshipsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(PolicyLabelDocumentRelationshipsDTO, { target: {}, relationships: [] })).length, 0); + }); + + it('LATENT: target (VpDocumentDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(PolicyLabelDocumentRelationshipsDTO, { target: { id: 5 } })).length, 0); + }); + + it('LATENT: relationships array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyLabelDocumentRelationshipsDTO, { relationships: ['x', 1] })).length, 0); + }); + + it('rejects a non-object target', async () => { + assert.deepEqual(keys(await run(PolicyLabelDocumentRelationshipsDTO, { target: 'x' }), 'target'), ['isObject']); + }); + }); + + describe('PolicyLabelComponentsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(PolicyLabelComponentsDTO, { statistics: [], labels: [] })).length, 0); + }); + + it('LATENT: statistics array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyLabelComponentsDTO, { statistics: [{}, 'x'] })).length, 0); + }); + + it('LATENT: labels array is not deep-validated, garbage passes', async () => { + assert.equal((await run(PolicyLabelComponentsDTO, { labels: [{ name: 5 }] })).length, 0); + }); + + it('rejects a non-array statistics', async () => { + assert.ok(keys(await run(PolicyLabelComponentsDTO, { statistics: {} }), 'statistics').includes('isArray')); + }); + }); + + describe('PolicyLabelFiltersDTO swagger-only enum components', () => { + it('accepts an empty payload (all optional)', async () => { + assert.equal((await run(PolicyLabelFiltersDTO, {})).length, 0); + }); + + it('accepts a documented enum value', async () => { + assert.equal((await run(PolicyLabelFiltersDTO, { components: 'label' })).length, 0); + }); + + it('LATENT: components enum [all|label|statistic] is not enforced, any string passes', async () => { + assert.equal((await run(PolicyLabelFiltersDTO, { components: 'NOPE' })).length, 0); + }); + + it('rejects a non-string components', async () => { + assert.deepEqual(keys(await run(PolicyLabelFiltersDTO, { components: 5 }), 'components'), ['isString']); + }); + }); + + describe('SchemaRuleDTO swagger-only enum status', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(SchemaRuleDTO, { name: 'R', status: 'DRAFT', config: {} })).length, 0); + }); + + it('requires name', async () => { + assert.deepEqual(keys(await run(SchemaRuleDTO, {}), 'name'), ['isString']); + }); + + it('LATENT: status enum (EntityStatus) is not enforced, any string passes', async () => { + assert.equal((await run(SchemaRuleDTO, { name: 'R', status: 'XX' })).length, 0); + }); + + it('rejects a non-object config', async () => { + assert.deepEqual(keys(await run(SchemaRuleDTO, { name: 'R', config: 'x' }), 'config'), ['isObject']); + }); + }); + + describe('SchemaRuleRelationshipsDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(SchemaRuleRelationshipsDTO, { policy: {}, schemas: [] })).length, 0); + }); + + it('LATENT: policy (PolicyDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(SchemaRuleRelationshipsDTO, { policy: { status: 5 } })).length, 0); + }); + + it('LATENT: schemas array is not deep-validated, garbage passes', async () => { + assert.equal((await run(SchemaRuleRelationshipsDTO, { schemas: ['x', 1] })).length, 0); + }); + + it('rejects a non-object policy and non-array schemas', async () => { + const errors = await run(SchemaRuleRelationshipsDTO, { policy: 'x', schemas: 'y' }); + assert.deepEqual(keys(errors, 'policy'), ['isObject']); + assert.ok(keys(errors, 'schemas').includes('isArray')); + }); + }); + + describe('SchemaRuleDataDTO nested gaps', () => { + it('accepts a valid payload', async () => { + assert.equal((await run(SchemaRuleDataDTO, { rules: {}, document: {}, relationships: [] })).length, 0); + }); + + it('LATENT: rules (SchemaRuleDTO) is validated as a plain object only, garbage passes', async () => { + assert.equal((await run(SchemaRuleDataDTO, { rules: { name: 5 } })).length, 0); + }); + + it('LATENT: relationships array is not deep-validated, garbage passes', async () => { + assert.equal((await run(SchemaRuleDataDTO, { relationships: [{}, 'x'] })).length, 0); + }); + + it('rejects a non-object rules and non-array relationships', async () => { + const errors = await run(SchemaRuleDataDTO, { rules: 'x', relationships: 'y' }); + assert.deepEqual(keys(errors, 'rules'), ['isObject']); + assert.ok(keys(errors, 'relationships').includes('isArray')); + }); + }); + + describe('Mock nested DTOs (typed but bare) gaps', () => { + it('LATENT: MockTopicDataDTO.topic is plain-object only, garbage passes', async () => { + assert.equal((await run(MockTopicDataDTO, { topic: { id: 5 } })).length, 0); + }); + + it('LATENT: MockTopicDataDTO.messages array is not deep-validated, garbage passes', async () => { + assert.equal((await run(MockTopicDataDTO, { messages: [{ sequence_number: 'x' }, 1] })).length, 0); + }); + + it('LATENT: MockApiDataDTO.request is plain-object only, garbage passes', async () => { + assert.equal((await run(MockApiDataDTO, { request: { method: 5 } })).length, 0); + }); + + it('LATENT: MockUserDataDTO.document is plain-object only, garbage passes', async () => { + assert.equal((await run(MockUserDataDTO, { document: { id: 5 } })).length, 0); + }); + }); +}); diff --git a/api-gateway/tests/validation/dto-policies.test.mjs b/api-gateway/tests/validation/dto-policies.test.mjs new file mode 100644 index 0000000000..8659139329 --- /dev/null +++ b/api-gateway/tests/validation/dto-policies.test.mjs @@ -0,0 +1,677 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + PolicyTestDTO, + BasePolicyDTO, + PolicyToolDTO, + PolicyImportantParametersDTO, + PolicyDTO, + PolicyPreviewDTO, + PolicyValidationDTO, + PoliciesValidationDTO, + PolicyCategoryDTO, + PolicyVersionDTO, + DebugBlockDataDTO, + DebugBlockConfigDTO, + DebugBlockResultDTO, + DebugBlockHistoryDTO, + IgnoreRuleDTO, + DeleteSavepointsDTO, + DeleteSavepointsResultDTO, +} from '../../dist/middlewares/validation/schemas/policies.dto.js'; +import { PolicyParametersDTO } from '../../dist/middlewares/validation/schemas/policy-parameters.dto.js'; +import { + PolicyLabelDTO, + PolicyLabelRelationshipsDTO, + PolicyLabelDocumentDTO, + PolicyLabelDocumentRelationshipsDTO, + PolicyLabelComponentsDTO, + PolicyLabelFiltersDTO, +} from '../../dist/middlewares/validation/schemas/policy-labels.dto.js'; + +const errorsFor = async (Dto, input) => validate(plainToInstance(Dto, input)); + +const props = (errs) => errs.map((e) => e.property).sort(); + +const constraintsFor = (errs, property) => { + const found = errs.find((e) => e.property === property); + return found ? Object.keys(found.constraints || {}) : []; +}; + +const childConstraints = (errs, property) => { + const found = errs.find((e) => e.property === property); + if (!found) { + return []; + } + const out = []; + const walk = (node) => { + if (node.constraints) { + out.push(...Object.keys(node.constraints)); + } + (node.children || []).forEach(walk); + }; + (found.children || []).forEach(walk); + return out.sort(); +}; + +describe('@unit api-gateway validation DTO policies', () => { + describe('PolicyTestDTO', () => { + it('accepts an empty payload (all optional)', async () => { + const errs = await errorsFor(PolicyTestDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts a fully-populated valid payload', async () => { + const errs = await errorsFor(PolicyTestDTO, { + id: 'a', + uuid: 'b', + name: 'Test', + policyId: 'p', + owner: 'did', + status: 'New', + date: '2020-01-01', + duration: 1, + progress: 2, + resultId: 'r', + result: { ok: true }, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string id', async () => { + const errs = await errorsFor(PolicyTestDTO, { id: 5 }); + assert.deepEqual(constraintsFor(errs, 'id'), ['isString']); + }); + + it('rejects non-number duration', async () => { + const errs = await errorsFor(PolicyTestDTO, { duration: 'x' }); + assert.deepEqual(constraintsFor(errs, 'duration'), ['isNumber']); + }); + + it('rejects non-number progress', async () => { + const errs = await errorsFor(PolicyTestDTO, { progress: 'x' }); + assert.deepEqual(constraintsFor(errs, 'progress'), ['isNumber']); + }); + + it('rejects non-object result', async () => { + const errs = await errorsFor(PolicyTestDTO, { result: 'x' }); + assert.deepEqual(constraintsFor(errs, 'result'), ['isObject']); + }); + }); + + describe('BasePolicyDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(BasePolicyDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid id and name', async () => { + const errs = await errorsFor(BasePolicyDTO, { id: 'a', name: 'b' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string id and name', async () => { + const errs = await errorsFor(BasePolicyDTO, { id: 1, name: 2 }); + assert.deepEqual(props(errs), ['id', 'name']); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + }); + + describe('PolicyToolDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(PolicyToolDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts a valid payload', async () => { + const errs = await errorsFor(PolicyToolDTO, { + name: 'Tool', + version: '1.0.0', + topicId: '0.0.1', + messageId: 'm', + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string name', async () => { + const errs = await errorsFor(PolicyToolDTO, { name: 5 }); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + + it('rejects non-string topicId', async () => { + const errs = await errorsFor(PolicyToolDTO, { topicId: 5 }); + assert.deepEqual(constraintsFor(errs, 'topicId'), ['isString']); + }); + }); + + describe('PolicyImportantParametersDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(PolicyImportantParametersDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid strings', async () => { + const errs = await errorsFor(PolicyImportantParametersDTO, { + atValidation: 'x', + monitored: 'y', + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string fields', async () => { + const errs = await errorsFor(PolicyImportantParametersDTO, { + atValidation: 1, + monitored: 2, + }); + assert.deepEqual(props(errs), ['atValidation', 'monitored']); + }); + }); + + describe('PolicyDTO', () => { + it('accepts an empty payload (every field optional)', async () => { + const errs = await errorsFor(PolicyDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts a rich valid payload', async () => { + const errs = await errorsFor(PolicyDTO, { + id: 'a', + uuid: 'b', + name: 'Policy', + description: 'd', + status: 'DRAFT', + originalChanged: false, + config: { a: 1 }, + userRoles: ['Installer'], + tools: [{ name: 't' }], + ignoreRules: [{ severity: 'info' }], + importantParameters: { atValidation: 'x' }, + tests: [{ name: 'one' }], + categories: ['c'], + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string name', async () => { + const errs = await errorsFor(PolicyDTO, { name: 5 }); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + + it('rejects non-boolean originalChanged', async () => { + const errs = await errorsFor(PolicyDTO, { originalChanged: 'x' }); + assert.deepEqual(constraintsFor(errs, 'originalChanged'), ['isBoolean']); + }); + + it('rejects non-object config', async () => { + const errs = await errorsFor(PolicyDTO, { config: 'x' }); + assert.deepEqual(constraintsFor(errs, 'config'), ['isObject']); + }); + + it('rejects non-array userRoles', async () => { + const errs = await errorsFor(PolicyDTO, { userRoles: 'x' }); + assert.deepEqual(constraintsFor(errs, 'userRoles'), ['isArray']); + }); + + it('rejects non-array tools', async () => { + const errs = await errorsFor(PolicyDTO, { tools: 'x' }); + assert.ok(constraintsFor(errs, 'tools').includes('isArray')); + }); + + it('reports nested PolicyToolDTO constraint via ValidateNested each', async () => { + const errs = await errorsFor(PolicyDTO, { tools: [{ name: 5 }] }); + assert.equal(errs.length, 1); + assert.deepEqual(childConstraints(errs, 'tools'), ['isString']); + }); + + it('reports nested IgnoreRuleDTO severity constraint', async () => { + const errs = await errorsFor(PolicyDTO, { ignoreRules: [{ severity: 'oops' }] }); + assert.equal(errs.length, 1); + assert.deepEqual(childConstraints(errs, 'ignoreRules'), ['isIn']); + }); + + it('reports nested importantParameters constraint', async () => { + const errs = await errorsFor(PolicyDTO, { importantParameters: { atValidation: 5 } }); + assert.deepEqual(childConstraints(errs, 'importantParameters'), ['isString']); + }); + + it('LATENT: editableParametersSettings ValidateNested only emits unknownValue (target DTO has no class-validator decorators)', async () => { + const errs = await errorsFor(PolicyDTO, { editableParametersSettings: [{ junk: 1 }] }); + assert.equal(errs.length, 1); + assert.deepEqual(childConstraints(errs, 'editableParametersSettings'), ['unknownValue']); + }); + }); + + describe('PolicyPreviewDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(PolicyPreviewDTO, { module: {}, messageId: 'm' }); + assert.equal(errs.length, 0); + }); + + it('rejects empty payload (module + messageId required)', async () => { + const errs = await errorsFor(PolicyPreviewDTO, {}); + assert.deepEqual(props(errs), ['messageId', 'module']); + assert.deepEqual(constraintsFor(errs, 'module'), ['isObject']); + assert.deepEqual(constraintsFor(errs, 'messageId'), ['isString']); + }); + + it('rejects string module with isObject', async () => { + const errs = await errorsFor(PolicyPreviewDTO, { module: 'x', messageId: 'm' }); + assert.deepEqual(constraintsFor(errs, 'module'), ['isObject']); + }); + + it('rejects non-array schemas', async () => { + const errs = await errorsFor(PolicyPreviewDTO, { module: {}, messageId: 'm', schemas: 'x' }); + assert.deepEqual(constraintsFor(errs, 'schemas'), ['isArray']); + }); + }); + + describe('PolicyValidationDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(PolicyValidationDTO, { policy: {}, results: {} }); + assert.equal(errs.length, 0); + }); + + it('rejects empty payload (policy + results required objects)', async () => { + const errs = await errorsFor(PolicyValidationDTO, {}); + assert.deepEqual(props(errs), ['policy', 'results']); + }); + }); + + describe('PoliciesValidationDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(PoliciesValidationDTO, { policies: [], isValid: true, errors: {} }); + assert.equal(errs.length, 0); + }); + + it('rejects empty payload (policies array, isValid bool, errors object)', async () => { + const errs = await errorsFor(PoliciesValidationDTO, {}); + assert.deepEqual(props(errs), ['errors', 'isValid', 'policies']); + assert.deepEqual(constraintsFor(errs, 'policies'), ['isArray']); + assert.deepEqual(constraintsFor(errs, 'isValid'), ['isBoolean']); + assert.deepEqual(constraintsFor(errs, 'errors'), ['isObject']); + }); + + it('rejects non-boolean isValid', async () => { + const errs = await errorsFor(PoliciesValidationDTO, { policies: [], isValid: 'x', errors: {} }); + assert.deepEqual(constraintsFor(errs, 'isValid'), ['isBoolean']); + }); + }); + + describe('PolicyCategoryDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(PolicyCategoryDTO, { name: 'n', type: 't' }); + assert.equal(errs.length, 0); + }); + + it('rejects empty payload (name + type required)', async () => { + const errs = await errorsFor(PolicyCategoryDTO, {}); + assert.deepEqual(props(errs), ['name', 'type']); + }); + + it('accepts optional id when valid', async () => { + const errs = await errorsFor(PolicyCategoryDTO, { id: 'x', name: 'n', type: 't' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string type', async () => { + const errs = await errorsFor(PolicyCategoryDTO, { name: 'n', type: 5 }); + assert.deepEqual(constraintsFor(errs, 'type'), ['isString']); + }); + }); + + describe('PolicyVersionDTO', () => { + it('accepts a minimal valid payload', async () => { + const errs = await errorsFor(PolicyVersionDTO, { policyVersion: '1.0.0' }); + assert.equal(errs.length, 0); + }); + + it('accepts a full valid payload', async () => { + const errs = await errorsFor(PolicyVersionDTO, { + policyVersion: '1.0.0', + policyAvailability: 'private', + recordingEnabled: false, + }); + assert.equal(errs.length, 0); + }); + + it('rejects missing policyVersion', async () => { + const errs = await errorsFor(PolicyVersionDTO, {}); + assert.deepEqual(constraintsFor(errs, 'policyVersion'), ['isString']); + }); + + it('rejects non-boolean recordingEnabled', async () => { + const errs = await errorsFor(PolicyVersionDTO, { policyVersion: '1.0.0', recordingEnabled: 'x' }); + assert.deepEqual(constraintsFor(errs, 'recordingEnabled'), ['isBoolean']); + }); + }); + + describe('DebugBlockDataDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(DebugBlockDataDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid strings', async () => { + const errs = await errorsFor(DebugBlockDataDTO, { input: 'a', output: 'b', type: 'json' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string type', async () => { + const errs = await errorsFor(DebugBlockDataDTO, { type: 5 }); + assert.deepEqual(constraintsFor(errs, 'type'), ['isString']); + }); + }); + + describe('DebugBlockConfigDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(DebugBlockConfigDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid block and data objects', async () => { + const errs = await errorsFor(DebugBlockConfigDTO, { block: {}, data: {} }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object data', async () => { + const errs = await errorsFor(DebugBlockConfigDTO, { data: 'x' }); + assert.deepEqual(constraintsFor(errs, 'data'), ['isObject']); + }); + }); + + describe('DebugBlockResultDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(DebugBlockResultDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid logs and errors arrays', async () => { + const errs = await errorsFor(DebugBlockResultDTO, { logs: ['a'], errors: ['b'] }); + assert.equal(errs.length, 0); + }); + + it('rejects non-array logs', async () => { + const errs = await errorsFor(DebugBlockResultDTO, { logs: 'x' }); + assert.deepEqual(constraintsFor(errs, 'logs'), ['isArray']); + }); + }); + + describe('DebugBlockHistoryDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(DebugBlockHistoryDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts a valid payload', async () => { + const errs = await errorsFor(DebugBlockHistoryDTO, { id: 'x', createDate: 'd', document: {} }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object document', async () => { + const errs = await errorsFor(DebugBlockHistoryDTO, { document: 'x' }); + assert.deepEqual(constraintsFor(errs, 'document'), ['isObject']); + }); + }); + + describe('IgnoreRuleDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(IgnoreRuleDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts severity warning', async () => { + const errs = await errorsFor(IgnoreRuleDTO, { severity: 'warning' }); + assert.equal(errs.length, 0); + }); + + it('accepts severity info', async () => { + const errs = await errorsFor(IgnoreRuleDTO, { severity: 'info' }); + assert.equal(errs.length, 0); + }); + + it('rejects out-of-enum severity', async () => { + const errs = await errorsFor(IgnoreRuleDTO, { severity: 'x' }); + assert.deepEqual(constraintsFor(errs, 'severity'), ['isIn']); + }); + + it('rejects non-string code', async () => { + const errs = await errorsFor(IgnoreRuleDTO, { code: 5 }); + assert.deepEqual(constraintsFor(errs, 'code'), ['isString']); + }); + }); + + describe('DeleteSavepointsDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, { savepointIds: ['a'] }); + assert.equal(errs.length, 0); + }); + + it('accepts optional skipCurrentSavepointGuard', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, { savepointIds: ['a'], skipCurrentSavepointGuard: true }); + assert.equal(errs.length, 0); + }); + + it('rejects missing savepointIds (isArray, arrayNotEmpty, isString each)', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, {}); + assert.deepEqual(constraintsFor(errs, 'savepointIds').sort(), ['arrayNotEmpty', 'isArray', 'isString']); + }); + + it('rejects empty savepointIds array with arrayNotEmpty', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, { savepointIds: [] }); + assert.deepEqual(constraintsFor(errs, 'savepointIds'), ['arrayNotEmpty']); + }); + + it('rejects non-string array element', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, { savepointIds: [1] }); + assert.deepEqual(constraintsFor(errs, 'savepointIds'), ['isString']); + }); + + it('rejects non-boolean skipCurrentSavepointGuard', async () => { + const errs = await errorsFor(DeleteSavepointsDTO, { savepointIds: ['a'], skipCurrentSavepointGuard: 'x' }); + assert.deepEqual(constraintsFor(errs, 'skipCurrentSavepointGuard'), ['isBoolean']); + }); + }); + + describe('DeleteSavepointsResultDTO', () => { + it('accepts a valid payload', async () => { + const errs = await errorsFor(DeleteSavepointsResultDTO, { hardDeletedIds: [] }); + assert.equal(errs.length, 0); + }); + + it('rejects missing hardDeletedIds (isArray, isString each)', async () => { + const errs = await errorsFor(DeleteSavepointsResultDTO, {}); + assert.deepEqual(constraintsFor(errs, 'hardDeletedIds').sort(), ['isArray', 'isString']); + }); + + it('rejects non-string element', async () => { + const errs = await errorsFor(DeleteSavepointsResultDTO, { hardDeletedIds: [1] }); + assert.deepEqual(constraintsFor(errs, 'hardDeletedIds'), ['isString']); + }); + }); + + describe('PolicyParametersDTO', () => { + it('accepts a minimal valid payload', async () => { + const errs = await errorsFor(PolicyParametersDTO, { policyId: 'p' }); + assert.equal(errs.length, 0); + }); + + it('accepts optional updated flag', async () => { + const errs = await errorsFor(PolicyParametersDTO, { policyId: 'p', updated: true }); + assert.equal(errs.length, 0); + }); + + it('rejects missing policyId', async () => { + const errs = await errorsFor(PolicyParametersDTO, {}); + assert.deepEqual(constraintsFor(errs, 'policyId'), ['isString']); + }); + + it('rejects non-array config with isArray + nestedValidation', async () => { + const errs = await errorsFor(PolicyParametersDTO, { policyId: 'p', config: 'x' }); + assert.deepEqual(constraintsFor(errs, 'config').sort(), ['isArray', 'nestedValidation']); + }); + + it('rejects non-boolean updated', async () => { + const errs = await errorsFor(PolicyParametersDTO, { policyId: 'p', updated: 'x' }); + assert.deepEqual(constraintsFor(errs, 'updated'), ['isBoolean']); + }); + + it('LATENT: config array of PolicyEditableFieldDTO only emits unknownValue (target DTO has no decorators)', async () => { + const errs = await errorsFor(PolicyParametersDTO, { policyId: 'p', config: [{ junk: 1 }] }); + assert.equal(errs.length, 1); + assert.deepEqual(childConstraints(errs, 'config'), ['unknownValue']); + }); + }); + + describe('PolicyLabelDTO', () => { + it('accepts a minimal valid payload', async () => { + const errs = await errorsFor(PolicyLabelDTO, { name: 'n' }); + assert.equal(errs.length, 0); + }); + + it('accepts a full valid payload', async () => { + const errs = await errorsFor(PolicyLabelDTO, { + id: 'a', + uuid: 'b', + name: 'n', + description: 'd', + creator: 'did', + owner: 'did', + topicId: '0.0.1', + status: 'DRAFT', + config: {}, + }); + assert.equal(errs.length, 0); + }); + + it('rejects missing name', async () => { + const errs = await errorsFor(PolicyLabelDTO, {}); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + + it('rejects non-string name', async () => { + const errs = await errorsFor(PolicyLabelDTO, { name: 5 }); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + + it('rejects non-object config', async () => { + const errs = await errorsFor(PolicyLabelDTO, { name: 'n', config: 'x' }); + assert.deepEqual(constraintsFor(errs, 'config'), ['isObject']); + }); + }); + + describe('PolicyLabelRelationshipsDTO', () => { + it('accepts an empty payload (all optional)', async () => { + const errs = await errorsFor(PolicyLabelRelationshipsDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid payload', async () => { + const errs = await errorsFor(PolicyLabelRelationshipsDTO, { policy: {}, policySchemas: [] }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object policy', async () => { + const errs = await errorsFor(PolicyLabelRelationshipsDTO, { policy: 'x' }); + assert.deepEqual(constraintsFor(errs, 'policy'), ['isObject']); + }); + + it('rejects non-array policySchemas', async () => { + const errs = await errorsFor(PolicyLabelRelationshipsDTO, { policySchemas: 'x' }); + assert.deepEqual(constraintsFor(errs, 'policySchemas'), ['isArray']); + }); + }); + + describe('PolicyLabelDocumentDTO', () => { + it('accepts an empty payload (all fields optional)', async () => { + const errs = await errorsFor(PolicyLabelDocumentDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts a valid payload', async () => { + const errs = await errorsFor(PolicyLabelDocumentDTO, { + id: 'a', + definitionId: 'b', + relationships: ['m'], + document: {}, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string topicId', async () => { + const errs = await errorsFor(PolicyLabelDocumentDTO, { topicId: 5 }); + assert.deepEqual(constraintsFor(errs, 'topicId'), ['isString']); + }); + + it('rejects non-array relationships', async () => { + const errs = await errorsFor(PolicyLabelDocumentDTO, { relationships: 'x' }); + assert.deepEqual(constraintsFor(errs, 'relationships'), ['isArray']); + }); + + it('rejects non-object document', async () => { + const errs = await errorsFor(PolicyLabelDocumentDTO, { document: 'x' }); + assert.deepEqual(constraintsFor(errs, 'document'), ['isObject']); + }); + }); + + describe('PolicyLabelDocumentRelationshipsDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(PolicyLabelDocumentRelationshipsDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-object target', async () => { + const errs = await errorsFor(PolicyLabelDocumentRelationshipsDTO, { target: 'x' }); + assert.deepEqual(constraintsFor(errs, 'target'), ['isObject']); + }); + + it('rejects non-array relationships', async () => { + const errs = await errorsFor(PolicyLabelDocumentRelationshipsDTO, { relationships: 'x' }); + assert.deepEqual(constraintsFor(errs, 'relationships'), ['isArray']); + }); + }); + + describe('PolicyLabelComponentsDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(PolicyLabelComponentsDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid arrays', async () => { + const errs = await errorsFor(PolicyLabelComponentsDTO, { statistics: [], labels: [] }); + assert.equal(errs.length, 0); + }); + + it('rejects non-array statistics', async () => { + const errs = await errorsFor(PolicyLabelComponentsDTO, { statistics: 'x' }); + assert.deepEqual(constraintsFor(errs, 'statistics'), ['isArray']); + }); + + it('rejects non-array labels', async () => { + const errs = await errorsFor(PolicyLabelComponentsDTO, { labels: 'x' }); + assert.deepEqual(constraintsFor(errs, 'labels'), ['isArray']); + }); + }); + + describe('PolicyLabelFiltersDTO', () => { + it('accepts an empty payload', async () => { + const errs = await errorsFor(PolicyLabelFiltersDTO, {}); + assert.equal(errs.length, 0); + }); + + it('accepts valid filters', async () => { + const errs = await errorsFor(PolicyLabelFiltersDTO, { text: 't', owner: 'o', components: 'all' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string text', async () => { + const errs = await errorsFor(PolicyLabelFiltersDTO, { text: 5 }); + assert.deepEqual(constraintsFor(errs, 'text'), ['isString']); + }); + + it('LATENT: components accepts any string (only @IsString, enum not enforced)', async () => { + const errs = await errorsFor(PolicyLabelFiltersDTO, { components: 'not-a-valid-enum' }); + assert.equal(errs.length, 0); + }); + }); +}); diff --git a/api-gateway/tests/validation/dto-schemas-profiles-record.test.mjs b/api-gateway/tests/validation/dto-schemas-profiles-record.test.mjs new file mode 100644 index 0000000000..cae7c67f53 --- /dev/null +++ b/api-gateway/tests/validation/dto-schemas-profiles-record.test.mjs @@ -0,0 +1,710 @@ +import assert from 'node:assert/strict'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + SchemaDTO, + SchemaParentDTO, + SchemaListAllItemDTO, + SchemaWithSubSchemasDTO, + SchemaPushCopyRequestDTO, + SchemaImportDuplicatesRequestDTO, + SystemSchemaDTO, + ExportSchemaDTO, + VersionSchemaDTO, + MessageSchemaDTO, +} from '../../dist/middlewares/validation/schemas/schemas.dto.js'; +import { + UserDTO, + ProfileDidDocumentRecordDTO, + ProfileVcDocumentDTO, + ProfileDTO, + PolicyKeyDTO, + PolicyKeyConfigDTO, +} from '../../dist/middlewares/validation/schemas/profiles.dto.js'; +import { + RecordStatusDTO, + RecordActionDTO, + ResultDocumentDTO, + ResultInfoDTO, + RunningResultDTO, + RunningDetailsDTO, +} from '../../dist/middlewares/validation/schemas/record.js'; + +const errorsFor = async (Dto, input) => validate(plainToInstance(Dto, input)); + +const props = (errs) => errs.map((e) => e.property).sort(); + +const constraintsFor = (errs, property) => { + const found = errs.find((e) => e.property === property); + return found ? Object.keys(found.constraints || {}) : []; +}; + +describe('@unit api-gateway validation DTO schemas/profiles/record', () => { + describe('SchemaDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SchemaDTO, { + id: '000000000000000000000001', + uuid: 'a-uuid', + name: 'Schema name', + description: 'desc', + entity: 'POLICY', + iri: '#iri', + status: 'DRAFT', + topicId: '0.0.1', + version: '1.0.0', + creator: 'did:hedera:x', + owner: 'did:hedera:x', + category: 'POLICY', + document: {}, + context: {}, + }); + assert.equal(errs.length, 0); + }); + + it('accepts an empty payload (all fields optional)', async () => { + const errs = await errorsFor(SchemaDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-string name', async () => { + const errs = await errorsFor(SchemaDTO, { name: 123 }); + assert.deepEqual(constraintsFor(errs, 'name'), ['isString']); + }); + + it('rejects non-object document', async () => { + const errs = await errorsFor(SchemaDTO, { document: 'not-an-object' }); + assert.deepEqual(constraintsFor(errs, 'document'), ['isObject']); + }); + + it('accepts object context but rejects string context', async () => { + const ok = await errorsFor(SchemaDTO, { context: { a: 1 } }); + assert.equal(ok.length, 0); + const bad = await errorsFor(SchemaDTO, { context: 'string-context' }); + assert.deepEqual(constraintsFor(bad, 'context'), ['isObject']); + }); + + it('does not validate undecorated boolean/number fields', async () => { + const errs = await errorsFor(SchemaDTO, { + readonly: 'yes', + system: 'no', + active: 'maybe', + topicCount: 'lots', + createDate: 12345, + }); + assert.equal(errs.length, 0); + }); + }); + + describe('SchemaParentDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SchemaParentDTO, { + id: '1', + name: 'n', + status: 'PUBLISHED', + version: '1.0.0', + sourceVersion: '', + category: 'POLICY', + }); + assert.equal(errs.length, 0); + }); + + it('accepts empty payload', async () => { + const errs = await errorsFor(SchemaParentDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-string version', async () => { + const errs = await errorsFor(SchemaParentDTO, { version: 100 }); + assert.deepEqual(constraintsFor(errs, 'version'), ['isString']); + }); + }); + + describe('SchemaListAllItemDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SchemaListAllItemDTO, { + id: '1', + name: 'n', + description: 'd', + status: 'PUBLISHED', + version: '1.0.0', + sourceVersion: '', + topicId: '0.0.1', + category: 'POLICY', + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string topicId', async () => { + const errs = await errorsFor(SchemaListAllItemDTO, { topicId: 1 }); + assert.deepEqual(constraintsFor(errs, 'topicId'), ['isString']); + }); + }); + + describe('SchemaWithSubSchemasDTO', () => { + it('accepts empty payload', async () => { + const errs = await errorsFor(SchemaWithSubSchemasDTO, {}); + assert.equal(errs.length, 0); + }); + + it('does not deep-validate nested schema/subSchemas (only @IsOptional)', async () => { + const errs = await errorsFor(SchemaWithSubSchemasDTO, { + schema: { name: 123 }, + subSchemas: 'not-an-array', + }); + assert.equal(errs.length, 0); + }); + }); + + describe('SchemaPushCopyRequestDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SchemaPushCopyRequestDTO, { + topicId: '0.0.1', + name: 'copy', + iri: '#uuid&1.0.0', + copyNested: true, + }); + assert.equal(errs.length, 0); + }); + + it('rejects empty topicId', async () => { + const errs = await errorsFor(SchemaPushCopyRequestDTO, { + topicId: '', + name: 'copy', + iri: '#x', + copyNested: false, + }); + assert.deepEqual(constraintsFor(errs, 'topicId'), ['isNotEmpty']); + }); + + it('rejects missing/non-boolean copyNested', async () => { + const errs = await errorsFor(SchemaPushCopyRequestDTO, { + topicId: '0.0.1', + name: 'copy', + iri: '#x', + copyNested: 'true', + }); + assert.deepEqual(constraintsFor(errs, 'copyNested'), ['isBoolean']); + }); + + it('rejects missing required string fields', async () => { + const errs = await errorsFor(SchemaPushCopyRequestDTO, { copyNested: true }); + assert.deepEqual(props(errs), ['iri', 'name', 'topicId']); + assert.deepEqual(constraintsFor(errs, 'name'), ['isNotEmpty', 'isString']); + }); + }); + + describe('SchemaImportDuplicatesRequestDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SchemaImportDuplicatesRequestDTO, { + policyId: '0.0.1', + schemaNames: ['A', 'B'], + }); + assert.equal(errs.length, 0); + }); + + it('accepts empty schemaNames array', async () => { + const errs = await errorsFor(SchemaImportDuplicatesRequestDTO, { + policyId: '0.0.1', + schemaNames: [], + }); + assert.equal(errs.length, 0); + }); + + it('rejects empty policyId', async () => { + const errs = await errorsFor(SchemaImportDuplicatesRequestDTO, { + policyId: '', + schemaNames: [], + }); + assert.deepEqual(constraintsFor(errs, 'policyId'), ['isNotEmpty']); + }); + + it('rejects non-array schemaNames', async () => { + const errs = await errorsFor(SchemaImportDuplicatesRequestDTO, { + policyId: '0.0.1', + schemaNames: 'A', + }); + assert.deepEqual(constraintsFor(errs, 'schemaNames'), ['isArray']); + }); + }); + + describe('SystemSchemaDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(SystemSchemaDTO, { + name: 'n', + entity: 'STANDARD_REGISTRY', + }); + assert.equal(errs.length, 0); + }); + + it('accepts entity USER', async () => { + const errs = await errorsFor(SystemSchemaDTO, { name: 'n', entity: 'USER' }); + assert.equal(errs.length, 0); + }); + + it('rejects entity not in allowed list', async () => { + const errs = await errorsFor(SystemSchemaDTO, { name: 'n', entity: 'OTHER' }); + assert.deepEqual(constraintsFor(errs, 'entity'), ['isIn']); + }); + + it('rejects missing name and entity', async () => { + const errs = await errorsFor(SystemSchemaDTO, {}); + assert.deepEqual(props(errs), ['entity', 'name']); + }); + }); + + describe('ExportSchemaDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(ExportSchemaDTO, { + id: '1', + name: 'n', + description: 'd', + version: '1.0.0', + owner: 'did:x', + messageId: 'm', + }); + assert.equal(errs.length, 0); + }); + + it('accepts minimal required payload', async () => { + const errs = await errorsFor(ExportSchemaDTO, { id: '1', name: 'n' }); + assert.equal(errs.length, 0); + }); + + it('rejects missing required id and name', async () => { + const errs = await errorsFor(ExportSchemaDTO, {}); + assert.deepEqual(props(errs), ['id', 'name']); + assert.deepEqual(constraintsFor(errs, 'id'), ['isNotEmpty', 'isString']); + }); + + it('rejects empty name', async () => { + const errs = await errorsFor(ExportSchemaDTO, { id: '1', name: '' }); + assert.deepEqual(constraintsFor(errs, 'name'), ['isNotEmpty']); + }); + }); + + describe('VersionSchemaDTO', () => { + it('accepts a valid version', async () => { + const errs = await errorsFor(VersionSchemaDTO, { version: '1.0.0' }); + assert.equal(errs.length, 0); + }); + + it('rejects empty version', async () => { + const errs = await errorsFor(VersionSchemaDTO, { version: '' }); + assert.deepEqual(constraintsFor(errs, 'version'), ['isNotEmpty']); + }); + + it('rejects missing version', async () => { + const errs = await errorsFor(VersionSchemaDTO, {}); + assert.deepEqual(props(errs), ['version']); + }); + }); + + describe('MessageSchemaDTO', () => { + it('accepts a valid messageId', async () => { + const errs = await errorsFor(MessageSchemaDTO, { messageId: '1234.5' }); + assert.equal(errs.length, 0); + }); + + it('rejects empty messageId', async () => { + const errs = await errorsFor(MessageSchemaDTO, { messageId: '' }); + assert.deepEqual(constraintsFor(errs, 'messageId'), ['isNotEmpty']); + }); + + it('rejects non-string messageId', async () => { + const errs = await errorsFor(MessageSchemaDTO, { messageId: 99 }); + assert.deepEqual(constraintsFor(errs, 'messageId'), ['isString']); + }); + }); + + describe('UserDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(UserDTO, { + username: 'user', + role: 'USER', + permissionsGroup: [{}], + permissions: ['POLICIES_POLICY_READ'], + did: 'did:x', + parent: 'did:y', + hederaAccountId: '0.0.1', + }); + assert.equal(errs.length, 0); + }); + + it('accepts minimal required payload (optionals omitted)', async () => { + const errs = await errorsFor(UserDTO, { + username: 'user', + role: 'USER', + permissionsGroup: [], + permissions: [], + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string username and role', async () => { + const errs = await errorsFor(UserDTO, { + username: 1, + role: 2, + permissionsGroup: [], + permissions: [], + }); + assert.deepEqual(constraintsFor(errs, 'username'), ['isString']); + assert.deepEqual(constraintsFor(errs, 'role'), ['isString']); + }); + + it('rejects non-array permissions and permissionsGroup', async () => { + const errs = await errorsFor(UserDTO, { + username: 'u', + role: 'USER', + permissionsGroup: 'x', + permissions: 'y', + }); + assert.deepEqual(constraintsFor(errs, 'permissions'), ['isArray']); + assert.deepEqual(constraintsFor(errs, 'permissionsGroup'), ['isArray']); + }); + + it('rejects optional did when non-string', async () => { + const errs = await errorsFor(UserDTO, { + username: 'u', + role: 'USER', + permissionsGroup: [], + permissions: [], + did: 123, + }); + assert.deepEqual(constraintsFor(errs, 'did'), ['isString']); + }); + }); + + describe('ProfileDidDocumentRecordDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(ProfileDidDocumentRecordDTO, { + createDate: 'd', + updateDate: 'd', + did: 'did:x', + status: 'CREATE', + messageId: 'm', + topicId: '0.0.1', + id: '1', + }); + assert.equal(errs.length, 0); + }); + + it('accepts empty payload', async () => { + const errs = await errorsFor(ProfileDidDocumentRecordDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-string did', async () => { + const errs = await errorsFor(ProfileDidDocumentRecordDTO, { did: 5 }); + assert.deepEqual(constraintsFor(errs, 'did'), ['isString']); + }); + + it('does not validate undecorated document/verificationMethods', async () => { + const errs = await errorsFor(ProfileDidDocumentRecordDTO, { + document: 'x', + verificationMethods: 'x', + }); + assert.equal(errs.length, 0); + }); + }); + + describe('ProfileVcDocumentDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(ProfileVcDocumentDTO, { + documentFileId: 'f', + tableFileIds: ['a', 'b'], + }); + assert.equal(errs.length, 0); + }); + + it('accepts empty payload', async () => { + const errs = await errorsFor(ProfileVcDocumentDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-string documentFileId', async () => { + const errs = await errorsFor(ProfileVcDocumentDTO, { documentFileId: 1 }); + assert.deepEqual(constraintsFor(errs, 'documentFileId'), ['isString']); + }); + + it('rejects non-array tableFileIds', async () => { + const errs = await errorsFor(ProfileVcDocumentDTO, { tableFileIds: 'x' }); + assert.deepEqual(constraintsFor(errs, 'tableFileIds'), ['isArray']); + }); + + it('rejects non-string element inside tableFileIds (each)', async () => { + const errs = await errorsFor(ProfileVcDocumentDTO, { tableFileIds: ['a', 2] }); + assert.deepEqual(constraintsFor(errs, 'tableFileIds'), ['isString']); + }); + }); + + describe('ProfileDTO', () => { + it('accepts a fully-valid payload (inherits UserDTO)', async () => { + const errs = await errorsFor(ProfileDTO, { + username: 'u', + role: 'USER', + permissionsGroup: [], + permissions: [], + confirmed: true, + failed: false, + topicId: '0.0.1', + parentTopicId: '0.0.2', + location: 'local', + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-boolean confirmed', async () => { + const errs = await errorsFor(ProfileDTO, { + username: 'u', + role: 'USER', + permissionsGroup: [], + permissions: [], + confirmed: 'yes', + }); + assert.deepEqual(constraintsFor(errs, 'confirmed'), ['isBoolean']); + }); + + it('rejects location not in LocationType enum', async () => { + const errs = await errorsFor(ProfileDTO, { + username: 'u', + role: 'USER', + permissionsGroup: [], + permissions: [], + location: 'galaxy', + }); + assert.deepEqual(constraintsFor(errs, 'location'), ['isEnum']); + }); + + it('inherits parent required-field validation', async () => { + const errs = await errorsFor(ProfileDTO, { + permissionsGroup: [], + permissions: [], + }); + assert.deepEqual(props(errs), ['role', 'username']); + }); + }); + + describe('PolicyKeyDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(PolicyKeyDTO, { + id: '1', + createDate: 'd', + updateDate: 'd', + messageId: 'm', + owner: 'did:x', + policyName: 'p', + key: 'k', + }); + assert.equal(errs.length, 0); + }); + + it('accepts empty payload', async () => { + const errs = await errorsFor(PolicyKeyDTO, {}); + assert.equal(errs.length, 0); + }); + + it('rejects non-string key', async () => { + const errs = await errorsFor(PolicyKeyDTO, { key: 7 }); + assert.deepEqual(constraintsFor(errs, 'key'), ['isString']); + }); + }); + + describe('PolicyKeyConfigDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(PolicyKeyConfigDTO, { messageId: 'm', key: 'k' }); + assert.equal(errs.length, 0); + }); + + it('accepts payload without optional key', async () => { + const errs = await errorsFor(PolicyKeyConfigDTO, { messageId: 'm' }); + assert.equal(errs.length, 0); + }); + + it('rejects non-string messageId', async () => { + const errs = await errorsFor(PolicyKeyConfigDTO, { messageId: 1 }); + assert.deepEqual(constraintsFor(errs, 'messageId'), ['isString']); + }); + + it('rejects missing messageId (required @IsString)', async () => { + const errs = await errorsFor(PolicyKeyConfigDTO, {}); + assert.deepEqual(constraintsFor(errs, 'messageId'), ['isString']); + }); + }); + + describe('RecordStatusDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(RecordStatusDTO, { + type: 'Recording', + policyId: '1', + uuid: 'u', + status: 'New', + }); + assert.equal(errs.length, 0); + }); + + it('rejects all-empty required fields', async () => { + const errs = await errorsFor(RecordStatusDTO, { + type: '', + policyId: '', + uuid: '', + status: '', + }); + assert.deepEqual(props(errs), ['policyId', 'status', 'type', 'uuid']); + assert.deepEqual(constraintsFor(errs, 'type'), ['isNotEmpty']); + }); + + it('rejects missing required fields', async () => { + const errs = await errorsFor(RecordStatusDTO, {}); + assert.deepEqual(props(errs), ['policyId', 'status', 'type', 'uuid']); + }); + }); + + describe('RecordActionDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(RecordActionDTO, { + uuid: 'u', + policyId: '1', + method: 'POST', + action: 'CreateDID', + time: 'd', + user: 'did:x', + target: 'tag', + }); + assert.equal(errs.length, 0); + }); + + it('rejects empty uuid/policyId/method (IsNotEmpty) but allows empty action/time/user/target', async () => { + const errs = await errorsFor(RecordActionDTO, { + uuid: '', + policyId: '', + method: '', + action: '', + time: '', + user: '', + target: '', + }); + assert.deepEqual(props(errs), ['method', 'policyId', 'uuid']); + }); + + it('rejects non-string action', async () => { + const errs = await errorsFor(RecordActionDTO, { + uuid: 'u', + policyId: '1', + method: 'POST', + action: 5, + time: 'd', + user: 'did:x', + target: 'tag', + }); + assert.deepEqual(constraintsFor(errs, 'action'), ['isString']); + }); + }); + + describe('ResultDocumentDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(ResultDocumentDTO, { + type: 'VC', + schema: 'u', + rate: '100%', + documents: { a: 1 }, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object documents', async () => { + const errs = await errorsFor(ResultDocumentDTO, { + type: 'VC', + schema: 'u', + rate: '100%', + documents: 'x', + }); + assert.deepEqual(constraintsFor(errs, 'documents'), ['isObject']); + }); + + it('rejects missing required fields', async () => { + const errs = await errorsFor(ResultDocumentDTO, {}); + assert.deepEqual(props(errs), ['documents', 'rate', 'schema', 'type']); + }); + }); + + describe('ResultInfoDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(ResultInfoDTO, { tokens: 1, documents: 5 }); + assert.equal(errs.length, 0); + }); + + it('rejects non-number tokens', async () => { + const errs = await errorsFor(ResultInfoDTO, { tokens: 'one', documents: 5 }); + assert.deepEqual(constraintsFor(errs, 'tokens'), ['isNumber']); + }); + + it('accepts zero documents (0 is not empty for IsNotEmpty)', async () => { + const errs = await errorsFor(ResultInfoDTO, { tokens: 1, documents: 0 }); + assert.equal(errs.length, 0); + }); + }); + + describe('RunningResultDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(RunningResultDTO, { + info: { tokens: 1, documents: 5 }, + total: 5, + documents: [{ type: 'VC', schema: 'u', rate: '100%', documents: {} }], + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object info and non-array documents', async () => { + const errs = await errorsFor(RunningResultDTO, { + info: 'x', + total: 5, + documents: 'y', + }); + assert.deepEqual(constraintsFor(errs, 'info'), ['isObject']); + assert.deepEqual(constraintsFor(errs, 'documents'), ['isArray']); + }); + + it('rejects non-number total', async () => { + const errs = await errorsFor(RunningResultDTO, { + info: { tokens: 1, documents: 5 }, + total: 'lots', + documents: [], + }); + assert.deepEqual(constraintsFor(errs, 'total'), ['isNumber']); + }); + }); + + describe('RunningDetailsDTO', () => { + it('accepts a fully-valid payload', async () => { + const errs = await errorsFor(RunningDetailsDTO, { + left: { a: 1 }, + right: { b: 2 }, + total: 10, + documents: { c: 3 }, + }); + assert.equal(errs.length, 0); + }); + + it('rejects non-object left/right/documents', async () => { + const errs = await errorsFor(RunningDetailsDTO, { + left: 'x', + right: 'y', + total: 10, + documents: 'z', + }); + assert.deepEqual(constraintsFor(errs, 'left'), ['isObject']); + assert.deepEqual(constraintsFor(errs, 'right'), ['isObject']); + assert.deepEqual(constraintsFor(errs, 'documents'), ['isObject']); + }); + + it('rejects non-number total', async () => { + const errs = await errorsFor(RunningDetailsDTO, { + left: {}, + right: {}, + total: 'x', + documents: {}, + }); + assert.deepEqual(constraintsFor(errs, 'total'), ['isNumber']); + }); + }); +}); diff --git a/api-gateway/tests/validation/old-descriptions.test.mjs b/api-gateway/tests/validation/old-descriptions.test.mjs new file mode 100644 index 0000000000..e5f9023496 --- /dev/null +++ b/api-gateway/tests/validation/old-descriptions.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { SwaggerPaths, SwaggerModels } from '../../dist/old-descriptions.js'; + +describe('old-descriptions swagger constants', () => { + it('exports a SwaggerPaths object', () => { + assert.equal(typeof SwaggerPaths, 'object'); + assert.ok(SwaggerPaths !== null); + }); + + it('SwaggerPaths contains known route entries', () => { + assert.ok(Object.prototype.hasOwnProperty.call(SwaggerPaths, '/schemas')); + assert.ok(Object.keys(SwaggerPaths).length > 0); + }); + + it('exports a SwaggerModels object', () => { + assert.equal(typeof SwaggerModels, 'object'); + assert.ok(SwaggerModels !== null); + assert.ok(Object.keys(SwaggerModels).length > 0); + }); + + it('SwaggerPaths route entries expose HTTP method definitions', () => { + const schemasGet = SwaggerPaths['/schemas']; + assert.equal(typeof schemasGet, 'object'); + assert.ok(Object.keys(schemasGet).length > 0); + }); +}); diff --git a/api-gateway/tests/validation/validation-pipes.test.mjs b/api-gateway/tests/validation/validation-pipes.test.mjs new file mode 100644 index 0000000000..4c543a72de --- /dev/null +++ b/api-gateway/tests/validation/validation-pipes.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import * as yup from 'yup'; +import validate, { prepareValidationResponse } from '../../dist/middlewares/validation/index.js'; +import { pageHeader } from '../../dist/middlewares/validation/page-header.js'; +import fieldsValidation from '../../dist/middlewares/validation/fields-validation.js'; +import { IsNumberOrString } from '../../dist/middlewares/validation/string-or-number.js'; +import { IsStringOrObject } from '../../dist/middlewares/validation/string-or-object.js'; + +function buildRes() { + return { + statusCode: null, + payload: undefined, + status(code) { + this.statusCode = code; + return this; + }, + send(payload) { + this.payload = payload; + return this; + }, + }; +} + +describe('@unit IsNumberOrString constraint', () => { + const c = new IsNumberOrString(); + + it('accepts an integer', () => assert.equal(c.validate(42, {}), true)); + it('accepts zero', () => assert.equal(c.validate(0, {}), true)); + it('accepts a negative number', () => assert.equal(c.validate(-3.5, {}), true)); + it('accepts Infinity', () => assert.equal(c.validate(Infinity, {}), true)); + it('accepts NaN because typeof NaN is number', () => assert.equal(c.validate(NaN, {}), true)); + it('accepts a non-empty string', () => assert.equal(c.validate('hello', {}), true)); + it('accepts an empty string', () => assert.equal(c.validate('', {}), true)); + it('rejects a boolean true', () => assert.equal(c.validate(true, {}), false)); + it('rejects a plain object', () => assert.equal(c.validate({}, {}), false)); + it('rejects an array', () => assert.equal(c.validate([1, 2], {}), false)); + it('rejects null', () => assert.equal(c.validate(null, {}), false)); + it('rejects undefined', () => assert.equal(c.validate(undefined, {}), false)); + it('rejects a bigint', () => assert.equal(c.validate(10n, {}), false)); + it('rejects a symbol', () => assert.equal(c.validate(Symbol('x'), {}), false)); + it('rejects a function', () => assert.equal(c.validate(() => 0, {}), false)); + it('returns the documented default message', () => { + assert.equal(c.defaultMessage({}), '($value) must be number or string'); + }); +}); + +describe('@unit IsStringOrObject constraint', () => { + const c = new IsStringOrObject(); + + it('accepts a populated object', () => assert.equal(c.validate({ a: 1 }, {}), true)); + it('accepts an empty object', () => assert.equal(c.validate({}, {}), true)); + it('accepts an array because typeof array is object', () => assert.equal(c.validate([1], {}), true)); + it('accepts null because typeof null is object', () => assert.equal(c.validate(null, {}), true)); + it('accepts a non-empty string', () => assert.equal(c.validate('value', {}), true)); + it('accepts an empty string', () => assert.equal(c.validate('', {}), true)); + it('rejects an integer', () => assert.equal(c.validate(123, {}), false)); + it('rejects NaN', () => assert.equal(c.validate(NaN, {}), false)); + it('rejects a boolean false', () => assert.equal(c.validate(false, {}), false)); + it('rejects undefined', () => assert.equal(c.validate(undefined, {}), false)); + it('rejects a bigint', () => assert.equal(c.validate(5n, {}), false)); + it('rejects a symbol', () => assert.equal(c.validate(Symbol('y'), {}), false)); + it('rejects a function', () => assert.equal(c.validate(function () {}, {}), false)); + it('returns the documented default message', () => { + assert.equal(c.defaultMessage({}), '($value) must be object or string'); + }); +}); + +describe('@unit prepareValidationResponse', () => { + it('passes through a populated errors array under message', () => { + assert.deepEqual( + prepareValidationResponse({ errors: ['a', 'b'] }), + { type: 'ValidationError', message: ['a', 'b'] } + ); + }); + + it('wraps a bare string error in a single-element array', () => { + assert.deepEqual( + prepareValidationResponse('boom'), + { type: 'ValidationError', message: ['boom'] } + ); + }); + + it('wraps an err whose errors property is undefined', () => { + assert.deepEqual( + prepareValidationResponse({ message: 'x' }), + { type: 'ValidationError', message: [{ message: 'x' }] } + ); + }); + + it('returns the empty errors array unchanged because an empty array is truthy', () => { + assert.deepEqual( + prepareValidationResponse({ errors: [] }), + { type: 'ValidationError', message: [] } + ); + }); + + it('wraps a null err in a single-element array', () => { + assert.deepEqual( + prepareValidationResponse(null), + { type: 'ValidationError', message: [null] } + ); + }); + + it('wraps an undefined err in a single-element array', () => { + assert.deepEqual( + prepareValidationResponse(undefined), + { type: 'ValidationError', message: [undefined] } + ); + }); + + it('uses a provided custom type', () => { + assert.equal(prepareValidationResponse({ errors: ['e'] }, 'CustomError').type, 'CustomError'); + }); + + it('defaults the type to ValidationError when omitted', () => { + assert.equal(prepareValidationResponse({ errors: ['e'] }).type, 'ValidationError'); + }); +}); + +describe('@unit validate middleware', () => { + it('calls next() and leaves status untouched when the schema resolves', async () => { + let nextCalled = false; + const res = buildRes(); + const mw = validate({ validate: async () => undefined }); + await mw({ body: {}, query: {}, params: {} }, res, () => { + nextCalled = true; + }); + assert.equal(nextCalled, true); + assert.equal(res.statusCode, null); + }); + + it('forwards body/query/params with abortEarly false to the schema', async () => { + let received; + let opts; + const mw = validate({ + validate: async (data, o) => { + received = data; + opts = o; + }, + }); + await mw({ body: { a: 1 }, query: { b: 2 }, params: { c: 3 } }, buildRes(), () => {}); + assert.deepEqual(received, { body: { a: 1 }, query: { b: 2 }, params: { c: 3 } }); + assert.equal(opts.abortEarly, false); + }); + + it('responds 422 with the prepared body and skips next() on failure', async () => { + let nextCalled = false; + const res = buildRes(); + const err = { name: 'MyError', errors: ['bad'] }; + const mw = validate({ + validate: async () => { + throw err; + }, + }); + await mw({ body: {}, query: {}, params: {} }, res, () => { + nextCalled = true; + }); + assert.equal(res.statusCode, 422); + assert.deepEqual(res.payload, { type: 'MyError', message: ['bad'] }); + assert.equal(nextCalled, false); + }); + + it('uses ValidationError as the type when the thrown error has no name', async () => { + const res = buildRes(); + const mw = validate({ + validate: async () => { + throw { errors: ['oops'] }; + }, + }); + await mw({ body: {}, query: {}, params: {} }, res, () => {}); + assert.equal(res.payload.type, 'ValidationError'); + assert.deepEqual(res.payload.message, ['oops']); + }); + + it('propagates real yup error messages through to the 422 payload', async () => { + const schema = yup.object({ + body: yup.object({ name: yup.string().required('name required') }), + }); + const res = buildRes(); + await mw422(validate(schema), res); + assert.equal(res.statusCode, 422); + assert.ok(res.payload.message.includes('name required')); + assert.equal(res.payload.type, 'ValidationError'); + }); +}); + +async function mw422(handler, res) { + await handler({ body: {}, query: {}, params: {} }, res, () => {}); +} + +describe('@unit pageHeader definition', () => { + it('is a non-null object', () => { + assert.equal(typeof pageHeader, 'object'); + assert.notEqual(pageHeader, null); + }); + + it('declares exactly the X-Total-Count header', () => { + assert.deepEqual(Object.keys(pageHeader), ['X-Total-Count']); + }); + + it('types X-Total-Count as an integer schema', () => { + assert.equal(pageHeader['X-Total-Count'].schema.type, 'integer'); + }); + + it('describes X-Total-Count for the collection total', () => { + assert.equal(pageHeader['X-Total-Count'].description, 'Total items in the collection.'); + }); +}); + +describe('@unit fieldsValidation re-exported through the index barrel', () => { + it('exposes the same default fields object referenced from the barrel', () => { + assert.equal(typeof fieldsValidation, 'object'); + assert.ok(fieldsValidation.contractId); + }); + + it('contractId accepts a string and rejects undefined', () => { + assert.equal(fieldsValidation.contractId.isValidSync('0.0.1'), true); + assert.equal(fieldsValidation.contractId.isValidSync(undefined), false); + }); + + it('oppositeTokenSerials accepts null and an array of numbers', () => { + assert.equal(fieldsValidation.oppositeTokenSerials.isValidSync(null), true); + assert.equal(fieldsValidation.oppositeTokenSerials.isValidSync([1, 2]), true); + assert.equal(fieldsValidation.oppositeTokenSerials.isValidSync(undefined), true); + }); +}); diff --git a/auth-service/tests/_handler-harness.mjs b/auth-service/tests/_handler-harness.mjs new file mode 100644 index 0000000000..0cf533cae6 --- /dev/null +++ b/auth-service/tests/_handler-harness.mjs @@ -0,0 +1,191 @@ +// Shared NATS handler harness — loads a dist service via esmock with +// @guardian/common + @guardian/interfaces (and common transitive deps) +// stubbed so the real ESM imports in dist resolve to lightweight fakes. +// +// Module._load (CJS) does not intercept ESM static imports inside the +// "type": "module" dist bundle, so any test that needs to instantiate a +// dist class must use loadService(...) instead of `await import(...)`. + +import esmock from 'esmock'; + +export const capturedHandlers = []; + +export const stubs = { + nextUser: { id: 'u-1', username: 'alice', email: 'a@x', role: 'USER', did: 'did:hedera:0.0.1', parent: null, permissionsGroup: [], permissions: [], hederaAccountId: '0.0.1', walletToken: 'w-1' }, + nextRole: { id: 'r-1', name: 'Role', owner: 'u-1', permissions: ['P'], default: false, readonly: false, uuid: 'role-uuid' }, +}; + +export class StubMessageResponse { constructor(body) { this.body = body; this.type = 'response'; } } +export class StubMessageError { constructor(err) { this.error = err; this.type = 'error'; } } +export class StubMessageErrorWrongInput extends StubMessageError {} +export class StubBinaryMessageResponse extends StubMessageResponse {} +export class StubMessageInitialization { constructor() { this.type = 'init'; } } + +class StubNatsService { + messageQueueName = 'stub'; + replySubject = 'stub-reply'; + async init() {} + async sendMessage() { return null; } + publish() {} + subscribe() { return { unsubscribe: () => {} }; } + getMessages(event, cb) { capturedHandlers.push({ event, cb }); return { unsubscribe: () => {} }; } +} + +class StubDataBaseHelper { + constructor(EntityClass, tenantId) { + this.entity = EntityClass?.name || String(EntityClass); + this.tenantId = tenantId; + } + async findOne() { return stubs.nextUser; } + async find() { return []; } + async findAndCount() { return [[], 0]; } + async count() { return 0; } + async create(data) { return { _id: 'new', ...data }; } + async save(_, data) { return data || { _id: 'saved' }; } + async update() { return stubs.nextUser; } + async remove() {} + async delete() {} + async aggregate() { return []; } +} + +class StubDatabaseServer { + constructor() {} + async findOne() { return stubs.nextUser; } + async find() { return []; } + async findAndCount() { return [[], 0]; } + async count() { return 0; } + create(_, d) { return d || {}; } + async save(_, d) { return d || {}; } + async update() { return stubs.nextUser; } + async remove() {} + async aggregate() { return []; } +} + +class StubWallet { + async getKey() { return 'fake-key'; } + async setKey() {} + async getGlobalApplicationKey() { return 'global-key'; } + async setGlobalApplicationKey() {} +} + +class StubWorkers { + async addRetryableTask() { return { balance: 0, balanceTinybar: 0 }; } + async addNonRetryableTask() { return { balance: 0, hederaAccountId: '0.0.1', key: 'k' }; } +} + +class StubUsers { + async getUser() { return stubs.nextUser; } + async getUserById() { return stubs.nextUser; } + async updateUser() { return stubs.nextUser; } +} + +const proxyEnum = () => new Proxy({}, { get: (_, p) => `Enum.${String(p)}` }); + +const guardianCommonMocks = { + NatsService: StubNatsService, + Singleton: (t) => t, + MessageError: StubMessageError, + MessageErrorWrongInput: StubMessageErrorWrongInput, + MessageResponse: StubMessageResponse, + MessageInitialization: StubMessageInitialization, + BinaryMessageResponse: StubBinaryMessageResponse, + DataBaseHelper: StubDataBaseHelper, + DatabaseServer: StubDatabaseServer, + PinoLogger: class { async error() {} async info() {} async warn() {} async debug() {} }, + Wallet: StubWallet, + Workers: StubWorkers, + Users: StubUsers, + MgsUsers: StubUsers, + IAuthUser: class {}, + KeyType: { KEY: 'KEY', RELAYER_ACCOUNT: 'RELAYER_ACCOUNT' }, + KeyEntity: class {}, + ApplicationState: class { getState() { return 'READY'; } }, + NotificationHelper: { success: async () => {}, error: async () => {} }, + SecretManager: { New: async () => ({ getSecrets: async () => null, setSecrets: async () => {} }) }, + SecretManagerBase: class {}, + SecretManagerType: { HCP_VAULT: 'hcp', MSG_HCP_VAULT: 'hashicorp' }, + TenantSecretManager: class { async getSecrets() { return null; } async setSecrets() {} }, + MgsGuardians: class { async deleteAllUserData() {} async getPolicyById() { return { id: 'p-1', name: 'demo' }; } }, + ProviderAuthUser: class {}, + extractTenantContext: (msg) => ({ tenantId: msg?.tenantId || null, fromTenantId: (id) => ({ tenantId: id }) }), +}; + +const guardianInterfacesMocks = { + GenerateUUIDv4: () => 'uuid-' + Math.random().toString(36).slice(2), + AuthEvents: proxyEnum(), + MgsAuthEvents: proxyEnum(), + WalletEvents: proxyEnum(), + MessageAPI: proxyEnum(), + MgsMessageAPI: proxyEnum(), + WorkerTaskType: proxyEnum(), + UserRole: { ADMIN: 'ADMIN', STANDARD_REGISTRY: 'STANDARD_REGISTRY', USER: 'USER', AUDITOR: 'AUDITOR' }, + NetworkType: { MAINNET: 'mainnet', TESTNET: 'testnet', PREVIEWNET: 'previewnet', LOCALNODE: 'localnode', NA: 'N/A' }, + LocationType: { LOCAL: 'LOCAL', REMOTE: 'REMOTE' }, + NetworkOptions: {}, + TenantContext: { Empty: { tenantId: null }, fromTenantId: (id) => ({ tenantId: id }) }, + AdminDefaultPermission: ['ADMIN'], + AuditDefaultPermission: ['AUDIT'], + PermissionsArray: [], + FeatureTypes: proxyEnum(), + NotificationAction: proxyEnum(), + PasswordType: { OLD: 'OLD', NEW: 'NEW' }, + RoleMapTypes: proxyEnum(), + SubscriptionChangeReasons: proxyEnum(), + SubscriptionCodes: proxyEnum(), + SubscriptionNotifications: proxyEnum(), + SubscriptionPeriods: proxyEnum(), + SubscriptionRequestStatuses: proxyEnum(), + SubscriptionStatuses: proxyEnum(), + SubscriptionTenantNotifications: proxyEnum(), + TenantAtpLogTypesIncomingMap: {}, + TenantLimitExcessReason: proxyEnum(), + TenantMigrationStatus: proxyEnum(), + TermsVersions: { TERMS_V1: 'v1' }, + TenantAtpLogTypesIncomingMap: {}, + IGroup: class {}, + IOwner: class {}, + IPermissionsMapPair: class {}, + IRoleMap: class {}, + IAuthUser: class {}, + ISubscriptionInfo: class {}, + ISubscriptionBaseInfo: class {}, + ISubscriptionRequestBilling: class {}, + SubscriptionRequest: class {}, + ITenantResponse: class {}, + IUser: class {}, +}; + +/** + * Merge a per-call override into the default mock map. + * `overrides['@guardian/common']` is a partial: `{ Wallet: CustomWallet }` only + * replaces `Wallet`, the rest of the default common stub stays in place. + */ +function mergeMocks(overrides) { + const merged = { + '@guardian/common': { ...guardianCommonMocks, ...(overrides?.['@guardian/common'] || {}) }, + '@guardian/interfaces': { ...guardianInterfacesMocks, ...(overrides?.['@guardian/interfaces'] || {}) }, + }; + for (const [k, v] of Object.entries(overrides || {})) { + if (k === '@guardian/common' || k === '@guardian/interfaces') continue; + merged[k] = v; + } + return merged; +} + +/** + * Load a dist module under esmock with the default @guardian/common + + * @guardian/interfaces stubs. Returns the loaded namespace object. + */ +export async function loadService(distPath, overrides = {}, globals = undefined) { + if (globals) { + return await esmock(distPath, mergeMocks(overrides), globals); + } + return await esmock(distPath, mergeMocks(overrides)); +} + +export { mergeMocks }; + +// Kept as no-ops for any caller that hasn't migrated yet. Tests should use +// loadService(...) instead. +export function installHarness() {} +export function restoreHarness() { capturedHandlers.length = 0; } diff --git a/auth-service/tests/account-service-handlers.test.mjs b/auth-service/tests/account-service-handlers.test.mjs new file mode 100644 index 0000000000..114b29df5d --- /dev/null +++ b/auth-service/tests/account-service-handlers.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { loadService, capturedHandlers, stubs, StubMessageError, StubMessageResponse } from './_handler-harness.mjs'; + +// Per-handler timeout so a single hung handler can't kill the loop. Handlers +// that hang count as exceptions and are summarised in the assertion. +const HANDLER_TIMEOUT_MS = 200; +async function callWithTimeout(cb, msg) { + return await Promise.race([ + Promise.resolve().then(() => cb(msg)), + new Promise((_, reject) => setTimeout(() => reject(new Error('handler timeout')), HANDLER_TIMEOUT_MS)), + ]); +} + +const localStubs = { + nextAccessToken: { userId: 'u-1', username: 'alice', did: 'did:hedera:0.0.1', role: 'STANDARD_USER', expireAt: Date.now() + 60000 }, + nextRefreshToken: { id: 'r-1', name: 'alice', expireAt: Date.now() + 60000 }, + nextTenant: { id: 't-1', tenantName: 'Tenant 1', owner: 'u-1', network: 'testnet' }, + nextSubscription: { features: ['F'], limits: { tenant: 0, policy: 0, policyTotal: 0 }, status: 'ACTIVE', code: 'PRO' }, + nextInvite: { id: 'i-1', deadline: new Date(Date.now() + 60_000_000), tenantId: 't-1' }, +}; + +const fakeUserAccessTokenService = { + async generateAccessToken() { return 'acc.token.jwt'; }, + async verifyAccessToken() { return localStubs.nextAccessToken; }, + generateRefreshToken() { return { id: 'r-1', token: 'ref.token.jwt' }; }, + verifyRefreshToken() { return localStubs.nextRefreshToken; }, +}; + +let AccountService; +let UserAccessTokenService; +try { + ({ AccountService } = await loadService('../dist/api/account-service.js')); + ({ UserAccessTokenService } = await loadService('../dist/utils/user-access-token.js')); +} catch (e) { + console.warn('[account-service-handlers.test] dist import failed:', e.message); +} + +describe('@unit account-service NATS handler envelope coverage', () => { + let svc; + before(() => { + if (!AccountService || !UserAccessTokenService) { console.warn(' [skip] dist not available'); return; } + UserAccessTokenService.New = async () => fakeUserAccessTokenService; + try { + svc = new AccountService(); + const logger = { async error() {}, async info() {}, async warn() {}, async debug() {} }; + svc.registerListeners(logger); + } catch (err) { + console.warn('[account-service-handlers.test] registerListeners failed:', err.message); + } + }); + + it('registers >20 handlers', () => { + if (!svc) return; + assert.ok(capturedHandlers.length > 20, `expected >20 handlers, got ${capturedHandlers.length}`); + }); + + it('every handler returns an envelope on empty input', async function () { + if (!svc) return; + this.timeout(HANDLER_TIMEOUT_MS * capturedHandlers.length + 5000); + let envelopes = 0, exceptions = 0; + const errorsByEvent = []; + for (const { event, cb } of capturedHandlers) { + try { + const result = await callWithTimeout(cb, {}); + if (result && (result.type === 'response' || result.type === 'error' || result instanceof StubMessageError || result instanceof StubMessageResponse)) { + envelopes++; + } else { + envelopes++; + } + } catch (e) { + exceptions++; + errorsByEvent.push(`${event}: ${e.message}`); + } + } + assert.ok( + exceptions < capturedHandlers.length * 0.7, + `Too many handlers threw on empty input (${exceptions}/${capturedHandlers.length}):\n ${errorsByEvent.slice(0, 5).join('\n ')}`, + ); + assert.ok(envelopes > 0); + }); + + it('handlers do not throw on shaped user input', async function () { + if (!svc) return; + this.timeout(HANDLER_TIMEOUT_MS * capturedHandlers.length + 5000); + const msg = { + tenantId: 't-1', + userId: 'u-1', + user: stubs.nextUser, + username: 'alice', + password: 'pwd', + did: 'did:hedera:0.0.1', + token: 'tok', + account: '0.0.1', + role: 'USER', + email: 'a@x', + newEmail: 'b@x', + oldEmail: 'a@x', + refreshToken: 'r', + tenantName: 'T', + owner: { creator: 'u-1', owner: 'u-1' }, + }; + let exceptions = 0; + for (const { cb } of capturedHandlers) { + try { await callWithTimeout(cb, msg); } catch { exceptions++; } + } + assert.ok(exceptions < capturedHandlers.length * 0.75); + }); +}); diff --git a/auth-service/tests/bitstring.test.mjs b/auth-service/tests/bitstring.test.mjs new file mode 100644 index 0000000000..e5f125a548 --- /dev/null +++ b/auth-service/tests/bitstring.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { Bitstring } from '../dist/helpers/credentials-validation/bitstring.js'; + +describe('Bitstring', () => { + it('rejects construction with both length and buffer', () => { + assert.throws(() => new Bitstring({ length: 8, buffer: new Uint8Array(1) }), /Only one of "length" or "buffer"/); + }); + + it('allocates ceil(length/8) bytes when constructed by length', () => { + const bs = new Bitstring({ length: 17 }); + // 17 bits → 3 bytes (24 bits storage), all zero + assert.equal(bs.bits.length, 3); + assert.equal(bs.length, 17); + for (let i = 0; i < bs.length; i++) { + assert.equal(bs.get(i), false); + } + }); + + it('reads bits MSB-first under default leftToRightIndexing=true', () => { + // First byte 0b10000001 → position 0 = 1, 1..6 = 0, 7 = 1 + const bs = new Bitstring({ buffer: new Uint8Array([0b10000001]) }); + assert.equal(bs.get(0), true); + assert.equal(bs.get(7), true); + for (let i = 1; i <= 6; i++) assert.equal(bs.get(i), false); + }); + + it('reads bits LSB-first when leftToRightIndexing=false', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0b00000001]), leftToRightIndexing: false }); + assert.equal(bs.get(0), true); + }); + + it('throws when get() position is out of range', () => { + const bs = new Bitstring({ length: 8 }); + assert.throws(() => bs.get(8), /out of range/); + assert.throws(() => bs.get(9), /out of range/); + }); + + it('honours deprecated littleEndianBits alias', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0b00000001]), littleEndianBits: false }); + // littleEndianBits=false ↔ leftToRightIndexing=false → position 0 = LSB = 1 + assert.equal(bs.get(0), true); + }); + + it('rejects when both leftToRightIndexing and littleEndianBits are supplied', () => { + assert.throws( + () => new Bitstring({ length: 8, leftToRightIndexing: true, littleEndianBits: false }), + /not allowed/ + ); + }); + + it('decodeBits round-trips a base64url-encoded gzipped payload (deflate via pako)', async () => { + // Smoke-test: empty string is not valid; just verify decodeBits requires a string and throws on bad input + await assert.rejects(Bitstring.decodeBits({ encoded: 'not-real-base64url-gzip' })); + await assert.rejects(Bitstring.decodeBits({ encoded: 123 }), /must be a string/); + }); +}); diff --git a/auth-service/tests/credentials-assertions.test.mjs b/auth-service/tests/credentials-assertions.test.mjs new file mode 100644 index 0000000000..7e962dbde8 --- /dev/null +++ b/auth-service/tests/credentials-assertions.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import * as A from '../dist/helpers/credentials-validation/assertions.js'; + +describe('credentials-validation assertions', () => { + it('isNumber accepts numbers, throws on others', () => { + assert.doesNotThrow(() => A.isNumber(1, 'x')); + assert.throws(() => A.isNumber('1', 'x'), /must be number/); + assert.throws(() => A.isNumber(null, 'x'), /must be number/); + }); + + it('isPositiveInteger rejects 0, negatives, fractionals, and non-numbers', () => { + assert.doesNotThrow(() => A.isPositiveInteger(1, 'x')); + assert.doesNotThrow(() => A.isPositiveInteger(7, 'x')); + assert.throws(() => A.isPositiveInteger(0, 'x'), /positive integer/); + assert.throws(() => A.isPositiveInteger(-1, 'x'), /positive integer/); + assert.throws(() => A.isPositiveInteger(1.5, 'x'), /positive integer/); + assert.throws(() => A.isPositiveInteger('1', 'x'), /positive integer/); + }); + + it('isString accepts strings only', () => { + assert.doesNotThrow(() => A.isString('', 'x')); + assert.throws(() => A.isString(0, 'x'), /must be a string/); + }); + + it('isBoolean accepts booleans only', () => { + assert.doesNotThrow(() => A.isBoolean(false, 'x')); + assert.throws(() => A.isBoolean(0, 'x'), /must be a boolean/); + }); + + it('isNonNegativeInteger allows 0, rejects negatives', () => { + assert.doesNotThrow(() => A.isNonNegativeInteger(0, 'x')); + assert.doesNotThrow(() => A.isNonNegativeInteger(7, 'x')); + assert.throws(() => A.isNonNegativeInteger(-1, 'x'), /non-negative integer/); + assert.throws(() => A.isNonNegativeInteger(1.5, 'x'), /non-negative integer/); + }); + + it('isUint8Array accepts only Uint8Array (Buffer counts since it extends Uint8Array)', () => { + assert.doesNotThrow(() => A.isUint8Array(new Uint8Array([1, 2, 3]), 'x')); + assert.doesNotThrow(() => A.isUint8Array(Buffer.from([1, 2, 3]), 'x')); + assert.throws(() => A.isUint8Array([1, 2, 3], 'x'), /Uint8Array/); + assert.throws(() => A.isUint8Array('abc', 'x'), /Uint8Array/); + }); +}); diff --git a/auth-service/tests/credentials-validation-assertions.test.mjs b/auth-service/tests/credentials-validation-assertions.test.mjs new file mode 100644 index 0000000000..e8a3e3d17a --- /dev/null +++ b/auth-service/tests/credentials-validation-assertions.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { + isNumber, + isPositiveInteger, + isString, + isBoolean, + isNonNegativeInteger, + isUint8Array, +} from '../dist/helpers/credentials-validation/assertions.js'; + +describe('credentials-validation/assertions', () => { + describe('isNumber', () => { + it('accepts number values', () => { + assert.doesNotThrow(() => isNumber(0, 'x')); + assert.doesNotThrow(() => isNumber(-1.5, 'x')); + }); + + it('rejects non-numbers with TypeError naming the field', () => { + assert.throws(() => isNumber('1', 'pos'), /"pos" must be number/); + assert.throws(() => isNumber(null, 'pos'), TypeError); + }); + }); + + describe('isPositiveInteger', () => { + it('accepts positive integers', () => { + assert.doesNotThrow(() => isPositiveInteger(1, 'len')); + assert.doesNotThrow(() => isPositiveInteger(1000, 'len')); + }); + + it('rejects zero, negatives, and non-integers', () => { + assert.throws(() => isPositiveInteger(0, 'len'), /positive integer/); + assert.throws(() => isPositiveInteger(-3, 'len'), /positive integer/); + assert.throws(() => isPositiveInteger(1.5, 'len'), /positive integer/); + assert.throws(() => isPositiveInteger('1', 'len'), /positive integer/); + }); + }); + + describe('isNonNegativeInteger', () => { + it('accepts zero and positive integers', () => { + assert.doesNotThrow(() => isNonNegativeInteger(0, 'pos')); + assert.doesNotThrow(() => isNonNegativeInteger(42, 'pos')); + }); + + it('rejects negatives and non-integers', () => { + assert.throws(() => isNonNegativeInteger(-1, 'pos'), /non-negative integer/); + assert.throws(() => isNonNegativeInteger(0.5, 'pos'), /non-negative integer/); + }); + }); + + describe('isString', () => { + it('accepts strings (including empty)', () => { + assert.doesNotThrow(() => isString('', 's')); + assert.doesNotThrow(() => isString('abc', 's')); + }); + + it('rejects non-strings', () => { + assert.throws(() => isString(1, 's'), /"s" must be a string/); + assert.throws(() => isString(undefined, 's'), TypeError); + }); + }); + + describe('isBoolean', () => { + it('accepts true/false', () => { + assert.doesNotThrow(() => isBoolean(true, 'b')); + assert.doesNotThrow(() => isBoolean(false, 'b')); + }); + + it('rejects truthy/falsy non-booleans', () => { + assert.throws(() => isBoolean(1, 'b'), /boolean/); + assert.throws(() => isBoolean('true', 'b'), /boolean/); + }); + }); + + describe('isUint8Array', () => { + it('accepts a Uint8Array', () => { + assert.doesNotThrow(() => isUint8Array(new Uint8Array(2), 'buf')); + }); + + it('rejects ordinary arrays and Buffers-of-ints', () => { + assert.throws(() => isUint8Array([1, 2], 'buf'), /Uint8Array/); + }); + + it('accepts Buffer (subclass of Uint8Array)', () => { + assert.doesNotThrow(() => isUint8Array(Buffer.alloc(1), 'buf')); + }); + }); +}); diff --git a/auth-service/tests/credentials-validation-bitstring.test.mjs b/auth-service/tests/credentials-validation-bitstring.test.mjs new file mode 100644 index 0000000000..117034a103 --- /dev/null +++ b/auth-service/tests/credentials-validation-bitstring.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { Bitstring } from '../dist/helpers/credentials-validation/bitstring.js'; + +describe('Bitstring', () => { + describe('construction', () => { + it('builds a zero-filled bitstring from a length', () => { + const bs = new Bitstring({ length: 16 }); + for (let i = 0; i < 16; i++) { + assert.equal(bs.get(i), false); + } + }); + + it('rounds up to the next byte for non-multiples of 8', () => { + const bs = new Bitstring({ length: 9 }); + assert.equal(bs.get(8), false); + assert.throws(() => bs.get(9), /out of range/); + }); + + it('refuses both length and buffer at once', () => { + assert.throws( + () => new Bitstring({ length: 8, buffer: new Uint8Array(1) }), + /Only one of "length" or "buffer"/, + ); + }); + + it('refuses both leftToRightIndexing and littleEndianBits', () => { + assert.throws( + () => new Bitstring({ length: 8, leftToRightIndexing: true, littleEndianBits: false }), + /not allowed/, + ); + }); + + it('accepts littleEndianBits as a deprecated alias for leftToRightIndexing', () => { + const buffer = new Uint8Array([0b1000_0000]); + const ltr = new Bitstring({ buffer, leftToRightIndexing: true }); + const alias = new Bitstring({ buffer, littleEndianBits: true }); + assert.equal(ltr.get(0), alias.get(0)); + assert.equal(ltr.get(7), alias.get(7)); + }); + + it('rejects a non-positive length', () => { + assert.throws(() => new Bitstring({ length: 0 }), /positive integer/); + assert.throws(() => new Bitstring({ length: -1 }), /positive integer/); + }); + + it('rejects a non-Uint8Array buffer', () => { + assert.throws(() => new Bitstring({ buffer: [1, 2] }), /Uint8Array/); + }); + }); + + describe('get with leftToRightIndexing=true (default)', () => { + it('reads bit 0 as the most-significant bit of byte 0', () => { + const buffer = new Uint8Array([0b1000_0000]); + const bs = new Bitstring({ buffer }); + assert.equal(bs.get(0), true); + assert.equal(bs.get(7), false); + }); + + it('reads bit 7 as the least-significant bit of byte 0', () => { + const buffer = new Uint8Array([0b0000_0001]); + const bs = new Bitstring({ buffer }); + assert.equal(bs.get(0), false); + assert.equal(bs.get(7), true); + }); + + it('crosses byte boundaries correctly', () => { + const buffer = new Uint8Array([0x00, 0b1000_0000]); + const bs = new Bitstring({ buffer }); + assert.equal(bs.get(7), false); + assert.equal(bs.get(8), true); + }); + }); + + describe('get with leftToRightIndexing=false', () => { + it('reads bit 0 as the least-significant bit of byte 0', () => { + const buffer = new Uint8Array([0b0000_0001]); + const bs = new Bitstring({ buffer, leftToRightIndexing: false }); + assert.equal(bs.get(0), true); + assert.equal(bs.get(7), false); + }); + }); + + describe('get range checks', () => { + it('throws when position is out of range', () => { + const bs = new Bitstring({ length: 8 }); + assert.throws(() => bs.get(8), /out of range/); + }); + + it('throws when position is negative (non-non-negative integer)', () => { + const bs = new Bitstring({ length: 8 }); + assert.throws(() => bs.get(-1), /non-negative integer/); + }); + + it('throws when position is not a number', () => { + const bs = new Bitstring({ length: 8 }); + assert.throws(() => bs.get('0'), /must be number/); + }); + }); + + describe('decodeBits', () => { + it('rejects non-string input', async () => { + await assert.rejects(Bitstring.decodeBits({ encoded: 123 }), /must be a string/); + }); + }); +}); diff --git a/auth-service/tests/credentials-validation-extra.test.mjs b/auth-service/tests/credentials-validation-extra.test.mjs new file mode 100644 index 0000000000..4f813eb6e7 --- /dev/null +++ b/auth-service/tests/credentials-validation-extra.test.mjs @@ -0,0 +1,257 @@ +import { assert } from 'chai'; +import pako from 'pako'; +import { Bitstring } from '../dist/helpers/credentials-validation/bitstring.js'; +import { StatusList } from '../dist/helpers/credentials-validation/status-list.js'; +import { + isNumber, + isPositiveInteger, + isString, + isBoolean, + isNonNegativeInteger, + isUint8Array, +} from '../dist/helpers/credentials-validation/assertions.js'; + +function encode(bytes) { + const gz = pako.gzip(new Uint8Array(bytes)); + return Buffer.from(gz).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +describe('@unit assertions.isNumber', () => { + it('accepts an integer', () => assert.doesNotThrow(() => isNumber(5, 'n'))); + it('accepts a float', () => assert.doesNotThrow(() => isNumber(1.5, 'n'))); + it('accepts zero', () => assert.doesNotThrow(() => isNumber(0, 'n'))); + it('accepts a negative number', () => assert.doesNotThrow(() => isNumber(-3, 'n'))); + it('accepts NaN (typeof number)', () => assert.doesNotThrow(() => isNumber(NaN, 'n'))); + it('rejects a string', () => assert.throws(() => isNumber('5', 'n'), /"n" must be number/)); + it('rejects null', () => assert.throws(() => isNumber(null, 'n'), /must be number/)); + it('rejects undefined', () => assert.throws(() => isNumber(undefined, 'n'), /must be number/)); + it('rejects a boolean', () => assert.throws(() => isNumber(true, 'n'), /must be number/)); +}); + +describe('@unit assertions.isPositiveInteger', () => { + it('accepts 1', () => assert.doesNotThrow(() => isPositiveInteger(1, 'p'))); + it('accepts a large integer', () => assert.doesNotThrow(() => isPositiveInteger(99999, 'p'))); + it('rejects 0', () => assert.throws(() => isPositiveInteger(0, 'p'), /positive integer/)); + it('rejects a negative integer', () => assert.throws(() => isPositiveInteger(-1, 'p'), /positive integer/)); + it('rejects a float', () => assert.throws(() => isPositiveInteger(1.5, 'p'), /positive integer/)); + it('rejects a numeric string', () => assert.throws(() => isPositiveInteger('1', 'p'), /positive integer/)); + it('rejects NaN', () => assert.throws(() => isPositiveInteger(NaN, 'p'), /positive integer/)); +}); + +describe('@unit assertions.isString', () => { + it('accepts an empty string', () => assert.doesNotThrow(() => isString('', 's'))); + it('accepts a non-empty string', () => assert.doesNotThrow(() => isString('hi', 's'))); + it('rejects a number', () => assert.throws(() => isString(1, 's'), /must be a string/)); + it('rejects null', () => assert.throws(() => isString(null, 's'), /must be a string/)); + it('rejects an array', () => assert.throws(() => isString([], 's'), /must be a string/)); +}); + +describe('@unit assertions.isBoolean', () => { + it('accepts true', () => assert.doesNotThrow(() => isBoolean(true, 'b'))); + it('accepts false', () => assert.doesNotThrow(() => isBoolean(false, 'b'))); + it('rejects 0', () => assert.throws(() => isBoolean(0, 'b'), /must be a boolean/)); + it('rejects a string', () => assert.throws(() => isBoolean('true', 'b'), /must be a boolean/)); + it('rejects null', () => assert.throws(() => isBoolean(null, 'b'), /must be a boolean/)); +}); + +describe('@unit assertions.isNonNegativeInteger', () => { + it('accepts 0', () => assert.doesNotThrow(() => isNonNegativeInteger(0, 'i'))); + it('accepts a positive integer', () => assert.doesNotThrow(() => isNonNegativeInteger(10, 'i'))); + it('rejects -1', () => assert.throws(() => isNonNegativeInteger(-1, 'i'), /non-negative integer/)); + it('rejects a float', () => assert.throws(() => isNonNegativeInteger(0.5, 'i'), /non-negative integer/)); + it('rejects a string', () => assert.throws(() => isNonNegativeInteger('0', 'i'), /non-negative integer/)); +}); + +describe('@unit assertions.isUint8Array', () => { + it('accepts a Uint8Array', () => assert.doesNotThrow(() => isUint8Array(new Uint8Array(2), 'u'))); + it('accepts an empty Uint8Array', () => assert.doesNotThrow(() => isUint8Array(new Uint8Array(0), 'u'))); + it('rejects a plain array', () => assert.throws(() => isUint8Array([1, 2], 'u'), /must be a Uint8Array/)); + it('rejects null', () => assert.throws(() => isUint8Array(null, 'u'), /must be a Uint8Array/)); + it('rejects an ArrayBuffer', () => assert.throws(() => isUint8Array(new ArrayBuffer(2), 'u'), /must be a Uint8Array/)); +}); + +describe('@unit Bitstring.get — full-byte patterns (leftToRight default)', () => { + it('all-ones byte reads true at every position', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0xff]) }); + for (let i = 0; i < 8; i++) assert.isTrue(bs.get(i)); + }); + + it('all-zero byte reads false at every position', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0x00]) }); + for (let i = 0; i < 8; i++) assert.isFalse(bs.get(i)); + }); + + it('alternating 0b10101010 reads true on even indices', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0b10101010]) }); + assert.isTrue(bs.get(0)); + assert.isFalse(bs.get(1)); + assert.isTrue(bs.get(2)); + assert.isFalse(bs.get(3)); + }); + + it('alternating 0b01010101 reads false on even indices', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0b01010101]) }); + assert.isFalse(bs.get(0)); + assert.isTrue(bs.get(1)); + assert.isFalse(bs.get(2)); + assert.isTrue(bs.get(7)); + }); + + it('reads across three bytes', () => { + const bs = new Bitstring({ buffer: new Uint8Array([0x00, 0xff, 0x00]) }); + assert.isFalse(bs.get(0)); + assert.isTrue(bs.get(8)); + assert.isTrue(bs.get(15)); + assert.isFalse(bs.get(16)); + }); +}); + +describe('@unit Bitstring.get — leftToRightIndexing=false', () => { + it('reverses the within-byte bit order', () => { + const buffer = new Uint8Array([0b0000_0001]); + const bs = new Bitstring({ buffer, leftToRightIndexing: false }); + assert.isTrue(bs.get(0)); + assert.isFalse(bs.get(7)); + }); + + it('MSB is read at index 7', () => { + const buffer = new Uint8Array([0b1000_0000]); + const bs = new Bitstring({ buffer, leftToRightIndexing: false }); + assert.isFalse(bs.get(0)); + assert.isTrue(bs.get(7)); + }); +}); + +describe('@unit Bitstring construction edge cases', () => { + it('length=1 yields a single addressable bit', () => { + const bs = new Bitstring({ length: 1 }); + assert.isFalse(bs.get(0)); + assert.throws(() => bs.get(1), /out of range/); + }); + + it('length=8 spans exactly one byte', () => { + const bs = new Bitstring({ length: 8 }); + assert.isFalse(bs.get(7)); + assert.throws(() => bs.get(8), /out of range/); + }); + + it('buffer length determines bit length (n*8)', () => { + const bs = new Bitstring({ buffer: new Uint8Array(3) }); + assert.isFalse(bs.get(23)); + assert.throws(() => bs.get(24), /out of range/); + }); +}); + +describe('@unit Bitstring.decodeBits — gzip round-trips', () => { + it('recovers a single byte', async () => { + const out = await Bitstring.decodeBits({ encoded: encode([0xab]) }); + assert.deepEqual(Array.from(out), [0xab]); + }); + + it('recovers multiple bytes', async () => { + const out = await Bitstring.decodeBits({ encoded: encode([1, 2, 3, 4, 5]) }); + assert.deepEqual(Array.from(out), [1, 2, 3, 4, 5]); + }); + + it('recovers all-zero bytes', async () => { + const out = await Bitstring.decodeBits({ encoded: encode([0, 0, 0, 0]) }); + assert.deepEqual(Array.from(out), [0, 0, 0, 0]); + }); + + it('result is a Uint8Array', async () => { + const out = await Bitstring.decodeBits({ encoded: encode([7]) }); + assert.instanceOf(out, Uint8Array); + }); + + it('rejects non-string encoded input', async () => { + let threw = false; + try { await Bitstring.decodeBits({ encoded: 42 }); } catch { threw = true; } + assert.isTrue(threw); + }); +}); + +describe('@unit StatusList.decode + getStatus', () => { + it('reconstructs bit length from the decoded buffer', async () => { + const sl = await StatusList.decode({ encodedList: encode([0x00]) }); + assert.equal(sl.length, 8); + }); + + it('reads a set MSB at index 0', async () => { + const sl = await StatusList.decode({ encodedList: encode([0b1000_0000]) }); + assert.isTrue(sl.getStatus(0)); + assert.isFalse(sl.getStatus(1)); + }); + + it('reads a set LSB at index 7', async () => { + const sl = await StatusList.decode({ encodedList: encode([0b0000_0001]) }); + assert.isTrue(sl.getStatus(7)); + assert.isFalse(sl.getStatus(0)); + }); + + it('reads both ends set (0b10000001)', async () => { + const sl = await StatusList.decode({ encodedList: encode([0b1000_0001]) }); + assert.isTrue(sl.getStatus(0)); + assert.isTrue(sl.getStatus(7)); + assert.isFalse(sl.getStatus(3)); + }); + + it('two bytes give length 16', async () => { + const sl = await StatusList.decode({ encodedList: encode([0x00, 0xff]) }); + assert.equal(sl.length, 16); + assert.isFalse(sl.getStatus(0)); + assert.isTrue(sl.getStatus(8)); + assert.isTrue(sl.getStatus(15)); + }); + + it('rejects an undecodable encoded list', async () => { + let threw = false; + try { await StatusList.decode({ encodedList: 'not-valid-gzip-base64url' }); } catch { threw = true; } + assert.isTrue(threw); + }); +}); + +describe('@unit StatusList.convertToBinaryString', () => { + it('renders an all-zero byte as eight zeros', async () => { + const sl = await StatusList.decode({ encodedList: encode([0x00]) }); + assert.equal(sl.convertToBinaryString(), '00000000'); + }); + + it('renders an all-ones byte as eight ones', async () => { + const sl = await StatusList.decode({ encodedList: encode([0xff]) }); + assert.equal(sl.convertToBinaryString(), '11111111'); + }); + + it('renders a known pattern with leading-zero padding', async () => { + const sl = await StatusList.decode({ encodedList: encode([0b0000_0001]) }); + assert.equal(sl.convertToBinaryString(), '00000001'); + }); + + it('concatenates multiple bytes in order', async () => { + const sl = await StatusList.decode({ encodedList: encode([0b1000_0000, 0b0000_0001]) }); + assert.equal(sl.convertToBinaryString(), '1000000000000001'); + }); + + it('output length equals 8 * byte count', async () => { + const sl = await StatusList.decode({ encodedList: encode([1, 2, 3]) }); + assert.lengthOf(sl.convertToBinaryString(), 24); + }); +}); + +describe('@unit StatusList constructor from buffer', () => { + it('exposes a length of buffer.length * 8', () => { + const sl = new StatusList({ buffer: new Uint8Array(2) }); + assert.equal(sl.length, 16); + }); + + it('reads status from a directly-supplied buffer', () => { + const sl = new StatusList({ buffer: new Uint8Array([0b1000_0000]) }); + assert.isTrue(sl.getStatus(0)); + assert.isFalse(sl.getStatus(7)); + }); + + it('builds from a length producing all-false statuses', () => { + const sl = new StatusList({ length: 16 }); + assert.equal(sl.length, 16); + for (let i = 0; i < 16; i++) assert.isFalse(sl.getStatus(i)); + }); +}); diff --git a/auth-service/tests/cryppo.test.mjs b/auth-service/tests/cryppo.test.mjs new file mode 100644 index 0000000000..7e7916dda6 --- /dev/null +++ b/auth-service/tests/cryppo.test.mjs @@ -0,0 +1,111 @@ +import { assert } from 'chai'; +import baseX from 'base-x'; +import { Cryppo } from '../dist/meeco/cryppo.js'; + +const base32Alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +function encodeBase32(str) { + const bytes = Uint8Array.from(Buffer.from(str, 'binary')); + return baseX(base32Alphabet).encode(bytes); +} + +describe('@unit Cryppo.decodeBase32', () => { + it('round-trips a base32-encoded ascii passphrase', () => { + const passphrase = 'hello'; + const encoded = encodeBase32(passphrase); + const c = new Cryppo(encoded); + assert.equal(c.decodeBase32(encoded), passphrase); + }); + + it('strips hyphens before decoding', () => { + const passphrase = 'world'; + const encoded = encodeBase32(passphrase); + const hyphenated = encoded.match(/.{1,2}/g).join('-'); + const c = new Cryppo(encoded); + assert.equal(c.decodeBase32(hyphenated), passphrase); + }); + + it('trims surrounding whitespace before decoding', () => { + const passphrase = 'abc'; + const encoded = encodeBase32(passphrase); + const c = new Cryppo(encoded); + assert.equal(c.decodeBase32(` ${encoded} `), passphrase); + }); + + it('decodes a single-byte value', () => { + const encoded = encodeBase32('Z'); + const c = new Cryppo(encoded); + assert.equal(c.decodeBase32(encoded), 'Z'); + }); +}); + +describe('@unit Cryppo.iDerivedKeyToParams', () => { + const c = new Cryppo(encodeBase32('seed')); + + it('applies all defaults when no artifacts supplied', () => { + const params = c.iDerivedKeyToParams(); + assert.equal(params.iterationVariance, 0); + assert.equal(params.minIterations, 10000); + assert.equal(params.length, 32); + assert.equal(params.useSalt, ''); + assert.equal(params.hash, 'SHA256'); + assert.isString(params.strategy); + }); + + it('applies all defaults for an empty artifacts object', () => { + const params = c.iDerivedKeyToParams({}); + assert.equal(params.minIterations, 10000); + assert.equal(params.length, 32); + assert.equal(params.hash, 'SHA256'); + }); + + it('honours provided iterations, length, salt and hash', () => { + const params = c.iDerivedKeyToParams({ + iterations: 50000, + length: 64, + salt: 'mysalt', + hash: 'SHA512', + }); + assert.equal(params.minIterations, 50000); + assert.equal(params.length, 64); + assert.equal(params.useSalt, 'mysalt'); + assert.equal(params.hash, 'SHA512'); + }); + + it('falls back to defaults for zero/falsy numeric artifacts', () => { + const params = c.iDerivedKeyToParams({ iterations: 0, length: 0 }); + assert.equal(params.minIterations, 10000); + assert.equal(params.length, 32); + }); + + it('iterationVariance is always 0 regardless of input', () => { + const params = c.iDerivedKeyToParams({ iterations: 99999 }); + assert.equal(params.iterationVariance, 0); + }); +}); + +describe('@unit Cryppo.deriveMEK — artifact validation', () => { + const c = new Cryppo(encodeBase32('seed')); + + it('throws when only derivationArtifacts is provided', async () => { + let threw = false; + try { + await c.deriveMEK('something', ''); + } catch (e) { + threw = true; + assert.match(e.message, /both artefacts/); + } + assert.isTrue(threw); + }); + + it('throws when only verificationArtifacts is provided', async () => { + let threw = false; + try { + await c.deriveMEK('', 'something'); + } catch (e) { + threw = true; + assert.match(e.message, /both artefacts/); + } + assert.isTrue(threw); + }); +}); diff --git a/auth-service/tests/entity-init-hooks.test.mjs b/auth-service/tests/entity-init-hooks.test.mjs new file mode 100644 index 0000000000..0a714e542e --- /dev/null +++ b/auth-service/tests/entity-init-hooks.test.mjs @@ -0,0 +1,188 @@ +import { assert } from 'chai'; +import { Role } from '../dist/entity/role.js'; +import { RelayerAccount } from '../dist/entity/relayer-account.js'; +import { User } from '../dist/entity/user.js'; + +describe('@unit Role.setInitState', () => { + it('defaults empty name to empty string', () => { + const r = new Role(); + r.setInitState(); + assert.equal(r.name, ''); + }); + + it('defaults empty owner to empty string', () => { + const r = new Role(); + r.setInitState(); + assert.equal(r.owner, ''); + }); + + it('defaults missing permissions to empty array', () => { + const r = new Role(); + r.setInitState(); + assert.isArray(r.permissions); + assert.lengthOf(r.permissions, 0); + }); + + it('preserves an existing name', () => { + const r = new Role(); + r.name = 'Editor'; + r.setInitState(); + assert.equal(r.name, 'Editor'); + }); + + it('preserves an existing owner', () => { + const r = new Role(); + r.owner = 'owner-1'; + r.setInitState(); + assert.equal(r.owner, 'owner-1'); + }); + + it('preserves an existing permissions array', () => { + const r = new Role(); + r.permissions = ['A', 'B']; + r.setInitState(); + assert.deepEqual(r.permissions, ['A', 'B']); + }); + + it('replaces a non-array permissions value with []', () => { + const r = new Role(); + r.permissions = 'not-array'; + r.setInitState(); + assert.deepEqual(r.permissions, []); + }); + + it('treats null name as empty string', () => { + const r = new Role(); + r.name = null; + r.setInitState(); + assert.equal(r.name, ''); + }); + + it('keeps an empty permissions array as empty (idempotent)', () => { + const r = new Role(); + r.permissions = []; + r.setInitState(); + assert.deepEqual(r.permissions, []); + }); + + it('is idempotent across repeated calls', () => { + const r = new Role(); + r.name = 'X'; + r.setInitState(); + r.setInitState(); + assert.equal(r.name, 'X'); + assert.deepEqual(r.permissions, []); + }); +}); + +describe('@unit RelayerAccount.setInitState', () => { + it('defaults empty name to empty string', () => { + const a = new RelayerAccount(); + a.setInitState(); + assert.equal(a.name, ''); + }); + + it('defaults empty owner to empty string', () => { + const a = new RelayerAccount(); + a.setInitState(); + assert.equal(a.owner, ''); + }); + + it('defaults empty account to empty string', () => { + const a = new RelayerAccount(); + a.setInitState(); + assert.equal(a.account, ''); + }); + + it('preserves a provided name', () => { + const a = new RelayerAccount(); + a.name = 'relayer'; + a.setInitState(); + assert.equal(a.name, 'relayer'); + }); + + it('preserves a provided owner', () => { + const a = new RelayerAccount(); + a.owner = 'o-9'; + a.setInitState(); + assert.equal(a.owner, 'o-9'); + }); + + it('preserves a provided account', () => { + const a = new RelayerAccount(); + a.account = '0.0.42'; + a.setInitState(); + assert.equal(a.account, '0.0.42'); + }); + + it('does not touch parent or username fields', () => { + const a = new RelayerAccount(); + a.parent = 'p'; + a.username = 'u'; + a.setInitState(); + assert.equal(a.parent, 'p'); + assert.equal(a.username, 'u'); + }); + + it('coerces null fields to empty strings', () => { + const a = new RelayerAccount(); + a.name = null; + a.owner = null; + a.account = null; + a.setInitState(); + assert.equal(a.name, ''); + assert.equal(a.owner, ''); + assert.equal(a.account, ''); + }); +}); + +describe('@unit User.setInitState', () => { + it('defaults role to USER when unset', () => { + const u = new User(); + u.setInitState(); + assert.equal(u.role, 'USER'); + }); + + it('defaults location to LOCAL when unset', () => { + const u = new User(); + u.setInitState(); + assert.equal(u.location, 'local'); + }); + + it('preserves an explicitly set role', () => { + const u = new User(); + u.role = 'STANDARD_REGISTRY'; + u.setInitState(); + assert.equal(u.role, 'STANDARD_REGISTRY'); + }); + + it('preserves an explicitly set location', () => { + const u = new User(); + u.location = 'REMOTE'; + u.setInitState(); + assert.equal(u.location, 'REMOTE'); + }); + + it('coerces null role back to the USER default', () => { + const u = new User(); + u.role = null; + u.setInitState(); + assert.equal(u.role, 'USER'); + }); + + it('coerces null location back to the LOCAL default', () => { + const u = new User(); + u.location = null; + u.setInitState(); + assert.equal(u.location, 'local'); + }); + + it('is idempotent', () => { + const u = new User(); + u.role = 'AUDITOR'; + u.setInitState(); + u.setInitState(); + assert.equal(u.role, 'AUDITOR'); + assert.equal(u.location, 'local'); + }); +}); diff --git a/auth-service/tests/import-keys-from-database.test.mjs b/auth-service/tests/import-keys-from-database.test.mjs new file mode 100644 index 0000000000..bb9b96d9ff --- /dev/null +++ b/auth-service/tests/import-keys-from-database.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const state = { + findCalls: [], + wallets: [], + setKeyCalls: [], + setKeyThrows: false, + infoLogs: [], + errorLogs: [], +}; + +function reset() { + state.findCalls = []; + state.wallets = []; + state.setKeyCalls = []; + state.setKeyThrows = false; + state.infoLogs = []; + state.errorLogs = []; +} + +class FakeDatabaseServer { + async find(entity, filter) { + state.findCalls.push([entity.name, filter]); + return state.wallets; + } +} + +class HashicorpFake { + async setKey(token, type, key, value) { + if (state.setKeyThrows) { + throw new Error('vault sealed'); + } + state.setKeyCalls.push({ token, type, key, value }); + } +} + +const logger = { + async info(message, attr) { + state.infoLogs.push([message, attr]); + }, + async error(message, attr) { + state.errorLogs.push([message, attr]); + }, +}; + +const { ImportKeysFromDatabase } = await esmock('../dist/helpers/import-keys-from-database.js', { + '@guardian/common': { DatabaseServer: FakeDatabaseServer }, +}); + +const originalProvider = process.env.VAULT_PROVIDER; + +describe('@unit ImportKeysFromDatabase', () => { + beforeEach(() => { + reset(); + process.env.VAULT_PROVIDER = 'hashicorp'; + }); + + after(() => { + if (originalProvider === undefined) { + delete process.env.VAULT_PROVIDER; + } else { + process.env.VAULT_PROVIDER = originalProvider; + } + }); + + it('refuses to import into the database provider', async () => { + process.env.VAULT_PROVIDER = 'database'; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.equal(state.errorLogs.length, 1); + assert.match(state.errorLogs[0][0], /Cannot import to database provider/); + assert.equal(state.findCalls.length, 0); + assert.equal(state.setKeyCalls.length, 0); + }); + + it('queries wallet accounts whose type matches the KEY|suffix pattern', async () => { + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.equal(state.findCalls.length, 1); + assert.equal(state.findCalls[0][0], 'WalletAccount'); + assert.ok(state.findCalls[0][1].type instanceof RegExp); + assert.ok(state.findCalls[0][1].type.test('OPERATOR_KEY|did:1')); + assert.ok(!state.findCalls[0][1].type.test('TOKEN|did:1')); + }); + + it('splits the stored type into vault type and key around the pipe', async () => { + state.wallets = [{ token: 't-1', type: 'OPERATOR_KEY|did:user:9', key: 'priv' }]; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.deepEqual(state.setKeyCalls, [{ token: 't-1', type: 'OPERATOR_KEY', key: 'did:user:9', value: 'priv' }]); + }); + + it('imports every matched wallet account', async () => { + state.wallets = [ + { token: 't-1', type: 'KEY|a', key: 'v1' }, + { token: 't-2', type: 'KEY|b', key: 'v2' }, + { token: 't-3', type: 'KEY|c', key: 'v3' }, + ]; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.equal(state.setKeyCalls.length, 3); + assert.deepEqual(state.setKeyCalls.map((c) => c.key), ['a', 'b', 'c']); + }); + + it('logs how many keys were found', async () => { + state.wallets = [ + { token: 't-1', type: 'KEY|a', key: 'v1' }, + { token: 't-2', type: 'KEY|b', key: 'v2' }, + ]; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.ok(state.infoLogs.some(([m]) => m === 'found 2 keys')); + }); + + it('logs success with the uppercased vault class name', async () => { + state.wallets = [{ token: 't-1', type: 'KEY|a', key: 'v1' }]; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.ok(state.infoLogs.some(([m]) => m === '1 keys was added to HASHICORPFAKE')); + }); + + it('logs the vault error and resolves when setKey fails', async () => { + state.wallets = [{ token: 't-1', type: 'KEY|a', key: 'v1' }]; + state.setKeyThrows = true; + await assert.doesNotReject(ImportKeysFromDatabase(new HashicorpFake(), logger)); + assert.equal(state.errorLogs.length, 1); + assert.match(state.errorLogs[0][0], /HASHICORPFAKE vault import error: vault sealed/); + }); + + it('completes with zero accounts without touching the vault', async () => { + await ImportKeysFromDatabase(new HashicorpFake(), logger); + assert.equal(state.setKeyCalls.length, 0); + assert.ok(state.infoLogs.some(([m]) => m === 'found 0 keys')); + assert.ok(state.infoLogs.some(([m]) => m === '0 keys was added to HASHICORPFAKE')); + }); + + it('tags every log line with the AUTH_SERVICE attribute', async () => { + state.wallets = [{ token: 't-1', type: 'KEY|a', key: 'v1' }]; + await ImportKeysFromDatabase(new HashicorpFake(), logger); + for (const [, attr] of state.infoLogs) { + assert.deepEqual(attr, ['AUTH_SERVICE']); + } + }); +}); diff --git a/auth-service/tests/meeco-api.test.mjs b/auth-service/tests/meeco-api.test.mjs new file mode 100644 index 0000000000..4da869eb2f --- /dev/null +++ b/auth-service/tests/meeco-api.test.mjs @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +function makeAxiosFake() { + const calls = []; + const responses = { get: [], post: [], patch: [] }; + function next(method) { + const queue = responses[method]; + if (queue.length) { + return queue.shift(); + } + return { data: {}, status: 200 }; + } + const axios = { + calls, + responses, + queue(method, value) { responses[method].push(value); }, + async post(url, data, config) { + const r = next('post'); + calls.push({ method: 'post', url, data, config }); + if (r instanceof Error) { throw r; } + return r; + }, + async patch(url, data, config) { + const r = next('patch'); + calls.push({ method: 'patch', url, data, config }); + if (r instanceof Error) { throw r; } + return r; + }, + async get(url, config) { + const r = next('get'); + calls.push({ method: 'get', url, config }); + if (r instanceof Error) { throw r; } + return r; + }, + }; + return axios; +} + +const baseConfig = () => ({ + baseUrl: 'https://api.meeco.test', + meecoOrganizationId: 'ORG-1', + oauth: { + url: 'https://auth.meeco.test/token', + clientId: 'CID', + clientSecret: 'CSECRET', + scope: 'read write', + grantType: 'client_credentials', + }, +}); + +describe('MeecoApi', function () { + this.timeout(60000); + + let MeecoApi; + let axios; + let jwtDecodeCalls; + + before(async () => { + axios = makeAxiosFake(); + jwtDecodeCalls = []; + const mod = await esmock('../dist/meeco/meeco-api.js', { + axios: { default: axios }, + jsonwebtoken: { + decode: (token) => { jwtDecodeCalls.push(token); return { decoded: token }; }, + }, + }); + MeecoApi = mod.MeecoApi; + }); + + beforeEach(() => { + axios.calls.length = 0; + axios.responses.get.length = 0; + axios.responses.post.length = 0; + axios.responses.patch.length = 0; + jwtDecodeCalls.length = 0; + }); + + function api() { + return new MeecoApi(baseConfig()); + } + + it('exports a constructible class (link sanity)', () => { + assert.equal(typeof MeecoApi, 'function'); + assert.equal(typeof api().getMe, 'function'); + }); + + it('freezes the config so it cannot be mutated', () => { + const cfg = baseConfig(); + const a = new MeecoApi(cfg); + const inner = a.config; + assert.ok(Object.isFrozen(inner)); + assert.throws(() => { inner.baseUrl = 'x'; }); + }); + + describe('getTokenOauth2', () => { + it('posts form-encoded oauth data to the oauth url', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + const out = await api().getTokenOauth2(); + const call = axios.calls[0]; + assert.equal(call.method, 'post'); + assert.equal(call.url, 'https://auth.meeco.test/token'); + assert.equal(call.config.headers['content-type'], 'application/x-www-form-urlencoded'); + assert.equal(out, 'Bearer AT'); + }); + + it('serializes all oauth params into the body', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + await api().getTokenOauth2(); + const body = axios.calls[0].data; + assert.ok(body.includes('grant_type=client_credentials')); + assert.ok(body.includes('client_id=CID')); + assert.ok(body.includes('client_secret=CSECRET')); + assert.ok(body.includes('scope=read%20write')); + }); + }); + + describe('getMe', () => { + it('authenticates then GETs /me with org header', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { id: 'me-1' } }); + const out = await api().getMe(); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/me'); + assert.equal(getCall.config.headers['Authorization'], 'Bearer AT'); + assert.equal(getCall.config.headers['Meeco-Organisation-Id'], 'ORG-1'); + assert.deepEqual(out, { id: 'me-1' }); + }); + }); + + describe('getKeyEncryptionKey', () => { + it('GETs /key_encryption_key and returns the data body', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { kek: 1 } }); + const out = await api().getKeyEncryptionKey(); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/key_encryption_key'); + assert.deepEqual(out, { kek: 1 }); + }); + }); + + describe('getSchema / getSchemas', () => { + it('getSchema interpolates schemaId into the URL', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { schema: 's' } }); + const out = await api().getSchema('SCHEMA-42'); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/schemas/SCHEMA-42'); + assert.deepEqual(out, { schema: 's' }); + }); + + it('getSchemas hits the collection endpoint', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: [{ id: 1 }] }); + const out = await api().getSchemas(); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/schemas'); + assert.deepEqual(out, [{ id: 1 }]); + }); + }); + + describe('createSchema', () => { + it('POSTs a schema wrapper with name, json and org ids', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('post', { data: { id: 'new' } }); + const out = await api().createSchema('MySchema', { fields: [] }); + const postCall = axios.calls.filter(c => c.method === 'post')[1]; + assert.equal(postCall.url, 'https://api.meeco.test/schemas'); + const body = JSON.parse(postCall.data); + assert.equal(body.schema.name, 'MySchema'); + assert.deepEqual(body.schema.schema_json, { fields: [] }); + assert.deepEqual(body.schema.organization_ids, ['ORG-1']); + assert.deepEqual(out, { id: 'new' }); + }); + }); + + describe('getDataEncryptionKey / getKeyPairs', () => { + it('getDataEncryptionKey interpolates external id', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { dek: 1 } }); + await api().getDataEncryptionKey('DEK-9'); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/data_encryption_keys/DEK-9'); + }); + + it('getKeyPairs interpolates external id', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { keypair: 1 } }); + await api().getKeyPairs('EXT-7'); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/keypairs/external_id/EXT-7'); + }); + }); + + describe('getPassphraseArtefact', () => { + it('GETs the passphrase derivation artefact endpoint', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { passphrase_derivation_artefact: {} } }); + const out = await api().getPassphraseArtefact(); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/passphrase_derivation_artefact'); + assert.deepEqual(out, { passphrase_derivation_artefact: {} }); + }); + }); + + describe('createPresentationRequest', () => { + it('builds the request body with method qrcode and a future expiry', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('post', { data: { id: 'pr-1' } }); + const before = Date.now(); + await api().createPresentationRequest('Req', 'did:abc', 'Client', 'PD-1'); + const postCall = axios.calls.filter(c => c.method === 'post')[1]; + assert.equal(postCall.url, 'https://api.meeco.test/oidc/presentations/requests'); + const body = JSON.parse(postCall.data); + const pr = body.presentation_request; + assert.equal(pr.name, 'Req'); + assert.equal(pr.client_id, 'did:abc'); + assert.equal(pr.client_name, 'Client'); + assert.equal(pr.presentation_definition_id, 'PD-1'); + assert.equal(pr.method, 'qrcode'); + assert.equal(pr.redirect_base_uri, 'https://api.meeco.test'); + assert.ok(new Date(pr.expires_at).getTime() > before); + }); + }); + + describe('submitPresentationRequestSignature', () => { + it('PATCHes the request with the signed jwt', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('patch', { data: { id: 'pr-1' } }); + await api().submitPresentationRequestSignature('REQ-1', 'SIGNED'); + const patchCall = axios.calls.find(c => c.method === 'patch'); + assert.equal(patchCall.url, 'https://api.meeco.test/oidc/presentations/requests/REQ-1'); + const body = JSON.parse(patchCall.data); + assert.equal(body.presentation_request.signed_request_jwt, 'SIGNED'); + }); + }); + + describe('getVPSubmissions', () => { + it('GETs the submissions collection for a request', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('get', { data: { submissions: [] } }); + const out = await api().getVPSubmissions('REQ-2'); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://api.meeco.test/oidc/presentations/requests/REQ-2/submissions'); + assert.deepEqual(out, { submissions: [] }); + }); + }); + + describe('verifyVP', () => { + it('returns true when the verify response status starts with 20', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('post', { data: {}, status: 200 }); + const out = await api().verifyVP('IDTOK', 'REQ-3', 'VPTOK'); + assert.equal(out, true); + const verifyCall = axios.calls.filter(c => c.method === 'post')[1]; + assert.equal(verifyCall.url, 'https://api.meeco.test/oidc/presentations/response/verify'); + const body = JSON.parse(verifyCall.data); + assert.equal(body.presentation_request_response.id_token, 'IDTOK'); + assert.equal(body.presentation_request_response.vp_token, 'VPTOK'); + assert.equal( + body.presentation_request_response.request_uri, + 'https://api.meeco.test/oidc/presentations/requests/REQ-3/jwt' + ); + }); + + it('returns false when status does not start with 20', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('post', { data: {}, status: 404 }); + const out = await api().verifyVP('IDTOK', 'REQ-3', 'VPTOK'); + assert.equal(out, false); + }); + + it('rethrows the meeco reason on verification error', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + const err = new Error('orig'); + err.response = { data: { errors: [{ extra_info: { reason: 'bad signature' } }] } }; + axios.queue('post', err); + await assert.rejects(() => api().verifyVP('I', 'R', 'V'), /bad signature/); + }); + }); + + describe('approveVPSubmission', () => { + it('sets status verified when verified is true', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('patch', { data: { ok: true } }); + await api().approveVPSubmission('REQ-4', 'SUB-1', true); + const patchCall = axios.calls.find(c => c.method === 'patch'); + assert.equal(patchCall.url, 'https://api.meeco.test/oidc/presentations/requests/REQ-4/submissions/SUB-1'); + const body = JSON.parse(patchCall.data); + assert.equal(body.submission.status, 'verified'); + }); + + it('sets status rejected when verified is false', async () => { + axios.queue('post', { data: { token_type: 'Bearer', access_token: 'AT' } }); + axios.queue('patch', { data: { ok: true } }); + await api().approveVPSubmission('REQ-4', 'SUB-1', false); + const patchCall = axios.calls.find(c => c.method === 'patch'); + const body = JSON.parse(patchCall.data); + assert.equal(body.submission.status, 'rejected'); + }); + }); + + describe('getVCStatusList', () => { + it('GETs the url directly (no auth) and decodes the jwt body', async () => { + axios.queue('get', { data: 'JWT.TOKEN' }); + const out = await api().getVCStatusList('https://status.test/list'); + const getCall = axios.calls.find(c => c.method === 'get'); + assert.equal(getCall.url, 'https://status.test/list'); + assert.equal(getCall.config, undefined); + assert.deepEqual(jwtDecodeCalls, ['JWT.TOKEN']); + assert.deepEqual(out, { decoded: 'JWT.TOKEN' }); + }); + }); +}); diff --git a/auth-service/tests/meeco-service.test.mjs b/auth-service/tests/meeco-service.test.mjs new file mode 100644 index 0000000000..7884b9388b --- /dev/null +++ b/auth-service/tests/meeco-service.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +describe('MeecoService', function () { + this.timeout(60000); + + let MeecoService; + let lastApi; + let lastCryppo; + let jwtDecodeQueue; + let statusListStatus; + let statusListDecodeArg; + + before(async () => { + jwtDecodeQueue = []; + const mod = await esmock('../dist/meeco/meeco.service.js', { + '../dist/meeco/meeco-api.js': { + MeecoApi: class { + constructor(config) { + this.config = config; + this.calls = []; + lastApi = this; + } + async getMe() { return this._me ?? { user: { did: 'did:example:abc', private_dek_external_id: 'DEK-EXT' } }; } + async getPassphraseArtefact() { + return { passphrase_derivation_artefact: { derivation_artefacts: 'DA', verification_artefacts: 'VA' } }; + } + async getKeyEncryptionKey() { return { key_encryption_key: { serialized_key_encryption_key: 'SER-KEK' } }; } + async getDataEncryptionKey(id) { this.calls.push(['getDataEncryptionKey', id]); return { data_encryption_key: { serialized_data_encryption_key: 'SER-DEK' } }; } + async getKeyPairs(id) { this.calls.push(['getKeyPairs', id]); return { keypair: { encrypted_serialized_key: 'SER-KP' } }; } + async getSchema(id) { this.calls.push(['getSchema', id]); return { schema: id }; } + async getSchemas() { return [{ id: 1 }]; } + async createSchema(name, data) { this.calls.push(['createSchema', name, data]); return { created: name }; } + async createPresentationRequest(...a) { this.calls.push(['createPresentationRequest', ...a]); return { id: 'pr' }; } + async submitPresentationRequestSignature(...a) { this.calls.push(['submit', ...a]); return { id: 'signed' }; } + async getVPSubmissions(id) { this.calls.push(['getVPSubmissions', id]); return { submissions: [] }; } + async verifyVP(...a) { this.calls.push(['verifyVP', ...a]); return true; } + async approveVPSubmission(...a) { this.calls.push(['approve', ...a]); return { ok: true }; } + async getVCStatusList(url) { this.calls.push(['getVCStatusList', url]); return this._statusList; } + }, + }, + '../dist/meeco/cryppo.js': { + Cryppo: class { + constructor(p) { this.passphrase = p; lastCryppo = this; this.calls = []; } + async deriveMEK(da, va) { this.calls.push(['deriveMEK', da, va]); return { key: { key: { id: 'mek' } } }; } + async decryptKey(key, data) { this.calls.push(['decryptKey', key, data]); return { key: { bytes: Buffer.alloc(32) }, serializedKey: data }; } + }, + }, + jsonwebtoken: { + decode: (token) => { + jwtDecodeQueue.push(token); + if (token === 'VP') { return { vp: { verifiableCredential: ['VC-JWT'] } }; } + if (token === 'VC-JWT') { return { id: 'vc-decoded' }; } + return { decoded: token }; + }, + }, + base64url: { default: { encode: (b) => `b64:${Buffer.from(b).length}` } }, + tweetnacl: { + default: { + sign: { + keyPair: { fromSeed: (seed) => ({ secretKey: new Uint8Array(64), seedLen: seed.length }) }, + detached: () => new Uint8Array([1, 2, 3]), + }, + }, + }, + '../dist/helpers/credentials-validation/status-list.js': { + StatusList: { + decode: async (arg) => { + statusListDecodeArg = arg; + return { getStatus: () => statusListStatus }; + }, + }, + }, + '@guardian/common': {}, + }); + MeecoService = mod.MeecoService; + }); + + function service(config) { + return new MeecoService(config ?? { baseUrl: 'https://api.meeco.test' }, 'PASSPHRASE32'); + } + + it('exports a constructible class (link sanity)', () => { + assert.equal(typeof MeecoService, 'function'); + assert.equal(typeof service().decodeVPToken, 'function'); + }); + + it('freezes its config', () => { + const svc = service({ baseUrl: 'https://x' }); + assert.throws(() => { svc.config.baseUrl = 'y'; }); + }); + + describe('getVPSubmissionRedirectUri', () => { + it('builds an openid-vc deeplink with the request_uri', async () => { + const svc = service({ baseUrl: 'https://api.meeco.test' }); + const out = await svc.getVPSubmissionRedirectUri('REQ-1'); + assert.equal( + out, + 'openid-vc://?request_uri=https://api.meeco.test/oidc/presentations/requests/REQ-1/jwt' + ); + }); + }); + + describe('decodeVPToken', () => { + it('decodes the vp token then the first verifiable credential', () => { + jwtDecodeQueue.length = 0; + const svc = service(); + const out = svc.decodeVPToken('VP'); + assert.deepEqual(out, { id: 'vc-decoded' }); + assert.deepEqual(jwtDecodeQueue, ['VP', 'VC-JWT']); + }); + }); + + describe('createSchema', () => { + it('parses the schema string then forwards to the api', async () => { + const svc = service(); + const out = await svc.createSchema('S1', '{"a":1}'); + const call = svc.meecoApi.calls.find(c => c[0] === 'createSchema'); + assert.equal(call[1], 'S1'); + assert.deepEqual(call[2], { a: 1 }); + assert.deepEqual(out, { created: 'S1' }); + }); + + it('rejects on invalid JSON', async () => { + const svc = service(); + await assert.rejects(() => svc.createSchema('S', 'not-json')); + }); + }); + + describe('getMEK', () => { + it('derives the MEK from passphrase artefacts', async () => { + const svc = service(); + const out = await svc.getMEK(); + assert.deepEqual(out.key.key, { id: 'mek' }); + const cryppoCall = svc.cryppo.calls.find(c => c[0] === 'deriveMEK'); + assert.deepEqual(cryppoCall.slice(1), ['DA', 'VA']); + }); + }); + + describe('getKEK', () => { + it('decrypts the serialized KEK with the MEK key', async () => { + const svc = service(); + const out = await svc.getKEK(); + assert.equal(out.serializedKey, 'SER-KEK'); + const decryptCall = svc.cryppo.calls.find(c => c[0] === 'decryptKey'); + assert.deepEqual(decryptCall[1], { id: 'mek' }); + }); + }); + + describe('getDEK', () => { + it('uses the private_dek_external_id from /me', async () => { + const svc = service(); + const out = await svc.getDEK(); + const dekCall = svc.meecoApi.calls.find(c => c[0] === 'getDataEncryptionKey'); + assert.equal(dekCall[1], 'DEK-EXT'); + assert.equal(out.serializedKey, 'SER-DEK'); + }); + }); + + describe('getKeyPair', () => { + it('derives the external id as the hex of the did', async () => { + const svc = service(); + await svc.getKeyPair(); + const kpCall = svc.meecoApi.calls.find(c => c[0] === 'getKeyPairs'); + assert.equal(kpCall[1], Buffer.from('did:example:abc').toString('hex')); + }); + }); + + describe('signPresentationRequestToken', () => { + it('appends a base64url signature to the unsigned jwt and submits it', async () => { + const svc = service(); + await svc.signPresentationRequestToken('REQ-9', 'UNSIGNED'); + const submitCall = svc.meecoApi.calls.find(c => c[0] === 'submit'); + assert.equal(submitCall[1], 'REQ-9'); + assert.ok(submitCall[2].startsWith('UNSIGNED.b64:')); + }); + }); + + describe('delegating methods forward to the api', () => { + it('getMe / getSchemas / getVPSubmissions / verifyVP / approveVPSubmission', async () => { + const svc = service(); + assert.deepEqual(await svc.getSchemas(), [{ id: 1 }]); + assert.deepEqual(await svc.getVPSubmissions('R'), { submissions: [] }); + assert.equal(await svc.verifyVP('id', 'r', 'vp'), true); + assert.deepEqual(await svc.approveVPSubmission('r', 's', true), { ok: true }); + assert.deepEqual(await svc.createPresentationRequest('n', 'd', 'c', 'p'), { id: 'pr' }); + }); + + it('getSchema passes the schema id through', async () => { + const svc = service(); + const out = await svc.getSchema('SCH-1'); + assert.deepEqual(out, { schema: 'SCH-1' }); + }); + }); + + describe('validateCredentials', () => { + const vc = { credentialStatus: { statusListCredential: 'https://status/list', statusListIndex: 3 } }; + + it('returns failure when no encoded list is present', async () => { + const svc = service(); + svc.meecoApi._statusList = { vc: { credentialSubject: {} } }; + const out = await svc.validateCredentials(vc); + assert.equal(out.success, false); + assert.match(out.message, /No encoded list/); + }); + + it('returns revoked failure when getStatus is false', async () => { + const svc = service(); + svc.meecoApi._statusList = { vc: { credentialSubject: { encodedList: 'EL' } } }; + statusListStatus = false; + const out = await svc.validateCredentials(vc); + assert.equal(out.success, false); + assert.match(out.message, /revoked/); + assert.deepEqual(statusListDecodeArg, { encodedList: 'EL' }); + }); + + it('returns success when the status bit is set (not revoked)', async () => { + const svc = service(); + svc.meecoApi._statusList = { vc: { credentialSubject: { encodedList: 'EL' } } }; + statusListStatus = true; + const out = await svc.validateCredentials(vc); + assert.equal(out.success, true); + assert.match(out.message, /Valid credentials/); + }); + }); +}); diff --git a/auth-service/tests/mongo-constants.test.mjs b/auth-service/tests/mongo-constants.test.mjs new file mode 100644 index 0000000000..81a184e40f --- /dev/null +++ b/auth-service/tests/mongo-constants.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { DEFAULT } from '../dist/constants/mongo.js'; + +describe('auth-service mongo defaults', () => { + it('exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); + it('values parse as positive numbers', () => { + assert.ok(Number(DEFAULT.MIN_POOL_SIZE) > 0); + assert.ok(Number(DEFAULT.MAX_POOL_SIZE) >= Number(DEFAULT.MIN_POOL_SIZE)); + assert.ok(Number(DEFAULT.MAX_IDLE_TIME_MS) > 0); + }); +}); diff --git a/auth-service/tests/password-constants.test.mjs b/auth-service/tests/password-constants.test.mjs new file mode 100644 index 0000000000..21536330fa --- /dev/null +++ b/auth-service/tests/password-constants.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { PasswordComplexityEnum, minPasswordLength, passwordComplexity, PasswordError } from '../dist/constants/password.js'; + +describe('auth-service password constants', () => { + it('PasswordComplexityEnum exposes easy/medium/hard', () => { + assert.equal(PasswordComplexityEnum.EASY, 'easy'); + assert.equal(PasswordComplexityEnum.MEDIUM, 'medium'); + assert.equal(PasswordComplexityEnum.HARD, 'hard'); + }); + it('minPasswordLength is at least 1 and defaults to 8 when env is unset', () => { + assert.ok(minPasswordLength >= 1); + // Default branch (no MIN_PASSWORD_LENGTH env) yields 8 + if (!process.env.MIN_PASSWORD_LENGTH) { + assert.equal(minPasswordLength, 8); + } + }); + it('passwordComplexity defaults to MEDIUM when env is unset', () => { + if (!process.env.PASSWORD_COMPLEXITY) { + assert.equal(passwordComplexity, PasswordComplexityEnum.MEDIUM); + } + }); + it('PasswordError exposes a tailored message per complexity tier', () => { + for (const tier of [PasswordComplexityEnum.EASY, PasswordComplexityEnum.MEDIUM, PasswordComplexityEnum.HARD]) { + assert.equal(typeof PasswordError[tier], 'string'); + assert.ok(PasswordError[tier].includes(String(minPasswordLength))); + } + assert.ok(PasswordError[PasswordComplexityEnum.HARD].length > PasswordError[PasswordComplexityEnum.EASY].length); + }); +}); diff --git a/auth-service/tests/relayer-accounts-handlers.test.mjs b/auth-service/tests/relayer-accounts-handlers.test.mjs new file mode 100644 index 0000000000..6cd198d2f9 --- /dev/null +++ b/auth-service/tests/relayer-accounts-handlers.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { loadService, capturedHandlers, stubs, StubMessageError, StubMessageResponse } from './_handler-harness.mjs'; + +const HANDLER_TIMEOUT_MS = 200; +async function callWithTimeout(cb, msg) { + return await Promise.race([ + Promise.resolve().then(() => cb(msg)), + new Promise((_, reject) => setTimeout(() => reject(new Error('handler timeout')), HANDLER_TIMEOUT_MS)), + ]); +} + +let RelayerAccountsService; +try { + ({ RelayerAccountsService } = await loadService('../dist/api/relayer-accounts.js')); +} catch (e) { + console.warn('[relayer-accounts-handlers.test] dist import failed:', e.message); +} + +const handlers = (() => { + if (!RelayerAccountsService) return []; + try { + const svc = new RelayerAccountsService(); + const logger = { async error() {}, async info() {}, async warn() {}, async debug() {} }; + const start = capturedHandlers.length; + svc.registerListeners(logger); + return capturedHandlers.slice(start); + } catch (err) { + console.warn('[relayer-accounts-handlers.test] registerListeners failed:', err.message); + return []; + } +})(); + +describe('@unit relayer-accounts NATS handler envelope coverage', () => { + it('registers handlers', () => { + if (!RelayerAccountsService) { console.warn(' [skip] dist not available'); return; } + assert.ok(handlers.length > 0); + }); + + it('every handler returns an envelope on shaped input', async function () { + if (!handlers.length) return; + this.timeout(HANDLER_TIMEOUT_MS * handlers.length + 5000); + const msg = { + tenantId: 't-1', + user: { ...stubs.nextUser, did: 'did:hedera:0.0.1', id: 'u-1', hederaAccountId: '0.0.1' }, + account: '0.0.1', + relayerAccount: '0.0.2', + did: 'did:hedera:0.0.1', + filters: { search: '', pageIndex: 0, pageSize: 50 }, + config: { name: 'rel-1', account: '0.0.99', key: 'priv' }, + userId: 'u-1', + }; + let exceptions = 0; + for (const { cb } of handlers) { + try { await callWithTimeout(cb, msg); } catch { exceptions++; } + } + assert.ok(exceptions < handlers.length * 0.9); + }); + + it('handlers do not throw on empty input', async function () { + if (!handlers.length) return; + this.timeout(HANDLER_TIMEOUT_MS * handlers.length + 5000); + let exceptions = 0; + for (const { cb } of handlers) { + try { await callWithTimeout(cb, {}); } catch { exceptions++; } + } + assert.ok(exceptions < handlers.length); + }); +}); diff --git a/auth-service/tests/role-service-handlers.test.mjs b/auth-service/tests/role-service-handlers.test.mjs new file mode 100644 index 0000000000..0fd9d1e7d0 --- /dev/null +++ b/auth-service/tests/role-service-handlers.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { loadService, capturedHandlers, stubs, StubMessageError, StubMessageResponse } from './_handler-harness.mjs'; + +let RoleService; +try { + ({ RoleService } = await loadService('../dist/api/role-service.js')); +} catch (e) { + console.warn('[role-service-handlers.test] dist import failed:', e.message); +} + +const handlers = (() => { + if (!RoleService) return []; + try { + const svc = new RoleService(); + const logger = { async error() {}, async info() {}, async warn() {}, async debug() {} }; + const start = capturedHandlers.length; + svc.registerListeners(logger); + return capturedHandlers.slice(start); + } catch (err) { + console.warn('[role-service-handlers.test] registerListeners failed:', err.message); + return []; + } +})(); + +describe('@unit role-service NATS handler envelope coverage', () => { + it('registers handlers', () => { + if (!RoleService) { console.warn(' [skip] RoleService dist not available'); return; } + assert.ok(handlers.length > 0); + }); + + it('every handler returns an envelope on empty input', async () => { + if (!handlers.length) return; + let envelopes = 0, exceptions = 0; + for (const { cb } of handlers) { + try { + const result = await cb({}); + if (result === undefined || + result instanceof StubMessageResponse || + result instanceof StubMessageError || + (result && (result.type === 'response' || result.type === 'error'))) { + envelopes++; + } + } catch { exceptions++; } + } + assert.ok(exceptions < handlers.length, `${exceptions}/${handlers.length} handlers threw on empty input`); + assert.ok(envelopes > 0); + }); + + it('every handler returns an envelope on shaped input', async () => { + if (!handlers.length) return; + const msg = { + tenantId: 't-1', + userId: 'u-1', + id: 'r-1', + username: 'alice', + user: stubs.nextUser, + owner: { creator: 'u-1', owner: 'u-1' }, + role: stubs.nextRole, + userRoles: ['r-1'], + name: 'role-search', + organization: 'org-1', + permissionsMap: [], + orgName: 'org-1', + pageIndex: 0, + pageSize: 50, + }; + let exceptions = 0; + for (const { cb } of handlers) { + try { await cb(msg); } catch { exceptions++; } + } + assert.ok(exceptions < handlers.length * 0.7, `too many handlers threw: ${exceptions}/${handlers.length}`); + }); +}); diff --git a/auth-service/tests/user-password-branches.test.mjs b/auth-service/tests/user-password-branches.test.mjs new file mode 100644 index 0000000000..ba93fb795c --- /dev/null +++ b/auth-service/tests/user-password-branches.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const PasswordComplexityEnum = { EASY: 'easy', MEDIUM: 'medium', HARD: 'hard' }; + +async function loadWithComplexity(complexity, minLen = 8) { + return esmock('../dist/utils/user-password.js', { + '#constants': { + PasswordComplexityEnum, + minPasswordLength: minLen, + passwordComplexity: complexity, + }, + }); +} + +async function loadWithBrokenPbkdf2() { + return esmock('../dist/utils/user-password.js', { + crypto: { + randomBytes: () => Buffer.from('00'.repeat(16), 'hex'), + createHash: () => ({ update: () => ({ digest: () => 'x' }) }), + pbkdf2: (_pw, _salt, _it, _len, _alg, cb) => cb(new Error('pbkdf2-fail')), + }, + }); +} + +describe('UserPassword.validatePassword — complexity tiers', function () { + this.timeout(60000); + + it('HARD requires a special character (line 120-121, 127-128)', async () => { + const { UserPassword } = await loadWithComplexity(PasswordComplexityEnum.HARD); + assert.equal(UserPassword.validatePassword('Abcdefgh1234'), false); + assert.equal(UserPassword.validatePassword('Abcdefgh123!'), true); + }); + + it('EASY only enforces length (line 122-124, returns true at 131)', async () => { + const { UserPassword } = await loadWithComplexity(PasswordComplexityEnum.EASY); + assert.equal(UserPassword.validatePassword('alllowercase'), true); + assert.equal(UserPassword.validatePassword('short'), false); + }); + + it('unknown complexity falls through default to length-only (line 124)', async () => { + const { UserPassword } = await loadWithComplexity('something-else'); + assert.equal(UserPassword.validatePassword('alllowercase'), true); + }); +}); + +describe('UserPassword V2 pbkdf2 error propagation', function () { + this.timeout(60000); + + it('generatePasswordV2 rejects when pbkdf2 errors (line 54-55)', async () => { + const { UserPassword } = await loadWithBrokenPbkdf2(); + await assert.rejects(() => UserPassword.generatePasswordV2('pw'), /pbkdf2-fail/); + }); + + it('verifyPasswordV2 rejects when pbkdf2 errors (line 84-85)', async () => { + const { UserPassword } = await loadWithBrokenPbkdf2(); + await assert.rejects( + () => UserPassword.verifyPasswordV2({ password: 'h', salt: 's', passwordVersion: 'v2' }, 'pw'), + /pbkdf2-fail/, + ); + }); + + it('verifyPasswordV2 resolves false when record is missing (line 72-74)', async () => { + const { UserPassword } = await loadWithBrokenPbkdf2(); + assert.equal(await UserPassword.verifyPasswordV2(null, 'pw'), false); + }); +}); diff --git a/auth-service/tests/user-password-validate.test.mjs b/auth-service/tests/user-password-validate.test.mjs new file mode 100644 index 0000000000..fe3a81f0e6 --- /dev/null +++ b/auth-service/tests/user-password-validate.test.mjs @@ -0,0 +1,54 @@ +import { assert } from 'chai'; +import { UserPassword } from '../dist/utils/user-password.js'; +import { minPasswordLength, passwordComplexity, PasswordComplexityEnum } from '../dist/constants/password.js'; + +describe('@unit UserPassword.validatePassword — length gate', () => { + it('rejects an empty password', () => { + assert.equal(UserPassword.validatePassword(''), false); + }); + + it('rejects a password one char below the minimum', () => { + const pw = 'Aa1' + 'a'.repeat(Math.max(0, minPasswordLength - 4)); + if (pw.length < minPasswordLength) { + assert.equal(UserPassword.validatePassword(pw), false); + } + }); + + it('a sufficiently long, fully-complex password is accepted at any tier', () => { + const pw = 'Aa1!'.repeat(Math.ceil(minPasswordLength / 4) + 1); + assert.equal(UserPassword.validatePassword(pw), true); + }); +}); + +describe('@unit UserPassword.validatePassword — default (MEDIUM) complexity behaviour', () => { + const isMedium = passwordComplexity === PasswordComplexityEnum.MEDIUM; + + it('returns a boolean for any input', () => { + assert.equal(typeof UserPassword.validatePassword('whatever-long-enough-1A'), 'boolean'); + }); + + it('rejects long all-lowercase password under MEDIUM', function () { + if (!isMedium) return this.skip(); + assert.equal(UserPassword.validatePassword('abcdefghijkl'), false); + }); + + it('rejects long password missing a digit under MEDIUM', function () { + if (!isMedium) return this.skip(); + assert.equal(UserPassword.validatePassword('AbcdefghIJKL'), false); + }); + + it('rejects long password missing an uppercase under MEDIUM', function () { + if (!isMedium) return this.skip(); + assert.equal(UserPassword.validatePassword('abcdefgh1234'), false); + }); + + it('accepts a password with lower+upper+digit under MEDIUM (no symbol required)', function () { + if (!isMedium) return this.skip(); + assert.equal(UserPassword.validatePassword('Abcdefgh1234'), true); + }); + + it('accepts a password that also has a symbol under MEDIUM', function () { + if (!isMedium) return this.skip(); + assert.equal(UserPassword.validatePassword('Abcdefgh123!'), true); + }); +}); diff --git a/auth-service/tests/user-password.test.mjs b/auth-service/tests/user-password.test.mjs new file mode 100644 index 0000000000..df68e8c688 --- /dev/null +++ b/auth-service/tests/user-password.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { UserPassword, PasswordType } from '../dist/utils/user-password.js'; + +describe('UserPassword.generatePasswordV1 / verifyPasswordV1', () => { + it('hashes deterministically (sha256, no salt)', async () => { + const a = await UserPassword.generatePasswordV1('hunter2'); + const b = await UserPassword.generatePasswordV1('hunter2'); + assert.equal(a.password, b.password); + assert.equal(a.salt, null); + assert.equal(a.passwordVersion, PasswordType.V1); + }); + + it('verifyPasswordV1 succeeds for the matching plaintext', async () => { + const stored = await UserPassword.generatePasswordV1('correct-horse'); + assert.equal(await UserPassword.verifyPasswordV1(stored, 'correct-horse'), true); + }); + + it('verifyPasswordV1 fails for the wrong plaintext', async () => { + const stored = await UserPassword.generatePasswordV1('correct-horse'); + assert.equal(await UserPassword.verifyPasswordV1(stored, 'battery-staple'), false); + }); + + it('verifyPasswordV1 returns false when the stored record is missing', async () => { + assert.equal(await UserPassword.verifyPasswordV1(null, 'anything'), false); + assert.equal(await UserPassword.verifyPasswordV1(undefined, 'anything'), false); + }); +}); + +describe('UserPassword.generatePasswordV2 / verifyPasswordV2', function () { + this.timeout(20000); + + it('produces a unique salt each call', async () => { + const a = await UserPassword.generatePasswordV2('hunter2'); + const b = await UserPassword.generatePasswordV2('hunter2'); + assert.notEqual(a.salt, b.salt); + assert.notEqual(a.password, b.password); + assert.equal(a.passwordVersion, PasswordType.V2); + }); + + it('verifyPasswordV2 succeeds for the matching plaintext', async () => { + const stored = await UserPassword.generatePasswordV2('s3cret-pw'); + assert.equal(await UserPassword.verifyPasswordV2(stored, 's3cret-pw'), true); + }); + + it('verifyPasswordV2 fails for the wrong plaintext', async () => { + const stored = await UserPassword.generatePasswordV2('s3cret-pw'); + assert.equal(await UserPassword.verifyPasswordV2(stored, 'wrong-pw'), false); + }); + + it('verifyPasswordV2 returns false when the stored record is missing', async () => { + assert.equal(await UserPassword.verifyPasswordV2(null, 'anything'), false); + }); +}); + +describe('UserPassword.verifyPassword (version dispatch)', function () { + this.timeout(20000); + + it('delegates V2 records to the pbkdf2 verifier', async () => { + const v2 = await UserPassword.generatePasswordV2('pw-v2'); + assert.equal(await UserPassword.verifyPassword(v2, 'pw-v2'), true); + assert.equal(await UserPassword.verifyPassword(v2, 'nope'), false); + }); + + it('delegates V1 records (and unspecified versions) to the sha256 verifier', async () => { + const v1 = await UserPassword.generatePasswordV1('pw-v1'); + assert.equal(await UserPassword.verifyPassword(v1, 'pw-v1'), true); + assert.equal(await UserPassword.verifyPassword(v1, 'nope'), false); + }); + + it('returns false when no stored password is provided', async () => { + assert.equal(await UserPassword.verifyPassword(null, 'anything'), false); + }); +}); + +describe('UserPassword.validatePassword', () => { + // Note: complexity rules are read at module load from environment-driven + // constants. Without overriding env, we exercise the always-applicable + // length check and the pattern-pass case for a strong password. + + it('rejects passwords shorter than the configured minimum', () => { + assert.equal(UserPassword.validatePassword('a'), false); + }); + + it('accepts a strong password that satisfies any complexity tier', () => { + // 12+ chars, lower+upper+digit+symbol — passes EASY/MEDIUM/HARD. + assert.equal(UserPassword.validatePassword('Aa1!Aa1!Aa1!'), true); + }); +}); diff --git a/auth-service/tests/validate-config-jwt-tokens.test.mjs b/auth-service/tests/validate-config-jwt-tokens.test.mjs new file mode 100644 index 0000000000..83bb589ebb --- /dev/null +++ b/auth-service/tests/validate-config-jwt-tokens.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { checkValidJwt } from '../dist/utils/validate-config-jwt-tokens.js'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +const otherPair = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +describe('@unit checkValidJwt', () => { + let originalConsoleError; + before(() => { + originalConsoleError = console.error; + console.error = () => {}; + }); + after(() => { console.error = originalConsoleError; }); + + describe('input validation', () => { + it('returns false when private key is empty', () => { + assert.equal(checkValidJwt(publicKey, ''), false); + }); + + it('returns false when public key is empty', () => { + assert.equal(checkValidJwt('', privateKey), false); + }); + + it('returns false when both keys are empty', () => { + assert.equal(checkValidJwt('', ''), false); + }); + + it('returns false when keys are too short (< 8 chars)', () => { + assert.equal(checkValidJwt('abc', 'def'), false); + }); + + it('returns false when private key is whitespace only', () => { + assert.equal(checkValidJwt(publicKey, ' '), false); + }); + + it('returns false when public key is whitespace only', () => { + assert.equal(checkValidJwt(' ', privateKey), false); + }); + + it('returns false when private key is null', () => { + assert.equal(checkValidJwt(publicKey, null), false); + }); + + it('returns false when public key is undefined', () => { + assert.equal(checkValidJwt(undefined, privateKey), false); + }); + }); + + describe('PEM format check', () => { + it('returns false for non-PEM strings of sufficient length', () => { + const fake = 'a'.repeat(64); + assert.equal(checkValidJwt(fake, fake), false); + }); + + it('returns false for almost-PEM (missing dashes)', () => { + const fake = 'BEGIN RSA PUBLIC KEY ... END RSA PUBLIC KEY'; + assert.equal(checkValidJwt(fake, fake), false); + }); + + it('returns false for almost-PEM (missing END)', () => { + const fake = '-----BEGIN RSA PUBLIC KEY-----\nbase64body\n'; + assert.equal(checkValidJwt(fake, fake), false); + }); + + it('returns false when private key looks like PEM but public does not', () => { + assert.equal(checkValidJwt('not pem at all but long enough', privateKey), false); + }); + }); + + describe('cryptographic round-trip', () => { + it('returns true for a valid RS256 key pair', () => { + assert.equal(checkValidJwt(publicKey, privateKey), true); + }); + + it('returns false when public and private keys are from different pairs', () => { + assert.equal(checkValidJwt(otherPair.publicKey, privateKey), false); + }); + + it('returns false when the private key body is corrupted', () => { + // Corrupt the entire base64 body (preserving the PEM header/footer so + // looksLikePem still passes). A single mangled line in a PKCS8 RSA key + // is NOT a reliable corruption: the redundant CRT parameters let + // OpenSSL recover a usable signing key, so the round-trip can still + // verify. Damaging the whole body forces a genuine decode failure. + const lines = privateKey.split('\n'); + for (let i = 1; i < lines.length - 2; i++) { + if (lines[i].length > 0) lines[i] = 'A'.repeat(lines[i].length); + } + const corrupted = lines.join('\n'); + assert.equal(checkValidJwt(publicKey, corrupted), false); + }); + + it('returns false when the public key is corrupted in the middle', () => { + const lines = publicKey.split('\n'); + const mid = Math.floor(lines.length / 2); + lines[mid] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const corrupted = lines.join('\n'); + assert.equal(checkValidJwt(corrupted, privateKey), false); + }); + + it('does not throw on any input — always returns boolean', () => { + const cases = [ + [null, null], + [undefined, undefined], + ['', ''], + ['x', 'y'], + [publicKey, 'garbage'], + ['garbage', privateKey], + [Buffer.from('not-a-string'), privateKey], + ]; + for (const [pub, priv] of cases) { + let result; + assert.doesNotThrow(() => { result = checkValidJwt(pub, priv); }); + assert.equal(typeof result, 'boolean'); + } + }); + }); +}); diff --git a/common/tests/unit-tests/base-integration/base-integration.test.mjs b/common/tests/unit-tests/base-integration/base-integration.test.mjs new file mode 100644 index 0000000000..9a194747eb --- /dev/null +++ b/common/tests/unit-tests/base-integration/base-integration.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { BaseIntegrationService } from '../../../dist/integrations/base-integration-service.js'; + +const method = (overrides = {}) => ({ + method: 'GET', + endpoint: '/items', + description: 'list items', + ...overrides, +}); + +describe('BaseIntegrationService.getBaseUrl / getAvailableMethods', () => { + it('returns "" by default for getBaseUrl', () => { + assert.equal(BaseIntegrationService.getBaseUrl(), ''); + }); + + it('returns {} by default for getAvailableMethods', () => { + assert.deepEqual(BaseIntegrationService.getAvailableMethods(), {}); + }); +}); + +describe('BaseIntegrationService.getDataForRequest — endpoint substitution', () => { + it('throws when method is null', () => { + assert.throws(() => BaseIntegrationService.getDataForRequest(null), /Unsupported method/); + }); + + it('substitutes :name path placeholders from params', () => { + const out = BaseIntegrationService.getDataForRequest( + method({ endpoint: '/items/:id/status/:state' }), + { id: 'abc', state: 'open' } + ); + assert.equal(out.url, '/items/abc/status/open'); + }); + + it('URL-encodes path values', () => { + const out = BaseIntegrationService.getDataForRequest( + method({ endpoint: '/items/:name' }), + { name: 'a b/c' } + ); + assert.equal(out.url, '/items/a%20b%2Fc'); + }); + + it('throws when a required path placeholder is missing', () => { + const m = method({ + endpoint: '/items/:id', + parameters: { path: { id: { required: true, name: 'id', value: '' } } }, + }); + assert.throws( + () => BaseIntegrationService.getDataForRequest(m, {}), + /Missing required path parameter: "id"/, + ); + }); + + it('omits a missing optional path placeholder (replaces with "")', () => { + const m = method({ endpoint: '/items/:id' }); + const out = BaseIntegrationService.getDataForRequest(m, {}); + // After encoding empty + collapsing the trailing dup slash, the URL is "/items/". + assert.equal(out.url, '/items/'); + }); + + it('skips substitution for the named paramNameForSkipReplace', () => { + const out = BaseIntegrationService.getDataForRequest( + method({ endpoint: '/items/:id/:placeholder' }), + { id: 'abc' }, + false, + 'placeholder', + ); + assert.equal(out.url, '/items/abc/:placeholder'); + }); + + it('collapses // to / in the rendered endpoint', () => { + const out = BaseIntegrationService.getDataForRequest( + method({ endpoint: '/items//:id' }), + { id: 'abc' } + ); + assert.equal(out.url.includes('//'), false); + }); +}); + +describe('BaseIntegrationService.getDataForRequest — query params', () => { + it('forwards optional query params from the input', () => { + const m = method({ + parameters: { query: { sort: { name: 'sort', value: '' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, { sort: 'desc' }); + assert.equal(out.params.sort, 'desc'); + }); + + it('skips query params that are not provided', () => { + const m = method({ + parameters: { query: { sort: { name: 'sort', value: '' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, {}); + assert.equal(out.params.sort, undefined); + }); + + it('throws when a required query param is missing', () => { + const m = method({ + parameters: { query: { sort: { name: 'sort', value: '', required: true } } }, + }); + assert.throws( + () => BaseIntegrationService.getDataForRequest(m, {}), + /Missing required path parameter: "sort"/, + ); + }); + + it('merges additionalParams into the params object', () => { + const m = method(); + const out = BaseIntegrationService.getDataForRequest(m, {}, false, '', { traceId: 't-1' }); + assert.equal(out.params.traceId, 't-1'); + }); +}); + +describe('BaseIntegrationService.getDataForRequest — body params', () => { + const POST = (overrides = {}) => method({ method: 'POST', endpoint: '/items', ...overrides }); + + it('passes string body fields through', () => { + const m = POST({ + parameters: { body: { name: { name: 'name', value: '' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, { name: 'x' }); + assert.deepEqual(out.data, { name: 'x' }); + }); + + it('parses NUMBER body fields with Number()', () => { + const m = POST({ + parameters: { body: { age: { name: 'age', value: '', parseType: 'NUMBER' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, { age: '42' }); + assert.equal(out.data.age, 42); + }); + + it('parses JSON body fields with JSON.parse()', () => { + const m = POST({ + parameters: { body: { meta: { name: 'meta', value: '', parseType: 'JSON' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, { meta: '{"k":1}' }); + assert.deepEqual(out.data.meta, { k: 1 }); + }); + + it('throws when a required body field is missing', () => { + const m = POST({ + parameters: { body: { name: { name: 'name', value: '', required: true } } }, + }); + assert.throws( + () => BaseIntegrationService.getDataForRequest(m, {}), + /Missing required bodyField parameter: "name"/, + ); + }); + + it('omits the data field for GET requests', () => { + const m = method({ + method: 'GET', + parameters: { body: { name: { name: 'name', value: '' } } }, + }); + const out = BaseIntegrationService.getDataForRequest(m, { name: 'x' }); + assert.equal(out.data, undefined); + }); +}); + +describe('BaseIntegrationService.getDataForRequest — fullUrl', () => { + it('prepends the base URL when fullUrl=true and getBaseUrl returns a value', () => { + class Sub extends BaseIntegrationService { + static getBaseUrl() { return 'https://api.example.com'; } + } + const out = Sub.getDataForRequest(method({ endpoint: '/items' }), {}, true); + assert.equal(out.url, 'https://api.example.com/items'); + }); + + it('uses the relative endpoint when fullUrl=false', () => { + class Sub extends BaseIntegrationService { + static getBaseUrl() { return 'https://api.example.com'; } + } + const out = Sub.getDataForRequest(method({ endpoint: '/items' }), {}, false); + assert.equal(out.url, '/items'); + }); +}); diff --git a/common/tests/unit-tests/common-variables/common-variables.test.mjs b/common/tests/unit-tests/common-variables/common-variables.test.mjs new file mode 100644 index 0000000000..dce6c6a282 --- /dev/null +++ b/common/tests/unit-tests/common-variables/common-variables.test.mjs @@ -0,0 +1,52 @@ +import { assert } from 'chai'; +import { CommonVariables } from '../../../dist/helpers/common-variables.js'; + +describe('CommonVariables', () => { + it('round-trips a simple value', () => { + const v = new CommonVariables(); + v.setVariable('greeting', 'hello'); + assert.equal(v.getVariable('greeting'), 'hello'); + }); + + it('returns undefined for unknown keys', () => { + const v = new CommonVariables(); + assert.equal(v.getVariable('does-not-exist'), undefined); + }); + + it('overwrites a previously set value', () => { + const v = new CommonVariables(); + v.setVariable('k', 1); + v.setVariable('k', 2); + assert.equal(v.getVariable('k'), 2); + }); + + it('keeps separate keys independent', () => { + const v = new CommonVariables(); + v.setVariable('a', 1); + v.setVariable('b', 2); + assert.equal(v.getVariable('a'), 1); + assert.equal(v.getVariable('b'), 2); + }); + + it('stores objects/arrays/null intact', () => { + const v = new CommonVariables(); + const obj = { a: 1 }; + const arr = [1, 2]; + v.setVariable('obj', obj); + v.setVariable('arr', arr); + v.setVariable('nul', null); + assert.equal(v.getVariable('obj'), obj); + assert.equal(v.getVariable('arr'), arr); + assert.equal(v.getVariable('nul'), null); + }); + + it('is a Singleton — fresh instances share state', () => { + // The @Singleton decorator wraps the constructor so all `new` + // invocations return the same instance. + const a = new CommonVariables(); + const b = new CommonVariables(); + a.setVariable('shared', 'x'); + assert.equal(b.getVariable('shared'), 'x'); + assert.equal(a, b); + }); +}); diff --git a/common/tests/unit-tests/console-transport/console-transport-edge.test.mjs b/common/tests/unit-tests/console-transport/console-transport-edge.test.mjs new file mode 100644 index 0000000000..7d1826daeb --- /dev/null +++ b/common/tests/unit-tests/console-transport/console-transport-edge.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; +import { ConsoleTransport } from '../../../dist/helpers/console-transport.js'; + +const captureConsole = () => { + const calls = { info: [], warn: [], error: [], log: [] }; + const original = { + info: console.info, + warn: console.warn, + error: console.error, + log: console.log, + }; + console.info = (...args) => calls.info.push(args); + console.warn = (...args) => calls.warn.push(args); + console.error = (...args) => calls.error.push(args); + console.log = (...args) => calls.log.push(args); + const restore = () => { + console.info = original.info; + console.warn = original.warn; + console.error = original.error; + console.log = original.log; + }; + return { calls, restore }; +}; + +describe('@unit ConsoleTransport (edge)', () => { + it('throws synchronously from _write on malformed JSON', () => { + const t = new ConsoleTransport({}); + assert.throws(() => t._write(Buffer.from('not-json{'), 'utf8', () => {})); + }); + + it('does not invoke the callback when _write parsing fails', () => { + const t = new ConsoleTransport({}); + let called = false; + try { + t._write(Buffer.from('{bad'), 'utf8', () => { called = true; }); + } catch { + // expected + } + assert.equal(called, false); + }); + + it('renders an empty attributes array as an empty bracket prefix', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'm', attributes: [] }, () => { + cap.restore(); + assert.ok(cap.calls.info[0][0].includes('[]:')); + done(); + }); + }); + + it('passes through an undefined message unchanged', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', attributes: ['svc'] }, () => { + cap.restore(); + assert.equal(cap.calls.info.length, 1); + assert.equal(cap.calls.info[0][1], undefined); + done(); + }); + }); + + it('renders the literal "undefined" attribute join when attributes is missing', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'WARN', message: 'm' }, () => { + cap.restore(); + assert.ok(cap.calls.warn[0][0].includes('[undefined]:')); + done(); + }); + }); + + it('routes a lowercase "info" type to console.log (unknown level)', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'info', message: 'm', attributes: [] }, () => { + cap.restore(); + assert.equal(cap.calls.log.length, 1); + assert.equal(cap.calls.info.length, 0); + done(); + }); + }); + + it('routes a missing type to console.log (unknown level)', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ message: 'm', attributes: [] }, () => { + cap.restore(); + assert.equal(cap.calls.log.length, 1); + done(); + }); + }); + + it('preserves multi-line messages without altering line breaks', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + const multi = 'line-1\nline-2\nline-3'; + t.log({ type: 'ERROR', message: multi, attributes: ['svc'] }, () => { + cap.restore(); + assert.equal(cap.calls.error[0][1], multi); + done(); + }); + }); + + it('invokes the _write callback exactly once for a valid chunk', () => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + let count = 0; + const chunk = Buffer.from(JSON.stringify({ type: 'INFO', message: 'x', attributes: [] })); + t._write(chunk, 'utf8', () => { count += 1; }); + cap.restore(); + assert.equal(count, 1); + }); + + it('joins a single empty-string attribute into an empty token', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'm', attributes: [''] }, () => { + cap.restore(); + assert.ok(cap.calls.info[0][0].includes('[]:')); + done(); + }); + }); +}); + +const loadPinoTransport = async (spies) => { + const writes = []; + const opened = []; + const made = []; + const existing = spies.existing || new Set(); + + const fsStub = { + existsSync: (p) => existing.has(p), + mkdirSync: (p) => { made.push(p); }, + openSync: (p) => { opened.push(p); return 1; }, + }; + const pinoStub = { + default: { + destination: () => ({ + write: (line) => { writes.push(line); }, + }), + }, + }; + + const { PinoFileTransport } = await esmock( + '../../../dist/helpers/pino-file-transport.js', + { + fs: fsStub, + pino: pinoStub, + }, + ); + return { PinoFileTransport, writes, opened, made }; +}; + +describe('@unit PinoFileTransport (edge)', () => { + it('creates the directory and opens the file when neither exists', async () => { + const { PinoFileTransport, opened, made } = await loadPinoTransport({}); + new PinoFileTransport({ filePath: '/var/log/app/out.log' }); + assert.equal(made.length, 1); + assert.equal(opened.length, 1); + }); + + it('does not mkdir or openSync when the directory and file already exist', async () => { + const existing = new Set(['/var/log/app', '/var/log/app/out.log']); + const { PinoFileTransport, opened, made } = await loadPinoTransport({ existing }); + new PinoFileTransport({ filePath: '/var/log/app/out.log' }); + assert.equal(made.length, 0); + assert.equal(opened.length, 0); + }); + + it('creates the file only when the directory exists but the file is missing', async () => { + const existing = new Set(['/var/log/app']); + const { PinoFileTransport, opened, made } = await loadPinoTransport({ existing }); + new PinoFileTransport({ filePath: '/var/log/app/out.log' }); + assert.equal(made.length, 0); + assert.equal(opened.length, 1); + }); + + it('re-serializes the parsed object, stripping incidental whitespace', async () => { + const { PinoFileTransport, writes } = await loadPinoTransport({}); + const t = new PinoFileTransport({ filePath: '/tmp/x.log' }); + t.write('{ "b" : 2 , "a" : 1 }'); + assert.equal(writes.length, 1); + assert.equal(writes[0], '{"b":2,"a":1}\n'); + }); + + it('terminates each written entry with exactly one newline', async () => { + const { PinoFileTransport, writes } = await loadPinoTransport({}); + const t = new PinoFileTransport({ filePath: '/tmp/x.log' }); + t.write(JSON.stringify({ message: 'hello' })); + assert.ok(writes[0].endsWith('\n')); + assert.equal(writes[0].endsWith('\n\n'), false); + }); + + it('preserves a multi-line message value through the round-trip', async () => { + const { PinoFileTransport, writes } = await loadPinoTransport({}); + const t = new PinoFileTransport({ filePath: '/tmp/x.log' }); + t.write(JSON.stringify({ message: 'a\nb\nc' })); + const parsed = JSON.parse(writes[0]); + assert.equal(parsed.message, 'a\nb\nc'); + }); + + it('throws and writes nothing when given an empty string', async () => { + const { PinoFileTransport, writes } = await loadPinoTransport({}); + const t = new PinoFileTransport({ filePath: '/tmp/x.log' }); + assert.throws(() => t.write('')); + assert.equal(writes.length, 0); + }); + + it('serializes a JSON null payload to the literal "null" line', async () => { + const { PinoFileTransport, writes } = await loadPinoTransport({}); + const t = new PinoFileTransport({ filePath: '/tmp/x.log' }); + t.write('null'); + assert.equal(writes[0], 'null\n'); + }); +}); diff --git a/common/tests/unit-tests/console-transport/console-transport.test.mjs b/common/tests/unit-tests/console-transport/console-transport.test.mjs new file mode 100644 index 0000000000..e7a57bec3c --- /dev/null +++ b/common/tests/unit-tests/console-transport/console-transport.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { ConsoleTransport } from '../../../dist/helpers/console-transport.js'; + +const captureConsole = () => { + const calls = { info: [], warn: [], error: [], log: [] }; + const original = { + info: console.info, + warn: console.warn, + error: console.error, + log: console.log, + }; + console.info = (...args) => calls.info.push(args); + console.warn = (...args) => calls.warn.push(args); + console.error = (...args) => calls.error.push(args); + console.log = (...args) => calls.log.push(args); + const restore = () => { + console.info = original.info; + console.warn = original.warn; + console.error = original.error; + console.log = original.log; + }; + return { calls, restore }; +}; + +describe('ConsoleTransport.log (direct)', () => { + it('routes INFO entries to console.info', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'hello', attributes: ['svc'] }, () => { + cap.restore(); + assert.equal(cap.calls.info.length, 1); + assert.ok(cap.calls.info[0][0].includes('[svc]:')); + assert.equal(cap.calls.info[0][1], 'hello'); + assert.equal(cap.calls.warn.length, 0); + assert.equal(cap.calls.error.length, 0); + done(); + }); + }); + + it('routes WARN entries to console.warn', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'WARN', message: 'warn-msg', attributes: ['a', 'b'] }, () => { + cap.restore(); + assert.equal(cap.calls.warn.length, 1); + assert.ok(cap.calls.warn[0][0].includes('[a,b]:')); + assert.equal(cap.calls.warn[0][1], 'warn-msg'); + done(); + }); + }); + + it('routes ERROR entries to console.error', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'ERROR', message: 'boom', attributes: ['svc'] }, () => { + cap.restore(); + assert.equal(cap.calls.error.length, 1); + assert.equal(cap.calls.error[0][1], 'boom'); + done(); + }); + }); + + it('falls through to console.log for unknown types', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'TRACE', message: 'trace', attributes: [] }, () => { + cap.restore(); + assert.equal(cap.calls.log.length, 1); + done(); + }); + }); + + it('joins attributes with comma in the prefix', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'm', attributes: ['x', 'y', 'z'] }, () => { + cap.restore(); + assert.ok(cap.calls.info[0][0].includes('[x,y,z]:')); + done(); + }); + }); + + it('handles missing attributes safely', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'm' }, () => { + cap.restore(); + assert.equal(cap.calls.info.length, 1); + done(); + }); + }); + + it('prefix begins with an ISO timestamp', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.log({ type: 'INFO', message: 'x', attributes: [] }, () => { + cap.restore(); + assert.match(cap.calls.info[0][0], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /); + done(); + }); + }); +}); + +describe('ConsoleTransport pino adapter (_write)', () => { + it('parses a JSON chunk and forwards to log()', () => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + const chunk = Buffer.from(JSON.stringify({ + type: 'INFO', + message: 'wired', + attributes: ['pino'], + })); + t._write(chunk, 'utf8', () => { + cap.restore(); + assert.equal(cap.calls.info.length, 1); + assert.equal(cap.calls.info[0][1], 'wired'); + }); + }); + + it('writes via the Writable stream interface', (done) => { + const cap = captureConsole(); + const t = new ConsoleTransport({}); + t.write(JSON.stringify({ type: 'WARN', message: 'streamed', attributes: [] }), 'utf8', () => { + cap.restore(); + assert.equal(cap.calls.warn.length, 1); + assert.equal(cap.calls.warn[0][1], 'streamed'); + done(); + }); + }); +}); diff --git a/common/tests/unit-tests/context-helper/context-helper.test.mjs b/common/tests/unit-tests/context-helper/context-helper.test.mjs new file mode 100644 index 0000000000..deb7101066 --- /dev/null +++ b/common/tests/unit-tests/context-helper/context-helper.test.mjs @@ -0,0 +1,91 @@ +import { assert } from 'chai'; +import { ContextHelper } from '../../../dist/hedera-modules/vcjs/context-helper.js'; + +describe('ContextHelper.clearEmptyProperties', () => { + it('removes null/undefined keys recursively', () => { + const vc = { a: 1, b: null, c: { x: undefined, y: 2 }, d: [1, null, { z: undefined }] }; + ContextHelper.clearEmptyProperties(vc); + assert.deepEqual(vc, { a: 1, c: { y: 2 }, d: [1, null, {}] }); + }); + + it('returns input unchanged for null/undefined', () => { + assert.equal(ContextHelper.clearEmptyProperties(null), null); + assert.equal(ContextHelper.clearEmptyProperties(undefined), undefined); + }); + + it('returns scalar values unchanged', () => { + assert.equal(ContextHelper.clearEmptyProperties('s'), 's'); + assert.equal(ContextHelper.clearEmptyProperties(42), 42); + }); + + it('walks arrays in place', () => { + const arr = [{ a: 1, b: null }]; + ContextHelper.clearEmptyProperties(arr); + assert.deepEqual(arr, [{ a: 1 }]); + }); +}); + +describe('ContextHelper.clearContext', () => { + it('strips inline @context entries from a credentialSubject and lifts them to subject.@context', () => { + const vc = { + credentialSubject: { + name: 'alice', + child: { '@context': ['https://x'], v: 1 }, + }, + }; + ContextHelper.clearContext(vc); + assert.deepEqual(vc.credentialSubject['@context'], ['https://x']); + assert.equal(vc.credentialSubject.child['@context'], undefined); + }); + + it('preserves the credentialSubject.type when present', () => { + const vc = { + credentialSubject: { + type: 'Person', + child: { '@context': 'https://x', v: 1 }, + }, + }; + ContextHelper.clearContext(vc); + assert.equal(vc.credentialSubject.type, 'Person'); + }); + + it('handles array credentialSubject (multiple subjects)', () => { + const vc = { + credentialSubject: [ + { name: 'a', '@context': ['https://a'] }, + { name: 'b', '@context': ['https://b'] }, + ], + }; + ContextHelper.clearContext(vc); + assert.deepEqual(vc.credentialSubject[0]['@context'], ['https://a']); + assert.deepEqual(vc.credentialSubject[1]['@context'], ['https://b']); + }); + + it('drops unrecognised type fields (not in the allow list)', () => { + const vc = { + credentialSubject: { + name: 'a', + child: { type: 'NotAllowedType', v: 1 }, + }, + }; + ContextHelper.clearContext(vc); + assert.equal(vc.credentialSubject.child.type, undefined); + }); + + it('keeps allow-listed type values (e.g. Polygon, Point, geometry)', () => { + const vc = { + credentialSubject: { + name: 'a', + geo: { type: 'Polygon', coordinates: [] }, + }, + }; + ContextHelper.clearContext(vc); + assert.equal(vc.credentialSubject.geo.type, 'Polygon'); + }); + + it('returns the input vc unchanged when there is no credentialSubject', () => { + const vc = { issuer: 'did:1' }; + const result = ContextHelper.clearContext(vc); + assert.equal(result, vc); + }); +}); diff --git a/common/tests/unit-tests/context-helper/set-context.test.mjs b/common/tests/unit-tests/context-helper/set-context.test.mjs new file mode 100644 index 0000000000..7e79bcac22 --- /dev/null +++ b/common/tests/unit-tests/context-helper/set-context.test.mjs @@ -0,0 +1,59 @@ +import { assert } from 'chai'; +import { ContextHelper } from '../../../dist/hedera-modules/vcjs/context-helper.js'; + +describe('ContextHelper.setContext', () => { + it('returns the vc unchanged when schema is null', () => { + const vc = { '@context': ['ctx'], a: { b: 1 } }; + const out = ContextHelper.setContext(vc, null); + assert.strictEqual(out, vc); + }); + + it('strips the temporary __path marker from every nested object', () => { + const vc = { '@context': ['ctx'], nested: { deep: { leaf: 1 } }, arr: [{ x: 1 }] }; + ContextHelper.setContext(vc, null); + assert.notProperty(vc, '__path'); + assert.notProperty(vc.nested, '__path'); + assert.notProperty(vc.nested.deep, '__path'); + assert.notProperty(vc.arr[0], '__path'); + }); + + it('applies field context.type and top-level @context to ref fields', () => { + const topContext = ['https://example.org/ctx']; + const vc = { + '@context': topContext, + child: { value: 1 } + }; + const schema = { + getField(path) { + if (path === 'child') { + return { isRef: true, context: { type: 'ChildType' } }; + } + return null; + } + }; + ContextHelper.setContext(vc, schema); + assert.equal(vc.child.type, 'ChildType'); + assert.deepEqual(vc.child['@context'], topContext); + assert.notProperty(vc.child, '__path'); + }); + + it('leaves non-ref fields untouched', () => { + const vc = { '@context': ['ctx'], plain: { value: 1 } }; + const schema = { + getField() { + return { isRef: false }; + } + }; + ContextHelper.setContext(vc, schema); + assert.isUndefined(vc.plain.type); + assert.isUndefined(vc.plain['@context']); + }); + + it('handles arrays and primitives in _getItems traversal', () => { + const vc = { '@context': ['ctx'], list: [1, 'str', { k: 2 }], n: null }; + const schema = { getField() { return null; } }; + const out = ContextHelper.setContext(vc, schema); + assert.strictEqual(out, vc); + assert.notProperty(vc.list[2], '__path'); + }); +}); diff --git a/common/tests/unit-tests/custom-csv-parser/custom-csv-parser-edge.test.mjs b/common/tests/unit-tests/custom-csv-parser/custom-csv-parser-edge.test.mjs new file mode 100644 index 0000000000..41170aecc6 --- /dev/null +++ b/common/tests/unit-tests/custom-csv-parser/custom-csv-parser-edge.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { parseCsv } from '../../../dist/helpers/custom-csv-parser.js'; + +describe('parseCsv — edge & quirks', () => { + it('drops values beyond the declared header count', () => { + assert.deepEqual(parseCsv('a,b\n1,2,3'), [{ a: '1', b: '2' }]); + }); + + it('collapses duplicate headers so the last column wins', () => { + assert.deepEqual(parseCsv('a,a\n1,2'), [{ a: '2' }]); + }); + + it('tolerates CRLF line endings by trimming the trailing carriage return', () => { + assert.deepEqual(parseCsv('a,b\r\n1,2\r\n3,4'), [ + { a: '1', b: '2' }, + { a: '3', b: '4' }, + ]); + }); + + it('does not honour quotes around embedded commas (naive split limitation)', () => { + assert.deepEqual(parseCsv('a,b\n"x,y",2'), [{ a: '"x', b: 'y"' }]); + }); + + it('returns [] for empty or whitespace-only input', () => { + assert.deepEqual(parseCsv(''), []); + assert.deepEqual(parseCsv(' '), []); + }); +}); diff --git a/common/tests/unit-tests/custom-csv-parser/custom-csv-parser.test.mjs b/common/tests/unit-tests/custom-csv-parser/custom-csv-parser.test.mjs new file mode 100644 index 0000000000..5a41294f06 --- /dev/null +++ b/common/tests/unit-tests/custom-csv-parser/custom-csv-parser.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { parseCsv } from '../../../dist/helpers/custom-csv-parser.js'; + +describe('parseCsv', () => { + it('parses a simple two-column CSV into objects keyed by header', () => { + const records = parseCsv('name,age\nAlice,30\nBob,25'); + assert.deepEqual(records, [ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]); + }); + + it('trims whitespace around headers and values', () => { + const records = parseCsv(' a , b \n 1 , 2 '); + assert.deepEqual(records, [{ a: '1', b: '2' }]); + }); + + it('fills missing trailing columns with empty string', () => { + const records = parseCsv('a,b,c\n1,2'); + assert.deepEqual(records, [{ a: '1', b: '2', c: '' }]); + }); + + it('returns [] for input with only a header row', () => { + assert.deepEqual(parseCsv('a,b'), []); + }); + + it('trims leading/trailing whitespace in the whole input', () => { + const records = parseCsv('\n a,b\n1,2\n '); + assert.deepEqual(records, [{ a: '1', b: '2' }]); + }); + + it('handles a single column', () => { + assert.deepEqual(parseCsv('id\nx\ny'), [{ id: 'x' }, { id: 'y' }]); + }); +}); diff --git a/common/tests/unit-tests/database-server-static/database-server-static.test.mjs b/common/tests/unit-tests/database-server-static/database-server-static.test.mjs new file mode 100644 index 0000000000..6f6b24793b --- /dev/null +++ b/common/tests/unit-tests/database-server-static/database-server-static.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DatabaseServer } from '../../../dist/database-modules/database-server.js'; + +describe('DatabaseServer.dbID', () => { + it('builds a valid ObjectId from a 24-char hex string', () => { + const hex = new ObjectId().toHexString(); + const id = DatabaseServer.dbID(hex); + assert.ok(id instanceof ObjectId); + assert.equal(id.toHexString(), hex); + }); + + it('returns null for a malformed input', () => { + assert.equal(DatabaseServer.dbID('not-an-objectid'), null); + assert.equal(DatabaseServer.dbID('zzz'), null); + }); + + it('returns null for an empty string', () => { + // Empty string is invalid for ObjectId construction. + assert.equal(DatabaseServer.dbID(''), null); + }); +}); diff --git a/common/tests/unit-tests/db-helper-constants/db-helper-constants.test.mjs b/common/tests/unit-tests/db-helper-constants/db-helper-constants.test.mjs new file mode 100644 index 0000000000..7a35167546 --- /dev/null +++ b/common/tests/unit-tests/db-helper-constants/db-helper-constants.test.mjs @@ -0,0 +1,37 @@ +import { assert } from 'chai'; +import { + MAP_DOCUMENT_AGGREGATION_FILTERS, + MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS, + MAP_ATTRIBUTES_AGGREGATION_FILTERS, + MAP_TASKS_AGGREGATION_FILTERS, + MAP_TRANSACTION_SERIALS_AGGREGATION_FILTERS, +} from '../../../dist/helpers/db-helper.js'; + +describe('db-helper aggregation filter maps', () => { + it('MAP_DOCUMENT_AGGREGATION_FILTERS exposes the canonical document filter keys', () => { + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.BASE, 'base'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.HISTORY, 'history'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.SORT, 'sort'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.PAGINATION, 'pagination'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.VC_DOCUMENTS, 'vc-documents'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.VP_DOCUMENTS, 'vp-documents'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.APPROVE, 'approve'); + assert.equal(MAP_DOCUMENT_AGGREGATION_FILTERS.DRY_RUN_SAVEPOINT, 'dry-run-savepoint'); + }); + + it('MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS exposes document/instance/groups/schema_by_name', () => { + assert.equal(MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS.DOC_BY_POLICY, 'doc_by_policy'); + assert.equal(MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS.DOC_BY_INSTANCE, 'doc_by_instance'); + assert.equal(MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS.DOCS_GROUPS, 'docs_groups'); + assert.equal(MAP_REPORT_ANALYTICS_AGGREGATION_FILTERS.SCHEMA_BY_NAME, 'schema_by_name'); + }); + + it('MAP_ATTRIBUTES_AGGREGATION_FILTERS / MAP_TASKS_AGGREGATION_FILTERS use the same RESULT key', () => { + assert.equal(MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, 'result'); + assert.equal(MAP_TASKS_AGGREGATION_FILTERS.RESULT, 'result'); + }); + + it('MAP_TRANSACTION_SERIALS_AGGREGATION_FILTERS exposes COUNT="count"', () => { + assert.equal(MAP_TRANSACTION_SERIALS_AGGREGATION_FILTERS.COUNT, 'count'); + }); +}); diff --git a/common/tests/unit-tests/db-helper/aggregation-filters.test.mjs b/common/tests/unit-tests/db-helper/aggregation-filters.test.mjs new file mode 100644 index 0000000000..5b363b53e1 --- /dev/null +++ b/common/tests/unit-tests/db-helper/aggregation-filters.test.mjs @@ -0,0 +1,106 @@ +import { assert } from 'chai'; +import { + DataBaseHelper, + MAP_ATTRIBUTES_AGGREGATION_FILTERS, + MAP_TASKS_AGGREGATION_FILTERS, +} from '../../../dist/helpers/db-helper.js'; + +describe('DataBaseHelper.getAttributesAggregationFilters', () => { + it('returns the documented pipeline for the RESULT key', () => { + const pipeline = DataBaseHelper.getAttributesAggregationFilters( + MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, + 'foo', + ['existing-1', 'existing-2'] + ); + assert.isArray(pipeline); + assert.isAbove(pipeline.length, 0); + }); + + it('first stage projects only the attributes field', () => { + const pipeline = DataBaseHelper.getAttributesAggregationFilters( + MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, + 'foo', + [] + ); + assert.deepEqual(pipeline[0], { $project: { attributes: '$attributes' } }); + }); + + it('embeds the supplied regex (case-insensitive) into a $match stage', () => { + const pipeline = DataBaseHelper.getAttributesAggregationFilters( + MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, + 'svc-name', + [] + ); + const match = pipeline.find( + (s) => s.$match && s.$match.attributes && s.$match.attributes.$regex === 'svc-name' + ); + assert.ok(match, 'expected a $match stage with the supplied regex'); + assert.equal(match.$match.attributes.$options, 'i'); + }); + + it('embeds existingAttributes into a $not/$in exclusion match', () => { + const existing = ['svc:already', 'env:done']; + const pipeline = DataBaseHelper.getAttributesAggregationFilters( + MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, + 'foo', + existing + ); + const exclude = pipeline.find( + (s) => s.$match && s.$match.attributes && s.$match.attributes.$not + ); + assert.ok(exclude); + assert.deepEqual(exclude.$match.attributes.$not.$in, existing); + }); + + it('caps results at 20 unique values via a $limit stage', () => { + const pipeline = DataBaseHelper.getAttributesAggregationFilters( + MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, + 'foo', + [] + ); + const limit = pipeline.find((s) => s.$limit !== undefined); + assert.ok(limit); + assert.equal(limit.$limit, 20); + }); + + it('returns undefined for an unknown map key', () => { + const out = DataBaseHelper.getAttributesAggregationFilters('not-result', 'x', []); + assert.equal(out, undefined); + }); +}); + +describe('DataBaseHelper.getTasksAggregationFilters', () => { + it('returns a non-empty pipeline for the RESULT key', () => { + const pipeline = DataBaseHelper.getTasksAggregationFilters( + MAP_TASKS_AGGREGATION_FILTERS.RESULT, + 30000 + ); + assert.isArray(pipeline); + assert.isAbove(pipeline.length, 0); + }); + + it('first stage matches sent=true and done!=true', () => { + const pipeline = DataBaseHelper.getTasksAggregationFilters( + MAP_TASKS_AGGREGATION_FILTERS.RESULT, + 30000 + ); + const match = pipeline[0].$match; + assert.equal(match.sent, true); + assert.deepEqual(match.done, { $ne: true }); + }); + + it('returns undefined for an unknown map key', () => { + const out = DataBaseHelper.getTasksAggregationFilters('not-a-key', 1000); + assert.equal(out, undefined); + }); +}); + +describe('DataBaseHelper aggregation map constants', () => { + it('MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT is the literal "result"', () => { + assert.equal(MAP_ATTRIBUTES_AGGREGATION_FILTERS.RESULT, 'result'); + }); + + it('MAP_TASKS_AGGREGATION_FILTERS.RESULT is the literal "result"', () => { + assert.equal(MAP_TASKS_AGGREGATION_FILTERS.RESULT, 'result'); + }); +}); diff --git a/common/tests/unit-tests/db-helper/db-helper-gridfs.test.mjs b/common/tests/unit-tests/db-helper/db-helper-gridfs.test.mjs new file mode 100644 index 0000000000..33b30e4438 --- /dev/null +++ b/common/tests/unit-tests/db-helper/db-helper-gridfs.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DataBaseHelper } from '../../../dist/helpers/db-helper.js'; + +let bucket; +function makeBucket() { + const calls = []; + const state = { files: [], download: [], deleteError: null }; + const b = { + calls, + state, + openUploadStream(name) { + const id = new ObjectId(ObjectId.generate()); + return { + id, + write(buf) { calls.push(['write', name, buf]); }, + end(cb) { calls.push(['end', name]); cb(); }, + }; + }, + openUploadStreamWithId(id, name) { + return { + end(buf, cb) { calls.push(['endWithId', id, name, buf]); cb(state.endError || undefined); }, + }; + }, + async delete(id) { calls.push(['delete', id]); if (state.deleteError) { throw state.deleteError; } }, + find(id) { calls.push(['find', id]); return { toArray: async () => state.files }; }, + openDownloadStream(id) { + calls.push(['openDownloadStream', id]); + const chunks = state.download; + return (async function* () { for (const c of chunks) { yield c; } })(); + }, + }; + return b; +} +beforeEach(() => { bucket = makeBucket(); DataBaseHelper.gridFS = bucket; }); + +describe('@unit DataBaseHelper.saveFile', () => { + it('writes the buffer, ends the stream, and resolves the new file id', async () => { + const id = await DataBaseHelper.saveFile('uuid-1', Buffer.from('hello')); + assert.ok(id instanceof ObjectId); + assert.deepEqual(bucket.calls.map((c) => c[0]), ['write', 'end']); + }); +}); + +describe('@unit DataBaseHelper.saveFileWithId', () => { + it('ends the upload stream with the buffer and resolves the supplied id', async () => { + const id = new ObjectId(ObjectId.generate()); + const out = await DataBaseHelper.saveFileWithId(id, 'f.json', Buffer.from('x')); + assert.equal(out, id); + assert.equal(bucket.calls[0][0], 'endWithId'); + }); + + it('rejects when the upload stream reports an error', async () => { + bucket.state.endError = new Error('disk full'); + const id = new ObjectId(ObjectId.generate()); + await assert.rejects(() => DataBaseHelper.saveFileWithId(id, 'f', Buffer.from('x')), /disk full/); + }); +}); + +describe('@unit DataBaseHelper.overwriteFile', () => { + it('deletes the existing file then re-saves with the same id', async () => { + const id = new ObjectId(ObjectId.generate()); + const out = await DataBaseHelper.overwriteFile(id, 'f', Buffer.from('x')); + assert.equal(out, id); + assert.equal(bucket.calls[0][0], 'delete'); + assert.ok(bucket.calls.some((c) => c[0] === 'endWithId')); + }); + + it('swallows a delete error and still re-saves', async () => { + bucket.state.deleteError = new Error('not found'); + const id = new ObjectId(ObjectId.generate()); + const out = await DataBaseHelper.overwriteFile(id, 'f', Buffer.from('x')); + assert.equal(out, id); + }); +}); + +describe('@unit DataBaseHelper.deleteFile', () => { + it('delegates to gridFS.delete', async () => { + const id = new ObjectId(ObjectId.generate()); + await DataBaseHelper.deleteFile(id); + assert.deepEqual(bucket.calls[0], ['delete', id]); + }); +}); + +describe('@unit DataBaseHelper.loadFile', () => { + it('returns null when no file matches', async () => { + bucket.state.files = []; + assert.equal(await DataBaseHelper.loadFile(new ObjectId(ObjectId.generate())), null); + }); + + it('concatenates the download stream chunks into a single buffer', async () => { + bucket.state.files = [{ _id: new ObjectId(ObjectId.generate()) }]; + bucket.state.download = [Buffer.from('foo'), Buffer.from('bar')]; + const out = await DataBaseHelper.loadFile(new ObjectId(ObjectId.generate())); + assert.equal(out.toString(), 'foobar'); + }); +}); diff --git a/common/tests/unit-tests/db-helper/document-aggregation-filters.test.mjs b/common/tests/unit-tests/db-helper/document-aggregation-filters.test.mjs new file mode 100644 index 0000000000..b62d113038 --- /dev/null +++ b/common/tests/unit-tests/db-helper/document-aggregation-filters.test.mjs @@ -0,0 +1,145 @@ +import { assert } from 'chai'; +import { + DataBaseHelper, + MAP_DOCUMENT_AGGREGATION_FILTERS, + MAP_TRANSACTION_SERIALS_AGGREGATION_FILTERS +} from '../../../dist/helpers/db-helper.js'; + +function mockAggregation() { + const calls = []; + return { + calls, + push(...stages) { + calls.push(...stages); + return this; + } + }; +} + +describe('DataBaseHelper.getDocumentAggregationFilters', () => { + it('appends the BASE pipeline stages onto the aggregation', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.BASE + }); + assert.isAbove(aggregation.calls.length, 0); + assert.property(aggregation.calls[0], '$match'); + }); + + it('SORT stage uses the supplied sortObject', () => { + const aggregation = mockAggregation(); + const sortObject = { createDate: -1 }; + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.SORT, + sortObject + }); + assert.deepEqual(aggregation.calls[0], { $sort: sortObject }); + }); + + it('PAGINATION stage computes skip = itemsPerPage * page and limit', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.PAGINATION, + itemsPerPage: 10, + page: 3 + }); + assert.deepEqual(aggregation.calls[0], { $skip: 30 }); + assert.deepEqual(aggregation.calls[1], { $limit: 10 }); + }); + + it('VC_DOCUMENTS stage matches the supplied policyId', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.VC_DOCUMENTS, + policyId: 'pol-1' + }); + assert.deepEqual(aggregation.calls[0], { $match: { policyId: { $eq: 'pol-1' } } }); + }); + + it('HISTORY stage looks up document_state when not dry-run', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.HISTORY, + dryRun: false + }); + assert.equal(aggregation.calls[0].$lookup.from, 'document_state'); + }); + + it('HISTORY stage looks up dry_run when dryRun=true', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.HISTORY, + dryRun: true + }); + assert.equal(aggregation.calls[0].$lookup.from, 'dry_run'); + }); + + it('DRY_RUN_SAVEPOINT includes savepointId $in when ids supplied', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.DRY_RUN_SAVEPOINT, + savepointIds: ['s1', 's2'] + }); + const orArr = aggregation.calls[0].$match.$or; + assert.deepInclude(orArr, { savepointId: { $in: ['s1', 's2'] } }); + }); + + it('DRY_RUN_SAVEPOINT omits the $in clause when no ids supplied', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getDocumentAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_DOCUMENT_AGGREGATION_FILTERS.DRY_RUN_SAVEPOINT, + savepointIds: null + }); + const orArr = aggregation.calls[0].$match.$or; + assert.equal(orArr.length, 2); + assert.deepInclude(orArr, { savepointId: { $exists: false } }); + assert.deepInclude(orArr, { savepointId: null }); + }); +}); + +describe('DataBaseHelper.getTransactionsSerialsAggregationFilters', () => { + it('appends a $project counting serials for the COUNT key', () => { + const aggregation = mockAggregation(); + DataBaseHelper.getTransactionsSerialsAggregationFilters({ + aggregation, + aggregateMethod: 'push', + nameFilter: MAP_TRANSACTION_SERIALS_AGGREGATION_FILTERS.COUNT + }); + assert.deepEqual(aggregation.calls[0], { $project: { serials: { $size: '$serials' } } }); + }); +}); + +describe('DataBaseHelper._getTransactionsSerialsAggregation', () => { + it('matches only mintRequestId when no transferStatus', () => { + const pipeline = DataBaseHelper._getTransactionsSerialsAggregation('req-1'); + assert.deepEqual(pipeline[0].$match, { mintRequestId: 'req-1' }); + }); + + it('includes transferStatus in the match when provided', () => { + const pipeline = DataBaseHelper._getTransactionsSerialsAggregation('req-1', 'COMPLETED'); + assert.deepEqual(pipeline[0].$match, { mintRequestId: 'req-1', transferStatus: 'COMPLETED' }); + }); + + it('groups and reduces serials into a flat array', () => { + const pipeline = DataBaseHelper._getTransactionsSerialsAggregation('req-1'); + assert.property(pipeline[1], '$group'); + assert.property(pipeline[2], '$project'); + assert.property(pipeline[2].$project.serials, '$reduce'); + }); +}); diff --git a/common/tests/unit-tests/db-naming-strategy-edge.test.mjs b/common/tests/unit-tests/db-naming-strategy-edge.test.mjs new file mode 100644 index 0000000000..79edbbfb92 --- /dev/null +++ b/common/tests/unit-tests/db-naming-strategy-edge.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { DataBaseNamingStrategy } from '../../dist/helpers/db-naming-strategy.js'; + +describe('DataBaseNamingStrategy.classToTableName — edges', () => { + const strategy = new DataBaseNamingStrategy(); + + it('returns an empty string unchanged', () => { + assert.equal(strategy.classToTableName(''), ''); + }); + + it('does not insert underscores around digits', () => { + assert.equal(strategy.classToTableName('User2Factor'), 'user2factor'); + }); + + it('splits a minimal lower-then-upper pair', () => { + assert.equal(strategy.classToTableName('aB'), 'a_b'); + }); + + it('does not split an acronym that runs into a word with no lower-upper boundary', () => { + assert.equal(strategy.classToTableName('ABCdef'), 'abcdef'); + }); +}); diff --git a/common/tests/unit-tests/db-naming-strategy.test.mjs b/common/tests/unit-tests/db-naming-strategy.test.mjs new file mode 100644 index 0000000000..14f27e17af --- /dev/null +++ b/common/tests/unit-tests/db-naming-strategy.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { DataBaseNamingStrategy } from '../../dist/helpers/db-naming-strategy.js'; + +describe('DataBaseNamingStrategy.classToTableName', () => { + const strategy = new DataBaseNamingStrategy(); + + it('converts CamelCase entity names to snake_case table names', () => { + assert.equal(strategy.classToTableName('VcDocument'), 'vc_document'); + assert.equal(strategy.classToTableName('PolicyAction'), 'policy_action'); + assert.equal(strategy.classToTableName('User'), 'user'); + }); + + it('keeps single-word lowercase names unchanged', () => { + assert.equal(strategy.classToTableName('user'), 'user'); + assert.equal(strategy.classToTableName('topic'), 'topic'); + }); + + it('handles consecutive capitals: only inserts an underscore between lower-then-upper boundary', () => { + // 'XMLParser' has no lower-then-upper transition until 'r' (no transition) — output is xmlparser + assert.equal(strategy.classToTableName('XMLParser'), 'xmlparser'); + // 'JSONHelper' likewise + assert.equal(strategy.classToTableName('JSONHelper'), 'jsonhelper'); + }); + + it('inserts the underscore at every lowerUpper transition', () => { + assert.equal(strategy.classToTableName('AaBbCc'), 'aa_bb_cc'); + }); +}); diff --git a/common/tests/unit-tests/do-nothing.test.mjs b/common/tests/unit-tests/do-nothing.test.mjs new file mode 100644 index 0000000000..89bd095848 --- /dev/null +++ b/common/tests/unit-tests/do-nothing.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { doNothing } from '../../dist/helpers/do-nothing.js'; + +describe('doNothing helper', () => { + it('returns undefined and does not throw', () => { + assert.equal(doNothing(), undefined); + }); + it('is callable with arbitrary args (ignored)', () => { + assert.doesNotThrow(() => doNothing(1, 'x', null, {})); + }); +}); diff --git a/common/tests/unit-tests/empty-notifier/empty-notifier.test.mjs b/common/tests/unit-tests/empty-notifier/empty-notifier.test.mjs new file mode 100644 index 0000000000..33a5885c6c --- /dev/null +++ b/common/tests/unit-tests/empty-notifier/empty-notifier.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { EmptyNotifier } from '../../../dist/notification/empty-notifier.js'; + +describe('EmptyNotifier', () => { + it('exposes the documented "empty" name', () => { + const n = new EmptyNotifier(); + assert.equal(n.name, 'empty'); + }); + + it('every mutator returns this (fluent chain compatibility)', () => { + const n = new EmptyNotifier(); + assert.equal(n.minimize(true), n); + assert.equal(n.setEstimate(7), n); + assert.equal(n.addEstimate(2), n); + assert.equal(n.start(), n); + assert.equal(n.complete(), n); + assert.equal(n.skip(), n); + assert.equal(n.fail('err'), n); + assert.equal(n.fail(new Error('e'), 500), n); + assert.equal(n.result({}), n); + }); + + it('child-step methods return this (so step chains keep working)', () => { + const n = new EmptyNotifier(); + assert.equal(n.startStep('any'), n); + assert.equal(n.completeStep('any'), n); + assert.equal(n.skipStep('any'), n); + assert.equal(n.failStep('any', 'err'), n); + assert.equal(n.addStep('any'), n); + assert.equal(n.getStep('any'), n); + assert.equal(n.findStepById('any'), n); + assert.equal(n.getStepById('any'), n); + assert.equal(n.setId('any'), n); + }); + + it('sendStatus / sendError / sendResult return undefined (no-op)', () => { + const n = new EmptyNotifier(); + assert.equal(n.sendStatus(), undefined); + assert.equal(n.sendError({ code: 500, message: 'x' }), undefined); + assert.equal(n.sendResult({}), undefined); + }); + + it('info() returns the documented zero/empty shape', () => { + const n = new EmptyNotifier(); + const info = n.info(); + assert.equal(info.name, 'empty'); + assert.equal(info.started, false); + assert.equal(info.completed, false); + assert.equal(info.failed, false); + assert.equal(info.skipped, false); + assert.equal(info.error, null); + assert.equal(info.size, -1); + assert.equal(info.estimate, -1); + assert.deepEqual(info.steps, []); + assert.equal(info.startDate, null); + assert.equal(info.stopDate, null); + assert.equal(info.minimized, false); + assert.equal(info.index, -1); + assert.equal(info.progress, 0); + assert.equal(info.message, ''); + }); +}); diff --git a/common/tests/unit-tests/encrypt-utils/encrypt-utils.test.mjs b/common/tests/unit-tests/encrypt-utils/encrypt-utils.test.mjs new file mode 100644 index 0000000000..6d9dbcab4f --- /dev/null +++ b/common/tests/unit-tests/encrypt-utils/encrypt-utils.test.mjs @@ -0,0 +1,46 @@ +import { assert } from 'chai'; +import { EncryptUtils } from '../../../dist/helpers/encrypt-utils.js'; + +describe('EncryptUtils.encrypt / decrypt', function () { + this.timeout(20000); // pbkdf2 in cryppo is slow + + it('round-trips an arbitrary buffer with the right key', async () => { + const plaintext = Buffer.from('hello-mgs-payload', 'utf8'); + const key = 'super-secret-passphrase'; + + const encrypted = await EncryptUtils.encrypt(plaintext, key); + assert.notEqual(Buffer.from(encrypted).toString('utf8'), plaintext.toString('utf8')); + + const decrypted = await EncryptUtils.decrypt(encrypted, key); + assert.equal(Buffer.from(decrypted).toString('utf8'), 'hello-mgs-payload'); + }); + + it('produces different ciphertext on each encrypt (random IV/salt)', async () => { + const plaintext = Buffer.from('determinism-check', 'utf8'); + const key = 'k'; + const a = await EncryptUtils.encrypt(plaintext, key); + const b = await EncryptUtils.encrypt(plaintext, key); + assert.notEqual(Buffer.from(a).toString('utf8'), Buffer.from(b).toString('utf8')); + }); + + it('throws when no key is provided', async () => { + try { + await EncryptUtils.encrypt(Buffer.from('x'), ''); + assert.fail('expected throw'); + } catch (err) { + assert.match(err.message, /no appropriate private key/i); + } + }); + + it('decrypt with the wrong key rejects (tamper-resistant AES-GCM)', async () => { + const plaintext = Buffer.from('top-secret', 'utf8'); + const encrypted = await EncryptUtils.encrypt(plaintext, 'right-key'); + let threw = false; + try { + await EncryptUtils.decrypt(encrypted, 'wrong-key'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); +}); diff --git a/common/tests/unit-tests/encrypt-vc-helper/encrypt-vc-helper.test.mjs b/common/tests/unit-tests/encrypt-vc-helper/encrypt-vc-helper.test.mjs new file mode 100644 index 0000000000..cb1fc7f9b2 --- /dev/null +++ b/common/tests/unit-tests/encrypt-vc-helper/encrypt-vc-helper.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { EncryptVcHelper } from '../../../dist/helpers/encrypt-vc-helper.js'; + +describe('EncryptVcHelper', () => { + it('round-trips a document through encrypt/decrypt with the same key', async () => { + const original = JSON.stringify({ amount: 5, owner: 'did:owner', payload: 'hello' }); + const encrypted = await EncryptVcHelper.encrypt(original, 'secret-passphrase'); + const decrypted = await EncryptVcHelper.decrypt(encrypted, 'secret-passphrase'); + assert.equal(decrypted, original); + }); + + it('decrypt does not support an empty-string ciphertext (cryppo limitation)', async () => { + // Encrypting "" succeeds but decrypt back through cryppo requires a non-NULL algo. + const encrypted = await EncryptVcHelper.encrypt('', 'k1'); + await assert.rejects(EncryptVcHelper.decrypt(encrypted, 'k1')); + }); + + it('throws when encrypt is called with no key', async () => { + await assert.rejects( + EncryptVcHelper.encrypt('payload', null), + /no appropriate private key/, + ); + await assert.rejects( + EncryptVcHelper.encrypt('payload', ''), + /no appropriate private key/, + ); + await assert.rejects( + EncryptVcHelper.encrypt('payload', undefined), + /no appropriate private key/, + ); + }); + + it('produces different ciphertexts for different inputs', async () => { + const a = await EncryptVcHelper.encrypt('A', 'same-key'); + const b = await EncryptVcHelper.encrypt('B', 'same-key'); + assert.notEqual(a, b); + }); + + it('produces different ciphertexts for the same input on repeat calls (random IV)', async () => { + const a = await EncryptVcHelper.encrypt('payload', 'same-key'); + const b = await EncryptVcHelper.encrypt('payload', 'same-key'); + assert.notEqual(a, b); + }); + + it('decrypting with the wrong key rejects', async () => { + const ct = await EncryptVcHelper.encrypt('payload', 'right-key'); + await assert.rejects(EncryptVcHelper.decrypt(ct, 'wrong-key')); + }); + + it('round-trips a unicode payload', async () => { + const text = 'Привет 🌍 — naïve façade'; + const ct = await EncryptVcHelper.encrypt(text, 'k'); + const pt = await EncryptVcHelper.decrypt(ct, 'k'); + assert.equal(pt, text); + }); +}); diff --git a/common/tests/unit-tests/entity/document-draft-hooks.test.mjs b/common/tests/unit-tests/entity/document-draft-hooks.test.mjs new file mode 100644 index 0000000000..0e7a258134 --- /dev/null +++ b/common/tests/unit-tests/entity/document-draft-hooks.test.mjs @@ -0,0 +1,98 @@ +import { assert } from 'chai'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DocumentDraft } from '../../../dist/entity/index.js'; +import { DataBaseHelper } from '../../../dist/helpers/db-helper.js'; + +const FILE_A = '507f1f77bcf86cd799439011'; +const FILE_B = '507f1f77bcf86cd799439012'; + +function makeBucket() { + const calls = []; + return { calls, async delete(id) { calls.push(String(id)); } }; +} + +describe('@unit DocumentDraft lifecycle hooks', () => { + let bucket; + let prevGridFS; + let prevOrm; + beforeEach(() => { + bucket = makeBucket(); + prevGridFS = DataBaseHelper.gridFS; + prevOrm = DataBaseHelper.orm; + DataBaseHelper.gridFS = bucket; + DataBaseHelper._orm = undefined; + }); + afterEach(() => { + DataBaseHelper.gridFS = prevGridFS; + DataBaseHelper._orm = prevOrm; + }); + + it('setDefaults extracts table file ids from a string payload', async () => { + const e = new DocumentDraft(); + e.data = JSON.stringify({ type: 'table', fileId: FILE_A }); + await e.setDefaults(); + assert.equal(e.tableFileIds.length, 1); + assert.equal(e.tableFileIds[0].toString(), FILE_A); + }); + + it('setDefaults extracts from an object payload', async () => { + const e = new DocumentDraft(); + e.data = { type: 'table', fileId: FILE_A }; + await e.setDefaults(); + assert.equal(e.tableFileIds.length, 1); + }); + + it('setDefaults leaves tableFileIds undefined for invalid json / empty string', async () => { + const e = new DocumentDraft(); + e.data = '{not json'; + await e.setDefaults(); + assert.isUndefined(e.tableFileIds); + const e2 = new DocumentDraft(); + e2.data = ' '; + await e2.setDefaults(); + assert.isUndefined(e2.tableFileIds); + }); + + it('updateFiles marks removed ids as _oldTableFileIds', async () => { + const e = new DocumentDraft(); + e.tableFileIds = [new ObjectId(FILE_A), new ObjectId(FILE_B)]; + e.data = { type: 'table', fileId: FILE_A }; + await e.updateFiles(); + assert.equal(e.tableFileIds.length, 1); + assert.equal(e._oldTableFileIds.length, 1); + assert.equal(e._oldTableFileIds[0].toString(), FILE_B); + }); + + it('updateFiles with no parsed data stashes all current ids for deletion', async () => { + const e = new DocumentDraft(); + e.tableFileIds = [new ObjectId(FILE_A)]; + e.data = ' '; + await e.updateFiles(); + assert.isUndefined(e.tableFileIds); + assert.equal(e._oldTableFileIds.length, 1); + }); + + it('postUpdateFiles deletes the stashed old ids via gridFS', async () => { + const e = new DocumentDraft(); + e._id = new ObjectId(ObjectId.generate()); + e._oldTableFileIds = [new ObjectId(FILE_A), new ObjectId(FILE_B)]; + await e.postUpdateFiles(); + assert.deepEqual(bucket.calls.sort(), [FILE_A, FILE_B].sort()); + assert.isUndefined(e._oldTableFileIds); + }); + + it('deleteFiles removes all current table file ids', async () => { + const e = new DocumentDraft(); + e._id = new ObjectId(ObjectId.generate()); + e.tableFileIds = [new ObjectId(FILE_A)]; + await e.deleteFiles(); + assert.deepEqual(bucket.calls, [FILE_A]); + }); + + it('deleteCache swallows the ORM-not-initialized error', async () => { + const e = new DocumentDraft(); + e._id = new ObjectId(ObjectId.generate()); + await e.deleteCache(); + assert.instanceOf(e, DocumentDraft); + }); +}); diff --git a/common/tests/unit-tests/entity/entity-file-hooks.test.mjs b/common/tests/unit-tests/entity/entity-file-hooks.test.mjs new file mode 100644 index 0000000000..a9a036e8e2 --- /dev/null +++ b/common/tests/unit-tests/entity/entity-file-hooks.test.mjs @@ -0,0 +1,204 @@ +import { assert } from 'chai'; +import { ObjectId } from '@mikro-orm/mongodb'; +import * as Entities from '../../../dist/entity/index.js'; +import { DataBaseHelper } from '../../../dist/helpers/db-helper.js'; + +function makeBucket() { + const store = new Map(); + const calls = []; + return { + calls, + store, + openUploadStream(name) { + const id = new ObjectId(ObjectId.generate()); + let chunks = []; + return { + id, + write(buf) { chunks.push(Buffer.from(buf)); }, + end(cb) { store.set(id.toString(), Buffer.concat(chunks)); if (cb) { cb(); } }, + }; + }, + openDownloadStream(id) { + calls.push(['download', String(id)]); + const buf = store.get(String(id)) ?? Buffer.from('null'); + return (async function* () { yield buf; })(); + }, + async delete(id) { calls.push(['delete', String(id)]); }, + }; +} + +const PAYLOAD_OBJECT_FIELDS = [ + 'document', + 'context', + 'config', + 'results', + 'hashMap', +]; + +const FILE_ID_FIELDS = [ + 'documentFileId', + 'contextFileId', + 'configFileId', + 'encryptedDocumentFileId', + 'resultsFileId', + 'hashMapFileId', + 'fileId', + 'contentFileId', + 'contentDocumentFileId', + 'contentContextFileId', + '_documentFileId', + '_contextFileId', + '_configFileId', + '_fileId', + '_hashMapFileId', + '_oldTableFileIds', +]; + +const HOOK_METHODS = [ + 'setDefaults', + 'loadFiles', + 'updateFiles', + 'postUpdateFiles', + 'deleteFiles', + 'deleteConfig', + 'deleteContentFile', + 'deleteContentFiles', + 'deleteCache', + 'deleteFailedItems', +]; + +function entityClasses() { + const out = []; + for (const [name, value] of Object.entries(Entities)) { + if (typeof value === 'function' && /^[A-Z]/.test(name)) { + out.push([name, value]); + } + } + return out; +} + +describe('@unit entity file-lifecycle hooks (gridFS round-trip)', () => { + let bucket; + let prevGridFS; + let prevOrm; + + beforeEach(() => { + bucket = makeBucket(); + prevGridFS = DataBaseHelper.gridFS; + prevOrm = DataBaseHelper.orm; + DataBaseHelper.gridFS = bucket; + DataBaseHelper._orm = undefined; + }); + + afterEach(() => { + DataBaseHelper.gridFS = prevGridFS; + DataBaseHelper._orm = prevOrm; + }); + + for (const [name, Entity] of entityClasses()) { + it(`${name} imports and instantiates`, () => { + const e = new Entity(); + assert.instanceOf(e, Entity); + }); + } + + for (const [name, Entity] of entityClasses()) { + const proto = Entity.prototype; + const present = HOOK_METHODS.filter((m) => typeof proto[m] === 'function'); + if (present.length === 0) { + continue; + } + it(`${name} drives lifecycle hooks: ${present.join(',')}`, async () => { + const e = new Entity(); + e._id = new ObjectId(ObjectId.generate()); + + for (const f of PAYLOAD_OBJECT_FIELDS) { + e[f] = { sample: f, n: 1, big: 'x' }; + } + e.encryptedDocument = 'encrypted-blob'; + e.file = Buffer.from('file-bytes'); + e.value = { v: 1 }; + e.isLongValue = true; + e.content = { c: 1 }; + e.contentDocument = { cd: 1 }; + e.contentContext = { cc: 1 }; + + if (typeof proto.setDefaults === 'function') { + await e.setDefaults(); + } + if (typeof proto.loadFiles === 'function') { + await e.loadFiles(); + } + + for (const f of PAYLOAD_OBJECT_FIELDS) { + e[f] = { sample: f, n: 2 }; + } + e.encryptedDocument = 'encrypted-blob-2'; + e.file = Buffer.from('file-bytes-2'); + e.value = { v: 2 }; + e.content = { c: 2 }; + + if (typeof proto.updateFiles === 'function') { + await e.updateFiles(); + } + + for (const f of FILE_ID_FIELDS) { + if (f === '_oldTableFileIds') { + e[f] = [new ObjectId(ObjectId.generate())]; + } else { + e[f] = new ObjectId(ObjectId.generate()); + } + } + + if (typeof proto.postUpdateFiles === 'function') { + await e.postUpdateFiles(); + } + for (const m of ['deleteFiles', 'deleteConfig', 'deleteContentFile', 'deleteContentFiles']) { + if (typeof proto[m] === 'function') { + await e[m](); + } + } + if (typeof proto.deleteCache === 'function') { + await e.deleteCache(); + } + if (typeof proto.deleteFailedItems === 'function') { + await e.deleteFailedItems(); + } + + assert.ok(bucket.calls.length >= 0); + }); + } +}); + +describe('@unit entity hooks: empty payload no-op branches', () => { + let prevGridFS; + let prevOrm; + + beforeEach(() => { + prevGridFS = DataBaseHelper.gridFS; + prevOrm = DataBaseHelper.orm; + DataBaseHelper.gridFS = makeBucket(); + DataBaseHelper._orm = undefined; + }); + + afterEach(() => { + DataBaseHelper.gridFS = prevGridFS; + DataBaseHelper._orm = prevOrm; + }); + + for (const [name, Entity] of entityClasses()) { + const proto = Entity.prototype; + const present = HOOK_METHODS.filter((m) => typeof proto[m] === 'function'); + if (present.length === 0) { + continue; + } + it(`${name} hooks run with no payload fields set`, async () => { + const e = new Entity(); + e._id = new ObjectId(ObjectId.generate()); + for (const m of present) { + await e[m](); + } + assert.instanceOf(e, Entity); + }); + } +}); diff --git a/common/tests/unit-tests/enum-message-action.test.mjs b/common/tests/unit-tests/enum-message-action.test.mjs new file mode 100644 index 0000000000..b1fd6716dc --- /dev/null +++ b/common/tests/unit-tests/enum-message-action.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { MessageAction } from '../../dist/hedera-modules/message/message-action.js'; + +describe('common MessageAction enum', () => { + it('exposes representative document/policy/schema/role/comment actions', () => { + assert.equal(MessageAction.CreateDID, 'create-did-document'); + assert.equal(MessageAction.CreateVC, 'create-vc-document'); + assert.equal(MessageAction.CreatePolicy, 'create-policy'); + assert.equal(MessageAction.PublishSchema, 'publish-schema'); + assert.equal(MessageAction.RevokeDocument, 'revoke-document'); + assert.equal(MessageAction.Mint, 'mint'); + assert.equal(MessageAction.CreateRole, 'create-role'); + assert.equal(MessageAction.CreateComment, 'create-policy-comment'); + }); + it('values are kebab-cased identifiers (or "Init" sentinel)', () => { + for (const v of Object.values(MessageAction)) { + assert.equal(typeof v, 'string'); + if (v !== 'Init') assert.match(v, /^[a-z][a-z0-9-]*$/); + } + }); + it('has 50+ actions (includes all policy-action variants)', () => { + assert.ok(Object.keys(MessageAction).length >= 50); + }); +}); diff --git a/common/tests/unit-tests/enum-message-type.test.mjs b/common/tests/unit-tests/enum-message-type.test.mjs new file mode 100644 index 0000000000..f627d956f5 --- /dev/null +++ b/common/tests/unit-tests/enum-message-type.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { MessageType } from '../../dist/hedera-modules/message/message-type.js'; + +describe('common MessageType enum', () => { + it('exposes representative document and resource types', () => { + assert.equal(MessageType.VCDocument, 'VC-Document'); + assert.equal(MessageType.VPDocument, 'VP-Document'); + assert.equal(MessageType.DIDDocument, 'DID-Document'); + assert.equal(MessageType.Policy, 'Policy'); + assert.equal(MessageType.Schema, 'Schema'); + assert.equal(MessageType.StandardRegistry, 'Standard Registry'); + assert.equal(MessageType.RoleDocument, 'Role-Document'); + assert.equal(MessageType.PolicyComment, 'Policy-Comment'); + }); + it('has 25+ message types', () => { + assert.ok(Object.keys(MessageType).length >= 25); + }); +}); diff --git a/common/tests/unit-tests/environment/environment.test.mjs b/common/tests/unit-tests/environment/environment.test.mjs new file mode 100644 index 0000000000..acb54700a4 --- /dev/null +++ b/common/tests/unit-tests/environment/environment.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { Environment } from '../../../dist/hedera-modules/environment.js'; + +// Mutable singleton — restore between tests. +const reset = () => { + Environment.setMirrorNodes([]); + Environment.setNodes({}); + Environment.setLocalNodeAddress('localhost'); + Environment.setLocalNodeProtocol('http'); + Environment.setNetwork('testnet'); +}; + +describe('Environment URL constants', () => { + afterEach(reset); + + it('exposes the documented mainnet/testnet/preview base APIs via the live getters', () => { + Environment.setNetwork('mainnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://mainnet.mirrornode.hedera.com/api/v1/topics/messages'); + Environment.setNetwork('testnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://testnet.mirrornode.hedera.com/api/v1/topics/messages'); + Environment.setNetwork('previewnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://preview.mirrornode.hedera.com/api/v1/topics/messages'); + }); + + it('builds the message/topic/account URLs as `${BASE}/topics/messages` and friends', () => { + Environment.setNetwork('mainnet'); + assert.equal( + Environment.HEDERA_MESSAGE_API, + 'https://mainnet.mirrornode.hedera.com/api/v1/topics/messages' + ); + Environment.setNetwork('testnet'); + assert.equal( + Environment.HEDERA_TOPIC_API, + 'https://testnet.mirrornode.hedera.com/api/v1/topics' + ); + Environment.setNetwork('previewnet'); + assert.equal( + Environment.HEDERA_ACCOUNT_API, + 'https://preview.mirrornode.hedera.com/api/v1/accounts' + ); + }); +}); + +describe('Environment.setNetwork', () => { + afterEach(reset); + + it('routes the live API getters to the mainnet endpoints', () => { + Environment.setNetwork('mainnet'); + assert.equal(Environment.network, 'mainnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://mainnet.mirrornode.hedera.com/api/v1/topics/messages'); + assert.equal(Environment.HEDERA_TOPIC_API, 'https://mainnet.mirrornode.hedera.com/api/v1/topics'); + assert.equal(Environment.HEDERA_ACCOUNT_API, 'https://mainnet.mirrornode.hedera.com/api/v1/accounts'); + assert.equal(Environment.HEDERA_TOKENS_API, 'https://mainnet.mirrornode.hedera.com/api/v1/tokens'); + }); + + it('routes the live API getters to the testnet endpoints', () => { + Environment.setNetwork('testnet'); + assert.equal(Environment.network, 'testnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://testnet.mirrornode.hedera.com/api/v1/topics/messages'); + }); + + it('routes the live API getters to the previewnet endpoints', () => { + Environment.setNetwork('previewnet'); + assert.equal(Environment.network, 'previewnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://preview.mirrornode.hedera.com/api/v1/topics/messages'); + }); + + it('routes the live API getters to the localnode endpoints', () => { + Environment.setNetwork('localnode'); + assert.equal(Environment.network, 'localnode'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'http://localhost:5551/api/v1/topics/messages'); + }); + + it('throws for an unknown network', () => { + assert.throws(() => Environment.setNetwork('moonnet'), /Unknown network/); + }); + + it('overrides API URLs to the configured mirror node when set', () => { + Environment.setMirrorNodes(['https://custom-mirror.example.com']); + Environment.setNetwork('testnet'); + assert.equal( + Environment.HEDERA_MESSAGE_API, + 'https://custom-mirror.example.com/api/v1/topics/messages' + ); + assert.equal( + Environment.HEDERA_TOPIC_API, + 'https://custom-mirror.example.com/api/v1/topics' + ); + }); + + it('prepends https:// to mirror node URLs that lack a scheme', () => { + Environment.setMirrorNodes(['custom-mirror.example.com']); + Environment.setNetwork('testnet'); + assert.equal( + Environment.HEDERA_MESSAGE_API, + 'https://custom-mirror.example.com/api/v1/topics/messages' + ); + }); +}); + +describe('Environment.setLocalNodeAddress / setLocalNodeProtocol', () => { + afterEach(reset); + + it('rebuilds the localnode URLs with the supplied address', () => { + Environment.setLocalNodeAddress('hedera.local'); + Environment.setNetwork('localnode'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'http://hedera.local:5551/api/v1/topics/messages'); + }); + + it("falls back to 'localhost' when no address is supplied", () => { + Environment.setLocalNodeAddress(null); + Environment.setNetwork('localnode'); + assert.equal(Environment.localNodeAddress, 'localhost'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'http://localhost:5551/api/v1/topics/messages'); + }); + + it('the localnode protocol is read back via the getter', () => { + Environment.setLocalNodeProtocol('https'); + assert.equal(Environment.localNodeProtocol, 'https'); + }); + + it('subsequent setLocalNodeAddress() uses the updated protocol', () => { + Environment.setLocalNodeProtocol('https'); + Environment.setLocalNodeAddress('hedera.local'); + Environment.setNetwork('localnode'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://hedera.local:5551/api/v1/topics/messages'); + }); +}); + +describe('Environment.nodes / mirrorNodes accessors', () => { + afterEach(reset); + + it('round-trips nodes via setNodes/getter', () => { + const nodes = { 'hedera.local:50211': '0.0.3' }; + Environment.setNodes(nodes); + assert.deepEqual(Environment.nodes, nodes); + }); + + it('round-trips mirrorNodes via setMirrorNodes/getter', () => { + const list = ['https://mirror-1', 'https://mirror-2']; + Environment.setMirrorNodes(list); + assert.deepEqual(Environment.mirrorNodes, list); + }); +}); diff --git a/common/tests/unit-tests/fix-connection-string/fix-connection-string-edge.test.mjs b/common/tests/unit-tests/fix-connection-string/fix-connection-string-edge.test.mjs new file mode 100644 index 0000000000..ec60b359e2 --- /dev/null +++ b/common/tests/unit-tests/fix-connection-string/fix-connection-string-edge.test.mjs @@ -0,0 +1,24 @@ +import { assert } from 'chai'; +import fixConnectionString from '../../../dist/helpers/fix-connection-string.js'; + +describe('fixConnectionString — edge & quirks', () => { + it('returns a bare scheme for an empty string', () => { + assert.equal(fixConnectionString(''), 'mongodb://'); + }); + + it('prepends when nothing precedes :// (regex needs a char on the left)', () => { + assert.equal(fixConnectionString('://x'), 'mongodb://://x'); + }); + + it('prepends when nothing follows :// (regex needs a char on the right)', () => { + assert.equal(fixConnectionString('x://'), 'mongodb://x://'); + }); + + it('prepends to a lone :// token', () => { + assert.equal(fixConnectionString('://'), 'mongodb://://'); + }); + + it('leaves an uppercase scheme untouched (scheme-agnostic match)', () => { + assert.equal(fixConnectionString('MONGODB://h'), 'MONGODB://h'); + }); +}); diff --git a/common/tests/unit-tests/fix-connection-string/fix-connection-string.test.mjs b/common/tests/unit-tests/fix-connection-string/fix-connection-string.test.mjs new file mode 100644 index 0000000000..cdab5f05bc --- /dev/null +++ b/common/tests/unit-tests/fix-connection-string/fix-connection-string.test.mjs @@ -0,0 +1,31 @@ +import { assert } from 'chai'; +import fixConnectionString from '../../../dist/helpers/fix-connection-string.js'; + +describe('fixConnectionString', () => { + it('prepends mongodb:// to a bare host:port', () => { + assert.equal(fixConnectionString('localhost:27017'), 'mongodb://localhost:27017'); + }); + + it('prepends mongodb:// to a bare host', () => { + assert.equal(fixConnectionString('mongo'), 'mongodb://mongo'); + }); + + it('leaves an mongodb:// URI unchanged', () => { + assert.equal( + fixConnectionString('mongodb://user:pass@host/db'), + 'mongodb://user:pass@host/db', + ); + }); + + it('leaves an mongodb+srv:// URI unchanged', () => { + assert.equal( + fixConnectionString('mongodb+srv://cluster.example/db'), + 'mongodb+srv://cluster.example/db', + ); + }); + + it('leaves any scheme://… URI unchanged (not mongodb-aware)', () => { + // Documents the regex: anything with `://` is left alone. + assert.equal(fixConnectionString('postgres://x'), 'postgres://x'); + }); +}); diff --git a/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block-edge.test.mjs b/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block-edge.test.mjs new file mode 100644 index 0000000000..efad2cb277 --- /dev/null +++ b/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block-edge.test.mjs @@ -0,0 +1,26 @@ +import { assert } from 'chai'; +import { generateConfigForIntegrationBlock } from '../../../dist/helpers/generate-config-for-integration-block-helper.js'; + +describe('generateConfigForIntegrationBlock — fallback quirks', () => { + it('falls back to defaults when enum values are empty strings (|| is falsy-aware)', () => { + const cfg = generateConfigForIntegrationBlock( + { Input: '', Checkbox: '', Select: '' }, + { Special: '' }, + { UI: '' }, + undefined, + { RunEvent: '', ReleaseEvent: '', RefreshEvent: '' }, + ); + assert.equal(cfg.children, 'Special'); + assert.equal(cfg.control, 'UI'); + assert.deepEqual(cfg.output, ['RunEvent', 'ReleaseEvent', 'RefreshEvent']); + assert.equal(cfg.properties.find((p) => p.name === 'buttonName').type, 'Input'); + }); + + it('falls back per-field when only some output events are provided', () => { + const cfg = generateConfigForIntegrationBlock( + undefined, undefined, undefined, undefined, + { RunEvent: 'RUN_X' }, + ); + assert.deepEqual(cfg.output, ['RUN_X', 'ReleaseEvent', 'RefreshEvent']); + }); +}); diff --git a/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block.test.mjs b/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block.test.mjs new file mode 100644 index 0000000000..6ae2eae413 --- /dev/null +++ b/common/tests/unit-tests/generate-config-integration-block/generate-config-integration-block.test.mjs @@ -0,0 +1,51 @@ +import { assert } from 'chai'; +import { generateConfigForIntegrationBlock } from '../../../dist/helpers/generate-config-for-integration-block-helper.js'; + +describe('generateConfigForIntegrationBlock', () => { + it('produces the Integration button block shell', () => { + const cfg = generateConfigForIntegrationBlock(); + assert.equal(cfg.label, 'Integration button'); + assert.equal(cfg.title, "Add 'Integration button' Block"); + assert.isTrue(cfg.post); + assert.isTrue(cfg.get); + assert.isFalse(cfg.defaultEvent); + assert.deepEqual(cfg.input, []); + }); + + it('falls back to string defaults when no enum maps are passed', () => { + const cfg = generateConfigForIntegrationBlock(); + assert.equal(cfg.children, 'Special'); + assert.equal(cfg.control, 'UI'); + assert.deepEqual(cfg.output, ['RunEvent', 'ReleaseEvent', 'RefreshEvent']); + }); + + it('threads provided enum maps through children/control/output', () => { + const cfg = generateConfigForIntegrationBlock( + undefined, + { Special: 'SPECIAL_X' }, + { UI: 'UI_X' }, + undefined, + { RunEvent: 'RUN_X', ReleaseEvent: 'REL_X', RefreshEvent: 'REF_X' }, + ); + assert.equal(cfg.children, 'SPECIAL_X'); + assert.equal(cfg.control, 'UI_X'); + assert.deepEqual(cfg.output, ['RUN_X', 'REL_X', 'REF_X']); + }); + + it('uses the propertyType map for property field types', () => { + const cfg = generateConfigForIntegrationBlock({ Input: 'IN', Checkbox: 'CHK', Select: 'SEL' }); + const byName = (name) => cfg.properties.find((p) => p.name === name); + assert.equal(byName('buttonName').type, 'IN'); + assert.equal(byName('getFromCache').type, 'CHK'); + assert.equal(byName('integrationType').type, 'SEL'); + }); + + it('builds a required integrationType select backed by the integration registry', () => { + const cfg = generateConfigForIntegrationBlock(); + const integrationType = cfg.properties.find((p) => p.name === 'integrationType'); + assert.equal(integrationType.type, 'Select'); + assert.isTrue(integrationType.required); + assert.isArray(integrationType.items); + assert.isAbove(integrationType.items.length, 0); + }); +}); diff --git a/common/tests/unit-tests/generate-tls-options.test.mjs b/common/tests/unit-tests/generate-tls-options.test.mjs new file mode 100644 index 0000000000..1047bbf07a --- /dev/null +++ b/common/tests/unit-tests/generate-tls-options.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { GenerateTLSOptionsNats } from '../../dist/helpers/generate-tls-options.js'; + +describe('GenerateTLSOptionsNats', () => { + const original = { cert: process.env.TLS_CERT, key: process.env.TLS_KEY, ca: process.env.TLS_CA }; + + afterEach(() => { + if (original.cert === undefined) delete process.env.TLS_CERT; else process.env.TLS_CERT = original.cert; + if (original.key === undefined) delete process.env.TLS_KEY; else process.env.TLS_KEY = original.key; + if (original.ca === undefined) delete process.env.TLS_CA; else process.env.TLS_CA = original.ca; + }); + + it('returns undefined when TLS_CERT is missing', () => { + delete process.env.TLS_CERT; + process.env.TLS_KEY = 'k'; + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns undefined when TLS_KEY is missing', () => { + process.env.TLS_CERT = 'c'; + delete process.env.TLS_KEY; + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns { cert, key, ca } when both cert and key are set', () => { + process.env.TLS_CERT = 'CERT_PEM'; + process.env.TLS_KEY = 'KEY_PEM'; + process.env.TLS_CA = 'CA_PEM'; + assert.deepEqual(GenerateTLSOptionsNats(), { cert: 'CERT_PEM', key: 'KEY_PEM', ca: 'CA_PEM' }); + }); + + it('returns { cert, key, ca: undefined } when CA is unset', () => { + process.env.TLS_CERT = 'CERT_PEM'; + process.env.TLS_KEY = 'KEY_PEM'; + delete process.env.TLS_CA; + assert.deepEqual(GenerateTLSOptionsNats(), { cert: 'CERT_PEM', key: 'KEY_PEM', ca: undefined }); + }); +}); diff --git a/common/tests/unit-tests/generate-tls-options/generate-tls-options.test.mjs b/common/tests/unit-tests/generate-tls-options/generate-tls-options.test.mjs new file mode 100644 index 0000000000..f3294a9408 --- /dev/null +++ b/common/tests/unit-tests/generate-tls-options/generate-tls-options.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { GenerateTLSOptionsNats } from '../../../dist/helpers/generate-tls-options.js'; + +describe('GenerateTLSOptionsNats', () => { + let saved; + + beforeEach(() => { + saved = { + cert: process.env.TLS_CERT, + key: process.env.TLS_KEY, + ca: process.env.TLS_CA, + }; + delete process.env.TLS_CERT; + delete process.env.TLS_KEY; + delete process.env.TLS_CA; + }); + + afterEach(() => { + for (const [name, value] of Object.entries({ + TLS_CERT: saved.cert, + TLS_KEY: saved.key, + TLS_CA: saved.ca, + })) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + }); + + it('returns undefined when both TLS_CERT and TLS_KEY are unset', () => { + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns undefined when only TLS_CERT is set', () => { + process.env.TLS_CERT = 'cert-data'; + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns undefined when only TLS_KEY is set', () => { + process.env.TLS_KEY = 'key-data'; + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns the cert/key/ca trio when both are set', () => { + process.env.TLS_CERT = 'cert-data'; + process.env.TLS_KEY = 'key-data'; + process.env.TLS_CA = 'ca-data'; + assert.deepEqual(GenerateTLSOptionsNats(), { + cert: 'cert-data', + key: 'key-data', + ca: 'ca-data', + }); + }); + + it('omits CA gracefully when only cert+key are set', () => { + process.env.TLS_CERT = 'cert-data'; + process.env.TLS_KEY = 'key-data'; + const out = GenerateTLSOptionsNats(); + assert.equal(out.cert, 'cert-data'); + assert.equal(out.key, 'key-data'); + assert.equal(out.ca, undefined); + }); +}); diff --git a/common/tests/unit-tests/hashing/hashing-encrypt-edge.test.mjs b/common/tests/unit-tests/hashing/hashing-encrypt-edge.test.mjs new file mode 100644 index 0000000000..a410ddaaf1 --- /dev/null +++ b/common/tests/unit-tests/hashing/hashing-encrypt-edge.test.mjs @@ -0,0 +1,374 @@ +import { assert } from 'chai'; +import { Hashing } from '../../../dist/hedera-modules/hashing.js'; +import { EncryptUtils } from '../../../dist/helpers/encrypt-utils.js'; + +const hex = (d) => Buffer.from(d).toString('hex'); + +describe('@unit Hashing.sha256 edge cases', () => { + it('empty string digests to the canonical sha256 empty hash', () => { + assert.equal( + hex(Hashing.sha256.digest('')), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + it('empty Buffer matches empty string digest', () => { + assert.equal( + hex(Hashing.sha256.digest(Buffer.alloc(0))), + hex(Hashing.sha256.digest('')), + ); + }); + + it('is deterministic across repeated calls', () => { + const input = 'determinism-check'; + assert.equal(hex(Hashing.sha256.digest(input)), hex(Hashing.sha256.digest(input))); + }); + + it('always returns exactly 32 bytes regardless of input size', () => { + assert.equal(Hashing.sha256.digest('').length, 32); + assert.equal(Hashing.sha256.digest('a').length, 32); + assert.equal(Hashing.sha256.digest('a'.repeat(100000)).length, 32); + }); + + it('different inputs produce different digests (collision-shape)', () => { + assert.notEqual(hex(Hashing.sha256.digest('abc')), hex(Hashing.sha256.digest('abd'))); + }); + + it('single-bit-ish change avalanches the digest', () => { + const a = hex(Hashing.sha256.digest('hello')); + const b = hex(Hashing.sha256.digest('Hello')); + assert.notEqual(a, b); + }); + + it('handles unicode/multibyte input deterministically', () => { + const s = 'café-naïve-Ω-Ä'; + assert.equal(hex(Hashing.sha256.digest(s)), hex(Hashing.sha256.digest(s))); + }); + + it('hashes emoji input as its UTF-8 byte sequence', () => { + const emoji = '😀🚀🔥'; + assert.equal( + hex(Hashing.sha256.digest(emoji)), + hex(Hashing.sha256.digest(Buffer.from(emoji, 'utf8'))), + ); + }); + + it('string and equivalent UTF-8 Buffer hash identically', () => { + assert.equal( + hex(Hashing.sha256.digest('hello world')), + hex(Hashing.sha256.digest(Buffer.from('hello world', 'utf8'))), + ); + }); + + it('hashes raw binary (all byte values 0..255)', () => { + const bin = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); + assert.equal(Hashing.sha256.digest(bin).length, 32); + assert.equal(hex(Hashing.sha256.digest(bin)), hex(Hashing.sha256.digest(bin))); + }); + + it('hashes a very large 1MB input deterministically', () => { + const big = Buffer.alloc(1024 * 1024, 0xab); + assert.equal(hex(Hashing.sha256.digest(big)), hex(Hashing.sha256.digest(big))); + }); + + it('treats a Uint8Array view the same as the backing Buffer', () => { + const buf = Buffer.from([10, 20, 30, 40]); + const view = new Uint8Array(buf); + assert.equal(hex(Hashing.sha256.digest(view)), hex(Hashing.sha256.digest(buf))); + }); + + it('returns a Buffer instance', () => { + assert.isTrue(Buffer.isBuffer(Hashing.sha256.digest('x'))); + }); + + it('throws on null input', () => { + assert.throws(() => Hashing.sha256.digest(null)); + }); + + it('throws on undefined input', () => { + assert.throws(() => Hashing.sha256.digest(undefined)); + }); + + it('throws on a plain number input', () => { + assert.throws(() => Hashing.sha256.digest(12345)); + }); +}); + +describe('@unit Hashing.base58 edge cases', () => { + it('round-trips an empty byte array', () => { + const encoded = Hashing.base58.encode(new Uint8Array([])); + assert.equal(encoded, ''); + const decoded = Hashing.base58.decode(encoded); + assert.equal(decoded.length, 0); + }); + + it('round-trips arbitrary binary including leading zeros', () => { + const original = new Uint8Array([0, 0, 1, 2, 3, 255, 254, 0]); + const decoded = Hashing.base58.decode(Hashing.base58.encode(original)); + assert.deepEqual(Array.from(decoded), Array.from(original)); + }); + + it('preserves leading zero bytes as leading 1s', () => { + const encoded = Hashing.base58.encode(new Uint8Array([0, 0, 5])); + assert.match(encoded, /^11/); + }); + + it('round-trips a full 32-byte digest', () => { + const digest = Hashing.sha256.digest('payload'); + const decoded = Hashing.base58.decode(Hashing.base58.encode(digest)); + assert.deepEqual(Array.from(decoded), Array.from(digest)); + }); + + it('is deterministic for the same input', () => { + const data = new Uint8Array([9, 8, 7, 6, 5]); + assert.equal(Hashing.base58.encode(data), Hashing.base58.encode(data)); + }); + + it('produces output free of the ambiguous 0OIl alphabet', () => { + const encoded = Hashing.base58.encode(new Uint8Array([255, 255, 255, 255, 255])); + assert.notMatch(encoded, /[0OIl]/); + }); + + it('decode returns a Buffer instance', () => { + const decoded = Hashing.base58.decode(Hashing.base58.encode(new Uint8Array([1, 2, 3]))); + assert.isTrue(Buffer.isBuffer(decoded)); + }); + + it('different inputs yield different encodings', () => { + assert.notEqual( + Hashing.base58.encode(new Uint8Array([1, 2, 3])), + Hashing.base58.encode(new Uint8Array([1, 2, 4])), + ); + }); + + it('throws when decoding a string with non-base58 characters', () => { + assert.throws(() => Hashing.base58.decode('0OIl')); + }); +}); + +describe('@unit Hashing.base64 edge cases', () => { + it('round-trips an empty string', () => { + assert.equal(Hashing.base64.encode(''), ''); + assert.equal(Hashing.base64.decode(''), ''); + }); + + it('emits two padding chars for a 1-byte input', () => { + assert.equal(Hashing.base64.encode('a'), 'YQ=='); + }); + + it('emits one padding char for a 2-byte input', () => { + assert.equal(Hashing.base64.encode('ab'), 'YWI='); + }); + + it('emits no padding for a 3-byte input', () => { + assert.equal(Hashing.base64.encode('abc'), 'YWJj'); + }); + + it('round-trips unicode/multibyte content', () => { + const s = 'café-Ω-😀'; + assert.equal(Hashing.base64.decode(Hashing.base64.encode(s)), s); + }); + + it('is deterministic for the same input', () => { + assert.equal(Hashing.base64.encode('determinism'), Hashing.base64.encode('determinism')); + }); + + it('round-trips a long input', () => { + const s = 'x'.repeat(50000); + assert.equal(Hashing.base64.decode(Hashing.base64.encode(s)), s); + }); + + it('different inputs yield different encodings', () => { + assert.notEqual(Hashing.base64.encode('aaa'), Hashing.base64.encode('aab')); + }); + + it('decode returns a JS string (lossy for binary, text-only round-trip)', () => { + const digest = Hashing.sha256.digest('seed'); + const encoded = Hashing.base64.encode(digest); + const decoded = Hashing.base64.decode(encoded); + assert.isString(decoded); + assert.notEqual(Hashing.base64.encode(Buffer.from(decoded, 'binary')), encoded); + }); +}); + +describe('@unit EncryptUtils edge cases', function () { + this.timeout(60000); + + it('empty-buffer ciphertext cannot be decrypted (cryppo NULL-algorithm; latent bug)', async () => { + const enc = await EncryptUtils.encrypt(Buffer.alloc(0), 'key'); + assert.match(enc.toString('utf8'), /^null\./); + let err; + try { + await EncryptUtils.decrypt(enc, 'key'); + } catch (e) { + err = e; + } + assert.isDefined(err); + assert.match(err.message, /Unsupported algorithm: NULL/i); + }); + + it('round-trips unicode/emoji payload with byte fidelity', async () => { + const plain = Buffer.from('café-😀-Ω-naïve', 'utf8'); + const enc = await EncryptUtils.encrypt(plain, 'unicode-key'); + const dec = await EncryptUtils.decrypt(enc, 'unicode-key'); + assert.equal(dec.toString('utf8'), 'café-😀-Ω-naïve'); + }); + + it('round-trips arbitrary binary (all 256 byte values)', async () => { + const plain = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); + const enc = await EncryptUtils.encrypt(plain, 'bin-key'); + const dec = await EncryptUtils.decrypt(enc, 'bin-key'); + assert.deepEqual(Array.from(dec), Array.from(plain)); + }); + + it('round-trips a large 64KB payload', async () => { + const plain = Buffer.alloc(64 * 1024, 0x5a); + const enc = await EncryptUtils.encrypt(plain, 'big-key'); + const dec = await EncryptUtils.decrypt(enc, 'big-key'); + assert.deepEqual(Array.from(dec.subarray(0, 16)), Array.from(plain.subarray(0, 16))); + assert.equal(dec.length, plain.length); + }); + + it('accepts a unicode passphrase and round-trips', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('payload'), 'pä$$-😀-Ω'); + const dec = await EncryptUtils.decrypt(enc, 'pä$$-😀-Ω'); + assert.equal(dec.toString('utf8'), 'payload'); + }); + + it('produces ciphertext distinct from plaintext', async () => { + const plain = Buffer.from('visible?', 'utf8'); + const enc = await EncryptUtils.encrypt(plain, 'k'); + assert.notEqual(Buffer.from(enc).toString('utf8'), plain.toString('utf8')); + }); + + it('uses random IV/salt: two encrypts of same input differ', async () => { + const plain = Buffer.from('same-input', 'utf8'); + const a = await EncryptUtils.encrypt(plain, 'k'); + const b = await EncryptUtils.encrypt(plain, 'k'); + assert.notEqual(Buffer.from(a).toString('utf8'), Buffer.from(b).toString('utf8')); + }); + + it('decrypts the same ciphertext idempotently', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('idem'), 'k'); + const a = await EncryptUtils.decrypt(enc, 'k'); + const b = await EncryptUtils.decrypt(enc, 'k'); + assert.equal(a.toString('utf8'), b.toString('utf8')); + }); + + it('encrypt throws when key is empty string', async () => { + let err; + try { + await EncryptUtils.encrypt(Buffer.from('x'), ''); + } catch (e) { + err = e; + } + assert.isDefined(err); + assert.match(err.message, /no appropriate private key/i); + }); + + it('encrypt throws when key is undefined', async () => { + let err; + try { + await EncryptUtils.encrypt(Buffer.from('x'), undefined); + } catch (e) { + err = e; + } + assert.isDefined(err); + assert.match(err.message, /no appropriate private key/i); + }); + + it('encrypt throws when key is null', async () => { + let err; + try { + await EncryptUtils.encrypt(Buffer.from('x'), null); + } catch (e) { + err = e; + } + assert.isDefined(err); + assert.match(err.message, /no appropriate private key/i); + }); + + it('decrypt with the wrong key rejects (AES-GCM auth tag)', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('top-secret'), 'right-key'); + let threw = false; + try { + await EncryptUtils.decrypt(enc, 'wrong-key'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); + + it('decrypt of garbage serialized data rejects', async () => { + let threw = false; + try { + await EncryptUtils.decrypt(Buffer.from('not-a-valid-cryppo-string'), 'k'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); + + it('decrypt of an empty buffer rejects', async () => { + let threw = false; + try { + await EncryptUtils.decrypt(Buffer.alloc(0), 'k'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); + + it('decrypt of truncated ciphertext rejects', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('truncate-me-please'), 'k'); + const truncated = Buffer.from(enc).subarray(0, Math.floor(enc.length / 2)); + let threw = false; + try { + await EncryptUtils.decrypt(truncated, 'k'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); + + it('decrypt of tampered ciphertext body rejects', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('tamper-target'), 'k'); + const tampered = Buffer.from(enc); + tampered[tampered.length - 2] = tampered[tampered.length - 2] ^ 0xff; + let threw = false; + try { + await EncryptUtils.decrypt(tampered, 'k'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); + + it('encrypt accepts a string payload (Buffer.from coercion)', async () => { + const enc = await EncryptUtils.encrypt('plain-string-payload', 'k'); + const dec = await EncryptUtils.decrypt(enc, 'k'); + assert.equal(dec.toString('utf8'), 'plain-string-payload'); + }); + + it('returns a Buffer from encrypt', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('x'), 'k'); + assert.isTrue(Buffer.isBuffer(enc)); + }); + + it('returns a Buffer from decrypt', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('x'), 'k'); + const dec = await EncryptUtils.decrypt(enc, 'k'); + assert.isTrue(Buffer.isBuffer(dec)); + }); + + it('a key differing by one character fails to decrypt', async () => { + const enc = await EncryptUtils.encrypt(Buffer.from('secret'), 'passphrase'); + let threw = false; + try { + await EncryptUtils.decrypt(enc, 'passphras3'); + } catch { + threw = true; + } + assert.isTrue(threw); + }); +}); diff --git a/common/tests/unit-tests/hashing/hashing.test.mjs b/common/tests/unit-tests/hashing/hashing.test.mjs new file mode 100644 index 0000000000..9e7f28b87e --- /dev/null +++ b/common/tests/unit-tests/hashing/hashing.test.mjs @@ -0,0 +1,53 @@ +import { assert } from 'chai'; +import { Hashing } from '../../../dist/hedera-modules/hashing.js'; + +describe('Hashing.sha256', () => { + it('produces the documented sha256 of an empty string', () => { + const digest = Hashing.sha256.digest(''); + const hex = Buffer.from(digest).toString('hex'); + assert.equal( + hex, + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + it('hashes a UTF-8 string deterministically', () => { + const a = Hashing.sha256.digest('hello'); + const b = Hashing.sha256.digest('hello'); + assert.equal(Buffer.from(a).toString('hex'), Buffer.from(b).toString('hex')); + }); + + it('returns a 32-byte digest', () => { + const digest = Hashing.sha256.digest('anything'); + assert.equal(digest.length, 32); + }); +}); + +describe('Hashing.base58', () => { + it('round-trips arbitrary byte data', () => { + const original = new Uint8Array([1, 2, 3, 4, 250, 251, 252, 253]); + const encoded = Hashing.base58.encode(original); + const decoded = Hashing.base58.decode(encoded); + assert.deepEqual(Array.from(decoded), Array.from(original)); + }); + + it('produces alphanumeric output (no 0OIl)', () => { + const encoded = Hashing.base58.encode(new Uint8Array([0xff, 0xff, 0xff])); + assert.notMatch(encoded, /[0OIl]/); + }); +}); + +describe('Hashing.base64', () => { + it('round-trips ASCII strings', () => { + const original = 'hello world'; + const encoded = Hashing.base64.encode(original); + const decoded = Hashing.base64.decode(encoded); + assert.equal(decoded, original); + }); + + it('produces valid base64 padding for non-multiple-of-3 lengths', () => { + const encoded = Hashing.base64.encode('a'); + // 'a' is 1 byte → 4-char base64 with two '=' padding ('YQ==') + assert.equal(encoded, 'YQ=='); + }); +}); diff --git a/common/tests/unit-tests/hedera-environment/environment.test.mjs b/common/tests/unit-tests/hedera-environment/environment.test.mjs new file mode 100644 index 0000000000..475069546a --- /dev/null +++ b/common/tests/unit-tests/hedera-environment/environment.test.mjs @@ -0,0 +1,79 @@ +import { assert } from 'chai'; +import { Environment } from '../../../dist/hedera-modules/environment.js'; + +describe('common Environment.setNetwork', () => { + it('configures testnet endpoints', () => { + Environment.setMirrorNodes([]); + Environment.setNetwork('testnet'); + assert.equal(Environment.network, 'testnet'); + assert.match(Environment.HEDERA_MESSAGE_API, /testnet\.mirrornode\.hedera\.com/); + assert.match(Environment.HEDERA_ACCOUNT_API, /testnet/); + }); + + it('configures mainnet endpoints', () => { + Environment.setMirrorNodes([]); + Environment.setNetwork('mainnet'); + assert.equal(Environment.network, 'mainnet'); + assert.match(Environment.HEDERA_MESSAGE_API, /mainnet\.mirrornode\.hedera\.com/); + }); + + it('configures previewnet endpoints', () => { + Environment.setMirrorNodes([]); + Environment.setNetwork('previewnet'); + assert.match(Environment.HEDERA_MESSAGE_API, /preview\.mirrornode\.hedera\.com/); + }); + + it('configures localnode endpoints', () => { + Environment.setMirrorNodes([]); + Environment.setNetwork('localnode'); + assert.equal(Environment.network, 'localnode'); + assert.match(Environment.HEDERA_MESSAGE_API, /localhost.*topics\/messages/); + }); + + it('throws on unknown network', () => { + assert.throws(() => Environment.setNetwork('mystery'), /Unknown network/); + }); + + it('overrides API URLs when mirror nodes are configured', () => { + Environment.setMirrorNodes(['https://my-mirror.example']); + Environment.setNetwork('testnet'); + assert.equal(Environment.HEDERA_MESSAGE_API, 'https://my-mirror.example/api/v1/topics/messages'); + assert.equal(Environment.HEDERA_TOPIC_API, 'https://my-mirror.example/api/v1/topics'); + Environment.setMirrorNodes([]); + }); + + it('prepends https:// to mirror nodes that lack a scheme', () => { + Environment.setMirrorNodes(['my-mirror.example']); + Environment.setNetwork('testnet'); + assert.match(Environment.HEDERA_MESSAGE_API, /^https:\/\/my-mirror\.example/); + Environment.setMirrorNodes([]); + }); +}); + +describe('common Environment.setLocalNodeAddress / setLocalNodeProtocol', () => { + it('rebuilds localnode URLs with the supplied address', () => { + Environment.setLocalNodeProtocol('http'); + Environment.setLocalNodeAddress('1.2.3.4'); + Environment.setMirrorNodes([]); + Environment.setNetwork('localnode'); + assert.equal(Environment.localNodeAddress, '1.2.3.4'); + assert.match(Environment.HEDERA_MESSAGE_API, /1\.2\.3\.4:5551/); + }); + + it("falls back to 'localhost' when no address is supplied", () => { + Environment.setLocalNodeAddress(undefined); + Environment.setNetwork('localnode'); + assert.equal(Environment.localNodeAddress, 'localhost'); + }); +}); + +describe('common Environment.nodes / mirrorNodes accessors', () => { + it('round-trips nodes and mirrorNodes via setters', () => { + Environment.setNodes({ foo: '0.0.3' }); + Environment.setMirrorNodes(['m1.example']); + assert.deepEqual(Environment.nodes, { foo: '0.0.3' }); + assert.deepEqual(Environment.mirrorNodes, ['m1.example']); + Environment.setNodes({}); + Environment.setMirrorNodes([]); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/contract-message.test.mjs b/common/tests/unit-tests/hedera-modules/contract-message.test.mjs new file mode 100644 index 0000000000..28fd942cfe --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/contract-message.test.mjs @@ -0,0 +1,93 @@ +import { assert } from 'chai'; +import { ContractMessage } from '../../../dist/hedera-modules/message/contract-message.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; + +const body = (over = {}) => ({ + id: 'cid', status: 'ISSUE', + type: MessageType.Contract, action: MessageAction.CreateContract, + lang: 'en', account: '0.0.9', + contractId: '0.0.100', description: 'desc', contractType: 'WIPE', + owner: 'did:owner', version: '2.1.0', ...over +}); + +describe('ContractMessage', () => { + it('constructs with the Contract type', () => { + const m = new ContractMessage(MessageAction.CreateContract); + assert.equal(m.type, MessageType.Contract); + assert.equal(m.action, MessageAction.CreateContract); + }); + + it('setDocument copies contract fields (type → contractType)', () => { + const m = new ContractMessage(MessageAction.CreateContract); + m.setDocument({ contractId: '0.0.100', description: 'd', type: 'RETIRE', owner: 'o', version: '1.2.3' }); + assert.equal(m.contractId, '0.0.100'); + assert.equal(m.description, 'd'); + assert.equal(m.contractType, 'RETIRE'); + assert.equal(m.owner, 'o'); + assert.equal(m.version, '1.2.3'); + }); + + it('toMessageObject serializes with null id/status', () => { + const m = new ContractMessage(MessageAction.CreateContract); + m.setDocument({ contractId: '0.0.100', type: 'WIPE' }); + const obj = m.toMessageObject(); + assert.equal(obj.id, null); + assert.equal(obj.status, null); + assert.equal(obj.contractId, '0.0.100'); + assert.equal(obj.contractType, 'WIPE'); + }); + + it('toDocuments resolves to []', async () => { + assert.deepEqual(await new ContractMessage(MessageAction.CreateContract).toDocuments(), []); + }); + + it('loadDocuments returns the instance', () => { + const m = new ContractMessage(MessageAction.CreateContract); + assert.equal(m.loadDocuments([]), m); + }); + + it('validate is true and getUrls is empty', () => { + const m = new ContractMessage(MessageAction.CreateContract); + assert.equal(m.validate(), true); + assert.deepEqual(m.getUrls(), []); + }); + + it('fromMessage throws on an empty message', () => { + assert.throws(() => ContractMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => ContractMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Contract type', () => { + assert.throws(() => ContractMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps contract fields', () => { + const m = ContractMessage.fromMessageObject(body()); + assert.equal(m.contractId, '0.0.100'); + assert.equal(m.contractType, 'WIPE'); + assert.equal(m.owner, 'did:owner'); + assert.equal(m.version, '2.1.0'); + }); + + it('fromMessageObject defaults version to 1.0.0 when missing', () => { + const m = ContractMessage.fromMessageObject(body({ version: undefined })); + assert.equal(m.version, '1.0.0'); + }); + + it('getOwner returns the contract owner', () => { + const m = ContractMessage.fromMessageObject(body()); + assert.equal(m.getOwner(), 'did:owner'); + }); + + it('toJson / fromJson round-trips contract fields', () => { + const original = ContractMessage.fromMessageObject(body()); + const restored = ContractMessage.fromJson(original.toJson()); + assert.equal(restored.contractId, '0.0.100'); + assert.equal(restored.contractType, 'WIPE'); + assert.equal(restored.version, '2.1.0'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/did-url.test.mjs b/common/tests/unit-tests/hedera-modules/did-url.test.mjs new file mode 100644 index 0000000000..458047d3d6 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/did-url.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { DidURL } from '../../../dist/hedera-modules/vcjs/did/components/did-url.js'; + +describe('DidURL.getController', () => { + it('returns the bare DID when there is no fragment/path/query', () => { + assert.equal(DidURL.getController('did:hedera:testnet:abc_0.0.1'), 'did:hedera:testnet:abc_0.0.1'); + }); + + it('strips a #fragment', () => { + assert.equal(DidURL.getController('did:abc#key-1'), 'did:abc'); + }); + + it('strips a /path', () => { + assert.equal(DidURL.getController('did:abc/path'), 'did:abc'); + }); + + it('strips a ?query', () => { + assert.equal(DidURL.getController('did:abc?versionId=1'), 'did:abc'); + }); + + it('throws on an empty string', () => { + assert.throws(() => DidURL.getController(''), /DID cannot be/); + }); + + it('throws on a non-string', () => { + assert.throws(() => DidURL.getController(123), /DID cannot be/); + assert.throws(() => DidURL.getController(null), /DID cannot be/); + }); +}); + +describe('DidURL.getPath', () => { + it('returns the fragment portion', () => { + assert.equal(DidURL.getPath('did:abc#key-1'), 'key-1'); + }); + + it('joins remaining segments without delimiters', () => { + assert.equal(DidURL.getPath('did:abc/p?q'), 'pq'); + }); + + it('returns null when there is no fragment/path/query', () => { + assert.equal(DidURL.getPath('did:abc'), null); + }); + + it('throws on an empty string', () => { + assert.throws(() => DidURL.getPath(''), /DID cannot be/); + }); + + it('throws on a non-string', () => { + assert.throws(() => DidURL.getPath(undefined), /DID cannot be/); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/comment-discussion-messages.test.mjs b/common/tests/unit-tests/hedera-modules/message/comment-discussion-messages.test.mjs new file mode 100644 index 0000000000..8fc6594bce --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/comment-discussion-messages.test.mjs @@ -0,0 +1,197 @@ +import { assert } from 'chai'; +import { + CommentMessage, + DiscussionMessage, + MessageType, + MessageAction +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('CommentMessage', () => { + const body = (over = {}) => ({ + id: 'id1', status: 'ISSUE', type: MessageType.PolicyComment, action: MessageAction.CreateComment, + lang: 'en', account: '0.0.1', + hash: 'h1', target: 't1', discussion: 'd1', cid: 'cid1', uri: 'ipfs://cid1', ...over + }); + + it('constructs with the PolicyComment type', () => { + assert.equal(new CommentMessage(MessageAction.CreateComment).type, MessageType.PolicyComment); + }); + + it('default lang is en-US', () => { + assert.equal(new CommentMessage(MessageAction.CreateComment).lang, 'en-US'); + }); + + it('fromMessage throws on empty string', () => { + assert.throws(() => CommentMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => CommentMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromJson throws on empty json', () => { + assert.throws(() => CommentMessage.fromJson(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps comment fields', () => { + const m = CommentMessage.fromMessageObject(body()); + assert.equal(m.hash, 'h1'); + assert.equal(m.target, 't1'); + assert.equal(m.discussion, 'd1'); + }); + + it('fromMessageObject builds url from cid', () => { + const m = CommentMessage.fromMessageObject(body()); + assert.equal(m.getUrl().cid, 'cid1'); + assert.equal(m.getDocumentUrl('cid'), 'cid1'); + }); + + it('fromMessage parses a serialized object', () => { + const m = CommentMessage.fromMessage(JSON.stringify(body())); + assert.equal(m.hash, 'h1'); + assert.equal(m.target, 't1'); + }); + + it('setDocument copies entity fields', () => { + const m = new CommentMessage(MessageAction.CreateComment); + m.setDocument({ hash: 'hh', target: 'tt', discussionMessageId: 'dd', document: { a: 1 } }); + assert.equal(m.hash, 'hh'); + assert.equal(m.target, 'tt'); + assert.equal(m.discussion, 'dd'); + assert.deepEqual(m.getDocument(), { a: 1 }); + }); + + it('setDocument defaults missing target/discussion to empty', () => { + const m = new CommentMessage(MessageAction.CreateComment); + m.setDocument({ hash: 'hh', document: {} }); + assert.equal(m.target, ''); + assert.equal(m.discussion, ''); + }); + + it('toMessageObject reflects type and action', () => { + const m = CommentMessage.fromMessageObject(body()); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyComment); + assert.equal(obj.action, MessageAction.CreateComment); + assert.equal(obj.hash, 'h1'); + }); + + it('validate returns true', () => { + assert.isTrue(new CommentMessage(MessageAction.CreateComment).validate()); + }); + + it('toJson round-trips through fromJson', () => { + const m = CommentMessage.fromMessageObject(body()); + m.document = { content: 'hello' }; + const json = m.toJson(); + const restored = CommentMessage.fromJson(json); + assert.equal(restored.hash, 'h1'); + assert.equal(restored.target, 't1'); + assert.equal(restored.discussion, 'd1'); + assert.deepEqual(restored.document, { content: 'hello' }); + }); + + it('toDocuments rejects without a key', async () => { + const m = new CommentMessage(MessageAction.CreateComment); + m.document = { a: 1 }; + let threw = false; + try { await m.toDocuments(''); } catch (e) { threw = true; } + assert.isTrue(threw); + }); + + it('toDocuments/loadDocuments round-trips with a key', async () => { + const m = new CommentMessage(MessageAction.CreateComment); + m.document = { secret: 'value' }; + const docs = await m.toDocuments('pass'); + assert.isArray(docs); + const loaded = await m.loadDocuments(docs.map((b) => b.toString()), 'pass'); + assert.equal(loaded.document, JSON.stringify({ secret: 'value' })); + }); +}); + +describe('DiscussionMessage', () => { + const body = (over = {}) => ({ + id: 'id1', status: 'ISSUE', type: MessageType.PolicyDiscussion, action: MessageAction.CreateComment, + lang: 'en', account: '0.0.1', + hash: 'h1', target: 't1', relationships: ['r1', 'r2'], cid: 'cid1', uri: 'ipfs://cid1', ...over + }); + + it('constructs with the PolicyDiscussion type', () => { + assert.equal(new DiscussionMessage(MessageAction.CreateComment).type, MessageType.PolicyDiscussion); + }); + + it('fromMessage throws on empty string', () => { + assert.throws(() => DiscussionMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => DiscussionMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromJson throws on empty json', () => { + assert.throws(() => DiscussionMessage.fromJson(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps discussion fields', () => { + const m = DiscussionMessage.fromMessageObject(body()); + assert.equal(m.hash, 'h1'); + assert.equal(m.target, 't1'); + assert.deepEqual(m.relationships, ['r1', 'r2']); + }); + + it('fromMessageObject builds url from cid', () => { + const m = DiscussionMessage.fromMessageObject(body()); + assert.equal(m.getUrl().cid, 'cid1'); + assert.equal(m.getDocumentUrl('url'), 'ipfs://cid1'); + }); + + it('setDocument copies entity fields', () => { + const m = new DiscussionMessage(MessageAction.CreateComment); + m.setDocument({ hash: 'hh', target: 'tt', relationships: ['x'], document: { a: 1 } }); + assert.equal(m.hash, 'hh'); + assert.equal(m.target, 'tt'); + assert.deepEqual(m.relationships, ['x']); + }); + + it('setDocument defaults missing target/relationships', () => { + const m = new DiscussionMessage(MessageAction.CreateComment); + m.setDocument({ hash: 'hh', document: {} }); + assert.equal(m.target, ''); + assert.deepEqual(m.relationships, []); + }); + + it('toMessageObject reflects fields', () => { + const obj = DiscussionMessage.fromMessageObject(body()).toMessageObject(); + assert.equal(obj.type, MessageType.PolicyDiscussion); + assert.deepEqual(obj.relationships, ['r1', 'r2']); + }); + + it('validate returns true', () => { + assert.isTrue(new DiscussionMessage(MessageAction.CreateComment).validate()); + }); + + it('toJson round-trips through fromJson', () => { + const m = DiscussionMessage.fromMessageObject(body()); + m.document = { content: 'hi' }; + const restored = DiscussionMessage.fromJson(m.toJson()); + assert.equal(restored.hash, 'h1'); + assert.deepEqual(restored.relationships, ['r1', 'r2']); + assert.deepEqual(restored.document, { content: 'hi' }); + }); + + it('toDocuments rejects without a key', async () => { + const m = new DiscussionMessage(MessageAction.CreateComment); + m.document = { a: 1 }; + let threw = false; + try { await m.toDocuments(''); } catch (e) { threw = true; } + assert.isTrue(threw); + }); + + it('toDocuments/loadDocuments round-trips with a key', async () => { + const m = new DiscussionMessage(MessageAction.CreateComment); + m.document = { secret: 'v' }; + const docs = await m.toDocuments('pass'); + const loaded = await m.loadDocuments(docs.map((b) => b.toString()), 'pass'); + assert.equal(loaded.document, JSON.stringify({ secret: 'v' })); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/did-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/did-message-extra.test.mjs new file mode 100644 index 0000000000..131c8761f1 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/did-message-extra.test.mjs @@ -0,0 +1,134 @@ +import { assert } from 'chai'; +import { DIDMessage } from '../../../../dist/hedera-modules/message/did-message.js'; +import { MessageStatus } from '../../../../dist/hedera-modules/message/message.js'; +import { MessageType } from '../../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../../dist/hedera-modules/message/message-action.js'; + +describe('DIDMessage relationships', () => { + it('getRelationships defaults to an empty array', () => { + const m = new DIDMessage(MessageAction.CreateDID); + assert.deepEqual(m.getRelationships(), []); + }); + + it('setRelationships then getRelationships round-trips', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.setRelationships(['a', 'b']); + assert.deepEqual(m.getRelationships(), ['a', 'b']); + }); +}); + +describe('DIDMessage.toMessageObject', () => { + it('omits relationships when empty', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.did = 'did:1'; + m.setUrls([{ cid: 'c', url: 'ipfs://c' }]); + const obj = m.toMessageObject(); + assert.equal(obj.did, 'did:1'); + assert.equal(obj.type, MessageType.DIDDocument); + assert.notProperty(obj, 'relationships'); + }); + + it('includes relationships when present', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.setUrls([{ cid: 'c', url: 'ipfs://c' }]); + m.setRelationships(['r1']); + const obj = m.toMessageObject(); + assert.deepEqual(obj.relationships, ['r1']); + }); + + it('embeds cid/uri from the first URL', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.setUrls([{ cid: 'c1', url: 'ipfs://c1' }]); + const obj = m.toMessageObject(); + assert.equal(obj.cid, 'c1'); + assert.equal(obj.uri, 'ipfs://c1'); + }); +}); + +describe('DIDMessage document handling', () => { + it('getDocument returns the loaded document', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.loadDocuments([JSON.stringify({ name: 'n' })]); + assert.deepEqual(m.getDocument(), { name: 'n' }); + }); + + it('loadDocuments ignores non-array input', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.loadDocuments(null); + assert.isUndefined(m.getDocument()); + }); + + it('toDocuments serializes the document into a single buffer', async () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.document = { x: 1 }; + const docs = await m.toDocuments(); + assert.equal(docs.length, 1); + assert.deepEqual(JSON.parse(docs[0].toString()), { x: 1 }); + }); +}); + +describe('DIDMessage.fromJson / toJson', () => { + it('fromJson throws on empty input', () => { + assert.throws(() => DIDMessage.fromJson(null), /JSON Object is empty/); + }); + + it('round-trips did/relationships/document via toJson/fromJson', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.setId('id-1'); + m.did = 'did:9'; + m.relationships = ['x']; + m.document = { a: 1 }; + const json = m.toJson(); + assert.equal(json.did, 'did:9'); + assert.deepEqual(json.relationships, ['x']); + assert.deepEqual(json.document, { a: 1 }); + + const back = DIDMessage.fromJson(json); + assert.equal(back.did, 'did:9'); + assert.deepEqual(back.getRelationships(), ['x']); + assert.deepEqual(back.getDocument(), { a: 1 }); + }); +}); + +describe('DIDMessage misc', () => { + it('getOwner returns the did', () => { + const m = new DIDMessage(MessageAction.CreateDID); + m.did = 'did:owner'; + assert.equal(m.getOwner(), 'did:owner'); + }); + + it('validate returns true', () => { + assert.isTrue(new DIDMessage(MessageAction.CreateDID).validate()); + }); + + it('toHash is deterministic over status/type/action/lang/did', () => { + const a = new DIDMessage(MessageAction.CreateDID); + a.did = 'did:1'; + const b = new DIDMessage(MessageAction.CreateDID); + b.did = 'did:1'; + assert.equal(a.toHash(), b.toHash()); + }); + + it('toHash differs when did differs', () => { + const a = new DIDMessage(MessageAction.CreateDID); + a.did = 'did:1'; + const b = new DIDMessage(MessageAction.CreateDID); + b.did = 'did:2'; + assert.notEqual(a.toHash(), b.toHash()); + }); + + it('fromMessageObject maps did/relationships and builds a url from cid', () => { + const m = DIDMessage.fromMessageObject({ + id: 'i', + status: MessageStatus.ISSUE, + type: MessageType.DIDDocument, + action: MessageAction.CreateDID, + did: 'did:5', + cid: 'cidX', + relationships: ['p'] + }); + assert.equal(m.did, 'did:5'); + assert.deepEqual(m.getRelationships(), ['p']); + assert.equal(m.getUrl().cid, 'cidX'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/label-document-message.test.mjs b/common/tests/unit-tests/hedera-modules/message/label-document-message.test.mjs new file mode 100644 index 0000000000..01a45917ff --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/label-document-message.test.mjs @@ -0,0 +1,123 @@ +import { assert } from 'chai'; + +import { + LabelDocumentMessage, + MessageAction, + MessageType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('LabelDocumentMessage', function () { + const body = { + id: 'msgId', + status: 'ISSUE', + type: MessageType.VPDocument, + action: MessageAction.CreateLabelDocument, + issuer: 'did:hedera:testnet:issuer', + relationships: ['rel1', 'rel2'], + target: 'targetId', + definition: 'defId', + cid: 'testCid', + url: 'ipfs://testCid' + }; + + it('default type is VPDocument', function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + assert.equal(m.type, MessageType.VPDocument); + }); + + it('fromMessage throws on empty', function () { + assert.throws(() => LabelDocumentMessage.fromMessage(null), 'Message Object is empty'); + }); + + it('fromMessageObject throws on empty', function () { + assert.throws(() => LabelDocumentMessage.fromMessageObject(null), 'JSON Object is empty'); + }); + + it('fromJson throws on empty', function () { + assert.throws(() => LabelDocumentMessage.fromJson(null), 'JSON Object is empty'); + }); + + it('fromMessageObject maps fields', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + assert.equal(m.action, MessageAction.CreateLabelDocument); + assert.equal(m.issuer, body.issuer); + assert.deepEqual(m.relationships, body.relationships); + assert.equal(m.target, body.target); + assert.equal(m.definition, body.definition); + }); + + it('fromMessage parses JSON string', function () { + const m = LabelDocumentMessage.fromMessage(JSON.stringify(body)); + assert.equal(m.issuer, body.issuer); + }); + + it('getters return mapped values', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + assert.deepEqual(m.getRelationships(), body.relationships); + assert.equal(m.getTarget(), body.target); + assert.equal(m.getDefinition(), body.definition); + assert.equal(m.getOwner(), body.issuer); + }); + + it('getRelationships defaults to empty array', function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + assert.deepEqual(m.getRelationships(), []); + }); + + it('setRelationships dedupes', function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + m.setRelationships(['a', 'a', 'b']); + assert.deepEqual(m.relationships, ['a', 'b']); + }); + + it('setTarget and setDefinition', function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + m.setTarget('t'); + m.setDefinition({ messageId: 'def-msg' }); + assert.equal(m.getTarget(), 't'); + assert.equal(m.getDefinition(), 'def-msg'); + }); + + it('validate returns true', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + assert.isTrue(m.validate()); + }); + + it('toMessageObject contains type/action/issuer', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.VPDocument); + assert.equal(obj.action, MessageAction.CreateLabelDocument); + assert.equal(obj.issuer, body.issuer); + assert.equal(obj.target, body.target); + }); + + it('loadDocuments + getDocument', async function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + await m.loadDocuments([JSON.stringify({ a: 1 })]); + assert.deepEqual(m.getDocument(), { a: 1 }); + }); + + it('toDocuments returns buffer', async function () { + const m = new LabelDocumentMessage(MessageAction.CreateLabelDocument); + await m.loadDocuments([JSON.stringify({ a: 1 })]); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), JSON.stringify({ a: 1 })); + }); + + it('toJson then fromJson round-trips', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + const json = m.toJson(); + const back = LabelDocumentMessage.fromJson(json); + assert.equal(back.issuer, m.issuer); + assert.deepEqual(back.relationships, m.relationships); + assert.equal(back.target, m.target); + assert.equal(back.definition, m.definition); + }); + + it('getDocumentUrl returns cid', function () { + const m = LabelDocumentMessage.fromMessageObject(body); + assert.equal(m.getUrl().cid, body.cid); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/label-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/label-message-extra.test.mjs new file mode 100644 index 0000000000..808d4b8ded --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/label-message-extra.test.mjs @@ -0,0 +1,179 @@ +import { assert } from 'chai'; + +import { + LabelMessage, + MessageAction, + MessageType, + MessageStatus, + UrlType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('LabelMessage extra', function () { + const item = { + name: 'Label 1', + description: 'desc', + owner: 'did:hedera:testnet:owner', + uuid: 'uuid-1', + policyTopicId: '0.0.111', + policyInstanceTopicId: '0.0.222' + }; + + const body = { + id: 'msg-id', + status: MessageStatus.ISSUE, + type: MessageType.PolicyLabel, + action: MessageAction.PublishPolicyLabel, + lang: 'en-US', + name: 'Label 1', + description: 'desc', + owner: 'did:hedera:testnet:owner', + uuid: 'uuid-1', + policyTopicId: '0.0.111', + policyInstanceTopicId: '0.0.222', + cid: 'cid123' + }; + + it('setDocument copies fields and stores the zip as a Buffer', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.setDocument(item, Buffer.from('zip-data')); + assert.equal(m.name, item.name); + assert.equal(m.description, item.description); + assert.equal(m.owner, item.owner); + assert.equal(m.uuid, item.uuid); + assert.equal(m.policyTopicId, item.policyTopicId); + assert.equal(m.policyInstanceTopicId, item.policyInstanceTopicId); + assert.isTrue(Buffer.isBuffer(m.config)); + assert.equal(m.config.toString(), 'zip-data'); + }); + + it('setDocument accepts an ArrayBuffer zip', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + const source = Buffer.from('abc'); + const arrayBuffer = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength); + m.setDocument(item, arrayBuffer); + assert.isTrue(Buffer.isBuffer(m.config)); + assert.equal(m.config.toString(), 'abc'); + }); + + it('getDocument returns the stored buffer', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.setDocument(item, Buffer.from('zzz')); + assert.equal(m.getDocument().toString(), 'zzz'); + }); + + it('toDocuments resolves to the config buffer when set', async function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.setDocument(item, Buffer.from('doc')); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), 'doc'); + }); + + it('toDocuments resolves to an empty array when no config', async function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + const docs = await m.toDocuments(); + assert.deepEqual(docs, []); + }); + + it('loadDocuments stores a single document as a Buffer and returns this', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + const result = m.loadDocuments(['payload']); + assert.equal(result, m); + assert.isTrue(Buffer.isBuffer(m.config)); + assert.equal(m.config.toString(), 'payload'); + }); + + it('loadDocuments ignores arrays with more than one document', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.loadDocuments(['a', 'b']); + assert.isUndefined(m.config); + }); + + it('loadDocuments ignores null input', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.loadDocuments(null); + assert.isUndefined(m.config); + }); + + it('toMessageObject exposes mapped fields and cid/uri after fromMessageObject', function () { + const m = LabelMessage.fromMessageObject(body); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyLabel); + assert.equal(obj.name, body.name); + assert.equal(obj.description, body.description); + assert.equal(obj.owner, body.owner); + assert.equal(obj.uuid, body.uuid); + assert.equal(obj.policyTopicId, body.policyTopicId); + assert.equal(obj.policyInstanceTopicId, body.policyInstanceTopicId); + assert.equal(obj.cid, 'cid123'); + assert.equal(obj.uri, 'ipfs://cid123'); + }); + + it('fromMessageObject drops the url entry when cid is missing', function () { + const m = LabelMessage.fromMessageObject({ ...body, cid: undefined }); + assert.deepEqual(m.getUrls(), []); + assert.isUndefined(m.getDocumentUrl(UrlType.cid)); + }); + + it('getUrl returns the same array as getUrls', function () { + const m = LabelMessage.fromMessageObject(body); + assert.deepEqual(m.getUrl(), m.getUrls()); + }); + + it('getDocumentUrl and getContextUrl read url slots 0 and 1', function () { + const m = LabelMessage.fromMessageObject(body); + assert.equal(m.getDocumentUrl(UrlType.cid), 'cid123'); + assert.equal(m.getDocumentUrl(UrlType.url), 'ipfs://cid123'); + assert.isUndefined(m.getContextUrl(UrlType.cid)); + }); + + it('validate always returns true', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + assert.isTrue(m.validate()); + }); + + it('fromJson restores the label fields', function () { + const m = LabelMessage.fromJson({ + action: MessageAction.PublishPolicyLabel, + name: 'n', + description: 'd', + owner: 'o', + uuid: 'u', + policyTopicId: 't1', + policyInstanceTopicId: 't2', + config: Buffer.from('cfg') + }); + assert.equal(m.name, 'n'); + assert.equal(m.description, 'd'); + assert.equal(m.owner, 'o'); + assert.equal(m.uuid, 'u'); + assert.equal(m.policyTopicId, 't1'); + assert.equal(m.policyInstanceTopicId, 't2'); + assert.equal(m.config.toString(), 'cfg'); + }); + + it('getOwner returns the owner did', function () { + const m = LabelMessage.fromMessageObject(body); + assert.equal(m.getOwner(), body.owner); + }); + + it('toMessage embeds the label payload for ISSUE status', function () { + const m = new LabelMessage(MessageAction.PublishPolicyLabel); + m.setDocument(item, Buffer.from('x')); + const parsed = JSON.parse(m.toMessage()); + assert.equal(parsed.status, MessageStatus.ISSUE); + assert.equal(parsed.type, MessageType.PolicyLabel); + assert.equal(parsed.name, item.name); + assert.equal(parsed.uuid, item.uuid); + }); + + it('fromMessage with REVOKE status restores the revoke reason', function () { + const m = LabelMessage.fromMessage(JSON.stringify({ + ...body, + status: MessageStatus.REVOKE, + revokeMessage: 'revoked!', + reason: 'Document Revoked' + })); + assert.isTrue(m.isRevoked()); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/message-base.test.mjs b/common/tests/unit-tests/hedera-modules/message/message-base.test.mjs new file mode 100644 index 0000000000..6bf8a8eb1c --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/message-base.test.mjs @@ -0,0 +1,187 @@ +import { assert } from 'chai'; +import { Message, MessageStatus } from '../../../../dist/hedera-modules/message/message.js'; +import { MessageType } from '../../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../../dist/hedera-modules/message/message-action.js'; +import { UrlType } from '../../../../dist/hedera-modules/message/url.interface.js'; + +class TestMessage extends Message { + constructor() { + super(MessageAction.CreateVC, MessageType.VCDocument); + } + toMessageObject() { + return { type: this.type, action: this.action, foo: 'bar' }; + } + async toDocuments() { + return []; + } + loadDocuments() { + return this; + } + validate() { + return true; + } +} + +describe('Message base: simple accessors', () => { + let m; + beforeEach(() => { + m = new TestMessage(); + }); + + it('constructor seeds type/lang/action/status and a messageId', () => { + assert.equal(m.type, MessageType.VCDocument); + assert.equal(m.lang, 'en-US'); + assert.equal(m.action, MessageAction.CreateVC); + assert.isString(m.getMessageId()); + assert.equal(m.responseType, 'str'); + }); + + it('setId/getId round-trips', () => { + m.setId('id-1'); + assert.equal(m.getId(), 'id-1'); + }); + + it('setPayer / setOwnerAccount / setIndex assign fields', () => { + m.setPayer('0.0.10'); + m.setOwnerAccount('0.0.11'); + m.setIndex(5); + assert.equal(m.payer, '0.0.10'); + assert.equal(m.account, '0.0.11'); + assert.equal(m.index, 5); + }); + + it('setTopicId/getTopicId stringifies; null when unset', () => { + assert.isNull(m.getTopicId()); + m.setTopicId('0.0.99'); + assert.equal(m.getTopicId(), '0.0.99'); + }); + + it('setLang falls back to en-US for falsy input', () => { + m.setLang('uk'); + assert.equal(m.lang, 'uk'); + m.setLang(''); + assert.equal(m.lang, 'en-US'); + }); + + it('setMemo/getMemo round-trips', () => { + m.setMemo('hello'); + assert.equal(m.getMemo(), 'hello'); + }); + + it('getOwner returns null at the base level', () => { + assert.isNull(m.getOwner()); + }); + + it('getRelationships returns an empty array at the base level', () => { + assert.deepEqual(m.getRelationships(), []); + }); +}); + +describe('Message base: URLs', () => { + let m; + beforeEach(() => { + m = new TestMessage(); + }); + + it('setUrls drops entries without a cid', () => { + m.setUrls([{ cid: 'a', url: 'ua' }, { url: 'no-cid' }, { cid: 'b', url: 'ub' }]); + assert.equal(m.getUrls().length, 2); + }); + + it('getUrl returns the same array as getUrls', () => { + m.setUrls([{ cid: 'a', url: 'ua' }]); + assert.deepEqual(m.getUrl(), m.getUrls()); + }); + + it('getUrlValue returns cid or url by type', () => { + m.setUrls([{ cid: 'a', url: 'ua' }]); + assert.equal(m.getUrlValue(0, UrlType.cid), 'a'); + assert.equal(m.getUrlValue(0, UrlType.url), 'ua'); + }); + + it('getUrlValue returns undefined for out-of-range index', () => { + m.setUrls([{ cid: 'a', url: 'ua' }]); + assert.isUndefined(m.getUrlValue(5, UrlType.cid)); + }); + + it('isDocuments reflects URL presence at an index', () => { + assert.isFalse(m.isDocuments()); + m.setUrls([{ cid: 'a', url: 'ua' }]); + assert.isTrue(m.isDocuments(0)); + assert.isFalse(m.isDocuments(1)); + }); +}); + +describe('Message base: status transitions', () => { + it('revoke sets REVOKE status and RevokeDocument action (document reason)', () => { + const m = new TestMessage(); + m.revoke('msg', 'owner'); + assert.isTrue(m.isRevoked()); + assert.equal(m.action, MessageAction.RevokeDocument); + }); + + it('revoke with parentIds uses the ParentRevoked reason path', () => { + const m = new TestMessage(); + m.revoke('msg', 'owner', ['p1']); + assert.isTrue(m.isRevoked()); + const body = JSON.parse(m.toMessage()); + assert.equal(body.reason, 'Parent Revoked'); + assert.deepEqual(body.parentIds, ['p1']); + }); + + it('delete sets DELETED status and DeleteDocument action', () => { + const m = new TestMessage(); + m.delete('gone', ['p1']); + assert.isFalse(m.isRevoked()); + assert.equal(m.action, MessageAction.DeleteDocument); + const body = JSON.parse(m.toMessage()); + assert.equal(body.status, MessageStatus.DELETED); + assert.equal(body.deleteMessage, 'gone'); + }); + + it('setMessageStatus updates status and sets ChangeMessageStatus action', () => { + const m = new TestMessage(); + m.setMessageStatus(MessageStatus.WITHDRAW, 'note'); + assert.equal(m.action, MessageAction.ChangeMessageStatus); + const body = JSON.parse(m.toMessage()); + assert.equal(body.status, MessageStatus.WITHDRAW); + assert.equal(body.statusMessage, 'note'); + }); +}); + +describe('Message base: serialization', () => { + it('toMessage for ISSUE embeds toMessageObject + id/status', () => { + const m = new TestMessage(); + const body = JSON.parse(m.toMessage()); + assert.equal(body.status, MessageStatus.ISSUE); + assert.equal(body.foo, 'bar'); + assert.isString(body.id); + }); + + it('toHash is a deterministic base58 string', () => { + const m = new TestMessage(); + const h1 = m.toHash(); + const h2 = m.toHash(); + assert.isString(h1); + assert.equal(h1, h2); + }); + + it('toJson exposes the documented surface', () => { + const m = new TestMessage(); + m.setId('id-1'); + m.setTopicId('0.0.5'); + m.setMemo('memo'); + const json = m.toJson(); + assert.equal(json.id, 'id-1'); + assert.equal(json.topicId, '0.0.5'); + assert.equal(json.transactionMemo, 'memo'); + assert.equal(json.type, MessageType.VCDocument); + assert.equal(json.lang, 'en-US'); + assert.isString(json.messageId); + }); + + it('toJson topicId is null when unset', () => { + const m = new TestMessage(); + assert.isNull(m.toJson().topicId); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/message-classes-coverage.test.mjs b/common/tests/unit-tests/hedera-modules/message/message-classes-coverage.test.mjs new file mode 100644 index 0000000000..3faef0a5be --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/message-classes-coverage.test.mjs @@ -0,0 +1,337 @@ +import { assert } from 'chai'; + +import { SchemaMessage } from '../../../../dist/hedera-modules/message/schema-message.js'; +import { SynchronizationMessage } from '../../../../dist/hedera-modules/message/synchronization-message.js'; +import { ToolMessage } from '../../../../dist/hedera-modules/message/tool-message.js'; +import { ModuleMessage } from '../../../../dist/hedera-modules/message/module-message.js'; +import { FormulaMessage } from '../../../../dist/hedera-modules/message/formula-message.js'; +import { MessageType } from '../../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../../dist/hedera-modules/message/message-action.js'; +import { UrlType } from '../../../../dist/hedera-modules/message/url.interface.js'; + +describe('SchemaMessage coverage', function () { + it('setRelationships keeps only relationships with a messageId', function () { + const m = new SchemaMessage(MessageAction.CreateSchema); + m.setRelationships([{ messageId: 'a' }, { messageId: null }, {}, { messageId: 'b' }]); + assert.deepEqual(m.relationships, ['a', 'b']); + }); + + it('toDocuments serializes documents for PublishSchema action', async function () { + const m = new SchemaMessage(MessageAction.PublishSchema); + m.setDocument({ document: { a: 1 }, context: { b: 2 } }); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 2); + assert.deepEqual(JSON.parse(docs[0].toString()), { a: 1 }); + assert.deepEqual(JSON.parse(docs[1].toString()), { b: 2 }); + }); + + it('toDocuments serializes documents for PublishSystemSchema action', async function () { + const m = new SchemaMessage(MessageAction.PublishSystemSchema); + m.setDocument({ document: { a: 1 }, context: { b: 2 } }); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 2); + }); + + it('toDocuments returns [] for non-publish actions', async function () { + const m = new SchemaMessage(MessageAction.CreateSchema); + m.setDocument({ document: { a: 1 }, context: { b: 2 } }); + assert.deepEqual(await m.toDocuments(), []); + }); + + it('toJson / fromJson round-trip schema fields', function () { + const src = new SchemaMessage(MessageAction.CreateSchema); + src.setDocument({ + name: 'n', description: 'd', entity: 'e', owner: 'o', + uuid: 'u', version: '1', codeVersion: '2', + document: { x: 1 }, context: { y: 2 } + }); + src.relationships = ['r1']; + const json = src.toJson(); + assert.equal(json.name, 'n'); + assert.equal(json.codeVersion, '2'); + assert.deepEqual(json.document, { x: 1 }); + assert.deepEqual(json.context, { y: 2 }); + + const back = SchemaMessage.fromJson(json); + assert.equal(back.name, 'n'); + assert.equal(back.entity, 'e'); + assert.equal(back.codeVersion, '2'); + assert.deepEqual(back.documents, [{ x: 1 }, { y: 2 }]); + }); + + it('fromJson throws on empty json', function () { + assert.throws(() => SchemaMessage.fromJson(null), /JSON Object is empty/); + }); + + it('getOwner returns owner', function () { + const m = new SchemaMessage(MessageAction.CreateSchema); + m.setDocument({ owner: 'did:owner' }); + assert.equal(m.getOwner(), 'did:owner'); + }); + + it('getContextUrl reads the second url slot', function () { + const m = SchemaMessage.fromMessageObject({ + type: MessageType.Schema, + action: MessageAction.CreateSchema, + document_cid: 'dc', document_uri: 'du', + context_cid: 'cc', context_uri: 'cu' + }); + assert.equal(m.getContextUrl(UrlType.cid), 'cc'); + assert.equal(m.getContextUrl(UrlType.url), 'cu'); + }); +}); + +describe('SynchronizationMessage coverage', function () { + const multiPolicy = { + user: 'did:user', + instanceTopicId: '0.0.1', + type: 'Main', + policyOwner: 'did:owner' + }; + + it('setDocument copies policy fields and mint data', function () { + const m = new SynchronizationMessage(MessageAction.Mint); + m.setDocument(multiPolicy, { + messageId: 'mid', tokenId: '0.0.99', amount: 5, memo: 'memo', target: '0.0.7' + }); + assert.equal(m.user, 'did:user'); + assert.equal(m.policy, '0.0.1'); + assert.equal(m.policyOwner, 'did:owner'); + assert.equal(m.messageId, 'mid'); + assert.equal(m.amount, 5); + }); + + it('setDocument without data leaves mint fields undefined', function () { + const m = new SynchronizationMessage(MessageAction.CreateMultiPolicy); + m.setDocument(multiPolicy); + assert.equal(m.user, 'did:user'); + assert.isUndefined(m.messageId); + }); + + it('toMessageObject for CreateMultiPolicy excludes mint fields', function () { + const m = new SynchronizationMessage(MessageAction.CreateMultiPolicy); + m.setDocument(multiPolicy); + const obj = m.toMessageObject(); + assert.equal(obj.user, 'did:user'); + assert.isUndefined(obj.tokenId); + }); + + it('toMessageObject for Mint includes mint fields', function () { + const m = new SynchronizationMessage(MessageAction.Mint); + m.setDocument(multiPolicy, { messageId: 'mid', tokenId: '0.0.99', amount: 5, memo: 'memo', target: '0.0.7' }); + const obj = m.toMessageObject(); + assert.equal(obj.tokenId, '0.0.99'); + assert.equal(obj.amount, 5); + }); + + it('toDocuments and loadDocuments are no-ops', async function () { + const m = new SynchronizationMessage(MessageAction.Mint); + assert.deepEqual(await m.toDocuments(), []); + assert.strictEqual(m.loadDocuments(['x']), m); + assert.deepEqual(m.getUrls(), []); + }); + + it('fromMessage parses string and rejects non-sync type', function () { + const valid = { + type: MessageType.Synchronization, action: MessageAction.Mint, + user: 'u', policy: 'p', policyOwner: 'po' + }; + const m = SynchronizationMessage.fromMessage(JSON.stringify(valid)); + assert.equal(m.user, 'u'); + assert.throws(() => SynchronizationMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => SynchronizationMessage.fromMessageObject({ type: 'X' }), /Invalid message type/); + assert.throws(() => SynchronizationMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('toJson / fromJson round-trip', function () { + const src = new SynchronizationMessage(MessageAction.Mint); + src.setDocument(multiPolicy, { messageId: 'mid', tokenId: '0.0.99', amount: 5, memo: 'memo', target: '0.0.7' }); + const json = src.toJson(); + assert.equal(json.user, 'did:user'); + assert.equal(json.tokenId, '0.0.99'); + const back = SynchronizationMessage.fromJson(json); + assert.equal(back.user, 'did:user'); + assert.equal(back.policyOwner, 'did:owner'); + assert.throws(() => SynchronizationMessage.fromJson(null), /JSON Object is empty/); + }); + + it('getOwner returns policyOwner', function () { + const m = new SynchronizationMessage(MessageAction.Mint); + m.setDocument(multiPolicy); + assert.equal(m.getOwner(), 'did:owner'); + }); +}); + +describe('ToolMessage coverage', function () { + const model = { + uuid: 'u', name: 'tool', description: 'd', owner: 'did:o', hash: 'h', + topicId: '0.0.10', tagsTopicId: '0.0.11', version: '1.0.0' + }; + + it('setDocument / getDocument round-trip with zip buffer', function () { + const m = new ToolMessage(MessageType.Tool, MessageAction.CreateVC); + m.setDocument(model, Buffer.from('zip')); + assert.equal(m.uuid, 'u'); + assert.equal(m.toolTopicId, '0.0.10'); + assert.equal(m.getDocument().toString(), 'zip'); + }); + + it('toDocuments returns the document buffer when present', async function () { + const m = new ToolMessage(MessageType.Tool, MessageAction.CreateVC); + m.setDocument(model, Buffer.from('zip')); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), 'zip'); + }); + + it('loadDocuments loads a single buffer', function () { + const m = new ToolMessage(MessageType.Tool, MessageAction.CreateVC); + m.loadDocuments([Buffer.from('abc')]); + assert.equal(m.document.toString(), 'abc'); + }); + + it('fromMessage with cid builds an ipfs url and getUrl/getDocumentUrl', function () { + const m = ToolMessage.fromMessage(JSON.stringify({ + type: MessageType.Tool, action: MessageAction.CreateVC, ...model, cid: 'CID1' + })); + assert.equal(m.getUrl().cid, 'CID1'); + assert.equal(m.getDocumentUrl(UrlType.cid), 'CID1'); + assert.isTrue(m.getDocumentUrl(UrlType.url).endsWith('CID1')); + assert.throws(() => ToolMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject without cid yields no urls', function () { + const m = ToolMessage.fromMessageObject({ type: MessageType.Tool, action: MessageAction.CreateVC, ...model }); + assert.isUndefined(m.getUrl()); + }); + + it('toMessageObject and toJson / fromJson round-trip', function () { + const src = new ToolMessage(MessageType.Tool, MessageAction.CreateVC); + src.setDocument(model, Buffer.from('zip')); + const obj = src.toMessageObject(); + assert.equal(obj.uuid, 'u'); + assert.equal(obj.topicId, '0.0.10'); + const json = src.toJson(); + assert.equal(json.name, 'tool'); + const back = ToolMessage.fromJson({ ...json, type: MessageType.Tool, action: MessageAction.CreateVC }); + assert.equal(back.name, 'tool'); + assert.equal(back.toolTopicId, '0.0.10'); + assert.throws(() => ToolMessage.fromJson(null), /JSON Object is empty/); + }); + + it('validate true and getOwner', function () { + const m = new ToolMessage(MessageType.Tool, MessageAction.CreateVC); + m.setDocument(model); + assert.isTrue(m.validate()); + assert.equal(m.getOwner(), 'did:o'); + }); +}); + +describe('ModuleMessage coverage', function () { + const model = { uuid: 'u', name: 'mod', description: 'd', owner: 'did:o', topicId: '0.0.20' }; + + it('setDocument / getDocument with zip', function () { + const m = new ModuleMessage(MessageType.Module, MessageAction.CreateVC); + m.setDocument(model, Buffer.from('z')); + assert.equal(m.moduleTopicId, '0.0.20'); + assert.equal(m.getDocument().toString(), 'z'); + }); + + it('toDocuments returns buffer when present', async function () { + const m = new ModuleMessage(MessageType.Module, MessageAction.CreateVC); + m.setDocument(model, Buffer.from('z')); + assert.lengthOf(await m.toDocuments(), 1); + }); + + it('loadDocuments loads single buffer', function () { + const m = new ModuleMessage(MessageType.Module, MessageAction.CreateVC); + m.loadDocuments([Buffer.from('y')]); + assert.equal(m.document.toString(), 'y'); + }); + + it('fromMessage with cid builds urls', function () { + const m = ModuleMessage.fromMessage(JSON.stringify({ + type: MessageType.Module, action: MessageAction.CreateVC, ...model, cid: 'CID2' + })); + assert.equal(m.getUrl().cid, 'CID2'); + assert.equal(m.getDocumentUrl(UrlType.cid), 'CID2'); + assert.throws(() => ModuleMessage.fromMessage(''), /Message Object is empty/); + }); + + it('toMessageObject / toJson / fromJson round-trip and validate / getOwner', function () { + const src = new ModuleMessage(MessageType.Module, MessageAction.CreateVC); + src.setDocument(model, Buffer.from('z')); + const obj = src.toMessageObject(); + assert.equal(obj.topicId, '0.0.20'); + const json = src.toJson(); + const back = ModuleMessage.fromJson({ ...json, type: MessageType.Module, action: MessageAction.CreateVC }); + assert.equal(back.moduleTopicId, '0.0.20'); + assert.isTrue(src.validate()); + assert.equal(src.getOwner(), 'did:o'); + assert.throws(() => ModuleMessage.fromJson(null), /JSON Object is empty/); + }); +}); + +describe('FormulaMessage coverage', function () { + const item = { + name: 'f', description: 'd', owner: 'did:o', uuid: 'u', + policyTopicId: '0.0.30', policyInstanceTopicId: '0.0.31', autoGenerated: 1 + }; + + it('setDocument / getDocument with zip and coerces autoGenerated to boolean', function () { + const m = new FormulaMessage(MessageAction.CreateVC); + m.setDocument(item, Buffer.from('cfg')); + assert.strictEqual(m.autoGenerated, true); + assert.equal(m.getDocument().toString(), 'cfg'); + }); + + it('toDocuments returns config when present, else empty', async function () { + const m = new FormulaMessage(MessageAction.CreateVC); + assert.deepEqual(await m.toDocuments(), []); + m.setDocument(item, Buffer.from('cfg')); + assert.lengthOf(await m.toDocuments(), 1); + }); + + it('loadDocuments loads single buffer', function () { + const m = new FormulaMessage(MessageAction.CreateVC); + m.loadDocuments([Buffer.from('c')]); + assert.equal(m.config.toString(), 'c'); + }); + + it('fromMessage parses and builds url; getUrl / getDocumentUrl / getContextUrl', function () { + const m = FormulaMessage.fromMessage(JSON.stringify({ + type: MessageType.Formula, action: MessageAction.CreateVC, ...item, cid: 'CID3' + })); + assert.equal(m.getUrl()[0].cid, 'CID3'); + assert.equal(m.getDocumentUrl(UrlType.cid), 'CID3'); + assert.isUndefined(m.getContextUrl(UrlType.cid)); + assert.throws(() => FormulaMessage.fromMessage(''), /Message Object is empty/); + }); + + it('toMessageObject exposes formula fields', function () { + const m = new FormulaMessage(MessageAction.CreateVC); + m.setDocument(item, Buffer.from('cfg')); + const obj = m.toMessageObject(); + assert.equal(obj.policyTopicId, '0.0.30'); + assert.equal(obj.autoGenerated, true); + }); + + it('toJson returns undefined (pinned bug: missing return)', function () { + const m = new FormulaMessage(MessageAction.CreateVC); + m.setDocument(item, Buffer.from('cfg')); + assert.isUndefined(m.toJson()); + }); + + it('fromJson round-trips and validate / getOwner', function () { + const back = FormulaMessage.fromJson({ + type: MessageType.Formula, action: MessageAction.CreateVC, + name: 'f', description: 'd', owner: 'did:o', uuid: 'u', + policyTopicId: '0.0.30', policyInstanceTopicId: '0.0.31', autoGenerated: true, + config: Buffer.from('cfg') + }); + assert.equal(back.policyInstanceTopicId, '0.0.31'); + assert.isTrue(back.validate()); + assert.equal(back.getOwner(), 'did:o'); + assert.throws(() => FormulaMessage.fromJson(null), /JSON Object is empty/); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/message-serializers.test.mjs b/common/tests/unit-tests/hedera-modules/message/message-serializers.test.mjs new file mode 100644 index 0000000000..6ec0e8a3e1 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/message-serializers.test.mjs @@ -0,0 +1,378 @@ +import { assert } from 'chai'; +import { PolicyMessage } from '../../../../dist/hedera-modules/message/policy-message.js'; +import { VCMessage } from '../../../../dist/hedera-modules/message/vc-message.js'; +import { VPMessage } from '../../../../dist/hedera-modules/message/vp-message.js'; +import { TagMessage } from '../../../../dist/hedera-modules/message/tag-message.js'; +import { ToolMessage } from '../../../../dist/hedera-modules/message/tool-message.js'; +import { ModuleMessage } from '../../../../dist/hedera-modules/message/module-message.js'; +import { SynchronizationMessage } from '../../../../dist/hedera-modules/message/synchronization-message.js'; +import { FormulaMessage } from '../../../../dist/hedera-modules/message/formula-message.js'; +import { MessageType } from '../../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../../dist/hedera-modules/message/message-action.js'; + +describe('@unit PolicyMessage serializer', () => { + const policyModel = { + uuid: 'u1', name: 'Name', description: 'Desc', topicDescription: 'TD', + version: '1.0.0', policyTag: 'Tag', owner: 'did:owner', topicId: '0.0.1', + instanceTopicId: '0.0.2', synchronizationTopicId: '0.0.3', availability: 'public', + restoreTopicId: '0.0.4', actionsTopicId: '0.0.5', recordsTopicId: '0.0.6', + commentsTopicId: '0.0.7', originalHash: 'oh', originalMessageId: 'omid', + }; + + it('constructs with type/action and raw response', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + assert.equal(m.type, MessageType.Policy); + assert.equal(m.action, MessageAction.PublishPolicy); + }); + + it('setDocument/getDocument round-trips the zip buffer', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + m.setDocument(policyModel, Buffer.from('zip')); + assert.equal(m.uuid, 'u1'); + assert.ok(Buffer.isBuffer(m.getDocument())); + }); + + it('toMessageObject truncates oversized text fields via limit/cut', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + const big = 'x'.repeat(2000); + m.setDocument({ ...policyModel, description: big, topicDescription: big, name: big, policyTag: big }, Buffer.from('zip')); + const obj = m.toMessageObject(); + assert.isAtMost(JSON.stringify(obj).length, 1100); + }); + + it('toMessageObject adds effectiveDate for discontinue action', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.DiscontinuePolicy); + m.setDocument({ ...policyModel, discontinuedDate: new Date('2024-01-01') }, Buffer.from('z')); + const obj = m.toMessageObject(); + assert.equal(obj.effectiveDate, new Date('2024-01-01').toISOString()); + }); + + it('toDocuments returns the buffer only for PublishPolicy', async () => { + const pub = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + pub.setDocument(policyModel, Buffer.from('zip')); + assert.equal((await pub.toDocuments()).length, 1); + const other = new PolicyMessage(MessageType.Policy, MessageAction.DiscontinuePolicy); + other.setDocument(policyModel, Buffer.from('zip')); + assert.deepEqual(await other.toDocuments(), []); + }); + + it('toDocuments returns empty for PublishPolicy with no document', async () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + assert.deepEqual(await m.toDocuments(), []); + }); + + it('loadDocuments sets the document buffer', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + m.loadDocuments(['raw']); + assert.ok(Buffer.isBuffer(m.document)); + m.loadDocuments([]); + }); + + it('fromMessage throws on empty input', () => { + assert.throws(() => PolicyMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject throws on null and invalid type', () => { + assert.throws(() => PolicyMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => PolicyMessage.fromMessageObject({ type: 'other', action: MessageAction.PublishPolicy }), /Invalid message type/); + }); + + it('round-trips through toMessageObject/fromMessageObject with cid url', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + m.setDocument(policyModel, Buffer.from('z')); + const obj = { ...m.toMessageObject(), id: 'mid', status: 'ISSUE', cid: 'QmCid' }; + const restored = PolicyMessage.fromMessageObject(obj); + assert.equal(restored.uuid, 'u1'); + assert.equal(restored.getOwner(), 'did:owner'); + assert.ok(restored.getUrl()); + }); + + it('fromMessageObject without cid sets empty urls', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + m.setDocument(policyModel, Buffer.from('z')); + const obj = { ...m.toMessageObject(), cid: undefined }; + const restored = PolicyMessage.fromMessageObject(obj); + assert.isUndefined(restored.getUrl()); + }); + + it('fromMessageObject parses discontinue effectiveDate', () => { + const obj = { type: MessageType.Policy, action: MessageAction.DiscontinuePolicy, effectiveDate: '2024-01-01T00:00:00.000Z' }; + const restored = PolicyMessage.fromMessageObject(obj); + assert.instanceOf(restored.discontinuedDate, Date); + }); + + it('validate returns true and toJson/fromJson round-trip', () => { + const m = new PolicyMessage(MessageType.Policy, MessageAction.PublishPolicy); + m.setDocument(policyModel, Buffer.from('z')); + assert.isTrue(m.validate()); + const restored = PolicyMessage.fromJson(m.toJson()); + assert.equal(restored.uuid, 'u1'); + }); + + it('fromJson throws on empty', () => { + assert.throws(() => PolicyMessage.fromJson(null), /JSON Object is empty/); + }); +}); + +describe('@unit VCMessage serializer', () => { + const body = () => ({ + id: 'mid', status: 'ISSUE', type: MessageType.VCDocument, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', issuer: 'did:issuer', initId: 'init', + relationships: ['r1'], encodedData: false, documentStatus: 'NEW', + guardianVersion: '3', tag: 't', startMessage: 'sm', entityType: 'e', + option: { o: 1 }, tags: [], cid: 'QmCid', + }); + + it('setters mutate relationships/user/tag/entity/option/ref/init', () => { + const m = new VCMessage(MessageAction.CreateVC); + m.setDocumentStatus('OK'); + m.setUser('u1'); + m.setRelationships(['r0']); + assert.include(m.getRelationships(), 'u1'); + m.setTag({ tag: 'tg' }); + assert.equal(m.tag, 'tg'); + m.setEntityType({ options: { entityType: 'ET' } }); + assert.equal(m.entityType, 'ET'); + m.setOption({ option: { a: 1 } }); + assert.deepEqual(m.option, { a: 1 }); + m.setOption({}, { options: { options: [{ name: 'n', value: 'v' }] } }); + assert.equal(m.option.n, 'v'); + m.setRef('startmsg'); + assert.equal(m.startMessage, 'startmsg'); + m.setInitId('iid'); + m.setInit('iid2'); + assert.equal(m.initId, 'iid2'); + }); + + it('setUser with no prior relationships seeds the array', () => { + const m = new VCMessage(MessageAction.CreateVC); + m.setUser('only'); + assert.deepEqual(m.relationships, ['only']); + }); + + it('toDocuments/loadDocuments round-trip unencoded', async () => { + const m = VCMessage.fromMessageObject(body()); + m.document = { foo: 'bar' }; + const docs = await m.toDocuments(); + const out = await m.loadDocuments([docs[0].toString()]); + assert.deepEqual(out.document, { foo: 'bar' }); + }); + + it('toDocuments encrypts and loadDocuments decrypts when encoded', async () => { + const m = VCMessage.fromMessageObject({ ...body(), encodedData: true }); + m.document = { secret: 1 }; + const docs = await m.toDocuments('passphrase'); + const out = await m.loadDocuments([docs[0].toString()], 'passphrase'); + assert.equal(out.document, JSON.stringify({ secret: 1 })); + }); + + it('toDocuments throws when encoded but no key', async () => { + const m = VCMessage.fromMessageObject({ ...body(), encodedData: true }); + m.document = { secret: 1 }; + let threw = false; + try { await m.toDocuments(''); } catch (e) { threw = /private key/.test(e.message); } + assert.isTrue(threw); + }); + + it('fromMessage/validate/getUrl/toHash/toJson/fromJson/getOwner', () => { + const m = VCMessage.fromMessage(JSON.stringify(body())); + assert.isTrue(m.validate()); + assert.ok(m.getUrl()); + assert.isString(m.toHash()); + assert.equal(m.getOwner(), 'did:issuer'); + const restored = VCMessage.fromJson(m.toJson()); + assert.equal(restored.issuer, 'did:issuer'); + }); + + it('fromMessage/fromMessageObject/fromJson empty guards', () => { + assert.throws(() => VCMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => VCMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => VCMessage.fromJson(null), /JSON Object is empty/); + }); + + it('fromMessageObject marks encodedData when body type is EVCDocument', () => { + const m = VCMessage.fromMessageObject({ ...body(), type: MessageType.EVCDocument }); + assert.isTrue(m.encodedData); + const obj = m.toMessageObject(); + assert.equal(obj.issuer, 'did:issuer'); + }); +}); + +describe('@unit VPMessage serializer', () => { + const body = () => ({ + id: 'mid', status: 'ISSUE', type: MessageType.VPDocument, action: MessageAction.CreateVP, + lang: 'en', account: '0.0.1', issuer: 'did:issuer', relationships: ['r1'], + tag: 't', entityType: 'e', option: { o: 1 }, tags: [], cid: 'QmCid', + }); + + it('setters and round-trip', async () => { + const m = new VPMessage(MessageAction.CreateVP); + m.setUser('u1'); + m.setRelationships(['r0']); + assert.include(m.getRelationships(), 'u1'); + m.setTag({ tag: 'x' }); + m.setEntityType({ options: { entityType: 'ET' } }); + m.setOption({ option: { a: 1 } }); + m.setOption({}, { options: { options: [{ name: 'n', value: 'v' }] } }); + m.document = { d: 1 }; + const docs = await m.toDocuments(); + const out = m.loadDocuments([docs[0].toString()]); + assert.deepEqual(out.getDocument(), { d: 1 }); + }); + + it('static from(ITopicMessage) populates id/index/topic/payer', () => { + const data = { + message: JSON.stringify(body()), owner: 'payer', + sequenceNumber: 5, consensusTimestamp: 'ts', topicId: '0.0.9', + }; + const m = VPMessage.from(data); + assert.equal(m.issuer, 'did:issuer'); + }); + + it('from throws on empty data/message', () => { + assert.throws(() => VPMessage.from(null), /Message Object is empty/); + assert.throws(() => VPMessage.from({ message: '' }), /Message Object is empty/); + }); + + it('fromMessage/validate/getUrl/toHash/toJson/fromJson/getOwner', () => { + const m = VPMessage.fromMessage(JSON.stringify(body())); + assert.isTrue(m.validate()); + assert.ok(m.getUrl()); + assert.isString(m.toHash()); + assert.equal(m.getOwner(), 'did:issuer'); + const obj = m.toMessageObject(); + assert.equal(obj.issuer, 'did:issuer'); + const restored = VPMessage.fromJson(m.toJson()); + assert.equal(restored.issuer, 'did:issuer'); + }); + + it('empty guards', () => { + assert.throws(() => VPMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => VPMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => VPMessage.fromJson(null), /JSON Object is empty/); + }); +}); + +describe('@unit TagMessage serializer', () => { + const tag = { + uuid: 'u', name: 'n', description: 'd', owner: 'o', target: 't', + operation: 'Create', entity: 'e', date: '2024', document: { a: 1 }, + linkedItems: ['l'], inheritTags: true, + }; + + it('setDocument/toDocuments/loadDocuments round-trip', async () => { + const m = new TagMessage(MessageAction.CreateMultiPolicy); + m.setDocument(tag); + const docs = await m.toDocuments(); + const out = m.loadDocuments([docs[0].toString()]); + assert.deepEqual(out.getDocument(), { a: 1 }); + }); + + it('toDocuments empty and loadDocuments empty branch', async () => { + const m = new TagMessage(MessageAction.CreateMultiPolicy); + assert.deepEqual(await m.toDocuments(), []); + assert.equal(m.loadDocuments([]), m); + }); + + it('toMessageObject/getUrls/validate/toJson/fromJson/getOwner', () => { + const m = new TagMessage(MessageAction.CreateMultiPolicy); + m.setDocument(tag); + assert.deepEqual(m.getUrls(), []); + assert.isTrue(m.validate()); + const obj = m.toMessageObject(); + assert.equal(obj.uuid, 'u'); + const restored = TagMessage.fromJson(m.toJson()); + assert.equal(restored.owner, 'o'); + assert.equal(m.getOwner(), 'o'); + }); + + it('fromMessage round-trip and guards', () => { + const m = new TagMessage(MessageAction.CreateMultiPolicy); + m.setDocument(tag); + const obj = { ...m.toMessageObject(), id: 'i', status: 's' }; + const restored = TagMessage.fromMessageObject(obj); + assert.equal(restored.name, 'n'); + assert.throws(() => TagMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => TagMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => TagMessage.fromMessageObject({ type: 'x' }), /Invalid message type/); + assert.throws(() => TagMessage.fromJson(null), /JSON Object is empty/); + }); +}); + +describe('@unit ToolMessage serializer', () => { + const model = { uuid: 'u', name: 'n', description: 'd', owner: 'o', hash: 'h', topicId: '0.0.1', tagsTopicId: '0.0.2', version: '1' }; + + it('setDocument/toDocuments/fromMessageObject round-trip', async () => { + const m = new ToolMessage(MessageType.Tool, MessageAction.PublishTool); + m.setDocument(model, Buffer.from('zip')); + assert.equal(m.uuid, 'u'); + const docs = await m.toDocuments(); + assert.isArray(docs); + const restored = ToolMessage.fromMessageObject({ ...m.toMessageObject(), id: 'i', status: 's' }); + assert.equal(restored.getOwner(), 'o'); + }); + + it('guards', () => { + assert.throws(() => ToolMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => ToolMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => ToolMessage.fromMessageObject({ type: 'x' }), /Invalid message type/); + }); +}); + +describe('@unit ModuleMessage serializer', () => { + const model = { uuid: 'u', name: 'n', description: 'd', owner: 'o', topicId: '0.0.1' }; + + it('setDocument/toDocuments/fromMessageObject round-trip', async () => { + const m = new ModuleMessage(MessageType.Module, MessageAction.PublishModule); + m.setDocument(model, Buffer.from('zip')); + assert.equal(m.uuid, 'u'); + await m.toDocuments(); + const restored = ModuleMessage.fromMessageObject({ ...m.toMessageObject(), id: 'i', status: 's' }); + assert.equal(restored.getOwner(), 'o'); + }); + + it('guards', () => { + assert.throws(() => ModuleMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => ModuleMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => ModuleMessage.fromMessageObject({ type: 'x' }), /Invalid message type/); + }); +}); + +describe('@unit SynchronizationMessage serializer', () => { + const policy = { user: 'usr', instanceTopicId: '0.0.1', type: 'Main', policyOwner: 'own' }; + + it('setDocument with and without extra data', () => { + const m = new SynchronizationMessage(MessageAction.CreateMultiPolicy); + m.setDocument(policy, { messageId: 'mid', tokenId: 'tk', amount: 1, memo: 'm', target: 't' }); + assert.equal(m.policy, '0.0.1'); + const m2 = new SynchronizationMessage(MessageAction.CreateMultiPolicy); + m2.setDocument(policy); + assert.equal(m2.policyOwner, 'own'); + }); + + it('toDocuments/getUrls/fromMessageObject round-trip and guards', async () => { + const m = new SynchronizationMessage(MessageAction.CreateMultiPolicy); + m.setDocument(policy); + assert.deepEqual(m.getUrls(), []); + await m.toDocuments(); + const restored = SynchronizationMessage.fromMessageObject({ ...m.toMessageObject(), id: 'i', status: 's' }); + assert.equal(restored.policy, '0.0.1'); + assert.throws(() => SynchronizationMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => SynchronizationMessage.fromMessageObject({ type: 'x' }), /Invalid message type/); + }); +}); + +describe('@unit FormulaMessage serializer', () => { + const item = { name: 'n', description: 'd', owner: 'o', uuid: 'u', policyTopicId: '0.0.1', policyInstanceTopicId: '0.0.2', autoGenerated: true }; + + it('setDocument/toDocuments/fromMessageObject round-trip and guards', async () => { + const m = new FormulaMessage(MessageAction.CreateMultiPolicy); + m.setDocument(item, Buffer.from('zip')); + assert.equal(m.uuid, 'u'); + await m.toDocuments(); + const restored = FormulaMessage.fromMessageObject({ ...m.toMessageObject(), id: 'i', status: 's' }); + assert.equal(restored.uuid, 'u'); + assert.throws(() => FormulaMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => FormulaMessage.fromMessageObject(null), /JSON Object is empty/); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/message-server-parsers.test.mjs b/common/tests/unit-tests/hedera-modules/message/message-server-parsers.test.mjs new file mode 100644 index 0000000000..b24d870cf7 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/message-server-parsers.test.mjs @@ -0,0 +1,180 @@ +import { assert } from 'chai'; +import { MessageServer } from '../../../../dist/hedera-modules/message/message-server.js'; +import { MessageStatus } from '../../../../dist/hedera-modules/message/message.js'; +import { MessageType } from '../../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../../dist/hedera-modules/message/message-action.js'; +import { TopicMessage } from '../../../../dist/hedera-modules/message/topic-message.js'; +import { VCMessage } from '../../../../dist/hedera-modules/message/vc-message.js'; +import { DIDMessage } from '../../../../dist/hedera-modules/message/did-message.js'; +import { PolicyMessage } from '../../../../dist/hedera-modules/message/policy-message.js'; +import { RegistrationMessage } from '../../../../dist/hedera-modules/message/registration-message.js'; + +const topicBody = () => ({ + id: 'mid', + status: MessageStatus.ISSUE, + type: MessageType.Topic, + action: MessageAction.CreateTopic, + name: 'name', + description: 'desc', + owner: 'did:owner', + messageType: 'POLICY_TOPIC', + childId: 'c', + parentId: 'p', + rationale: 'r', + lang: 'en' +}); + +const vcBody = () => ({ + id: 'mid', + status: MessageStatus.ISSUE, + type: MessageType.VCDocument, + action: MessageAction.CreateVC, + issuer: 'did:issuer', + cid: 'cid', + url: 'ipfs://cid', + relationships: ['a'] +}); + +const didBody = () => ({ + id: 'mid', + status: MessageStatus.ISSUE, + type: MessageType.DIDDocument, + action: MessageAction.CreateDID, + did: 'did:x', + cid: 'cid', + url: 'ipfs://cid' +}); + +const policyBody = () => ({ + id: 'mid', + status: MessageStatus.ISSUE, + type: MessageType.Policy, + action: MessageAction.CreatePolicy, + uuid: 'uuid', + name: 'p', + description: 'd', + topicDescription: 'td', + version: '1.0.0', + policyTag: 'tag', + owner: 'did:o', + topicId: '0.0.1', + instanceTopicId: '0.0.2', + cid: 'cid', + url: 'ipfs://cid' +}); + +const registrationBody = () => ({ + id: 'mid', + status: MessageStatus.ISSUE, + type: MessageType.StandardRegistry, + action: MessageAction.Init, + did: 'did:sr', + topicId: '0.0.3', + lang: 'en', + attributes: { a: '1' } +}); + +describe('MessageServer.setLang', () => { + it('stores the language statically', () => { + const saved = MessageServer.lang; + MessageServer.setLang('cn'); + assert.equal(MessageServer.lang, 'cn'); + MessageServer.setLang(saved); + }); +}); + +describe('MessageServer.fromMessage', () => { + it('parses a topic message string', () => { + const message = MessageServer.fromMessage(JSON.stringify(topicBody()), null); + assert.instanceOf(message, TopicMessage); + assert.equal(message.name, 'name'); + }); + + it('parses a vc message string', () => { + const message = MessageServer.fromMessage(JSON.stringify(vcBody()), null); + assert.instanceOf(message, VCMessage); + assert.equal(message.issuer, 'did:issuer'); + }); + + it('throws for a malformed string', () => { + assert.throws(() => MessageServer.fromMessage('{bad', null)); + }); +}); + +describe('MessageServer.fromMessageObject', () => { + it('dispatches DID documents to DIDMessage', () => { + const message = MessageServer.fromMessageObject(didBody(), null); + assert.instanceOf(message, DIDMessage); + assert.equal(message.did, 'did:x'); + }); + + it('dispatches policies to PolicyMessage', () => { + const message = MessageServer.fromMessageObject(policyBody(), null); + assert.instanceOf(message, PolicyMessage); + assert.equal(message.uuid, 'uuid'); + }); + + it('dispatches standard registries to RegistrationMessage', () => { + const message = MessageServer.fromMessageObject(registrationBody(), null); + assert.instanceOf(message, RegistrationMessage); + assert.equal(message.did, 'did:sr'); + }); + + it('accepts a matching expected type', () => { + const message = MessageServer.fromMessageObject(topicBody(), null, MessageType.Topic); + assert.instanceOf(message, TopicMessage); + }); + + it('accepts a matching type from an array of expected types', () => { + const message = MessageServer.fromMessageObject(topicBody(), null, [MessageType.Policy, MessageType.Topic]); + assert.instanceOf(message, TopicMessage); + }); + + it('keeps the message action from the payload', () => { + const message = MessageServer.fromMessageObject(topicBody(), null); + assert.equal(message.action, MessageAction.CreateTopic); + }); +}); + +describe('MessageServer.fromJson', () => { + it('round-trips a topic message', () => { + const original = new TopicMessage(MessageAction.CreateTopic); + original.setDocument({ + name: 'n', + description: 'd', + owner: 'o', + messageType: 'mt', + childId: 'c', + parentId: 'p', + rationale: 'r' + }); + const restored = MessageServer.fromJson(original.toJson()); + assert.instanceOf(restored, TopicMessage); + assert.equal(restored.name, 'n'); + assert.equal(restored.owner, 'o'); + }); + + it('round-trips a registration message', () => { + const original = new RegistrationMessage(MessageAction.Init); + original.setDocument('did:x', '0.0.5', { a: '1' }); + const restored = MessageServer.fromJson(original.toJson()); + assert.instanceOf(restored, RegistrationMessage); + assert.equal(restored.did, 'did:x'); + assert.deepEqual(restored.attributes, { a: '1' }); + }); + + it('preserves the message type through round-trip', () => { + const original = new TopicMessage(MessageAction.CreateTopic); + original.setDocument({ name: 'n', description: 'd', owner: 'o', messageType: 'mt', childId: 'c', parentId: 'p', rationale: 'r' }); + const restored = MessageServer.fromJson(original.toJson()); + assert.equal(restored.type, MessageType.Topic); + }); + + it('throws for an unknown message type', () => { + assert.throws(() => MessageServer.fromJson({ type: 'bogus' }), 'Invalid format message'); + }); + + it('throws for a missing type', () => { + assert.throws(() => MessageServer.fromJson({}), 'Invalid format message'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/policy-diff-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/policy-diff-message-extra.test.mjs new file mode 100644 index 0000000000..f316d12aad --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/policy-diff-message-extra.test.mjs @@ -0,0 +1,178 @@ +import { assert } from 'chai'; + +import { + PolicyDiffMessage, + MessageAction, + MessageType, + MessageStatus, + UrlType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('PolicyDiffMessage extra', function () { + const diff = { + uuid: 'uuid-1', + owner: 'did:hedera:testnet:owner', + diffType: 'backup', + diffIndex: 3, + policyTopicId: '0.0.111', + instanceTopicId: '0.0.222' + }; + + const body = { + id: 'msg-id', + status: MessageStatus.ISSUE, + type: MessageType.PolicyDiff, + action: MessageAction.PublishPolicyDiff, + lang: 'en-US', + uuid: 'uuid-1', + owner: 'did:hedera:testnet:owner', + diffType: 'diff', + diffIndex: 7, + policyTopicId: '0.0.111', + instanceTopicId: '0.0.222', + cid: 'cidDiff' + }; + + it('constructor sets raw response type and empty urls', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + assert.equal(m.responseType, 'raw'); + assert.deepEqual(m.getUrls(), []); + }); + + it('setDocument maps diff fields and buffers the zip', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m.setDocument(diff, Buffer.from('zip')); + assert.equal(m.uuid, diff.uuid); + assert.equal(m.owner, diff.owner); + assert.equal(m.diffType, 'backup'); + assert.equal(m.diffIndex, 3); + assert.equal(m.policyTopicId, diff.policyTopicId); + assert.equal(m.instanceTopicId, diff.instanceTopicId); + assert.equal(m.getDocument().toString(), 'zip'); + }); + + it('setDocument without zip leaves document undefined', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m.setDocument(diff); + assert.isUndefined(m.getDocument()); + }); + + it('toMessageObject maps fields and stringifies topic ids', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m.setDocument({ ...diff, policyTopicId: { toString: () => '0.0.999' } }); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyDiff); + assert.equal(obj.uuid, diff.uuid); + assert.equal(obj.diffType, 'backup'); + assert.equal(obj.diffIndex, 3); + assert.equal(obj.policyTopicId, '0.0.999'); + assert.equal(obj.instanceTopicId, '0.0.222'); + assert.isUndefined(obj.cid); + }); + + it('toDocuments returns the buffer when set and [] otherwise', async function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + assert.deepEqual(await m.toDocuments(), []); + m.setDocument(diff, Buffer.from('abc')); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), 'abc'); + }); + + it('loadDocuments stores a single document only', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + const result = m.loadDocuments(['payload']); + assert.equal(result, m); + assert.equal(m.getDocument().toString(), 'payload'); + const m2 = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m2.loadDocuments(['a', 'b']); + assert.isUndefined(m2.getDocument()); + }); + + it('fromMessageObject with cid builds the ipfs url', function () { + const m = PolicyDiffMessage.fromMessageObject(body); + assert.equal(m.getDocumentUrl(UrlType.cid), 'cidDiff'); + assert.equal(m.getDocumentUrl(UrlType.url), 'ipfs://cidDiff'); + }); + + it('fromMessageObject without cid keeps urls empty', function () { + const m = PolicyDiffMessage.fromMessageObject({ ...body, cid: undefined }); + assert.deepEqual(m.getUrls(), []); + assert.isUndefined(m.getUrl()); + }); + + it('fromMessageObject maps the diff fields', function () { + const m = PolicyDiffMessage.fromMessageObject(body); + assert.equal(m.uuid, body.uuid); + assert.equal(m.owner, body.owner); + assert.equal(m.diffType, 'diff'); + assert.equal(m.diffIndex, 7); + assert.equal(m.policyTopicId, body.policyTopicId); + assert.equal(m.instanceTopicId, body.instanceTopicId); + }); + + it('getUrl returns the first url entry', function () { + const m = PolicyDiffMessage.fromMessageObject(body); + assert.deepEqual(m.getUrl(), { cid: 'cidDiff', url: 'ipfs://cidDiff' }); + }); + + it('fromMessage parses a JSON string', function () { + const m = PolicyDiffMessage.fromMessage(JSON.stringify(body)); + assert.equal(m.uuid, body.uuid); + assert.equal(m.diffIndex, 7); + }); + + it('toJson exposes diff fields and the document', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m.setDocument(diff, Buffer.from('zip')); + const json = m.toJson(); + assert.equal(json.uuid, diff.uuid); + assert.equal(json.owner, diff.owner); + assert.equal(json.diffType, 'backup'); + assert.equal(json.diffIndex, 3); + assert.equal(json.policyTopicId, diff.policyTopicId); + assert.equal(json.instanceTopicId, diff.instanceTopicId); + assert.equal(json.document.toString(), 'zip'); + }); + + it('fromJson restores the diff fields', function () { + const m = PolicyDiffMessage.fromJson({ + type: MessageType.PolicyDiff, + action: MessageAction.PublishPolicyDiff, + uuid: 'u', + owner: 'o', + diffType: 'keys', + diffIndex: 1, + policyTopicId: 't1', + instanceTopicId: 't2', + document: Buffer.from('d') + }); + assert.equal(m.uuid, 'u'); + assert.equal(m.owner, 'o'); + assert.equal(m.diffType, 'keys'); + assert.equal(m.diffIndex, 1); + assert.equal(m.policyTopicId, 't1'); + assert.equal(m.instanceTopicId, 't2'); + assert.equal(m.getDocument().toString(), 'd'); + }); + + it('fromJson throws on empty input', function () { + assert.throws(() => PolicyDiffMessage.fromJson(null), 'JSON Object is empty'); + }); + + it('validate returns true and getOwner returns the owner', function () { + const m = PolicyDiffMessage.fromMessageObject(body); + assert.isTrue(m.validate()); + assert.equal(m.getOwner(), body.owner); + }); + + it('toMessage embeds the diff payload for ISSUE status', function () { + const m = new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.PublishPolicyDiff); + m.setDocument(diff, Buffer.from('x')); + const parsed = JSON.parse(m.toMessage()); + assert.equal(parsed.type, MessageType.PolicyDiff); + assert.equal(parsed.uuid, diff.uuid); + assert.equal(parsed.diffType, 'backup'); + assert.equal(parsed.status, MessageStatus.ISSUE); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/policy-record-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/policy-record-message-extra.test.mjs new file mode 100644 index 0000000000..a5ea21c88e --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/policy-record-message-extra.test.mjs @@ -0,0 +1,201 @@ +import { assert } from 'chai'; + +import { + PolicyRecordMessage, + MessageAction, + MessageType, + MessageStatus, + UrlType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('PolicyRecordMessage extra', function () { + const model = { + policyId: 'policy-1', + policyMessageId: 'pm-1', + recordingUuid: 'rec-uuid', + recordId: 'rec-1', + recordActionId: 'act-1', + method: 'STEP', + action: 'block-action', + time: 12345, + user: 'did:hedera:testnet:user', + target: 'target-1' + }; + + const body = { + id: 'msg-id', + status: MessageStatus.ISSUE, + type: MessageType.PolicyRecordStep, + action: MessageAction.PolicyRecordStep, + lang: 'en-US', + policyId: 'policy-1', + recordingUuid: 'rec-uuid', + recordId: 'rec-1', + recordActionId: 'act-1', + method: 'STEP', + time: 12345, + cid: 'cidRec' + }; + + it('defaults to the PolicyRecordStep action and raw response type', function () { + const m = new PolicyRecordMessage(); + assert.equal(m.action, MessageAction.PolicyRecordStep); + assert.equal(m.type, MessageType.PolicyRecordStep); + assert.equal(m.responseType, 'raw'); + assert.deepEqual(m.getUrls(), []); + }); + + it('setDocument maps all fields and buffers the zip', function () { + const m = new PolicyRecordMessage(); + m.setDocument(model, Buffer.from('zip')); + assert.equal(m.policyId, model.policyId); + assert.equal(m.policyMessageId, model.policyMessageId); + assert.equal(m.recordingUuid, model.recordingUuid); + assert.equal(m.recordId, model.recordId); + assert.equal(m.recordActionId, model.recordActionId); + assert.equal(m.method, model.method); + assert.equal(m.actionName, model.action); + assert.equal(m.time, model.time); + assert.equal(m.user, model.user); + assert.equal(m.target, model.target); + assert.equal(m.getDocument().toString(), 'zip'); + }); + + it('setDocument defaults optional fields to null', function () { + const m = new PolicyRecordMessage(); + m.setDocument({ + policyId: 'p', + recordingUuid: 'ru', + recordId: 'r', + recordActionId: 'a', + method: 'STEP', + time: 1 + }); + assert.isNull(m.policyMessageId); + assert.isNull(m.actionName); + assert.isNull(m.user); + assert.isNull(m.target); + assert.isUndefined(m.getDocument()); + }); + + it('toMessageObject maps the record fields', function () { + const m = new PolicyRecordMessage(); + m.setDocument(model, Buffer.from('x')); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyRecordStep); + assert.equal(obj.policyId, model.policyId); + assert.equal(obj.policyMessageId, model.policyMessageId); + assert.equal(obj.recordingUuid, model.recordingUuid); + assert.equal(obj.recordId, model.recordId); + assert.equal(obj.recordActionId, model.recordActionId); + assert.equal(obj.method, model.method); + assert.equal(obj.actionName, model.action); + assert.equal(obj.time, model.time); + assert.equal(obj.user, model.user); + assert.equal(obj.target, model.target); + assert.isUndefined(obj.cid); + }); + + it('toDocuments returns the buffer when set and [] otherwise', async function () { + const m = new PolicyRecordMessage(); + assert.deepEqual(await m.toDocuments(), []); + m.setDocument(model, Buffer.from('doc')); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), 'doc'); + }); + + it('loadDocuments stores a single document only', function () { + const m = new PolicyRecordMessage(); + const result = m.loadDocuments(['payload']); + assert.equal(result, m); + assert.equal(m.getDocument().toString(), 'payload'); + const m2 = new PolicyRecordMessage(); + m2.loadDocuments(['a', 'b']); + assert.isUndefined(m2.getDocument()); + }); + + it('fromMessageObject with cid builds the ipfs url', function () { + const m = PolicyRecordMessage.fromMessageObject(body); + assert.equal(m.getDocumentUrl(UrlType.cid), 'cidRec'); + assert.equal(m.getDocumentUrl(UrlType.url), 'ipfs://cidRec'); + assert.deepEqual(m.getUrl(), { cid: 'cidRec', url: 'ipfs://cidRec' }); + }); + + it('fromMessageObject without cid keeps urls empty', function () { + const m = PolicyRecordMessage.fromMessageObject({ ...body, cid: undefined }); + assert.deepEqual(m.getUrls(), []); + assert.isUndefined(m.getUrl()); + }); + + it('fromMessage parses a JSON string', function () { + const m = PolicyRecordMessage.fromMessage(JSON.stringify(body)); + assert.equal(m.policyId, body.policyId); + assert.equal(m.time, body.time); + }); + + it('validate requires policyId, recordingUuid and recordId', function () { + const m = new PolicyRecordMessage(); + assert.isFalse(m.validate()); + m.policyId = 'p'; + m.recordingUuid = 'ru'; + assert.isFalse(m.validate()); + m.recordId = 'r'; + assert.isTrue(m.validate()); + }); + + it('toJson exposes record fields and the document', function () { + const m = new PolicyRecordMessage(); + m.setDocument(model, Buffer.from('zip')); + const json = m.toJson(); + assert.equal(json.policyId, model.policyId); + assert.equal(json.policyMessageId, model.policyMessageId); + assert.equal(json.recordingUuid, model.recordingUuid); + assert.equal(json.recordId, model.recordId); + assert.equal(json.recordActionId, model.recordActionId); + assert.equal(json.method, model.method); + assert.equal(json.actionName, model.action); + assert.equal(json.time, model.time); + assert.equal(json.user, model.user); + assert.equal(json.target, model.target); + assert.equal(json.document.toString(), 'zip'); + }); + + it('fromJson restores fields with nullish defaults', function () { + const m = PolicyRecordMessage.fromJson({ + action: MessageAction.PolicyRecordStep, + policyId: 'p', + recordingUuid: 'ru', + recordId: 'r', + recordActionId: 'a', + method: 'STEP', + time: 9 + }); + assert.equal(m.policyId, 'p'); + assert.isNull(m.policyMessageId); + assert.isNull(m.actionName); + assert.isNull(m.user); + assert.isNull(m.target); + }); + + it('fromJson throws on empty input', function () { + assert.throws(() => PolicyRecordMessage.fromJson(null), 'JSON Object is empty'); + }); + + it('getOwner returns the user or null', function () { + const m = new PolicyRecordMessage(); + assert.isNull(m.getOwner()); + m.setDocument(model); + assert.equal(m.getOwner(), model.user); + }); + + it('toMessage embeds the record payload for ISSUE status', function () { + const m = new PolicyRecordMessage(); + m.setDocument(model, Buffer.from('x')); + const parsed = JSON.parse(m.toMessage()); + assert.equal(parsed.type, MessageType.PolicyRecordStep); + assert.equal(parsed.policyId, model.policyId); + assert.equal(parsed.method, model.method); + assert.equal(parsed.status, MessageStatus.ISSUE); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/schema-package-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/schema-package-message-extra.test.mjs new file mode 100644 index 0000000000..9c65805c97 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/schema-package-message-extra.test.mjs @@ -0,0 +1,246 @@ +import { assert } from 'chai'; + +import { + SchemaPackageMessage, + MessageAction, + MessageType, + MessageStatus, + UrlType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('SchemaPackageMessage extra', function () { + const pack = { + name: 'Pack', + owner: 'did:hedera:testnet:owner', + version: '1.0.0', + document: { docField: 1 }, + context: { ctxField: 2 } + }; + + const schemaA = { + iri: '#iriA', + uuid: 'uuid-a', + name: 'A', + description: 'da', + entity: 'VC', + owner: 'did:owner', + version: '1.0.0', + codeVersion: '1.0.0', + messageId: 'm-a' + }; + + const schemaB = { + iri: '#iriB', + uuid: 'uuid-b', + name: 'B', + description: 'db', + entity: 'VC', + owner: 'did:owner', + version: '1.0.0', + codeVersion: '1.0.0', + messageId: 'm-b' + }; + + const body = { + id: 'msg-id', + status: MessageStatus.ISSUE, + type: MessageType.SchemaPackage, + action: MessageAction.PublishSchemas, + lang: 'en-US', + name: 'Pack', + owner: 'did:hedera:testnet:owner', + version: '1.0.0', + schemas: 2, + document_cid: 'cidDoc', + document_uri: 'ipfs://cidDoc', + context_cid: 'cidCtx', + context_uri: 'ipfs://cidCtx', + metadata_cid: 'cidMeta', + metadata_uri: 'ipfs://cidMeta' + }; + + function buildMessage() { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + m.setDocument(pack); + m.setMetadata([schemaA, schemaB], [schemaA, schemaB, { iri: '#x' }]); + return m; + } + + it('setDocument stores name/owner/version and the documents array', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + m.setDocument(pack); + assert.equal(m.name, 'Pack'); + assert.equal(m.owner, pack.owner); + assert.equal(m.version, '1.0.0'); + assert.deepEqual(m.documents[0], pack.document); + assert.deepEqual(m.documents[1], pack.context); + assert.isUndefined(m.documents[2]); + }); + + it('setMetadata fills metadata schemas and counts them', function () { + const m = buildMessage(); + assert.equal(m.schemas, 2); + const metadata = m.getMetadata(); + assert.lengthOf(metadata.schemas, 2); + assert.deepEqual(metadata.schemas[0], { + id: schemaA.iri, + uuid: schemaA.uuid, + name: schemaA.name, + description: schemaA.description, + entity: schemaA.entity, + owner: schemaA.owner, + version: schemaA.version, + codeVersion: schemaA.codeVersion + }); + }); + + it('setMetadata collects unique relationship messageIds and skips missing ones', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + m.setDocument(pack); + m.setMetadata([schemaA], [schemaA, schemaA, schemaB, { iri: '#none' }]); + assert.deepEqual(m.getMetadata().relationships, ['m-a', 'm-b']); + }); + + it('setMetadata with null arguments produces empty metadata', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + m.setDocument(pack); + m.setMetadata(null, null); + assert.equal(m.schemas, 0); + assert.deepEqual(m.getMetadata(), { schemas: [], relationships: [] }); + }); + + it('getDocument and getContext read the documents array', function () { + const m = buildMessage(); + assert.deepEqual(m.getDocument(), pack.document); + assert.deepEqual(m.getContext(), pack.context); + }); + + it('toDocuments serializes all parts for PublishSchemas', async function () { + const m = buildMessage(); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 3); + assert.deepEqual(JSON.parse(docs[0].toString()), pack.document); + assert.deepEqual(JSON.parse(docs[1].toString()), pack.context); + assert.deepEqual(JSON.parse(docs[2].toString()).schemas.length, 2); + }); + + it('toDocuments serializes for PublishSystemSchemas as well', async function () { + const m = new SchemaPackageMessage(MessageAction.PublishSystemSchemas); + m.setDocument(pack); + m.setMetadata([schemaA], []); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 3); + }); + + it('toDocuments returns an empty array for other actions', async function () { + const m = new SchemaPackageMessage(MessageAction.CreateSchema); + m.setDocument(pack); + m.setMetadata([schemaA], []); + assert.deepEqual(await m.toDocuments(), []); + }); + + it('loadDocuments parses every string entry', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + const result = m.loadDocuments([JSON.stringify({ a: 1 }), JSON.stringify({ b: 2 })]); + assert.equal(result, m); + assert.deepEqual(m.documents, [{ a: 1 }, { b: 2 }]); + }); + + it('loadDocuments ignores non-array input', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + m.loadDocuments('nope'); + assert.isUndefined(m.documents); + }); + + it('fromMessageObject maps fields and all three url slots', function () { + const m = SchemaPackageMessage.fromMessageObject(body); + assert.equal(m.name, 'Pack'); + assert.equal(m.owner, body.owner); + assert.equal(m.version, '1.0.0'); + assert.equal(m.schemas, 2); + assert.equal(m.getDocumentUrl(UrlType.cid), 'cidDoc'); + assert.equal(m.getContextUrl(UrlType.cid), 'cidCtx'); + assert.equal(m.getMetadataUrl(UrlType.cid), 'cidMeta'); + assert.equal(m.getMetadataUrl(UrlType.url), 'ipfs://cidMeta'); + }); + + it('fromMessageObject prefers *_url over *_uri when both present', function () { + const m = SchemaPackageMessage.fromMessageObject({ + ...body, + document_url: 'ipfs://other', + document_uri: 'ipfs://cidDoc' + }); + assert.equal(m.getDocumentUrl(UrlType.url), 'ipfs://other'); + }); + + it('fromMessageObject drops url slots without a cid', function () { + const m = SchemaPackageMessage.fromMessageObject({ + ...body, + context_cid: undefined, + metadata_cid: undefined + }); + assert.lengthOf(m.getUrls(), 1); + assert.isUndefined(m.getContextUrl(UrlType.cid)); + }); + + it('getUrl returns the same array as getUrls', function () { + const m = SchemaPackageMessage.fromMessageObject(body); + assert.deepEqual(m.getUrl(), m.getUrls()); + }); + + it('toJson exposes document/context/metadata when documents are set', function () { + const m = buildMessage(); + const json = m.toJson(); + assert.equal(json.name, 'Pack'); + assert.equal(json.owner, pack.owner); + assert.equal(json.version, '1.0.0'); + assert.deepEqual(json.document, pack.document); + assert.deepEqual(json.context, pack.context); + assert.equal(json.metadata.schemas.length, 2); + }); + + it('toJson omits documents when none are loaded', function () { + const m = new SchemaPackageMessage(MessageAction.PublishSchemas); + const json = m.toJson(); + assert.isUndefined(json.document); + assert.isUndefined(json.context); + assert.isUndefined(json.metadata); + }); + + it('fromJson restores documents triple', function () { + const m = SchemaPackageMessage.fromJson({ + action: MessageAction.PublishSchemas, + name: 'n', + owner: 'o', + version: 'v', + schemas: 5, + document: { d: 1 }, + context: { c: 2 }, + metadata: { schemas: [], relationships: [] } + }); + assert.equal(m.name, 'n'); + assert.equal(m.schemas, 5); + assert.deepEqual(m.getDocument(), { d: 1 }); + assert.deepEqual(m.getContext(), { c: 2 }); + assert.deepEqual(m.getMetadata(), { schemas: [], relationships: [] }); + }); + + it('fromJson throws on empty input', function () { + assert.throws(() => SchemaPackageMessage.fromJson(null), 'JSON Object is empty'); + }); + + it('validate returns true and getOwner returns the owner', function () { + const m = SchemaPackageMessage.fromMessageObject(body); + assert.isTrue(m.validate()); + assert.equal(m.getOwner(), body.owner); + }); + + it('toMessage embeds the package payload for ISSUE status', function () { + const m = buildMessage(); + const parsed = JSON.parse(m.toMessage()); + assert.equal(parsed.type, MessageType.SchemaPackage); + assert.equal(parsed.name, 'Pack'); + assert.equal(parsed.schemas, 2); + assert.equal(parsed.status, MessageStatus.ISSUE); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/statistic-assessment-message.test.mjs b/common/tests/unit-tests/hedera-modules/message/statistic-assessment-message.test.mjs new file mode 100644 index 0000000000..ce541c506e --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/statistic-assessment-message.test.mjs @@ -0,0 +1,123 @@ +import { assert } from 'chai'; + +import { + StatisticAssessmentMessage, + MessageAction, + MessageType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('StatisticAssessmentMessage', function () { + const body = { + id: 'msgId', + status: 'ISSUE', + type: MessageType.VCDocument, + action: MessageAction.CreateStatisticAssessment, + issuer: 'did:hedera:testnet:issuer', + relationships: ['rel1', 'rel2'], + target: 'targetId', + definition: 'defId', + cid: 'testCid', + url: 'ipfs://testCid' + }; + + it('default type is VCDocument', function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + assert.equal(m.type, MessageType.VCDocument); + }); + + it('fromMessage throws on empty', function () { + assert.throws(() => StatisticAssessmentMessage.fromMessage(null), 'Message Object is empty'); + }); + + it('fromMessageObject throws on empty', function () { + assert.throws(() => StatisticAssessmentMessage.fromMessageObject(null), 'JSON Object is empty'); + }); + + it('fromJson throws on empty', function () { + assert.throws(() => StatisticAssessmentMessage.fromJson(null), 'JSON Object is empty'); + }); + + it('fromMessageObject maps fields', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + assert.equal(m.action, MessageAction.CreateStatisticAssessment); + assert.equal(m.issuer, body.issuer); + assert.deepEqual(m.relationships, body.relationships); + assert.equal(m.target, body.target); + assert.equal(m.definition, body.definition); + }); + + it('fromMessage parses JSON string', function () { + const m = StatisticAssessmentMessage.fromMessage(JSON.stringify(body)); + assert.equal(m.issuer, body.issuer); + }); + + it('getters return mapped values', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + assert.deepEqual(m.getRelationships(), body.relationships); + assert.equal(m.getTarget(), body.target); + assert.equal(m.getDefinition(), body.definition); + assert.equal(m.getOwner(), body.issuer); + }); + + it('getRelationships defaults to empty array', function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + assert.deepEqual(m.getRelationships(), []); + }); + + it('setRelationships dedupes', function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + m.setRelationships(['a', 'a', 'b']); + assert.deepEqual(m.relationships, ['a', 'b']); + }); + + it('setTarget and setDefinition', function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + m.setTarget('t'); + m.setDefinition({ messageId: 'def-msg' }); + assert.equal(m.getTarget(), 't'); + assert.equal(m.getDefinition(), 'def-msg'); + }); + + it('validate returns true', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + assert.isTrue(m.validate()); + }); + + it('toMessageObject contains type/action/issuer', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.VCDocument); + assert.equal(obj.action, MessageAction.CreateStatisticAssessment); + assert.equal(obj.issuer, body.issuer); + assert.equal(obj.target, body.target); + }); + + it('loadDocuments + getDocument', async function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + await m.loadDocuments([JSON.stringify({ a: 1 })]); + assert.deepEqual(m.getDocument(), { a: 1 }); + }); + + it('toDocuments returns buffer', async function () { + const m = new StatisticAssessmentMessage(MessageAction.CreateStatisticAssessment); + await m.loadDocuments([JSON.stringify({ a: 1 })]); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.equal(docs[0].toString(), JSON.stringify({ a: 1 })); + }); + + it('toJson then fromJson round-trips', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + const json = m.toJson(); + const back = StatisticAssessmentMessage.fromJson(json); + assert.equal(back.issuer, m.issuer); + assert.deepEqual(back.relationships, m.relationships); + assert.equal(back.target, m.target); + assert.equal(back.definition, m.definition); + }); + + it('getDocumentUrl returns cid', function () { + const m = StatisticAssessmentMessage.fromMessageObject(body); + assert.equal(m.getUrl().cid, body.cid); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/message/statistic-message-extra.test.mjs b/common/tests/unit-tests/hedera-modules/message/statistic-message-extra.test.mjs new file mode 100644 index 0000000000..833608e17c --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/message/statistic-message-extra.test.mjs @@ -0,0 +1,165 @@ +import { assert } from 'chai'; + +import { + StatisticMessage, + MessageAction, + MessageType, + MessageStatus, + UrlType +} from '../../../../dist/hedera-modules/message/index.js'; + +describe('StatisticMessage extra', function () { + const item = { + name: 'Statistic 1', + description: 'desc', + owner: 'did:hedera:testnet:owner', + uuid: 'uuid-1', + policyTopicId: '0.0.111', + policyInstanceTopicId: '0.0.222', + config: { rules: [{ id: 'r1' }], formulas: [] } + }; + + const body = { + id: 'msg-id', + status: MessageStatus.ISSUE, + type: MessageType.PolicyStatistic, + action: MessageAction.PublishPolicyStatistic, + lang: 'en-US', + name: 'Statistic 1', + description: 'desc', + owner: 'did:hedera:testnet:owner', + uuid: 'uuid-1', + policyTopicId: '0.0.111', + policyInstanceTopicId: '0.0.222', + cid: 'cid777' + }; + + it('setDocument copies fields and keeps the config object', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.setDocument(item); + assert.equal(m.name, item.name); + assert.equal(m.description, item.description); + assert.equal(m.owner, item.owner); + assert.equal(m.uuid, item.uuid); + assert.equal(m.policyTopicId, item.policyTopicId); + assert.equal(m.policyInstanceTopicId, item.policyInstanceTopicId); + assert.deepEqual(m.config, item.config); + }); + + it('getDocument returns the config', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.setDocument(item); + assert.deepEqual(m.getDocument(), item.config); + }); + + it('toDocuments serializes the config to a JSON buffer', async function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.setDocument(item); + const docs = await m.toDocuments(); + assert.lengthOf(docs, 1); + assert.deepEqual(JSON.parse(docs[0].toString()), item.config); + }); + + it('loadDocuments parses the first document', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + const result = m.loadDocuments([JSON.stringify({ a: 1 })]); + assert.equal(result, m); + assert.deepEqual(m.config, { a: 1 }); + }); + + it('loadDocuments ignores non-array input', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.loadDocuments('not-an-array'); + assert.isUndefined(m.config); + }); + + it('toMessageObject exposes mapped fields with cid/uri', function () { + const m = StatisticMessage.fromMessageObject(body); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyStatistic); + assert.equal(obj.name, body.name); + assert.equal(obj.owner, body.owner); + assert.equal(obj.uuid, body.uuid); + assert.equal(obj.policyTopicId, body.policyTopicId); + assert.equal(obj.policyInstanceTopicId, body.policyInstanceTopicId); + assert.equal(obj.cid, 'cid777'); + assert.equal(obj.uri, 'ipfs://cid777'); + }); + + it('fromMessageObject drops the url entry when cid is missing', function () { + const m = StatisticMessage.fromMessageObject({ ...body, cid: undefined }); + assert.deepEqual(m.getUrls(), []); + assert.isUndefined(m.getDocumentUrl(UrlType.cid)); + }); + + it('getUrl returns the same array as getUrls', function () { + const m = StatisticMessage.fromMessageObject(body); + assert.deepEqual(m.getUrl(), m.getUrls()); + }); + + it('getDocumentUrl and getContextUrl read url slots 0 and 1', function () { + const m = StatisticMessage.fromMessageObject(body); + assert.equal(m.getDocumentUrl(UrlType.cid), 'cid777'); + assert.equal(m.getDocumentUrl(UrlType.url), 'ipfs://cid777'); + assert.isUndefined(m.getContextUrl(UrlType.url)); + }); + + it('validate always returns true', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + assert.isTrue(m.validate()); + }); + + it('fromJson restores the statistic fields', function () { + const m = StatisticMessage.fromJson({ + action: MessageAction.PublishPolicyStatistic, + name: 'n', + description: 'd', + owner: 'o', + uuid: 'u', + policyTopicId: 't1', + policyInstanceTopicId: 't2', + config: { rules: [] } + }); + assert.equal(m.name, 'n'); + assert.equal(m.description, 'd'); + assert.equal(m.owner, 'o'); + assert.equal(m.uuid, 'u'); + assert.equal(m.policyTopicId, 't1'); + assert.equal(m.policyInstanceTopicId, 't2'); + assert.deepEqual(m.config, { rules: [] }); + }); + + it('getOwner returns the owner did', function () { + const m = StatisticMessage.fromMessageObject(body); + assert.equal(m.getOwner(), body.owner); + }); + + it('toMessage embeds the statistic payload for ISSUE status', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.setDocument(item); + const parsed = JSON.parse(m.toMessage()); + assert.equal(parsed.status, MessageStatus.ISSUE); + assert.equal(parsed.type, MessageType.PolicyStatistic); + assert.equal(parsed.name, item.name); + assert.equal(parsed.uuid, item.uuid); + }); + + it('toMessage / fromMessage round-trips the scalar fields', function () { + const m = new StatisticMessage(MessageAction.PublishPolicyStatistic); + m.setDocument(item); + const restored = StatisticMessage.fromMessage(m.toMessage()); + assert.equal(restored.name, item.name); + assert.equal(restored.owner, item.owner); + assert.equal(restored.policyTopicId, item.policyTopicId); + }); + + it('fromMessage with REVOKE status restores the revoked flag', function () { + const m = StatisticMessage.fromMessage(JSON.stringify({ + ...body, + status: MessageStatus.REVOKE, + revokeMessage: 'gone', + reason: 'Document Revoked' + })); + assert.isTrue(m.isRevoked()); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/policy-action-message-full.test.mjs b/common/tests/unit-tests/hedera-modules/policy-action-message-full.test.mjs new file mode 100644 index 0000000000..5413857e10 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/policy-action-message-full.test.mjs @@ -0,0 +1,77 @@ +import { assert } from 'chai'; +import { PolicyActionMessage } from '../../../dist/hedera-modules/message/policy-action-message.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; + +const action = MessageAction.PublishPolicy; + +const model = { + uuid: 'u', owner: 'o', policyId: 'p', accountId: 'a', + relayerAccount: 'ra', blockTag: 'bt', startMessageId: 'parent', +}; + +describe('@unit PolicyActionMessage full', () => { + it('setDocument copies action fields and the data document', () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, { payload: 1 }); + assert.equal(m.uuid, 'u'); + assert.equal(m.parent, 'parent'); + assert.deepEqual(m.getDocument(), { payload: 1 }); + }); + + it('toDocuments encrypts and loadDocuments decrypts the document', async () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, { secret: 42 }); + const docs = await m.toDocuments('key'); + assert.equal(docs.length, 1); + const out = await m.loadDocuments([docs[0].toString()], 'key'); + assert.deepEqual(out.document, { secret: 42 }); + }); + + it('toMessageObject serializes the action body', () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, {}); + const obj = m.toMessageObject(); + assert.equal(obj.type, MessageType.PolicyAction); + assert.equal(obj.uuid, 'u'); + assert.equal(obj.policyId, 'p'); + }); + + it('fromMessage/fromMessageObject round-trips with a cid url', () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, {}); + const obj = { ...m.toMessageObject(), id: 'i', status: 's', cid: 'QmCid' }; + const restored = PolicyActionMessage.fromMessage(JSON.stringify(obj)); + assert.equal(restored.owner, 'o'); + assert.ok(restored.getUrl()); + assert.equal(restored.getDocumentUrl('cid'), 'QmCid'); + }); + + it('static from populates payer/index/id/topic', () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, {}); + const data = { + message: JSON.stringify({ ...m.toMessageObject(), cid: 'QmCid' }), + owner: 'payer', sequenceNumber: 3, consensusTimestamp: 'ts', topicId: '0.0.9', + }; + const restored = PolicyActionMessage.from(data); + assert.equal(restored.uuid, 'u'); + }); + + it('empty-input guards on from / fromMessage / fromMessageObject / fromJson', () => { + assert.throws(() => PolicyActionMessage.from(null), /Message Object is empty/); + assert.throws(() => PolicyActionMessage.from({ message: '' }), /Message Object is empty/); + assert.throws(() => PolicyActionMessage.fromMessage(''), /Message Object is empty/); + assert.throws(() => PolicyActionMessage.fromMessageObject(null), /JSON Object is empty/); + assert.throws(() => PolicyActionMessage.fromJson(null), /JSON Object is empty/); + }); + + it('validate is true and toJson/fromJson round-trips', () => { + const m = new PolicyActionMessage(action); + m.setDocument(model, { d: 1 }); + assert.isTrue(m.validate()); + const restored = PolicyActionMessage.fromJson(m.toJson()); + assert.equal(restored.uuid, 'u'); + assert.deepEqual(restored.document, { d: 1 }); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/policy-action-message.test.mjs b/common/tests/unit-tests/hedera-modules/policy-action-message.test.mjs new file mode 100644 index 0000000000..ce72ac647e --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/policy-action-message.test.mjs @@ -0,0 +1,38 @@ +import { assert } from 'chai'; +import { + PolicyActionMessage, + MessageType, + MessageAction +} from '../../../dist/hedera-modules/message/index.js'; + +describe('PolicyActionMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.PolicyAction, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', + uuid: 'u1', owner: 'did:o', policyId: 'p1', accountId: '0.0.5', + relayerAccount: '0.0.6', blockTag: 'tag', parent: 'parent-1', ...over + }); + + it('constructs with the PolicyAction type', () => { + assert.equal(new PolicyActionMessage(MessageAction.CreateVC).type, MessageType.PolicyAction); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => PolicyActionMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps action fields', () => { + const m = PolicyActionMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.policyId, 'p1'); + assert.equal(m.accountId, '0.0.5'); + assert.equal(m.relayerAccount, '0.0.6'); + assert.equal(m.blockTag, 'tag'); + assert.equal(m.parent, 'parent-1'); + }); + + it('toDocuments resolves to an array', async () => { + assert.isArray(await PolicyActionMessage.fromMessageObject(body()).toDocuments()); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/policy-artifacts-messages.test.mjs b/common/tests/unit-tests/hedera-modules/policy-artifacts-messages.test.mjs new file mode 100644 index 0000000000..25ab380274 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/policy-artifacts-messages.test.mjs @@ -0,0 +1,126 @@ +import { assert } from 'chai'; +import { + LabelMessage, + StatisticMessage, + SchemaPackageMessage, + PolicyDiffMessage, + PolicyRecordMessage, + MessageType, + MessageAction +} from '../../../dist/hedera-modules/message/index.js'; + +describe('LabelMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.PolicyLabel, action: MessageAction.CreateVC, + name: 'label', description: 'd', owner: 'did:o', uuid: 'u1', + policyTopicId: '0.0.1', policyInstanceTopicId: '0.0.2', ...over + }); + it('constructs with the PolicyLabel type', () => { + assert.equal(new LabelMessage(MessageAction.CreateVC).type, MessageType.PolicyLabel); + }); + it('fromMessageObject throws on empty json', () => { + assert.throws(() => LabelMessage.fromMessageObject(null), /JSON Object is empty/); + }); + it('fromMessageObject maps label fields', () => { + const m = LabelMessage.fromMessageObject(body()); + assert.equal(m.name, 'label'); + assert.equal(m.uuid, 'u1'); + assert.equal(m.policyTopicId, '0.0.1'); + assert.equal(m.policyInstanceTopicId, '0.0.2'); + }); +}); + +describe('StatisticMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.PolicyStatistic, action: MessageAction.CreateVC, + name: 'stat', description: 'd', owner: 'did:o', uuid: 'u1', + policyTopicId: '0.0.1', policyInstanceTopicId: '0.0.2', ...over + }); + it('constructs with the PolicyStatistic type', () => { + assert.equal(new StatisticMessage(MessageAction.CreateVC).type, MessageType.PolicyStatistic); + }); + it('fromMessageObject throws on empty json', () => { + assert.throws(() => StatisticMessage.fromMessageObject(null), /JSON Object is empty/); + }); + it('fromMessageObject maps statistic fields', () => { + const m = StatisticMessage.fromMessageObject(body()); + assert.equal(m.name, 'stat'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.policyTopicId, '0.0.1'); + }); +}); + +describe('SchemaPackageMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.SchemaPackage, action: MessageAction.CreateVC, + name: 'pkg', owner: 'did:o', version: '1.0.0', schemas: ['s1', 's2'], ...over + }); + it('constructs with the SchemaPackage type', () => { + assert.equal(new SchemaPackageMessage(MessageAction.CreateVC).type, MessageType.SchemaPackage); + }); + it('fromMessageObject throws on empty json', () => { + assert.throws(() => SchemaPackageMessage.fromMessageObject(null), /JSON Object is empty/); + }); + it('fromMessageObject maps package fields', () => { + const m = SchemaPackageMessage.fromMessageObject(body()); + assert.equal(m.name, 'pkg'); + assert.equal(m.version, '1.0.0'); + assert.deepEqual(m.schemas, ['s1', 's2']); + }); +}); + +describe('PolicyDiffMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.PolicyDiff, action: MessageAction.CreateVC, + uuid: 'u1', owner: 'did:o', diffType: 'full', diffIndex: 3, + policyTopicId: '0.0.1', instanceTopicId: '0.0.2', ...over + }); + it('constructs with the PolicyDiff type', () => { + assert.equal(new PolicyDiffMessage(MessageType.PolicyDiff, MessageAction.CreateVC).type, MessageType.PolicyDiff); + }); + it('fromMessageObject throws on empty json', () => { + assert.throws(() => PolicyDiffMessage.fromMessageObject(null), /JSON Object is empty/); + }); + it('fromMessageObject throws on a wrong type', () => { + assert.throws(() => PolicyDiffMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + it('fromMessageObject maps diff fields', () => { + const m = PolicyDiffMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.diffType, 'full'); + assert.equal(m.diffIndex, 3); + assert.equal(m.policyTopicId, '0.0.1'); + assert.equal(m.instanceTopicId, '0.0.2'); + }); +}); + +describe('PolicyRecordMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.PolicyRecordStep, action: MessageAction.PolicyRecordStep, + policyId: 'p1', policyMessageId: 'pm1', recordingUuid: 'r1', recordId: 'rec1', + recordActionId: 'ra1', method: 'POST', actionName: 'act', time: '123', user: 'u', target: 't', ...over + }); + it('constructs with the PolicyRecordStep type', () => { + assert.equal(new PolicyRecordMessage().type, MessageType.PolicyRecordStep); + }); + it('fromMessageObject throws on empty json', () => { + assert.throws(() => PolicyRecordMessage.fromMessageObject(null), /JSON Object is empty/); + }); + it('fromMessageObject throws on a wrong type', () => { + assert.throws(() => PolicyRecordMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + it('fromMessageObject maps record fields', () => { + const m = PolicyRecordMessage.fromMessageObject(body()); + assert.equal(m.policyId, 'p1'); + assert.equal(m.recordingUuid, 'r1'); + assert.equal(m.recordId, 'rec1'); + assert.equal(m.method, 'POST'); + assert.equal(m.actionName, 'act'); + }); + it('fromMessageObject defaults optional fields to null when absent', () => { + const m = PolicyRecordMessage.fromMessageObject(body({ user: undefined, target: undefined, actionName: undefined })); + assert.equal(m.user, null); + assert.equal(m.target, null); + assert.equal(m.actionName, null); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/registration-message.test.mjs b/common/tests/unit-tests/hedera-modules/registration-message.test.mjs new file mode 100644 index 0000000000..c5760d4f4c --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/registration-message.test.mjs @@ -0,0 +1,89 @@ +import { assert } from 'chai'; +import { RegistrationMessage } from '../../../dist/hedera-modules/message/registration-message.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; + +const body = (over = {}) => ({ + id: 'rid', status: 'ISSUE', + type: MessageType.StandardRegistry, action: MessageAction.Init, + lang: 'en-US', account: '0.0.7', + did: 'did:registrant', topicId: '0.0.42', attributes: { geography: 'EU' }, ...over +}); + +describe('RegistrationMessage', () => { + it('constructs with the StandardRegistry type', () => { + const m = new RegistrationMessage(MessageAction.Init); + assert.equal(m.type, MessageType.StandardRegistry); + }); + + it('setDocument stores did, registrant topic, lang and attributes', () => { + const m = new RegistrationMessage(MessageAction.Init); + m.setDocument('did:x', '0.0.5', { a: '1' }); + assert.equal(m.did, 'did:x'); + assert.equal(m.registrantTopicId, '0.0.5'); + assert.equal(m.lang, 'en-US'); + assert.deepEqual(m.attributes, { a: '1' }); + }); + + it('setDocument defaults attributes to an empty object', () => { + const m = new RegistrationMessage(MessageAction.Init); + m.setDocument('did:x', '0.0.5'); + assert.deepEqual(m.attributes, {}); + }); + + it('toMessageObject maps registrantTopicId to topicId', () => { + const m = new RegistrationMessage(MessageAction.Init); + m.setDocument('did:x', '0.0.5', { a: '1' }); + const obj = m.toMessageObject(); + assert.equal(obj.did, 'did:x'); + assert.equal(obj.topicId, '0.0.5'); + assert.deepEqual(obj.attributes, { a: '1' }); + }); + + it('toDocuments resolves to []', async () => { + assert.deepEqual(await new RegistrationMessage(MessageAction.Init).toDocuments(), []); + }); + + it('loadDocuments returns the instance', () => { + const m = new RegistrationMessage(MessageAction.Init); + assert.equal(m.loadDocuments([]), m); + }); + + it('validate is true and getUrls is empty', () => { + const m = new RegistrationMessage(MessageAction.Init); + assert.equal(m.validate(), true); + assert.deepEqual(m.getUrls(), []); + }); + + it('fromMessage throws on an empty message', () => { + assert.throws(() => RegistrationMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => RegistrationMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a wrong type', () => { + assert.throws(() => RegistrationMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps did, topic and attributes', () => { + const m = RegistrationMessage.fromMessageObject(body()); + assert.equal(m.did, 'did:registrant'); + assert.equal(m.registrantTopicId, '0.0.42'); + assert.deepEqual(m.attributes, { geography: 'EU' }); + }); + + it('fromMessageObject defaults attributes to {} when absent', () => { + const m = RegistrationMessage.fromMessageObject(body({ attributes: undefined })); + assert.deepEqual(m.attributes, {}); + }); + + it('toJson / fromJson round-trips registration fields', () => { + const original = RegistrationMessage.fromMessageObject(body()); + const restored = RegistrationMessage.fromJson(original.toJson()); + assert.equal(restored.did, 'did:registrant'); + assert.equal(restored.registrantTopicId, '0.0.42'); + assert.deepEqual(restored.attributes, { geography: 'EU' }); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/role-vc-messages.test.mjs b/common/tests/unit-tests/hedera-modules/role-vc-messages.test.mjs new file mode 100644 index 0000000000..8e6e49440b --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/role-vc-messages.test.mjs @@ -0,0 +1,126 @@ +import { assert } from 'chai'; +import { + RoleMessage, + GuardianRoleMessage, + UserPermissionsMessage, + MessageType, + MessageAction +} from '../../../dist/hedera-modules/message/index.js'; + +const vcBase = (type) => ({ + id: 'mid', status: 'ISSUE', type, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.3', issuer: 'did:issuer', + cid: 'testCID', url: 'ipfs://testCID', relationships: ['r1', 'r2'] +}); + +describe('RoleMessage', () => { + it('constructs with the RoleDocument type by default', () => { + const m = new RoleMessage(MessageAction.CreateVC); + assert.equal(m.type, MessageType.RoleDocument); + }); + + it('setRole stores role and group, exposed by getters', () => { + const m = new RoleMessage(MessageAction.CreateVC); + m.setRole({ role: 'Installer', groupName: 'GroupA' }); + assert.equal(m.getRole(), 'Installer'); + assert.equal(m.getGroup(), 'GroupA'); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => RoleMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps role/group and inherited VC url', () => { + const m = RoleMessage.fromMessageObject({ ...vcBase(MessageType.RoleDocument), role: 'Auditor', group: 'G1' }); + assert.equal(m.role, 'Auditor'); + assert.equal(m.group, 'G1'); + assert.deepEqual(m.getUrl(), { cid: 'testCID', url: 'ipfs://testCID' }); + }); + + it('toMessageObject includes role/group when present', () => { + const m = RoleMessage.fromMessageObject({ ...vcBase(MessageType.RoleDocument), role: 'Auditor', group: 'G1' }); + const obj = m.toMessageObject(); + assert.equal(obj.role, 'Auditor'); + assert.equal(obj.group, 'G1'); + }); + + it('toJson / fromJson round-trips role and group', () => { + const original = RoleMessage.fromMessageObject({ ...vcBase(MessageType.RoleDocument), role: 'Auditor', group: 'G1' }); + const restored = RoleMessage.fromJson(original.toJson()); + assert.equal(restored.role, 'Auditor'); + assert.equal(restored.group, 'G1'); + }); +}); + +describe('GuardianRoleMessage', () => { + it('constructs with the GuardianRole type by default', () => { + const m = new GuardianRoleMessage(MessageAction.CreateVC); + assert.equal(m.type, MessageType.GuardianRole); + }); + + it('setRole stores uuid/name/description', () => { + const m = new GuardianRoleMessage(MessageAction.CreateVC); + m.setRole({ uuid: 'u1', name: 'Admin', description: 'desc' }); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'Admin'); + assert.equal(m.description, 'desc'); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => GuardianRoleMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps uuid/name/description', () => { + const m = GuardianRoleMessage.fromMessageObject({ ...vcBase(MessageType.GuardianRole), uuid: 'u1', name: 'Admin', description: 'd' }); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'Admin'); + assert.equal(m.description, 'd'); + }); + + it('toMessageObject includes role fields when present', () => { + const m = GuardianRoleMessage.fromMessageObject({ ...vcBase(MessageType.GuardianRole), uuid: 'u1', name: 'Admin', description: 'd' }); + const obj = m.toMessageObject(); + assert.equal(obj.uuid, 'u1'); + assert.equal(obj.name, 'Admin'); + }); + + it('toJson / fromJson round-trips role fields', () => { + const original = GuardianRoleMessage.fromMessageObject({ ...vcBase(MessageType.GuardianRole), uuid: 'u1', name: 'Admin', description: 'd' }); + const restored = GuardianRoleMessage.fromJson(original.toJson()); + assert.equal(restored.uuid, 'u1'); + assert.equal(restored.description, 'd'); + }); +}); + +describe('UserPermissionsMessage', () => { + it('constructs with the UserPermissions type by default', () => { + const m = new UserPermissionsMessage(MessageAction.CreateVC); + assert.equal(m.type, MessageType.UserPermissions); + }); + + it('setRole stores the user DID', () => { + const m = new UserPermissionsMessage(MessageAction.CreateVC); + m.setRole({ user: 'did:user' }); + assert.equal(m.user, 'did:user'); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => UserPermissionsMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps the user field', () => { + const m = UserPermissionsMessage.fromMessageObject({ ...vcBase(MessageType.UserPermissions), user: 'did:user' }); + assert.equal(m.user, 'did:user'); + }); + + it('toMessageObject includes the user when present', () => { + const m = UserPermissionsMessage.fromMessageObject({ ...vcBase(MessageType.UserPermissions), user: 'did:user' }); + assert.equal(m.toMessageObject().user, 'did:user'); + }); + + it('toJson / fromJson round-trips the user field', () => { + const original = UserPermissionsMessage.fromMessageObject({ ...vcBase(MessageType.UserPermissions), user: 'did:user' }); + const restored = UserPermissionsMessage.fromJson(original.toJson()); + assert.equal(restored.user, 'did:user'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/tag-sync-messages.test.mjs b/common/tests/unit-tests/hedera-modules/tag-sync-messages.test.mjs new file mode 100644 index 0000000000..d5a9f896cf --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/tag-sync-messages.test.mjs @@ -0,0 +1,88 @@ +import { assert } from 'chai'; +import { + TagMessage, + SynchronizationMessage, + MessageType, + MessageAction +} from '../../../dist/hedera-modules/message/index.js'; + +describe('TagMessage', () => { + const body = (over = {}) => ({ + id: 'tid', status: 'ISSUE', type: MessageType.Tag, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', + uuid: 'u1', name: 'tag-name', description: 'd', owner: 'did:owner', + target: 'target-1', operation: 'Create', entity: 'PolicyDocument', + date: '2024-01-01', linkedItems: ['a', 'b'], inheritTags: true, ...over + }); + + it('constructs with the Tag type', () => { + assert.equal(new TagMessage(MessageAction.CreateVC).type, MessageType.Tag); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => TagMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Tag type', () => { + assert.throws(() => TagMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps all tag scalar fields', () => { + const m = TagMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'tag-name'); + assert.equal(m.description, 'd'); + assert.equal(m.owner, 'did:owner'); + assert.equal(m.target, 'target-1'); + assert.equal(m.operation, 'Create'); + assert.equal(m.entity, 'PolicyDocument'); + assert.equal(m.date, '2024-01-01'); + assert.deepEqual(m.linkedItems, ['a', 'b']); + assert.equal(m.inheritTags, true); + }); + + it('toDocuments resolves to an array', async () => { + const m = TagMessage.fromMessageObject(body()); + assert.isArray(await m.toDocuments()); + }); +}); + +describe('SynchronizationMessage', () => { + const body = (over = {}) => ({ + id: 'sid', status: 'ISSUE', type: MessageType.Synchronization, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.2', + user: 'did:user', policy: 'policy-1', policyType: 'Main', + messageId: 'm-1', tokenId: '0.0.50', amount: '10', + memo: 'note', target: 't-1', policyOwner: 'did:owner', ...over + }); + + it('constructs with the Synchronization type', () => { + assert.equal(new SynchronizationMessage(MessageAction.CreateVC).type, MessageType.Synchronization); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => SynchronizationMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Synchronization type', () => { + assert.throws(() => SynchronizationMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps the synchronization fields', () => { + const m = SynchronizationMessage.fromMessageObject(body()); + assert.equal(m.user, 'did:user'); + assert.equal(m.policy, 'policy-1'); + assert.equal(m.policyType, 'Main'); + assert.equal(m.messageId, 'm-1'); + assert.equal(m.tokenId, '0.0.50'); + assert.equal(m.amount, '10'); + assert.equal(m.memo, 'note'); + assert.equal(m.target, 't-1'); + assert.equal(m.policyOwner, 'did:owner'); + }); + + it('toDocuments resolves to an empty array', async () => { + const m = SynchronizationMessage.fromMessageObject(body()); + assert.deepEqual(await m.toDocuments(), []); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/token-message.test.mjs b/common/tests/unit-tests/hedera-modules/token-message.test.mjs new file mode 100644 index 0000000000..456c2036de --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/token-message.test.mjs @@ -0,0 +1,104 @@ +import { assert } from 'chai'; +import { TokenMessage } from '../../../dist/hedera-modules/message/token-message.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; + +const sampleBody = () => ({ + id: 'mid', status: 'ISSUE', + type: MessageType.Token, action: MessageAction.CreateToken, + lang: 'en', account: '0.0.5', + tokenId: '0.0.1', tokenName: 'My Token', tokenSymbol: 'MTK', + tokenType: 'fungible', decimals: '2', owner: 'did:owner' +}); + +describe('TokenMessage', () => { + it('constructs with the Token type and given action', () => { + const m = new TokenMessage(MessageAction.CreateToken); + assert.equal(m.type, MessageType.Token); + assert.equal(m.action, MessageAction.CreateToken); + }); + + it('setDocument copies token fields', () => { + const m = new TokenMessage(MessageAction.CreateToken); + m.setDocument({ tokenId: '0.0.1', tokenName: 'N', tokenSymbol: 'S', tokenType: 't', decimals: '2', owner: 'o' }); + assert.equal(m.tokenId, '0.0.1'); + assert.equal(m.tokenName, 'N'); + assert.equal(m.tokenSymbol, 'S'); + assert.equal(m.tokenType, 't'); + assert.equal(m.decimals, '2'); + assert.equal(m.owner, 'o'); + }); + + it('toMessageObject serializes token fields with null id/status', () => { + const m = new TokenMessage(MessageAction.CreateToken); + m.setDocument({ tokenId: '0.0.1', tokenName: 'N' }); + const obj = m.toMessageObject(); + assert.equal(obj.id, null); + assert.equal(obj.status, null); + assert.equal(obj.type, MessageType.Token); + assert.equal(obj.action, MessageAction.CreateToken); + assert.equal(obj.tokenId, '0.0.1'); + assert.equal(obj.tokenName, 'N'); + }); + + it('toDocuments resolves to an empty array', async () => { + const m = new TokenMessage(MessageAction.CreateToken); + assert.deepEqual(await m.toDocuments(), []); + }); + + it('loadDocuments returns the same instance', () => { + const m = new TokenMessage(MessageAction.CreateToken); + assert.equal(m.loadDocuments(['x']), m); + }); + + it('validate returns true', () => { + assert.equal(new TokenMessage(MessageAction.CreateToken).validate(), true); + }); + + it('getUrls returns an empty array', () => { + assert.deepEqual(new TokenMessage(MessageAction.CreateToken).getUrls(), []); + }); + + it('fromMessage throws on an empty message', () => { + assert.throws(() => TokenMessage.fromMessage(''), /Message Object is empty/); + }); + + it('fromMessage parses a JSON string', () => { + const m = TokenMessage.fromMessage(JSON.stringify(sampleBody())); + assert.equal(m.tokenId, '0.0.1'); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => TokenMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Token type', () => { + assert.throws(() => TokenMessage.fromMessageObject({ ...sampleBody(), type: 'Other' }), /Invalid message type/); + }); + + it('fromMessageObject maps token fields and id/status', () => { + const m = TokenMessage.fromMessageObject(sampleBody()); + assert.equal(m.tokenId, '0.0.1'); + assert.equal(m.tokenName, 'My Token'); + assert.equal(m.tokenSymbol, 'MTK'); + assert.equal(m.tokenType, 'fungible'); + assert.equal(m.decimals, '2'); + assert.equal(m.owner, 'did:owner'); + }); + + it('toJson includes token fields on top of the base payload', () => { + const m = TokenMessage.fromMessageObject(sampleBody()); + const json = m.toJson(); + assert.equal(json.tokenId, '0.0.1'); + assert.equal(json.tokenSymbol, 'MTK'); + assert.equal(json.owner, 'did:owner'); + }); + + it('fromJson reconstructs a TokenMessage from toJson output', () => { + const original = TokenMessage.fromMessageObject(sampleBody()); + const restored = TokenMessage.fromJson(original.toJson()); + assert.equal(restored.tokenId, original.tokenId); + assert.equal(restored.tokenName, original.tokenName); + assert.equal(restored.owner, original.owner); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/tool-module-formula-messages.test.mjs b/common/tests/unit-tests/hedera-modules/tool-module-formula-messages.test.mjs new file mode 100644 index 0000000000..900ccd936e --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/tool-module-formula-messages.test.mjs @@ -0,0 +1,103 @@ +import { assert } from 'chai'; +import { + ToolMessage, + ModuleMessage, + FormulaMessage, + MessageType, + MessageAction +} from '../../../dist/hedera-modules/message/index.js'; + +describe('ToolMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.Tool, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', + uuid: 'u1', name: 'tool', description: 'd', owner: 'did:o', hash: 'h1', + topicId: '0.0.10', tagsTopicId: '0.0.11', version: '1.0.0', ...over + }); + + it('constructs with the Tool type', () => { + assert.equal(new ToolMessage(MessageType.Tool, MessageAction.CreateVC).type, MessageType.Tool); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => ToolMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Tool type', () => { + assert.throws(() => ToolMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps tool fields (topicId → toolTopicId)', () => { + const m = ToolMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'tool'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.hash, 'h1'); + assert.equal(m.toolTopicId, '0.0.10'); + assert.equal(m.tagsTopicId, '0.0.11'); + assert.equal(m.version, '1.0.0'); + }); + + it('toDocuments resolves to an array', async () => { + assert.isArray(await ToolMessage.fromMessageObject(body()).toDocuments()); + }); +}); + +describe('ModuleMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.Module, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', + uuid: 'u1', name: 'mod', description: 'd', owner: 'did:o', topicId: '0.0.20', ...over + }); + + it('constructs with the Module type', () => { + assert.equal(new ModuleMessage(MessageType.Module, MessageAction.CreateVC).type, MessageType.Module); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => ModuleMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject throws on a non-Module type', () => { + assert.throws(() => ModuleMessage.fromMessageObject(body({ type: 'Other' })), /Invalid message type/); + }); + + it('fromMessageObject maps module fields (topicId → moduleTopicId)', () => { + const m = ModuleMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'mod'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.moduleTopicId, '0.0.20'); + }); + + it('toDocuments resolves to an array', async () => { + assert.isArray(await ModuleMessage.fromMessageObject(body()).toDocuments()); + }); +}); + +describe('FormulaMessage', () => { + const body = (over = {}) => ({ + id: 'id', status: 'ISSUE', type: MessageType.Formula, action: MessageAction.CreateVC, + lang: 'en', account: '0.0.1', + uuid: 'u1', name: 'formula', description: 'd', owner: 'did:o', + policyTopicId: '0.0.30', policyInstanceTopicId: '0.0.31', autoGenerated: true, ...over + }); + + it('constructs with the Formula type', () => { + assert.equal(new FormulaMessage(MessageAction.CreateVC).type, MessageType.Formula); + }); + + it('fromMessageObject throws on empty json', () => { + assert.throws(() => FormulaMessage.fromMessageObject(null), /JSON Object is empty/); + }); + + it('fromMessageObject maps formula fields', () => { + const m = FormulaMessage.fromMessageObject(body()); + assert.equal(m.uuid, 'u1'); + assert.equal(m.name, 'formula'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.policyTopicId, '0.0.30'); + assert.equal(m.policyInstanceTopicId, '0.0.31'); + assert.equal(m.autoGenerated, true); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document-coverage.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document-coverage.test.mjs new file mode 100644 index 0000000000..160d07f3a9 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document-coverage.test.mjs @@ -0,0 +1,110 @@ +import { assert } from 'chai'; + +import { CommonDidDocument } from '../../../../../dist/hedera-modules/vcjs/did/common-did-document.js'; + +describe('CommonDidDocument coverage', function () { + const did = 'did:example:123456789abcdefghi'; + const vm = { + id: did + '#keys-1', + controller: did, + type: 'Ed25519VerificationKey2018', + publicKeyBase58: 'pub1' + }; + const fullDocument = { + '@context': ['https://www.w3.org/ns/did/v1'], + id: did, + alsoKnownAs: ['did:example:alias'], + controller: [did, 'did:example:other'], + verificationMethod: [vm], + authentication: [vm.id], + assertionMethod: [vm.id], + keyAgreement: [vm.id], + capabilityInvocation: [vm.id], + capabilityDelegation: [vm.id], + service: [{ id: did + '#svc', type: 'LinkedDomains', serviceEndpoint: 'https://x' }] + }; + + it('parses a non-Hedera DID via CommonDid', function () { + const doc = CommonDidDocument.from(fullDocument); + assert.equal(doc.getDid(), did); + }); + + it('keeps alsoKnownAs and array controller', function () { + const obj = CommonDidDocument.from(fullDocument).getDocument(); + assert.deepEqual(obj.alsoKnownAs, ['did:example:alias']); + assert.deepEqual(obj.controller, [did, 'did:example:other']); + }); + + it('handles a string controller', function () { + const obj = CommonDidDocument.from({ ...fullDocument, controller: did }).getDocument(); + assert.equal(obj.controller, did); + }); + + it('maps keyAgreement / capabilityInvocation / capabilityDelegation', function () { + const obj = CommonDidDocument.from(fullDocument).getDocument(); + assert.deepEqual(obj.keyAgreement, [vm.id]); + assert.deepEqual(obj.capabilityInvocation, [vm.id]); + assert.deepEqual(obj.capabilityDelegation, [vm.id]); + }); + + it('round-trips all collection sections through toObject', function () { + const obj = CommonDidDocument.from(fullDocument).getDocument(); + assert.deepEqual(obj.authentication, [vm.id]); + assert.deepEqual(obj.assertionMethod, [vm.id]); + assert.lengthOf(obj.service, 1); + }); + + it('compare returns false when verificationMethod length differs', function () { + const a = CommonDidDocument.from(fullDocument); + const twoMethods = { + ...fullDocument, + verificationMethod: [vm, { ...vm, id: did + '#keys-2', publicKeyBase58: 'pub2' }] + }; + const b = CommonDidDocument.from(twoMethods); + assert.isFalse(a.compare(b)); + }); + + it('compare returns false when a method id is missing on the other side', function () { + const a = CommonDidDocument.from(fullDocument); + const renamed = { + ...fullDocument, + verificationMethod: [{ ...vm, id: did + '#different' }] + }; + const b = CommonDidDocument.from(renamed); + assert.isFalse(a.compare(b)); + }); + + it('compare accepts a plain IDidDocument object', function () { + const a = CommonDidDocument.from(fullDocument); + assert.isTrue(a.compare(a.getDocument())); + }); + + it('serializes embedded verification-method objects (not just string links)', function () { + const embedded = { + '@context': ['https://www.w3.org/ns/did/v1'], + id: did, + verificationMethod: [vm], + authentication: [vm], + assertionMethod: [vm], + keyAgreement: [vm], + capabilityInvocation: [vm], + capabilityDelegation: [vm] + }; + const obj = CommonDidDocument.from(embedded).getDocument(); + assert.equal(obj.authentication[0].id, vm.id); + assert.equal(obj.assertionMethod[0].id, vm.id); + assert.equal(obj.keyAgreement[0].id, vm.id); + assert.equal(obj.capabilityInvocation[0].id, vm.id); + assert.equal(obj.capabilityDelegation[0].id, vm.id); + }); + + it('compare returns false when a method comparison fails (different key)', function () { + const a = CommonDidDocument.from(fullDocument); + const changedKey = { + ...fullDocument, + verificationMethod: [{ ...vm, publicKeyBase58: 'differentKey' }] + }; + const b = CommonDidDocument.from(changedKey); + assert.isFalse(a.compare(b)); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document.test.mjs new file mode 100644 index 0000000000..f36b66649c --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did-document.test.mjs @@ -0,0 +1,139 @@ +import { assert } from 'chai'; + +import { CommonDidDocument } from '../../../../../dist/hedera-modules/vcjs/did/common-did-document.js'; + +describe('CommonDidDocument', function () { + const did = 'did:hedera:testnet:abc'; + const vm = { + id: did + '#did-root-key', + controller: did, + type: 'Ed25519VerificationKey2018', + publicKeyBase58: 'pubKey' + }; + const document = { + '@context': 'https://www.w3.org/ns/did/v1', + id: did, + verificationMethod: [vm], + authentication: [did + '#did-root-key'], + assertionMethod: [did + '#did-root-key'], + service: [{ id: did + '#svc', type: 'LinkedDomains', serviceEndpoint: 'https://x' }] + }; + + it('static contexts', function () { + assert.equal(CommonDidDocument.DID_DOCUMENT_CONTEXT, 'https://www.w3.org/ns/did/v1'); + assert.equal(CommonDidDocument.DID_DOCUMENT_TRANSMUTE_CONTEXT, 'https://ns.did.ai/transmute/v1'); + }); + + it('from object parses did', function () { + const doc = CommonDidDocument.from(document); + assert.equal(doc.getDid(), did); + }); + + it('from string parses', function () { + const doc = CommonDidDocument.from(JSON.stringify(document)); + assert.equal(doc.getDid(), did); + }); + + it('from invalid type throws', function () { + assert.throws(() => CommonDidDocument.from(123), 'Invalid document format'); + }); + + it('getDocument round-trips id and context', function () { + const obj = CommonDidDocument.from(document).getDocument(); + assert.equal(obj.id, did); + assert.equal(obj['@context'], 'https://www.w3.org/ns/did/v1'); + }); + + it('getDocument maps verification methods', function () { + const obj = CommonDidDocument.from(document).getDocument(); + assert.lengthOf(obj.verificationMethod, 1); + assert.equal(obj.verificationMethod[0].id, vm.id); + }); + + it('getDocument keeps authentication string links', function () { + const obj = CommonDidDocument.from(document).getDocument(); + assert.deepEqual(obj.authentication, [did + '#did-root-key']); + }); + + it('getDocument maps service', function () { + const obj = CommonDidDocument.from(document).getDocument(); + assert.lengthOf(obj.service, 1); + assert.equal(obj.service[0].serviceEndpoint, 'https://x'); + }); + + it('getVerificationMethods returns array', function () { + const doc = CommonDidDocument.from(document); + assert.lengthOf(doc.getVerificationMethods(), 1); + }); + + it('getMethodByType finds method', function () { + const doc = CommonDidDocument.from(document); + const m = doc.getMethodByType('Ed25519VerificationKey2018'); + assert.exists(m); + assert.equal(m.getId(), vm.id); + }); + + it('getMethodByType returns null when missing', function () { + const doc = CommonDidDocument.from(document); + assert.isNull(doc.getMethodByType('Unknown')); + }); + + it('getMethodByName finds method', function () { + const doc = CommonDidDocument.from(document); + const m = doc.getMethodByName(vm.id); + assert.exists(m); + }); + + it('getMethodByName returns null when missing', function () { + const doc = CommonDidDocument.from(document); + assert.isNull(doc.getMethodByName('nope')); + }); + + it('getPrivateKeys empty without private key', function () { + const doc = CommonDidDocument.from(document); + assert.deepEqual(doc.getPrivateKeys(), []); + }); + + it('setPrivateKey then getPrivateKeys', function () { + const doc = CommonDidDocument.from(document); + doc.setPrivateKey(vm.id, 'secret'); + const keys = doc.getPrivateKeys(); + assert.lengthOf(keys, 1); + assert.equal(keys[0].id, vm.id); + assert.equal(keys[0].key, 'secret'); + }); + + it('toCredentialHash is deterministic base58 string', function () { + const h1 = CommonDidDocument.from(document).toCredentialHash(); + const h2 = CommonDidDocument.from(document).toCredentialHash(); + assert.isString(h1); + assert.equal(h1, h2); + }); + + it('compare identical documents true', function () { + const a = CommonDidDocument.from(document); + const b = CommonDidDocument.from(document); + assert.isTrue(a.compare(b)); + }); + + it('compare with json string true', function () { + const a = CommonDidDocument.from(document); + assert.isTrue(a.compare(JSON.stringify(a.getDocument()))); + }); + + it('compare different id false', function () { + const a = CommonDidDocument.from(document); + const b = CommonDidDocument.from({ ...document, id: 'did:hedera:testnet:other' }); + assert.isFalse(a.compare(b)); + }); + + it('compare invalid input false', function () { + const a = CommonDidDocument.from(document); + assert.isFalse(a.compare('not json')); + }); + + it('getPrivateDocument excludes nothing when no keys', function () { + const obj = CommonDidDocument.from(document).getPrivateDocument(); + assert.equal(obj.id, did); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/common-did.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did.test.mjs new file mode 100644 index 0000000000..14f70631da --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/common-did.test.mjs @@ -0,0 +1,61 @@ +import { assert } from 'chai'; + +import { CommonDid } from '../../../../../dist/hedera-modules/vcjs/did/common-did.js'; + +describe('CommonDid', function () { + it('static separators', function () { + assert.equal(CommonDid.DID_PREFIX, 'did'); + assert.equal(CommonDid.DID_METHOD_SEPARATOR, ':'); + }); + + it('parse returns components', function () { + const c = CommonDid.parse('did:hedera:testnet:abc'); + assert.equal(c.prefix, 'did'); + assert.equal(c.method, 'hedera'); + assert.equal(c.identifier, 'testnet:abc'); + }); + + it('parse simple identifier', function () { + const c = CommonDid.parse('did:example:123'); + assert.equal(c.identifier, '123'); + }); + + it('parse throws on null', function () { + assert.throws(() => CommonDid.parse(null), 'DID string cannot be null'); + }); + + it('parse throws on non-string', function () { + assert.throws(() => CommonDid.parse(123), 'DID string cannot be null'); + }); + + it('parse throws on too few parts', function () { + assert.throws(() => CommonDid.parse('did:example'), 'invalid did format'); + }); + + it('parse throws on wrong prefix', function () { + assert.throws(() => CommonDid.parse('xid:example:123'), 'invalid did format'); + }); + + it('from builds CommonDid with getters', function () { + const did = CommonDid.from('did:hedera:testnet:abc'); + assert.equal(did.getMethod(), 'hedera'); + assert.equal(did.getIdentifier(), 'testnet:abc'); + assert.equal(did.toString(), 'did:hedera:testnet:abc'); + }); + + it('implement true for did prefix', function () { + assert.isTrue(CommonDid.implement('did:example:1')); + }); + + it('implement false for wrong prefix', function () { + assert.isFalse(CommonDid.implement('foo:example:1')); + }); + + it('implement false for null', function () { + assert.isFalse(CommonDid.implement(null)); + }); + + it('implement false for non-string', function () { + assert.isFalse(CommonDid.implement(5)); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/did-document-fixtures.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/did-document-fixtures.test.mjs new file mode 100644 index 0000000000..27f9edec97 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/did-document-fixtures.test.mjs @@ -0,0 +1,181 @@ +import { assert } from 'chai'; + +import { CommonDidDocument } from '../../../../../dist/hedera-modules/vcjs/did/common-did-document.js'; +import { HederaDidDocument } from '../../../../../dist/hedera-modules/vcjs/did/hedera-did-document.js'; +import { VerificationMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/verification-method.js'; +import { HederaDid } from '../../../../../dist/hedera-modules/vcjs/did/hedera-did.js'; + +import { did_document } from '../../../dump/did_document.mjs'; + +function docTree(index) { + return JSON.parse(JSON.stringify(did_document[index].document)); +} + +describe('CommonDidDocument with DID fixtures', function () { + for (let i = 0; i < did_document.length; i++) { + const raw = did_document[i].document; + + it(`fixture ${i}: from(object) preserves the id`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.equal(doc.getDid(), raw.id); + }); + + it(`fixture ${i}: from(string) preserves the id`, function () { + const doc = CommonDidDocument.from(JSON.stringify(docTree(i))); + assert.equal(doc.getDid(), raw.id); + }); + + it(`fixture ${i}: getDocument id matches`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.equal(doc.getDocument().id, raw.id); + }); + + it(`fixture ${i}: getDocument @context matches`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.deepEqual(doc.getDocument()['@context'], raw['@context']); + }); + + it(`fixture ${i}: exposes one verification method`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const methods = doc.getVerificationMethods(); + assert.lengthOf(methods, raw.verificationMethod.length); + assert.instanceOf(methods[0], VerificationMethod); + }); + + it(`fixture ${i}: verification method round-trips fields`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const vmObj = doc.getDocument().verificationMethod[0]; + assert.deepEqual(vmObj, raw.verificationMethod[0]); + }); + + it(`fixture ${i}: getMethodByName finds the root key`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const id = raw.verificationMethod[0].id; + assert.equal(doc.getMethodByName(id).getId(), id); + }); + + it(`fixture ${i}: getMethodByName returns null for unknown id`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.isNull(doc.getMethodByName('did:hedera:testnet:none#x')); + }); + + it(`fixture ${i}: getMethodByType finds Ed25519 method`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const t = raw.verificationMethod[0].type; + assert.equal(doc.getMethodByType(t).getType(), t); + }); + + it(`fixture ${i}: getMethodByType returns null for unknown type`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.isNull(doc.getMethodByType('NoSuchKeyType')); + }); + + it(`fixture ${i}: assertionMethod link is preserved as string`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const am = doc.getDocument().assertionMethod; + assert.deepEqual(am, raw.assertionMethod); + }); + + it(`fixture ${i}: getPrivateKeys is empty (no private material)`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.lengthOf(doc.getPrivateKeys(), 0); + }); + + it(`fixture ${i}: toCredentialHash is deterministic base58`, function () { + const a = CommonDidDocument.from(docTree(i)).toCredentialHash(); + const b = CommonDidDocument.from(docTree(i)).toCredentialHash(); + assert.isString(a); + assert.equal(a, b); + assert.match(a, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + + it(`fixture ${i}: compare with itself returns true`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.isTrue(doc.compare(doc)); + }); + + it(`fixture ${i}: compare with its own JSON returns true`, function () { + const doc = CommonDidDocument.from(docTree(i)); + assert.isTrue(doc.compare(JSON.stringify(doc.getDocument()))); + }); + + it(`fixture ${i}: compare with a different fixture returns false`, function () { + const a = CommonDidDocument.from(docTree(i)); + const other = CommonDidDocument.from(docTree((i + 1) % did_document.length)); + assert.isFalse(a.compare(other)); + }); + + it(`fixture ${i}: setPrivateKey then getPrivateKeys exposes it`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const id = raw.verificationMethod[0].id; + doc.setPrivateKey(id, 'fakePrivateKey58'); + const keys = doc.getPrivateKeys(); + assert.lengthOf(keys, 1); + assert.equal(keys[0].id, id); + assert.equal(keys[0].key, 'fakePrivateKey58'); + }); + + it(`fixture ${i}: getPrivateDocument includes private key after set`, function () { + const doc = CommonDidDocument.from(docTree(i)); + const id = raw.verificationMethod[0].id; + doc.setPrivateKey(id, 'fakePrivateKey58'); + const pub = doc.getDocument(); + assert.notProperty(pub.verificationMethod[0], 'privateKeyBase58'); + const priv = doc.getPrivateDocument(); + assert.equal(priv.verificationMethod[0].privateKeyBase58, 'fakePrivateKey58'); + }); + } + + it('from throws on invalid (number) document', function () { + assert.throws(() => CommonDidDocument.from(42), /Invalid document format/); + }); + + it('compare returns false for malformed JSON', function () { + const doc = CommonDidDocument.from(docTree(0)); + assert.isFalse(doc.compare('{not-json')); + }); + + it('compare returns false when ids differ', function () { + const doc = CommonDidDocument.from(docTree(0)); + const other = docTree(0); + other.id = 'did:hedera:testnet:different_0.0.1'; + assert.isFalse(doc.compare(other)); + }); + + it('static DID context constants', function () { + assert.equal(CommonDidDocument.DID_DOCUMENT_CONTEXT, 'https://www.w3.org/ns/did/v1'); + assert.equal(CommonDidDocument.DID_DOCUMENT_TRANSMUTE_CONTEXT, 'https://ns.did.ai/transmute/v1'); + }); +}); + +describe('HederaDidDocument with DID fixtures', function () { + for (let i = 0; i < did_document.length; i++) { + const raw = did_document[i].document; + + it(`fixture ${i}: from(string) parses correctly`, function () { + const doc = HederaDidDocument.from(JSON.stringify(docTree(i))); + assert.equal(doc.getDid(), raw.id); + }); + + it(`fixture ${i}: fromJsonTree equals toJsonTree round-trip on id`, function () { + const doc = HederaDidDocument.fromJsonTree(docTree(i)); + assert.equal(doc.toJsonTree().id, raw.id); + }); + + it(`fixture ${i}: did resolves to a HederaDid`, function () { + const doc = HederaDidDocument.from(docTree(i)); + const did = HederaDid.from(doc.getDid()); + assert.equal(did.getMethod(), 'hedera'); + }); + + it(`fixture ${i}: setDidTopicId accepts a string`, function () { + const doc = HederaDidDocument.from(docTree(i)); + doc.setDidTopicId('0.0.999'); + assert.equal(doc.getDidTopicId().toString(), '0.0.999'); + }); + } + + it('from throws on invalid input', function () { + assert.throws(() => HederaDidDocument.from(123), /Invalid document format/); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/did-extras-coverage.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/did-extras-coverage.test.mjs new file mode 100644 index 0000000000..fceb4eaf36 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/did-extras-coverage.test.mjs @@ -0,0 +1,122 @@ +import { assert } from 'chai'; +import { PrivateKey, TopicId } from '@hiero-ledger/sdk'; + +import { HederaDid } from '../../../../../dist/hedera-modules/vcjs/did/hedera-did.js'; +import { VerificationMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/verification-method.js'; + +describe('HederaDid coverage', function () { + const key = PrivateKey.generate(); + + it('generate accepts a string topicId', async function () { + const did = await HederaDid.generate('testnet', key, '0.0.123'); + assert.equal(did.getDidTopicId().toString(), '0.0.123'); + assert.equal(did.getNetwork(), 'testnet'); + }); + + it('generate accepts a TopicId object', async function () { + const did = await HederaDid.generate('testnet', key, TopicId.fromString('0.0.456')); + assert.equal(did.getDidTopicId().toString(), '0.0.456'); + }); + + it('generate accepts a string private key', async function () { + const did = await HederaDid.generate('mainnet', key.toString(), null); + assert.isNull(did.getDidTopicId()); + assert.equal(did.getNetwork(), 'mainnet'); + }); + + it('from parses a V1 (parameter) DID with tid=', function () { + const v1 = 'did:hedera:testnet:abcdefKEY;hedera:testnet:tid=0.0.789'; + const did = HederaDid.from(v1); + assert.equal(did.getMethod(), 'hedera'); + assert.equal(did.getNetwork(), 'testnet'); + assert.equal(did.getDidTopicId().toString(), '0.0.789'); + }); + + it('parseV1 throws on an invalid prefix', function () { + assert.throws( + () => HederaDid.parse('xid:hedera:testnet:abc;hedera:testnet:tid=0.0.1'), + /invalid prefix/ + ); + }); + + it('parseV1 throws on an invalid method', function () { + assert.throws( + () => HederaDid.parse('did:other:testnet:abc;hedera:testnet:tid=0.0.1'), + /invalid method name/ + ); + }); + + it('parse routes a leading-separator DID to parseV2', function () { + assert.throws(() => HederaDid.parse(';did:hedera:testnet:abc'), /invalid did format/); + }); +}); + +describe('VerificationMethod coverage', function () { + function jwkMethod() { + return VerificationMethod.from({ + id: 'did:x#k1', + controller: 'did:x', + type: 'JsonWebKey2020', + publicKeyJwk: { kty: 'OKP', x: 'pub' }, + privateKeyJwk: { kty: 'OKP', d: 'priv' } + }); + } + + function multibaseMethod() { + return VerificationMethod.from({ + id: 'did:x#k2', + controller: 'did:x', + type: 'Multikey', + publicKeyMultibase: 'zPub', + privateKeyMultibase: 'zPriv' + }); + } + + it('getPrivateKey returns the jwk private key', function () { + assert.deepEqual(jwkMethod().getPrivateKey(), { kty: 'OKP', d: 'priv' }); + }); + + it('getPrivateKey returns the multibase private key', function () { + assert.equal(multibaseMethod().getPrivateKey(), 'zPriv'); + }); + + it('toObject includes jwk public and private keys when requested', function () { + const obj = jwkMethod().toObject(true); + assert.deepEqual(obj.publicKeyJwk, { kty: 'OKP', x: 'pub' }); + assert.deepEqual(obj.privateKeyJwk, { kty: 'OKP', d: 'priv' }); + }); + + it('toObject includes multibase public and private keys when requested', function () { + const obj = multibaseMethod().toObject(true); + assert.equal(obj.publicKeyMultibase, 'zPub'); + assert.equal(obj.privateKeyMultibase, 'zPriv'); + }); + + it('setPrivateKey assigns to the jwk slot when public jwk is present', function () { + const m = VerificationMethod.from({ + id: 'did:x#k1', controller: 'did:x', type: 'JsonWebKey2020', + publicKeyJwk: { kty: 'OKP', x: 'pub' } + }); + m.setPrivateKey({ d: 'newpriv' }); + assert.deepEqual(m.getPrivateKey(), { d: 'newpriv' }); + }); + + it('setPrivateKey assigns to the multibase slot when public multibase is present', function () { + const m = VerificationMethod.from({ + id: 'did:x#k2', controller: 'did:x', type: 'Multikey', + publicKeyMultibase: 'zPub' + }); + m.setPrivateKey('zNewPriv'); + assert.equal(m.getPrivateKey(), 'zNewPriv'); + }); + + it('from loads a multibase private key', function () { + const m = multibaseMethod(); + assert.equal(m.toObject(true).privateKeyMultibase, 'zPriv'); + }); + + it('compare returns false on a malformed input (catch path)', function () { + const m = jwkMethod(); + assert.isFalse(m.compare(null)); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/did-types-properties.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/did-types-properties.test.mjs new file mode 100644 index 0000000000..6d98d994fc --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/did-types-properties.test.mjs @@ -0,0 +1,77 @@ +import { assert } from 'chai'; + +import { DidDocumentProperties } from '../../../../../dist/hedera-modules/vcjs/did/types/did-document-properties.js'; +import { VerificationMethodProperties } from '../../../../../dist/hedera-modules/vcjs/did/types/verification-method-properties.js'; +import { ServiceProperties } from '../../../../../dist/hedera-modules/vcjs/did/types/service-properties.js'; + +describe('DidDocumentProperties enum', function () { + const expected = { + CONTEXT: '@context', + ID: 'id', + ALSO_KNOWN_AS: 'alsoKnownAs', + CONTROLLER: 'controller', + VERIFICATION_METHOD: 'verificationMethod', + AUTHENTICATION: 'authentication', + ASSERTION_METHOD: 'assertionMethod', + KEY_AGREEMENT: 'keyAgreement', + CAPABILITY_INVOCATION: 'capabilityInvocation', + CAPABILITY_DELEGATION: 'capabilityDelegation', + SERVICE: 'service', + }; + + for (const [key, value] of Object.entries(expected)) { + it(`${key} maps to "${value}"`, function () { + assert.equal(DidDocumentProperties[key], value); + }); + } + + it('exposes exactly the expected number of members', function () { + assert.equal(Object.keys(expected).length, 11); + for (const key of Object.keys(expected)) { + assert.property(DidDocumentProperties, key); + } + }); +}); + +describe('VerificationMethodProperties enum', function () { + const expected = { + ID: 'id', + CONTROLLER: 'controller', + TYPE: 'type', + PUBLIC_KEY_JWK: 'publicKeyJwk', + PUBLIC_KEY_MULTIBASE: 'publicKeyMultibase', + PUBLIC_KEY_BASE58: 'publicKeyBase58', + PRIVATE_KEY_JWK: 'privateKeyJwk', + PRIVATE_KEY_MULTIBASE: 'privateKeyMultibase', + PRIVATE_KEY_BASE58: 'privateKeyBase58', + }; + + for (const [key, value] of Object.entries(expected)) { + it(`${key} maps to "${value}"`, function () { + assert.equal(VerificationMethodProperties[key], value); + }); + } + + it('public and private key constants are distinct', function () { + assert.notEqual(VerificationMethodProperties.PUBLIC_KEY_BASE58, VerificationMethodProperties.PRIVATE_KEY_BASE58); + assert.notEqual(VerificationMethodProperties.PUBLIC_KEY_JWK, VerificationMethodProperties.PRIVATE_KEY_JWK); + }); +}); + +describe('ServiceProperties enum', function () { + it('ID maps to "id"', function () { + assert.equal(ServiceProperties.ID, 'id'); + }); + + it('TYPE maps to "type"', function () { + assert.equal(ServiceProperties.TYPE, 'type'); + }); + + it('SERVICE_ENDPOINT maps to "serviceEndpoint"', function () { + assert.equal(ServiceProperties.SERVICE_ENDPOINT, 'serviceEndpoint'); + }); + + it('has exactly three members', function () { + assert.lengthOf(Object.keys(ServiceProperties), 3); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/document-context.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/document-context.test.mjs new file mode 100644 index 0000000000..76839f1b3b --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/document-context.test.mjs @@ -0,0 +1,72 @@ +import { assert } from 'chai'; + +import { DocumentContext } from '../../../../../dist/hedera-modules/vcjs/did/components/document-context.js'; + +describe('DocumentContext', function () { + it('new context is empty', function () { + const ctx = new DocumentContext(); + assert.isTrue(ctx.isEmpty()); + assert.isNull(ctx.toObject()); + }); + + it('single context returns string', function () { + const ctx = new DocumentContext(); + ctx.add('https://example.com/v1'); + assert.isFalse(ctx.isEmpty()); + assert.equal(ctx.toObject(), 'https://example.com/v1'); + }); + + it('multiple contexts return array copy', function () { + const ctx = new DocumentContext(); + ctx.add('a'); + ctx.add('b'); + const obj = ctx.toObject(); + assert.deepEqual(obj, ['a', 'b']); + obj.push('c'); + assert.deepEqual(ctx.toObject(), ['a', 'b']); + }); + + it('add ignores duplicates', function () { + const ctx = new DocumentContext(); + ctx.add('a'); + ctx.add('a'); + assert.equal(ctx.toObject(), 'a'); + }); + + it('from null returns empty context', function () { + const ctx = DocumentContext.from(null); + assert.isTrue(ctx.isEmpty()); + }); + + it('from undefined returns empty context', function () { + const ctx = DocumentContext.from(undefined); + assert.isTrue(ctx.isEmpty()); + }); + + it('from string returns single context', function () { + const ctx = DocumentContext.from('https://x'); + assert.equal(ctx.toObject(), 'https://x'); + }); + + it('from array returns multiple contexts', function () { + const ctx = DocumentContext.from(['a', 'b']); + assert.deepEqual(ctx.toObject(), ['a', 'b']); + }); + + it('from array dedupes', function () { + const ctx = DocumentContext.from(['a', 'a', 'b']); + assert.deepEqual(ctx.toObject(), ['a', 'b']); + }); + + it('from array with non-string throws', function () { + assert.throws(() => DocumentContext.from(['a', 5]), 'Invalid document context'); + }); + + it('from object throws', function () { + assert.throws(() => DocumentContext.from({}), 'Invalid document context'); + }); + + it('from number throws', function () { + assert.throws(() => DocumentContext.from(42), 'Invalid document context'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/document-service.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/document-service.test.mjs new file mode 100644 index 0000000000..72a24066ab --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/document-service.test.mjs @@ -0,0 +1,37 @@ +import { assert } from 'chai'; + +import { DocumentService } from '../../../../../dist/hedera-modules/vcjs/did/components/document-service.js'; + +describe('DocumentService', function () { + const svc = { + id: 'did:hedera:testnet:abc#service-1', + type: 'LinkedDomains', + serviceEndpoint: 'https://example.com' + }; + + it('from builds service', function () { + const s = DocumentService.from(svc); + assert.instanceOf(s, DocumentService); + }); + + it('from + toObject round-trip', function () { + const s = DocumentService.from(svc); + assert.deepEqual(s.toObject(), svc); + }); + + it('fromArray builds list', function () { + const list = DocumentService.fromArray([svc, { ...svc, id: 'x#service-2' }]); + assert.lengthOf(list, 2); + assert.instanceOf(list[0], DocumentService); + assert.equal(list[1].toObject().id, 'x#service-2'); + }); + + it('fromArray empty', function () { + assert.deepEqual(DocumentService.fromArray([]), []); + }); + + it('toObject preserves serviceEndpoint object', function () { + const s = DocumentService.from({ id: 'i', type: 't', serviceEndpoint: { origins: ['a'] } }); + assert.deepEqual(s.toObject().serviceEndpoint, { origins: ['a'] }); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-did.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-did.test.mjs new file mode 100644 index 0000000000..0fbc7fc28b --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-did.test.mjs @@ -0,0 +1,77 @@ +import { assert } from 'chai'; + +import { HederaDid } from '../../../../../dist/hedera-modules/vcjs/did/hedera-did.js'; +import { CommonDid } from '../../../../../dist/hedera-modules/vcjs/did/common-did.js'; + +describe('HederaDid', function () { + const valid = 'did:hedera:testnet:abcdefKEY_0.0.123'; + + it('static constants', function () { + assert.equal(HederaDid.DID_TOPIC_SEPARATOR, '_'); + assert.equal(HederaDid.HEDERA_HCS, 'hedera'); + assert.equal(HederaDid.DID_TOPIC_ID, 'tid'); + }); + + it('extends CommonDid', function () { + const d = HederaDid.from(valid); + assert.instanceOf(d, CommonDid); + }); + + it('parseV2 returns components', function () { + const c = HederaDid.parseV2(valid); + assert.equal(c.prefix, 'did'); + assert.equal(c.method, 'hedera'); + assert.equal(c.network, 'testnet'); + assert.equal(c.key, 'abcdefKEY'); + assert.equal(c.topicId, '0.0.123'); + assert.equal(c.identifier, 'abcdefKEY'); + }); + + it('parseV2 throws without topic separator', function () { + assert.throws(() => HederaDid.parseV2('did:hedera:testnet:abc'), 'invalid did format'); + }); + + it('parseV2 throws on wrong part count', function () { + assert.throws(() => HederaDid.parseV2('did:hedera:abc_0.0.1'), 'invalid did format'); + }); + + it('parseV2 throws on invalid prefix', function () { + assert.throws(() => HederaDid.parseV2('xid:hedera:testnet:abc_0.0.1'), 'invalid prefix'); + }); + + it('parseV2 throws on invalid method', function () { + assert.throws(() => HederaDid.parseV2('did:other:testnet:abc_0.0.1'), 'invalid method name'); + }); + + it('parse throws on null', function () { + assert.throws(() => HederaDid.parse(null), 'DID string cannot be null'); + }); + + it('from builds with getters', function () { + const d = HederaDid.from(valid); + assert.equal(d.getMethod(), 'hedera'); + assert.equal(d.getNetwork(), 'testnet'); + assert.equal(d.toString(), valid); + assert.isString(d.getIdentifier()); + }); + + it('implement true for hedera did', function () { + assert.isTrue(HederaDid.implement(valid)); + }); + + it('implement false for non-hedera method', function () { + assert.isFalse(HederaDid.implement('did:example:abc')); + }); + + it('implement false for non-string', function () { + assert.isFalse(HederaDid.implement(null)); + }); + + it('getTopicId extracts last segment', function () { + assert.equal(HederaDid.getTopicId(valid), '0.0.123'); + }); + + it('getTopicId returns whole string when no separator', function () { + assert.equal(HederaDid.getTopicId('noseparator'), 'noseparator'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-ed25519-generate.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-ed25519-generate.test.mjs new file mode 100644 index 0000000000..eaf82eaf19 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-ed25519-generate.test.mjs @@ -0,0 +1,143 @@ +import { assert } from 'chai'; +import { PrivateKey } from '@hiero-ledger/sdk'; + +import { HederaEd25519Method } from '../../../../../dist/hedera-modules/vcjs/did/components/hedera-ed25519-method.js'; +import { VerificationMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/verification-method.js'; + +const DID = 'did:hedera:testnet:8x9abcDEF_0.0.1'; + +describe('HederaEd25519Method.generateKeyPair (offline, deterministic)', function () { + let key; + before(function () { + key = PrivateKey.generate(); + }); + + it('produces an id with the root key suffix', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key); + assert.equal(kp.id, DID + '#did-root-key'); + }); + + it('sets the controller to the DID', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key); + assert.equal(kp.controller, DID); + }); + + it('sets the Ed25519 key type', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key); + assert.equal(kp.type, 'Ed25519VerificationKey2018'); + }); + + it('encodes a base58 public key', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key); + assert.isString(kp.publicKey); + assert.match(kp.publicKey, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + + it('encodes a base58 private key', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key); + assert.isString(kp.privateKey); + assert.match(kp.privateKey, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + + it('is deterministic for the same key', async function () { + const a = await HederaEd25519Method.generateKeyPair(DID, key); + const b = await HederaEd25519Method.generateKeyPair(DID, key); + assert.equal(a.publicKey, b.publicKey); + assert.equal(a.privateKey, b.privateKey); + }); + + it('accepts a string private key', async function () { + const kp = await HederaEd25519Method.generateKeyPair(DID, key.toString()); + assert.equal(kp.controller, DID); + assert.isString(kp.publicKey); + }); + + it('produces matching public key for string and object key forms', async function () { + const fromObject = await HederaEd25519Method.generateKeyPair(DID, key); + const fromString = await HederaEd25519Method.generateKeyPair(DID, key.toString()); + assert.equal(fromObject.publicKey, fromString.publicKey); + }); + + it('rejects a missing did', async function () { + let err; + try { + await HederaEd25519Method.generateKeyPair(null, key); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID cannot be'); + }); + + it('rejects a missing key', async function () { + let err; + try { + await HederaEd25519Method.generateKeyPair(DID, null); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID root key cannot be'); + }); +}); + +describe('HederaEd25519Method.generate (offline, deterministic)', function () { + let key; + before(function () { + key = PrivateKey.generate(); + }); + + it('returns a HederaEd25519Method instance', async function () { + const m = await HederaEd25519Method.generate(DID, key); + assert.instanceOf(m, HederaEd25519Method); + assert.instanceOf(m, VerificationMethod); + }); + + it('sets the id to the default root key id', async function () { + const m = await HederaEd25519Method.generate(DID, key); + assert.equal(m.getId(), HederaEd25519Method.defaultId(DID)); + }); + + it('sets the controller and type', async function () { + const m = await HederaEd25519Method.generate(DID, key); + assert.equal(m.getController(), DID); + assert.equal(m.getType(), 'Ed25519VerificationKey2018'); + }); + + it('names the method with the root key name', async function () { + const m = await HederaEd25519Method.generate(DID, key); + assert.equal(m.getName(), '#did-root-key'); + }); + + it('exposes a private key', async function () { + const m = await HederaEd25519Method.generate(DID, key); + assert.isTrue(m.hasPrivateKey()); + assert.isString(m.getPrivateKey()); + }); + + it('serializes a public key in toObject', async function () { + const m = await HederaEd25519Method.generate(DID, key); + const obj = m.toObject(); + assert.equal(obj.id, HederaEd25519Method.defaultId(DID)); + assert.isString(obj.publicKeyBase58); + assert.isUndefined(obj.privateKeyBase58); + }); + + it('includes private key in toObject when requested', async function () { + const m = await HederaEd25519Method.generate(DID, key); + const obj = m.toObject(true); + assert.isString(obj.privateKeyBase58); + }); + + it('accepts a string private key', async function () { + const m = await HederaEd25519Method.generate(DID, key.toString()); + assert.equal(m.getController(), DID); + assert.isString(m.getPrivateKey()); + }); + + it('compares equal to a freshly generated method from the same key', async function () { + const a = await HederaEd25519Method.generate(DID, key); + const b = await HederaEd25519Method.generate(DID, key); + assert.isTrue(a.compare(b)); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-methods.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-methods.test.mjs new file mode 100644 index 0000000000..cd44283f30 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/hedera-methods.test.mjs @@ -0,0 +1,95 @@ +import { assert } from 'chai'; + +import { HederaEd25519Method } from '../../../../../dist/hedera-modules/vcjs/did/components/hedera-ed25519-method.js'; +import { HederaBBSMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/hedera-bbs-method.js'; +import { VerificationMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/verification-method.js'; + +describe('HederaEd25519Method', function () { + it('static constants', function () { + assert.equal(HederaEd25519Method.DID_ROOT_KEY_NAME, '#did-root-key'); + assert.equal(HederaEd25519Method.DID_ROOT_KEY_TYPE, 'Ed25519VerificationKey2018'); + assert.equal(HederaEd25519Method.TYPE, 'Ed25519VerificationKey2018'); + }); + + it('extends VerificationMethod', function () { + const m = new HederaEd25519Method(); + assert.instanceOf(m, VerificationMethod); + }); + + it('defaultId appends root key name', function () { + assert.equal(HederaEd25519Method.defaultId('did:hedera:testnet:abc'), 'did:hedera:testnet:abc#did-root-key'); + }); + + it('private key getter/setter use base58', function () { + const m = new HederaEd25519Method(); + m.setPrivateKey('secret58'); + assert.equal(m.getPrivateKey(), 'secret58'); + }); + + it('generateKeyPair rejects missing did', async function () { + let err; + try { + await HederaEd25519Method.generateKeyPair(null, 'key'); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID cannot be'); + }); + + it('generateKeyPair rejects missing key', async function () { + let err; + try { + await HederaEd25519Method.generateKeyPair('did:x', null); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID root key cannot be'); + }); +}); + +describe('HederaBBSMethod', function () { + it('static constants', function () { + assert.equal(HederaBBSMethod.DID_ROOT_KEY_NAME, '#did-root-key-bbs'); + assert.equal(HederaBBSMethod.DID_ROOT_KEY_TYPE, 'Bls12381G2Key2020'); + assert.equal(HederaBBSMethod.TYPE, 'Bls12381G2Key2020'); + }); + + it('extends VerificationMethod', function () { + const m = new HederaBBSMethod(); + assert.instanceOf(m, VerificationMethod); + }); + + it('defaultId appends bbs root key name', function () { + assert.equal(HederaBBSMethod.defaultId('did:hedera:testnet:abc'), 'did:hedera:testnet:abc#did-root-key-bbs'); + }); + + it('private key getter/setter use base58', function () { + const m = new HederaBBSMethod(); + m.setPrivateKey('bbsSecret'); + assert.equal(m.getPrivateKey(), 'bbsSecret'); + }); + + it('generateKeyPair rejects missing did', async function () { + let err; + try { + await HederaBBSMethod.generateKeyPair(undefined, 'key'); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID cannot be'); + }); + + it('generateKeyPair rejects missing key', async function () { + let err; + try { + await HederaBBSMethod.generateKeyPair('did:x', undefined); + } catch (e) { + err = e; + } + assert.exists(err); + assert.include(err.message, 'DID root key cannot be'); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/did/verification-method.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/did/verification-method.test.mjs new file mode 100644 index 0000000000..780464368f --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/did/verification-method.test.mjs @@ -0,0 +1,118 @@ +import { assert } from 'chai'; + +import { VerificationMethod } from '../../../../../dist/hedera-modules/vcjs/did/components/verification-method.js'; + +describe('VerificationMethod', function () { + const base = { + id: 'did:hedera:testnet:abc#key-1', + controller: 'did:hedera:testnet:abc', + type: 'Ed25519VerificationKey2018', + publicKeyBase58: 'pubKeyBase58' + }; + + it('from builds method and derives name', function () { + const vm = VerificationMethod.from(base); + assert.equal(vm.getId(), base.id); + assert.equal(vm.getController(), base.controller); + assert.equal(vm.getType(), base.type); + assert.equal(vm.getName(), '#key-1'); + assert.equal(vm.getMethod(), '#key-1'); + }); + + it('from throws on missing id', function () { + assert.throws(() => VerificationMethod.from({ controller: 'c', type: 't' }), 'Invalid method format'); + }); + + it('from throws on missing controller', function () { + assert.throws(() => VerificationMethod.from({ id: 'i', type: 't' }), 'Invalid method format'); + }); + + it('from throws on missing type', function () { + assert.throws(() => VerificationMethod.from({ id: 'i', controller: 'c' }), 'Invalid method format'); + }); + + it('toObject contains required fields and public key', function () { + const vm = VerificationMethod.from(base); + const obj = vm.toObject(); + assert.equal(obj.id, base.id); + assert.equal(obj.controller, base.controller); + assert.equal(obj.type, base.type); + assert.equal(obj.publicKeyBase58, base.publicKeyBase58); + }); + + it('toObject omits private key by default', function () { + const vm = VerificationMethod.from({ ...base, privateKeyBase58: 'secret' }); + const obj = vm.toObject(); + assert.isUndefined(obj.privateKeyBase58); + }); + + it('toObject includes private key when requested', function () { + const vm = VerificationMethod.from({ ...base, privateKeyBase58: 'secret' }); + const obj = vm.toObject(true); + assert.equal(obj.privateKeyBase58, 'secret'); + }); + + it('hasPrivateKey reflects presence', function () { + const vm1 = VerificationMethod.from(base); + assert.isFalse(vm1.hasPrivateKey()); + const vm2 = VerificationMethod.from({ ...base, privateKeyBase58: 'secret' }); + assert.isTrue(vm2.hasPrivateKey()); + }); + + it('getPrivateKey returns base58 key', function () { + const vm = VerificationMethod.from({ ...base, privateKeyBase58: 'secret' }); + assert.equal(vm.getPrivateKey(), 'secret'); + }); + + it('getPrivateKey returns jwk key first', function () { + const vm = VerificationMethod.from({ ...base, privateKeyJwk: { k: 1 } }); + assert.deepEqual(vm.getPrivateKey(), { k: 1 }); + }); + + it('setPrivateKey sets matching key kind', function () { + const vm = VerificationMethod.from(base); + vm.setPrivateKey('newSecret'); + assert.equal(vm.getPrivateKey(), 'newSecret'); + }); + + it('compare equal methods returns true', function () { + const a = VerificationMethod.from(base); + const b = VerificationMethod.from(base); + assert.isTrue(a.compare(b)); + }); + + it('compare different methods returns false', function () { + const a = VerificationMethod.from(base); + const b = VerificationMethod.from({ ...base, publicKeyBase58: 'other' }); + assert.isFalse(a.compare(b)); + }); + + it('compare with plain object', function () { + const a = VerificationMethod.from(base); + assert.isTrue(a.compare(a.toObject())); + }); + + it('fromArray converts objects', function () { + const arr = VerificationMethod.fromArray([base]); + assert.lengthOf(arr, 1); + assert.instanceOf(arr[0], VerificationMethod); + }); + + it('fromArray keeps string links when allowed', function () { + const arr = VerificationMethod.fromArray([base, 'did:hedera:testnet:abc#key-1'], true); + assert.lengthOf(arr, 2); + assert.equal(arr[1], 'did:hedera:testnet:abc#key-1'); + }); + + it('fromArray drops string links when not allowed', function () { + const arr = VerificationMethod.fromArray([base, 'link'], false); + assert.lengthOf(arr, 1); + }); + + it('publicKeyJwk and multibase round-trip', function () { + const vm = VerificationMethod.from({ ...base, publicKeyBase58: undefined, publicKeyMultibase: 'zMulti', publicKeyJwk: { kty: 'OKP' } }); + const obj = vm.toObject(); + assert.equal(obj.publicKeyMultibase, 'zMulti'); + assert.deepEqual(obj.publicKeyJwk, { kty: 'OKP' }); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vc-document-extra.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vc-document-extra.test.mjs new file mode 100644 index 0000000000..e5ad1a98e1 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vc-document-extra.test.mjs @@ -0,0 +1,250 @@ +import { assert } from 'chai'; + +import { VcDocument } from '../../../../dist/hedera-modules/vcjs/vc-document.js'; +import { VcSubject } from '../../../../dist/hedera-modules/vcjs/vc-subject.js'; +import { Issuer } from '../../../../dist/hedera-modules/vcjs/issuer.js'; + +describe('VcDocument extra branches', function () { + it('default constructor uses Ed25519 context', function () { + const vc = new VcDocument(); + assert.deepEqual(vc.getContext(), [VcDocument.FIRST_CONTEXT_ENTRY]); + assert.equal(vc.getSignatureType(), 'Ed25519Signature2018'); + }); + + it('constructor with boolean true uses BBS context', function () { + const vc = new VcDocument(true); + assert.deepEqual(vc.getContext(), [ + VcDocument.FIRST_CONTEXT_ENTRY, + VcDocument.BBS_SIGNATURE_CONTEXT + ]); + assert.equal(vc.getSignatureType(), 'BbsBlsSignature2020'); + }); + + it('constructor with BBS signature-type string uses BBS context', function () { + const vc = new VcDocument('BbsBlsSignature2020'); + assert.equal(vc.getSignatureType(), 'BbsBlsSignature2020'); + }); + + it('constructor with unrelated string falls back to Ed25519', function () { + const vc = new VcDocument('something-else'); + assert.equal(vc.getSignatureType(), 'Ed25519Signature2018'); + }); + + it('setId converts bare uuid to urn form', function () { + const vc = new VcDocument(); + vc.setId('abc-123'); + assert.equal(vc.getId(), 'urn:uuid:abc-123'); + }); + + it('setId keeps already-prefixed ids unchanged', function () { + const vc = new VcDocument(); + vc.setId('urn:uuid:already'); + assert.equal(vc.getId(), 'urn:uuid:already'); + }); + + it('setId with falsy value stores as-is', function () { + const vc = new VcDocument(); + vc.setId(''); + assert.equal(vc.getId(), ''); + }); + + it('getIssuerDid returns null when no issuer set', function () { + const vc = new VcDocument(); + assert.isNull(vc.getIssuerDid()); + }); + + it('setIssuer with string builds an Issuer', function () { + const vc = new VcDocument(); + vc.setIssuer('did:hedera:1'); + assert.instanceOf(vc.getIssuer(), Issuer); + assert.equal(vc.getIssuerDid(), 'did:hedera:1'); + }); + + it('setIssuer with Issuer instance keeps reference', function () { + const vc = new VcDocument(); + const issuer = new Issuer('did:hedera:2'); + vc.setIssuer(issuer); + assert.strictEqual(vc.getIssuer(), issuer); + }); + + it('setIssuer with object exposing getDid builds issuer from did', function () { + const vc = new VcDocument(); + vc.setIssuer({ getDid: () => 'did:hedera:3' }); + assert.equal(vc.getIssuerDid(), 'did:hedera:3'); + }); + + it('getInitId returns null when unset and value when set', function () { + const vc = new VcDocument(); + assert.isNull(vc.getInitId()); + vc.setInitId('init-1'); + assert.equal(vc.getInitId(), 'init-1'); + }); + + it('addContext ignores duplicates and falsy values', function () { + const vc = new VcDocument(); + vc.addContext('x'); + vc.addContext('x'); + vc.addContext(''); + vc.addContext(null); + assert.deepEqual(vc.getContext(), [VcDocument.FIRST_CONTEXT_ENTRY, 'x']); + }); + + it('addContexts handles array and single string', function () { + const vc = new VcDocument(); + vc.addContexts(['a', 'b']); + vc.addContexts('c'); + assert.deepEqual(vc.getContext(), [VcDocument.FIRST_CONTEXT_ENTRY, 'a', 'b', 'c']); + }); + + it('addType ignores duplicates', function () { + const vc = new VcDocument(); + vc.addType('T'); + vc.addType('T'); + assert.deepEqual(vc.getType(), [VcDocument.VERIFIABLE_CREDENTIAL_TYPE, 'T']); + }); + + it('addEvidence accumulates evidences in toJsonTree', function () { + const vc = new VcDocument(); + vc.addEvidence({ a: 1 }); + vc.setIssuer('did:1'); + const tree = vc.toJsonTree(); + assert.deepEqual(tree.evidence, [{ a: 1 }]); + }); + + it('toJsonTree omits evidence when empty', function () { + const vc = new VcDocument(); + const tree = vc.toJsonTree(); + assert.notProperty(tree, 'evidence'); + }); + + it('getCredentialSubject returns undefined for empty subject', function () { + const vc = new VcDocument(); + assert.isUndefined(vc.getCredentialSubject()); + assert.isUndefined(vc.getSubjectType()); + assert.isUndefined(vc.getField('any')); + }); + + it('addCredentialSubject ignores falsy and increments length', function () { + const vc = new VcDocument(); + vc.addCredentialSubject(null); + assert.equal(vc.length, 0); + vc.addCredentialSubject(VcSubject.create({ type: 'A', value: 5 })); + assert.equal(vc.length, 1); + assert.equal(vc.getSubjectType(), 'A'); + assert.equal(vc.getField('value'), 5); + }); + + it('addCredentialSubjects ignores undefined input', function () { + const vc = new VcDocument(); + vc.addCredentialSubjects(undefined); + assert.equal(vc.length, 0); + }); + + it('getProof/setProof round trips', function () { + const vc = new VcDocument(); + assert.isUndefined(vc.getProof()); + vc.setProof({ p: 1 }); + assert.deepEqual(vc.getProof(), { p: 1 }); + }); + + it('proofFromJson extracts proof from a tree', function () { + const vc = new VcDocument(); + vc.proofFromJson({ proof: { sig: 'z' } }); + assert.deepEqual(vc.getProof(), { sig: 'z' }); + }); + + it('getTags/setTags round trips', function () { + const vc = new VcDocument(); + assert.isUndefined(vc.getTags()); + vc.setTags([{ messageId: '1' }]); + assert.deepEqual(vc.getTags(), [{ messageId: '1' }]); + }); + + it('addTags ignores empty/null input', function () { + const vc = new VcDocument(); + vc.addTags(null); + vc.addTags([]); + assert.isUndefined(vc.getTags()); + }); + + it('addTags only keeps inheritTags entries and dedupes by messageId', function () { + const vc = new VcDocument(); + vc.addTags([ + { messageId: 'a', inheritTags: true }, + { messageId: 'b', inheritTags: false }, + { messageId: 'a', inheritTags: true } + ]); + assert.deepEqual(vc.getTags(), [{ messageId: 'a', inheritTags: true }]); + }); + + it('toJsonTree includes tags when present', function () { + const vc = new VcDocument(); + vc.setTags([{ messageId: 'x' }]); + assert.deepEqual(vc.toJsonTree().tags, [{ messageId: 'x' }]); + }); + + it('static toCredentialHash returns null for falsy input', function () { + assert.isNull(VcDocument.toCredentialHash(null)); + }); + + it('static toCredentialHash hashes a single document', function () { + const vc = new VcDocument(); + vc.setIssuer('did:1'); + vc.addCredentialSubject(VcSubject.create({ type: 'A', v: 1 })); + const hash = VcDocument.toCredentialHash(vc); + assert.isString(hash); + assert.isAbove(hash.length, 0); + }); + + it('static toCredentialHash hashes an array of documents', function () { + const vc1 = new VcDocument(); + vc1.setIssuer('did:1'); + vc1.addCredentialSubject(VcSubject.create({ type: 'A', v: 1 })); + const vc2 = new VcDocument(); + vc2.setIssuer('did:2'); + vc2.addCredentialSubject(VcSubject.create({ type: 'B', v: 2 })); + const hash = VcDocument.toCredentialHash([vc1, vc2]); + assert.isString(hash); + }); + + it('toStaticObject collapses to issuer + subjects', function () { + const vc = new VcDocument(); + vc.setIssuer('did:9'); + vc.addCredentialSubject(VcSubject.create({ type: 'A', v: 1 })); + const obj = vc.toStaticObject(); + assert.equal(obj.issuer, 'did:9'); + assert.isArray(obj.credentialSubject); + assert.equal(obj.credentialSubject.length, 1); + }); + + it('fromJson throws for invalid JSON', function () { + assert.throws(() => VcDocument.fromJson('{not-json'), /not a valid VcDocument/); + }); + + it('fromJsonTree throws for empty input', function () { + assert.throws(() => VcDocument.fromJsonTree(null), /JSON Object is empty/); + }); + + it('fromJsonTree builds with single (non-array) credentialSubject', function () { + const tree = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + id: 'urn:uuid:1', + type: ['VerifiableCredential'], + credentialSubject: { type: 'A', v: 1 } + }; + const vc = VcDocument.fromJsonTree(tree); + assert.equal(vc.length, 1); + assert.deepEqual(vc.getContext(), ['https://www.w3.org/2018/credentials/v1']); + }); + + it('fromJsonTree wraps a single evidence object into an array', function () { + const tree = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + credentialSubject: [], + evidence: { e: 1 } + }; + const vc = VcDocument.fromJsonTree(tree); + assert.deepEqual(vc.toJsonTree().evidence, [{ e: 1 }]); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vc-document-fixtures.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vc-document-fixtures.test.mjs new file mode 100644 index 0000000000..346771fcb8 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vc-document-fixtures.test.mjs @@ -0,0 +1,146 @@ +import { assert } from 'chai'; + +import { VcDocument } from '../../../../dist/hedera-modules/vcjs/vc-document.js'; +import { VcSubject } from '../../../../dist/hedera-modules/vcjs/vc-subject.js'; +import { Issuer } from '../../../../dist/hedera-modules/vcjs/issuer.js'; + +import { vc_document } from '../../dump/vc_document.mjs'; + +function cloneDoc(index) { + return JSON.parse(JSON.stringify(vc_document[index].document)); +} + +function expectedDoc(index) { + const doc = cloneDoc(index); + if (doc.id) { + doc.id = VcDocument.convertUUID(doc.id); + } + if (Array.isArray(doc.credentialSubject)) { + for (const cs of doc.credentialSubject) { + if (cs && cs.id) { + cs.id = VcSubject.convertUUID(cs.id); + } + } + } + return doc; +} + +describe('VcDocument fixtures round-trip', function () { + for (let i = 0; i < vc_document.length; i++) { + const doc = expectedDoc(i); + + it(`fixture ${i}: fromJsonTree -> toJsonTree is stable`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(vc.toJsonTree(), doc); + }); + + it(`fixture ${i}: toJson parses back to the original tree`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(JSON.parse(vc.toJson()), doc); + }); + + it(`fixture ${i}: fromJson string matches fromJsonTree`, function () { + const vc = VcDocument.fromJson(JSON.stringify(cloneDoc(i))); + assert.deepEqual(vc.toJsonTree(), doc); + }); + + it(`fixture ${i}: getId converts to urn form when no scheme`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.equal(vc.getId(), VcDocument.convertUUID(doc.id)); + }); + + it(`fixture ${i}: getContext equals document context`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(vc.getContext(), doc['@context']); + }); + + it(`fixture ${i}: getType equals document type`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(vc.getType(), doc.type); + }); + + it(`fixture ${i}: getIssuer reconstructs Issuer`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.instanceOf(vc.getIssuer(), Issuer); + assert.equal(vc.getIssuerDid(), doc.issuer); + }); + + it(`fixture ${i}: getProof equals document proof`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(vc.getProof(), doc.proof); + }); + + it(`fixture ${i}: subject count matches credentialSubject length`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.equal(vc.length, doc.credentialSubject.length); + }); + + it(`fixture ${i}: getCredentialSubject(0) is a VcSubject`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.instanceOf(vc.getCredentialSubject(0), VcSubject); + }); + + it(`fixture ${i}: getSubjectType matches first subject type`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.equal(vc.getSubjectType(), doc.credentialSubject[0].type); + }); + + it(`fixture ${i}: getCredentialSubjects returns all subjects`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.lengthOf(vc.getCredentialSubjects(), doc.credentialSubject.length); + }); + + it(`fixture ${i}: toCredentialHash is a deterministic base58 string`, function () { + const a = VcDocument.fromJsonTree(cloneDoc(i)).toCredentialHash(); + const b = VcDocument.fromJsonTree(cloneDoc(i)).toCredentialHash(); + assert.isString(a); + assert.equal(a, b); + assert.match(a, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + + it(`fixture ${i}: getDocument equals toJsonTree`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.deepEqual(vc.getDocument(), vc.toJsonTree()); + }); + + it(`fixture ${i}: getSignatureType is Ed25519 (non-BBS context)`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.equal(vc.getSignatureType(), 'Ed25519Signature2018'); + }); + + it(`fixture ${i}: getIssuanceDate round-trips to the same epoch`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.equal( + vc.getIssuanceDate().toDate().getTime(), + new Date(doc.issuanceDate).getTime() + ); + }); + + it(`fixture ${i}: static toCredentialHash(single) is a string`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + assert.isString(VcDocument.toCredentialHash(vc)); + }); + + it(`fixture ${i}: toStaticObject exposes issuer + credentialSubject`, function () { + const vc = VcDocument.fromJsonTree(cloneDoc(i)); + const obj = vc.toStaticObject(); + assert.equal(obj.issuer, doc.issuer); + assert.lengthOf(obj.credentialSubject, doc.credentialSubject.length); + }); + } + + it('static toCredentialHash over an array of all fixtures is stable', function () { + const docs = vc_document.map((e) => VcDocument.fromJsonTree(JSON.parse(JSON.stringify(e.document)))); + const a = VcDocument.toCredentialHash(docs); + const docs2 = vc_document.map((e) => VcDocument.fromJsonTree(JSON.parse(JSON.stringify(e.document)))); + const b = VcDocument.toCredentialHash(docs2); + assert.isString(a); + assert.equal(a, b); + }); + + it('different fixtures produce different credential hashes', function () { + const h0 = VcDocument.fromJsonTree(cloneDoc(0)).toCredentialHash(); + const h1 = VcDocument.fromJsonTree(cloneDoc(1)).toCredentialHash(); + assert.notEqual(h0, h1); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vc-subject-extra.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vc-subject-extra.test.mjs new file mode 100644 index 0000000000..99412ccef2 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vc-subject-extra.test.mjs @@ -0,0 +1,157 @@ +import { assert } from 'chai'; + +import { VcSubject } from '../../../../dist/hedera-modules/vcjs/vc-subject.js'; + +import { vc_document } from '../../dump/vc_document.mjs'; + +describe('VcSubject extra branches', function () { + it('constructor starts with empty context', function () { + const s = new VcSubject(); + assert.deepEqual(s.getContext(), []); + }); + + it('create throws for empty input', function () { + assert.throws(() => VcSubject.create(null), /Subject is empty/); + }); + + it('fromJson throws for invalid JSON', function () { + assert.throws(() => VcSubject.fromJson('{nope'), /not a valid VcSubject/); + }); + + it('create extracts id, type and context out of the document', function () { + const s = VcSubject.create({ + '@context': ['c1'], + id: 'sub-1', + type: 'MyType', + field: 'value' + }); + assert.equal(s.getId(), 'urn:uuid:sub-1'); + assert.equal(s.getType(), 'MyType'); + assert.deepEqual(s.getContext(), ['c1']); + assert.deepEqual(s.getFields(), { field: 'value' }); + }); + + it('create keeps already-prefixed id', function () { + const s = VcSubject.create({ id: 'did:hedera:1', type: 'T' }); + assert.equal(s.getId(), 'did:hedera:1'); + }); + + it('addContext deduplicates string entries', function () { + const s = new VcSubject(); + s.addContext('a'); + s.addContext('a'); + s.addContext('b'); + assert.deepEqual(s.getContext(), ['a', 'b']); + }); + + it('addContext accepts an array', function () { + const s = new VcSubject(); + s.addContext(['a', 'b', 'a']); + assert.deepEqual(s.getContext(), ['a', 'b']); + }); + + it('addContext ignores falsy input', function () { + const s = new VcSubject(); + s.addContext(null); + s.addContext(undefined); + s.addContext(''); + assert.deepEqual(s.getContext(), []); + }); + + it('setField / removeField / frameField mutate the document', function () { + const s = VcSubject.create({ type: 'T', a: 1 }); + s.setField('b', 2); + assert.equal(s.getField('b'), 2); + s.removeField('a'); + assert.isUndefined(s.getField('a')); + s.frameField('c'); + assert.deepEqual(s.getField('c'), {}); + }); + + it('getField traverses nested dotted paths', function () { + const s = VcSubject.create({ type: 'T', a: { b: { c: 7 } } }); + assert.equal(s.getField('a.b.c'), 7); + }); + + it('getField with L selects the last array element', function () { + const s = VcSubject.create({ type: 'T', items: [{ v: 1 }, { v: 2 }, { v: 3 }] }); + assert.equal(s.getField('items.L.v'), 3); + }); + + it('getField returns null for a missing path', function () { + const s = VcSubject.create({ type: 'T' }); + assert.isNull(s.getField('x.y.z')); + }); + + it('toJsonTree includes @context, id and type when present', function () { + const s = VcSubject.create({ '@context': ['c'], id: 'i', type: 'T', f: 1 }); + const tree = s.toJsonTree(); + assert.deepEqual(tree['@context'], ['c']); + assert.equal(tree.id, 'urn:uuid:i'); + assert.equal(tree.type, 'T'); + assert.equal(tree.f, 1); + }); + + it('toJsonTree omits @context when empty', function () { + const s = VcSubject.create({ type: 'T', f: 1 }); + assert.notProperty(s.toJsonTree(), '@context'); + }); + + it('toJson serializes the json tree', function () { + const s = VcSubject.create({ type: 'T', f: 1 }); + assert.deepEqual(JSON.parse(s.toJson()), s.toJsonTree()); + }); + + it('getFields returns a copy, not the internal document', function () { + const s = VcSubject.create({ type: 'T', f: 1 }); + const fields = s.getFields(); + fields.f = 999; + assert.equal(s.getField('f'), 1); + }); + + it('toStaticObject strips id/type/context recursively', function () { + const s = VcSubject.create({ + type: 'T', + nested: { id: 'x', type: 'Y', '@context': ['c'], keep: 5 } + }); + const obj = s.toStaticObject(); + assert.equal(obj.nested.keep, 5); + assert.notProperty(obj.nested, 'id'); + assert.notProperty(obj.nested, 'type'); + assert.notProperty(obj.nested, '@context'); + }); + + it('toStaticObject applies the clear function to objects', function () { + const s = VcSubject.create({ type: 'T', a: { keep: 1 } }); + const obj = s.toStaticObject((m) => { + m.tagged = true; + return m; + }); + assert.isTrue(obj.tagged); + }); + + for (let i = 0; i < vc_document.length; i++) { + const cs = vc_document[i].document.credentialSubject[0]; + + it(`fixture ${i}: round-trips first credentialSubject`, function () { + const clone = JSON.parse(JSON.stringify(cs)); + const expected = JSON.parse(JSON.stringify(cs)); + if (expected.id) { + expected.id = VcSubject.convertUUID(expected.id); + } + const s = VcSubject.fromJsonTree(clone); + assert.deepEqual(s.toJsonTree(), expected); + }); + + it(`fixture ${i}: getType matches`, function () { + const s = VcSubject.fromJsonTree(JSON.parse(JSON.stringify(cs))); + assert.equal(s.getType(), cs.type); + }); + + it(`fixture ${i}: fromJson string equals fromJsonTree`, function () { + const a = VcSubject.fromJson(JSON.stringify(cs)); + const b = VcSubject.fromJsonTree(JSON.parse(JSON.stringify(cs))); + assert.deepEqual(a.toJsonTree(), b.toJsonTree()); + }); + } +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vc-vp-subject-branches.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vc-vp-subject-branches.test.mjs new file mode 100644 index 0000000000..8d39167aa7 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vc-vp-subject-branches.test.mjs @@ -0,0 +1,221 @@ +import { assert } from 'chai'; + +import { VcDocument } from '../../../../dist/hedera-modules/vcjs/vc-document.js'; +import { VcSubject } from '../../../../dist/hedera-modules/vcjs/vc-subject.js'; +import { VpDocument } from '../../../../dist/hedera-modules/vcjs/vp-document.js'; + +function makeVcTree(extra = {}) { + return { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'did:hedera:testnet:issuer_0.0.1', + issuanceDate: '2020-01-01T00:00:00.000Z', + credentialSubject: [{ id: 'subj', type: 'TestType', a: { b: [1, 2, 3] }, name: 'n' }], + ...extra, + }; +} + +describe('VcDocument.getField traversal', function () { + it('resolves a nested dotted path', function () { + const vc = VcDocument.fromJsonTree(makeVcTree()); + assert.deepEqual(vc.getField('a.b'), [1, 2, 3]); + }); + + it('resolves the last array element with the L selector', function () { + const vc = VcDocument.fromJsonTree(makeVcTree()); + assert.equal(vc.getField('a.b.L'), 3); + }); + + it('returns null for a missing nested path', function () { + const vc = VcDocument.fromJsonTree(makeVcTree()); + assert.isNull(vc.getField('does.not.exist')); + }); + + it('reads a field from a specific subject index', function () { + const tree = makeVcTree({ + credentialSubject: [ + { id: 's0', type: 'T', value: 'first' }, + { id: 's1', type: 'T', value: 'second' }, + ], + }); + const vc = VcDocument.fromJsonTree(tree); + assert.equal(vc.getField('value', 1), 'second'); + }); + + it('getSubjectType returns the first subject type', function () { + const vc = VcDocument.fromJsonTree(makeVcTree()); + assert.equal(vc.getSubjectType(), 'TestType'); + }); +}); + +describe('VcDocument context mutators', function () { + it('addContexts handles a single string argument', function () { + const vc = new VcDocument(); + vc.addContexts('http://single'); + assert.include(vc.getContext(), 'http://single'); + }); + + it('addContexts handles an array argument and dedupes', function () { + const vc = new VcDocument(); + vc.addContexts(['http://a', 'http://a', 'http://b']); + const ctx = vc.getContext(); + assert.equal(ctx.filter((c) => c === 'http://a').length, 1); + assert.include(ctx, 'http://b'); + }); + + it('addContext ignores falsy values', function () { + const vc = new VcDocument(); + const before = vc.getContext().length; + vc.addContext(null); + vc.addContext(''); + assert.equal(vc.getContext().length, before); + }); + + it('addType dedupes', function () { + const vc = new VcDocument(); + vc.addType('Custom'); + vc.addType('Custom'); + assert.equal(vc.getType().filter((t) => t === 'Custom').length, 1); + }); +}); + +describe('VcDocument initId and tags', function () { + it('getInitId returns null then the set value', function () { + const vc = new VcDocument(); + assert.isNull(vc.getInitId()); + vc.setInitId('init-123'); + assert.equal(vc.getInitId(), 'init-123'); + }); + + it('setTags / getTags round-trip', function () { + const vc = new VcDocument(); + const tags = [{ messageId: 'm1', inheritTags: true }]; + vc.setTags(tags); + assert.deepEqual(vc.getTags(), tags); + }); + + it('addTags only keeps inheritTags entries and dedupes by messageId', function () { + const vc = new VcDocument(); + vc.addTags([ + { messageId: 'm1', inheritTags: true }, + { messageId: 'm1', inheritTags: true }, + { messageId: 'm2', inheritTags: false }, + ]); + assert.lengthOf(vc.getTags(), 1); + assert.equal(vc.getTags()[0].messageId, 'm1'); + }); + + it('addTags is a no-op for empty input', function () { + const vc = new VcDocument(); + vc.addTags([]); + assert.isUndefined(vc.getTags()); + }); +}); + +describe('VcSubject _clear over nested structures', function () { + it('strips id/type/context from nested array elements', function () { + const sub = VcSubject.create({ + id: 'outer', type: 'T', + arr: [ + { id: 'i1', type: 'X', v: 1 }, + { id: 'i2', type: 'X', v: 2 }, + ], + }); + const out = sub.toStaticObject(); + assert.lengthOf(out.arr, 2); + assert.equal(out.arr[0].v, 1); + assert.equal(out.arr[1].v, 2); + assert.notProperty(out.arr[0], 'id'); + assert.notProperty(out.arr[0], 'type'); + }); + + it('applies a clear function to each object', function () { + const sub = VcSubject.create({ id: 'o', type: 'T', value: 1 }); + const out = sub.toStaticObject((m) => ({ ...m, marked: true })); + assert.equal(out.marked, true); + }); + + it('frameField replaces a field with an empty object', function () { + const sub = VcSubject.create({ id: 'o', type: 'T', value: 1 }); + sub.frameField('framed'); + assert.deepEqual(sub.getFields().framed, {}); + }); + + it('removeField deletes a field', function () { + const sub = VcSubject.create({ id: 'o', type: 'T', value: 1, drop: 2 }); + sub.removeField('drop'); + assert.notProperty(sub.getFields(), 'drop'); + }); + + it('setField writes a field value', function () { + const sub = VcSubject.create({ id: 'o', type: 'T' }); + sub.setField('added', 42); + assert.equal(sub.getFields().added, 42); + }); + + it('getFields returns a copy, not the internal document', function () { + const sub = VcSubject.create({ id: 'o', type: 'T', value: 1 }); + const copy = sub.getFields(); + copy.value = 999; + assert.equal(sub.getFields().value, 1); + }); +}); + +describe('VpDocument credential accessors', function () { + function makeVpTree() { + return { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiablePresentation'], + verifiableCredential: [makeVcTree(), makeVcTree({ issuer: 'did:hedera:testnet:issuer2_0.0.2' })], + }; + } + + it('getVerifiableCredential defaults to index 0', function () { + const vp = VpDocument.fromJsonTree(makeVpTree()); + const vc = vp.getVerifiableCredential(); + assert.instanceOf(vc, VcDocument); + assert.equal(vc.getIssuerDid(), 'did:hedera:testnet:issuer_0.0.1'); + }); + + it('getVerifiableCredential reads a specific index', function () { + const vp = VpDocument.fromJsonTree(makeVpTree()); + assert.equal(vp.getVerifiableCredential(1).getIssuerDid(), 'did:hedera:testnet:issuer2_0.0.2'); + }); + + it('length reflects credential count', function () { + const vp = VpDocument.fromJsonTree(makeVpTree()); + assert.equal(vp.length, 2); + }); + + it('getVerifiableCredentials returns all credentials', function () { + const vp = VpDocument.fromJsonTree(makeVpTree()); + assert.lengthOf(vp.getVerifiableCredentials(), 2); + }); + + it('addVerifiableCredential ignores falsy', function () { + const vp = new VpDocument(); + vp.addVerifiableCredential(null); + assert.equal(vp.length, 0); + }); + + it('addVerifiableCredentials adds all from an array', function () { + const vp = new VpDocument(); + const vc1 = VcDocument.fromJsonTree(makeVcTree()); + const vc2 = VcDocument.fromJsonTree(makeVcTree()); + vp.addVerifiableCredentials([vc1, vc2]); + assert.equal(vp.length, 2); + }); + + it('toCredentialHash is a deterministic base58 string', function () { + const a = VpDocument.fromJsonTree(makeVpTree()).toCredentialHash(); + const b = VpDocument.fromJsonTree(makeVpTree()).toCredentialHash(); + assert.isString(a); + assert.equal(a, b); + assert.match(a, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + + it('getDocument equals toJsonTree', function () { + const vp = VpDocument.fromJsonTree(makeVpTree()); + assert.deepEqual(vp.getDocument(), vp.toJsonTree()); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vcjs-coverage.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vcjs-coverage.test.mjs new file mode 100644 index 0000000000..923fc7ed2f --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vcjs-coverage.test.mjs @@ -0,0 +1,287 @@ +import { assert } from 'chai'; + +import { DefaultDocumentLoader } from '../../../../dist/hedera-modules/document-loader/document-loader-default.js'; +import { LocalDidLoader } from '../../../../dist/document-loader/local-did-loader.js'; +import { VCJS } from '../../../../dist/hedera-modules/vcjs/vcjs.js'; +import { SignatureType } from '@guardian/interfaces'; + +void DefaultDocumentLoader; +void LocalDidLoader; + +describe('VCJS coverage (offline paths)', function () { + function makeVcjs() { + return new VCJS('tenant-1'); + } + + describe('addContext', function () { + it('pushes a context onto schemaContext', function () { + const vcjs = makeVcjs(); + vcjs.addContext('ctx-a'); + vcjs.addContext('ctx-b'); + assert.deepEqual(vcjs.schemaContext, ['ctx-a', 'ctx-b']); + }); + }); + + describe('generateUUID', function () { + it('returns the supplied uuid when present', function () { + const vcjs = makeVcjs(); + assert.equal(vcjs.generateUUID({ uuid: 'fixed' }), 'fixed'); + }); + + it('generates a urn:uuid prefix otherwise', function () { + const vcjs = makeVcjs(); + assert.match(vcjs.generateUUID(), /^urn:uuid:/); + assert.match(vcjs.generateUUID({}), /^urn:uuid:/); + }); + }); + + describe('addContextInSubject', function () { + it('creates an array context when none exists', function () { + const vcjs = makeVcjs(); + const s = vcjs.addContextInSubject({}, 'c1'); + assert.deepEqual(s['@context'], ['c1']); + }); + + it('pushes onto an existing array context', function () { + const vcjs = makeVcjs(); + const s = vcjs.addContextInSubject({ '@context': ['c0'] }, 'c1'); + assert.deepEqual(s['@context'], ['c0', 'c1']); + }); + + it('wraps a scalar context into an array', function () { + const vcjs = makeVcjs(); + const s = vcjs.addContextInSubject({ '@context': 'c0' }, 'c1'); + assert.deepEqual(s['@context'], ['c0', 'c1']); + }); + }); + + describe('addDryRunContext', function () { + it('returns non-object inputs untouched', function () { + const vcjs = makeVcjs(); + assert.equal(vcjs.addDryRunContext('str'), 'str'); + assert.equal(vcjs.addDryRunContext(null), null); + }); + + it('recurses over arrays', function () { + const vcjs = makeVcjs(); + const arr = [{ type: 'A' }, { type: 'B' }]; + vcjs.addDryRunContext(arr); + assert.deepEqual(arr[0]['@context'], ['schema:A']); + assert.deepEqual(arr[1]['@context'], ['schema:B']); + }); + + it('returns object without type unchanged', function () { + const vcjs = makeVcjs(); + const o = { foo: 1 }; + assert.strictEqual(vcjs.addDryRunContext(o), o); + assert.isUndefined(o['@context']); + }); + + it('sets a default schema context and propagates to nested typed children', function () { + const vcjs = makeVcjs(); + const o = { type: 'Root', child: { type: 'Child', v: 1 } }; + vcjs.addDryRunContext(o); + assert.deepEqual(o['@context'], ['schema:Root']); + assert.deepEqual(o.child['@context'], ['schema:Root']); + }); + + it('honours an explicit context override', function () { + const vcjs = makeVcjs(); + const o = { type: 'Root' }; + vcjs.addDryRunContext(o, ['custom:ctx']); + assert.deepEqual(o['@context'], ['custom:ctx']); + }); + }); + + describe('prepareSchema', function () { + it('returns early when there are no $defs', function () { + const vcjs = makeVcjs(); + const schema = { type: 'object' }; + assert.doesNotThrow(() => vcjs.prepareSchema(schema)); + }); + + it('drops readOnly fields from a nested required list', function () { + const vcjs = makeVcjs(); + const schema = { + $defs: { + Foo: { + required: ['a', 'b'], + properties: { a: { readOnly: true }, b: { readOnly: false } } + }, + Bar: { required: [], properties: {} } + } + }; + vcjs.prepareSchema(schema); + assert.deepEqual(schema.$defs.Foo.required, ['b']); + assert.deepEqual(schema.$defs.Bar.required, []); + }); + }); + + describe('verifySubject', function () { + it('throws when no schema loader is configured', async function () { + const vcjs = makeVcjs(); + await assertRejects(() => vcjs.verifySubject({ type: 'X' }), /Schema Loader not found/); + }); + + it('throws when the loader resolves no schema', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => null; + await assertRejects(() => vcjs.verifySubject({ type: 'X' }), /Schema not found/); + }); + + it('validates a subject against a resolved schema (valid)', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => ({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + const result = await vcjs.verifySubject({ '@context': [], type: 'X', name: 'alice' }); + assert.isTrue(result.ok); + }); + + it('returns a failing CheckResult for an invalid subject', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => ({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + const result = await vcjs.verifySubject({ '@context': [], type: 'X' }); + assert.isFalse(result.ok); + }); + }); + + describe('verifySchema', function () { + it('throws when credentialSubject is missing', async function () { + const vcjs = makeVcjs(); + await assertRejects(() => vcjs.verifySchema({}), /credentialSubject/); + }); + + it('reads from toJsonTree when available', async function () { + const vcjs = makeVcjs(); + const vcLike = { toJsonTree: () => ({ credentialSubject: null }) }; + await assertRejects(() => vcjs.verifySchema(vcLike), /credentialSubject/); + }); + + it('throws when no schema loader is configured', async function () { + const vcjs = makeVcjs(); + await assertRejects( + () => vcjs.verifySchema({ credentialSubject: { '@context': [], type: 'X' } }), + /Schema Loader not found/ + ); + }); + + it('throws when the loader resolves no schema', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => null; + await assertRejects( + () => vcjs.verifySchema({ credentialSubject: { '@context': [], type: 'X' } }), + /Schema not found/ + ); + }); + + it('compiles the resolved schema and returns a CheckResult', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => ({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + $defs: {} + }); + const result = await vcjs.verifySchema({ + credentialSubject: { '@context': [], type: 'X', name: 'alice' } + }); + assert.property(result, 'ok'); + }); + + it('uses the first subject when credentialSubject is an array', async function () { + const vcjs = makeVcjs(); + vcjs.schemaLoader = async () => ({ + type: 'object', + properties: { name: { type: 'string' } }, + $defs: {} + }); + const result = await vcjs.verifySchema({ + credentialSubject: [{ '@context': [], type: 'X', name: 'alice' }] + }); + assert.property(result, 'ok'); + }); + }); + + describe('verifyVC', function () { + it('delegates to the configured loader when none supplied', async function () { + const vcjs = makeVcjs(); + let usedLoader; + vcjs.loader = 'configured-loader'; + vcjs.verify = async (json, loader) => { usedLoader = loader; return true; }; + const res = await vcjs.verifyVC({ proof: {} }); + assert.isTrue(res); + assert.equal(usedLoader, 'configured-loader'); + }); + + it('uses an explicit loader and reads toJsonTree', async function () { + const vcjs = makeVcjs(); + let usedLoader; let usedJson; + vcjs.verify = async (json, loader) => { usedLoader = loader; usedJson = json; return true; }; + const res = await vcjs.verifyVC({ toJsonTree: () => ({ proof: {}, tree: 1 }) }, 'explicit'); + assert.isTrue(res); + assert.equal(usedLoader, 'explicit'); + assert.deepEqual(usedJson, { proof: {}, tree: 1 }); + }); + }); + + describe('createSuiteByMethod error branches', function () { + function fakeDid(methodsByType) { + return { + getMethodByType(type) { return methodsByType[type] || null; }, + getDid() { return 'did:example:1'; } + }; + } + + it('throws when no Ed25519 method exists (default branch)', async function () { + const vcjs = makeVcjs(); + await assertRejects( + () => vcjs.createSuiteByMethod(fakeDid({}), SignatureType.Ed25519Signature2018), + /Verification method not found/ + ); + }); + + it('throws when Ed25519 method has no private key', async function () { + const vcjs = makeVcjs(); + const did = fakeDid({ Ed25519VerificationKey2018: { hasPrivateKey: () => false } }); + await assertRejects( + () => vcjs.createSuiteByMethod(did, SignatureType.Ed25519Signature2018), + /Private key not found/ + ); + }); + + it('throws when no BBS method exists', async function () { + const vcjs = makeVcjs(); + await assertRejects( + () => vcjs.createSuiteByMethod(fakeDid({}), SignatureType.BbsBlsSignature2020), + /Verification method not found/ + ); + }); + + it('throws when BBS method has no private key', async function () { + const vcjs = makeVcjs(); + const did = fakeDid({ Bls12381G2Key2020: { hasPrivateKey: () => false } }); + await assertRejects( + () => vcjs.createSuiteByMethod(did, SignatureType.BbsBlsSignature2020), + /Private key not found/ + ); + }); + }); +}); + +async function assertRejects(fn, regex) { + let threw = false; + try { + await fn(); + } catch (e) { + threw = true; + assert.match(e.message, regex); + } + assert.isTrue(threw, 'expected promise to reject'); +} diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vp-document-extra.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vp-document-extra.test.mjs new file mode 100644 index 0000000000..bbb76dce89 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vp-document-extra.test.mjs @@ -0,0 +1,170 @@ +import { assert } from 'chai'; + +import { VpDocument } from '../../../../dist/hedera-modules/vcjs/vp-document.js'; +import { VcDocument } from '../../../../dist/hedera-modules/vcjs/vc-document.js'; +import { VcSubject } from '../../../../dist/hedera-modules/vcjs/vc-subject.js'; +import { Issuer } from '../../../../dist/hedera-modules/vcjs/issuer.js'; +import { TimestampUtils } from '../../../../dist/hedera-modules/timestamp-utils.js'; + +function makeVc(did, subject) { + const vc = new VcDocument(); + vc.setIssuer(did); + vc.setIssuanceDate(TimestampUtils.now()); + vc.addCredentialSubject(VcSubject.create(subject)); + return vc; +} + +describe('VpDocument extra branches', function () { + it('default constructor sets type and context', function () { + const vp = new VpDocument(); + assert.deepEqual(vp.getType(), [VpDocument.VERIFIABLE_PRESENTATION_TYPE]); + assert.deepEqual(vp.getContext(), [VpDocument.FIRST_CONTEXT_ENTRY]); + assert.equal(vp.length, 0); + }); + + it('setId converts bare uuid to urn form', function () { + const vp = new VpDocument(); + vp.setId('zzz'); + assert.equal(vp.getId(), 'urn:uuid:zzz'); + }); + + it('setId keeps prefixed id', function () { + const vp = new VpDocument(); + vp.setId('did:something'); + assert.equal(vp.getId(), 'did:something'); + }); + + it('getIssuerDid returns null with no issuer', function () { + const vp = new VpDocument(); + assert.isNull(vp.getIssuerDid()); + }); + + it('setIssuer with string', function () { + const vp = new VpDocument(); + vp.setIssuer('did:1'); + assert.instanceOf(vp.getIssuer(), Issuer); + assert.equal(vp.getIssuerDid(), 'did:1'); + }); + + it('setIssuer with Issuer instance', function () { + const vp = new VpDocument(); + const issuer = new Issuer('did:2'); + vp.setIssuer(issuer); + assert.strictEqual(vp.getIssuer(), issuer); + }); + + it('setIssuer with getDid object', function () { + const vp = new VpDocument(); + vp.setIssuer({ getDid: () => 'did:3' }); + assert.equal(vp.getIssuerDid(), 'did:3'); + }); + + it('addContext and addType append', function () { + const vp = new VpDocument(); + vp.addContext('c1'); + vp.addType('t1'); + assert.deepEqual(vp.getContext(), [VpDocument.FIRST_CONTEXT_ENTRY, 'c1']); + assert.deepEqual(vp.getType(), [VpDocument.VERIFIABLE_PRESENTATION_TYPE, 't1']); + }); + + it('getProof/setProof and proofFromJson', function () { + const vp = new VpDocument(); + assert.isUndefined(vp.getProof()); + vp.setProof({ a: 1 }); + assert.deepEqual(vp.getProof(), { a: 1 }); + vp.proofFromJson({ proof: { b: 2 } }); + assert.deepEqual(vp.getProof(), { b: 2 }); + }); + + it('getTags/setTags round trips', function () { + const vp = new VpDocument(); + assert.isUndefined(vp.getTags()); + vp.setTags([{ messageId: '1' }]); + assert.deepEqual(vp.getTags(), [{ messageId: '1' }]); + }); + + it('addTags ignores empty input', function () { + const vp = new VpDocument(); + vp.addTags(null); + vp.addTags([]); + assert.isUndefined(vp.getTags()); + }); + + it('addTags filters by inheritTags and dedupes', function () { + const vp = new VpDocument(); + vp.addTags([ + { messageId: 'a', inheritTags: true }, + { messageId: 'b', inheritTags: false }, + { messageId: 'a', inheritTags: true } + ]); + assert.deepEqual(vp.getTags(), [{ messageId: 'a', inheritTags: true }]); + }); + + it('addVerifiableCredential ignores falsy', function () { + const vp = new VpDocument(); + vp.addVerifiableCredential(null); + assert.equal(vp.length, 0); + vp.addVerifiableCredential(makeVc('did:1', { type: 'A', v: 1 })); + assert.equal(vp.length, 1); + }); + + it('addVerifiableCredentials ignores undefined and adds all', function () { + const vp = new VpDocument(); + vp.addVerifiableCredentials(undefined); + assert.equal(vp.length, 0); + vp.addVerifiableCredentials([ + makeVc('did:1', { type: 'A', v: 1 }), + makeVc('did:2', { type: 'B', v: 2 }) + ]); + assert.equal(vp.length, 2); + assert.instanceOf(vp.getVerifiableCredential(1), VcDocument); + }); + + it('toCredentialHash produces a string', function () { + const vp = new VpDocument(); + vp.setId('urn:uuid:1'); + vp.addVerifiableCredential(makeVc('did:1', { type: 'A', v: 1 })); + assert.isString(vp.toCredentialHash()); + }); + + it('toJsonTree includes tags when present', function () { + const vp = new VpDocument(); + vp.setTags([{ messageId: 'x' }]); + assert.deepEqual(vp.toJsonTree().tags, [{ messageId: 'x' }]); + }); + + it('toJsonTree omits proof when absent', function () { + const vp = new VpDocument(); + assert.notProperty(vp.toJsonTree(), 'proof'); + }); + + it('fromJson throws for invalid JSON', function () { + assert.throws(() => VpDocument.fromJson('{bad'), /not a valid VpDocument/); + }); + + it('fromJsonTree throws for empty input', function () { + assert.throws(() => VpDocument.fromJsonTree(null), /JSON Object is empty/); + }); + + it('fromJsonTree handles single (non-array) verifiableCredential', function () { + const single = makeVc('did:1', { type: 'A', v: 1 }).toJsonTree(); + const tree = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiablePresentation'], + verifiableCredential: single + }; + const vp = VpDocument.fromJsonTree(tree); + assert.equal(vp.length, 1); + }); + + it('fromJsonTree sets proof/tags to null when absent', function () { + const tree = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiablePresentation'], + verifiableCredential: [] + }; + const vp = VpDocument.fromJsonTree(tree); + assert.isNull(vp.getProof()); + assert.isNull(vp.getTags()); + }); +}); diff --git a/common/tests/unit-tests/hedera-modules/vcjs/vp-document-fixtures.test.mjs b/common/tests/unit-tests/hedera-modules/vcjs/vp-document-fixtures.test.mjs new file mode 100644 index 0000000000..0fc9127996 --- /dev/null +++ b/common/tests/unit-tests/hedera-modules/vcjs/vp-document-fixtures.test.mjs @@ -0,0 +1,108 @@ +import { assert } from 'chai'; + +import { VpDocument } from '../../../../dist/hedera-modules/vcjs/vp-document.js'; +import { VcDocument } from '../../../../dist/hedera-modules/vcjs/vc-document.js'; +import { Issuer } from '../../../../dist/hedera-modules/vcjs/issuer.js'; + +import { vc_document } from '../../dump/vc_document.mjs'; + +function vcTree(index) { + return JSON.parse(JSON.stringify(vc_document[index].document)); +} + +function buildVp(indices) { + const vp = new VpDocument(); + for (const i of indices) { + vp.addVerifiableCredential(VcDocument.fromJsonTree(vcTree(i))); + } + return vp; +} + +describe('VpDocument with VC fixtures', function () { + it('wraps several VC fixtures and reports correct length', function () { + const vp = buildVp([0, 1, 2]); + assert.equal(vp.length, 3); + for (let i = 0; i < 3; i++) { + assert.instanceOf(vp.getVerifiableCredential(i), VcDocument); + } + }); + + it('toJsonTree carries type, context and verifiableCredential array', function () { + const vp = buildVp([0, 1]); + const tree = vp.toJsonTree(); + assert.deepEqual(tree.type, [VpDocument.VERIFIABLE_PRESENTATION_TYPE]); + assert.deepEqual(tree['@context'], [VpDocument.FIRST_CONTEXT_ENTRY]); + assert.lengthOf(tree.verifiableCredential, 2); + }); + + it('toCredentialHash is deterministic for the same VCs', function () { + const a = buildVp([0, 1, 2]); + a.setId('urn:uuid:fixed'); + const b = buildVp([0, 1, 2]); + b.setId('urn:uuid:fixed'); + assert.equal(a.toCredentialHash(), b.toCredentialHash()); + }); + + it('toCredentialHash differs when credential set differs', function () { + const a = buildVp([0, 1]); + a.setId('urn:uuid:fixed'); + const b = buildVp([2, 3]); + b.setId('urn:uuid:fixed'); + assert.notEqual(a.toCredentialHash(), b.toCredentialHash()); + }); + + it('fromJsonTree rebuilds an array of credentials', function () { + const vp = buildVp([0, 1, 2]); + vp.setId('urn:uuid:p1'); + vp.setIssuer('did:hedera:testnet:issuer'); + const tree = vp.toJsonTree(); + const restored = VpDocument.fromJsonTree(tree); + assert.equal(restored.length, 3); + assert.equal(restored.getId(), 'urn:uuid:p1'); + assert.instanceOf(restored.getIssuer(), Issuer); + assert.equal(restored.getIssuerDid(), 'did:hedera:testnet:issuer'); + }); + + it('toJson string round-trips through fromJson', function () { + const vp = buildVp([0, 1]); + vp.setId('urn:uuid:p2'); + const json = vp.toJson(); + const restored = VpDocument.fromJson(json); + assert.equal(restored.getId(), 'urn:uuid:p2'); + assert.equal(restored.length, 2); + }); + + it('getDocument equals toJsonTree', function () { + const vp = buildVp([0]); + assert.deepEqual(vp.getDocument(), vp.toJsonTree()); + }); + + it('getVerifiableCredentials returns the full array', function () { + const vp = buildVp([0, 1, 2, 3]); + assert.lengthOf(vp.getVerifiableCredentials(), 4); + }); + + it('issuer with group survives a round-trip', function () { + const vp = buildVp([0]); + vp.setIssuer(new Issuer('did:hedera:testnet:g', 'group-a')); + const restored = VpDocument.fromJsonTree(vp.toJsonTree()); + assert.equal(restored.getIssuer().getId(), 'did:hedera:testnet:g'); + assert.equal(restored.getIssuer().getGroup(), 'group-a'); + }); + + for (let i = 0; i < vc_document.length; i++) { + it(`single VC fixture ${i} wrapped into a VP round-trips length`, function () { + const vp = buildVp([i]); + const restored = VpDocument.fromJsonTree(vp.toJsonTree()); + assert.equal(restored.length, 1); + assert.instanceOf(restored.getVerifiableCredential(0), VcDocument); + }); + + it(`single VC fixture ${i} VP hash is a base58 string`, function () { + const vp = buildVp([i]); + const hash = vp.toCredentialHash(); + assert.isString(hash); + assert.match(hash, /^[1-9A-HJ-NP-Za-km-z]+$/); + }); + } +}); diff --git a/common/tests/unit-tests/hedera-utils/timestamp-utils.test.mjs b/common/tests/unit-tests/hedera-utils/timestamp-utils.test.mjs new file mode 100644 index 0000000000..3aed263e2b --- /dev/null +++ b/common/tests/unit-tests/hedera-utils/timestamp-utils.test.mjs @@ -0,0 +1,91 @@ +import { assert } from 'chai'; +import { TimestampUtils } from '../../../dist/hedera-modules/timestamp-utils.js'; +import { Timestamp } from '@hiero-ledger/sdk'; + +describe('TimestampUtils.now', () => { + it('returns a Timestamp close to "now"', () => { + const before = Date.now(); + const t = TimestampUtils.now(); + const after = Date.now(); + const ms = t.toDate().getTime(); + assert.isAtLeast(ms, before - 5); + assert.isAtMost(ms, after + 5); + }); +}); + +describe('TimestampUtils.toJSON / fromJson', () => { + it('round-trips the ISO format', () => { + const date = new Date('2024-06-15T12:34:56.789Z'); + const t = Timestamp.fromDate(date); + const json = TimestampUtils.toJSON(t); + assert.equal(json, '2024-06-15T12:34:56.789Z'); + const back = TimestampUtils.fromJson(json); + assert.equal(back.toDate().toISOString(), date.toISOString()); + }); + + it('round-trips the ISO8601 (second-precision) format', () => { + const date = new Date('2024-06-15T12:34:56.000Z'); + const t = Timestamp.fromDate(date); + const json = TimestampUtils.toJSON(t, TimestampUtils.ISO8601); + assert.equal(json, '2024-06-15T12:34:56Z'); + const back = TimestampUtils.fromJson(json, TimestampUtils.ISO8601); + assert.equal(back.toDate().toISOString(), date.toISOString()); + }); + + it('exposes the documented ISO format strings', () => { + assert.equal(TimestampUtils.ISO, 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + assert.equal(TimestampUtils.ISO8601, 'YYYY-MM-DDTHH:mm:ss[Z]'); + }); +}); + +describe('TimestampUtils.equals', () => { + it('returns true for the identical reference', () => { + const t = TimestampUtils.now(); + assert.isTrue(TimestampUtils.equals(t, t)); + }); + + it('returns true for two timestamps representing the same instant', () => { + const date = new Date('2024-01-01T00:00:00.123Z'); + const a = Timestamp.fromDate(date); + const b = Timestamp.fromDate(date); + assert.isTrue(TimestampUtils.equals(a, b)); + }); + + it('returns false when seconds differ', () => { + const a = Timestamp.fromDate(new Date('2024-01-01T00:00:00.000Z')); + const b = Timestamp.fromDate(new Date('2024-01-01T00:00:01.000Z')); + assert.isFalse(TimestampUtils.equals(a, b)); + }); + + it('returns false when nanos differ', () => { + const a = Timestamp.fromDate(new Date('2024-01-01T00:00:00.111Z')); + const b = Timestamp.fromDate(new Date('2024-01-01T00:00:00.222Z')); + assert.isFalse(TimestampUtils.equals(a, b)); + }); + + it('returns false when either side is null/undefined', () => { + const t = TimestampUtils.now(); + assert.isFalse(TimestampUtils.equals(t, null)); + assert.isFalse(TimestampUtils.equals(null, t)); + assert.isFalse(TimestampUtils.equals(undefined, t)); + }); +}); + +describe('TimestampUtils.lessThan', () => { + it('returns false for the identical reference', () => { + const t = TimestampUtils.now(); + assert.isFalse(TimestampUtils.lessThan(t, t)); + }); + + it('returns true when nanos are smaller within the same second', () => { + const a = Timestamp.fromDate(new Date('2024-01-01T00:00:00.100Z')); + const b = Timestamp.fromDate(new Date('2024-01-01T00:00:00.200Z')); + assert.isTrue(TimestampUtils.lessThan(a, b)); + }); + + it('returns false when either side is null/undefined', () => { + const t = TimestampUtils.now(); + assert.isFalse(TimestampUtils.lessThan(t, null)); + assert.isFalse(TimestampUtils.lessThan(null, t)); + }); +}); diff --git a/common/tests/unit-tests/helpers/settings-container.test.mjs b/common/tests/unit-tests/helpers/settings-container.test.mjs new file mode 100644 index 0000000000..e74186b7c3 --- /dev/null +++ b/common/tests/unit-tests/helpers/settings-container.test.mjs @@ -0,0 +1,361 @@ +import { assert } from 'chai'; +import { WalletEvents } from '@guardian/interfaces'; +import { SettingsContainerOLD } from '../../../dist/helpers/settings-container.js'; +import { PinoLogger } from '../../../dist/helpers/pino-logger.js'; + +describe('@unit SettingsContainerOLD', () => { + let instance; + let recorder; + let savedEnv; + let logged; + let savedLoggerInfo; + + const makeResponder = (responses) => { + recorder = []; + return (subject, data) => { + recorder.push({ subject, data }); + if (subject === WalletEvents.GET_GLOBAL_APPLICATION_KEY) { + const value = Object.prototype.hasOwnProperty.call(responses, data.type) + ? responses[data.type] + : ''; + return Promise.resolve({ key: value }); + } + return Promise.resolve({}); + }; + }; + + const resetInstanceState = () => { + instance.initialized = false; + for (const key of Object.keys(instance._settings)) { + delete instance._settings[key]; + } + }; + + beforeEach(() => { + savedEnv = { ...process.env }; + process.env.QM_VERIFICATION = 'false'; + + logged = []; + const logger = new PinoLogger(); + savedLoggerInfo = logger.info; + logger.info = async (message, attributes, userId) => { + logged.push({ message, attributes, userId }); + }; + + instance = new SettingsContainerOLD(); + instance.setConnection({ + subscribe() { + return undefined; + }, + publish() { + return undefined; + } + }); + resetInstanceState(); + recorder = []; + instance.sendMessage = makeResponder({}); + }); + + afterEach(() => { + const logger = new PinoLogger(); + logger.info = savedLoggerInfo; + resetInstanceState(); + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(savedEnv, key)) { + delete process.env[key]; + } + } + for (const key of Object.keys(savedEnv)) { + process.env[key] = savedEnv[key]; + } + }); + + describe('singleton', () => { + it('returns the same instance for repeated construction', () => { + const a = new SettingsContainerOLD(); + const b = new SettingsContainerOLD(); + assert.strictEqual(a, b); + }); + + it('reuses the original instance', () => { + const a = new SettingsContainerOLD(); + assert.strictEqual(a, instance); + }); + }); + + describe('settings getter', () => { + it('throws when not initialized', () => { + assert.throws(() => instance.settings, 'Settings container was not initialized'); + }); + + it('returns the settings object after init', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + assert.deepEqual(instance.settings, { FOO: 'foo-value' }); + }); + + it('returns the same underlying object reference', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + assert.strictEqual(instance.settings, instance._settings); + }); + }); + + describe('init', () => { + it('marks the container initialized', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + assert.isTrue(instance.initialized); + }); + + it('reads each setting via GET event with {type} payload', async () => { + instance.sendMessage = makeResponder({ FOO: 'a', BAR: 'b' }); + await instance.init('FOO', 'BAR'); + + const gets = recorder.filter((r) => r.subject === WalletEvents.GET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(gets, 2); + assert.deepEqual(gets[0].data, { type: 'FOO' }); + assert.deepEqual(gets[1].data, { type: 'BAR' }); + }); + + it('uses the get-setting-key subject literal', async () => { + instance.sendMessage = makeResponder({ FOO: 'a' }); + await instance.init('FOO'); + assert.strictEqual(recorder[0].subject, 'get-setting-key'); + }); + + it('stores the returned key in the settings map', async () => { + instance.sendMessage = makeResponder({ FOO: 'remote-value' }); + await instance.init('FOO'); + assert.strictEqual(instance._settings.FOO, 'remote-value'); + }); + + it('issues no SET event when remote key is present', async () => { + process.env.FOO = 'env-value'; + instance.sendMessage = makeResponder({ FOO: 'remote-value' }); + await instance.init('FOO'); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 0); + assert.strictEqual(instance._settings.FOO, 'remote-value'); + }); + + it('handles init with zero settings', async () => { + await instance.init(); + assert.isTrue(instance.initialized); + assert.deepEqual(instance._settings, {}); + assert.lengthOf(recorder, 0); + }); + + it('handles multiple settings preserving order', async () => { + instance.sendMessage = makeResponder({ A: '1', B: '2', C: '3' }); + await instance.init('A', 'B', 'C'); + assert.deepEqual(instance._settings, { A: '1', B: '2', C: '3' }); + }); + }); + + describe('init env fallback', () => { + it('writes env value via SET event when remote key is empty', async () => { + process.env.FOO = 'env-value'; + instance.sendMessage = makeResponder({ FOO: '' }); + await instance.init('FOO'); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 1); + assert.deepEqual(sets[0].data, { type: 'FOO', key: 'env-value' }); + }); + + it('uses the set-setting-key subject literal for fallback', async () => { + process.env.FOO = 'env-value'; + instance.sendMessage = makeResponder({ FOO: '' }); + await instance.init('FOO'); + const sets = recorder.filter((r) => r.subject === 'set-setting-key'); + assert.lengthOf(sets, 1); + }); + + it('updates the local map to the env value on fallback', async () => { + process.env.FOO = 'env-value'; + instance.sendMessage = makeResponder({ FOO: '' }); + await instance.init('FOO'); + assert.strictEqual(instance._settings.FOO, 'env-value'); + }); + + it('logs that the setting was set from environment', async () => { + process.env.FOO = 'env-value'; + instance.sendMessage = makeResponder({ FOO: '' }); + await instance.init('FOO'); + assert.lengthOf(logged, 1); + assert.strictEqual(logged[0].message, 'FOO was set from environment'); + assert.deepEqual(logged[0].attributes, ['GUARDIAN_SERVICE']); + }); + + it('does not fall back when env var is absent and remote empty', async () => { + delete process.env.FOO; + instance.sendMessage = makeResponder({ FOO: '' }); + await instance.init('FOO'); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 0); + assert.strictEqual(instance._settings.FOO, ''); + }); + + it('falls back only for the empty settings among several', async () => { + process.env.A = 'env-a'; + process.env.B = 'env-b'; + instance.sendMessage = makeResponder({ A: '', B: 'remote-b' }); + await instance.init('A', 'B'); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 1); + assert.deepEqual(sets[0].data, { type: 'A', key: 'env-a' }); + assert.strictEqual(instance._settings.A, 'env-a'); + assert.strictEqual(instance._settings.B, 'remote-b'); + }); + }); + + describe('double init', () => { + it('throws when initialized a second time', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + + let error; + try { + await instance.init('FOO'); + } catch (e) { + error = e; + } + assert.instanceOf(error, Error); + assert.strictEqual(error.message, 'Settings already initialized'); + }); + + it('checks the flag after super.init (re-reads keys before throwing)', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + recorder.length = 0; + + try { + await instance.init('FOO'); + } catch (e) { + // expected + } + const gets = recorder.filter((r) => r.subject === WalletEvents.GET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(gets, 0); + }); + }); + + describe('updateSetting', () => { + it('throws for an unregistered name', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + + let error; + try { + await instance.updateSetting('BAR', 'x'); + } catch (e) { + error = e; + } + assert.instanceOf(error, Error); + assert.strictEqual(error.message, 'BAR setting was not registered'); + }); + + it('throws before init since no settings registered', async () => { + let error; + try { + await instance.updateSetting('FOO', 'x'); + } catch (e) { + error = e; + } + assert.instanceOf(error, Error); + assert.strictEqual(error.message, 'FOO setting was not registered'); + }); + + it('sends SET event with {type,key} for a registered name', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + recorder.length = 0; + + await instance.updateSetting('FOO', 'new-value'); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 1); + assert.deepEqual(sets[0].data, { type: 'FOO', key: 'new-value' }); + }); + + it('updates the local map for a registered name', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.init('FOO'); + await instance.updateSetting('FOO', 'new-value'); + assert.strictEqual(instance._settings.FOO, 'new-value'); + }); + }); + + describe('requestSettings', () => { + it('refreshes each registered key via GET event', async () => { + instance.sendMessage = makeResponder({ A: '1', B: '2' }); + await instance.init('A', 'B'); + + instance.sendMessage = makeResponder({ A: '10', B: '20' }); + await instance.requestSettings(); + + const gets = recorder.filter((r) => r.subject === WalletEvents.GET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(gets, 2); + assert.deepEqual(instance._settings, { A: '10', B: '20' }); + }); + + it('is a no-op when no settings are registered', async () => { + await instance.requestSettings(); + assert.lengthOf(recorder, 0); + }); + + it('applies env fallback for keys that became empty', async () => { + instance.sendMessage = makeResponder({ A: '1' }); + await instance.init('A'); + + process.env.A = 'env-a'; + instance.sendMessage = makeResponder({ A: '' }); + await instance.requestSettings(); + + const sets = recorder.filter((r) => r.subject === WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.lengthOf(sets, 1); + assert.deepEqual(sets[0].data, { type: 'A', key: 'env-a' }); + assert.strictEqual(instance._settings.A, 'env-a'); + }); + + it('does not change initialized flag', async () => { + instance.sendMessage = makeResponder({ A: '1' }); + await instance.init('A'); + await instance.requestSettings(); + assert.isTrue(instance.initialized); + }); + }); + + describe('getGlobalApplicationKey', () => { + it('returns the key field of the response', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + const result = await instance.getGlobalApplicationKey('FOO'); + assert.strictEqual(result, 'foo-value'); + }); + + it('sends GET event with {type} payload', async () => { + instance.sendMessage = makeResponder({ FOO: 'foo-value' }); + await instance.getGlobalApplicationKey('FOO'); + assert.strictEqual(recorder[0].subject, WalletEvents.GET_GLOBAL_APPLICATION_KEY); + assert.deepEqual(recorder[0].data, { type: 'FOO' }); + }); + }); + + describe('setGlobalApplicationKey', () => { + it('sends SET event with {type,key} payload', async () => { + instance.sendMessage = makeResponder({}); + await instance.setGlobalApplicationKey('FOO', 'bar'); + assert.strictEqual(recorder[0].subject, WalletEvents.SET_GLOBAL_APPLICATION_KEY); + assert.deepEqual(recorder[0].data, { type: 'FOO', key: 'bar' }); + }); + + it('updates the local settings map', async () => { + instance.sendMessage = makeResponder({}); + await instance.setGlobalApplicationKey('FOO', 'bar'); + assert.strictEqual(instance._settings.FOO, 'bar'); + }); + }); +}); diff --git a/common/tests/unit-tests/import-export/formula-import-export.test.mjs b/common/tests/unit-tests/import-export/formula-import-export.test.mjs new file mode 100644 index 0000000000..dfce0972d1 --- /dev/null +++ b/common/tests/unit-tests/import-export/formula-import-export.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { FormulaImportExport } from '../../../dist/import-export/formula.js'; + +describe('FormulaImportExport zip handling', () => { + it('generateZipFile writes formula.json and schemas.json', async () => { + const zip = await FormulaImportExport.generateZipFile({ + formula: { name: 'F', config: { formulas: [] } }, + schemas: [{ iri: '#s1' }] + }); + assert.ok(zip.files['formula.json']); + assert.ok(zip.files['schemas.json']); + }); + + it('strips identity fields from the packed formula', async () => { + const zip = await FormulaImportExport.generateZipFile({ + formula: { id: '1', _id: 'x', owner: 'o', createDate: 'c', updateDate: 'u', name: 'F' }, + schemas: [] + }); + const parsed = JSON.parse(await zip.files['formula.json'].async('string')); + assert.equal(parsed.id, undefined); + assert.equal(parsed._id, undefined); + assert.equal(parsed.owner, undefined); + assert.equal(parsed.name, 'F'); + }); + + it('parseZipFile round-trips formula and schemas', async () => { + const zip = await FormulaImportExport.generateZipFile({ + formula: { name: 'RT', config: { formulas: [] } }, + schemas: [{ iri: '#a', name: 'A' }] + }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const components = await FormulaImportExport.parseZipFile(buffer); + assert.equal(components.formula.name, 'RT'); + assert.deepEqual(components.schemas, [{ iri: '#a', name: 'A' }]); + }); + + it('parseZipFile defaults schemas to empty array when schemas.json is missing', async () => { + const zip = new JSZip(); + zip.file('formula.json', JSON.stringify({ name: 'NoSchemas' })); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const components = await FormulaImportExport.parseZipFile(buffer); + assert.deepEqual(components.schemas, []); + }); + + it('parseZipFile rejects a zip without formula.json', async () => { + const zip = new JSZip(); + zip.file('schemas.json', '[]'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(FormulaImportExport.parseZipFile(buffer), /Zip file is not a formula/); + }); +}); + +describe('FormulaImportExport.validateConfig', () => { + it('returns the input untouched', () => { + const data = { formulas: [{ uuid: '1' }] }; + assert.equal(FormulaImportExport.validateConfig(data), data); + assert.equal(FormulaImportExport.validateConfig(undefined), undefined); + }); +}); + +describe('FormulaImportExport.getSchemaIds', () => { + it('collects entity ids of schema links', () => { + const ids = FormulaImportExport.getSchemaIds({ + formulas: [ + { link: { type: 'schema', entityId: '#s1' } }, + { link: { type: 'schema', entityId: '#s2' } }, + { link: { type: 'formula', entityId: '#f' } }, + { link: null }, + {} + ] + }); + assert.deepEqual(Array.from(ids).sort(), ['#s1', '#s2']); + }); + + it('returns an empty set for missing config or formulas', () => { + assert.equal(FormulaImportExport.getSchemaIds(undefined).size, 0); + assert.equal(FormulaImportExport.getSchemaIds({ formulas: 'bad' }).size, 0); + }); +}); + +describe('FormulaImportExport.replaceIds', () => { + it('replaces matching link entity ids', () => { + const data = { + formulas: [ + { link: { type: 'schema', entityId: 'old' } }, + { link: { type: 'schema', entityId: 'other' } } + ] + }; + const result = FormulaImportExport.replaceIds(data, 'old', 'new'); + assert.equal(result.formulas[0].link.entityId, 'new'); + assert.equal(result.formulas[1].link.entityId, 'other'); + }); + + it('passes through null data and non-array formulas', () => { + assert.equal(FormulaImportExport.replaceIds(null, 'a', 'b'), null); + const data = { formulas: 'bad' }; + assert.equal(FormulaImportExport.replaceIds(data, 'a', 'b'), data); + }); +}); + +describe('FormulaImportExport.generateByPolicy', () => { + it('returns null when the policy has no mathBlocks', () => { + const policy = { name: 'P', config: { blockType: 'root', children: [{ blockType: 'other' }] } }; + assert.equal(FormulaImportExport.generateByPolicy(policy), null); + }); + + it('builds a draft formula from a mathBlock', () => { + const policy = { + id: 'pid', + name: 'P', + description: 'D', + owner: 'did:owner', + topicId: '0.0.1', + instanceTopicId: '0.0.2', + config: { + blockType: 'root', + children: [{ + blockType: 'mathBlock', + tag: 'calc', + inputSchema: '#in', + outputSchema: '#out', + expression: { + variables: [{ name: 'a', description: 'var a', field: 'field0' }], + formulas: [{ name: 'f1', description: 'formula 1', body: 'a + 1', relationships: ['a'] }], + outputs: [{ name: 'f1', field: 'field1' }] + } + }] + } + }; + const formula = FormulaImportExport.generateByPolicy(policy); + assert.ok(formula); + assert.equal(formula.name, 'P'); + assert.equal(formula.policyId, 'pid'); + assert.equal(formula.autoGenerated, true); + const items = formula.config.formulas; + assert.equal(items.length, 2); + const variable = items.find((i) => i.type === 'variable'); + const f1 = items.find((i) => i.type === 'formula'); + assert.deepEqual(variable.link, { entityId: '#in', item: 'field0', type: 'schema' }); + assert.equal(f1.value, 'a + 1'); + assert.deepEqual(f1.relationships, [variable.uuid]); + assert.deepEqual(f1.link, { entityId: '#out', item: 'field1', type: 'schema' }); + }); + + it('falls back to inputSchema when outputSchema is missing', () => { + const policy = { + id: 'p', + name: 'N', + config: { + blockType: 'root', + children: [{ + blockType: 'mathBlock', + tag: 't', + inputSchema: '#only', + expression: { + variables: [{ name: 'v', description: 'v', field: 'f' }], + formulas: [], + outputs: [{ name: 'v', field: 'fOut' }] + } + }] + } + }; + const formula = FormulaImportExport.generateByPolicy(policy); + const items = formula.config.formulas; + const generated = items.find((i) => i.type === 'formula'); + assert.ok(generated); + assert.deepEqual(generated.link, { entityId: '#only', item: 'fOut', type: 'schema' }); + assert.equal(generated.value, 'v'); + }); + + it('flattens grouped expression items', () => { + const policy = { + id: 'p', + name: 'N', + config: { + blockType: 'root', + children: [{ + blockType: 'mathBlock', + tag: 't', + inputSchema: '#in', + expression: { + variables: [{ type: 'group', items: [{ name: 'g1', description: 'grouped', field: 'fg' }] }], + formulas: [], + outputs: [] + } + }] + } + }; + const formula = FormulaImportExport.generateByPolicy(policy); + assert.equal(formula.config.formulas.length, 1); + assert.equal(formula.config.formulas[0].name, 'g1'); + }); +}); diff --git a/common/tests/unit-tests/import-export/import-export-utils.test.mjs b/common/tests/unit-tests/import-export/import-export-utils.test.mjs new file mode 100644 index 0000000000..1fe9b6facc --- /dev/null +++ b/common/tests/unit-tests/import-export/import-export-utils.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { ImportExportUtils } from '../../../dist/import-export/utils.js'; + +describe('ImportExportUtils.findAllTools', () => { + it('returns empty array for a config without tools', () => { + const result = ImportExportUtils.findAllTools({ blockType: 'interfaceContainerBlock', children: [] }); + assert.deepEqual(result, []); + }); + + it('collects messageId of nested tool blocks', () => { + const config = { + blockType: 'interfaceContainerBlock', + children: [ + { blockType: 'tool', messageId: 'msg-1' }, + { blockType: 'tool', messageId: 'msg-2' } + ] + }; + assert.deepEqual(ImportExportUtils.findAllTools(config).sort(), ['msg-1', 'msg-2']); + }); + + it('does not treat the root block as a tool', () => { + const config = { blockType: 'tool', messageId: 'root-msg', children: [] }; + assert.deepEqual(ImportExportUtils.findAllTools(config), []); + }); + + it('deduplicates repeated messageIds', () => { + const config = { + blockType: 'interfaceContainerBlock', + children: [ + { blockType: 'tool', messageId: 'same' }, + { blockType: 'tool', messageId: 'same' } + ] + }; + assert.deepEqual(ImportExportUtils.findAllTools(config), ['same']); + }); + + it('skips tools with non-string messageId', () => { + const config = { + blockType: 'interfaceContainerBlock', + children: [ + { blockType: 'tool', messageId: 42 }, + { blockType: 'tool' } + ] + }; + assert.deepEqual(ImportExportUtils.findAllTools(config), []); + }); + + it('finds deeply nested tools through container chains', () => { + const config = { + blockType: 'a', + children: [{ blockType: 'b', children: [{ blockType: 'c', children: [{ blockType: 'tool', messageId: 'deep' }] }] }] + }; + assert.deepEqual(ImportExportUtils.findAllTools(config), ['deep']); + }); +}); + +describe('ImportExportUtils.findAllSchemas', () => { + it('collects schema field values from regular blocks', () => { + const config = { + blockType: 'requestVcDocumentBlock', + schema: '#schema-1', + presetSchema: '#schema-2', + children: [] + }; + assert.deepEqual(ImportExportUtils.findAllSchemas(config).sort(), ['#schema-1', '#schema-2']); + }); + + it('collects Schema-typed variables for tool blocks', () => { + const config = { + blockType: 'tool', + variables: [ + { name: 'inputSchema', type: 'Schema' }, + { name: 'other', type: 'Token' } + ], + inputSchema: '#tool-schema', + other: '0.0.1' + }; + assert.deepEqual(ImportExportUtils.findAllSchemas(config), ['#tool-schema']); + }); + + it('does not descend into tool block children', () => { + const config = { + blockType: 'tool', + variables: [], + children: [{ blockType: 'x', schema: '#hidden' }] + }; + assert.deepEqual(ImportExportUtils.findAllSchemas(config), []); + }); + + it('collects schemas from globalEventsReaderBlock branches', () => { + const config = { + blockType: 'globalEventsReaderBlock', + branches: [{ schema: '#branch-1' }, { schema: '#branch-2' }, { other: true }] + }; + assert.deepEqual(ImportExportUtils.findAllSchemas(config).sort(), ['#branch-1', '#branch-2']); + }); + + it('ignores non-string schema fields', () => { + const config = { blockType: 'x', schema: { iri: '#obj' }, children: [] }; + assert.deepEqual(ImportExportUtils.findAllSchemas(config), []); + }); +}); + +describe('ImportExportUtils.findAllTokens', () => { + it('collects tokenId from regular blocks', () => { + const config = { + blockType: 'mintDocumentBlock', + tokenId: '0.0.123', + children: [{ blockType: 'x', tokenId: '0.0.456' }] + }; + assert.deepEqual(ImportExportUtils.findAllTokens(config).sort(), ['0.0.123', '0.0.456']); + }); + + it('collects Token-typed variables for module blocks', () => { + const config = { + blockType: 'module', + variables: [{ name: 'tok', type: 'Token' }], + tok: '0.0.999' + }; + assert.deepEqual(ImportExportUtils.findAllTokens(config), ['0.0.999']); + }); + + it('returns empty array when no tokens exist', () => { + assert.deepEqual(ImportExportUtils.findAllTokens({ blockType: 'x', children: [] }), []); + }); +}); + +describe('ImportExportUtils zip helpers', () => { + it('exposes a fixed deterministic date of 1980-01-01 UTC', () => { + assert.equal(ImportExportUtils.DETERMINISTIC_ZIP_DATE.toISOString(), '1980-01-01T00:00:00.000Z'); + }); + + it('getDeterministicZipFileOptions returns stable file options', () => { + const opts = ImportExportUtils.getDeterministicZipFileOptions(); + assert.equal(opts.createFolders, false); + assert.equal(opts.date.getTime(), ImportExportUtils.DETERMINISTIC_ZIP_DATE.getTime()); + assert.equal(opts.unixPermissions, 0o100644); + assert.equal(opts.dosPermissions, 0x20); + }); + + it('addDeterministicZipDir adds a directory entry to the zip', () => { + const zip = new JSZip(); + ImportExportUtils.addDeterministicZipDir(zip, 'tags'); + const entry = zip.files['tags/']; + assert.ok(entry); + assert.equal(entry.dir, true); + }); +}); diff --git a/common/tests/unit-tests/import-export/module-import-export.test.mjs b/common/tests/unit-tests/import-export/module-import-export.test.mjs new file mode 100644 index 0000000000..b6a708f316 --- /dev/null +++ b/common/tests/unit-tests/import-export/module-import-export.test.mjs @@ -0,0 +1,181 @@ +import { assert } from 'chai'; +import JSZip from 'jszip'; +import { ModuleImportExport } from '../../../dist/import-export/module.js'; + +describe('ModuleImportExport.generateZipFile', function () { + const components = { + module: { + _id: 'raw-id', + id: 'raw-id', + uuid: 'uuid-1', + messageId: 'msg-1', + status: 'PUBLISHED', + topicId: '0.0.1', + createDate: '2020-01-01', + name: 'Module 1', + description: 'desc', + config: { blockType: 'module' } + }, + schemas: [ + { _id: 'sid', id: { toString: () => 'sid' }, iri: '#schema1', name: 'S1', status: 'PUBLISHED', readonly: true } + ], + tags: [ + { _id: 'tid', id: 'tid', name: 'tag1', status: 'Published' }, + { _id: 'tid2', id: 'tid2', name: 'tag2', status: 'Draft' } + ] + }; + + it('writes module.json into the zip', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + assert.exists(zip.files['module.json']); + }); + + it('strips volatile module fields', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + const parsed = JSON.parse(await zip.files['module.json'].async('string')); + assert.isUndefined(parsed._id); + assert.isUndefined(parsed.id); + assert.isUndefined(parsed.uuid); + assert.isUndefined(parsed.messageId); + assert.isUndefined(parsed.status); + assert.isUndefined(parsed.topicId); + assert.isUndefined(parsed.createDate); + }); + + it('keeps name, description and config', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + const parsed = JSON.parse(await zip.files['module.json'].async('string')); + assert.equal(parsed.name, 'Module 1'); + assert.equal(parsed.description, 'desc'); + assert.deepEqual(parsed.config, { blockType: 'module' }); + }); + + it('does not mutate the source module', async function () { + await ModuleImportExport.generateZipFile(components); + assert.equal(components.module.uuid, 'uuid-1'); + assert.equal(components.module.status, 'PUBLISHED'); + }); + + it('writes indexed tag files with History status and stripped ids', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + assert.exists(zip.files['tags/0.json']); + assert.exists(zip.files['tags/1.json']); + const tag0 = JSON.parse(await zip.files['tags/0.json'].async('string')); + assert.isUndefined(tag0.id); + assert.isUndefined(tag0._id); + assert.equal(tag0.status, 'History'); + assert.equal(tag0.name, 'tag1'); + }); + + it('creates ipfs and schemas directories', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + assert.exists(zip.files['ipfs/']); + assert.isTrue(zip.files['ipfs/'].dir); + assert.exists(zip.files['schemas/']); + }); + + it('writes schema files keyed by iri with a stringified id', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + const schema = JSON.parse(await zip.files['schemas/#schema1.json'].async('string')); + assert.equal(schema.id, 'sid'); + assert.isUndefined(schema._id); + assert.isUndefined(schema.status); + assert.isUndefined(schema.readonly); + assert.equal(schema.iri, '#schema1'); + assert.equal(schema.name, 'S1'); + }); + + it('produces deterministic zip bytes for the same input', async function () { + const zip1 = await ModuleImportExport.generateZipFile(components); + const zip2 = await ModuleImportExport.generateZipFile(components); + const buf1 = await zip1.generateAsync({ type: 'nodebuffer' }); + const buf2 = await zip2.generateAsync({ type: 'nodebuffer' }); + assert.equal(buf1.toString('base64'), buf2.toString('base64')); + }); +}); + +describe('ModuleImportExport.parseZipFile', function () { + const components = { + module: { + _id: 'x', + id: 'x', + uuid: 'u', + messageId: 'm', + status: 's', + topicId: 't', + createDate: 'c', + name: 'Round', + description: 'trip', + config: { blockType: 'module', children: [] } + }, + schemas: [ + { _id: 'a', id: { toString: () => 'a' }, iri: '#s1', name: 'S1' }, + { _id: 'b', id: { toString: () => 'b' }, iri: '#s2', name: 'S2' } + ], + tags: [{ _id: 'q', id: 'q', name: 'tag1' }] + }; + + it('throws when module.json is missing', async function () { + const zip = new JSZip(); + zip.file('other.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + try { + await ModuleImportExport.parseZipFile(buffer); + assert.fail('expected to throw'); + } catch (error) { + assert.equal(error.message, 'Zip file is not a module'); + } + }); + + it('throws when module.json is a directory', async function () { + const zip = new JSZip(); + zip.folder('module.json'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + try { + await ModuleImportExport.parseZipFile(buffer); + assert.fail('expected to throw'); + } catch (error) { + assert.equal(error.message, 'Zip file is not a module'); + } + }); + + it('round-trips the module fields', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ModuleImportExport.parseZipFile(buffer); + assert.equal(parsed.module.name, 'Round'); + assert.equal(parsed.module.description, 'trip'); + assert.deepEqual(parsed.module.config, { blockType: 'module', children: [] }); + }); + + it('collects all tags and schemas', async function () { + const zip = await ModuleImportExport.generateZipFile(components); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ModuleImportExport.parseZipFile(buffer); + assert.lengthOf(parsed.tags, 1); + assert.equal(parsed.tags[0].name, 'tag1'); + assert.lengthOf(parsed.schemas, 2); + assert.sameMembers(parsed.schemas.map(s => s.iri), ['#s1', '#s2']); + }); + + it('ignores directory entries inside tags and schemas', async function () { + const zip = new JSZip(); + zip.file('module.json', JSON.stringify({ name: 'm' })); + zip.folder('tags'); + zip.folder('schemas'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ModuleImportExport.parseZipFile(buffer); + assert.deepEqual(parsed.tags, []); + assert.deepEqual(parsed.schemas, []); + }); + + it('parses a module-only zip', async function () { + const zip = new JSZip(); + zip.file('module.json', JSON.stringify({ name: 'solo', config: {} })); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ModuleImportExport.parseZipFile(buffer); + assert.equal(parsed.module.name, 'solo'); + assert.deepEqual(parsed.tags, []); + assert.deepEqual(parsed.schemas, []); + }); +}); diff --git a/common/tests/unit-tests/import-export/policy-label-import-export.test.mjs b/common/tests/unit-tests/import-export/policy-label-import-export.test.mjs new file mode 100644 index 0000000000..13f296247b --- /dev/null +++ b/common/tests/unit-tests/import-export/policy-label-import-export.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { PolicyLabelImportExport } from '../../../dist/import-export/policy-label.js'; + +describe('PolicyLabelImportExport zip handling', () => { + it('generateZipFile writes labels.json without identity fields', async () => { + const zip = await PolicyLabelImportExport.generateZipFile({ + label: { id: '1', _id: 'x', owner: 'o', createDate: 'c', updateDate: 'u', name: 'L' } + }); + const parsed = JSON.parse(await zip.files['labels.json'].async('string')); + assert.equal(parsed.id, undefined); + assert.equal(parsed._id, undefined); + assert.equal(parsed.owner, undefined); + assert.equal(parsed.name, 'L'); + }); + + it('generate + parseZipFile round-trips a label', async () => { + const zip = await PolicyLabelImportExport.generate({ name: 'RT', config: { children: [] } }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const { label } = await PolicyLabelImportExport.parseZipFile(buffer); + assert.equal(label.name, 'RT'); + assert.deepEqual(label.config, { children: [] }); + }); + + it('parseZipFile rejects a zip without labels.json', async () => { + const zip = new JSZip(); + zip.file('other.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(PolicyLabelImportExport.parseZipFile(buffer), /Zip file is not a rule/); + }); + + it('exposes the documented filename constant', () => { + assert.equal(PolicyLabelImportExport.fileName, 'labels.json'); + }); +}); + +describe('PolicyLabelImportExport.validateConfig', () => { + it('returns empty structures for undefined input', () => { + assert.deepEqual(PolicyLabelImportExport.validateConfig(undefined), { + imports: [], + children: [], + schemaId: '' + }); + }); + + it('drops children of unknown type', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ type: 'mystery', name: 'x' }, null] + }); + assert.deepEqual(config.children, []); + }); + + it('validates a group child recursively', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ + type: 'group', + id: 'g1', + name: 'Group', + title: 'T', + tag: 'my tag', + rule: 'r', + schemaId: '#g', + children: [{ type: 'label', id: 'l1', name: 'Inner', config: {} }] + }] + }); + const group = config.children[0]; + assert.equal(group.type, 'group'); + assert.equal(group.id, 'g1'); + assert.equal(group.tag, 'my_tag'); + assert.equal(group.children.length, 1); + assert.equal(group.children[0].type, 'label'); + assert.equal(group.children[0].name, 'Inner'); + }); + + it('validates a label child with nested config', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ + type: 'label', + id: 'l1', + name: 'L', + description: 'd', + owner: 'o', + messageId: 'm', + config: { children: [{ type: 'rules', id: 'r1', name: 'R', config: {} }] } + }] + }); + const label = config.children[0]; + assert.equal(label.type, 'label'); + assert.equal(label.config.children[0].type, 'rules'); + assert.deepEqual(label.config.imports, []); + }); + + it('validates a rules child via validateRulesConfig', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ + type: 'rules', + id: 'r1', + name: 'Rules', + config: { variables: [{ id: 'v' }], scores: [], formulas: [{ id: 'f', rule: { type: 'formula', formula: 'x' } }] } + }] + }); + const rules = config.children[0]; + assert.equal(rules.config.variables[0].id, 'v'); + assert.deepEqual(rules.config.formulas[0].rule, { type: 'formula', formula: 'x' }); + assert.equal(rules.config.rules, undefined); + }); + + it('validates a statistic child via statistic validateConfig', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ + type: 'statistic', + id: 's1', + name: 'Stat', + config: { rules: [{ schemaId: '#x', type: 'main', unique: 'true' }] } + }] + }); + const stat = config.children[0]; + assert.deepEqual(stat.config.rules, [{ schemaId: '#x', type: 'main', unique: true }]); + assert.deepEqual(stat.config.variables, []); + }); + + it('keeps label and statistic imports and drops others', () => { + const config = PolicyLabelImportExport.validateConfig({ + imports: [ + { type: 'label', id: 'i1', name: 'IL', config: {} }, + { type: 'statistic', id: 'i2', name: 'IS', config: {} }, + { type: 'group', id: 'i3' } + ] + }); + assert.equal(config.imports.length, 2); + assert.equal(config.imports[0].type, 'label'); + assert.equal(config.imports[1].type, 'statistic'); + }); + + it('normalises tags by trimming and replacing whitespace', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ type: 'group', id: 'g', tag: ' a b\tc ', children: [] }] + }); + assert.equal(config.children[0].tag, 'a_b_c'); + }); + + it('coerces non-string tag to empty string', () => { + const config = PolicyLabelImportExport.validateConfig({ + children: [{ type: 'group', id: 'g', tag: 9, children: [] }] + }); + assert.equal(config.children[0].tag, ''); + }); +}); + +describe('PolicyLabelImportExport.validateRulesConfig', () => { + it('returns empty collections for undefined input', () => { + assert.deepEqual(PolicyLabelImportExport.validateRulesConfig(undefined), { + variables: [], + scores: [], + formulas: [] + }); + }); +}); + +describe('PolicyLabelImportExport.updateSchemas', () => { + it('returns undefined when data is missing', () => { + assert.equal(PolicyLabelImportExport.updateSchemas([], undefined), undefined); + }); + + it('maps unmatched variable schema ids to undefined', () => { + const data = { + children: [{ + type: 'rules', + config: { + variables: [{ schemaId: 'old', schemaName: 'S', path: 'p', fieldDescription: 'd', fieldType: 't', fieldArray: false, fieldRef: false }], + rules: [{ schemaId: 'old' }] + } + }] + }; + const result = PolicyLabelImportExport.updateSchemas([], data); + assert.equal(result.children[0].config.variables[0].schemaId, undefined); + assert.equal(result.children[0].config.rules[0].schemaId, undefined); + }); + + it('recurses through group and label children without throwing', () => { + const data = { + children: [{ + type: 'group', + children: [{ + type: 'label', + config: { children: [{ type: 'statistic', config: { variables: [], rules: [] } }] } + }] + }] + }; + const result = PolicyLabelImportExport.updateSchemas([], data); + assert.equal(result, data); + }); +}); diff --git a/common/tests/unit-tests/import-export/policy-statistic-import-export.test.mjs b/common/tests/unit-tests/import-export/policy-statistic-import-export.test.mjs new file mode 100644 index 0000000000..388c6f7ee7 --- /dev/null +++ b/common/tests/unit-tests/import-export/policy-statistic-import-export.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { PolicyStatisticImportExport } from '../../../dist/import-export/policy-statistic.js'; + +describe('PolicyStatisticImportExport zip handling', () => { + it('generateZipFile writes statistic.json without identity fields', async () => { + const zip = await PolicyStatisticImportExport.generateZipFile({ + definition: { id: '1', _id: 'x', owner: 'o', createDate: 'c', updateDate: 'u', name: 'S' } + }); + const parsed = JSON.parse(await zip.files['statistic.json'].async('string')); + assert.equal(parsed.id, undefined); + assert.equal(parsed._id, undefined); + assert.equal(parsed.owner, undefined); + assert.equal(parsed.name, 'S'); + }); + + it('generate + parseZipFile round-trips a definition', async () => { + const zip = await PolicyStatisticImportExport.generate({ name: 'RT', config: { variables: [] } }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const { definition } = await PolicyStatisticImportExport.parseZipFile(buffer); + assert.equal(definition.name, 'RT'); + assert.deepEqual(definition.config, { variables: [] }); + }); + + it('parseZipFile rejects a zip without statistic.json', async () => { + const zip = new JSZip(); + zip.file('whatever.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(PolicyStatisticImportExport.parseZipFile(buffer), /Zip file is not a policy statistic/); + }); +}); + +describe('PolicyStatisticImportExport.validateConfig', () => { + it('returns empty collections for undefined input', () => { + assert.deepEqual(PolicyStatisticImportExport.validateConfig(undefined), { + variables: [], + scores: [], + formulas: [], + rules: [] + }); + }); + + it('normalises a full config', () => { + const config = PolicyStatisticImportExport.validateConfig({ + variables: [{ id: 'v1', schemaId: '#s', path: 'p', fieldRef: 'true', fieldArray: 1 }], + scores: [{ id: 's1', type: 'score', description: 'd', relationships: ['v1'], options: [{ description: 'o', value: 3 }] }], + formulas: [{ id: 'f1', type: 'string', description: 'd', formula: 'a' }], + rules: [{ schemaId: '#s', type: 'main', unique: true }] + }); + assert.equal(config.variables[0].id, 'v1'); + assert.equal(config.variables[0].fieldRef, true); + assert.equal(config.variables[0].fieldArray, false); + assert.deepEqual(config.scores[0].relationships, ['v1']); + assert.deepEqual(config.scores[0].options, [{ description: 'o', value: 3 }]); + assert.deepEqual(config.formulas[0], { id: 'f1', type: 'string', description: 'd', formula: 'a' }); + assert.deepEqual(config.rules[0], { schemaId: '#s', type: 'main', unique: true }); + }); +}); + +describe('PolicyStatisticImportExport.validateVariables', () => { + it('returns empty array for non-array input', () => { + assert.deepEqual(PolicyStatisticImportExport.validateVariables('bad'), []); + assert.deepEqual(PolicyStatisticImportExport.validateVariables(undefined), []); + }); + + it('coerces missing string fields to empty strings', () => { + const variables = PolicyStatisticImportExport.validateVariables([{ id: 5 }]); + assert.deepEqual(variables[0], { + id: '', + schemaId: '', + path: '', + schemaName: '', + schemaPath: '', + fieldType: '', + fieldRef: false, + fieldArray: false, + fieldDescription: '', + fieldProperty: '', + fieldPropertyName: '' + }); + }); +}); + +describe('PolicyStatisticImportExport.validateScores', () => { + it('filters non-string relationships values to empty strings', () => { + const scores = PolicyStatisticImportExport.validateScores([ + { id: 's', type: 't', description: 'd', relationships: ['ok', 7], options: 'bad' } + ]); + assert.deepEqual(scores[0].relationships, ['ok', '']); + assert.deepEqual(scores[0].options, []); + }); + + it('keeps numeric and string option values', () => { + const scores = PolicyStatisticImportExport.validateScores([ + { options: [{ description: 'a', value: 1 }, { description: 'b', value: 'x' }, { description: 'c', value: null }] } + ]); + assert.deepEqual(scores[0].options.map((o) => o.value), [1, 'x', '']); + }); +}); + +describe('PolicyStatisticImportExport.validateFormulas', () => { + it('returns empty array for non-array input', () => { + assert.deepEqual(PolicyStatisticImportExport.validateFormulas(null), []); + }); + + it('drops unknown keys and keeps known ones', () => { + const formulas = PolicyStatisticImportExport.validateFormulas([ + { id: 'f', type: 't', description: 'd', formula: 'x', stray: true } + ]); + assert.deepEqual(formulas[0], { id: 'f', type: 't', description: 'd', formula: 'x' }); + }); +}); + +describe('PolicyStatisticImportExport.validateFormulasWithRule', () => { + it('includes a validated rule', () => { + const formulas = PolicyStatisticImportExport.validateFormulasWithRule([ + { id: 'f', type: 't', description: 'd', formula: 'x', rule: { type: 'formula', formula: 'y' } } + ]); + assert.deepEqual(formulas[0].rule, { type: 'formula', formula: 'y' }); + }); + + it('sets rule to undefined when missing', () => { + const formulas = PolicyStatisticImportExport.validateFormulasWithRule([{ id: 'f' }]); + assert.equal(formulas[0].rule, undefined); + }); +}); + +describe('PolicyStatisticImportExport.validateRules', () => { + it('returns empty array for non-array input', () => { + assert.deepEqual(PolicyStatisticImportExport.validateRules({}), []); + }); + + it('coerces unique with strict true matching', () => { + const rules = PolicyStatisticImportExport.validateRules([ + { schemaId: '#a', type: 'main', unique: 'true' }, + { schemaId: '#b', type: 'related', unique: 'yes' } + ]); + assert.equal(rules[0].unique, true); + assert.equal(rules[1].unique, false); + }); +}); + +describe('PolicyStatisticImportExport.updateSchemas', () => { + it('returns undefined when data is missing', () => { + assert.equal(PolicyStatisticImportExport.updateSchemas([], undefined), undefined); + }); + + it('maps unknown schema ids to undefined with empty schema list', () => { + const data = { + variables: [{ schemaId: 'old', schemaName: 'S', path: 'p', fieldDescription: 'd', fieldType: 't', fieldArray: false, fieldRef: false }], + rules: [{ schemaId: 'old' }] + }; + const result = PolicyStatisticImportExport.updateSchemas([], data); + assert.equal(result.variables[0].schemaId, undefined); + assert.equal(result.rules[0].schemaId, undefined); + }); +}); diff --git a/common/tests/unit-tests/import-export/record-import-export.test.mjs b/common/tests/unit-tests/import-export/record-import-export.test.mjs new file mode 100644 index 0000000000..d6a1247cea --- /dev/null +++ b/common/tests/unit-tests/import-export/record-import-export.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { RecordImportExport, RecordResult } from '../../../dist/import-export/record.js'; + +describe('RecordResult', () => { + it('stores type, id and document', () => { + const result = new RecordResult('vc', 'doc-1', { a: 1 }); + assert.equal(result.type, 'vc'); + assert.equal(result.id, 'doc-1'); + assert.deepEqual(result.document, { a: 1 }); + }); + + it('derives name as base64 of type|id', () => { + const result = new RecordResult('vp', 'doc-2', {}); + assert.equal(result.name, btoa('vp|doc-2')); + }); + + it('serialises the document as file', () => { + const result = new RecordResult('schema', 's-1', { x: [1, 2] }); + assert.equal(result.file, JSON.stringify({ x: [1, 2] })); + }); + + it('from() decodes name and parses json', () => { + const original = new RecordResult('vc', 'abc', { v: true }); + const restored = RecordResult.from(original.name, original.file); + assert.equal(restored.type, 'vc'); + assert.equal(restored.id, 'abc'); + assert.deepEqual(restored.document, { v: true }); + }); + + it('fromObject() and toObject() round-trip', () => { + const obj = { id: 'i', type: 'vp', document: { d: 1 } }; + assert.deepEqual(RecordResult.fromObject(obj).toObject(), obj); + }); +}); + +describe('RecordImportExport pure helpers', () => { + it('resultLink encodes type and id under results/', () => { + const link = RecordImportExport.resultLink({ type: 'vc', id: 'doc-9' }); + assert.equal(link, `results/${btoa('vc|doc-9')}`); + }); + + it('hasSelectedOutputs is false without policyTest or outputs', () => { + assert.equal(RecordImportExport.hasSelectedOutputs({ results: [] }), false); + assert.equal(RecordImportExport.hasSelectedOutputs({ results: [], policyTest: { outputs: [] } }), false); + }); + + it('hasSelectedOutputs is true with non-empty outputs', () => { + assert.equal(RecordImportExport.hasSelectedOutputs({ results: [], policyTest: { outputs: ['x'] } }), true); + }); + + it('getComparisonResults returns all results when no outputs selected', () => { + const results = [{ type: 'vc', id: '1', document: {} }]; + assert.equal(RecordImportExport.getComparisonResults({ results }), results); + }); + + it('getComparisonResults filters by selected output links', () => { + const keep = { type: 'vc', id: 'keep', document: {} }; + const drop = { type: 'vp', id: 'drop', document: {} }; + const components = { + results: [keep, drop], + policyTest: { outputs: [RecordImportExport.resultLink(keep)] } + }; + assert.deepEqual(RecordImportExport.getComparisonResults(components), [keep]); + }); +}); + +describe('RecordImportExport.generateZipFile', () => { + it('writes a START row into actions.csv', async () => { + const zip = await RecordImportExport.generateZipFile({ + records: [{ method: 'START', time: 1000, user: 'did:user' }], + results: [], + time: 1000, + duration: 0 + }); + const csv = await zip.files['actions.csv'].async('string'); + assert.equal(csv, 'START,0,,did:user\r\n'); + }); + + it('writes ACTION rows with document references', async () => { + const zip = await RecordImportExport.generateZipFile({ + records: [{ + method: 'ACTION', + action: 'CreateUser', + time: 1500, + user: 'u1', + target: 't1', + document: { body: 1 }, + userRole: 'OWNER', + recordActionId: 'ra-1' + }], + results: [], + time: 1000, + duration: 500 + }); + const csv = await zip.files['actions.csv'].async('string'); + assert.equal(csv, 'ACTION,500,CreateUser,u1,t1,0,OWNER,ra-1\r\n'); + assert.deepEqual(JSON.parse(await zip.files['documents/0'].async('string')), { body: 1 }); + }); + + it('stores results files by encoded name', async () => { + const zip = await RecordImportExport.generateZipFile({ + records: [], + results: [{ type: 'vc', id: 'res-1', document: { ok: true } }], + time: 0, + duration: 0 + }); + const name = `results/${btoa('vc|res-1')}`; + assert.deepEqual(JSON.parse(await zip.files[name].async('string')), { ok: true }); + }); + + it('includes policy-test.json only when metadata is present', async () => { + const base = { records: [], results: [], time: 0, duration: 0 }; + const without = await RecordImportExport.generateZipFile(base, null); + assert.equal(without.files['policy-test.json'], undefined); + const withMeta = await RecordImportExport.generateZipFile(base, { name: 'Test 1' }); + assert.deepEqual(JSON.parse(await withMeta.files['policy-test.json'].async('string')), { name: 'Test 1' }); + }); +}); + +describe('RecordImportExport.generateSingleRecordZip', () => { + it('packs a single record with zero diff time', async () => { + const zip = await RecordImportExport.generateSingleRecordZip({ method: 'START', time: 5000, user: 'u' }); + const csv = await zip.files['actions.csv'].async('string'); + assert.equal(csv, 'START,0,,u\r\n'); + }); +}); + +describe('RecordImportExport.parseZipFile', () => { + it('round-trips records, documents and results', async () => { + const zip = await RecordImportExport.generateZipFile({ + records: [ + { method: 'START', time: 1000, user: 'u0' }, + { method: 'ACTION', action: 'DoThing', time: 1250, user: 'u1', target: 'tg', document: { d: 2 }, userRole: 'ROLE', recordActionId: 'ra' }, + { method: 'STOP', time: 2000 } + ], + results: [{ type: 'vp', id: 'r1', document: { r: 1 } }], + time: 1000, + duration: 1000 + }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const components = await RecordImportExport.parseZipFile(buffer); + assert.equal(components.records.length, 3); + assert.equal(components.records[0].method, 'START'); + assert.equal(components.records[1].action, 'DoThing'); + assert.equal(components.records[1].user, 'u1'); + assert.equal(components.records[1].userRole, 'ROLE'); + assert.equal(components.records[1].recordActionId, 'ra'); + assert.deepEqual(components.records[1].document, { d: 2 }); + assert.equal(components.duration, 1000); + assert.deepEqual(components.results, [{ id: 'r1', type: 'vp', document: { r: 1 } }]); + }); + + it('parses policy test metadata when present', async () => { + const zip = await RecordImportExport.generateZipFile( + { records: [], results: [], time: 0, duration: 0 }, + { name: 'meta', outputs: ['results/x'] } + ); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const components = await RecordImportExport.parseZipFile(buffer); + assert.deepEqual(components.policyTest, { name: 'meta', outputs: ['results/x'] }); + }); + + it('rejects a zip without actions.csv', async () => { + const zip = new JSZip(); + zip.file('random.txt', 'hello'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(RecordImportExport.parseZipFile(buffer), /Zip file is not a record/); + }); + + it('exposes documented filename constants', () => { + assert.equal(RecordImportExport.recordFileName, 'actions.csv'); + assert.equal(RecordImportExport.policyTestFileName, 'policy-test.json'); + }); +}); diff --git a/common/tests/unit-tests/import-export/schema-import-export.test.mjs b/common/tests/unit-tests/import-export/schema-import-export.test.mjs new file mode 100644 index 0000000000..1995f5de53 --- /dev/null +++ b/common/tests/unit-tests/import-export/schema-import-export.test.mjs @@ -0,0 +1,133 @@ +import { assert } from 'chai'; +import JSZip from 'jszip'; +import { SchemaImportExport } from '../../../dist/import-export/schema.js'; + +describe('SchemaImportExport.generateZipFile', function () { + const schemas = [ + { iri: '#schema1', name: 'S1', status: 'DRAFT' }, + { iri: '#schema2', name: 'S2', status: 'DRAFT' } + ]; + const tags = [{ name: 'tag1' }, { name: 'tag2' }]; + + it('writes one json file per schema keyed by iri', async function () { + const zip = await SchemaImportExport.generateZipFile({ schemas, tags: null }); + assert.exists(zip.files['#schema1.json']); + assert.exists(zip.files['#schema2.json']); + }); + + it('serializes the full schema object', async function () { + const zip = await SchemaImportExport.generateZipFile({ schemas, tags: null }); + const parsed = JSON.parse(await zip.files['#schema1.json'].async('string')); + assert.deepEqual(parsed, schemas[0]); + }); + + it('writes indexed tag files when tags is an array', async function () { + const zip = await SchemaImportExport.generateZipFile({ schemas, tags }); + assert.exists(zip.files['tags/']); + const tag0 = JSON.parse(await zip.files['tags/0.json'].async('string')); + const tag1 = JSON.parse(await zip.files['tags/1.json'].async('string')); + assert.equal(tag0.name, 'tag1'); + assert.equal(tag1.name, 'tag2'); + }); + + it('skips the tags directory when tags is not an array', async function () { + const zip = await SchemaImportExport.generateZipFile({ schemas, tags: null }); + assert.notExists(zip.files['tags/']); + }); + + it('skips the ipfs branch when helpers or user are missing', async function () { + const zip = await SchemaImportExport.generateZipFile({ schemas, tags, helpers: { csvGetFile: () => null } }); + assert.notExists(zip.files['ipfs/']); + }); + + it('writes ipfs document and context files for published schemas', async function () { + const calls = []; + const helpers = { + csvGetFile: async (fileId, user) => { + calls.push([fileId, user]); + return { buffer: { data: Array.from(Buffer.from(JSON.stringify({ file: fileId }))) } }; + } + }; + const user = { did: 'did:user' }; + const published = [{ + iri: '#pub', + status: 'PUBLISHED', + contentDocumentFileId: { toString: () => 'doc-id' }, + contentContextFileId: { toString: () => 'ctx-id' } + }]; + const zip = await SchemaImportExport.generateZipFile({ schemas: published, tags: [], helpers, user }); + const doc = JSON.parse(await zip.files['ipfs/#pub.document.json'].async('string')); + const ctx = JSON.parse(await zip.files['ipfs/#pub.context.json'].async('string')); + assert.deepEqual(doc, { file: 'doc-id' }); + assert.deepEqual(ctx, { file: 'ctx-id' }); + assert.deepEqual(calls, [['doc-id', user], ['ctx-id', user]]); + }); + + it('skips ipfs files for non-published schemas', async function () { + const helpers = { + csvGetFile: async () => ({ buffer: { data: [1, 2, 3] } }) + }; + const drafts = [{ + iri: '#draft', + status: 'DRAFT', + contentDocumentFileId: { toString: () => 'doc-id' }, + contentContextFileId: { toString: () => 'ctx-id' } + }]; + const zip = await SchemaImportExport.generateZipFile({ schemas: drafts, tags: [], helpers, user: { did: 'd' } }); + assert.notExists(zip.files['ipfs/#draft.document.json']); + assert.notExists(zip.files['ipfs/#draft.context.json']); + }); + + it('skips ipfs files when content file ids are missing', async function () { + const helpers = { + csvGetFile: async () => ({ buffer: { data: [1] } }) + }; + const published = [{ iri: '#pub', status: 'PUBLISHED' }]; + const zip = await SchemaImportExport.generateZipFile({ schemas: published, tags: [], helpers, user: { did: 'd' } }); + assert.exists(zip.files['ipfs/']); + assert.notExists(zip.files['ipfs/#pub.document.json']); + assert.notExists(zip.files['ipfs/#pub.context.json']); + }); +}); + +describe('SchemaImportExport.parseZipFile', function () { + it('round-trips schemas and tags', async function () { + const schemas = [{ iri: '#s1', name: 'S1' }, { iri: '#s2', name: 'S2' }]; + const tags = [{ name: 'tag1' }]; + const zip = await SchemaImportExport.generateZipFile({ schemas, tags }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await SchemaImportExport.parseZipFile(buffer); + assert.lengthOf(parsed.schemas, 2); + assert.sameMembers(parsed.schemas.map(s => s.iri), ['#s1', '#s2']); + assert.deepEqual(parsed.tags, tags); + }); + + it('ignores files under ipfs/', async function () { + const zip = new JSZip(); + zip.file('#s1.json', JSON.stringify({ iri: '#s1' })); + zip.file('ipfs/#s1.document.json', JSON.stringify({ d: 1 })); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await SchemaImportExport.parseZipFile(buffer); + assert.lengthOf(parsed.schemas, 1); + assert.equal(parsed.schemas[0].iri, '#s1'); + }); + + it('separates tag files from schema files', async function () { + const zip = new JSZip(); + zip.file('#s1.json', JSON.stringify({ iri: '#s1' })); + zip.file('tags/0.json', JSON.stringify({ name: 't' })); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await SchemaImportExport.parseZipFile(buffer); + assert.lengthOf(parsed.schemas, 1); + assert.lengthOf(parsed.tags, 1); + assert.equal(parsed.tags[0].name, 't'); + }); + + it('returns empty arrays for an empty zip', async function () { + const zip = new JSZip(); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await SchemaImportExport.parseZipFile(buffer); + assert.deepEqual(parsed.schemas, []); + assert.deepEqual(parsed.tags, []); + }); +}); diff --git a/common/tests/unit-tests/import-export/schema-rule-import-export.test.mjs b/common/tests/unit-tests/import-export/schema-rule-import-export.test.mjs new file mode 100644 index 0000000000..ab3aa2ff9d --- /dev/null +++ b/common/tests/unit-tests/import-export/schema-rule-import-export.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { SchemaRuleImportExport } from '../../../dist/import-export/schema-rule.js'; + +describe('SchemaRuleImportExport zip handling', () => { + it('generateZipFile writes rules.json without identity fields', async () => { + const rule = { id: '1', _id: 'x', owner: 'o', createDate: 'c', updateDate: 'u', name: 'R', config: { fields: [] } }; + const zip = await SchemaRuleImportExport.generateZipFile({ rule }); + const parsed = JSON.parse(await zip.files['rules.json'].async('string')); + assert.equal(parsed.id, undefined); + assert.equal(parsed._id, undefined); + assert.equal(parsed.owner, undefined); + assert.equal(parsed.name, 'R'); + }); + + it('generate + parseZipFile round-trips a rule', async () => { + const zip = await SchemaRuleImportExport.generate({ name: 'RT', config: { fields: [] } }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const { rule } = await SchemaRuleImportExport.parseZipFile(buffer); + assert.equal(rule.name, 'RT'); + assert.deepEqual(rule.config, { fields: [] }); + }); + + it('parseZipFile rejects a zip without rules.json', async () => { + const zip = new JSZip(); + zip.file('nope.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(SchemaRuleImportExport.parseZipFile(buffer), /Zip file is not a rule/); + }); +}); + +describe('SchemaRuleImportExport.validateRuleConfig', () => { + it('returns empty fields for undefined input', () => { + assert.deepEqual(SchemaRuleImportExport.validateRuleConfig(undefined), { fields: [] }); + }); + + it('returns empty fields when fields is not an array', () => { + assert.deepEqual(SchemaRuleImportExport.validateRuleConfig({ fields: 'oops' }), { fields: [] }); + }); + + it('normalises a field, coercing missing strings to empty', () => { + const config = SchemaRuleImportExport.validateRuleConfig({ + fields: [{ id: 'f1', schemaId: '#s', path: 'a.b', fieldRef: 'true', fieldArray: false }] + }); + const field = config.fields[0]; + assert.equal(field.id, 'f1'); + assert.equal(field.schemaId, '#s'); + assert.equal(field.path, 'a.b'); + assert.equal(field.schemaName, ''); + assert.equal(field.fieldType, ''); + assert.equal(field.fieldRef, true); + assert.equal(field.fieldArray, false); + assert.equal(field.rule, undefined); + }); + + it('coerces boolean-ish strings strictly', () => { + const config = SchemaRuleImportExport.validateRuleConfig({ + fields: [{ id: 'x', fieldRef: 'TRUE', fieldArray: true }] + }); + assert.equal(config.fields[0].fieldRef, false); + assert.equal(config.fields[0].fieldArray, true); + }); +}); + +describe('SchemaRuleImportExport.validateRule', () => { + it('returns undefined for missing rule', () => { + assert.equal(SchemaRuleImportExport.validateRule(undefined), undefined); + }); + + it('returns undefined for unknown rule type', () => { + assert.equal(SchemaRuleImportExport.validateRule({ type: 'mystery' }), undefined); + }); + + it('validates a formula rule', () => { + assert.deepEqual( + SchemaRuleImportExport.validateRule({ type: 'formula', formula: 'a + b', extra: 1 }), + { type: 'formula', formula: 'a + b' } + ); + }); + + it('coerces a non-string formula to empty string', () => { + assert.deepEqual( + SchemaRuleImportExport.validateRule({ type: 'formula', formula: 42 }), + { type: 'formula', formula: '' } + ); + }); + + it('validates a range rule keeping numbers and strings', () => { + assert.deepEqual( + SchemaRuleImportExport.validateRule({ type: 'range', min: 0, max: '10' }), + { type: 'range', min: 0, max: '10' } + ); + }); + + it('coerces invalid range bounds to empty strings', () => { + assert.deepEqual( + SchemaRuleImportExport.validateRule({ type: 'range', min: null, max: {} }), + { type: 'range', min: '', max: '' } + ); + }); + + it('validates a condition rule with if and else branches', () => { + const rule = SchemaRuleImportExport.validateRule({ + type: 'condition', + conditions: [ + { type: 'if', condition: { type: 'formula', formula: 'x > 1' }, formula: { type: 'formula', formula: 'y' } }, + { type: 'else', formula: { type: 'formula', formula: 'z' } } + ] + }); + assert.equal(rule.type, 'condition'); + assert.equal(rule.conditions.length, 2); + assert.deepEqual(rule.conditions[0], { + type: 'if', + condition: { type: 'formula', formula: 'x > 1' }, + formula: { type: 'formula', formula: 'y' } + }); + assert.deepEqual(rule.conditions[1], { type: 'else', formula: { type: 'formula', formula: 'z' } }); + }); + + it('drops condition entries of unknown type', () => { + const rule = SchemaRuleImportExport.validateRule({ + type: 'condition', + conditions: [{ type: 'maybe' }] + }); + assert.deepEqual(rule.conditions, []); + }); + + it('normalises non-array conditions to empty array', () => { + const rule = SchemaRuleImportExport.validateRule({ type: 'condition', conditions: 'bad' }); + assert.deepEqual(rule.conditions, []); + }); + + it('validates range condition values inside if branches', () => { + const rule = SchemaRuleImportExport.validateRule({ + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'range', variable: 'v', min: 1, max: 2 }, + formula: { type: 'text', variable: 't', value: 'val' } + }] + }); + assert.deepEqual(rule.conditions[0].condition, { type: 'range', variable: 'v', min: 1, max: 2 }); + assert.deepEqual(rule.conditions[0].formula, { type: 'text', variable: 't', value: 'val' }); + }); + + it('validates enum condition values filtering non-strings', () => { + const rule = SchemaRuleImportExport.validateRule({ + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'enum', variable: 'e', value: ['a', 5, 'b'] }, + formula: { type: 'formula', formula: 'f' } + }] + }); + assert.deepEqual(rule.conditions[0].condition, { type: 'enum', variable: 'e', value: ['a', '', 'b'] }); + }); + + it('falls back to an empty formula for missing condition values', () => { + const rule = SchemaRuleImportExport.validateRule({ + type: 'condition', + conditions: [{ type: 'if', condition: null, formula: undefined }] + }); + assert.deepEqual(rule.conditions[0].condition, { type: 'formula', formula: '' }); + assert.deepEqual(rule.conditions[0].formula, { type: 'formula', formula: '' }); + }); +}); diff --git a/common/tests/unit-tests/import-export/theme-import-export.test.mjs b/common/tests/unit-tests/import-export/theme-import-export.test.mjs new file mode 100644 index 0000000000..372dea0e3f --- /dev/null +++ b/common/tests/unit-tests/import-export/theme-import-export.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import JSZip from 'jszip'; +import { ThemeImportExport } from '../../../dist/import-export/theme.js'; + +describe('ThemeImportExport.loadThemeComponents', () => { + it('wraps the theme into a components object', async () => { + const theme = { name: 'Dark', rules: [] }; + const components = await ThemeImportExport.loadThemeComponents(theme); + assert.deepEqual(components, { theme }); + }); +}); + +describe('ThemeImportExport.generateZipFile', () => { + it('writes theme.json into the zip', async () => { + const zip = await ThemeImportExport.generateZipFile({ theme: { name: 'T', rules: [] } }); + assert.ok(zip.files['theme.json']); + }); + + it('strips id, _id, owner and dates from the packed theme', async () => { + const theme = { + id: '1', + _id: 'x', + owner: 'did:hedera:1', + createDate: 'a', + updateDate: 'b', + name: 'T', + rules: [{ text: 'r1' }] + }; + const zip = await ThemeImportExport.generateZipFile({ theme }); + const parsed = JSON.parse(await zip.files['theme.json'].async('string')); + assert.equal(parsed.id, undefined); + assert.equal(parsed._id, undefined); + assert.equal(parsed.owner, undefined); + assert.equal(parsed.createDate, undefined); + assert.equal(parsed.updateDate, undefined); + assert.equal(parsed.name, 'T'); + assert.deepEqual(parsed.rules, [{ text: 'r1' }]); + }); + + it('normalises non-array rules to an empty array', async () => { + const zip = await ThemeImportExport.generateZipFile({ theme: { name: 'T', rules: 'broken' } }); + const parsed = JSON.parse(await zip.files['theme.json'].async('string')); + assert.deepEqual(parsed.rules, []); + }); + + it('does not mutate the original theme object', async () => { + const theme = { id: 'keep', name: 'T', rules: [] }; + await ThemeImportExport.generateZipFile({ theme }); + assert.equal(theme.id, 'keep'); + }); +}); + +describe('ThemeImportExport.generate', () => { + it('produces a zip directly from a theme', async () => { + const zip = await ThemeImportExport.generate({ name: 'G', rules: [] }); + const parsed = JSON.parse(await zip.files['theme.json'].async('string')); + assert.equal(parsed.name, 'G'); + }); +}); + +describe('ThemeImportExport.parseZipFile', () => { + it('round-trips a generated theme zip', async () => { + const zip = await ThemeImportExport.generate({ name: 'Round', description: 'trip', rules: [{ text: 'r' }] }); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const { theme } = await ThemeImportExport.parseZipFile(buffer); + assert.equal(theme.name, 'Round'); + assert.equal(theme.description, 'trip'); + assert.deepEqual(theme.rules, [{ text: 'r' }]); + }); + + it('throws when theme.json is missing', async () => { + const zip = new JSZip(); + zip.file('other.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(ThemeImportExport.parseZipFile(buffer), /Zip file is not a theme/); + }); + + it('throws when theme.json is a directory', async () => { + const zip = new JSZip(); + zip.folder('theme.json'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + await assert.rejects(ThemeImportExport.parseZipFile(buffer), /Zip file is not a theme/); + }); + + it('exposes the documented filename constant', () => { + assert.equal(ThemeImportExport.themeFileName, 'theme.json'); + }); +}); diff --git a/common/tests/unit-tests/import-export/tool-import-export.test.mjs b/common/tests/unit-tests/import-export/tool-import-export.test.mjs new file mode 100644 index 0000000000..48b274bff3 --- /dev/null +++ b/common/tests/unit-tests/import-export/tool-import-export.test.mjs @@ -0,0 +1,193 @@ +import { assert } from 'chai'; +import JSZip from 'jszip'; +import { ToolImportExport } from '../../../dist/import-export/tool.js'; + +describe('ToolImportExport.generateZipFile', function () { + const components = { + tool: { + _id: 'raw-id', + id: 'raw-id', + uuid: 'uuid-1', + messageId: 'msg-1', + status: 'PUBLISHED', + topicId: '0.0.1', + createDate: '2020-01-01', + updateDate: '2020-01-02', + hash: 'hash-1', + configFileId: 'file-1', + name: 'Tool 1', + description: 'desc', + config: { blockType: 'tool' } + }, + schemas: [ + { _id: 'sid', id: { toString: () => 'sid' }, iri: '#schema1', name: 'S1', status: 'PUBLISHED', readonly: false } + ], + tags: [ + { _id: 'tid', id: 'tid', name: 'tag1', status: 'Published' } + ], + tools: [ + { name: 'SubTool', description: 'sub', messageId: 'sub-msg', creator: 'did:creator', hash: 'sub-hash', topicId: '0.0.9' } + ] + }; + + it('writes tool.json into the zip', async function () { + const zip = await ToolImportExport.generateZipFile(components); + assert.exists(zip.files['tool.json']); + }); + + it('strips volatile tool fields', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const parsed = JSON.parse(await zip.files['tool.json'].async('string')); + assert.isUndefined(parsed._id); + assert.isUndefined(parsed.id); + assert.isUndefined(parsed.uuid); + assert.isUndefined(parsed.messageId); + assert.isUndefined(parsed.status); + assert.isUndefined(parsed.topicId); + assert.isUndefined(parsed.createDate); + assert.isUndefined(parsed.updateDate); + assert.isUndefined(parsed.hash); + assert.isUndefined(parsed.configFileId); + }); + + it('keeps name, description and config', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const parsed = JSON.parse(await zip.files['tool.json'].async('string')); + assert.equal(parsed.name, 'Tool 1'); + assert.equal(parsed.description, 'desc'); + assert.deepEqual(parsed.config, { blockType: 'tool' }); + }); + + it('does not mutate the source tool', async function () { + await ToolImportExport.generateZipFile(components); + assert.equal(components.tool.hash, 'hash-1'); + assert.equal(components.tool.topicId, '0.0.1'); + }); + + it('writes indexed tag files with History status', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const tag = JSON.parse(await zip.files['tags/0.json'].async('string')); + assert.isUndefined(tag.id); + assert.isUndefined(tag._id); + assert.equal(tag.status, 'History'); + assert.equal(tag.name, 'tag1'); + }); + + it('writes schema files keyed by iri', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const schema = JSON.parse(await zip.files['schemas/#schema1.json'].async('string')); + assert.equal(schema.id, 'sid'); + assert.isUndefined(schema._id); + assert.isUndefined(schema.status); + assert.isUndefined(schema.readonly); + }); + + it('writes sub-tool descriptors keyed by hash', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const subTool = JSON.parse(await zip.files['tools/sub-hash.json'].async('string')); + assert.deepEqual(subTool, { + name: 'SubTool', + description: 'sub', + messageId: 'sub-msg', + owner: 'did:creator', + hash: 'sub-hash' + }); + }); + + it('creates the ipfs directory', async function () { + const zip = await ToolImportExport.generateZipFile(components); + assert.exists(zip.files['ipfs/']); + assert.isTrue(zip.files['ipfs/'].dir); + }); + + it('produces deterministic zip bytes for the same input', async function () { + const zip1 = await ToolImportExport.generateZipFile(components); + const zip2 = await ToolImportExport.generateZipFile(components); + const buf1 = await zip1.generateAsync({ type: 'nodebuffer' }); + const buf2 = await zip2.generateAsync({ type: 'nodebuffer' }); + assert.equal(buf1.toString('base64'), buf2.toString('base64')); + }); +}); + +describe('ToolImportExport.parseZipFile', function () { + const components = { + tool: { + _id: 'x', + id: 'x', + name: 'Round', + description: 'trip', + config: { blockType: 'tool' } + }, + schemas: [ + { _id: 'a', id: { toString: () => 'a' }, iri: '#s1', name: 'S1' } + ], + tags: [{ _id: 'q', id: 'q', name: 'tag1' }], + tools: [ + { name: 'T1', description: 'd1', messageId: 'm1', creator: 'c1', hash: 'h1' }, + { name: 'T2', description: 'd2', messageId: 'm2', creator: 'c2', hash: 'h2' } + ] + }; + + it('throws when tool.json is missing', async function () { + const zip = new JSZip(); + zip.file('module.json', '{}'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + try { + await ToolImportExport.parseZipFile(buffer); + assert.fail('expected to throw'); + } catch (error) { + assert.equal(error.message, 'Zip file is not a tool'); + } + }); + + it('throws when tool.json is a directory', async function () { + const zip = new JSZip(); + zip.folder('tool.json'); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + try { + await ToolImportExport.parseZipFile(buffer); + assert.fail('expected to throw'); + } catch (error) { + assert.equal(error.message, 'Zip file is not a tool'); + } + }); + + it('round-trips the tool fields', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ToolImportExport.parseZipFile(buffer); + assert.equal(parsed.tool.name, 'Round'); + assert.equal(parsed.tool.description, 'trip'); + assert.deepEqual(parsed.tool.config, { blockType: 'tool' }); + }); + + it('collects tags, schemas and sub-tools', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ToolImportExport.parseZipFile(buffer); + assert.lengthOf(parsed.tags, 1); + assert.lengthOf(parsed.schemas, 1); + assert.lengthOf(parsed.tools, 2); + assert.sameMembers(parsed.tools.map(t => t.messageId), ['m1', 'm2']); + }); + + it('maps sub-tool creator to owner inside the descriptor', async function () { + const zip = await ToolImportExport.generateZipFile(components); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ToolImportExport.parseZipFile(buffer); + const t1 = parsed.tools.find(t => t.hash === 'h1'); + assert.equal(t1.owner, 'c1'); + assert.isUndefined(t1.creator); + }); + + it('parses a tool-only zip', async function () { + const zip = new JSZip(); + zip.file('tool.json', JSON.stringify({ name: 'solo' })); + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const parsed = await ToolImportExport.parseZipFile(buffer); + assert.equal(parsed.tool.name, 'solo'); + assert.deepEqual(parsed.tags, []); + assert.deepEqual(parsed.schemas, []); + assert.deepEqual(parsed.tools, []); + }); +}); diff --git a/common/tests/unit-tests/insert-variables/insert-variables-edge.test.mjs b/common/tests/unit-tests/insert-variables/insert-variables-edge.test.mjs new file mode 100644 index 0000000000..1b90f1cf17 --- /dev/null +++ b/common/tests/unit-tests/insert-variables/insert-variables-edge.test.mjs @@ -0,0 +1,38 @@ +import { assert } from 'chai'; +import { insertVariables } from '../../../dist/helpers/insert-variables.js'; + +describe('insertVariables — value coercion', () => { + it('stringifies a numeric value', () => { + assert.equal(insertVariables('n=${n}', { n: 5 }), 'n=5'); + }); + + it('stringifies a boolean value', () => { + assert.equal(insertVariables('b=${b}', { b: true }), 'b=true'); + }); + + it('stringifies an object value as [object Object]', () => { + assert.equal(insertVariables('o=${o}', { o: { a: 1 } }), 'o=[object Object]'); + }); + + it('emits "null" for a present-but-null value (default only fills undefined)', () => { + assert.equal(insertVariables('x=${x}', { x: null }), 'x=null'); + }); +}); + +describe('insertVariables — placeholder syntax edges', () => { + it('resolves a key beginning with @', () => { + assert.equal(insertVariables('t=${@type}', { '@type': 'VC' }), 't=VC'); + }); + + it('leaves an empty ${} placeholder literal (needs one+ chars)', () => { + assert.equal(insertVariables('a${}b', {}), 'a${}b'); + }); + + it('substitutes adjacent placeholders', () => { + assert.equal(insertVariables('${a}${b}', { a: '1', b: '2' }), '12'); + }); + + it('treats $-sequences in the substituted value literally', () => { + assert.equal(insertVariables('v=${a}', { a: '$& and $1' }), 'v=$& and $1'); + }); +}); diff --git a/common/tests/unit-tests/insert-variables/insert-variables.test.mjs b/common/tests/unit-tests/insert-variables/insert-variables.test.mjs new file mode 100644 index 0000000000..5536cc04ea --- /dev/null +++ b/common/tests/unit-tests/insert-variables/insert-variables.test.mjs @@ -0,0 +1,48 @@ +import { assert } from 'chai'; +import { insertVariables } from '../../../dist/helpers/insert-variables.js'; + +describe('insertVariables', () => { + it('substitutes a single ${path} from a flat object', () => { + assert.equal(insertVariables('Hello ${name}', { name: 'world' }), 'Hello world'); + }); + + it('substitutes multiple placeholders in one expression', () => { + assert.equal( + insertVariables('${a} + ${b}', { a: '1', b: '2' }), + '1 + 2', + ); + }); + + it('resolves dotted paths via lodash.get', () => { + assert.equal( + insertVariables('user=${user.name}', { user: { name: 'alice' } }), + 'user=alice', + ); + }); + + it('substitutes empty string for missing paths', () => { + assert.equal(insertVariables('Hi ${user.missing}', {}), 'Hi '); + }); + + it('returns the input unchanged when no placeholder is present', () => { + assert.equal(insertVariables('plain text', {}), 'plain text'); + }); + + it('returns falsy expressions unchanged (short-circuit)', () => { + assert.equal(insertVariables('', { x: 1 }), ''); + assert.equal(insertVariables(null, { x: 1 }), null); + assert.equal(insertVariables(undefined, { x: 1 }), undefined); + }); + + it('supports array indices in dotted paths', () => { + assert.equal( + insertVariables('first=${items[0].id}', { items: [{ id: 'a' }, { id: 'b' }] }), + 'first=a', + ); + }); + + it('does not substitute placeholders containing characters outside the allowed set', () => { + // The regex is ${[A-Za-z0-9.\[\]@]+} — spaces, dashes, parens are not allowed. + assert.equal(insertVariables('${has space}', { 'has space': 'x' }), '${has space}'); + }); +}); diff --git a/common/tests/unit-tests/integrations/integration-services.test.mjs b/common/tests/unit-tests/integrations/integration-services.test.mjs new file mode 100644 index 0000000000..996d2bf9cf --- /dev/null +++ b/common/tests/unit-tests/integrations/integration-services.test.mjs @@ -0,0 +1,123 @@ +import { assert } from 'chai'; +import { BaseIntegrationService } from '../../../dist/integrations/base-integration-service.js'; +import { FIRMSService } from '../../../dist/integrations/services/firms-service.js'; +import { GlobalForestWatchService } from '../../../dist/integrations/services/global-forest-watch-service.js'; +import { KanopioService } from '../../../dist/integrations/services/kanopio-service.js'; +import { WorldBankService } from '../../../dist/integrations/services/world-bank-service.js'; + +describe('FIRMSService', () => { + let savedToken; + + before(() => { + savedToken = process.env.FIRMS_AUTH_TOKEN; + delete process.env.FIRMS_AUTH_TOKEN; + }); + + after(() => { + if (savedToken !== undefined) { + process.env.FIRMS_AUTH_TOKEN = savedToken; + } + }); + + it('throws without a token', () => { + assert.throws(() => new FIRMSService(), 'API token is required.'); + }); + + it('throws for a too short token', () => { + assert.throws(() => new FIRMSService('abc'), 'API token is required.'); + }); + + it('constructs with a valid token', () => { + const service = new FIRMSService('valid-token'); + assert.instanceOf(service, BaseIntegrationService); + }); + + it('exposes the NASA FIRMS base url', () => { + assert.equal(FIRMSService.getBaseUrl(), 'https://firms.modaps.eosdis.nasa.gov'); + }); + + it('uses firm_map_key as the secret token param', () => { + assert.equal(FIRMSService.secretTokenParamName, 'firm_map_key'); + }); + + it('declares five available methods', () => { + assert.lengthOf(Object.keys(FIRMSService.getAvailableMethods()), 5); + }); + + it('embeds the secret param into every endpoint', () => { + for (const method of Object.values(FIRMSService.getAvailableMethods())) { + assert.include(method.endpoint, ':firm_map_key'); + } + }); + + it('rejects unsupported methods with a wrapped error', async () => { + const service = new FIRMSService('valid-token'); + try { + await service.executeRequest('nope'); + assert.fail('expected rejection'); + } catch (error) { + assert.include(error.message, 'not working right now'); + } + }); +}); + +describe('GlobalForestWatchService', () => { + it('exposes the GFW base url', () => { + assert.equal(GlobalForestWatchService.getBaseUrl(), 'https://data-api.globalforestwatch.org'); + }); + + it('declares available methods with method and endpoint', () => { + const methods = GlobalForestWatchService.getAvailableMethods(); + assert.isAbove(Object.keys(methods).length, 0); + for (const method of Object.values(methods)) { + assert.isString(method.method); + assert.isString(method.endpoint); + } + }); +}); + +describe('KanopioService', () => { + it('exposes the Kanop base url', () => { + assert.equal(KanopioService.getBaseUrl(), 'https://main.api.kanop.io'); + }); + + it('declares available methods with method and endpoint', () => { + const methods = KanopioService.getAvailableMethods(); + assert.isAbove(Object.keys(methods).length, 0); + for (const method of Object.values(methods)) { + assert.isString(method.method); + assert.isString(method.endpoint); + } + }); +}); + +describe('WorldBankService', () => { + it('constructs without a token', () => { + assert.instanceOf(new WorldBankService(), BaseIntegrationService); + }); + + it('exposes the World Bank base url', () => { + assert.equal(WorldBankService.getBaseUrl(), 'https://api.worldbank.org'); + }); + + it('declares seventeen available methods', () => { + assert.lengthOf(Object.keys(WorldBankService.getAvailableMethods()), 17); + }); + + it('declares GET methods with endpoints', () => { + for (const method of Object.values(WorldBankService.getAvailableMethods())) { + assert.isString(method.method); + assert.isString(method.endpoint); + } + }); + + it('rejects unsupported method names', async () => { + const service = new WorldBankService(); + try { + await service.executeRequest('nope'); + assert.fail('expected rejection'); + } catch (error) { + assert.include(error.message, 'Unsupported method'); + } + }); +}); diff --git a/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard-contract.test.mjs b/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard-contract.test.mjs new file mode 100644 index 0000000000..6442d65b16 --- /dev/null +++ b/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard-contract.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +let lastVerifiedToken = null; +let verifyCalls = 0; +let nextVerifyResult = 'OK'; + +const { JwtServiceAuthGuard } = await esmock('../../../dist/security/jwt-service-auth.guard.js', { + '@nestjs/common': { + Injectable: () => () => {}, + CanActivate: class {}, + ExecutionContext: class {}, + ForbiddenException: class ForbiddenException extends Error { + constructor(msg) { super(msg); this.name = 'ForbiddenException'; } + }, + }, + '@nestjs/microservices': { NatsContext: class {} }, + '../../../dist/security/jwt-services-validator.js': { + JwtServicesValidator: { + async verify(token) { + verifyCalls += 1; + lastVerifiedToken = token; + if (nextVerifyResult === 'OK') return 'AUTH_SERVICE'; + throw new Error(String(nextVerifyResult)); + }, + }, + }, +}); + +function makeCtx({ type = 'rpc', subject = 'allowed.subject', token = 'svc-token', arg1 } = {}) { + const rawMsg = arg1 !== undefined ? arg1 : { + getHeaders: () => ({ get: (key) => (key === 'serviceToken' ? token : undefined) }), + }; + return { + getType: () => type, + switchToRpc: () => ({ getContext: () => ({ getSubject: () => subject }) }), + getArgByIndex: (idx) => (idx === 1 ? rawMsg : null), + }; +} + +beforeEach(() => { lastVerifiedToken = null; verifyCalls = 0; nextVerifyResult = 'OK'; }); + +describe('@unit @security JwtServiceAuthGuard contract', () => { + describe('non-rpc short-circuit', () => { + it('returns true for http context without touching ACL or token', async () => { + const guard = new JwtServiceAuthGuard([]); + const result = await guard.canActivate(makeCtx({ type: 'http', subject: 'x' })); + assert.equal(result, true); + assert.equal(verifyCalls, 0); + assert.equal(lastVerifiedToken, null); + }); + + it('returns true for ws context without touching ACL or token', async () => { + const guard = new JwtServiceAuthGuard([]); + const result = await guard.canActivate(makeCtx({ type: 'ws', subject: 'x' })); + assert.equal(result, true); + assert.equal(verifyCalls, 0); + }); + + it('returns true for graphql context regardless of subject', async () => { + const guard = new JwtServiceAuthGuard(['only.this']); + const result = await guard.canActivate(makeCtx({ type: 'graphql', subject: 'forbidden' })); + assert.equal(result, true); + assert.equal(verifyCalls, 0); + }); + }); + + describe('ACL enforcement', () => { + it('throws ForbiddenException with the exact NATS ACL message for disallowed subject', async () => { + const guard = new JwtServiceAuthGuard(['allowed.A']); + await assert.rejects( + () => guard.canActivate(makeCtx({ subject: 'forbidden.B' })), + (err) => { + assert.equal(err.name, 'ForbiddenException'); + assert.equal(err.message, 'NATS ACL: "forbidden.B" not allowed'); + return true; + } + ); + assert.equal(verifyCalls, 0); + }); + + it('does not call verify when the subject is rejected', async () => { + const guard = new JwtServiceAuthGuard(['x']); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'y' }))); + assert.equal(verifyCalls, 0); + }); + + it('allows any of multiple whitelisted commands', async () => { + const guard = new JwtServiceAuthGuard(['cmd.one', 'cmd.two', 'cmd.three']); + assert.equal(await guard.canActivate(makeCtx({ subject: 'cmd.one' })), true); + assert.equal(await guard.canActivate(makeCtx({ subject: 'cmd.two' })), true); + assert.equal(await guard.canActivate(makeCtx({ subject: 'cmd.three' })), true); + assert.equal(verifyCalls, 3); + }); + + it('rejects a near-miss subject (substring is not membership)', async () => { + const guard = new JwtServiceAuthGuard(['get.user.profile']); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'get.user' }))); + }); + }); + + describe('token extraction + verification', () => { + it('returns true for allowed subject + valid token', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const result = await guard.canActivate(makeCtx({ subject: 'ok.cmd', token: 'good' })); + assert.equal(result, true); + assert.equal(lastVerifiedToken, 'good'); + assert.equal(verifyCalls, 1); + }); + + it('propagates a verify rejection for a garbage token', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + nextVerifyResult = 'Service validator: invalid or expired token'; + await assert.rejects( + () => guard.canActivate(makeCtx({ subject: 'ok.cmd', token: 'garbage' })), + /invalid or expired/ + ); + assert.equal(lastVerifiedToken, 'garbage'); + }); + + it('passes undefined to verify when serviceToken header is absent', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const arg1 = { getHeaders: () => ({ get: () => undefined }) }; + const result = await guard.canActivate(makeCtx({ subject: 'ok.cmd', arg1 })); + assert.equal(result, true); + assert.equal(lastVerifiedToken, undefined); + }); + + it('passes undefined to verify when getArgByIndex(1) is null (optional chaining)', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const ctx = { + getType: () => 'rpc', + switchToRpc: () => ({ getContext: () => ({ getSubject: () => 'ok.cmd' }) }), + getArgByIndex: () => null, + }; + const result = await guard.canActivate(ctx); + assert.equal(result, true); + assert.equal(lastVerifiedToken, undefined); + }); + + it('passes undefined to verify when the message has no getHeaders method', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const result = await guard.canActivate(makeCtx({ subject: 'ok.cmd', arg1: {} })); + assert.equal(result, true); + assert.equal(lastVerifiedToken, undefined); + }); + + it('passes undefined to verify when getHeaders() returns null', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const arg1 = { getHeaders: () => null }; + const result = await guard.canActivate(makeCtx({ subject: 'ok.cmd', arg1 })); + assert.equal(result, true); + assert.equal(lastVerifiedToken, undefined); + }); + + it('reads the token from the serviceToken header key specifically', async () => { + const guard = new JwtServiceAuthGuard(['ok.cmd']); + const arg1 = { + getHeaders: () => ({ get: (k) => (k === 'serviceToken' ? 'the-right-one' : 'wrong') }), + }; + await guard.canActivate(makeCtx({ subject: 'ok.cmd', arg1 })); + assert.equal(lastVerifiedToken, 'the-right-one'); + }); + }); + + describe('empty ACL', () => { + it('rejects every rpc subject when allowedCommands is empty', async () => { + const guard = new JwtServiceAuthGuard([]); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'anything' }))); + assert.equal(verifyCalls, 0); + }); + }); +}); diff --git a/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard.test.mjs b/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard.test.mjs new file mode 100644 index 0000000000..ec53d8a28d --- /dev/null +++ b/common/tests/unit-tests/jwt-service-auth-guard/jwt-service-auth-guard.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +let lastVerifiedToken = null; +let nextVerifyResult = 'OK'; + +const { JwtServiceAuthGuard } = await esmock('../../../dist/security/jwt-service-auth.guard.js', { + '@nestjs/common': { + Injectable: () => () => {}, + CanActivate: class {}, + ExecutionContext: class {}, + ForbiddenException: class ForbiddenException extends Error { + constructor(msg) { super(msg); this.name = 'ForbiddenException'; } + }, + }, + '@nestjs/microservices': { NatsContext: class {} }, + '../../../dist/security/jwt-services-validator.js': { + JwtServicesValidator: { + async verify(token) { + lastVerifiedToken = token; + if (nextVerifyResult === 'OK') return 'AUTH_SERVICE'; + throw new Error(String(nextVerifyResult)); + }, + }, + }, +}); + +function makeCtx({ type = 'rpc', subject = 'allowed.subject', token = 'svc-token' } = {}) { + const rawMsg = { + getHeaders: () => ({ get: (key) => key === 'serviceToken' ? token : undefined }), + }; + return { + getType: () => type, + switchToRpc: () => ({ + getContext: () => ({ getSubject: () => subject }), + }), + getArgByIndex: (idx) => idx === 1 ? rawMsg : null, + }; +} + +beforeEach(() => { lastVerifiedToken = null; nextVerifyResult = 'OK'; }); + +describe('@unit @security JwtServiceAuthGuard.canActivate', () => { + it('returns true for non-RPC contexts without checking ACL or token', async () => { + const guard = new JwtServiceAuthGuard(['allowed']); + const ctx = makeCtx({ type: 'http' }); + const result = await guard.canActivate(ctx); + assert.equal(result, true); + assert.equal(lastVerifiedToken, null); + }); + + it('returns true for whitelisted RPC subject with valid serviceToken', async () => { + const guard = new JwtServiceAuthGuard(['my.allowed.command']); + const ctx = makeCtx({ subject: 'my.allowed.command', token: 'good-token' }); + const result = await guard.canActivate(ctx); + assert.equal(result, true); + assert.equal(lastVerifiedToken, 'good-token'); + }); + + it('throws ForbiddenException for subjects outside the ACL', async () => { + const guard = new JwtServiceAuthGuard(['allowed.A']); + const ctx = makeCtx({ subject: 'forbidden.B' }); + await assert.rejects(() => guard.canActivate(ctx), /forbidden\.B.*not allowed/); + assert.equal(lastVerifiedToken, null); + }); + + it('propagates verify rejection', async () => { + const guard = new JwtServiceAuthGuard(['x']); + nextVerifyResult = 'invalid or expired token'; + const ctx = makeCtx({ subject: 'x' }); + await assert.rejects(() => guard.canActivate(ctx), /invalid or expired/); + }); + + it('extracts the token from the serviceToken header specifically', async () => { + const guard = new JwtServiceAuthGuard(['x']); + const ctx = makeCtx({ subject: 'x', token: 'header-specific-token' }); + await guard.canActivate(ctx); + assert.equal(lastVerifiedToken, 'header-specific-token'); + }); + + it('passes undefined token to verify when message is missing', async () => { + const guard = new JwtServiceAuthGuard(['x']); + const ctx = { + getType: () => 'rpc', + switchToRpc: () => ({ getContext: () => ({ getSubject: () => 'x' }) }), + getArgByIndex: () => null, + }; + await guard.canActivate(ctx); + assert.equal(lastVerifiedToken, undefined); + }); + + it('empty allowedCommands array rejects everything', async () => { + const guard = new JwtServiceAuthGuard([]); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'anything' }))); + }); + + it('case-sensitive subject matching', async () => { + const guard = new JwtServiceAuthGuard(['get.user']); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'Get.User' }))); + await assert.rejects(() => guard.canActivate(makeCtx({ subject: 'GET.USER' }))); + }); +}); diff --git a/common/tests/unit-tests/memo-mappings/memo-mappings.test.mjs b/common/tests/unit-tests/memo-mappings/memo-mappings.test.mjs new file mode 100644 index 0000000000..732ce0f78e --- /dev/null +++ b/common/tests/unit-tests/memo-mappings/memo-mappings.test.mjs @@ -0,0 +1,94 @@ +import { assert } from 'chai'; +import { MemoMap } from '../../../dist/hedera-modules/memo-mappings/memo-map.js'; +import { TopicMemo } from '../../../dist/hedera-modules/memo-mappings/topic-memo.js'; +import { MessageMemo } from '../../../dist/hedera-modules/memo-mappings/message-memo.js'; + +describe('MemoMap.parseMemo', () => { + it('substitutes ${path} placeholders from the supplied object', () => { + const out = MemoMap.parseMemo(true, 'Hello ${name}', { name: 'world' }); + assert.equal(out, 'Hello world'); + }); + + it('returns "" when memo is empty and safetyParse=true', () => { + assert.equal(MemoMap.parseMemo(true, ''), ''); + }); + + it('throws when memo is empty and safetyParse=false', () => { + assert.throws(() => MemoMap.parseMemo(false, ''), /Memo string is empty/); + }); + + it("throws on undefined parameters when safetyParse=false", () => { + assert.throws( + () => MemoMap.parseMemo(false, '${missing}', {}), + /Parameter missing in memo object is not defined/, + ); + }); + + it('substitutes "" for missing parameters when safetyParse=true', () => { + assert.equal(MemoMap.parseMemo(true, 'A ${x} B', {}), 'A B'); + }); + + it('resolves dotted paths via lodash.get', () => { + assert.equal( + MemoMap.parseMemo(true, '${user.name}', { user: { name: 'alice' } }), + 'alice', + ); + }); + + it('returns the literal text when no placeholder is present', () => { + assert.equal(MemoMap.parseMemo(true, 'plain'), 'plain'); + }); +}); + +describe('TopicMemo.getTopicMemo', () => { + it('maps known topic types to their canonical memo', () => { + assert.equal(TopicMemo.getTopicMemo({ type: 'USER_TOPIC' }), 'Standard Registry organization topic'); + assert.equal(TopicMemo.getTopicMemo({ type: 'POLICY_TOPIC' }), 'Policy development topic'); + assert.equal(TopicMemo.getTopicMemo({ type: 'TOKEN_TOPIC' }), 'Token topic'); + }); + + it('substitutes ${name} for DynamicTopic when supplied', () => { + assert.equal( + TopicMemo.getTopicMemo({ type: 'DYNAMIC_TOPIC', name: 'foo' }), + 'foo operation topic', + ); + }); + + it('falls back to the dynamic topic default when name is missing', () => { + assert.equal( + TopicMemo.getTopicMemo({ type: 'DYNAMIC_TOPIC' }), + 'Policy operation topic', + ); + }); + + it('returns "" for unknown topic types', () => { + assert.equal(TopicMemo.getTopicMemo({ type: 'NEVER_HEARD_OF_IT' }), ''); + }); + + it('exposes the global topic memo via getGlobalTopicMemo', () => { + assert.equal(TopicMemo.getGlobalTopicMemo(), 'Standard Registries initialization topic'); + }); +}); + +describe('MessageMemo.getMessageMemo', () => { + it('returns the static message for ChangeMessageStatus action', () => { + assert.equal( + MessageMemo.getMessageMemo({ type: 'X', action: 'change-message-status' }), + 'Status change message', + ); + }); + + it('returns the static message for RevokeDocument action', () => { + assert.equal( + MessageMemo.getMessageMemo({ type: 'X', action: 'revoke-document' }), + 'Revoke document message', + ); + }); + + it('returns "" for unknown action+type combinations', () => { + assert.equal( + MessageMemo.getMessageMemo({ type: 'Mystery', action: 'mystery-action' }), + '', + ); + }); +}); diff --git a/common/tests/unit-tests/message-action-type/message-action-type.test.mjs b/common/tests/unit-tests/message-action-type/message-action-type.test.mjs new file mode 100644 index 0000000000..be65328dff --- /dev/null +++ b/common/tests/unit-tests/message-action-type/message-action-type.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; + +describe('@unit MessageAction enum', () => { + it('contains the documented core actions', () => { + const keys = Object.keys(MessageAction); + for (const expected of ['CreateDID', 'CreateVC', 'PublishPolicy', 'CreateTopic', 'Mint', 'RevokeDocument']) { + assert.ok(keys.includes(expected), `expected MessageAction.${expected}`); + } + }); + + it('values are kebab-case strings (except Init)', () => { + for (const [key, value] of Object.entries(MessageAction)) { + if (key === 'Init') continue; + assert.equal(typeof value, 'string'); + assert.match(value, /^[a-z][a-z0-9-]*$/, `${key} = "${value}" should be kebab-case`); + } + }); + + it('every value is unique', () => { + const values = Object.values(MessageAction); + assert.equal(values.length, new Set(values).size, + `Duplicate MessageAction values would collide on-chain. Values: ${values.join(',')}`); + }); + + it('PublishPolicy === "publish-policy" (immutable on-chain)', () => { + assert.equal(MessageAction.PublishPolicy, 'publish-policy'); + }); + + it('CreateDID === "create-did-document"', () => { + assert.equal(MessageAction.CreateDID, 'create-did-document'); + }); + + it('Mint === "mint"', () => { + assert.equal(MessageAction.Mint, 'mint'); + }); +}); + +describe('@unit MessageType enum', () => { + it('contains the core types', () => { + const keys = Object.keys(MessageType); + for (const expected of ['VCDocument', 'VPDocument', 'DIDDocument', 'Policy', 'Schema', 'Token', 'StandardRegistry']) { + assert.ok(keys.includes(expected)); + } + }); + + it('values are unique', () => { + const values = Object.values(MessageType); + assert.equal(values.length, new Set(values).size); + }); + + it('StandardRegistry === "Standard Registry" (legacy on-chain format)', () => { + assert.equal(MessageType.StandardRegistry, 'Standard Registry'); + }); + + it('Synchronization === "Synchronization Event"', () => { + assert.equal(MessageType.Synchronization, 'Synchronization Event'); + }); + + it('every value is a non-empty string', () => { + for (const v of Object.values(MessageType)) { + assert.equal(typeof v, 'string'); + assert.ok(v.length > 0); + } + }); +}); diff --git a/common/tests/unit-tests/misc/base-entity.test.mjs b/common/tests/unit-tests/misc/base-entity.test.mjs new file mode 100644 index 0000000000..a0f93e87fc --- /dev/null +++ b/common/tests/unit-tests/misc/base-entity.test.mjs @@ -0,0 +1,119 @@ +import { assert } from 'chai'; +import { BaseEntity } from '../../../dist/models/base-entity.js'; + +class TestEntity extends BaseEntity {} + +describe('BaseEntity field defaults', () => { + it('createDate is initialised to a Date close to now', () => { + const before = Date.now(); + const e = new TestEntity(); + const after = Date.now(); + assert.instanceOf(e.createDate, Date); + const t = e.createDate.getTime(); + assert.isAtLeast(t, before - 5); + assert.isAtMost(t, after + 5); + }); + + it('updateDate is initialised to a Date close to now', () => { + const e = new TestEntity(); + assert.instanceOf(e.updateDate, Date); + }); + + it('tenantId, guardianId, _guardianId are unset until assigned', () => { + const e = new TestEntity(); + assert.equal(e.tenantId, undefined); + assert.equal(e.guardianId, undefined); + assert.equal(e._guardianId, undefined); + }); +}); + +describe('BaseEntity.toJSON', () => { + it('returns a shallow copy that includes the id field', () => { + const e = new TestEntity(); + e.id = 'string-id'; + e.tenantId = 't1'; + e.guardianId = 'g1'; + const json = e.toJSON(); + assert.equal(json.id, 'string-id'); + assert.equal(json.tenantId, 't1'); + assert.equal(json.guardianId, 'g1'); + assert.notStrictEqual(json, e, 'should be a fresh object, not the entity itself'); + }); + + it('includes own-enumerable properties added on the instance', () => { + const e = new TestEntity(); + e.customField = 'extra'; + const json = e.toJSON(); + assert.equal(json.customField, 'extra'); + }); +}); + +describe('BaseEntity __onBaseCreate / __onBaseUpdate hooks', () => { + it('__onBaseCreate sets createDate and updateDate to the same Date', () => { + const e = new TestEntity(); + e.__onBaseCreate(); + assert.instanceOf(e.createDate, Date); + assert.instanceOf(e.updateDate, Date); + // After __onBaseCreate they should be the exact same reference + assert.strictEqual(e.createDate, e.updateDate); + }); + + it('__onBaseUpdate moves updateDate forward without touching createDate', async () => { + const e = new TestEntity(); + e.__onBaseCreate(); + const original = e.createDate; + // Wait one tick so the new Date() is strictly later + await new Promise((r) => setTimeout(r, 10)); + e.__onBaseUpdate(); + assert.strictEqual(e.createDate, original, 'createDate should not move'); + assert.isAbove(e.updateDate.getTime(), original.getTime(), 'updateDate should advance'); + }); +}); + +describe('BaseEntity._createFieldCache (protected)', () => { + class CacheTester extends BaseEntity { + cache(document, fields) { + return this._createFieldCache(document, fields); + } + } + + it('returns null when fields list is null/undefined', () => { + const t = new CacheTester(); + assert.isNull(t.cache({ x: 1 }, null)); + assert.isNull(t.cache({ x: 1 }, undefined)); + }); + + it('copies numeric fields into the cache via dotted paths', () => { + const t = new CacheTester(); + const doc = { a: 1, nested: { b: 2 } }; + const out = t.cache(doc, ['a', 'nested.b']); + assert.deepEqual(out, { a: 1, nested: { b: 2 } }); + }); + + it('copies short string fields (under 100 chars by default)', () => { + const t = new CacheTester(); + const doc = { name: 'short' }; + const out = t.cache(doc, ['name']); + assert.equal(out.name, 'short'); + }); + + it('skips long string fields (over the default 100-char limit)', () => { + const t = new CacheTester(); + const doc = { big: 'x'.repeat(200) }; + const out = t.cache(doc, ['big']); + assert.notProperty(out, 'big'); + }); + + it('skips fields that are objects, arrays, booleans, null', () => { + const t = new CacheTester(); + const doc = { obj: { a: 1 }, arr: [1, 2], flag: true, nada: null }; + const out = t.cache(doc, ['obj', 'arr', 'flag', 'nada']); + assert.deepEqual(out, {}); + }); + + it('skips missing fields', () => { + const t = new CacheTester(); + const out = t.cache({ a: 1 }, ['missing']); + assert.deepEqual(out, {}); + }); +}); diff --git a/common/tests/unit-tests/misc/console-transport.test.mjs b/common/tests/unit-tests/misc/console-transport.test.mjs new file mode 100644 index 0000000000..b3102dbec9 --- /dev/null +++ b/common/tests/unit-tests/misc/console-transport.test.mjs @@ -0,0 +1,84 @@ +import { assert } from 'chai'; +import { ConsoleTransport } from '../../../dist/helpers/console-transport.js'; + +function withCapturedConsole(fn) { + const original = { + info: console.info, + warn: console.warn, + error: console.error, + log: console.log, + }; + const captured = { info: [], warn: [], error: [], log: [] }; + console.info = (...args) => { captured.info.push(args); }; + console.warn = (...args) => { captured.warn.push(args); }; + console.error = (...args) => { captured.error.push(args); }; + console.log = (...args) => { captured.log.push(args); }; + try { + fn(captured); + } finally { + console.info = original.info; + console.warn = original.warn; + console.error = original.error; + console.log = original.log; + } + return captured; +} + +describe('ConsoleTransport.log', () => { + let transport; + let cb; + + beforeEach(() => { + transport = new ConsoleTransport({}); + cb = () => {}; + }); + + it('routes type=INFO entries to console.info', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'INFO', message: 'hi', attributes: ['svc'] }, cb); + }); + assert.equal(captured.info.length, 1); + assert.equal(captured.warn.length, 0); + assert.equal(captured.error.length, 0); + }); + + it('routes type=WARN entries to console.warn', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'WARN', message: 'careful', attributes: ['svc'] }, cb); + }); + assert.equal(captured.warn.length, 1); + }); + + it('routes type=ERROR entries to console.error', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'ERROR', message: 'broke', attributes: ['svc'] }, cb); + }); + assert.equal(captured.error.length, 1); + }); + + it('falls back to console.log for unknown types', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'WHATEVER', message: 'misc', attributes: ['x'] }, cb); + }); + assert.equal(captured.log.length, 1); + }); + + it('formats the leading prefix as " [attributes]:" and passes message as second arg', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'INFO', message: 'payload', attributes: ['a', 'b'] }, cb); + }); + const [prefix, message] = captured.info[0]; + assert.match(prefix, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + assert.match(prefix, /\[a,b\]:/); + assert.equal(message, 'payload'); + }); + + it('handles missing attributes gracefully (renders the empty bracket)', () => { + const captured = withCapturedConsole(() => { + transport.log({ type: 'INFO', message: 'x' }, cb); + }); + const [prefix] = captured.info[0]; + // attributes?.join(',') yields undefined → "[undefined]:" in format + assert.match(prefix, /\[(undefined|)\]:/); + }); +}); diff --git a/common/tests/unit-tests/misc/context-helper.test.mjs b/common/tests/unit-tests/misc/context-helper.test.mjs new file mode 100644 index 0000000000..7a88fa55ce --- /dev/null +++ b/common/tests/unit-tests/misc/context-helper.test.mjs @@ -0,0 +1,122 @@ +import { assert } from 'chai'; +import { ContextHelper } from '../../../dist/hedera-modules/vcjs/context-helper.js'; + +describe('ContextHelper.clearEmptyProperties', () => { + it('removes top-level null and undefined keys', () => { + const vc = { a: 1, b: null, c: undefined, d: 'x' }; + const out = ContextHelper.clearEmptyProperties(vc); + assert.deepEqual(out, { a: 1, d: 'x' }); + }); + + it('removes nested null/undefined recursively', () => { + const vc = { a: { b: null, c: { d: undefined, e: 'x' } } }; + const out = ContextHelper.clearEmptyProperties(vc); + assert.deepEqual(out, { a: { c: { e: 'x' } } }); + }); + + it('returns null/undefined unchanged for falsy roots', () => { + assert.equal(ContextHelper.clearEmptyProperties(null), null); + assert.equal(ContextHelper.clearEmptyProperties(undefined), undefined); + }); + + it('returns scalar inputs unchanged', () => { + assert.equal(ContextHelper.clearEmptyProperties('hello'), 'hello'); + assert.equal(ContextHelper.clearEmptyProperties(42), 42); + assert.equal(ContextHelper.clearEmptyProperties(false), false); + }); + + it('descends into arrays and clears their object entries in place', () => { + const vc = { items: [{ a: null, b: 'keep' }, { c: undefined, d: 'also' }] }; + ContextHelper.clearEmptyProperties(vc); + assert.deepEqual(vc.items[0], { b: 'keep' }); + assert.deepEqual(vc.items[1], { d: 'also' }); + }); + + it('preserves falsy non-null values: 0, "", false', () => { + const vc = { zero: 0, empty: '', flag: false }; + const out = ContextHelper.clearEmptyProperties(vc); + assert.deepEqual(out, { zero: 0, empty: '', flag: false }); + }); +}); + +describe('ContextHelper.clearContext', () => { + it('lifts @context arrays from credentialSubject up to its top-level @context', () => { + const vc = { + credentialSubject: { + type: 'CustomType', + '@context': ['https://example.org/ctx'], + name: 'alice', + }, + }; + ContextHelper.clearContext(vc); + assert.deepEqual(vc.credentialSubject['@context'], ['https://example.org/ctx']); + assert.equal(vc.credentialSubject.name, 'alice'); + // The original `type` is preserved + assert.equal(vc.credentialSubject.type, 'CustomType'); + }); + + it('strips non-recognised type fields from nested objects', () => { + const vc = { + credentialSubject: { + type: 'KnownType', + inner: { + type: 'NonGeoJSONUnknownType', + value: 1, + }, + }, + }; + ContextHelper.clearContext(vc); + // Top-level type is preserved + assert.equal(vc.credentialSubject.type, 'KnownType'); + // Nested unknown type is stripped + assert.notProperty(vc.credentialSubject.inner, 'type'); + assert.equal(vc.credentialSubject.inner.value, 1); + }); + + it('preserves nested known geo type fields like Point and Polygon', () => { + const vc = { + credentialSubject: { + type: 'KnownType', + location: { type: 'Point', coordinates: [1, 2] }, + area: { type: 'Polygon', coordinates: [] }, + }, + }; + ContextHelper.clearContext(vc); + assert.equal(vc.credentialSubject.location.type, 'Point'); + assert.equal(vc.credentialSubject.area.type, 'Polygon'); + }); + + it('handles a credentialSubject array (each element gets its own contexts)', () => { + const vc = { + credentialSubject: [ + { + type: 'A', + '@context': 'https://a.example/ctx', + name: 'alice', + }, + { + type: 'B', + '@context': 'https://b.example/ctx', + name: 'bob', + }, + ], + }; + ContextHelper.clearContext(vc); + assert.deepEqual(vc.credentialSubject[0]['@context'], ['https://a.example/ctx']); + assert.deepEqual(vc.credentialSubject[1]['@context'], ['https://b.example/ctx']); + assert.equal(vc.credentialSubject[0].type, 'A'); + assert.equal(vc.credentialSubject[1].type, 'B'); + }); + + it('returns the input vc reference (mutates in place)', () => { + const vc = { credentialSubject: { type: 'X', name: 'y' } }; + const out = ContextHelper.clearContext(vc); + assert.strictEqual(out, vc); + }); + + it('handles a vc with no credentialSubject (no-op)', () => { + const vc = { someOtherField: 'x' }; + ContextHelper.clearContext(vc); + assert.equal(vc.someOtherField, 'x'); + }); +}); diff --git a/common/tests/unit-tests/misc/db-naming-strategy.test.mjs b/common/tests/unit-tests/misc/db-naming-strategy.test.mjs new file mode 100644 index 0000000000..f4efa4042e --- /dev/null +++ b/common/tests/unit-tests/misc/db-naming-strategy.test.mjs @@ -0,0 +1,40 @@ +import { assert } from 'chai'; +import { DataBaseNamingStrategy } from '../../../dist/helpers/db-naming-strategy.js'; + +describe('DataBaseNamingStrategy.classToTableName', () => { + let strategy; + before(() => { + strategy = new DataBaseNamingStrategy(); + }); + + it('snake-cases a CamelCase entity name', () => { + assert.equal(strategy.classToTableName('Notification'), 'notification'); + assert.equal(strategy.classToTableName('PolicyMessage'), 'policy_message'); + assert.equal(strategy.classToTableName('VcDocument'), 'vc_document'); + }); + + it('handles long multi-word names', () => { + assert.equal(strategy.classToTableName('ManagedGuardianService'), 'managed_guardian_service'); + assert.equal(strategy.classToTableName('IpfsContentPriorityQueueItem'), 'ipfs_content_priority_queue_item'); + }); + + it('lowercases an already-lowercase name', () => { + assert.equal(strategy.classToTableName('user'), 'user'); + }); + + it('lowercases an all-uppercase name', () => { + assert.equal(strategy.classToTableName('ABC'), 'abc'); + }); + + it('does not insert an underscore at the leading boundary', () => { + // Pattern is `([a-z])([A-Z])` so the first cap of "Foo" has no preceding lowercase. + assert.equal(strategy.classToTableName('FooBar'), 'foo_bar'); + assert.notMatch(strategy.classToTableName('FooBar'), /^_/); + }); + + it('does not insert a separator between a digit and a following capital (digits are not [a-z])', () => { + // Documented quirk: the boundary regex only fires between lowercase letter and uppercase letter, + // so "Topic1Listener" lower-cases to "topic1listener" — no underscore between "1" and "Listener". + assert.equal(strategy.classToTableName('Topic1Listener'), 'topic1listener'); + }); +}); diff --git a/common/tests/unit-tests/misc/dictionary.test.mjs b/common/tests/unit-tests/misc/dictionary.test.mjs new file mode 100644 index 0000000000..611715db40 --- /dev/null +++ b/common/tests/unit-tests/misc/dictionary.test.mjs @@ -0,0 +1,70 @@ +import { assert } from 'chai'; +import { Dictionary, FieldTypes } from '../../../dist/xlsx/models/dictionary.js'; + +describe('Dictionary enum (xlsx column labels)', () => { + it('exposes the canonical column labels', () => { + assert.equal(Dictionary.REQUIRED_FIELD, 'Required Field'); + assert.equal(Dictionary.FIELD_TYPE, 'Field Type'); + assert.equal(Dictionary.PARAMETER, 'Parameter'); + assert.equal(Dictionary.QUESTION, 'Question'); + assert.equal(Dictionary.ANSWER, 'Answer'); + assert.equal(Dictionary.SCHEMA_NAME, 'Schema'); + assert.equal(Dictionary.SCHEMA_TOOL_ID, 'Tool Id'); + assert.equal(Dictionary.ENUM_IPFS, 'Loaded to IPFS'); + }); + + it('all values are non-empty strings', () => { + for (const v of Object.values(Dictionary)) { + assert.equal(typeof v, 'string'); + assert.isAbove(v.length, 0); + } + }); + + it('all values are unique', () => { + const values = Object.values(Dictionary); + assert.equal(new Set(values).size, values.length); + }); +}); + +describe('FieldTypes.default', () => { + it('exposes a non-empty list of registered field types', () => { + assert.isArray(FieldTypes.default); + assert.isAbove(FieldTypes.default.length, 0); + }); + + it('includes the standard scalar types: Number, Integer, String', () => { + const names = FieldTypes.default.map((f) => f.name); + assert.include(names, 'Number'); + assert.include(names, 'Integer'); + assert.include(names, 'String'); + }); + + it('all entries expose a name and a type', () => { + for (const f of FieldTypes.default) { + assert.isString(f.name); + assert.isString(f.type); + } + }); + + it('Number.pars parses numeric strings, returns "" for NaN', () => { + const number = FieldTypes.default.find((f) => f.name === 'Number'); + assert.equal(number.pars('42.5'), 42.5); + assert.equal(number.pars(7), 7); + assert.equal(number.pars('not-a-number'), ''); + }); + + it('Integer.pars accepts integers, rejects fractional values with ""', () => { + const integer = FieldTypes.default.find((f) => f.name === 'Integer'); + assert.equal(integer.pars('10'), 10); + assert.equal(integer.pars(7), 7); + assert.equal(integer.pars('1.5'), ''); + assert.equal(integer.pars('abc'), ''); + }); + + it('String.pars coerces any input to a string', () => { + const str = FieldTypes.default.find((f) => f.name === 'String'); + assert.equal(str.pars(123), '123'); + assert.equal(str.pars(true), 'true'); + assert.equal(str.pars(null), 'null'); + }); +}); diff --git a/common/tests/unit-tests/misc/do-nothing.test.mjs b/common/tests/unit-tests/misc/do-nothing.test.mjs new file mode 100644 index 0000000000..040fb55dfe --- /dev/null +++ b/common/tests/unit-tests/misc/do-nothing.test.mjs @@ -0,0 +1,22 @@ +import { assert } from 'chai'; +import { doNothing } from '../../../dist/helpers/do-nothing.js'; + +describe('doNothing', () => { + it('is exported as a function', () => { + assert.equal(typeof doNothing, 'function'); + }); + + it('returns undefined when called with no args', () => { + assert.equal(doNothing(), undefined); + }); + + it('returns undefined regardless of arguments', () => { + assert.equal(doNothing(1, 'two', { three: 3 }, [4]), undefined); + }); + + it('does not throw under any input', () => { + assert.doesNotThrow(() => doNothing(null)); + assert.doesNotThrow(() => doNothing(undefined)); + assert.doesNotThrow(() => doNothing(() => { throw new Error('this is never called'); })); + }); +}); diff --git a/common/tests/unit-tests/misc/document-entities.test.mjs b/common/tests/unit-tests/misc/document-entities.test.mjs new file mode 100644 index 0000000000..27e23835e4 --- /dev/null +++ b/common/tests/unit-tests/misc/document-entities.test.mjs @@ -0,0 +1,141 @@ +import { assert } from 'chai'; +import { DidDocument } from '../../../dist/entity/did-document.js'; +import { VpDocument as VpDocumentEntity } from '../../../dist/entity/vp-document.js'; +import { VcDocument as VcDocumentEntity } from '../../../dist/entity/vc-document.js'; +import { MultiDocuments } from '../../../dist/entity/multi-documents.js'; +import { ExternalDocument } from '../../../dist/entity/external-document.js'; +import { DocumentState } from '../../../dist/entity/document-state.js'; +import { BlockState } from '../../../dist/entity/block-state.js'; + +describe('DidDocument.createDocument', () => { + it('defaults status to NEW and computes hashes', async () => { + const d = new DidDocument(); + d.did = 'did:1'; + await d.createDocument(); + assert.equal(d.status, 'NEW'); + assert.equal(d._propHash.length, 32); + assert.equal(d._docHash, ''); + }); + + it('hashes the document when present', async () => { + const d = new DidDocument(); + d.document = { id: 'x' }; + await d.createDocument(); + assert.equal(d._docHash.length, 32); + }); + + it('keeps an explicit status', async () => { + const d = new DidDocument(); + d.status = 'CREATE'; + await d.createDocument(); + assert.equal(d.status, 'CREATE'); + }); +}); + +describe('VpDocument entity.setDefaults (no document)', () => { + it('defaults status and signature, computes propHash, empty docHash', async () => { + const v = new VpDocumentEntity(); + await v.setDefaults(); + assert.equal(v.status, 'NEW'); + assert.equal(v.signature, 0); + assert.equal(v._propHash.length, 32); + assert.equal(v._docHash, ''); + }); +}); + +describe('VcDocument entity.setDefaults (no document)', () => { + it('defaults hederaStatus/signature/option and clears tableFileIds', async () => { + const v = new VcDocumentEntity(); + await v.setDefaults(); + assert.equal(v.hederaStatus, 'NEW'); + assert.equal(v.signature, 0); + assert.isObject(v.option); + assert.equal(v.option.status, 'NEW'); + assert.isUndefined(v.tableFileIds); + assert.equal(v._docHash, ''); + assert.equal(v._propHash.length, 32); + }); + + it('preserves an existing option.status', async () => { + const v = new VcDocumentEntity(); + v.option = { status: 'Approved' }; + await v.setDefaults(); + assert.equal(v.option.status, 'Approved'); + }); +}); + +describe('MultiDocuments.setDefaults (no document)', () => { + it('computes propHash and empty docHash', async () => { + const m = new MultiDocuments(); + m.uuid = 'u'; + m.did = 'did:1'; + await m.setDefaults(); + assert.equal(m._propHash.length, 32); + assert.equal(m._docHash, ''); + }); +}); + +describe('ExternalDocument.createDocument', () => { + it('defaults lastMessage/lastUpdate/active and hashes', async () => { + const e = new ExternalDocument(); + await e.createDocument(); + assert.equal(e.lastMessage, ''); + assert.equal(e.lastUpdate, ''); + assert.equal(e.active, false); + assert.equal(e._propHash.length, 32); + assert.equal(e._docHash, ''); + }); + + it('keeps an explicit active flag', async () => { + const e = new ExternalDocument(); + e.active = true; + await e.createDocument(); + assert.equal(e.active, true); + }); +}); + +describe('DocumentState.createDocument', () => { + it('computes propHash from documentId and empty docHash', async () => { + const s = new DocumentState(); + s.documentId = 'doc-1'; + await s.createDocument(); + assert.equal(s._propHash.length, 32); + assert.equal(s._docHash, ''); + }); + + it('hashes the document when present', async () => { + const s = new DocumentState(); + s.document = { v: 1 }; + await s.createDocument(); + assert.equal(s._docHash.length, 32); + }); +}); + +describe('BlockState.createDocument', () => { + it('computes propHash and docHash from blockState', async () => { + const b = new BlockState(); + b.blockId = 'b-1'; + b.policyId = 'p-1'; + b.blockState = 'state-string'; + await b.createDocument(); + assert.equal(b._propHash.length, 32); + assert.equal(b._docHash.length, 32); + }); + + it('propHash is deterministic for identical input', async () => { + const a = new BlockState(); + a.blockId = 'x'; + a.blockTag = 't'; + a.policyId = 'p'; + a.blockState = 's'; + await a.createDocument(); + const c = new BlockState(); + c.blockId = 'x'; + c.blockTag = 't'; + c.policyId = 'p'; + c.blockState = 's'; + await c.createDocument(); + assert.equal(a._propHash, c._propHash); + assert.equal(a._docHash, c._docHash); + }); +}); diff --git a/common/tests/unit-tests/misc/document-state-lifecycle.test.mjs b/common/tests/unit-tests/misc/document-state-lifecycle.test.mjs new file mode 100644 index 0000000000..8c3639f989 --- /dev/null +++ b/common/tests/unit-tests/misc/document-state-lifecycle.test.mjs @@ -0,0 +1,324 @@ +import { assert } from 'chai'; +import crypto from 'crypto'; +import { DidDocument } from '../../../dist/entity/did-document.js'; +import { DocumentState } from '../../../dist/entity/document-state.js'; +import { ExternalPolicy } from '../../../dist/entity/external-policy.js'; +import { GlobalEventsReaderStream } from '../../../dist/entity/global-events-reader-stream.js'; +import { GlobalEventsWriterStream } from '../../../dist/entity/global-events-writer-stream.js'; +import { RestoreEntity } from '../../../dist/models/index.js'; + +const md5 = (s) => crypto.createHash('md5').update(s).digest('hex'); + +describe('RestoreEntity hash primitives', () => { + class Probe extends RestoreEntity {} + + it('hashes a property object as md5 of its JSON', () => { + const p = new Probe(); + p._updatePropHash({ a: 1, b: 'x' }); + assert.equal(p._propHash, md5(JSON.stringify({ a: 1, b: 'x' }))); + }); + + it('produces a 32-char prop hash', () => { + const p = new Probe(); + p._updatePropHash({ a: 1 }); + assert.equal(p._propHash.length, 32); + }); + + it('is order-sensitive on object keys', () => { + const a = new Probe(); + const b = new Probe(); + a._updatePropHash({ x: 1, y: 2 }); + b._updatePropHash({ y: 2, x: 1 }); + assert.notEqual(a._propHash, b._propHash); + }); + + it('hashes a non-empty document string', () => { + const p = new Probe(); + p._updateDocHash('payload'); + assert.equal(p._docHash, md5('payload')); + }); + + it('empties the doc hash for an empty string', () => { + const p = new Probe(); + p._updateDocHash(''); + assert.equal(p._docHash, ''); + }); + + it('empties the doc hash for falsy input', () => { + const p = new Probe(); + p._updateDocHash(undefined); + assert.equal(p._docHash, ''); + }); + + it('is deterministic for equal documents', () => { + const a = new Probe(); + const b = new Probe(); + a._updateDocHash('same'); + b._updateDocHash('same'); + assert.equal(a._docHash, b._docHash); + }); +}); + +describe('DidDocument status transitions', () => { + it('defaults a missing status to NEW', async () => { + const d = new DidDocument(); + await d.createDocument(); + assert.equal(d.status, 'NEW'); + }); + + for (const status of ['CREATE', 'UPDATE', 'DELETE', 'FAILED']) { + it(`preserves an explicit ${status} status`, async () => { + const d = new DidDocument(); + d.status = status; + await d.createDocument(); + assert.equal(d.status, status); + }); + } + + it('treats empty-string status as missing and defaults to NEW', async () => { + const d = new DidDocument(); + d.status = ''; + await d.createDocument(); + assert.equal(d.status, 'NEW'); + }); + + it('folds the status into the property hash', async () => { + const a = new DidDocument(); + a.did = 'did:1'; + a.status = 'CREATE'; + await a.createDocument(); + const b = new DidDocument(); + b.did = 'did:1'; + b.status = 'UPDATE'; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('empties the doc hash when no document is present', async () => { + const d = new DidDocument(); + await d.createDocument(); + assert.equal(d._docHash, ''); + }); + + it('hashes the document JSON when present', async () => { + const d = new DidDocument(); + d.document = { id: 'x', value: 1 }; + await d.createDocument(); + assert.equal(d._docHash, md5(JSON.stringify({ id: 'x', value: 1 }))); + }); + + it('changing relationships changes the prop hash', async () => { + const a = new DidDocument(); + a.relationships = ['m1']; + await a.createDocument(); + const b = new DidDocument(); + b.relationships = ['m1', 'm2']; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('is idempotent across two runs with the same input', async () => { + const a = new DidDocument(); + a.did = 'did:z'; + a.status = 'CREATE'; + a.policyId = 'p1'; + await a.createDocument(); + const h1 = a._propHash; + await a.createDocument(); + assert.equal(a._propHash, h1); + }); +}); + +describe('DocumentState lifecycle', () => { + it('hashes the documentId into prop hash with empty doc hash', async () => { + const s = new DocumentState(); + s.documentId = 'doc-1'; + await s.createDocument(); + assert.equal(s._propHash, md5(JSON.stringify({ documentId: 'doc-1' }))); + assert.equal(s._docHash, ''); + }); + + it('hashes a present document', async () => { + const s = new DocumentState(); + s.document = { v: 1 }; + await s.createDocument(); + assert.equal(s._docHash, md5(JSON.stringify({ v: 1 }))); + }); + + it('different documentIds give different prop hashes', async () => { + const a = new DocumentState(); + a.documentId = 'd1'; + await a.createDocument(); + const b = new DocumentState(); + b.documentId = 'd2'; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('prop hash ignores policyId (not in the hashed prop)', async () => { + const a = new DocumentState(); + a.documentId = 'd1'; + a.policyId = 'p1'; + await a.createDocument(); + const b = new DocumentState(); + b.documentId = 'd1'; + b.policyId = 'p2'; + await b.createDocument(); + assert.equal(a._propHash, b._propHash); + }); +}); + +describe('ExternalPolicy.setDefaults', () => { + it('defaults a missing status to NEW', () => { + const e = new ExternalPolicy(); + e.setDefaults(); + assert.equal(e.status, 'NEW'); + }); + + it('keeps an explicit status', () => { + const e = new ExternalPolicy(); + e.status = 'APPROVED'; + e.setDefaults(); + assert.equal(e.status, 'APPROVED'); + }); + + it('treats empty-string status as missing', () => { + const e = new ExternalPolicy(); + e.status = ''; + e.setDefaults(); + assert.equal(e.status, 'NEW'); + }); +}); + +describe('GlobalEventsReaderStream defaults and hash', () => { + it('defaults status to FREE and active to false', () => { + const r = new GlobalEventsReaderStream(); + assert.equal(r.status, 'FREE'); + assert.equal(r.active, false); + }); + + it('defaults cursor and json maps', () => { + const r = new GlobalEventsReaderStream(); + assert.equal(r.lastMessageCursor, ''); + assert.deepEqual(r.filterFieldsByBranch, {}); + assert.deepEqual(r.branchDocumentTypeByBranch, {}); + }); + + it('computes a prop hash and empties the doc hash', () => { + const r = new GlobalEventsReaderStream(); + r.policyId = 'p1'; + r.blockId = 'b1'; + r.prepareEntity(); + assert.equal(r._propHash.length, 32); + assert.equal(r._docHash, ''); + }); + + it('is deterministic for identical input', () => { + const a = new GlobalEventsReaderStream(); + a.policyId = 'p'; + a.blockId = 'b'; + a.prepareEntity(); + const b = new GlobalEventsReaderStream(); + b.policyId = 'p'; + b.blockId = 'b'; + b.prepareEntity(); + assert.equal(a._propHash, b._propHash); + }); + + it('reflects status changes in the prop hash', () => { + const a = new GlobalEventsReaderStream(); + a.policyId = 'p'; + a.prepareEntity(); + const b = new GlobalEventsReaderStream(); + b.policyId = 'p'; + b.status = 'PROCESSING'; + b.prepareEntity(); + assert.notEqual(a._propHash, b._propHash); + }); +}); + +describe('DidDocument relationship and message chain in prop hash', () => { + it('empty vs populated messageIds differ', async () => { + const a = new DidDocument(); + await a.createDocument(); + const b = new DidDocument(); + b.messageIds = ['m1']; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('message chain order is significant', async () => { + const a = new DidDocument(); + a.messageIds = ['m1', 'm2']; + await a.createDocument(); + const b = new DidDocument(); + b.messageIds = ['m2', 'm1']; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('topicId is folded into the prop hash', async () => { + const a = new DidDocument(); + a.topicId = '0.0.1'; + await a.createDocument(); + const b = new DidDocument(); + b.topicId = '0.0.2'; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('policyId is folded into the prop hash', async () => { + const a = new DidDocument(); + a.policyId = 'p1'; + await a.createDocument(); + const b = new DidDocument(); + b.policyId = 'p2'; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('verificationMethods are folded into the prop hash', async () => { + const a = new DidDocument(); + await a.createDocument(); + const b = new DidDocument(); + b.verificationMethods = { key: 'v' }; + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('two empty did documents share the same prop hash', async () => { + const a = new DidDocument(); + await a.createDocument(); + const b = new DidDocument(); + await b.createDocument(); + assert.equal(a._propHash, b._propHash); + }); +}); + +describe('GlobalEventsWriterStream defaults and hash', () => { + it('defaults active false, documentType any, empty lastPublishMessageId', () => { + const w = new GlobalEventsWriterStream(); + assert.equal(w.active, false); + assert.equal(w.documentType, 'any'); + assert.equal(w.lastPublishMessageId, ''); + }); + + it('computes a prop hash and empties the doc hash', () => { + const w = new GlobalEventsWriterStream(); + w.policyId = 'p1'; + w.prepareEntity(); + assert.equal(w._propHash.length, 32); + assert.equal(w._docHash, ''); + }); + + it('reflects documentType changes in the prop hash', () => { + const a = new GlobalEventsWriterStream(); + a.policyId = 'p'; + a.prepareEntity(); + const b = new GlobalEventsWriterStream(); + b.policyId = 'p'; + b.documentType = 'vc'; + b.prepareEntity(); + assert.notEqual(a._propHash, b._propHash); + }); +}); diff --git a/common/tests/unit-tests/misc/dry-run-entity.test.mjs b/common/tests/unit-tests/misc/dry-run-entity.test.mjs new file mode 100644 index 0000000000..cd62d1248f --- /dev/null +++ b/common/tests/unit-tests/misc/dry-run-entity.test.mjs @@ -0,0 +1,55 @@ +import { assert } from 'chai'; +import { DryRun } from '../../../dist/entity/dry-run.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('DryRun.setDefaults (no document/context/config)', () => { + it('applies the full set of documented defaults', async () => { + const d = new DryRun(); + await d.setDefaults(); + assert.isObject(d.option); + assert.equal(d.option.status, 'NEW'); + assert.equal(d.status, 'NEW'); + assert.match(d.uuid, UUID_RE); + assert.equal(d.codeVersion, '1.0.0'); + assert.equal(d.entity, 'NONE'); + assert.equal(d.readonly, false); + assert.equal(d.iri, d.uuid); + assert.equal(d.system, false); + assert.equal(d.active, false); + assert.equal(d.hederaStatus, 'NEW'); + assert.equal(d.signature, 0); + assert.isUndefined(d.tableFileIds); + }); + + it('preserves provided option.status / status / uuid', async () => { + const d = new DryRun(); + d.option = { status: 'Approved' }; + d.status = 'ISSUE'; + d.uuid = 'fixed-uuid'; + await d.setDefaults(); + assert.equal(d.option.status, 'Approved'); + assert.equal(d.status, 'ISSUE'); + assert.equal(d.uuid, 'fixed-uuid'); + assert.equal(d.iri, 'fixed-uuid'); + }); + + it('coerces readonly to a boolean', async () => { + const d = new DryRun(); + d.readonly = 1; + await d.setDefaults(); + assert.strictEqual(d.readonly, true); + }); + + it('keeps an explicit iri', async () => { + const d = new DryRun(); + d.iri = '#explicit'; + await d.setDefaults(); + assert.equal(d.iri, '#explicit'); + }); + + it('extends BaseEntity (createDate present)', () => { + const d = new DryRun(); + assert.instanceOf(d.createDate, Date); + }); +}); diff --git a/common/tests/unit-tests/misc/dynamic-role.test.mjs b/common/tests/unit-tests/misc/dynamic-role.test.mjs new file mode 100644 index 0000000000..0ab779da85 --- /dev/null +++ b/common/tests/unit-tests/misc/dynamic-role.test.mjs @@ -0,0 +1,53 @@ +import { assert } from 'chai'; +import { DynamicRole } from '../../../dist/entity/dynamic-role.js'; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('DynamicRole entity', () => { + it('extends BaseEntity (createDate present)', () => { + const r = new DynamicRole(); + assert.instanceOf(r.createDate, Date); + }); + + it('all fields are optional and undefined by default', () => { + const r = new DynamicRole(); + assert.equal(r.uuid, undefined); + assert.equal(r.name, undefined); + assert.equal(r.description, undefined); + assert.equal(r.owner, undefined); + assert.equal(r.permissions, undefined); + }); + + it('setDefaults assigns a v4 UUID when none is set', () => { + const r = new DynamicRole(); + r.setDefaults(); + assert.match(r.uuid, UUID_V4); + }); + + it('setDefaults preserves an existing uuid', () => { + const r = new DynamicRole(); + r.uuid = 'preset-uuid'; + r.setDefaults(); + assert.equal(r.uuid, 'preset-uuid'); + }); + + it('setDefaults produces a fresh uuid each time when starting empty', () => { + const r1 = new DynamicRole(); + const r2 = new DynamicRole(); + r1.setDefaults(); + r2.setDefaults(); + assert.notEqual(r1.uuid, r2.uuid); + }); + + it('exposes assignable label/description/owner/permissions fields', () => { + const r = new DynamicRole(); + r.name = 'ADMIN'; + r.description = 'admin role'; + r.owner = 'org-1'; + r.permissions = ['p1', 'p2']; + assert.equal(r.name, 'ADMIN'); + assert.equal(r.description, 'admin role'); + assert.equal(r.owner, 'org-1'); + assert.deepEqual(r.permissions, ['p1', 'p2']); + }); +}); diff --git a/common/tests/unit-tests/misc/empty-notifier.test.mjs b/common/tests/unit-tests/misc/empty-notifier.test.mjs new file mode 100644 index 0000000000..f60fbd31e9 --- /dev/null +++ b/common/tests/unit-tests/misc/empty-notifier.test.mjs @@ -0,0 +1,33 @@ +import { assert } from 'chai'; +import { EmptyNotifier } from '../../../dist/notification/empty-notifier.js'; + +describe('EmptyNotifier', () => { + let n; + beforeEach(() => { n = new EmptyNotifier(); }); + + it('exposes the literal name "empty"', () => { + assert.equal(n.name, 'empty'); + }); + + it('all chainable methods return the same instance', () => { + assert.strictEqual(n.minimize(true), n); + assert.strictEqual(n.setEstimate(10), n); + assert.strictEqual(n.addEstimate(1), n); + assert.strictEqual(n.start(), n); + assert.strictEqual(n.complete(), n); + assert.strictEqual(n.skip(), n); + assert.strictEqual(n.result({ ok: true }), n); + assert.strictEqual(n.fail('err'), n); + assert.strictEqual(n.startStep('any'), n); + assert.strictEqual(n.completeStep('any'), n); + assert.strictEqual(n.skipStep('any'), n); + }); + + it('does not throw under any sequence of calls', () => { + assert.doesNotThrow(() => { + n.start().setEstimate(5).addEstimate(2); + n.startStep('a').completeStep('a'); + n.fail(new Error('x'), 500).complete(); + }); + }); +}); diff --git a/common/tests/unit-tests/misc/entity-defaults-2.test.mjs b/common/tests/unit-tests/misc/entity-defaults-2.test.mjs new file mode 100644 index 0000000000..db577f42b6 --- /dev/null +++ b/common/tests/unit-tests/misc/entity-defaults-2.test.mjs @@ -0,0 +1,120 @@ +import { assert } from 'chai'; +import { PolicyModule } from '../../../dist/entity/module.js'; +import { PolicyTool } from '../../../dist/entity/tool.js'; +import { Policy } from '../../../dist/entity/policy.js'; +import { PolicyAction } from '../../../dist/entity/policy-action.js'; +import { Artifact } from '../../../dist/entity/artifact.js'; +import { ExternalPolicy } from '../../../dist/entity/external-policy.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('PolicyModule.setDefaults (no config)', () => { + it('sets status/uuid/codeVersion/type defaults', async () => { + const m = new PolicyModule(); + await m.setDefaults(); + assert.equal(m.status, 'DRAFT'); + assert.match(m.uuid, UUID_RE); + assert.equal(m.codeVersion, '1.0.0'); + assert.equal(m.type, 'CUSTOM'); + }); + + it('preserves provided fields', async () => { + const m = new PolicyModule(); + m.status = 'PUBLISHED'; + m.uuid = 'fixed'; + m.codeVersion = '2.0.0'; + m.type = 'PRESET'; + await m.setDefaults(); + assert.equal(m.status, 'PUBLISHED'); + assert.equal(m.uuid, 'fixed'); + assert.equal(m.codeVersion, '2.0.0'); + assert.equal(m.type, 'PRESET'); + }); +}); + +describe('PolicyTool.setDefaults (no config)', () => { + it('sets status/uuid/codeVersion defaults', async () => { + const t = new PolicyTool(); + await t.setDefaults(); + assert.equal(t.status, 'DRAFT'); + assert.match(t.uuid, UUID_RE); + assert.equal(t.codeVersion, '1.0.0'); + }); +}); + +describe('Policy.setDefaults (no config)', () => { + it('sets location/status/availability/uuid/codeVersion defaults', async () => { + const p = new Policy(); + await p.setDefaults(); + assert.equal(p.locationType, 'local'); + assert.equal(p.status, 'DRAFT'); + assert.equal(p.availability, 'private'); + assert.match(p.uuid, UUID_RE); + assert.equal(p.codeVersion, '1.0.0'); + }); + + it('clears registeredUsers', async () => { + const p = new Policy(); + p.registeredUsers = { a: 1 }; + await p.setDefaults(); + assert.isUndefined(p.registeredUsers); + }); + + it('preserves provided status/availability', async () => { + const p = new Policy(); + p.status = 'PUBLISH'; + p.availability = 'public'; + await p.setDefaults(); + assert.equal(p.status, 'PUBLISH'); + assert.equal(p.availability, 'public'); + }); +}); + +describe('PolicyAction.setDefaults (no document)', () => { + it('sets uuid/status and mirrors lastStatus from status', async () => { + const a = new PolicyAction(); + await a.setDefaults(); + assert.match(a.uuid, UUID_RE); + assert.equal(a.status, 'NEW'); + assert.equal(a.lastStatus, 'NEW'); + }); + + it('keeps an explicit lastStatus', async () => { + const a = new PolicyAction(); + a.status = 'COMPLETED'; + a.lastStatus = 'NEW'; + await a.setDefaults(); + assert.equal(a.status, 'COMPLETED'); + assert.equal(a.lastStatus, 'NEW'); + }); +}); + +describe('Artifact.setDefaults', () => { + it('generates a uuid when missing', () => { + const a = new Artifact(); + a.setDefaults(); + assert.match(a.uuid, UUID_RE); + }); + + it('keeps an existing uuid', () => { + const a = new Artifact(); + a.uuid = 'keep'; + a.setDefaults(); + assert.equal(a.uuid, 'keep'); + }); +}); + +describe('ExternalPolicy.setDefaults', () => { + it('sets NEW status when missing', () => { + const e = new ExternalPolicy(); + e.setDefaults(); + assert.equal(e.status, 'NEW'); + }); + + it('keeps an existing status', () => { + const e = new ExternalPolicy(); + e.status = 'APPROVED'; + e.setDefaults(); + assert.equal(e.status, 'APPROVED'); + }); +}); diff --git a/common/tests/unit-tests/misc/entity-defaults-3.test.mjs b/common/tests/unit-tests/misc/entity-defaults-3.test.mjs new file mode 100644 index 0000000000..4885f556d0 --- /dev/null +++ b/common/tests/unit-tests/misc/entity-defaults-3.test.mjs @@ -0,0 +1,149 @@ +import { assert } from 'chai'; +import { Topic } from '../../../dist/entity/topic.js'; +import { DocumentDraft } from '../../../dist/entity/document-draft.js'; +import { BlockCache } from '../../../dist/entity/block-cache.js'; +import { Record } from '../../../dist/entity/record.js'; +import { DryRunFiles } from '../../../dist/entity/dry-run-files.js'; + +const makeTopic = (props) => { + const topic = new Topic(); + Object.assign(topic, props); + return topic; +}; + +describe('Topic.createDocument', () => { + it('fills _propHash with a hash string', async () => { + const topic = makeTopic({ topicId: '0.0.1', name: 'root' }); + await topic.createDocument(); + assert.isString(topic._propHash); + assert.isAbove(topic._propHash.length, 0); + }); + + it('sets an empty document hash', async () => { + const topic = makeTopic({ topicId: '0.0.1' }); + await topic.createDocument(); + assert.equal(topic._docHash, ''); + }); + + it('is deterministic for identical properties', async () => { + const a = makeTopic({ topicId: '0.0.2', name: 'n', owner: 'did:o' }); + const b = makeTopic({ topicId: '0.0.2', name: 'n', owner: 'did:o' }); + await a.createDocument(); + await b.createDocument(); + assert.equal(a._propHash, b._propHash); + }); + + it('changes the hash when a tracked property changes', async () => { + const a = makeTopic({ topicId: '0.0.2', name: 'n' }); + const b = makeTopic({ topicId: '0.0.2', name: 'other' }); + await a.createDocument(); + await b.createDocument(); + assert.notEqual(a._propHash, b._propHash); + }); + + it('ignores properties outside the tracked set', async () => { + const a = makeTopic({ topicId: '0.0.3', name: 'n' }); + const b = makeTopic({ topicId: '0.0.3', name: 'n', tenantId: 'tenant-x' }); + await a.createDocument(); + await b.createDocument(); + assert.equal(a._propHash, b._propHash); + }); +}); + +describe('DocumentDraft.setDefaults', () => { + it('extracts table file ids from a JSON string', async () => { + const draft = new DocumentDraft(); + draft.data = JSON.stringify({ type: 'table', fileId: '507f1f77bcf86cd799439011' }); + await draft.setDefaults(); + assert.lengthOf(draft.tableFileIds, 1); + assert.equal(String(draft.tableFileIds[0]), '507f1f77bcf86cd799439011'); + }); + + it('extracts table file ids from an object payload', async () => { + const draft = new DocumentDraft(); + draft.data = { nested: [{ type: 'table', fileId: '507f1f77bcf86cd799439012' }] }; + await draft.setDefaults(); + assert.lengthOf(draft.tableFileIds, 1); + assert.equal(String(draft.tableFileIds[0]), '507f1f77bcf86cd799439012'); + }); + + it('dedupes repeated file ids', async () => { + const draft = new DocumentDraft(); + draft.data = { + a: { type: 'table', fileId: '507f1f77bcf86cd799439013' }, + b: { type: 'TABLE', fileId: '507f1f77bcf86cd799439013' } + }; + await draft.setDefaults(); + assert.lengthOf(draft.tableFileIds, 1); + }); + + it('ignores non-table nodes', async () => { + const draft = new DocumentDraft(); + draft.data = { type: 'image', fileId: '507f1f77bcf86cd799439014' }; + await draft.setDefaults(); + assert.deepEqual(draft.tableFileIds, []); + }); + + it('leaves tableFileIds undefined for malformed JSON strings', async () => { + const draft = new DocumentDraft(); + draft.data = '{not json'; + await draft.setDefaults(); + assert.isUndefined(draft.tableFileIds); + }); + + it('leaves tableFileIds undefined for an empty string', async () => { + const draft = new DocumentDraft(); + draft.data = ' '; + await draft.setDefaults(); + assert.isUndefined(draft.tableFileIds); + }); + + it('leaves tableFileIds undefined when data is missing', async () => { + const draft = new DocumentDraft(); + await draft.setDefaults(); + assert.isUndefined(draft.tableFileIds); + }); +}); + +describe('BlockCache hooks', () => { + it('setDefaults keeps a short value inline', async () => { + const cache = new BlockCache(); + cache.value = { a: 1 }; + cache.isLongValue = false; + await cache.setDefaults(); + assert.deepEqual(cache.value, { a: 1 }); + assert.isUndefined(cache.fileId); + }); + + it('setDefaults does nothing without a value', async () => { + const cache = new BlockCache(); + cache.isLongValue = true; + await cache.setDefaults(); + assert.isUndefined(cache.fileId); + }); + + it('loadFiles does nothing without a fileId', async () => { + const cache = new BlockCache(); + await cache.loadFiles(); + assert.isUndefined(cache.value); + }); +}); + +describe('Record.setDefaults', () => { + it('does nothing without document or results', async () => { + const record = new Record(); + record.uuid = 'u'; + await record.setDefaults(); + assert.isUndefined(record.documentFileId); + assert.isUndefined(record.resultsFileId); + }); +}); + +describe('DryRunFiles.setDefaults', () => { + it('does nothing without a file', async () => { + const entity = new DryRunFiles(); + entity.policyId = 'p1'; + await entity.setDefaults(); + assert.isUndefined(entity.fileId); + }); +}); diff --git a/common/tests/unit-tests/misc/entity-defaults.test.mjs b/common/tests/unit-tests/misc/entity-defaults.test.mjs new file mode 100644 index 0000000000..7ace3216bd --- /dev/null +++ b/common/tests/unit-tests/misc/entity-defaults.test.mjs @@ -0,0 +1,153 @@ +import { assert } from 'chai'; +import { TagCache } from '../../../dist/entity/tag-cache.js'; +import { SchemaRule } from '../../../dist/entity/schema-rule.js'; +import { PolicyStatistic } from '../../../dist/entity/policy-statistic.js'; +import { PolicyDiff } from '../../../dist/entity/policy-diff.js'; +import { Formula } from '../../../dist/entity/formula.js'; +import { PolicyLabel } from '../../../dist/entity/policy-label.js'; +import { Theme } from '../../../dist/entity/theme.js'; +import { Tag } from '../../../dist/entity/tag.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('TagCache.setDefaults', () => { + it('fills date with an ISO string when missing', () => { + const t = new TagCache(); + t.setDefaults(); + assert.isString(t.date); + assert.isFalse(isNaN(Date.parse(t.date))); + }); + + it('keeps an existing date', () => { + const t = new TagCache(); + t.date = '2020-01-01T00:00:00.000Z'; + t.setDefaults(); + assert.equal(t.date, '2020-01-01T00:00:00.000Z'); + }); +}); + +describe('SchemaRule.setDefaults', () => { + it('generates a uuid and sets DRAFT status when missing', () => { + const s = new SchemaRule(); + s.setDefaults(); + assert.match(s.uuid, UUID_RE); + assert.equal(s.status, 'DRAFT'); + }); + + it('preserves provided uuid and status', () => { + const s = new SchemaRule(); + s.uuid = 'fixed-uuid'; + s.status = 'PUBLISHED'; + s.setDefaults(); + assert.equal(s.uuid, 'fixed-uuid'); + assert.equal(s.status, 'PUBLISHED'); + }); +}); + +describe('PolicyStatistic.setDefaults', () => { + it('generates uuid and DRAFT status', () => { + const p = new PolicyStatistic(); + p.setDefaults(); + assert.match(p.uuid, UUID_RE); + assert.equal(p.status, 'DRAFT'); + }); +}); + +describe('PolicyDiff.setDefaults', () => { + it('generates a uuid when missing', () => { + const p = new PolicyDiff(); + p.setDefaults(); + assert.match(p.uuid, UUID_RE); + }); + + it('keeps an existing uuid', () => { + const p = new PolicyDiff(); + p.uuid = 'keep-me'; + p.setDefaults(); + assert.equal(p.uuid, 'keep-me'); + }); +}); + +describe('Formula.setDefaults', () => { + it('generates uuid and DRAFT status', () => { + const f = new Formula(); + f.setDefaults(); + assert.match(f.uuid, UUID_RE); + assert.equal(f.status, 'DRAFT'); + }); +}); + +describe('PolicyLabel.setDefaults', () => { + it('generates uuid and DRAFT status', () => { + const p = new PolicyLabel(); + p.setDefaults(); + assert.match(p.uuid, UUID_RE); + assert.equal(p.status, 'DRAFT'); + }); +}); + +describe('Theme.setDefaults', () => { + it('generates a uuid when missing', () => { + const t = new Theme(); + t.setDefaults(); + assert.match(t.uuid, UUID_RE); + }); + + it('keeps an existing uuid', () => { + const t = new Theme(); + t.uuid = 'x'; + t.setDefaults(); + assert.equal(t.uuid, 'x'); + }); +}); + +describe('Tag.createDocument', () => { + it('fills uuid/status/operation/date defaults', async () => { + const t = new Tag(); + await t.createDocument(); + assert.match(t.uuid, UUID_RE); + assert.equal(t.status, 'Draft'); + assert.equal(t.operation, 'Create'); + assert.isString(t.date); + }); + + it('computes a 32-char md5 propHash', async () => { + const t = new Tag(); + t.name = 'n'; + await t.createDocument(); + assert.isString(t._propHash); + assert.equal(t._propHash.length, 32); + }); + + it('docHash is empty when no document, and set when present', async () => { + const a = new Tag(); + await a.createDocument(); + assert.equal(a._docHash, ''); + + const b = new Tag(); + b.document = { a: 1 }; + await b.createDocument(); + assert.isString(b._docHash); + assert.equal(b._docHash.length, 32); + }); + + it('preserves provided status/operation', async () => { + const t = new Tag(); + t.status = 'Published'; + t.operation = 'Delete'; + await t.createDocument(); + assert.equal(t.status, 'Published'); + assert.equal(t.operation, 'Delete'); + }); + + it('propHash is stable across calls with same data', async () => { + const t = new Tag(); + t.uuid = 'u'; + t.name = 'n'; + t.date = '2020-01-01T00:00:00.000Z'; + await t.createDocument(); + const first = t._propHash; + await t.createDocument(); + assert.equal(t._propHash, first); + }); +}); diff --git a/common/tests/unit-tests/misc/expression.test.mjs b/common/tests/unit-tests/misc/expression.test.mjs new file mode 100644 index 0000000000..6cb00c6f34 --- /dev/null +++ b/common/tests/unit-tests/misc/expression.test.mjs @@ -0,0 +1,99 @@ +import { assert } from 'chai'; +import { Expression } from '../../../dist/xlsx/models/expression.js'; + +describe('Expression constructor', () => { + it('captures name and formulae verbatim', () => { + const e = new Expression('B7', 'A1+A2'); + assert.equal(e.name, 'B7'); + assert.equal(e.formulae, 'A1+A2'); + }); + + it('starts with empty symbols/functions/ranges', () => { + const e = new Expression('B1', '0'); + assert.equal(e.symbols.size, 0); + assert.equal(e.functions.size, 0); + assert.equal(e.ranges.size, 0); + }); +}); + +describe('Expression.parse — symbols', () => { + it('collects all referenced symbols', () => { + const e = new Expression('C1', 'A1 + B2 - C3'); + e.parse(); + assert.includeMembers([...e.symbols], ['A1', 'B2', 'C3']); + }); + + it('deduplicates repeated symbols', () => { + const e = new Expression('C1', 'A1 + A1 + A1'); + e.parse(); + const syms = [...e.symbols]; + assert.equal(syms.filter((s) => s === 'A1').length, 1); + }); + + it('records no symbols for a pure literal expression', () => { + const e = new Expression('C1', '1 + 2'); + e.parse(); + assert.equal(e.symbols.size, 0); + }); +}); + +describe('Expression.parse — functions', () => { + it('records function calls and their formulae', () => { + const e = new Expression('C1', 'SUM(A1, A2)'); + e.parse(); + const sum = e.functions.get('SUM'); + assert.isArray(sum); + assert.equal(sum.length, 1); + }); + + it('captures multiple invocations of the same function', () => { + const e = new Expression('C1', 'MAX(A1, A2) + MAX(B1, B2)'); + e.parse(); + const maxes = e.functions.get('MAX'); + assert.equal(maxes.length, 2); + }); + + it('descends into function arguments to collect their symbols', () => { + const e = new Expression('C1', 'SUM(A1, B2)'); + e.parse(); + assert.isTrue(e.symbols.has('A1')); + assert.isTrue(e.symbols.has('B2')); + }); +}); + +describe('Expression.parse — ranges', () => { + it('expands a vertical range into the column cells', () => { + const e = new Expression('C1', 'SUM(A1:A4)'); + e.parse(); + const cells = e.ranges.get('A1_A4'); + assert.deepEqual(cells, ['A1', 'A2', 'A3', 'A4']); + }); + + it('rewrites a range node into a SymbolNode (start_end) in the transformed expression', () => { + const e = new Expression('C1', 'SUM(A1:A4)'); + e.parse(); + // The transformed string should reference the synthesised symbol "A1_A4" + assert.match(e.transformed, /A1_A4/); + }); + + it('handles a reversed range (A5:A2) as the same symbol', () => { + const e = new Expression('C1', 'SUM(A5:A2)'); + e.parse(); + const cells = e.ranges.get('A5_A2'); + assert.deepEqual(cells, ['A2', 'A3', 'A4', 'A5']); + }); + + it('throws "Invalid range" when start and end columns differ', () => { + const e = new Expression('C1', 'SUM(A1:B4)'); + assert.throws(() => e.parse(), /Invalid range/); + }); +}); + +describe('Expression.parse — transformed', () => { + it('preserves a literal expression', () => { + const e = new Expression('C1', '1 + 2'); + e.parse(); + // mathjs may insert/normalize whitespace, so just check the operator + operands are present + assert.match(e.transformed, /1\s*\+\s*2/); + }); +}); diff --git a/common/tests/unit-tests/misc/hash-entities.test.mjs b/common/tests/unit-tests/misc/hash-entities.test.mjs new file mode 100644 index 0000000000..d7a2d1d363 --- /dev/null +++ b/common/tests/unit-tests/misc/hash-entities.test.mjs @@ -0,0 +1,104 @@ +import { assert } from 'chai'; +import { Token } from '../../../dist/entity/token.js'; +import { PolicyRoles } from '../../../dist/entity/policy-roles.js'; +import { PolicyInvitations } from '../../../dist/entity/policy-invitations.js'; +import { Schema } from '../../../dist/entity/schema.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('Token.createDocument', () => { + it('computes propHash and empty docHash', async () => { + const t = new Token(); + t.tokenId = '0.0.1'; + t.tokenName = 'T'; + await t.createDocument(); + assert.equal(t._propHash.length, 32); + assert.equal(t._docHash, ''); + }); + + it('hash reflects a property change', async () => { + const t = new Token(); + t.tokenName = 'A'; + await t.createDocument(); + const before = t._propHash; + t.tokenName = 'B'; + await t.createDocument(); + assert.notEqual(t._propHash, before); + }); +}); + +describe('PolicyRoles.createDocument', () => { + it('defaults active to true and computes hashes', async () => { + const r = new PolicyRoles(); + await r.createDocument(); + assert.equal(r.active, true); + assert.equal(r._propHash.length, 32); + assert.equal(r._docHash, ''); + }); + + it('respects active === false', async () => { + const r = new PolicyRoles(); + r.active = false; + await r.createDocument(); + assert.equal(r.active, false); + }); +}); + +describe('PolicyInvitations.createDocument', () => { + it('defaults active to true and computes hashes', async () => { + const i = new PolicyInvitations(); + await i.createDocument(); + assert.equal(i.active, true); + assert.equal(i._propHash.length, 32); + assert.equal(i._docHash, ''); + }); + + it('respects active === false', async () => { + const i = new PolicyInvitations(); + i.active = false; + await i.createDocument(); + assert.equal(i.active, false); + }); +}); + +describe('Schema.setDefaults (no document/context)', () => { + it('applies entity/status/uuid/iri defaults', async () => { + const s = new Schema(); + await s.setDefaults(); + assert.equal(s.entity, 'NONE'); + assert.equal(s.status, 'DRAFT'); + assert.match(s.uuid, UUID_RE); + assert.equal(s.iri, s.uuid); + assert.equal(s.readonly, false); + assert.equal(s.system, false); + assert.equal(s.active, false); + assert.equal(s.codeVersion, '1.2.0'); + }); + + it('nulls messageId for DRAFT status', async () => { + const s = new Schema(); + s.messageId = 'm-1'; + await s.setDefaults(); + assert.isNull(s.messageId); + }); + + it('category defaults to POLICY when not readonly', async () => { + const s = new Schema(); + await s.setDefaults(); + assert.equal(s.category, 'POLICY'); + }); + + it('category defaults to SYSTEM when readonly', async () => { + const s = new Schema(); + s.readonly = true; + await s.setDefaults(); + assert.equal(s.category, 'SYSTEM'); + }); + + it('keeps an explicit iri', async () => { + const s = new Schema(); + s.iri = '#custom'; + await s.setDefaults(); + assert.equal(s.iri, '#custom'); + }); +}); diff --git a/common/tests/unit-tests/misc/integration-block-helper.test.mjs b/common/tests/unit-tests/misc/integration-block-helper.test.mjs new file mode 100644 index 0000000000..354e2fffdd --- /dev/null +++ b/common/tests/unit-tests/misc/integration-block-helper.test.mjs @@ -0,0 +1,58 @@ +import { assert } from 'chai'; +import { generateConfigForIntegrationBlock } from '../../../dist/helpers/generate-config-for-integration-block-helper.js'; + +describe('generateConfigForIntegrationBlock', () => { + it('returns the documented top-level shape', () => { + const cfg = generateConfigForIntegrationBlock(); + assert.equal(cfg.label, 'Integration button'); + assert.equal(cfg.title, "Add 'Integration button' Block"); + assert.isTrue(cfg.post); + assert.isTrue(cfg.get); + assert.equal(cfg.children, 'Special'); + assert.equal(cfg.control, 'UI'); + assert.deepEqual(cfg.input, []); + assert.isFalse(cfg.defaultEvent); + assert.isArray(cfg.output); + assert.isArray(cfg.properties); + }); + + it('uses provided enum-shaped overrides when supplied', () => { + const cfg = generateConfigForIntegrationBlock( + { Input: 'CUSTOM_INPUT', Checkbox: 'CUSTOM_CB', Select: 'CUSTOM_SELECT', Group: 'CUSTOM_GROUP' }, + { Special: 'CUSTOM_SPECIAL' }, + { UI: 'CUSTOM_UI' }, + null, + { RunEvent: 'RUN', ReleaseEvent: 'REL', RefreshEvent: 'REF' }, + ); + assert.equal(cfg.children, 'CUSTOM_SPECIAL'); + assert.equal(cfg.control, 'CUSTOM_UI'); + assert.deepEqual(cfg.output, ['RUN', 'REL', 'REF']); + const button = cfg.properties.find((p) => p.name === 'buttonName'); + assert.ok(button); + assert.equal(button.type, 'CUSTOM_INPUT'); + const cache = cfg.properties.find((p) => p.name === 'getFromCache'); + assert.equal(cache.type, 'CUSTOM_CB'); + const integrationType = cfg.properties.find((p) => p.name === 'integrationType'); + assert.equal(integrationType.type, 'CUSTOM_SELECT'); + }); + + it('falls back to default RunEvent/ReleaseEvent/RefreshEvent strings', () => { + const cfg = generateConfigForIntegrationBlock(); + assert.deepEqual(cfg.output, ['RunEvent', 'ReleaseEvent', 'RefreshEvent']); + }); + + it('declares the buttonName, getFromCache, and integrationType core properties', () => { + const cfg = generateConfigForIntegrationBlock(); + const names = cfg.properties.map((p) => p.name); + assert.include(names, 'buttonName'); + assert.include(names, 'getFromCache'); + assert.include(names, 'integrationType'); + }); + + it('the integrationType property is required and non-empty in items', () => { + const cfg = generateConfigForIntegrationBlock(); + const integrationType = cfg.properties.find((p) => p.name === 'integrationType'); + assert.isTrue(integrationType.required === true); + assert.isArray(integrationType.items); + }); +}); diff --git a/common/tests/unit-tests/misc/issuer.test.mjs b/common/tests/unit-tests/misc/issuer.test.mjs new file mode 100644 index 0000000000..e367c6080a --- /dev/null +++ b/common/tests/unit-tests/misc/issuer.test.mjs @@ -0,0 +1,82 @@ +import { assert } from 'chai'; +import { Issuer } from '../../../dist/hedera-modules/vcjs/issuer.js'; + +describe('Issuer constructor and accessors', () => { + it('returns the id passed in', () => { + const i = new Issuer('did:hedera:testnet:abc'); + assert.equal(i.getId(), 'did:hedera:testnet:abc'); + }); + + it('group defaults to null when not supplied', () => { + const i = new Issuer('did:x'); + assert.isNull(i.getGroup()); + }); + + it('preserves a non-empty group', () => { + const i = new Issuer('did:x', 'group-A'); + assert.equal(i.getGroup(), 'group-A'); + }); + + it('coerces empty string group to null (falsy)', () => { + const i = new Issuer('did:x', ''); + assert.isNull(i.getGroup()); + }); +}); + +describe('Issuer.toJsonTree', () => { + it('returns the bare id string when group is absent', () => { + const i = new Issuer('did:x'); + assert.equal(i.toJsonTree(), 'did:x'); + }); + + it('returns an object with id and group when group is present', () => { + const i = new Issuer('did:x', 'group-1'); + assert.deepEqual(i.toJsonTree(), { id: 'did:x', group: 'group-1' }); + }); +}); + +describe('Issuer.fromJsonTree', () => { + it('builds an Issuer from a bare id string', () => { + const i = Issuer.fromJsonTree('did:y'); + assert.equal(i.getId(), 'did:y'); + assert.isNull(i.getGroup()); + }); + + it('builds an Issuer from an {id, group} object', () => { + const i = Issuer.fromJsonTree({ id: 'did:y', group: 'g' }); + assert.equal(i.getId(), 'did:y'); + assert.equal(i.getGroup(), 'g'); + }); + + it('throws when input is null/undefined', () => { + assert.throws(() => Issuer.fromJsonTree(null), /empty/i); + assert.throws(() => Issuer.fromJsonTree(undefined), /empty/i); + }); +}); + +describe('Issuer.toJSON / fromJson round-trip', () => { + it('round-trips a bare-id issuer through JSON', () => { + const i = new Issuer('did:x'); + const back = Issuer.fromJson(i.toJSON()); + assert.equal(back.getId(), 'did:x'); + assert.isNull(back.getGroup()); + }); + + it('round-trips an issuer with a group through JSON', () => { + const i = new Issuer('did:x', 'g'); + const back = Issuer.fromJson(i.toJSON()); + assert.equal(back.getId(), 'did:x'); + assert.equal(back.getGroup(), 'g'); + }); + + it('throws on malformed JSON input', () => { + assert.throws(() => Issuer.fromJson('not-json{'), /not a valid Issuer/); + }); +}); + +describe('Issuer constants', () => { + it('exposes ID and GROUP key names', () => { + assert.equal(Issuer.ID, 'id'); + assert.equal(Issuer.GROUP, 'group'); + }); +}); diff --git a/common/tests/unit-tests/misc/memo-map.test.mjs b/common/tests/unit-tests/misc/memo-map.test.mjs new file mode 100644 index 0000000000..fd1430585e --- /dev/null +++ b/common/tests/unit-tests/misc/memo-map.test.mjs @@ -0,0 +1,124 @@ +import { assert } from 'chai'; +import { MemoMap } from '../../../dist/hedera-modules/memo-mappings/memo-map.js'; +import { TopicMemo } from '../../../dist/hedera-modules/memo-mappings/topic-memo.js'; +import { MessageMemo } from '../../../dist/hedera-modules/memo-mappings/message-memo.js'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; +import { TopicType } from '@guardian/interfaces'; + +describe('MemoMap.parseMemo', () => { + it('returns "" for an empty memo when safetyParse=true', () => { + assert.equal(MemoMap.parseMemo(true, '', {}), ''); + assert.equal(MemoMap.parseMemo(true, undefined, {}), ''); + assert.equal(MemoMap.parseMemo(true, null, {}), ''); + }); + + it('throws on empty memo when safetyParse=false', () => { + assert.throws(() => MemoMap.parseMemo(false, '', {}), /empty/i); + }); + + it('passes a literal string through unchanged', () => { + assert.equal( + MemoMap.parseMemo(true, 'plain text', {}), + 'plain text' + ); + }); + + it('substitutes ${...} placeholders from the memo object', () => { + assert.equal( + MemoMap.parseMemo(true, 'hello ${name}', { name: 'world' }), + 'hello world' + ); + }); + + it('supports dotted-path lookups', () => { + assert.equal( + MemoMap.parseMemo(true, 'user is ${user.name}', { user: { name: 'alice' } }), + 'user is alice' + ); + }); + + it('substitutes missing values with "" when safetyParse=true', () => { + assert.equal( + MemoMap.parseMemo(true, 'value=${missing}', {}), + 'value=' + ); + }); + + it('throws on missing value when safetyParse=false', () => { + assert.throws( + () => MemoMap.parseMemo(false, 'value=${missing}', {}), + /not defined/ + ); + }); +}); + +describe('TopicMemo.getTopicMemo', () => { + it('returns the canonical memo for known topic types', () => { + assert.equal(TopicMemo.getTopicMemo({ type: TopicType.UserTopic }), 'Standard Registry organization topic'); + assert.equal(TopicMemo.getTopicMemo({ type: TopicType.PolicyTopic }), 'Policy development topic'); + assert.equal(TopicMemo.getTopicMemo({ type: TopicType.TokenTopic }), 'Token topic'); + assert.equal(TopicMemo.getTopicMemo({ type: TopicType.ContractTopic }), 'Contract topic'); + assert.equal(TopicMemo.getTopicMemo({ type: TopicType.RetireTopic }), 'Retire topic'); + }); + + it('substitutes ${name} for DynamicTopic when supplied', () => { + const memo = TopicMemo.getTopicMemo({ type: TopicType.DynamicTopic, name: 'mint' }); + assert.equal(memo, 'mint operation topic'); + }); + + it('falls back to the default DynamicTopic memo when name is missing', () => { + const memo = TopicMemo.getTopicMemo({ type: TopicType.DynamicTopic }); + assert.equal(memo, 'Policy operation topic'); + }); + + it('returns "" for an unknown topic type', () => { + assert.equal(TopicMemo.getTopicMemo({ type: 'NOT_A_REAL_TOPIC' }), ''); + }); + + it('exposes a stable global topic memo', () => { + assert.equal(TopicMemo.getGlobalTopicMemo(), 'Standard Registries initialization topic'); + }); +}); + +describe('MessageMemo.getMessageMemo', () => { + it('returns mapped memo for {type, action} pair', () => { + const memo = MessageMemo.getMessageMemo({ + type: MessageType.StandardRegistry, + action: MessageAction.Init, + }); + assert.equal(memo, 'Standard Registry initialization message'); + }); + + it('returns mapped memo for action-only entries (e.g. RevokeDocument)', () => { + const memo = MessageMemo.getMessageMemo({ + type: MessageType.VCDocument, + action: MessageAction.RevokeDocument, + }); + assert.equal(memo, 'Revoke document message'); + }); + + it('substitutes ${name} for a Topic.CreateTopic.DynamicTopic message', () => { + const memo = MessageMemo.getMessageMemo({ + type: MessageType.Topic, + action: MessageAction.CreateTopic, + messageType: TopicType.DynamicTopic, + name: 'mint', + }); + assert.equal(memo, 'mint operation topic creation message'); + }); + + it('falls back to the default DynamicTopic creation memo when ${name} is missing', () => { + const memo = MessageMemo.getMessageMemo({ + type: MessageType.Topic, + action: MessageAction.CreateTopic, + messageType: TopicType.DynamicTopic, + }); + assert.equal(memo, 'Policy operation topic creation message'); + }); + + it('returns "" for an unknown {type, action} combination', () => { + const memo = MessageMemo.getMessageMemo({ type: 'unknown-type', action: 'unknown-action' }); + assert.equal(memo, ''); + }); +}); diff --git a/common/tests/unit-tests/misc/message-enums.test.mjs b/common/tests/unit-tests/misc/message-enums.test.mjs new file mode 100644 index 0000000000..e1045b63ec --- /dev/null +++ b/common/tests/unit-tests/misc/message-enums.test.mjs @@ -0,0 +1,61 @@ +import { assert } from 'chai'; +import { MessageType } from '../../../dist/hedera-modules/message/message-type.js'; +import { MessageAction } from '../../../dist/hedera-modules/message/message-action.js'; + +describe('MessageType enum', () => { + it('exposes the expected document/topic categories', () => { + assert.equal(MessageType.VCDocument, 'VC-Document'); + assert.equal(MessageType.EVCDocument, 'EVC-Document'); + assert.equal(MessageType.VPDocument, 'VP-Document'); + assert.equal(MessageType.DIDDocument, 'DID-Document'); + assert.equal(MessageType.Policy, 'Policy'); + assert.equal(MessageType.InstancePolicy, 'Instance-Policy'); + assert.equal(MessageType.Schema, 'Schema'); + assert.equal(MessageType.Topic, 'Topic'); + assert.equal(MessageType.StandardRegistry, 'Standard Registry'); + assert.equal(MessageType.Token, 'Token'); + }); + + it('all values are unique strings', () => { + const values = Object.values(MessageType); + assert.equal(new Set(values).size, values.length); + for (const v of values) { + assert.equal(typeof v, 'string'); + assert.isAbove(v.length, 0); + } + }); +}); + +describe('MessageAction enum', () => { + it('exposes the expected lifecycle actions', () => { + assert.equal(MessageAction.CreateDID, 'create-did-document'); + assert.equal(MessageAction.CreateVC, 'create-vc-document'); + assert.equal(MessageAction.CreatePolicy, 'create-policy'); + assert.equal(MessageAction.PublishPolicy, 'publish-policy'); + assert.equal(MessageAction.DeletePolicy, 'delete-policy'); + assert.equal(MessageAction.CreateSchema, 'create-schema'); + assert.equal(MessageAction.PublishSchema, 'publish-schema'); + assert.equal(MessageAction.CreateTopic, 'create-topic'); + assert.equal(MessageAction.RevokeDocument, 'revoke-document'); + assert.equal(MessageAction.DeleteDocument, 'delete-document'); + assert.equal(MessageAction.Init, 'Init'); + }); + + it('all values are unique strings (no overlapping wire identifiers)', () => { + const values = Object.values(MessageAction); + assert.equal(new Set(values).size, values.length); + for (const v of values) { + assert.equal(typeof v, 'string'); + assert.isAbove(v.length, 0); + } + }); + + it('most actions are kebab-case (verb-noun); a small set are PascalCase exceptions', () => { + const kebab = /^[a-z]+(-[a-z]+)*$/; + const exceptions = ['Init']; + for (const [key, value] of Object.entries(MessageAction)) { + if (exceptions.includes(value)) continue; + assert.match(value, kebab, `action ${key}=${value} is not kebab-case`); + } + }); +}); diff --git a/common/tests/unit-tests/misc/misc-helpers.test.mjs b/common/tests/unit-tests/misc/misc-helpers.test.mjs new file mode 100644 index 0000000000..2e6d652d15 --- /dev/null +++ b/common/tests/unit-tests/misc/misc-helpers.test.mjs @@ -0,0 +1,83 @@ +import { assert } from 'chai'; +import { parseCsv } from '../../../dist/helpers/custom-csv-parser.js'; +import { doNothing } from '../../../dist/helpers/do-nothing.js'; +import { GenerateTLSOptionsNats } from '../../../dist/helpers/generate-tls-options.js'; + +describe('parseCsv', () => { + it('parses a simple CSV with header row into records', () => { + const result = parseCsv('name,age\nalice,30\nbob,25'); + assert.deepEqual(result, [ + { name: 'alice', age: '30' }, + { name: 'bob', age: '25' }, + ]); + }); + + it('trims whitespace in headers and values', () => { + const result = parseCsv(' name , age \n alice , 30 '); + assert.deepEqual(result, [{ name: 'alice', age: '30' }]); + }); + + it("fills missing trailing columns with ''", () => { + const result = parseCsv('a,b,c\n1,2'); + assert.deepEqual(result, [{ a: '1', b: '2', c: '' }]); + }); + + it('returns [] when only the header row is present', () => { + const result = parseCsv('a,b,c'); + assert.deepEqual(result, []); + }); +}); + +describe('doNothing', () => { + it('returns undefined and does not throw', () => { + assert.equal(doNothing(), undefined); + }); +}); + +describe('GenerateTLSOptionsNats', () => { + let originalCert; + let originalKey; + let originalCa; + + before(() => { + originalCert = process.env.TLS_CERT; + originalKey = process.env.TLS_KEY; + originalCa = process.env.TLS_CA; + }); + + after(() => { + process.env.TLS_CERT = originalCert; + process.env.TLS_KEY = originalKey; + process.env.TLS_CA = originalCa; + }); + + it('returns undefined when TLS_CERT or TLS_KEY is missing', () => { + delete process.env.TLS_CERT; + delete process.env.TLS_KEY; + assert.equal(GenerateTLSOptionsNats(), undefined); + + process.env.TLS_CERT = 'has-cert'; + delete process.env.TLS_KEY; + assert.equal(GenerateTLSOptionsNats(), undefined); + }); + + it('returns the configured cert/key/ca when both are set', () => { + process.env.TLS_CERT = 'CERT_VALUE'; + process.env.TLS_KEY = 'KEY_VALUE'; + process.env.TLS_CA = 'CA_VALUE'; + const opts = GenerateTLSOptionsNats(); + assert.deepEqual(opts, { + cert: 'CERT_VALUE', + key: 'KEY_VALUE', + ca: 'CA_VALUE', + }); + }); + + it('returns ca=undefined when only TLS_CA is missing', () => { + process.env.TLS_CERT = 'CERT'; + process.env.TLS_KEY = 'KEY'; + delete process.env.TLS_CA; + const opts = GenerateTLSOptionsNats(); + assert.equal(opts.ca, undefined); + }); +}); diff --git a/common/tests/unit-tests/misc/notification-step.test.mjs b/common/tests/unit-tests/misc/notification-step.test.mjs new file mode 100644 index 0000000000..79bdd5965a --- /dev/null +++ b/common/tests/unit-tests/misc/notification-step.test.mjs @@ -0,0 +1,179 @@ +import { assert } from 'chai'; +import { NotificationStep } from '../../../dist/notification/notification-step.js'; + +describe('NotificationStep constructor', () => { + it('captures name and size', () => { + const s = new NotificationStep('build', 5); + assert.equal(s.name, 'build'); + assert.equal(s.size, 5); + }); + + it('starts not-started/not-completed/not-failed/not-skipped', () => { + const s = new NotificationStep('x', 1); + assert.isFalse(s.started); + assert.isFalse(s.completed); + assert.isFalse(s.failed); + assert.isFalse(s.skipped); + assert.equal(s.estimate, 0); + assert.isFalse(s.minimized); + }); +}); + +describe('NotificationStep lifecycle: start / complete / skip / fail', () => { + it('start() sets started=true and stamps startDate', () => { + const s = new NotificationStep('x', 1); + const before = Date.now(); + s.start(); + const after = Date.now(); + assert.isTrue(s.started); + assert.isAtLeast(s.startDate, before); + assert.isAtMost(s.startDate, after); + }); + + it('complete() sets completed=true and stamps stopDate', () => { + const s = new NotificationStep('x', 1).start(); + s.complete(); + assert.isTrue(s.completed); + assert.isFalse(s.failed); + assert.isNumber(s.stopDate); + }); + + it('skip() on a fresh step marks completed+skipped=true', () => { + const s = new NotificationStep('x', 1); + s.skip(); + assert.isTrue(s.completed); + assert.isTrue(s.skipped); + }); + + it('fail(string) records {code:500, message:string} by default', () => { + const s = new NotificationStep('x', 1); + s.fail('oh no'); + assert.deepEqual(s.error, { code: 500, message: 'oh no' }); + assert.isTrue(s.completed); + assert.isTrue(s.failed); + }); + + it('fail(Error, code) records the message and the supplied code', () => { + const s = new NotificationStep('x', 1); + s.fail(new Error('boom'), 'E_BOOM'); + assert.deepEqual(s.error, { code: 'E_BOOM', message: 'boom' }); + }); + + it('fail with empty Error falls back to "Unknown error"', () => { + const s = new NotificationStep('x', 1); + const e = new Error(); + e.message = ''; + e.stack = ''; + s.fail(e); + assert.equal(s.error.message, 'Unknown error'); + }); +}); + +describe('NotificationStep estimate', () => { + it('setEstimate stores the supplied number', () => { + const s = new NotificationStep('x', 1); + s.setEstimate(7); + assert.equal(s.estimate, 7); + }); + + it('addEstimate computes (children + delta)', () => { + const s = new NotificationStep('x', 1); + s.addStep('a').addStep?.('b'); // ignore second result + s.addStep('b'); + s.addEstimate(3); + assert.equal(s.estimate, 5); // 2 children + 3 + }); +}); + +describe('NotificationStep child steps', () => { + it('addStep registers and returns a child step', () => { + const root = new NotificationStep('root', 1); + const child = root.addStep('child', 2, true); + assert.instanceOf(child, NotificationStep); + assert.equal(child.name, 'child'); + assert.equal(child.size, 2); + assert.isTrue(child.minimized); + }); + + it('getStep finds an existing child by name', () => { + const root = new NotificationStep('root', 1); + root.addStep('child', 1); + assert.equal(root.getStep('child').name, 'child'); + assert.isUndefined(root.getStep('missing')); + }); + + it('startStep starts a registered child', () => { + const root = new NotificationStep('root', 1); + root.addStep('child', 1); + const child = root.startStep('child'); + assert.isTrue(child.started); + }); + + it('startStep throws if the named step is not registered', () => { + const root = new NotificationStep('root', 1); + assert.throws(() => root.startStep('missing'), /Step missing not found/); + }); +}); + +describe('NotificationStep.findStepById', () => { + it('finds itself when id matches', () => { + const s = new NotificationStep('x', 1); + s.setId('id-1'); + assert.strictEqual(s.findStepById('id-1'), s); + }); + + it('descends into children to find a matching id', () => { + const root = new NotificationStep('root', 1); + const child = root.addStep('c1', 1); + child.setId('child-id'); + assert.strictEqual(root.findStepById('child-id'), child); + }); + + it('returns null when no descendant matches', () => { + const root = new NotificationStep('root', 1); + root.addStep('c1', 1); + assert.isNull(root.findStepById('nope')); + }); +}); + +describe('NotificationStep.info()', () => { + it('reports progress=0/index=0 on a fresh step (not started)', () => { + const s = new NotificationStep('x', 1); + const info = s.info(); + assert.equal(info.progress, 0); + assert.equal(info.index, 0); + assert.equal(info.message, ''); + }); + + it('reports progress=100 on a completed step', () => { + const s = new NotificationStep('x', 1).start().complete(); + const info = s.info(); + assert.equal(info.progress, 100); + assert.equal(info.message, 'x'); + }); + + it('reports progress=100 even on a failed step (fail() also marks completed=true)', () => { + // fail() sets both completed=true and failed=true; the completed branch wins in info(). + const s = new NotificationStep('x', 1).start(); + s.fail('blew up'); + const info = s.info(); + assert.equal(info.progress, 100); + assert.deepEqual(info.error, { code: 500, message: 'blew up' }); + }); + + it('omits child steps from info when minimized', () => { + const root = new NotificationStep('root', 1); + root.addStep('hidden', 1); + root.minimize(true); + const info = root.info(); + assert.deepEqual(info.steps, []); + }); + + it('includes child steps when not minimized', () => { + const root = new NotificationStep('root', 1); + root.addStep('shown', 1); + const info = root.info(); + assert.equal(info.steps.length, 1); + assert.equal(info.steps[0].name, 'shown'); + }); +}); diff --git a/common/tests/unit-tests/misc/policy-document-entities.test.mjs b/common/tests/unit-tests/misc/policy-document-entities.test.mjs new file mode 100644 index 0000000000..35781abb2a --- /dev/null +++ b/common/tests/unit-tests/misc/policy-document-entities.test.mjs @@ -0,0 +1,99 @@ +import { assert } from 'chai'; +import { PolicyComment } from '../../../dist/entity/policy-comment.js'; +import { PolicyDiscussion } from '../../../dist/entity/policy-discussion.js'; +import { ApprovalDocument } from '../../../dist/entity/approval-document.js'; +import { AggregateVC } from '../../../dist/entity/aggregate-documents.js'; +import { SplitDocuments } from '../../../dist/entity/split-documents.js'; +import { PolicyStatisticDocument } from '../../../dist/entity/policy-statistic-document.js'; +import { PolicyLabelDocument } from '../../../dist/entity/policy-label-document.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('PolicyComment.setDefaults (no document)', () => { + it('generates a uuid and sets doc/prop hashes from the uuid', async () => { + const c = new PolicyComment(); + await c.setDefaults(); + assert.match(c.uuid, UUID_RE); + assert.equal(c._docHash.length, 32); + assert.equal(c._propHash.length, 32); + }); + + it('keeps an existing uuid', async () => { + const c = new PolicyComment(); + c.uuid = 'fixed'; + await c.setDefaults(); + assert.equal(c.uuid, 'fixed'); + }); +}); + +describe('PolicyDiscussion.setDefaults (no document)', () => { + it('generates a uuid and computes hashes', async () => { + const d = new PolicyDiscussion(); + await d.setDefaults(); + assert.match(d.uuid, UUID_RE); + assert.equal(d._docHash.length, 32); + assert.equal(d._propHash.length, 32); + }); +}); + +describe('ApprovalDocument.setDefaults (no document)', () => { + it('defaults option.status to NEW and computes hashes', async () => { + const a = new ApprovalDocument(); + await a.setDefaults(); + assert.isObject(a.option); + assert.equal(a.option.status, 'NEW'); + assert.equal(a._docHash, ''); + assert.equal(a._propHash.length, 32); + }); + + it('keeps an existing option.status', async () => { + const a = new ApprovalDocument(); + a.option = { status: 'Approved' }; + await a.setDefaults(); + assert.equal(a.option.status, 'Approved'); + }); +}); + +describe('AggregateVC.setDefaults (no document)', () => { + it('is a no-op when no document (does not throw)', async () => { + const a = new AggregateVC(); + await a.setDefaults(); + assert.isUndefined(a.documentFileId); + }); + + it('extends BaseEntity (createDate present)', () => { + const a = new AggregateVC(); + assert.instanceOf(a.createDate, Date); + }); +}); + +describe('SplitDocuments.setDefaults (no document)', () => { + it('is a no-op when no document (does not throw)', async () => { + const s = new SplitDocuments(); + await s.setDefaults(); + assert.isUndefined(s.documentFileId); + }); +}); + +describe('PolicyStatisticDocument.setDefaults (no document)', () => { + it('generates a uuid', async () => { + const s = new PolicyStatisticDocument(); + await s.setDefaults(); + assert.match(s.uuid, UUID_RE); + }); +}); + +describe('PolicyLabelDocument.setDefaults (no document)', () => { + it('generates a uuid', async () => { + const l = new PolicyLabelDocument(); + await l.setDefaults(); + assert.match(l.uuid, UUID_RE); + }); + + it('keeps an existing uuid', async () => { + const l = new PolicyLabelDocument(); + l.uuid = 'keep'; + await l.setDefaults(); + assert.equal(l.uuid, 'keep'); + }); +}); diff --git a/common/tests/unit-tests/misc/policy-property.test.mjs b/common/tests/unit-tests/misc/policy-property.test.mjs new file mode 100644 index 0000000000..9d4d114d6b --- /dev/null +++ b/common/tests/unit-tests/misc/policy-property.test.mjs @@ -0,0 +1,77 @@ +import { assert } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { GetPropertiesFromFile } from '../../../dist/helpers/policy-property.js'; + +let tmpDir; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'policy-property-')); +}); + +after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +function writeCsv(name, content) { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content); + return p; +} + +describe('GetPropertiesFromFile', () => { + it('parses each comma-delimited row into {title, value}', async () => { + const file = writeCsv( + 'basic.csv', + 'title-a,value-a\ntitle-b,value-b\ntitle-c,value-c' + ); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [ + { title: 'title-a', value: 'value-a' }, + { title: 'title-b', value: 'value-b' }, + { title: 'title-c', value: 'value-c' }, + ]); + }); + + it('skips rows with missing title (empty first column)', async () => { + const file = writeCsv( + 'missing-title.csv', + ',orphan-value\ntitle-keep,value-keep' + ); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'title-keep', value: 'value-keep' }]); + }); + + it('skips rows that do not have exactly two columns', async () => { + const file = writeCsv( + 'wrong-columns.csv', + 'only-one\nthree,col,row\nvalid,value' + ); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'valid', value: 'value' }]); + }); + + it('returns an empty array for an empty file', async () => { + const file = writeCsv('empty.csv', ''); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('handles a single-row file', async () => { + const file = writeCsv('one.csv', 'lonely,value'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'lonely', value: 'value' }]); + }); + + it('rejects when the file does not exist', async () => { + try { + await GetPropertiesFromFile(path.join(tmpDir, 'no-such-file.csv')); + assert.fail('expected to reject'); + } catch (err) { + assert.match(err.message, /ENOENT/); + } + }); +}); diff --git a/common/tests/unit-tests/misc/restore-entities.test.mjs b/common/tests/unit-tests/misc/restore-entities.test.mjs new file mode 100644 index 0000000000..5fa8b92e2e --- /dev/null +++ b/common/tests/unit-tests/misc/restore-entities.test.mjs @@ -0,0 +1,135 @@ +import { assert } from 'chai'; +import { MintRequest } from '../../../dist/entity/mint-request.js'; +import { MintTransaction } from '../../../dist/entity/mint-transaction.js'; +import { RetirePool } from '../../../dist/entity/retire-pool.js'; +import { RetireRequest } from '../../../dist/entity/retire-request.js'; + +describe('MintRequest entity', () => { + it('extends BaseEntity (createDate present)', () => { + const m = new MintRequest(); + assert.instanceOf(m.createDate, Date); + }); + + it('applies documented boolean defaults', () => { + const m = new MintRequest(); + assert.equal(m.isMintNeeded, true); + assert.equal(m.isTransferNeeded, false); + assert.equal(m.wasTransferNeeded, false); + }); + + it('createDocument sets a deterministic propHash and empty docHash', async () => { + const m = new MintRequest(); + m.amount = 100; + m.tokenId = '0.0.1'; + m.target = '0.0.2'; + m.vpMessageId = 'vp-1'; + m.memo = 'memo'; + await m.createDocument(); + assert.isString(m._propHash); + assert.equal(m._propHash.length, 32); + assert.equal(m._docHash, ''); + }); + + it('createDocument is stable for identical inputs', async () => { + const a = new MintRequest(); + a.amount = 5; + a.tokenId = 't'; + a.target = 'x'; + a.vpMessageId = 'v'; + a.memo = 'm'; + await a.createDocument(); + + const b = new MintRequest(); + b.amount = 5; + b.tokenId = 't'; + b.target = 'x'; + b.vpMessageId = 'v'; + b.memo = 'm'; + await b.createDocument(); + + assert.equal(a._propHash, b._propHash); + }); + + it('createDocument hash changes when a property changes', async () => { + const a = new MintRequest(); + a.amount = 1; + a.tokenId = 't'; + await a.createDocument(); + const first = a._propHash; + a.amount = 2; + await a.createDocument(); + assert.notEqual(a._propHash, first); + }); +}); + +describe('MintTransaction entity', () => { + it('extends BaseEntity (createDate present)', () => { + const m = new MintTransaction(); + assert.instanceOf(m.createDate, Date); + }); + + it('createDocument sets propHash and empty docHash', async () => { + const m = new MintTransaction(); + m.amount = 10; + m.mintRequestId = 'r-1'; + m.mintStatus = 'NEW'; + m.transferStatus = 'NEW'; + await m.createDocument(); + assert.isString(m._propHash); + assert.equal(m._docHash, ''); + }); + + it('createDocument hash reflects serials', async () => { + const m = new MintTransaction(); + m.amount = 1; + m.mintRequestId = 'r'; + await m.createDocument(); + const before = m._propHash; + m.serials = [1, 2, 3]; + await m.createDocument(); + assert.notEqual(m._propHash, before); + }); +}); + +describe('RetirePool entity', () => { + it('enabled defaults to false', () => { + const p = new RetirePool(); + assert.equal(p.enabled, false); + }); + + it('setTokens derives a unique tokenIds list from tokens', () => { + const p = new RetirePool(); + p.tokens = [ + { token: '0.0.1' }, + { token: '0.0.2' }, + { token: '0.0.1' } + ]; + p.setTokens(); + assert.deepEqual(p.tokenIds, ['0.0.1', '0.0.2']); + }); + + it('setTokens yields an empty list for no tokens', () => { + const p = new RetirePool(); + p.tokens = []; + p.setTokens(); + assert.deepEqual(p.tokenIds, []); + }); +}); + +describe('RetireRequest entity', () => { + it('setTokens derives a unique tokenIds list from tokens', () => { + const r = new RetireRequest(); + r.tokens = [ + { token: 'a' }, + { token: 'a' }, + { token: 'b' } + ]; + r.setTokens(); + assert.deepEqual(r.tokenIds, ['a', 'b']); + }); + + it('extends BaseEntity (createDate present)', () => { + const r = new RetireRequest(); + assert.instanceOf(r.createDate, Date); + }); +}); diff --git a/common/tests/unit-tests/misc/restore-entity.test.mjs b/common/tests/unit-tests/misc/restore-entity.test.mjs new file mode 100644 index 0000000000..915e1d017c --- /dev/null +++ b/common/tests/unit-tests/misc/restore-entity.test.mjs @@ -0,0 +1,83 @@ +import { assert } from 'chai'; +import crypto from 'node:crypto'; +import { RestoreEntity } from '../../../dist/models/restore-entity.js'; + +class TestRestore extends RestoreEntity { + async deleteCache() {} + + bumpProp(prop) { + this._updatePropHash(prop); + } + bumpDoc(doc) { + this._updateDocHash(doc); + } +} + +const md5 = (s) => crypto.createHash('md5').update(s).digest('hex'); + +describe('RestoreEntity._updatePropHash', () => { + it('sets _propHash to MD5 of JSON.stringify(prop)', () => { + const e = new TestRestore(); + const prop = { a: 1, b: 'two' }; + e.bumpProp(prop); + assert.equal(e._propHash, md5(JSON.stringify(prop))); + }); + + it('different props produce different hashes', () => { + const a = new TestRestore(); + const b = new TestRestore(); + a.bumpProp({ x: 1 }); + b.bumpProp({ x: 2 }); + assert.notEqual(a._propHash, b._propHash); + }); + + it('identical props produce identical hashes (deterministic)', () => { + const a = new TestRestore(); + const b = new TestRestore(); + const same = { foo: 'bar' }; + a.bumpProp(same); + b.bumpProp(same); + assert.equal(a._propHash, b._propHash); + }); +}); + +describe('RestoreEntity._updateDocHash', () => { + it('sets _docHash to MD5 of the document string', () => { + const e = new TestRestore(); + e.bumpDoc('hello world'); + assert.equal(e._docHash, md5('hello world')); + }); + + it('sets _docHash to "" when document is empty/falsy', () => { + const e = new TestRestore(); + e.bumpDoc(''); + assert.equal(e._docHash, ''); + e.bumpDoc(null); + assert.equal(e._docHash, ''); + e.bumpDoc(undefined); + assert.equal(e._docHash, ''); + }); + + it('different documents produce different hashes', () => { + const a = new TestRestore(); + const b = new TestRestore(); + a.bumpDoc('alpha'); + b.bumpDoc('beta'); + assert.notEqual(a._docHash, b._docHash); + }); +}); + +describe('RestoreEntity inherits BaseEntity behaviors', () => { + it('has createDate / updateDate from BaseEntity initialiser', () => { + const e = new TestRestore(); + assert.instanceOf(e.createDate, Date); + assert.instanceOf(e.updateDate, Date); + }); + + it('exposes _restoreId, _propHash, _docHash as undefined until set', () => { + const e = new TestRestore(); + assert.equal(e._restoreId, undefined); + assert.equal(e._propHash, undefined); + assert.equal(e._docHash, undefined); + }); +}); diff --git a/common/tests/unit-tests/misc/serialization.test.mjs b/common/tests/unit-tests/misc/serialization.test.mjs new file mode 100644 index 0000000000..aef03611e3 --- /dev/null +++ b/common/tests/unit-tests/misc/serialization.test.mjs @@ -0,0 +1,45 @@ +import { assert } from 'chai'; +import { + InboundMessageIdentityDeserializer, + OutboundResponseIdentitySerializer, +} from '../../../dist/mq/serialization.js'; + +describe('InboundMessageIdentityDeserializer', () => { + it('returns the input value unchanged (identity)', () => { + const d = new InboundMessageIdentityDeserializer(); + const value = { pattern: 'PING', data: { hello: 'world' }, id: 'req-1' }; + assert.strictEqual(d.deserialize(value), value); + }); + + it('passes through primitives and arrays', () => { + const d = new InboundMessageIdentityDeserializer(); + assert.equal(d.deserialize(42), 42); + assert.equal(d.deserialize('s'), 's'); + const arr = [1, 2, 3]; + assert.strictEqual(d.deserialize(arr), arr); + }); + + it('ignores the optional options arg', () => { + const d = new InboundMessageIdentityDeserializer(); + const value = { x: 1 }; + assert.strictEqual(d.deserialize(value, { irrelevant: true }), value); + }); +}); + +describe('OutboundResponseIdentitySerializer', () => { + it('extracts and returns value.data', () => { + const s = new OutboundResponseIdentitySerializer(); + const wrapped = { data: { foo: 'bar' }, id: 'res-1' }; + assert.deepEqual(s.serialize(wrapped), { foo: 'bar' }); + }); + + it('returns undefined when data is missing', () => { + const s = new OutboundResponseIdentitySerializer(); + assert.equal(s.serialize({ id: 'x' }), undefined); + }); + + it('returns null when data is explicitly null', () => { + const s = new OutboundResponseIdentitySerializer(); + assert.isNull(s.serialize({ data: null })); + }); +}); diff --git a/common/tests/unit-tests/misc/sheet-name.test.mjs b/common/tests/unit-tests/misc/sheet-name.test.mjs new file mode 100644 index 0000000000..454b6fbc83 --- /dev/null +++ b/common/tests/unit-tests/misc/sheet-name.test.mjs @@ -0,0 +1,69 @@ +import { assert } from 'chai'; +import { SheetName } from '../../../dist/xlsx/models/sheet-name.js'; + +describe('SheetName.getSheetName', () => { + it('returns the sanitized name when first-seen', () => { + const sn = new SheetName(); + assert.equal(sn.getSheetName('Schema A', 30), 'Schema A'); + }); + + it('strips Excel-forbidden characters * ? : \\ / [ ]', () => { + const sn = new SheetName(); + assert.equal(sn.getSheetName('a*?:[\\/]b', 30), 'ab'); + }); + + it('truncates to size or 30, whichever is smaller', () => { + const sn = new SheetName(); + const long = 'X'.repeat(60); + const out = sn.getSheetName(long, 10); + assert.equal(out.length, 10); + }); + + it('caps truncation at 30 even when caller passes a larger size', () => { + const sn = new SheetName(); + const long = 'Y'.repeat(60); + const out = sn.getSheetName(long, 60); + assert.equal(out.length, 30); + }); + + it('returns "blank" when input becomes empty after sanitization (first call)', () => { + assert.equal(new SheetName().getSheetName('', 30), 'blank'); + assert.equal(new SheetName().getSheetName('***', 30), 'blank'); + }); + + it('appends a numeric suffix on collision (case-insensitive dedup; uses current call casing)', () => { + const sn = new SheetName(); + sn.getSheetName('Foo', 30); + const second = sn.getSheetName('foo', 30); + // Dedup compares lowercased; the suffix uses the *current* call's casing + assert.match(second, /^foo\s2$/); + }); + + it('keeps incrementing the suffix across further collisions', () => { + const sn = new SheetName(); + sn.getSheetName('Bar', 30); + sn.getSheetName('Bar', 30); // -> "Bar 2" + const third = sn.getSheetName('Bar', 30); + assert.match(third, /^Bar\s3$/); + }); +}); + +describe('SheetName.getSchemaName / getToolName / getEnumName', () => { + it('schema name is constrained to 30 chars and reuses dedup logic', () => { + const sn = new SheetName(); + const a = sn.getSchemaName('My Schema'); + const b = sn.getSchemaName('My Schema'); + assert.equal(a, 'My Schema'); + assert.match(b, /^My Schema\s2$/); + }); + + it('tool name is suffixed with " (tool)"', () => { + const sn = new SheetName(); + assert.equal(sn.getToolName('MyTool'), 'MyTool (tool)'); + }); + + it('enum name is suffixed with " (enum)"', () => { + const sn = new SheetName(); + assert.equal(sn.getEnumName('Status'), 'Status (enum)'); + }); +}); diff --git a/common/tests/unit-tests/misc/simple-entities.test.mjs b/common/tests/unit-tests/misc/simple-entities.test.mjs new file mode 100644 index 0000000000..c2f9b59567 --- /dev/null +++ b/common/tests/unit-tests/misc/simple-entities.test.mjs @@ -0,0 +1,78 @@ +import { assert } from 'chai'; +import { Log } from '../../../dist/entity/log.js'; +import { PolicyProperty } from '../../../dist/entity/policy-property.js'; +import { Settings } from '../../../dist/entity/settings.js'; + +describe('Log entity (common)', () => { + it('extends BaseEntity (createDate/updateDate present)', () => { + const l = new Log(); + assert.instanceOf(l.createDate, Date); + assert.instanceOf(l.updateDate, Date); + }); + + it('initialises datetime to a Date close to now', () => { + const before = Date.now(); + const l = new Log(); + const after = Date.now(); + assert.instanceOf(l.datetime, Date); + assert.isAtLeast(l.datetime.getTime(), before - 5); + assert.isAtMost(l.datetime.getTime(), after + 5); + }); + + it('exposes assignable message/type/attributes/userId fields', () => { + const l = new Log(); + l.message = 'hello'; + l.type = 'INFO'; + l.attributes = ['a', 'b']; + l.userId = 'u-1'; + assert.equal(l.message, 'hello'); + assert.equal(l.type, 'INFO'); + assert.deepEqual(l.attributes, ['a', 'b']); + assert.equal(l.userId, 'u-1'); + }); + + it('attributes / userId are optional and undefined by default', () => { + const l = new Log(); + assert.equal(l.attributes, undefined); + assert.equal(l.userId, undefined); + }); +}); + +describe('PolicyProperty entity', () => { + it('extends BaseEntity', () => { + const p = new PolicyProperty(); + assert.instanceOf(p.createDate, Date); + }); + + it('exposes assignable title/value fields', () => { + const p = new PolicyProperty(); + p.title = 'limit'; + p.value = '42'; + assert.equal(p.title, 'limit'); + assert.equal(p.value, '42'); + }); +}); + +describe('Settings entity', () => { + it('extends BaseEntity', () => { + const s = new Settings(); + assert.instanceOf(s.createDate, Date); + }); + + it('name / value are optional and undefined by default', () => { + const s = new Settings(); + assert.equal(s.name, undefined); + assert.equal(s.value, undefined); + }); + + it('toJSON includes the assigned name/value fields', () => { + const s = new Settings(); + s.id = 'sid'; + s.name = 'flag'; + s.value = 'on'; + const json = s.toJSON(); + assert.equal(json.id, 'sid'); + assert.equal(json.name, 'flag'); + assert.equal(json.value, 'on'); + }); +}); diff --git a/common/tests/unit-tests/misc/singleton-decorator.test.mjs b/common/tests/unit-tests/misc/singleton-decorator.test.mjs new file mode 100644 index 0000000000..971f1c8301 --- /dev/null +++ b/common/tests/unit-tests/misc/singleton-decorator.test.mjs @@ -0,0 +1,64 @@ +import { assert } from 'chai'; +import { Singleton } from '../../../dist/decorators/singleton.js'; + +describe('Singleton (functional decorator usage)', () => { + it('returns the same instance on repeated `new` calls', () => { + class Plain { + constructor() { + this.value = Math.random(); + } + } + const Wrapped = Singleton(Plain); + const a = new Wrapped(); + const b = new Wrapped(); + assert.strictEqual(a, b); + assert.strictEqual(a.value, b.value); + }); + + it('the cached instance keeps the constructor arguments from the first call', () => { + class Greeter { + constructor(name) { + this.name = name; + } + } + const Wrapped = Singleton(Greeter); + const a = new Wrapped('Alice'); + const b = new Wrapped('Bob'); + assert.equal(a.name, 'Alice'); + assert.equal(b.name, 'Alice', 'second new() must reuse first instance, not rebuild'); + }); + + it('subclasses still construct independently per call (not affected by parent singleton cache)', () => { + class Base { + constructor() { this.kind = 'base'; } + } + const WrappedBase = Singleton(Base); + + class Child extends WrappedBase { + constructor() { + super(); + this.kind = 'child'; + } + } + + const c1 = new Child(); + const c2 = new Child(); + // Subclass construction goes through the Reflect.construct branch, + // so each `new Child()` produces a new Child instance. + assert.notStrictEqual(c1, c2); + assert.equal(c1.kind, 'child'); + assert.equal(c2.kind, 'child'); + }); + + it('two different singletoned classes have independent caches', () => { + class X {} + class Y {} + const SX = Singleton(X); + const SY = Singleton(Y); + const x = new SX(); + const y = new SY(); + assert.notStrictEqual(x, y); + assert.instanceOf(x, X); + assert.instanceOf(y, Y); + }); +}); diff --git a/common/tests/unit-tests/misc/tag-indexer.test.mjs b/common/tests/unit-tests/misc/tag-indexer.test.mjs new file mode 100644 index 0000000000..a190139379 --- /dev/null +++ b/common/tests/unit-tests/misc/tag-indexer.test.mjs @@ -0,0 +1,60 @@ +import { assert } from 'chai'; +import { TagIndexer } from '../../../dist/xlsx/models/tag-indexer.js'; +import { BlockType } from '@guardian/interfaces'; + +describe('TagIndexer.getTag', () => { + it('returns Tool_ for BlockType.Tool', () => { + const ti = new TagIndexer(); + const tag = ti.getTag(BlockType.Tool, null); + assert.match(tag, /^Tool_\d+$/); + }); + + it('returns "Root_Holder_" for Container with no data', () => { + const ti = new TagIndexer(); + const tag = ti.getTag(BlockType.Container, null); + assert.match(tag, /^Root_Holder_\d+$/); + }); + + it('returns "_Schema_Holder_" for Container with data', () => { + const ti = new TagIndexer(); + const tag = ti.getTag(BlockType.Container, { name: 'My Schema Name' }); + assert.match(tag, /^My_Schema_Name_Schema_Holder_\d+$/); + }); + + it('truncates the data.name prefix to 20 characters', () => { + const ti = new TagIndexer(); + const longName = 'X'.repeat(40); + const tag = ti.getTag(BlockType.Container, { name: longName }); + // Prefix is "X" repeated 20 times, then "_Schema_Holder_" + const prefix = tag.split('_Schema_Holder_')[0]; + assert.equal(prefix.length, 20); + }); + + it('returns "Schema_Entry_" for BlockType.Request', () => { + const ti = new TagIndexer(); + const tag = ti.getTag(BlockType.Request, null); + assert.match(tag, /^Schema_Entry_\d+$/); + }); + + it('returns "Schema_Calculations_" for BlockType.CustomLogicBlock', () => { + const ti = new TagIndexer(); + const tag = ti.getTag(BlockType.CustomLogicBlock, null); + assert.match(tag, /^Schema_Calculations_\d+$/); + }); + + it('falls back to "Autogenerated_" for any other block type', () => { + const ti = new TagIndexer(); + const tag = ti.getTag('SOMETHING_ELSE', null); + assert.match(tag, /^Autogenerated_\d+$/); + }); + + it('emits monotonically increasing indices on subsequent calls', () => { + const ti = new TagIndexer(); + const a = ti.getTag(BlockType.Request, null); + const b = ti.getTag(BlockType.Request, null); + const c = ti.getTag(BlockType.Request, null); + const idx = (s) => parseInt(s.match(/\d+$/)[0], 10); + assert.isAbove(idx(b), idx(a)); + assert.isAbove(idx(c), idx(b)); + }); +}); diff --git a/common/tests/unit-tests/misc/value-converters.test.mjs b/common/tests/unit-tests/misc/value-converters.test.mjs new file mode 100644 index 0000000000..4005b0cc45 --- /dev/null +++ b/common/tests/unit-tests/misc/value-converters.test.mjs @@ -0,0 +1,104 @@ +import { assert } from 'chai'; +import { + entityToXlsx, + xlsxToEntity, + xlsxToUnit, + xlsxToFont, + xlsxToPresetArray, + xlsxToPresetValue, +} from '../../../dist/xlsx/models/value-converters.js'; +import { SchemaEntity } from '@guardian/interfaces'; + +describe('entityToXlsx', () => { + it('maps SchemaEntity.VC to "Verifiable Credentials"', () => { + assert.equal(entityToXlsx(SchemaEntity.VC), 'Verifiable Credentials'); + }); + + it('maps SchemaEntity.EVC to "Encrypted Verifiable Credential"', () => { + assert.equal(entityToXlsx(SchemaEntity.EVC), 'Encrypted Verifiable Credential'); + }); + + it('falls back to "Sub-Schema" for any other entity', () => { + assert.equal(entityToXlsx(SchemaEntity.NONE), 'Sub-Schema'); + assert.equal(entityToXlsx('UnknownEntity'), 'Sub-Schema'); + }); +}); + +describe('xlsxToEntity', () => { + it('accepts both "VC" and the long form', () => { + assert.equal(xlsxToEntity('VC'), SchemaEntity.VC); + assert.equal(xlsxToEntity('Verifiable Credentials'), SchemaEntity.VC); + }); + + it('accepts both "EVC" and the long form', () => { + assert.equal(xlsxToEntity('EVC'), SchemaEntity.EVC); + assert.equal(xlsxToEntity('Encrypted Verifiable Credential'), SchemaEntity.EVC); + }); + + it('returns NONE for unrecognised input', () => { + assert.equal(xlsxToEntity('something else'), SchemaEntity.NONE); + assert.equal(xlsxToEntity(''), SchemaEntity.NONE); + }); +}); + +describe('xlsxToUnit', () => { + it('extracts the unit-bearing token from a number-format string', () => { + // Strips the digits/format glyphs and keeps the alphanumeric token + assert.equal(xlsxToUnit('#,##0.00 USD'), 'USD'); + assert.equal(xlsxToUnit('0.00 kg'), 'kg'); + }); +}); + +describe('xlsxToFont', () => { + it('parses a JSON string into the matching object', () => { + assert.deepEqual(xlsxToFont('{"bold":true,"size":"12px"}'), { bold: true, size: '12px' }); + }); + + it('returns {} for malformed JSON strings (try/catch)', () => { + assert.deepEqual(xlsxToFont('not-json'), {}); + }); + + it('returns {} for null/undefined input (no `value` branch hits)', () => { + assert.deepEqual(xlsxToFont(null), {}); + assert.deepEqual(xlsxToFont(undefined), {}); + }); +}); + +describe('xlsxToPresetValue', () => { + it('returns "" for null/undefined/empty-string values', () => { + assert.equal(xlsxToPresetValue({ type: 'string' }, null), ''); + assert.equal(xlsxToPresetValue({ type: 'string' }, undefined), ''); + assert.equal(xlsxToPresetValue({ type: 'string' }, ''), ''); + }); + + it('passes through non-ref values unchanged', () => { + assert.equal(xlsxToPresetValue({ type: 'string' }, 'plain'), 'plain'); + assert.equal(xlsxToPresetValue({ type: 'integer' }, 42), 42); + }); + + it('parses a ref field as JSON', () => { + const out = xlsxToPresetValue({ type: 'ref', isRef: true }, '{"a":1}'); + assert.deepEqual(out, { a: 1 }); + }); + + it('returns "" when a ref field cannot be parsed as JSON', () => { + assert.equal(xlsxToPresetValue({ type: 'ref', isRef: true }, 'not-json'), ''); + }); +}); + +describe('xlsxToPresetArray', () => { + it('returns null for null/undefined input', () => { + assert.isNull(xlsxToPresetArray({ type: 'string' }, null)); + assert.isNull(xlsxToPresetArray({ type: 'string' }, undefined)); + }); + + it('splits a CSV-style value into its parts', () => { + const arr = xlsxToPresetArray({ type: 'string' }, 'a,b,c'); + assert.deepEqual(arr, ['a', 'b', 'c']); + }); + + it('preserves quoted segments containing commas', () => { + const arr = xlsxToPresetArray({ type: 'string' }, '"a,b","c"'); + assert.deepEqual(arr, ['a,b', 'c']); + }); +}); diff --git a/common/tests/unit-tests/misc/vc-subject.test.mjs b/common/tests/unit-tests/misc/vc-subject.test.mjs new file mode 100644 index 0000000000..7d00caacc9 --- /dev/null +++ b/common/tests/unit-tests/misc/vc-subject.test.mjs @@ -0,0 +1,162 @@ +import { assert } from 'chai'; +import { VcSubject } from '../../../dist/hedera-modules/vcjs/vc-subject.js'; + +describe('VcSubject.create', () => { + it('throws on null/undefined input', () => { + assert.throws(() => VcSubject.create(null), /Subject is empty/); + assert.throws(() => VcSubject.create(undefined), /Subject is empty/); + }); + + it('extracts id, type, @context onto the instance and removes them from document', () => { + const subject = VcSubject.create({ + id: 'urn:uuid:abc', + type: 'PolicyClaim', + '@context': 'https://example.org/ctx', + field1: 'v1', + field2: 42, + }); + assert.equal(subject.getId(), 'urn:uuid:abc'); + assert.equal(subject.getType(), 'PolicyClaim'); + assert.deepEqual(subject.getContext(), ['https://example.org/ctx']); + assert.deepEqual(subject.getFields(), { field1: 'v1', field2: 42 }); + }); + + it('coerces a bare uuid id to urn:uuid: form', () => { + const subject = VcSubject.create({ id: 'abc' }); + assert.equal(subject.getId(), 'urn:uuid:abc'); + }); + + it('keeps an id that already has a colon (e.g. did:hedera:...) untouched', () => { + const subject = VcSubject.create({ id: 'did:hedera:testnet:xyz' }); + assert.equal(subject.getId(), 'did:hedera:testnet:xyz'); + }); + + it('accepts an array @context and dedups via addContext', () => { + const subject = VcSubject.create({ + '@context': ['https://a.example/ctx', 'https://a.example/ctx', 'https://b.example/ctx'], + }); + assert.deepEqual(subject.getContext(), ['https://a.example/ctx', 'https://b.example/ctx']); + }); + + it('handles missing @context gracefully (empty array)', () => { + const subject = VcSubject.create({ field: 'x' }); + assert.deepEqual(subject.getContext(), []); + }); +}); + +describe('VcSubject mutation methods', () => { + it('setField writes into the document', () => { + const subject = VcSubject.create({ id: 'urn:uuid:1' }); + subject.setField('newField', 'value'); + assert.equal(subject.getFields().newField, 'value'); + }); + + it('removeField deletes from the document', () => { + const subject = VcSubject.create({ field: 'v' }); + subject.removeField('field'); + assert.notProperty(subject.getFields(), 'field'); + }); + + it('frameField replaces the value with an empty object', () => { + const subject = VcSubject.create({ nested: 'old' }); + subject.frameField('nested'); + assert.deepEqual(subject.getFields().nested, {}); + }); + + it('setId coerces bare uuid via convertUUID', () => { + const subject = VcSubject.create({}); + subject.setId('plain'); + assert.equal(subject.getId(), 'urn:uuid:plain'); + }); + + it('addContext deduplicates repeated entries', () => { + const subject = VcSubject.create({}); + subject.addContext('https://a.example'); + subject.addContext('https://a.example'); + subject.addContext('https://b.example'); + assert.deepEqual(subject.getContext(), ['https://a.example', 'https://b.example']); + }); + + it('addContext is a no-op for null/undefined/false', () => { + const subject = VcSubject.create({}); + subject.addContext(null); + subject.addContext(undefined); + subject.addContext(false); + assert.deepEqual(subject.getContext(), []); + }); +}); + +describe('VcSubject.getField', () => { + it('reads a top-level field', () => { + const subject = VcSubject.create({ name: 'alice' }); + assert.equal(subject.getField('name'), 'alice'); + }); + + it('walks a dotted path', () => { + const subject = VcSubject.create({ a: { b: { c: 7 } } }); + assert.equal(subject.getField('a.b.c'), 7); + }); + + it('"L" returns the last array element', () => { + const subject = VcSubject.create({ items: [{ v: 1 }, { v: 2 }, { v: 3 }] }); + assert.equal(subject.getField('items.L.v'), 3); + }); + + it('returns null for any error during traversal', () => { + const subject = VcSubject.create({ a: 1 }); + assert.isNull(subject.getField('a.b.c')); + }); +}); + +describe('VcSubject.toJsonTree / fromJson round-trip', () => { + it('round-trips id, type, @context, and document fields', () => { + const original = VcSubject.create({ + id: 'urn:uuid:42', + type: 'PolicyClaim', + '@context': 'https://example.org/ctx', + field: 'value', + count: 7, + }); + const json = original.toJson(); + const back = VcSubject.fromJson(json); + assert.equal(back.getId(), original.getId()); + assert.equal(back.getType(), original.getType()); + assert.deepEqual(back.getContext(), original.getContext()); + assert.deepEqual(back.getFields(), original.getFields()); + }); + + it('throws on malformed JSON', () => { + assert.throws(() => VcSubject.fromJson('not-json{'), /not a valid VcSubject/); + }); +}); + +describe('VcSubject.toStaticObject', () => { + it('returns a deep clone with system keys stripped at the top level (flat document)', () => { + const subject = VcSubject.create({ + id: 'urn:uuid:1', + type: 'X', + '@context': 'ctx', + field: 'value', + }); + const obj = subject.toStaticObject(); + assert.equal(obj.field, 'value'); + assert.notProperty(obj, 'id'); + assert.notProperty(obj, 'type'); + assert.notProperty(obj, '@context'); + }); + + it('returns a deep clone of the document (mutating the clone does not affect the source)', () => { + const subject = VcSubject.create({ field: 'value' }); + const obj = subject.toStaticObject(); + obj.field = 'changed'; + assert.equal(subject.getField('field'), 'value'); + }); +}); + +describe('VcSubject constants', () => { + it('exposes CREDENTIAL_ID, CREDENTIAL_TYPE, CONTEXT key names', () => { + assert.equal(VcSubject.CREDENTIAL_ID, 'id'); + assert.equal(VcSubject.CREDENTIAL_TYPE, 'type'); + assert.equal(VcSubject.CONTEXT, '@context'); + }); +}); diff --git a/common/tests/unit-tests/mongo-transport/mongo-transport.test.mjs b/common/tests/unit-tests/mongo-transport/mongo-transport.test.mjs new file mode 100644 index 0000000000..b61943919d --- /dev/null +++ b/common/tests/unit-tests/mongo-transport/mongo-transport.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { MongoTransport } from '../../../dist/helpers/mongo-transport.js'; + +function fakeDb(collectionImpl) { + const calls = { requested: [], inserted: [] }; + const collection = collectionImpl === null ? null : { + async insertOne(doc) { calls.inserted.push(doc); }, + ...collectionImpl, + }; + return { + calls, + collection(name) { calls.requested.push(name); return collection; }, + }; +} + +function write(transport, log) { + return new Promise((resolve) => transport._write(typeof log === 'string' ? log : JSON.stringify(log), 'utf8', resolve)); +} + +describe('@unit MongoTransport', () => { + it('resolves the target collection by name at construction', () => { + const db = fakeDb(); + new MongoTransport({ collectionName: 'logs', client: db }); + assert.deepEqual(db.calls.requested, ['logs']); + }); + + it('is an object-mode Writable stream', () => { + const t = new MongoTransport({ collectionName: 'logs', client: fakeDb() }); + assert.equal(t.writableObjectMode, true); + }); + + it('inserts the parsed log document', async () => { + const db = fakeDb(); + const t = new MongoTransport({ collectionName: 'logs', client: db }); + await write(t, { level: 'info', message: 'hi' }); + assert.deepEqual(db.calls.inserted, [{ level: 'info', message: 'hi' }]); + }); + + it('calls back without error on a successful insert', async () => { + const t = new MongoTransport({ collectionName: 'logs', client: fakeDb() }); + const err = await write(t, { a: 1 }); + assert.equal(err, undefined); + }); + + it('calls back with an error on malformed JSON and does not insert', async () => { + const db = fakeDb(); + const t = new MongoTransport({ collectionName: 'logs', client: db }); + const err = await write(t, 'not-json{'); + assert.ok(err instanceof Error); + assert.equal(db.calls.inserted.length, 0); + }); + + it('propagates insertOne failures via the callback', async () => { + const db = fakeDb({ insertOne: async () => { throw new Error('write concern'); } }); + const t = new MongoTransport({ collectionName: 'logs', client: db }); + const err = await write(t, { a: 1 }); + assert.ok(err instanceof Error); + assert.match(err.message, /write concern/); + }); + + it('succeeds without inserting when the collection could not be resolved', async () => { + const db = fakeDb(null); + const t = new MongoTransport({ collectionName: 'logs', client: db }); + const err = await write(t, { a: 1 }); + assert.equal(err, undefined); + }); +}); diff --git a/common/tests/unit-tests/mq-serialization/serialization.test.mjs b/common/tests/unit-tests/mq-serialization/serialization.test.mjs new file mode 100644 index 0000000000..13448b0531 --- /dev/null +++ b/common/tests/unit-tests/mq-serialization/serialization.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { InboundMessageIdentityDeserializer, OutboundResponseIdentitySerializer } from '../../../dist/mq/serialization.js'; + +describe('@unit InboundMessageIdentityDeserializer', () => { + const d = new InboundMessageIdentityDeserializer(); + + it('returns the input value verbatim', () => { + const input = { pattern: 'X', data: { foo: 1 } }; + const out = d.deserialize(input); + assert.strictEqual(out, input); + }); + + it('handles null without throwing', () => { + assert.equal(d.deserialize(null), null); + }); + + it('handles undefined without throwing', () => { + assert.equal(d.deserialize(undefined), undefined); + }); + + it('ignores the options argument (identity contract)', () => { + const input = { x: 1 }; + const out = d.deserialize(input, { ignored: true }); + assert.strictEqual(out, input); + }); +}); + +describe('@unit OutboundResponseIdentitySerializer', () => { + const s = new OutboundResponseIdentitySerializer(); + + it('returns value.data — strips NestJS envelope', () => { + const out = s.serialize({ data: { payload: 'hello' }, id: 'req-1' }); + assert.deepEqual(out, { payload: 'hello' }); + }); + + it('returns undefined when value has no data field', () => { + assert.equal(s.serialize({ id: 'r-1' }), undefined); + }); + + it('does not throw on null/undefined-ish data', () => { + assert.equal(s.serialize({ data: null }), null); + assert.equal(s.serialize({ data: undefined }), undefined); + }); + + it('passes through complex shapes', () => { + const data = { arr: [1, 2, 3], nested: { ok: true } }; + const out = s.serialize({ data }); + assert.strictEqual(out, data); + }); +}); diff --git a/common/tests/unit-tests/mq/codec-serialization-edge.test.mjs b/common/tests/unit-tests/mq/codec-serialization-edge.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d41b2d18de8662ef35174745f6010669fb21c463 GIT binary patch literal 7967 zcmds6TW=dh6y~|VVvs<)RvkA9l#5Fcq3uIaZ%`otQPqxj&&JcPXWiLx9M_6ewGu_* z_67(c`h*Y&350~Wsp6r3!1)VQLOk{-aL&x`de>fioiwc~tSFn>$<%85h4^99YCwM5XgQ~N0J#B>Eg18&W zIFq`N$z+C2?VIZ1T13+BeaMH{Q=$c~^IA^|%`)r3-vs9I7-FZ++!|YZl?@Lue1)J{ z%(uWl!?(}#K^SrWSoVQA*0Dq8{}u$10yn>w)=#WuYpkmx56|2Hxz#xH=A|?1?_4^+ z{_g2>>nE&YTIB=o3k!)F8&GX}6t`y8?BLTF#sdv%$}SNwcha_}*VY)`2n0fcUrPWW zbLchOY}|Z8)A%4e48j;DRPEt(JLU-4S%h{SMP9RnuG)C5X?YnONEs*q)JKU)yV3H0SBn=;+d-7V3hi;;-&QNXw z^O<&1h{??Zw$pJ+?1Z9?D6^=ITq!;{U=uekmY7%qCCXKpDSMq3D0D~B5n61SfGxlD z$6u7ok<3X61#O<(AC)YGh3LRkz|RUlPB;0g_$ zxXCsyRs*{{7utr=)adr&wu@>JRs>k`4&K~l`e^Byq1d^M<;kO*g`cu#*(zJZg1gMp z>OpuviO3^UqH?#b1GL>gwTXu7Rn`%zyq?a@LWAx|l zC%d;Dbnnrxqr0P@c5jbEcg>ig5RqNFya!WDti5|XRIQYfwh^SS0oYn2k1csTMUmXyy$YJ6*; zA;#RYadCdqSv~qfY1T38ZAggR0Uy@Ihs+wbC^^f-OK=l0l0u}%9U-w>5nhuS>W37K z={8m!3}7Z?7t@UH3lG>pK5$go*US@Q@(}C@7C_HRh9)YQ5|6GLb%}U)?Q9ov9 zaWHUFDIz7Wub;R?lY<)Tw?SH%DWp0)MAFj1RAeG}Cg7HFI>`$ksw^4u=^!q;p>rGE?v-C!Ez&RyK3?X%VIRETPY; zHPPtN9K(#iOhTk0n{R$CnQ9F%Uk0G?I*^vnnATZQ>e9MgBS({T`6x}CmWY%2g=}g_ zJZb}(m0?vxq>5xJxgR^+K53_7=nv_57+kXvQXTs&VTm@kPfZ&!2k+$=xn|+uOv;8m zrY)n1%D_$xVzvdUg|qKY3BU!H^$TDuBM>tOU`0_JDWX&7i{Tu-cGy!K`bXX~ z5;SpR9L3m4VPe22riMyUd9bM>$+m@VAPwmJcL%mt-jm_;BYN7nxi+O}+88$2n9<+j zfjqro(@dA<1a#RF$6_}-u*(*i@HXeOfj)I@c7k5El)~JsH1WQNg;}_p)!BdZE<6*m zT!(smgLFWBJ;P}LZ^igMWJ&B7Qd{vD#^YqPM4Bvi**0aiB$VH@M&^cTUju?GBWn&) zDZ@LcpH{&A+H8ZYw&d%*yj=#(h4;@a-p5Ya+O*Rx#M(pUT5~y+B5aDaV?C2w#{;|k N;AEj3Q5J?|@_#wEZm0kN literal 0 HcmV?d00001 diff --git a/common/tests/unit-tests/mq/external-channel.test.mjs b/common/tests/unit-tests/mq/external-channel.test.mjs new file mode 100644 index 0000000000..765eac371e --- /dev/null +++ b/common/tests/unit-tests/mq/external-channel.test.mjs @@ -0,0 +1,149 @@ +import { assert } from 'chai'; +import { ExternalEventChannel } from '../../../dist/mq/external-channel.js'; + +describe('ExternalEventChannel @unit', function () { + function makeFake() { + const calls = []; + return { calls, channel: { publish: (...args) => calls.push(args) } }; + } + + it('new ExternalEventChannel() returns an object instance', function () { + const inst = new ExternalEventChannel(); + assert.isObject(inst); + assert.instanceOf(inst, ExternalEventChannel); + }); + + it('new ExternalEventChannel() twice returns the SAME instance (singleton)', function () { + const a = new ExternalEventChannel(); + const b = new ExternalEventChannel(); + assert.strictEqual(a, b); + }); + + it('exposes setChannel and publishMessage methods', function () { + const inst = new ExternalEventChannel(); + assert.isFunction(inst.setChannel); + assert.isFunction(inst.publishMessage); + }); + + it('publishMessage calls channel.publish exactly once', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('evt', { a: 1 }); + assert.lengthOf(fake.calls, 1); + }); + + it('publishMessage passes (type, data, true) with hardcoded allowError=true', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + const data = { foo: 'bar' }; + inst.publishMessage('my-type', data); + assert.deepStrictEqual(fake.calls[0], ['my-type', data, true]); + assert.strictEqual(fake.calls[0][2], true); + }); + + it('publishMessage passes data through unchanged by reference (object)', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + const data = { nested: { x: 1 } }; + inst.publishMessage('t', data); + assert.strictEqual(fake.calls[0][1], data); + }); + + it('publishMessage passes data through unchanged by reference (array)', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + const data = [1, 2, 3]; + inst.publishMessage('t', data); + assert.strictEqual(fake.calls[0][1], data); + }); + + it('publishMessage forwards a string data value', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('t', 'hello'); + assert.deepStrictEqual(fake.calls[0], ['t', 'hello', true]); + }); + + it('publishMessage forwards a null data value', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('t', null); + assert.deepStrictEqual(fake.calls[0], ['t', null, true]); + }); + + it('publishMessage forwards an undefined data value', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('t', undefined); + assert.deepStrictEqual(fake.calls[0], ['t', undefined, true]); + }); + + it('multiple publishMessage calls each delegate to the channel', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('one', 1); + inst.publishMessage('two', 2); + inst.publishMessage('three', 3); + assert.lengthOf(fake.calls, 3); + assert.deepStrictEqual(fake.calls[0], ['one', 1, true]); + assert.deepStrictEqual(fake.calls[1], ['two', 2, true]); + assert.deepStrictEqual(fake.calls[2], ['three', 3, true]); + }); + + it('forwards the type argument unchanged', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + inst.publishMessage('SOME_EVENT_TYPE', {}); + assert.strictEqual(fake.calls[0][0], 'SOME_EVENT_TYPE'); + }); + + it('re-setChannel redirects subsequent publishes to the new channel', function () { + const inst = new ExternalEventChannel(); + const first = makeFake(); + const second = makeFake(); + inst.setChannel(first.channel); + inst.publishMessage('a', 1); + inst.setChannel(second.channel); + inst.publishMessage('b', 2); + assert.lengthOf(first.calls, 1); + assert.lengthOf(second.calls, 1); + assert.deepStrictEqual(first.calls[0], ['a', 1, true]); + assert.deepStrictEqual(second.calls[0], ['b', 2, true]); + }); + + it('publishMessage before any setChannel throws (channel undefined)', function () { + const Fresh = class FreshExternalEventChannel { + channel; + setChannel(channel) { + this.channel = channel; + } + publishMessage(type, data) { + this.channel.publish(type, data, true); + } + }; + const inst = new Fresh(); + assert.throws(() => inst.publishMessage('t', {})); + }); + + it('setChannel returns undefined', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + assert.strictEqual(inst.setChannel(fake.channel), undefined); + }); + + it('publishMessage returns undefined', function () { + const inst = new ExternalEventChannel(); + const fake = makeFake(); + inst.setChannel(fake.channel); + assert.strictEqual(inst.publishMessage('t', {}), undefined); + }); +}); diff --git a/common/tests/unit-tests/mq/large-payload-container.test.mjs b/common/tests/unit-tests/mq/large-payload-container.test.mjs new file mode 100644 index 0000000000..16b7d6aa73 --- /dev/null +++ b/common/tests/unit-tests/mq/large-payload-container.test.mjs @@ -0,0 +1,50 @@ +import { assert } from 'chai'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +process.env.DIRECT_MESSAGE_PORT = '51234'; +process.env.DIRECT_MESSAGE_HOST = 'payload-host'; +delete process.env.DIRECT_MESSAGE_PROTOCOL; +delete process.env.TLS_SERVER_CERT; +delete process.env.TLS_SERVER_KEY; + +const { LargePayloadContainer } = await import('../../../dist/mq/large-payload-container.js'); + +describe('LargePayloadContainer', () => { + it('is not started before runServer', () => { + assert.isFalse(new LargePayloadContainer().started); + }); + + it('disables TLS when no server cert is configured', () => { + assert.isFalse(new LargePayloadContainer().enableTLS); + }); + + it('behaves as a singleton', () => { + assert.strictEqual(new LargePayloadContainer(), new LargePayloadContainer()); + }); + + it('addObject returns an http url on the configured host and port', () => { + const url = new LargePayloadContainer().addObject(Buffer.from('payload')); + assert.instanceOf(url, URL); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, 'payload-host'); + assert.equal(url.port, '51234'); + }); + + it('addObject uses a uuid as the object path', () => { + const url = new LargePayloadContainer().addObject(Buffer.from('payload')); + assert.match(url.pathname.slice(1), UUID_RE); + }); + + it('addObject generates a distinct url per object', () => { + const container = new LargePayloadContainer(); + const first = container.addObject(Buffer.from('a')); + const second = container.addObject(Buffer.from('b')); + assert.notEqual(first.href, second.href); + }); + + it('accepts Uint8Array payloads', () => { + const url = new LargePayloadContainer().addObject(Uint8Array.from([1, 2, 3])); + assert.instanceOf(url, URL); + }); +}); diff --git a/common/tests/unit-tests/mq/message-broker-channel-loops.test.mjs b/common/tests/unit-tests/mq/message-broker-channel-loops.test.mjs new file mode 100644 index 0000000000..c12f4683d9 --- /dev/null +++ b/common/tests/unit-tests/mq/message-broker-channel-loops.test.mjs @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict'; +import { headers } from 'nats'; + +process.env.QM_VERIFICATION = 'false'; + +const { MessageBrokerChannel } = await import('../../../dist/mq/message-broker-channel.js'); + +function makeMsg({ msgHeaders, data, subject = 'svc.evt' }) { + return { + headers: msgHeaders, + data: typeof data === 'string' ? Buffer.from(data) : data, + subject, + responded: [], + respond(payload, opts) { + this.responded.push({ payload, opts }); + } + }; +} + +function hdr(map) { + const h = headers(); + for (const [k, v] of Object.entries(map)) { + h.set(k, String(v)); + } + return h; +} + +function controllableSub() { + const queue = []; + let resolveNext = null; + let closed = false; + const push = (m) => { + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ value: m, done: false }); + } else { + queue.push(m); + } + }; + const close = () => { + closed = true; + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ value: undefined, done: true }); + } + }; + const iterable = { + [Symbol.asyncIterator]() { + return { + next() { + if (queue.length) { + return Promise.resolve({ value: queue.shift(), done: false }); + } + if (closed) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve) => { resolveNext = resolve; }); + } + }; + } + }; + return { iterable, push, close }; +} + +async function flush() { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +describe('@unit MessageBrokerChannel constructor response loop', () => { + it('skips messages with no headers and with no chunks header', async () => { + const sub = controllableSub(); + const channel = { + subscribe: () => sub.iterable, + publish() {}, + request() { return Promise.resolve(); } + }; + new MessageBrokerChannel(channel, 'svc'); + + sub.push(makeMsg({ msgHeaders: undefined, data: 'x' })); + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'm1' }), data: 'x' })); + await flush(); + sub.close(); + await flush(); + assert.ok(true); + }); + + it('accumulates chunks and continues while incomplete', async () => { + const sub = controllableSub(); + const channel = { + subscribe: () => sub.iterable, + publish() {}, + request() { return Promise.resolve(); } + }; + new MessageBrokerChannel(channel, 'svc'); + + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'mid-a', chunks: 2, chunk: 1 }), data: '{"a":' })); + await flush(); + sub.close(); + await flush(); + assert.ok(true); + }); + + it('accumulates a second chunk for the same messageId then drops it when no request is registered', async () => { + const sub = controllableSub(); + const channel = { + subscribe: () => sub.iterable, + publish() {}, + request() { return Promise.resolve(); } + }; + new MessageBrokerChannel(channel, 'svc'); + + const full = Buffer.from(JSON.stringify({ q: 1 })); + const half = Math.ceil(full.length / 2); + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'orphan', chunks: 2, chunk: 1 }), data: full.subarray(0, half) })); + await flush(); + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'orphan', chunks: 2, chunk: 2 }), data: full.subarray(half) })); + await flush(); + sub.close(); + await flush(); + assert.ok(true); + }); + + it('reassembles chunks and dispatches to a registered request callback', async () => { + const sub = controllableSub(); + const channel = { + subscribe: () => sub.iterable, + publish() {}, + request(subject, data, opts) { + const mid = opts.headers.get('messageId'); + setImmediate(() => { + sub.push(makeMsg({ msgHeaders: hdr({ messageId: mid, chunks: 1, chunk: 1 }), data })); + }); + return Promise.resolve(); + } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + const result = await mbc.request('svc.evt', { hello: 'world' }); + assert.deepEqual(result, { hello: 'world' }); + sub.close(); + await flush(); + }); +}); + +describe('@unit MessageBrokerChannel.response chunked handling', () => { + it('handles a single-chunk request: parses, runs handler, publishes chunked response', async () => { + const sub = controllableSub(); + const published = []; + const channel = { + subscribe: () => sub.iterable, + publish(subject, data, opts) { published.push({ subject, data, opts }); }, + request() { return Promise.resolve(); } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + + const handled = []; + const responsePromise = mbc.response('svc.evt', async (data) => { + handled.push(data); + return { code: 200, body: { echoed: data } }; + }); + + const reqPayload = Buffer.from(JSON.stringify({ x: 42 })); + const m = makeMsg({ msgHeaders: hdr({ messageId: 'r1', chunks: 1, chunk: 1 }), data: reqPayload }); + sub.push(m); + await flush(); + sub.close(); + await responsePromise; + + assert.deepEqual(handled[0], { x: 42 }); + assert.equal(m.responded.length, 1); + assert.ok(published.length >= 1); + assert.equal(published[0].subject, 'response-message'); + }); + + it('handles a non-chunked request body', async () => { + const sub = controllableSub(); + const published = []; + const channel = { + subscribe: () => sub.iterable, + publish(subject, data, opts) { published.push({ subject, data, opts }); }, + request() { return Promise.resolve(); } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + + const handled = []; + const responsePromise = mbc.response('svc.evt', async (data) => { + handled.push(data); + return { code: 200 }; + }); + + const m = makeMsg({ msgHeaders: hdr({ messageId: 'r2' }), data: JSON.stringify({ y: 7 }) }); + sub.push(m); + await flush(); + sub.close(); + await responsePromise; + + assert.deepEqual(handled[0], { y: 7 }); + assert.ok(published.length >= 1); + }); + + it('wraps a handler error into a MessageError response', async () => { + const sub = controllableSub(); + const published = []; + const channel = { + subscribe: () => sub.iterable, + publish(subject, data, opts) { published.push({ subject, data, opts }); }, + request() { return Promise.resolve(); } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + + const responsePromise = mbc.response('svc.evt', async () => { + const err = new Error('handler boom'); + err.code = 500; + throw err; + }); + + const m = makeMsg({ msgHeaders: hdr({ messageId: 'r3' }), data: JSON.stringify({ z: 1 }) }); + sub.push(m); + await flush(); + sub.close(); + await responsePromise; + + assert.ok(published.length >= 1); + const body = JSON.parse(Buffer.from(published[0].data).toString()); + assert.ok(body); + }); + + it('continues collecting multi-chunk requests before producing a response', async () => { + const sub = controllableSub(); + const published = []; + const channel = { + subscribe: () => sub.iterable, + publish(subject, data, opts) { published.push({ subject, data, opts }); }, + request() { return Promise.resolve(); } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + + const handled = []; + const responsePromise = mbc.response('svc.evt', async (data) => { + handled.push(data); + return { code: 200 }; + }); + + const full = Buffer.from(JSON.stringify({ big: 'payload' })); + const half = Math.ceil(full.length / 2); + const p1 = full.subarray(0, half); + const p2 = full.subarray(half); + + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'rc', chunks: 2, chunk: 1 }), data: p1 })); + await flush(); + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'rc', chunks: 2, chunk: 2 }), data: p2 })); + await flush(); + sub.close(); + await responsePromise; + + assert.deepEqual(handled[0], { big: 'payload' }); + }); + + it('logs and continues when message parsing fails', async () => { + const sub = controllableSub(); + const channel = { + subscribe: () => sub.iterable, + publish() {}, + request() { return Promise.resolve(); } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + + const responsePromise = mbc.response('svc.evt', async () => ({ code: 200 })); + sub.push(makeMsg({ msgHeaders: hdr({ messageId: 'bad' }), data: 'not-json{' })); + await flush(); + sub.close(); + await responsePromise; + assert.ok(true); + }); +}); + +describe('@unit MessageBrokerChannel.request error mapping', () => { + it('resolves null on a 503 (no listener) error', async () => { + const channel = { + subscribe() { + return { [Symbol.asyncIterator]() { return { next: () => Promise.resolve({ done: true }) }; } }; + }, + publish() {}, + request() { + const err = new Error('no responders'); + err.code = '503'; + return Promise.reject(err); + } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + const result = await mbc.request('svc.evt', { a: 1 }); + assert.equal(result, null); + }); + + it('rejects on a non-503 transport error', async () => { + const channel = { + subscribe() { + return { [Symbol.asyncIterator]() { return { next: () => Promise.resolve({ done: true }) }; } }; + }, + publish() {}, + request() { + const err = new Error('kaboom'); + err.code = '500'; + return Promise.reject(err); + } + }; + const mbc = new MessageBrokerChannel(channel, 'svc'); + await assert.rejects(() => mbc.request('svc.evt', { a: 1 }), /kaboom/); + }); +}); diff --git a/common/tests/unit-tests/mq/zip-codec-routing.test.mjs b/common/tests/unit-tests/mq/zip-codec-routing.test.mjs new file mode 100644 index 0000000000..788533994c --- /dev/null +++ b/common/tests/unit-tests/mq/zip-codec-routing.test.mjs @@ -0,0 +1,319 @@ +import { assert } from 'chai'; +import esmock from 'esmock'; +import { JSONCodec } from 'nats'; + +let addObjectCalls; +let lastAddedBuffer; +const FAKE_DIRECT_LINK = 'http://fake-host:50001/object-id'; + +class FakeLargePayloadContainer { + addObject(o) { + addObjectCalls.push(o); + lastAddedBuffer = o; + return new URL(FAKE_DIRECT_LINK); + } +} + +let axiosGetCalls; +let axiosResponseFactory; +const fakeAxios = { + defaults: {}, + async get(url, options) { + axiosGetCalls.push({ url, options }); + return axiosResponseFactory(url, options); + } +}; + +const { ZipCodec } = await esmock.strict('../../../dist/mq/zip-codec.js', { + '../../../dist/mq/large-payload-container.js': { LargePayloadContainer: FakeLargePayloadContainer }, + axios: { default: fakeAxios } +}); + +describe('@unit ZipCodec routing (large-payload / payload-cap)', () => { + let savedMaxPayload; + let savedTlsCert; + let savedTlsKey; + let savedTlsCa; + let codec; + + beforeEach(() => { + savedMaxPayload = process.env.MQ_MAX_PAYLOAD; + savedTlsCert = process.env.TLS_CERT; + savedTlsKey = process.env.TLS_KEY; + savedTlsCa = process.env.TLS_CA; + delete process.env.MQ_MAX_PAYLOAD; + delete process.env.TLS_CERT; + delete process.env.TLS_KEY; + delete process.env.TLS_CA; + + addObjectCalls = []; + lastAddedBuffer = undefined; + axiosGetCalls = []; + axiosResponseFactory = () => ({ data: Buffer.from(JSON.stringify({ ok: true })) }); + fakeAxios.defaults = {}; + + codec = ZipCodec(); + }); + + afterEach(() => { + const restore = (key, value) => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restore('MQ_MAX_PAYLOAD', savedMaxPayload); + restore('TLS_CERT', savedTlsCert); + restore('TLS_KEY', savedTlsKey); + restore('TLS_CA', savedTlsCa); + }); + + const headerReserved = 32 * 1024; + + describe('encode inline path', () => { + it('returns raw zipped bytes when MQ_MAX_PAYLOAD is unset', async () => { + const out = await codec.encode({ a: 1 }); + assert.instanceOf(out, Uint8Array); + assert.lengthOf(addObjectCalls, 0); + assert.deepEqual(JSONCodec().decode(out), { a: 1 }); + }); + + it('returns raw zipped bytes when MQ_MAX_PAYLOAD is not an integer', async () => { + process.env.MQ_MAX_PAYLOAD = 'not-a-number'; + const out = await codec.encode({ a: 1 }); + assert.instanceOf(out, Uint8Array); + assert.lengthOf(addObjectCalls, 0); + assert.deepEqual(JSONCodec().decode(out), { a: 1 }); + }); + + it('returns raw zipped bytes when MQ_MAX_PAYLOAD is empty string', async () => { + process.env.MQ_MAX_PAYLOAD = ''; + const out = await codec.encode({ a: 1 }); + assert.instanceOf(out, Uint8Array); + assert.lengthOf(addObjectCalls, 0); + }); + + it('returns raw zipped bytes when MQ_MAX_PAYLOAD is larger than payload + reserve', async () => { + process.env.MQ_MAX_PAYLOAD = '99999999'; + const out = await codec.encode({ inline: true }); + assert.instanceOf(out, Uint8Array); + assert.lengthOf(addObjectCalls, 0); + assert.deepEqual(JSONCodec().decode(out), { inline: true }); + }); + + it('round-trips an inline-encoded plain object through decode', async () => { + process.env.MQ_MAX_PAYLOAD = '99999999'; + const encoded = await codec.encode({ a: 1, b: 'x' }); + const decoded = await codec.decode(encoded); + assert.deepEqual(decoded, { a: 1, b: 'x' }); + assert.lengthOf(axiosGetCalls, 0); + }); + + it('parseInt parses a leading-integer string (e.g. "100abc") and may route', async () => { + process.env.MQ_MAX_PAYLOAD = '100abc'; + const out = await codec.encode({ a: 1 }); + // parseInt('100abc') === 100 which is an integer and small -> routes + assert.instanceOf(out, Uint8Array); + assert.lengthOf(addObjectCalls, 1); + assert.property(JSONCodec().decode(out), 'directLink'); + }); + }); + + describe('encode routing path', () => { + it('routes through LargePayloadContainer when MQ_MAX_PAYLOAD trips the cap', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + const out = await codec.encode({ a: 1 }); + assert.lengthOf(addObjectCalls, 1); + const decoded = JSONCodec().decode(out); + assert.property(decoded, 'directLink'); + assert.equal(decoded.directLink, FAKE_DIRECT_LINK); + }); + + it('passes the zipped buffer to addObject', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + const payload = { a: 1, b: 'hello' }; + await codec.encode(payload); + assert.lengthOf(addObjectCalls, 1); + assert.instanceOf(lastAddedBuffer, Buffer); + assert.deepEqual(JSONCodec().decode(lastAddedBuffer), payload); + }); + + it('routes at the exact boundary maxPayload === zipped.length + headerReserved', async () => { + const payload = { a: 1 }; + const zipped = JSONCodec().encode(payload); + process.env.MQ_MAX_PAYLOAD = String(zipped.length + headerReserved); + await codec.encode(payload); + assert.lengthOf(addObjectCalls, 1); + }); + + it('stays inline one byte above the boundary (maxPayload === len + reserve + 1)', async () => { + const payload = { a: 1 }; + const zipped = JSONCodec().encode(payload); + process.env.MQ_MAX_PAYLOAD = String(zipped.length + headerReserved + 1); + const out = await codec.encode(payload); + assert.lengthOf(addObjectCalls, 0); + assert.deepEqual(JSONCodec().decode(out), payload); + }); + + it('routes one byte below the boundary (maxPayload === len + reserve - 1)', async () => { + const payload = { a: 1 }; + const zipped = JSONCodec().encode(payload); + process.env.MQ_MAX_PAYLOAD = String(zipped.length + headerReserved - 1); + await codec.encode(payload); + assert.lengthOf(addObjectCalls, 1); + }); + + it('routes undefined (encoded as null) when the cap is tripped', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + const out = await codec.encode(undefined); + assert.lengthOf(addObjectCalls, 1); + assert.deepEqual(JSONCodec().decode(lastAddedBuffer), null); + assert.property(JSONCodec().decode(out), 'directLink'); + }); + + it('routes null when the cap is tripped', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + await codec.encode(null); + assert.lengthOf(addObjectCalls, 1); + assert.deepEqual(JSONCodec().decode(lastAddedBuffer), null); + }); + + it('encodes the envelope as JSON bytes (not the raw zipped payload)', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + const out = await codec.encode({ big: 'x'.repeat(100) }); + const decoded = JSONCodec().decode(out); + assert.hasAllKeys(decoded, ['directLink']); + }); + }); + + describe('decode directLink envelope', () => { + it('calls axios.get with the directLink and arraybuffer responseType', async () => { + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + const result = await codec.decode(envelope); + assert.lengthOf(axiosGetCalls, 1); + assert.equal(axiosGetCalls[0].url, FAKE_DIRECT_LINK); + assert.deepEqual(axiosGetCalls[0].options, { responseType: 'arraybuffer' }); + assert.deepEqual(result, { ok: true }); + }); + + it('returns the JSON-parsed inner object from the fetched buffer', async () => { + const inner = { nested: { x: [1, 2, 3] }, flag: false }; + axiosResponseFactory = () => ({ data: Buffer.from(JSON.stringify(inner)) }); + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + const result = await codec.decode(envelope); + assert.deepEqual(result, inner); + }); + + it('round-trips a routed payload (encode routing -> decode via axios)', async () => { + process.env.MQ_MAX_PAYLOAD = '1'; + const payload = { a: 1, b: 'round' }; + const encoded = await codec.encode(payload); + axiosResponseFactory = () => ({ data: Buffer.from(lastAddedBuffer) }); + const decoded = await codec.decode(encoded); + assert.deepEqual(decoded, payload); + assert.lengthOf(axiosGetCalls, 1); + }); + + it('does not set httpsAgent when TLS env vars are absent', async () => { + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + await codec.decode(envelope); + assert.notProperty(fakeAxios.defaults, 'httpsAgent'); + }); + + it('sets axios.defaults.httpsAgent when TLS_CERT and TLS_KEY are present', async () => { + process.env.TLS_CERT = 'cert-pem'; + process.env.TLS_KEY = 'key-pem'; + process.env.TLS_CA = 'ca-pem'; + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + await codec.decode(envelope); + assert.property(fakeAxios.defaults, 'httpsAgent'); + assert.exists(fakeAxios.defaults.httpsAgent); + }); + + it('does not set httpsAgent when only TLS_CERT is present', async () => { + process.env.TLS_CERT = 'cert-pem'; + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + await codec.decode(envelope); + assert.notProperty(fakeAxios.defaults, 'httpsAgent'); + }); + + it('does not set httpsAgent when only TLS_KEY is present', async () => { + process.env.TLS_KEY = 'key-pem'; + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + await codec.decode(envelope); + assert.notProperty(fakeAxios.defaults, 'httpsAgent'); + }); + }); + + describe('decode pass-through (non-directLink)', () => { + it('returns a plain object without touching axios', async () => { + const encoded = JSONCodec().encode({ a: 1, b: 'x' }); + const decoded = await codec.decode(encoded); + assert.deepEqual(decoded, { a: 1, b: 'x' }); + assert.lengthOf(axiosGetCalls, 0); + }); + + it('returns null when the encoded value is null', async () => { + const encoded = JSONCodec().encode(null); + const decoded = await codec.decode(encoded); + assert.isNull(decoded); + assert.lengthOf(axiosGetCalls, 0); + }); + + it('returns an array as-is (no directLink property)', async () => { + const encoded = JSONCodec().encode([1, 2, 3]); + const decoded = await codec.decode(encoded); + assert.deepEqual(decoded, [1, 2, 3]); + assert.lengthOf(axiosGetCalls, 0); + }); + }); + + describe('error handling', () => { + it('decode of malformed JSON bytes throws a BadJson NatsError', async () => { + try { + await codec.decode(Uint8Array.from([0x7b, 0x22])); + assert.fail('expected rejection'); + } catch (error) { + assert.equal(error.name, 'NatsError'); + assert.equal(error.code, 'BAD_JSON'); + } + }); + + it('decode throws BadJson when the fetched directLink body is not valid JSON', async () => { + axiosResponseFactory = () => ({ data: Buffer.from('not-json') }); + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + try { + await codec.decode(envelope); + assert.fail('expected rejection'); + } catch (error) { + assert.equal(error.name, 'NatsError'); + assert.equal(error.code, 'BAD_JSON'); + } + }); + + it('decode throws BadJson when axios.get rejects', async () => { + axiosResponseFactory = () => { throw new Error('network down'); }; + const envelope = JSONCodec().encode({ directLink: FAKE_DIRECT_LINK }); + try { + await codec.decode(envelope); + assert.fail('expected rejection'); + } catch (error) { + assert.equal(error.name, 'NatsError'); + assert.equal(error.code, 'BAD_JSON'); + } + }); + + it('encode throws BadJson when the value is not JSON-encodable (circular)', async () => { + const circular = {}; + circular.self = circular; + try { + await codec.encode(circular); + assert.fail('expected rejection'); + } catch (error) { + assert.equal(error.name, 'NatsError'); + assert.equal(error.code, 'BAD_JSON'); + } + }); + }); +}); diff --git a/common/tests/unit-tests/mq/zip-codec.test.mjs b/common/tests/unit-tests/mq/zip-codec.test.mjs new file mode 100644 index 0000000000..e80e530b3e --- /dev/null +++ b/common/tests/unit-tests/mq/zip-codec.test.mjs @@ -0,0 +1,70 @@ +import { assert } from 'chai'; +import { ZipCodec } from '../../../dist/mq/zip-codec.js'; + +describe('ZipCodec', () => { + let savedMaxPayload; + let codec; + + before(() => { + savedMaxPayload = process.env.MQ_MAX_PAYLOAD; + delete process.env.MQ_MAX_PAYLOAD; + codec = ZipCodec(); + }); + + after(() => { + if (savedMaxPayload !== undefined) { + process.env.MQ_MAX_PAYLOAD = savedMaxPayload; + } + }); + + it('encodes an object to bytes', async () => { + const encoded = await codec.encode({ a: 1 }); + assert.instanceOf(encoded, Uint8Array); + assert.isAbove(encoded.length, 0); + }); + + it('round-trips a flat object', async () => { + const decoded = await codec.decode(await codec.encode({ a: 1, b: 'x' })); + assert.deepEqual(decoded, { a: 1, b: 'x' }); + }); + + it('round-trips nested structures', async () => { + const payload = { list: [1, 2, { deep: true }], meta: { tags: ['a', 'b'] } }; + const decoded = await codec.decode(await codec.encode(payload)); + assert.deepEqual(decoded, payload); + }); + + it('encodes undefined as null', async () => { + const decoded = await codec.decode(await codec.encode(undefined)); + assert.isNull(decoded); + }); + + it('round-trips null', async () => { + const decoded = await codec.decode(await codec.encode(null)); + assert.isNull(decoded); + }); + + it('round-trips unicode strings', async () => { + const decoded = await codec.decode(await codec.encode({ s: 'привіт 🌍' })); + assert.deepEqual(decoded, { s: 'привіт 🌍' }); + }); + + it('keeps the payload inline when MQ_MAX_PAYLOAD is large', async () => { + process.env.MQ_MAX_PAYLOAD = '99999999'; + try { + const decoded = await codec.decode(await codec.encode({ inline: true })); + assert.deepEqual(decoded, { inline: true }); + } finally { + delete process.env.MQ_MAX_PAYLOAD; + } + }); + + it('rejects when decoding malformed bytes', async () => { + try { + await codec.decode(Uint8Array.from([0x7b, 0x22])); + assert.fail('expected rejection'); + } catch (error) { + assert.exists(error); + } + }); +}); diff --git a/common/tests/unit-tests/notification-step/notification-step.test.mjs b/common/tests/unit-tests/notification-step/notification-step.test.mjs new file mode 100644 index 0000000000..6e40c62b46 --- /dev/null +++ b/common/tests/unit-tests/notification-step/notification-step.test.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { NotificationStep } from '../../../dist/notification/notification-step.js'; + +describe('NotificationStep construction', () => { + it('captures name and size; starts in the default state', () => { + const s = new NotificationStep('build', 5); + assert.equal(s.name, 'build'); + assert.equal(s.size, 5); + assert.equal(s.started, false); + assert.equal(s.completed, false); + assert.equal(s.failed, false); + assert.equal(s.skipped, false); + assert.equal(s.minimized, false); + assert.equal(s.estimate, 0); + }); +}); + +describe('NotificationStep mutators (chaining + state)', () => { + it('minimize() sets the flag and chains', () => { + const s = new NotificationStep('x', 1); + assert.equal(s.minimize(true), s); + assert.equal(s.minimized, true); + }); + + it('setEstimate() sets and chains', () => { + const s = new NotificationStep('x', 1); + assert.equal(s.setEstimate(7), s); + assert.equal(s.estimate, 7); + }); + + it('addEstimate() adds steps.length + estimate', () => { + const s = new NotificationStep('x', 1); + s.addStep('a'); + s.addStep('b'); + assert.equal(s.addEstimate(3), s); + assert.equal(s.estimate, 5); + }); + + it('start() flips started=true and stamps startDate', () => { + const s = new NotificationStep('x', 1); + const before = Date.now(); + s.start(); + assert.equal(s.started, true); + assert.ok(s.startDate >= before); + }); + + it('complete() sets completed=true and stamps stopDate', () => { + const s = new NotificationStep('x', 1); + s.complete(); + assert.equal(s.completed, true); + assert.equal(s.failed, false); + assert.ok(typeof s.stopDate === 'number'); + }); + + it('skip() (after no completion) sets skipped=true and completed=true', () => { + const s = new NotificationStep('x', 1); + s.skip(); + assert.equal(s.completed, true); + assert.equal(s.skipped, true); + assert.equal(s.failed, false); + }); + + it('skip() after complete() leaves skipped=false', () => { + const s = new NotificationStep('x', 1); + s.complete(); + s.skip(); + assert.equal(s.skipped, false); + }); + + it('fail() captures string error with default code 500', () => { + const s = new NotificationStep('x', 1); + s.fail('boom'); + assert.equal(s.failed, true); + assert.equal(s.completed, true); + assert.equal(s.error.code, 500); + assert.equal(s.error.message, 'boom'); + }); + + it('fail() captures Error.message when given an Error', () => { + const s = new NotificationStep('x', 1); + s.fail(new Error('nested'), 'CUSTOM'); + assert.equal(s.error.code, 'CUSTOM'); + assert.equal(s.error.message, 'nested'); + }); + + it('fail() falls back to "Unknown error" when error has no message/stack', () => { + const s = new NotificationStep('x', 1); + s.fail({}); + assert.equal(s.error.message, 'Unknown error'); + }); +}); + +describe('NotificationStep nested steps', () => { + it('addStep() returns a child NotificationStep with the same parent notifier', () => { + const parent = new NotificationStep('parent', 1); + const child = parent.addStep('child', 2); + assert.ok(child instanceof NotificationStep); + assert.equal(child.name, 'child'); + assert.equal(child.size, 2); + }); + + it('startStep / completeStep / skipStep / failStep manage children by name', () => { + const parent = new NotificationStep('parent', 1); + parent.addStep('a'); + parent.addStep('b'); + parent.startStep('a'); + parent.completeStep('a'); + parent.skipStep('b'); + const a = parent.getStep('a'); + const b = parent.getStep('b'); + assert.equal(a.completed, true); + assert.equal(b.skipped, true); + }); + + it('failStep records error on the named child', () => { + const parent = new NotificationStep('parent', 1); + parent.addStep('a'); + parent.failStep('a', 'bad', 400); + const a = parent.getStep('a'); + assert.equal(a.failed, true); + assert.equal(a.error.code, 400); + assert.equal(a.error.message, 'bad'); + }); + + it('throws "Step not found" when interacting with an unknown step', () => { + const parent = new NotificationStep('parent', 1); + assert.throws(() => parent.startStep('nope'), /Step nope not found/); + assert.throws(() => parent.completeStep('nope'), /Step nope not found/); + assert.throws(() => parent.skipStep('nope'), /Step nope not found/); + assert.throws(() => parent.failStep('nope', 'x'), /Step nope not found/); + }); +}); + +describe('NotificationStep.info', () => { + it('returns progress=0 when not yet started', () => { + const info = new NotificationStep('x', 1).info(); + assert.equal(info.progress, 0); + assert.equal(info.index, 0); + assert.equal(info.message, ''); + }); + + it('returns progress=100 when completed', () => { + const s = new NotificationStep('x', 1); + s.complete(); + const info = s.info(); + assert.equal(info.progress, 100); + assert.equal(info.message, 'x'); + }); + + it('reports progress=100 when failed (fail() also sets completed=true)', () => { + // fail() flips completed=true alongside failed=true, so info() + // treats it like a finished step (progress=100). Document this. + const s = new NotificationStep('x', 1); + s.fail('boom'); + const info = s.info(); + assert.equal(info.progress, 100); + assert.equal(info.failed, true); + }); + + it('skipped steps look identical to completed (progress=100)', () => { + const s = new NotificationStep('x', 1); + s.skip(); + const info = s.info(); + assert.equal(info.progress, 100); + }); + + it('children are omitted when minimized=true', () => { + const parent = new NotificationStep('p', 1); + parent.addStep('a'); + parent.minimize(true); + const info = parent.info(); + assert.deepEqual(info.steps, []); + }); + + it('estimate reflects max(steps.length, estimate)', () => { + const parent = new NotificationStep('p', 1); + parent.addStep('a'); + parent.addStep('b'); + parent.addStep('c'); + parent.setEstimate(2); + const info = parent.info(); + assert.equal(info.estimate, 3); + }); +}); + +describe('NotificationStep findStepById', () => { + it('finds the root by id', () => { + const s = new NotificationStep('x', 1); + s.setId('root-id'); + assert.equal(s.findStepById('root-id'), s); + }); + + it('finds a nested child by id', () => { + const parent = new NotificationStep('p', 1); + const child = parent.addStep('c'); + child.setId('child-id'); + assert.equal(parent.findStepById('child-id'), child); + }); + + it('returns null when no step matches', () => { + const s = new NotificationStep('x', 1); + s.setId('root'); + assert.equal(s.findStepById('missing'), null); + }); +}); diff --git a/common/tests/unit-tests/notification/notification-events.test.mjs b/common/tests/unit-tests/notification/notification-events.test.mjs new file mode 100644 index 0000000000..89c44747d8 --- /dev/null +++ b/common/tests/unit-tests/notification/notification-events.test.mjs @@ -0,0 +1,167 @@ +import { assert } from 'chai'; +import { TaskAction, NotificationAction } from '@guardian/interfaces'; +import { + notificationActionMap, + taskResultTitleMap, + getNotificationResultMessage, + getNotificationResultTitle, + getNotificationResult, + getTaskResult +} from '../../../dist/notification/notification-events.js'; + +describe('notificationActionMap', () => { + it('maps CREATE_POLICY to POLICY_CONFIGURATION', () => { + assert.equal(notificationActionMap.get(TaskAction.CREATE_POLICY), NotificationAction.POLICY_CONFIGURATION); + }); + + it('maps PUBLISH_SCHEMA to SCHEMAS_PAGE', () => { + assert.equal(notificationActionMap.get(TaskAction.PUBLISH_SCHEMA), NotificationAction.SCHEMAS_PAGE); + }); + + it('maps CREATE_TOKEN to TOKENS_PAGE', () => { + assert.equal(notificationActionMap.get(TaskAction.CREATE_TOKEN), NotificationAction.TOKENS_PAGE); + }); + + it('maps DELETE_POLICY to POLICIES_PAGE', () => { + assert.equal(notificationActionMap.get(TaskAction.DELETE_POLICY), NotificationAction.POLICIES_PAGE); + }); + + it('maps PUBLISH_POLICY_LABEL to POLICY_LABEL_PAGE', () => { + assert.equal(notificationActionMap.get(TaskAction.PUBLISH_POLICY_LABEL), NotificationAction.POLICY_LABEL_PAGE); + }); + + it('has no entry for CONNECT_USER', () => { + assert.isUndefined(notificationActionMap.get(TaskAction.CONNECT_USER)); + }); + + it('contains 19 entries', () => { + assert.equal(notificationActionMap.size, 19); + }); +}); + +describe('taskResultTitleMap', () => { + it('titles CREATE_POLICY as Policy created', () => { + assert.equal(taskResultTitleMap.get(TaskAction.CREATE_POLICY), 'Policy created'); + }); + + it('titles DELETE_TOKEN as Token deleted', () => { + assert.equal(taskResultTitleMap.get(TaskAction.DELETE_TOKEN), 'Token deleted'); + }); + + it('titles PUBLISH_POLICY_LABEL as Label published', () => { + assert.equal(taskResultTitleMap.get(TaskAction.PUBLISH_POLICY_LABEL), 'Label published'); + }); + + it('contains 28 entries', () => { + assert.equal(taskResultTitleMap.size, 28); + }); +}); + +describe('getNotificationResultMessage', () => { + it('builds message for CREATE_POLICY from the raw result', () => { + assert.equal(getNotificationResultMessage(TaskAction.CREATE_POLICY, 'p1'), 'Policy p1 created'); + }); + + it('builds message for PUBLISH_POLICY from policyId', () => { + assert.equal(getNotificationResultMessage(TaskAction.PUBLISH_POLICY, { policyId: 'p2' }), 'Policy p2 published'); + }); + + it('builds the same message for both policy import variants', () => { + assert.equal(getNotificationResultMessage(TaskAction.IMPORT_POLICY_FILE, { policyId: 'p3' }), 'Policy p3 imported'); + assert.equal(getNotificationResultMessage(TaskAction.IMPORT_POLICY_MESSAGE, { policyId: 'p3' }), 'Policy p3 imported'); + }); + + it('builds message for IMPORT_TOOL_MESSAGE from toolId', () => { + assert.equal(getNotificationResultMessage(TaskAction.IMPORT_TOOL_MESSAGE, { toolId: 't1' }), 'Tool t1 imported'); + }); + + it('builds token messages from tokenName', () => { + assert.equal(getNotificationResultMessage(TaskAction.ASSOCIATE_TOKEN, { tokenName: 'TKN' }), 'TKN associated'); + assert.equal(getNotificationResultMessage(TaskAction.FREEZE_TOKEN, { tokenName: 'TKN' }), 'TKN frozen'); + }); + + it('builds KYC messages from tokenName', () => { + assert.equal(getNotificationResultMessage(TaskAction.GRANT_KYC, { tokenName: 'TKN' }), 'KYC granted for TKN'); + assert.equal(getNotificationResultMessage(TaskAction.REVOKE_KYC, { tokenName: 'TKN' }), 'KYC revoked for TKN'); + }); + + it('uses fixed texts for user actions', () => { + assert.equal(getNotificationResultMessage(TaskAction.CONNECT_USER, null), 'You are connected'); + assert.equal(getNotificationResultMessage(TaskAction.RESTORE_USER_PROFILE, null), 'Your profile restored'); + }); + + it('falls back to Operation completed for unmapped actions', () => { + assert.equal(getNotificationResultMessage(TaskAction.DELETE_POLICY, true), 'Operation completed'); + }); +}); + +describe('getNotificationResultTitle', () => { + it('returns undefined for PUBLISH_POLICY when result is invalid', () => { + assert.isUndefined(getNotificationResultTitle(TaskAction.PUBLISH_POLICY, { isValid: false })); + }); + + it('returns the publish title for PUBLISH_POLICY when result is valid', () => { + assert.equal(getNotificationResultTitle(TaskAction.PUBLISH_POLICY, { isValid: true }), 'Policy published'); + }); + + it('returns the mapped title for CREATE_SCHEMA', () => { + assert.equal(getNotificationResultTitle(TaskAction.CREATE_SCHEMA, 'id'), 'Schema created'); + }); + + it('returns undefined for actions without a title', () => { + assert.isUndefined(getNotificationResultTitle(TaskAction.GET_USER_TOPICS, {})); + }); +}); + +describe('getNotificationResult', () => { + it('returns undefined for a falsy result', () => { + assert.isUndefined(getNotificationResult(TaskAction.CREATE_POLICY, null)); + assert.isUndefined(getNotificationResult(TaskAction.CREATE_POLICY, undefined)); + }); + + it('returns the raw result for CREATE_POLICY', () => { + assert.equal(getNotificationResult(TaskAction.CREATE_POLICY, 'pid'), 'pid'); + }); + + it('extracts policyId for WIZARD_CREATE_POLICY and PUBLISH_POLICY', () => { + assert.equal(getNotificationResult(TaskAction.WIZARD_CREATE_POLICY, { policyId: 'a' }), 'a'); + assert.equal(getNotificationResult(TaskAction.PUBLISH_POLICY, { policyId: 'b' }), 'b'); + }); + + it('extracts errors for PUBLISH_TOOL', () => { + const errors = [{ text: 'x' }]; + assert.deepEqual(getNotificationResult(TaskAction.PUBLISH_TOOL, { errors }), errors); + }); + + it('extracts status for token association actions', () => { + assert.equal(getNotificationResult(TaskAction.ASSOCIATE_TOKEN, { status: true }), true); + assert.equal(getNotificationResult(TaskAction.DISSOCIATE_TOKEN, { status: false }), false); + }); + + it('returns undefined for schema file imports', () => { + assert.isUndefined(getNotificationResult(TaskAction.IMPORT_SCHEMA_FILE, { ok: 1 })); + assert.isUndefined(getNotificationResult(TaskAction.IMPORT_SCHEMA_MESSAGE, { ok: 1 })); + }); + + it('passes through results for unmapped actions', () => { + const result = { any: 'thing' }; + assert.equal(getNotificationResult(TaskAction.DELETE_TOKEN, true), true); + assert.deepEqual(getNotificationResult('SOMETHING_ELSE', result), result); + }); +}); + +describe('getTaskResult', () => { + it('extracts status for ASSOCIATE_TOKEN', () => { + assert.equal(getTaskResult(TaskAction.ASSOCIATE_TOKEN, { status: true }), true); + }); + + it('extracts status for DISSOCIATE_TOKEN', () => { + assert.equal(getTaskResult(TaskAction.DISSOCIATE_TOKEN, { status: false }), false); + }); + + it('passes through results for other actions', () => { + const result = { policyId: 'p' }; + assert.deepEqual(getTaskResult(TaskAction.PUBLISH_POLICY, result), result); + assert.equal(getTaskResult(TaskAction.CREATE_POLICY, 'id'), 'id'); + }); +}); diff --git a/common/tests/unit-tests/pino-file-transport/pino-file-transport.test.mjs b/common/tests/unit-tests/pino-file-transport/pino-file-transport.test.mjs new file mode 100644 index 0000000000..f5532eb93a --- /dev/null +++ b/common/tests/unit-tests/pino-file-transport/pino-file-transport.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { promises as fs, existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PinoFileTransport } from '../../../dist/helpers/pino-file-transport.js'; + +const tempPath = (suffix) => path.join(os.tmpdir(), `gp-pft-${Date.now()}-${Math.random().toString(36).slice(2)}${suffix}`); + +describe('PinoFileTransport.constructor', () => { + it('creates the parent directory when missing', async () => { + const dir = tempPath('-dir'); + const file = path.join(dir, 'log.txt'); + assert.equal(existsSync(dir), false); + const t = new PinoFileTransport({ filePath: file }); + assert.equal(existsSync(dir), true); + assert.equal(existsSync(file), true); + assert.ok(t); + // Cleanup is best-effort — pino keeps the file handle open asynchronously + // and rmdir may fail on Windows. Leave the temp file behind in that case. + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + }); + + it('reuses an existing file (does not throw)', async () => { + const file = tempPath('.log'); + await fs.writeFile(file, 'pre-existing\n'); + assert.doesNotThrow(() => new PinoFileTransport({ filePath: file })); + await fs.rm(file, { force: true }).catch(() => {}); + }); +}); + +describe('PinoFileTransport.write', () => { + it('appends a newline-terminated JSON entry to the destination', async () => { + const file = tempPath('.log'); + const t = new PinoFileTransport({ filePath: file }); + t.write(JSON.stringify({ level: 'info', message: 'hello' })); + // Allow pino's async destination to flush. + await new Promise((r) => setTimeout(r, 100)); + const contents = await fs.readFile(file, 'utf8'); + assert.ok(contents.includes('"message":"hello"')); + assert.ok(contents.endsWith('\n')); + await fs.rm(file, { force: true }).catch(() => {}); + }); + + it('appends multiple entries on separate lines', async () => { + const file = tempPath('.log'); + const t = new PinoFileTransport({ filePath: file }); + t.write(JSON.stringify({ message: 'one' })); + t.write(JSON.stringify({ message: 'two' })); + await new Promise((r) => setTimeout(r, 100)); + const contents = await fs.readFile(file, 'utf8'); + const lines = contents.trim().split('\n'); + assert.equal(lines.length, 2); + assert.ok(lines[0].includes('"one"')); + assert.ok(lines[1].includes('"two"')); + await fs.rm(file, { force: true }).catch(() => {}); + }); + + it('throws synchronously when the input is not valid JSON', () => { + const file = tempPath('.log'); + const t = new PinoFileTransport({ filePath: file }); + assert.throws(() => t.write('not-json')); + }); +}); diff --git a/common/tests/unit-tests/pino-logger/pino-logger-constants.test.mjs b/common/tests/unit-tests/pino-logger/pino-logger-constants.test.mjs new file mode 100644 index 0000000000..de12928c60 --- /dev/null +++ b/common/tests/unit-tests/pino-logger/pino-logger-constants.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { PinoLogType } from '@guardian/interfaces'; +import { levelTypeMapping, MAP_TRANSPORTS } from '../../../dist/helpers/pino-logger.js'; + +describe('levelTypeMapping', () => { + it('is an array of three entries (info / warn / error)', () => { + assert.ok(Array.isArray(levelTypeMapping)); + assert.equal(levelTypeMapping.length, 3); + }); + + it('orders levels INFO -> WARN -> ERROR', () => { + assert.equal(levelTypeMapping[0], PinoLogType.INFO); + assert.equal(levelTypeMapping[1], PinoLogType.WARN); + assert.equal(levelTypeMapping[2], PinoLogType.ERROR); + }); +}); + +describe('MAP_TRANSPORTS registry', () => { + it('exposes the four built-in transports as constructor classes', () => { + for (const name of ['CONSOLE', 'MONGO', 'FILE', 'SEQ']) { + assert.equal(typeof MAP_TRANSPORTS[name], 'function', `${name} should be a class`); + assert.ok(MAP_TRANSPORTS[name].prototype, `${name}.prototype should exist`); + } + }); + + it('does not include unknown transports', () => { + assert.equal(MAP_TRANSPORTS.OTLP, undefined); + assert.equal(MAP_TRANSPORTS.STDOUT, undefined); + }); +}); diff --git a/common/tests/unit-tests/pino-logger/pino-logger-mapping.test.mjs b/common/tests/unit-tests/pino-logger/pino-logger-mapping.test.mjs new file mode 100644 index 0000000000..0d198ecfd7 --- /dev/null +++ b/common/tests/unit-tests/pino-logger/pino-logger-mapping.test.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; +import { LogType } from '@guardian/interfaces'; + +const state = { + pinoCalls: [], + multistreamCalls: [], + logged: { debug: [], info: [], warn: [], error: [] }, +}; + +function makeLogger() { + return { + debug(obj) { state.logged.debug.push(obj); }, + info(obj) { state.logged.info.push(obj); }, + warn(obj) { state.logged.warn.push(obj); }, + error(obj) { state.logged.error.push(obj); }, + }; +} + +function pinoFactory(options, stream) { + state.pinoCalls.push({ options, stream }); + return makeLogger(); +} +pinoFactory.multistream = (streams, opts) => { + state.multistreamCalls.push({ streams, opts }); + return { __multistream: true, streams, opts }; +}; + +class FakeConsole { constructor(o) { this.kind = 'CONSOLE'; this.options = o; } } +class FakeMongo { constructor(o) { this.kind = 'MONGO'; this.options = o; } } +class FakeFile { constructor(o) { this.kind = 'FILE'; this.options = o; } } +class FakeSeq { constructor(o) { this.kind = 'SEQ'; this.options = o; } } + +const { PinoLogger, MAP_TRANSPORTS, levelTypeMapping } = await esmock.strict('../../../dist/helpers/pino-logger.js', { + pino: { default: pinoFactory }, + '../../../dist/helpers/console-transport.js': { ConsoleTransport: FakeConsole }, + '../../../dist/helpers/mongo-transport.js': { MongoTransport: FakeMongo }, + '../../../dist/helpers/pino-file-transport.js': { PinoFileTransport: FakeFile }, + '../../../dist/helpers/seq-transport.js': { SeqTransport: FakeSeq }, +}); + +const FAKE_MAP = { + CONSOLE: FakeConsole, + MONGO: FakeMongo, + FILE: FakeFile, + SEQ: FakeSeq, +}; + +function reset() { + state.pinoCalls = []; + state.multistreamCalls = []; + state.logged = { debug: [], info: [], warn: [], error: [] }; +} + +function build(transports, extra = {}) { + reset(); + const options = { + logLevel: LogType.INFO, + collectionName: 'logs', + transports, + mapTransports: FAKE_MAP, + ...extra, + }; + const logger = new PinoLogger().init(options); + return logger; +} + +describe('@unit PinoLogger.init transport selection', () => { + it('uses the mapTransports from the supplied options', () => { + build('CONSOLE'); + const streams = state.multistreamCalls[0].streams; + assert.equal(streams.length, 1); + assert.equal(streams[0].stream.kind, 'CONSOLE'); + }); + + it('selects a single known transport', () => { + build('MONGO'); + const kinds = state.multistreamCalls[0].streams.map((s) => s.stream.kind); + assert.deepEqual(kinds, ['MONGO']); + }); + + it('selects multiple comma-separated transports in order', () => { + build('CONSOLE,MONGO,SEQ'); + const kinds = state.multistreamCalls[0].streams.map((s) => s.stream.kind); + assert.deepEqual(kinds, ['CONSOLE', 'MONGO', 'SEQ']); + }); + + it('trims whitespace around transport names', () => { + build(' CONSOLE , FILE '); + const kinds = state.multistreamCalls[0].streams.map((s) => s.stream.kind); + assert.deepEqual(kinds, ['CONSOLE', 'FILE']); + }); + + it('silently drops unknown transport names', () => { + build('CONSOLE,OTLP,MONGO'); + const kinds = state.multistreamCalls[0].streams.map((s) => s.stream.kind); + assert.deepEqual(kinds, ['CONSOLE', 'MONGO']); + }); + + it('produces an empty stream list when no name is known', () => { + build('NOPE,STDOUT'); + assert.deepEqual(state.multistreamCalls[0].streams, []); + }); + + it('produces an empty stream list for an empty transports string', () => { + build(''); + assert.deepEqual(state.multistreamCalls[0].streams, []); + }); + + it('keeps duplicate transports when listed twice', () => { + build('CONSOLE,CONSOLE'); + const kinds = state.multistreamCalls[0].streams.map((s) => s.stream.kind); + assert.deepEqual(kinds, ['CONSOLE', 'CONSOLE']); + }); + + it('is case-sensitive: lowercase names are not matched', () => { + build('console,mongo'); + assert.deepEqual(state.multistreamCalls[0].streams, []); + }); + + it('passes the full options object to each transport constructor', () => { + const logger = build('MONGO'); + const stream = state.multistreamCalls[0].streams[0].stream; + assert.equal(stream.options.collectionName, 'logs'); + assert.equal(stream.options.transports, 'MONGO'); + assert.equal(logger instanceof PinoLogger, true); + }); + + it('returns the logger instance from init', () => { + const logger = build('CONSOLE'); + assert.equal(typeof logger.info, 'function'); + }); +}); + +describe('@unit PinoLogger.init pino configuration', () => { + it('configures pino with base null and disabled timestamp', () => { + build('CONSOLE'); + const opts = state.pinoCalls[0].options; + assert.equal(opts.base, null); + assert.equal(opts.timestamp, false); + }); + + it('uses a log formatter that spreads the object through unchanged', () => { + build('CONSOLE'); + const fmt = state.pinoCalls[0].options.formatters.log; + const input = { message: 'hi', attributes: ['a'], level: 30 }; + const out = fmt(input); + assert.deepEqual(out, input); + assert.notEqual(out, input); + }); + + it('passes the multistream result as the second pino argument', () => { + build('CONSOLE'); + assert.equal(state.pinoCalls[0].stream.__multistream, true); + }); + + it('enables dedupe on the multistream', () => { + build('CONSOLE'); + assert.deepEqual(state.multistreamCalls[0].opts, { dedupe: true }); + }); +}); + +describe('@unit PinoLogger log methods', () => { + it('debug logs message/attributes/userId with INFO type', async () => { + const logger = build('CONSOLE'); + await logger.debug('msg', ['A'], 'user-1'); + const entry = state.logged.debug[0]; + assert.equal(entry.message, 'msg'); + assert.deepEqual(entry.attributes, ['A']); + assert.equal(entry.type, LogType.INFO); + assert.equal(entry.userId, 'user-1'); + assert.ok(entry.datetime instanceof Date); + }); + + it('info logs with INFO type and defaults userId to null', async () => { + const logger = build('CONSOLE'); + await logger.info('hello', ['GW']); + const entry = state.logged.info[0]; + assert.equal(entry.message, 'hello'); + assert.equal(entry.type, LogType.INFO); + assert.equal(entry.userId, null); + }); + + it('warn logs with WARN type', async () => { + const logger = build('CONSOLE'); + await logger.warn('careful', null, 'u'); + const entry = state.logged.warn[0]; + assert.equal(entry.message, 'careful'); + assert.equal(entry.attributes, null); + assert.equal(entry.type, LogType.WARN); + assert.equal(entry.userId, 'u'); + }); + + it('error logs a string error verbatim with ERROR type', async () => { + const logger = build('CONSOLE'); + await logger.error('boom', ['svc']); + const entry = state.logged.error[0]; + assert.equal(entry.message, 'boom'); + assert.equal(entry.type, LogType.ERROR); + assert.equal(entry.userId, null); + }); + + it('error uses the stack of an Error instance', async () => { + const logger = build('CONSOLE'); + const err = new Error('kaboom'); + await logger.error(err, null); + assert.equal(state.logged.error[0].message, err.stack); + }); + + it('error falls back to "Unknown error" for falsy input', async () => { + const logger = build('CONSOLE'); + await logger.error('', null); + assert.equal(state.logged.error[0].message, 'Unknown error'); + }); + + it('error treats null as "Unknown error"', async () => { + const logger = build('CONSOLE'); + await logger.error(null, null); + assert.equal(state.logged.error[0].message, 'Unknown error'); + }); +}); + +describe('@unit PinoLogger esmocked constants', () => { + it('still exposes the level mapping and transport registry', () => { + assert.equal(levelTypeMapping.length, 3); + assert.deepEqual(Object.keys(MAP_TRANSPORTS).sort(), ['CONSOLE', 'FILE', 'MONGO', 'SEQ']); + }); +}); diff --git a/common/tests/unit-tests/policy-category/policy-category.test.mjs b/common/tests/unit-tests/policy-category/policy-category.test.mjs new file mode 100644 index 0000000000..495c2bf393 --- /dev/null +++ b/common/tests/unit-tests/policy-category/policy-category.test.mjs @@ -0,0 +1,105 @@ +import { assert } from 'chai'; +import { + GetGroupedCategories, + GetConditionsPoliciesByCategories, +} from '../../../dist/helpers/policy-category.js'; +import { DatabaseServer } from '../../../dist/database-modules/index.js'; + +describe('GetGroupedCategories', () => { + it('groups category ids by their `type`', () => { + const result = GetGroupedCategories([ + { id: 'a1', type: 'TYPE_A' }, + { id: 'b1', type: 'TYPE_B' }, + { id: 'a2', type: 'TYPE_A' }, + ]); + assert.deepEqual(result.TYPE_A, ['a1', 'a2']); + assert.deepEqual(result.TYPE_B, ['b1']); + }); + + it('returns {} for an empty list', () => { + assert.deepEqual(GetGroupedCategories([]), {}); + }); + + it('preserves first-seen insertion order within each bucket', () => { + const result = GetGroupedCategories([ + { id: 'x', type: 'T' }, + { id: 'y', type: 'T' }, + { id: 'z', type: 'T' }, + ]); + assert.deepEqual(result.T, ['x', 'y', 'z']); + }); +}); + +describe('GetConditionsPoliciesByCategories', () => { + let savedFind; + + beforeEach(() => { + savedFind = DatabaseServer.prototype.find; + }); + + afterEach(() => { + DatabaseServer.prototype.find = savedFind; + }); + + it('returns only the PUBLISH status condition when no text and no categoryIds', async () => { + DatabaseServer.prototype.find = async () => { + throw new Error('find should not be called'); + }; + const conditions = await GetConditionsPoliciesByCategories([], ''); + assert.deepEqual(conditions, [{ status: { $eq: 'PUBLISH' } }]); + }); + + it('adds a case-insensitive name regex when text is provided', async () => { + DatabaseServer.prototype.find = async () => { + throw new Error('find should not be called'); + }; + const conditions = await GetConditionsPoliciesByCategories(undefined, 'solar'); + assert.deepEqual(conditions, [ + { status: { $eq: 'PUBLISH' } }, + { name: { $regex: '.*solar.*', $options: 'i' } }, + ]); + }); + + it('queries categories and pushes one $in condition per category type', async () => { + let queriedFilter; + DatabaseServer.prototype.find = async (_entity, filter) => { + queriedFilter = filter; + return [ + { id: 'a1', type: 'TYPE_A' }, + { id: 'a2', type: 'TYPE_A' }, + { id: 'b1', type: 'TYPE_B' }, + ]; + }; + const conditions = await GetConditionsPoliciesByCategories(['a1', 'a2', 'b1'], ''); + assert.deepEqual(queriedFilter, { id: { $in: ['a1', 'a2', 'b1'] } }); + assert.deepEqual(conditions, [ + { status: { $eq: 'PUBLISH' } }, + { categories: { $in: ['a1', 'a2'] } }, + { categories: { $in: ['b1'] } }, + ]); + }); + + it('combines the text regex and category conditions together', async () => { + DatabaseServer.prototype.find = async () => [{ id: 'c1', type: 'T' }]; + const conditions = await GetConditionsPoliciesByCategories(['c1'], 'wind'); + assert.deepEqual(conditions, [ + { status: { $eq: 'PUBLISH' } }, + { name: { $regex: '.*wind.*', $options: 'i' } }, + { categories: { $in: ['c1'] } }, + ]); + }); + + it('skips the category query when categoryIds is empty', async () => { + let called = false; + DatabaseServer.prototype.find = async () => { + called = true; + return []; + }; + const conditions = await GetConditionsPoliciesByCategories([], 'hydro'); + assert.isFalse(called); + assert.deepEqual(conditions, [ + { status: { $eq: 'PUBLISH' } }, + { name: { $regex: '.*hydro.*', $options: 'i' } }, + ]); + }); +}); diff --git a/common/tests/unit-tests/policy-property.test.mjs b/common/tests/unit-tests/policy-property.test.mjs new file mode 100644 index 0000000000..da1c24490f --- /dev/null +++ b/common/tests/unit-tests/policy-property.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { writeFile, unlink, mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { GetPropertiesFromFile } from '../../dist/helpers/policy-property.js'; + +describe('GetPropertiesFromFile (CSV title,value reader)', () => { + let tmp; + + before(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'policy-prop-')); + }); + + it('parses well-formed two-column rows into title/value pairs', async () => { + const file = path.join(tmp, 'a.csv'); + await writeFile(file, 'Cap,100\nUnit,kg\n', 'utf8'); + const props = await GetPropertiesFromFile(file); + assert.deepEqual(props, [ + { title: 'Cap', value: '100' }, + { title: 'Unit', value: 'kg' }, + ]); + }); + + it('skips blank rows and rows with wrong column count', async () => { + const file = path.join(tmp, 'b.csv'); + await writeFile(file, 'Cap,100\n\nonecolumn\nthree,col,umns\nUnit,kg', 'utf8'); + const props = await GetPropertiesFromFile(file); + assert.deepEqual(props.map(p => p.title), ['Cap', 'Unit']); + }); + + it('skips rows whose first column is empty', async () => { + const file = path.join(tmp, 'c.csv'); + await writeFile(file, ',value-without-title\nReal,1\n', 'utf8'); + const props = await GetPropertiesFromFile(file); + assert.equal(props.length, 1); + assert.equal(props[0].title, 'Real'); + }); + + it('rejects when file is missing (re-throws filesystem error)', async () => { + await assert.rejects(GetPropertiesFromFile(path.join(tmp, 'no-such.csv'))); + }); + + after(async () => { + // best-effort cleanup; ignore errors + try { await unlink(path.join(tmp, 'a.csv')); } catch {} + try { await unlink(path.join(tmp, 'b.csv')); } catch {} + try { await unlink(path.join(tmp, 'c.csv')); } catch {} + }); +}); diff --git a/common/tests/unit-tests/policy-property/policy-property.test.mjs b/common/tests/unit-tests/policy-property/policy-property.test.mjs new file mode 100644 index 0000000000..4c3cf57c3c --- /dev/null +++ b/common/tests/unit-tests/policy-property/policy-property.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { GetPropertiesFromFile } from '../../../dist/helpers/policy-property.js'; + +const writeTemp = async (contents) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gp-')); + const file = path.join(dir, 'props.csv'); + await fs.writeFile(file, contents, 'utf8'); + return { file, dir }; +}; + +describe('GetPropertiesFromFile', () => { + it('parses two-column rows into title/value objects', async () => { + const { file } = await writeTemp('a,1\nb,2\nc,3\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [ + { title: 'a', value: '1' }, + { title: 'b', value: '2' }, + { title: 'c', value: '3' }, + ]); + }); + + it('skips rows that do not have exactly two columns', async () => { + const { file } = await writeTemp('a,1\nbad-row\nb,2,extra\nc,3\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [ + { title: 'a', value: '1' }, + { title: 'c', value: '3' }, + ]); + }); + + it('skips rows whose first column is empty', async () => { + const { file } = await writeTemp(',value-without-title\nname,real\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'name', value: 'real' }]); + }); + + it('returns [] for an empty file', async () => { + const { file } = await writeTemp(''); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('rejects when the file does not exist', async () => { + const missing = path.join(os.tmpdir(), 'definitely-missing-' + Date.now() + '.csv'); + // Suppress the helper's console.error inside the catch path to keep + // test output clean — its content isn't being asserted here. + const original = console.error; + console.error = () => {}; + try { + await assert.rejects(GetPropertiesFromFile(missing)); + } finally { + console.error = original; + } + }); +}); diff --git a/common/tests/unit-tests/run-async/run-async.test.mjs b/common/tests/unit-tests/run-async/run-async.test.mjs new file mode 100644 index 0000000000..e07806b3e3 --- /dev/null +++ b/common/tests/unit-tests/run-async/run-async.test.mjs @@ -0,0 +1,48 @@ +import { assert } from 'chai'; +import { RunFunctionAsync } from '../../../dist/helpers/run-function-async.js'; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +describe('RunFunctionAsync', () => { + it('runs the function and discards the resolved value', async () => { + let invoked = false; + RunFunctionAsync(async () => { + invoked = true; + }); + await tick(); + assert.equal(invoked, true); + }); + + it('routes thrown errors to onErrorFunc when provided', async () => { + let captured = null; + RunFunctionAsync( + async () => { throw new Error('boom'); }, + async (err) => { captured = err; }, + ); + await tick(); + await tick(); + assert.ok(captured); + assert.equal(captured.message, 'boom'); + }); + + it('falls back to console.error when no onErrorFunc is supplied', async () => { + const original = console.error; + let logged = null; + console.error = (...args) => { logged = args.join(' '); }; + try { + RunFunctionAsync(async () => { throw new Error('uncaught'); }); + await tick(); + await tick(); + assert.match(logged, /uncaught/); + } finally { + console.error = original; + } + }); + + it('does not throw synchronously even if func rejects', () => { + // Execution model: synchronous call returns void; rejection handled async. + assert.doesNotThrow(() => { + RunFunctionAsync(async () => { throw new Error('x'); }); + }); + }); +}); diff --git a/common/tests/unit-tests/run-function-async/run-function-async.test.mjs b/common/tests/unit-tests/run-function-async/run-function-async.test.mjs new file mode 100644 index 0000000000..ea05681e2f --- /dev/null +++ b/common/tests/unit-tests/run-function-async/run-function-async.test.mjs @@ -0,0 +1,56 @@ +import { assert } from 'chai'; +import { RunFunctionAsync } from '../../../dist/helpers/run-function-async.js'; + +function deferred() { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +describe('RunFunctionAsync', () => { + it('invokes the supplied function', async () => { + const d = deferred(); + let called = false; + RunFunctionAsync(async () => { called = true; d.resolve(); }); + await d.promise; + assert.isTrue(called); + }); + + it('passes the thrown error to onErrorFunc', async () => { + const d = deferred(); + const boom = new Error('boom'); + let received = null; + RunFunctionAsync( + async () => { throw boom; }, + async (error) => { received = error; d.resolve(); }, + ); + await d.promise; + assert.equal(received, boom); + }); + + it('does not call onErrorFunc when the function resolves', async () => { + const d = deferred(); + let errorCalled = false; + RunFunctionAsync( + async () => { d.resolve(); }, + async () => { errorCalled = true; }, + ); + await d.promise; + await new Promise((res) => setImmediate(res)); + assert.isFalse(errorCalled); + }); + + it('falls back to console.error(message) when no onErrorFunc is given', async () => { + const original = console.error; + const d = deferred(); + let logged = null; + console.error = (msg) => { logged = msg; d.resolve(); }; + try { + RunFunctionAsync(async () => { throw new Error('no handler'); }); + await d.promise; + } finally { + console.error = original; + } + assert.equal(logged, 'no handler'); + }); +}); diff --git a/common/tests/unit-tests/schema-converter/schema-converter-edge.test.mjs b/common/tests/unit-tests/schema-converter/schema-converter-edge.test.mjs new file mode 100644 index 0000000000..5ac3b6e5ec --- /dev/null +++ b/common/tests/unit-tests/schema-converter/schema-converter-edge.test.mjs @@ -0,0 +1,49 @@ +import { assert } from 'chai'; +import { SchemaConverterUtils } from '../../../dist/helpers/schema-converter-utils.js'; + +const IPFS_LEGACY_PATTERN = '^((https)://)?ipfs.io/ipfs/.+'; +const IPFS_PATTERN = '^ipfs://.+'; + +describe('SchemaConverterUtils.SchemaConverter — missing codeVersion', () => { + it('runs both migrations when codeVersion is absent', () => { + const schema = { + entity: 'VC', + document: { properties: { file: { pattern: IPFS_LEGACY_PATTERN } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.file.pattern, IPFS_PATTERN); + assert.equal(result.document.properties.guardianVersion.readOnly, true); + assert.equal(result.codeVersion, SchemaConverterUtils.VERSION); + }); + + it('treats a null codeVersion like an old schema', () => { + const schema = { codeVersion: null, entity: 'VC', document: { properties: {} } }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.isOk(result.document.properties.guardianVersion); + assert.equal(result.codeVersion, SchemaConverterUtils.VERSION); + }); + + it('does not append guardianVersion to an existing required array', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'VC', + document: { properties: {}, required: ['policyId'] }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.deepEqual(result.document.required, ['policyId']); + }); +}); + +describe('SchemaConverterUtils.versionCompare — quirks', () => { + it('treats leading zeros as numerically equal', () => { + assert.equal(SchemaConverterUtils.versionCompare('01.0.0', '1.0.0'), 0); + }); + + it('ranks non-numeric (NaN) segments below real versions', () => { + assert.equal(SchemaConverterUtils.versionCompare('a.b', '1.0'), -1); + }); + + it('tolerates leading whitespace inside numeric segments', () => { + assert.equal(SchemaConverterUtils.versionCompare(' 1.0.0', '1.0.0'), 0); + }); +}); diff --git a/common/tests/unit-tests/schema-converter/schema-converter.test.mjs b/common/tests/unit-tests/schema-converter/schema-converter.test.mjs new file mode 100644 index 0000000000..a5c09603b5 --- /dev/null +++ b/common/tests/unit-tests/schema-converter/schema-converter.test.mjs @@ -0,0 +1,179 @@ +import { assert } from 'chai'; +import { SchemaConverterUtils } from '../../../dist/helpers/schema-converter-utils.js'; + +describe('SchemaConverterUtils.versionCompare', () => { + it('returns 1 when v2 is null/undefined', () => { + assert.equal(SchemaConverterUtils.versionCompare('1.0.0', null), 1); + assert.equal(SchemaConverterUtils.versionCompare('1.0.0', undefined), 1); + assert.equal(SchemaConverterUtils.versionCompare('1.0.0', ''), 1); + }); + + it('returns 0 for identical versions', () => { + assert.equal(SchemaConverterUtils.versionCompare('1.2.0', '1.2.0'), 0); + assert.equal(SchemaConverterUtils.versionCompare('0.0.1', '0.0.1'), 0); + }); + + it('returns 1 when v1 is newer at the major position', () => { + assert.equal(SchemaConverterUtils.versionCompare('2.0.0', '1.9.9'), 1); + }); + + it('returns -1 when v1 is older at the major position', () => { + assert.equal(SchemaConverterUtils.versionCompare('1.0.0', '2.0.0'), -1); + }); + + it('returns 1 when v1 has more components than v2 with matching prefix', () => { + // The loop returns 1 once v2 runs out at index i with v1 still having parts. + assert.equal(SchemaConverterUtils.versionCompare('1.0.1', '1.0'), 1); + }); + + it('returns -1 when v1 is shorter than v2 with matching prefix', () => { + // After loop completes equal-component-by-component, length-mismatch → -1. + assert.equal(SchemaConverterUtils.versionCompare('1.0', '1.0.1'), -1); + }); + + it('compares minor and patch positions', () => { + assert.equal(SchemaConverterUtils.versionCompare('1.2.3', '1.2.2'), 1); + assert.equal(SchemaConverterUtils.versionCompare('1.2.3', '1.2.4'), -1); + assert.equal(SchemaConverterUtils.versionCompare('1.3.0', '1.2.999'), 1); + }); +}); + +describe('SchemaConverterUtils.SchemaConverter', () => { + it('returns the schema unchanged when codeVersion already equals VERSION', () => { + const schema = { + codeVersion: SchemaConverterUtils.VERSION, + document: { sentinel: true }, + entity: 'VC', + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result, schema); + assert.deepEqual(result.document, { sentinel: true }); + }); + + it('updates codeVersion to current VERSION after conversion', () => { + const schema = { + codeVersion: '0.0.1', + document: { properties: {} }, + entity: 'NONE', + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.codeVersion, SchemaConverterUtils.VERSION); + }); + + it('mutates and returns the same document reference', () => { + const document = { properties: {} }; + const schema = { codeVersion: '0.0.1', document, entity: 'NONE' }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result, schema); + assert.equal(result.document, document); + }); + + it('leaves a document without properties untouched', () => { + const schema = { codeVersion: '0.0.1', document: {}, entity: 'VC' }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.deepEqual(result.document, {}); + }); +}); + +const IPFS_LEGACY_PATTERN = '^((https)://)?ipfs.io/ipfs/.+'; +const IPFS_PATTERN = '^ipfs://.+'; + +describe('SchemaConverterUtils.SchemaConverter — v1_1_0 ipfs pattern migration', () => { + it('rewrites a direct ipfs.io property pattern to ipfs://', () => { + const schema = { + codeVersion: '1.0.0', + entity: 'NONE', + document: { properties: { file: { pattern: IPFS_LEGACY_PATTERN } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.file.pattern, IPFS_PATTERN); + }); + + it('rewrites a nested items.pattern to ipfs://', () => { + const schema = { + codeVersion: '1.0.0', + entity: 'NONE', + document: { properties: { files: { items: { pattern: IPFS_LEGACY_PATTERN } } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.files.items.pattern, IPFS_PATTERN); + }); + + it('leaves a non-ipfs pattern unchanged', () => { + const schema = { + codeVersion: '1.0.0', + entity: 'NONE', + document: { properties: { name: { pattern: '^[a-z]+$' } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.name.pattern, '^[a-z]+$'); + }); + + it('does not run on schemas already at 1.1.0', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'NONE', + document: { properties: { file: { pattern: IPFS_LEGACY_PATTERN } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.file.pattern, IPFS_LEGACY_PATTERN); + }); +}); + +describe('SchemaConverterUtils.SchemaConverter — v1_2_0 guardianVersion migration', () => { + it('adds a read-only guardianVersion property for VC entities', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'VC', + document: { properties: {} }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + const guardianVersion = result.document.properties.guardianVersion; + assert.equal(guardianVersion.title, 'Guardian Version'); + assert.equal(guardianVersion.type, 'string'); + assert.equal(guardianVersion.readOnly, true); + }); + + it('does not add guardianVersion for non-VC entities', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'NONE', + document: { properties: {} }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.isUndefined(result.document.properties.guardianVersion); + }); + + it('does not add guardianVersion for EVC entities (VC only)', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'EVC', + document: { properties: {} }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.isUndefined(result.document.properties.guardianVersion); + }); + + it('overwrites an existing guardianVersion with the canonical shape', () => { + const schema = { + codeVersion: '1.1.0', + entity: 'VC', + document: { properties: { guardianVersion: { stale: true } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.isUndefined(result.document.properties.guardianVersion.stale); + assert.equal(result.document.properties.guardianVersion.readOnly, true); + }); + + it('applies both ipfs and guardianVersion migrations for an old VC schema', () => { + const schema = { + codeVersion: '1.0.0', + entity: 'VC', + document: { properties: { file: { pattern: IPFS_LEGACY_PATTERN } } }, + }; + const result = SchemaConverterUtils.SchemaConverter(schema); + assert.equal(result.document.properties.file.pattern, IPFS_PATTERN); + assert.equal(result.document.properties.guardianVersion.readOnly, true); + assert.equal(result.codeVersion, SchemaConverterUtils.VERSION); + }); +}); diff --git a/common/tests/unit-tests/schemas-to-context/schemas-context-policy-edge.test.mjs b/common/tests/unit-tests/schemas-to-context/schemas-context-policy-edge.test.mjs new file mode 100644 index 0000000000..b7477d9757 --- /dev/null +++ b/common/tests/unit-tests/schemas-to-context/schemas-context-policy-edge.test.mjs @@ -0,0 +1,354 @@ +import { assert } from 'chai'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { schemasToContext } from '../../../dist/helpers/schemas-to-context.js'; +import { GetPropertiesFromFile } from '../../../dist/helpers/policy-property.js'; +import { GetGroupedCategories } from '../../../dist/helpers/policy-category.js'; + +const mkSchema = (term, fields) => ({ + '$id': '#' + term, + '$comment': JSON.stringify({ term, '@id': 'https://example.com/#' + term }), + title: term, + type: 'object', + properties: Object.fromEntries( + Object.entries(fields).map(([key, id]) => [ + key, + { + title: key, + type: 'string', + '$comment': JSON.stringify({ term: key, '@id': id }), + }, + ]) + ), +}); + +const TEXT = 'https://www.schema.org/text'; +const INT = 'https://www.schema.org/integer'; + +const writeTemp = async (contents) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gpe-')); + const file = path.join(dir, 'props.csv'); + await fs.writeFile(file, contents, 'utf8'); + return file; +}; + +describe('@unit schemasToContext (edge / real library)', () => { + it('returns only the base traceability context for an empty schema list', () => { + const out = schemasToContext([]); + assert.equal(out['@context']['@version'], 1.1); + assert.equal(out['@context']['@vocab'], 'https://w3id.org/traceability/#undefinedTerm'); + assert.equal(out['@context'].id, '@id'); + assert.equal(out['@context'].type, '@type'); + }); + + it('throws TypeError when schemas is null', () => { + assert.throws(() => schemasToContext(null), TypeError); + }); + + it('throws TypeError when schemas is undefined', () => { + assert.throws(() => schemasToContext(undefined), TypeError); + }); + + it('throws TypeError when a schema element is null', () => { + assert.throws(() => schemasToContext([null]), TypeError); + }); + + it('ignores an empty-object schema (no extra terms emitted)', () => { + const out = schemasToContext([{}]); + assert.deepEqual(Object.keys(out['@context']).sort(), ['@version', '@vocab', 'id', 'type']); + }); + + it('ignores a schema with no $id/properties', () => { + const out = schemasToContext([{ title: 'X', type: 'object' }]); + assert.deepEqual(Object.keys(out['@context']).sort(), ['@version', '@vocab', 'id', 'type']); + }); + + it('emits a nested @context for a populated schema keyed by its term', () => { + const out = schemasToContext([mkSchema('Alpha', { a: TEXT })]); + assert.ok(out['@context'].Alpha); + assert.equal(out['@context'].Alpha['@id'], 'https://example.com/#Alpha'); + assert.ok(out['@context'].Alpha['@context'].a); + }); + + it('rewrites field "@id" of schema.org/text into "@type"', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })]); + assert.equal(out['@context'].S['@context'].a['@type'], TEXT); + assert.isUndefined(out['@context'].S['@context'].a['@id']); + }); + + it('leaves non-text field "@id" (integer) untouched', () => { + const out = schemasToContext([mkSchema('S', { a: INT })]); + assert.equal(out['@context'].S['@context'].a['@id'], INT); + assert.isUndefined(out['@context'].S['@context'].a['@type']); + }); + + it('rewrites every occurrence of schema.org/text (replaceAll, not first-only)', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT, b: TEXT })]); + assert.equal(out['@context'].S['@context'].a['@type'], TEXT); + assert.equal(out['@context'].S['@context'].b['@type'], TEXT); + }); + + it('does NOT rewrite a longer URL that merely starts with schema.org/text', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT + 'area' })]); + assert.equal(out['@context'].S['@context'].a['@id'], TEXT + 'area'); + assert.isUndefined(out['@context'].S['@context'].a['@type']); + }); + + it('mixes rewritten and untouched fields within one schema', () => { + const out = schemasToContext([mkSchema('S', { txt: TEXT, num: INT })]); + assert.equal(out['@context'].S['@context'].txt['@type'], TEXT); + assert.equal(out['@context'].S['@context'].num['@id'], INT); + }); + + it('preserves a large field set without loss', () => { + const fields = {}; + for (let i = 0; i < 50; i++) { + fields['f' + i] = TEXT; + } + const out = schemasToContext([mkSchema('Big', fields)]); + assert.equal(Object.keys(out['@context'].Big['@context']).length, 50); + }); + + it('preserves unicode in term and field keys', () => { + const out = schemasToContext([mkSchema('Schéma_名', { café: TEXT })]); + assert.ok(out['@context']['Schéma_名']); + assert.equal(out['@context']['Schéma_名']['@context']['café']['@type'], TEXT); + }); + + it('merges additionalContexts entries on top of the real base context', () => { + const additional = new Map([ + ['foo', { '@id': 'https://example.com/foo' }], + ['bar', 'http://example.com/bar'], + ]); + const out = schemasToContext([], additional); + assert.deepEqual(out['@context'].foo, { '@id': 'https://example.com/foo' }); + assert.equal(out['@context'].bar, 'http://example.com/bar'); + }); + + it('additionalContexts can overwrite a schema-derived term', () => { + const additional = new Map([['S', 'REPLACED']]); + const out = schemasToContext([mkSchema('S', { a: TEXT })], additional); + assert.equal(out['@context'].S, 'REPLACED'); + }); + + it('additionalContexts can overwrite a base reserved key', () => { + const out = schemasToContext([], new Map([['type', 'OVERWRITTEN']])); + assert.equal(out['@context'].type, 'OVERWRITTEN'); + }); + + it('an empty additionalContexts Map is a no-op', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })], new Map()); + assert.ok(out['@context'].S); + assert.equal(out['@context'].S['@context'].a['@type'], TEXT); + }); + + it('ignores additionalContexts passed as a plain array (no .size)', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })], [['k', 'v']]); + assert.isUndefined(out['@context'].k); + }); + + it('ignores additionalContexts passed as undefined', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })], undefined); + assert.ok(out['@context'].S); + }); + + it('ignores additionalContexts passed as null', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })], null); + assert.ok(out['@context'].S); + }); + + it('returns a fresh object graph on each call (no shared references)', () => { + const a = schemasToContext([]); + const b = schemasToContext([]); + assert.notStrictEqual(a, b); + assert.notStrictEqual(a['@context'], b['@context']); + }); + + it('result survives a JSON round-trip identically (deterministic shape)', () => { + const out = schemasToContext([mkSchema('S', { a: TEXT })]); + assert.deepEqual(JSON.parse(JSON.stringify(out)), out); + }); + + it('merges fields from two schemas that share a term', () => { + const out = schemasToContext([ + mkSchema('Dup', { a: TEXT }), + mkSchema('Dup', { b: INT }), + ]); + assert.equal(out['@context'].Dup['@context'].a['@type'], TEXT); + assert.equal(out['@context'].Dup['@context'].b['@id'], INT); + }); +}); + +describe('@unit GetPropertiesFromFile (edge)', () => { + it('keeps an empty second column as empty-string value', async () => { + const file = await writeTemp('a,\nb,'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [ + { title: 'a', value: '' }, + { title: 'b', value: '' }, + ]); + }); + + it('does not trim surrounding whitespace from title or value', async () => { + const file = await writeTemp(' a , 1 '); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: ' a ', value: ' 1 ' }]); + }); + + it('leaves a trailing CR attached to the value on CRLF input', async () => { + const file = await writeTemp('a,1\r\nb,2\r\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [ + { title: 'a', value: '1\r' }, + { title: 'b', value: '2\r' }, + ]); + }); + + it('skips a quoted field containing a comma (no CSV quote handling)', async () => { + const file = await writeTemp('name,"x,y"'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('preserves unicode in title and value', async () => { + const file = await writeTemp('café,naïve'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'café', value: 'naïve' }]); + }); + + it('skips a single-column row with no comma', async () => { + const file = await writeTemp('solo'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('returns [] for a file of only newlines', async () => { + const file = await writeTemp('\n\n\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('skips a two-column row whose title is empty', async () => { + const file = await writeTemp(',v'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('skips a tab-separated row (only comma is a delimiter)', async () => { + const file = await writeTemp('a\t1'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, []); + }); + + it('skips rows with three or more columns', async () => { + const file = await writeTemp('a,1,2\nb,2\n'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'b', value: '2' }]); + }); + + it('treats a numeric-looking value as a string', async () => { + const file = await writeTemp('count,42'); + const out = await GetPropertiesFromFile(file); + assert.strictEqual(out[0].value, '42'); + }); + + it('handles a file with no trailing newline', async () => { + const file = await writeTemp('a,1'); + const out = await GetPropertiesFromFile(file); + assert.deepEqual(out, [{ title: 'a', value: '1' }]); + }); + + it('rejects when the file does not exist', async () => { + const missing = path.join(os.tmpdir(), 'gpe-missing-' + Date.now() + '.csv'); + const original = console.error; + console.error = () => {}; + try { + let threw = false; + try { + await GetPropertiesFromFile(missing); + } catch { + threw = true; + } + assert.isTrue(threw); + } finally { + console.error = original; + } + }); + + it('returns a distinct array instance per call', async () => { + const file = await writeTemp('a,1'); + const first = await GetPropertiesFromFile(file); + const second = await GetPropertiesFromFile(file); + assert.notStrictEqual(first, second); + assert.deepEqual(first, second); + }); +}); + +describe('@unit GetGroupedCategories (edge)', () => { + it('returns {} for an empty list', () => { + assert.deepEqual(GetGroupedCategories([]), {}); + }); + + it('throws TypeError when the list is null', () => { + assert.throws(() => GetGroupedCategories(null), TypeError); + }); + + it('throws TypeError when the list is undefined', () => { + assert.throws(() => GetGroupedCategories(undefined), TypeError); + }); + + it('buckets items with undefined type under the literal key "undefined"', () => { + const out = GetGroupedCategories([{ id: 'x' }]); + assert.deepEqual(out, { undefined: ['x'] }); + }); + + it('buckets items with null type under the literal key "null"', () => { + const out = GetGroupedCategories([{ id: 'x', type: null }]); + assert.deepEqual(out, { null: ['x'] }); + }); + + it('buckets items with empty-string type under the empty-string key', () => { + const out = GetGroupedCategories([{ id: 'a', type: '' }]); + assert.deepEqual(out, { '': ['a'] }); + }); + + it('coerces a numeric type to its string key', () => { + const out = GetGroupedCategories([{ id: 'a', type: 1 }, { id: 'b', type: 1 }]); + assert.deepEqual(out, { 1: ['a', 'b'] }); + }); + + it('pushes undefined id values without filtering them out', () => { + const out = GetGroupedCategories([{ type: 'T' }]); + assert.lengthOf(out.T, 1); + assert.isUndefined(out.T[0]); + }); + + it('keeps duplicate ids in the same bucket (no dedupe)', () => { + const out = GetGroupedCategories([{ id: 'a', type: 'T' }, { id: 'a', type: 'T' }]); + assert.deepEqual(out.T, ['a', 'a']); + }); + + it('throws when type collides with an inherited Object property name (__proto__)', () => { + assert.throws(() => GetGroupedCategories([{ id: 'a', type: '__proto__' }]), TypeError); + }); + + it('treats "constructor" type as a non-array inherited slot and throws', () => { + assert.throws(() => GetGroupedCategories([{ id: 'a', type: 'constructor' }]), TypeError); + }); + + it('groups multiple types preserving first-seen id order per bucket', () => { + const out = GetGroupedCategories([ + { id: 'a1', type: 'A' }, + { id: 'b1', type: 'B' }, + { id: 'a2', type: 'A' }, + ]); + assert.deepEqual(out.A, ['a1', 'a2']); + assert.deepEqual(out.B, ['b1']); + }); + + it('returns a plain object (own enumerable keys only)', () => { + const out = GetGroupedCategories([{ id: 'a', type: 'A' }]); + assert.deepEqual(Object.keys(out), ['A']); + }); +}); diff --git a/common/tests/unit-tests/schemas-to-context/schemas-to-context.test.mjs b/common/tests/unit-tests/schemas-to-context/schemas-to-context.test.mjs new file mode 100644 index 0000000000..c971d173b5 --- /dev/null +++ b/common/tests/unit-tests/schemas-to-context/schemas-to-context.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const { schemasToContext } = await esmock('../../../dist/helpers/schemas-to-context.js', { + '@transmute/jsonld-schema': { + schemasToContext: (schemas) => ({ + '@context': { + name: { '@id': 'https://www.schema.org/text' }, + age: { '@id': 'https://www.schema.org/integer' }, + schemasCount: schemas?.length ?? 0, + }, + }), + }, +}); + +describe('@unit schemasToContext', () => { + it('rewrites "@id":"https://www.schema.org/text" → "@type":"https://www.schema.org/text"', () => { + const out = schemasToContext([{ schema: 1 }]); + assert.equal(out['@context'].name['@type'], 'https://www.schema.org/text'); + assert.equal(out['@context'].name['@id'], undefined); + }); + + it('leaves non-text "@id" entries untouched', () => { + const out = schemasToContext([{ schema: 1 }]); + assert.equal(out['@context'].age['@id'], 'https://www.schema.org/integer'); + assert.equal(out['@context'].age['@type'], undefined); + }); + + it('merges additionalContexts on top of the base context', () => { + const additional = new Map([ + ['foo', { '@id': 'https://example.com/foo' }], + ['bar', 'http://example.com/bar'], + ]); + const out = schemasToContext([], additional); + assert.deepEqual(out['@context'].foo, { '@id': 'https://example.com/foo' }); + assert.equal(out['@context'].bar, 'http://example.com/bar'); + }); + + it('additional context entries can OVERWRITE keys from the base', () => { + const additional = new Map([ + ['name', 'overwritten-by-additional'], + ]); + const out = schemasToContext([], additional); + assert.equal(out['@context'].name, 'overwritten-by-additional'); + }); + + it('handles undefined additionalContexts (no error)', () => { + const out = schemasToContext([{ schema: 1 }]); + assert.ok(out['@context']); + }); + + it('handles empty additionalContexts map (no merge)', () => { + const out = schemasToContext([{ schema: 1 }], new Map()); + // Only the base context keys + assert.deepEqual(Object.keys(out['@context']).sort(), ['age', 'name', 'schemasCount']); + }); + + it('forwards schemas argument count through to the underlying library', () => { + const out = schemasToContext([{ a: 1 }, { b: 2 }, { c: 3 }]); + assert.equal(out['@context'].schemasCount, 3); + }); + + it('result is a fresh object — repeated calls do not share references', () => { + const a = schemasToContext([]); + const b = schemasToContext([]); + assert.notStrictEqual(a, b); + assert.notStrictEqual(a['@context'], b['@context']); + }); +}); diff --git a/common/tests/unit-tests/secret-manager-type/hcp-vault-configs.test.mjs b/common/tests/unit-tests/secret-manager-type/hcp-vault-configs.test.mjs new file mode 100644 index 0000000000..32e4f5f2c0 --- /dev/null +++ b/common/tests/unit-tests/secret-manager-type/hcp-vault-configs.test.mjs @@ -0,0 +1,75 @@ +import { assert } from 'chai'; +import fs from 'node:fs'; +import path from 'node:path'; +import { HcpVaultSecretManagerConfigs } from '../../../dist/secret-manager/hashicorp/hcp-vault-secret-manager-configs.js'; + +describe('HcpVaultSecretManagerConfigs.getConfigs', () => { + const saved = {}; + const keys = [ + 'VAULT_API_VERSION', + 'VAULT_ADDRESS', + 'VAULT_CA_CERT', + 'VAULT_CLIENT_CERT', + 'VAULT_CLIENT_KEY', + 'VAULT_APPROLE_ROLE_ID', + 'VAULT_APPROLE_SECRET_ID' + ]; + + before(() => { + for (const key of keys) { + saved[key] = process.env[key]; + } + process.env.VAULT_API_VERSION = 'v1'; + process.env.VAULT_ADDRESS = 'https://vault.local:8200'; + process.env.VAULT_CA_CERT = 'package.json'; + process.env.VAULT_CLIENT_CERT = 'package.json'; + process.env.VAULT_CLIENT_KEY = 'package.json'; + process.env.VAULT_APPROLE_ROLE_ID = 'role-id'; + process.env.VAULT_APPROLE_SECRET_ID = 'secret-id'; + }); + + after(() => { + for (const key of keys) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it('reads api version and endpoint from the environment', () => { + const configs = HcpVaultSecretManagerConfigs.getConfigs(); + assert.equal(configs.apiVersion, 'v1'); + assert.equal(configs.endpoint, 'https://vault.local:8200'); + }); + + it('builds the approle credential from the environment', () => { + const configs = HcpVaultSecretManagerConfigs.getConfigs(); + assert.deepEqual(configs.approleCredential, { roleId: 'role-id', secretId: 'secret-id' }); + }); + + it('loads tls files relative to the working directory', () => { + const configs = HcpVaultSecretManagerConfigs.getConfigs(); + const expected = fs.readFileSync(path.join(process.cwd(), 'package.json')); + assert.deepEqual(configs.tlsOptions.ca, expected); + assert.deepEqual(configs.tlsOptions.cert, expected); + assert.deepEqual(configs.tlsOptions.key, expected); + }); + + it('returns Buffers for tls options', () => { + const configs = HcpVaultSecretManagerConfigs.getConfigs(); + assert.instanceOf(configs.tlsOptions.ca, Buffer); + assert.instanceOf(configs.tlsOptions.cert, Buffer); + assert.instanceOf(configs.tlsOptions.key, Buffer); + }); + + it('throws when a tls file is missing', () => { + process.env.VAULT_CA_CERT = 'does-not-exist.pem'; + try { + assert.throws(() => HcpVaultSecretManagerConfigs.getConfigs()); + } finally { + process.env.VAULT_CA_CERT = 'package.json'; + } + }); +}); diff --git a/common/tests/unit-tests/seq-transport/seq-transport.test.mjs b/common/tests/unit-tests/seq-transport/seq-transport.test.mjs new file mode 100644 index 0000000000..f66e382e45 --- /dev/null +++ b/common/tests/unit-tests/seq-transport/seq-transport.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const state = { constructed: [], emitted: [], closed: 0 }; + +class Logger { + constructor(options) { this.options = options; state.constructed.push(options); } + emit(event) { state.emitted.push(event); } + close() { state.closed += 1; } +} + +const { SeqTransport } = await esmock.strict('../../../dist/helpers/seq-transport.js', { + 'seq-logging': { Logger }, +}); + +function write(transport, log) { + return new Promise((resolve) => transport._write(typeof log === 'string' ? log : JSON.stringify(log), 'utf8', resolve)); +} + +describe('@unit SeqTransport constructor', () => { + beforeEach(() => { state.constructed = []; state.emitted = []; state.closed = 0; }); + + it('passes the seq url through as serverUrl', () => { + new SeqTransport({ seqUrl: 'http://seq:5341' }); + assert.equal(state.constructed[0].serverUrl, 'http://seq:5341'); + }); + + it('sets an onError handler', () => { + new SeqTransport({ seqUrl: 'http://seq' }); + assert.equal(typeof state.constructed[0].onError, 'function'); + }); + + it('includes apiKey when a non-empty key is supplied', () => { + new SeqTransport({ seqUrl: 'http://seq', apiKey: 'abc' }); + assert.equal(state.constructed[0].apiKey, 'abc'); + }); + + it('omits apiKey when an empty string is supplied', () => { + new SeqTransport({ seqUrl: 'http://seq', apiKey: '' }); + assert.equal('apiKey' in state.constructed[0], false); + }); + + it('is an object-mode Writable stream', () => { + const t = new SeqTransport({ seqUrl: 'http://seq' }); + assert.equal(t.writableObjectMode, true); + }); +}); + +describe('@unit SeqTransport _write', () => { + let t; + beforeEach(() => { + state.constructed = []; state.emitted = []; state.closed = 0; + t = new SeqTransport({ seqUrl: 'http://seq' }); + }); + + it('maps the pino level to the Seq level name', async () => { + await write(t, { level: 'error', time: 1, message: 'boom', attributes: ['svc'] }); + assert.equal(state.emitted[0].level, 'Error'); + }); + + const cases = [ + ['trace', 'Verbose'], ['debug', 'Debug'], ['info', 'Information'], + ['warn', 'Warning'], ['error', 'Error'], ['fatal', 'Fatal'], + ]; + for (const [level, mapped] of cases) { + it(`maps level "${level}" → "${mapped}"`, async () => { + await write(t, { level, time: 2, message: 'm', attributes: ['a'] }); + assert.equal(state.emitted[0].level, mapped); + }); + } + + it('builds the message template from attributes and message', async () => { + await write(t, { level: 'info', time: 5, message: 'hello', attributes: ['API', 'GW'] }); + assert.equal(state.emitted[0].messageTemplate, '[API, GW]: hello'); + }); + + it('forwards timestamp and properties', async () => { + await write(t, { level: 'info', time: 99, message: 'm', attributes: ['x'] }); + assert.equal(state.emitted[0].timestamp, 99); + assert.deepEqual(state.emitted[0].properties, { level: 'info', time: 99, attributes: ['x'] }); + }); + + it('calls back without error on success', async () => { + const err = await new Promise((resolve) => + t._write(JSON.stringify({ level: 'info', time: 1, message: 'm', attributes: [] }), 'utf8', resolve)); + assert.equal(err, undefined); + }); + + it('calls back with an error on malformed JSON and does not emit', async () => { + const err = await new Promise((resolve) => t._write('}{not json', 'utf8', resolve)); + assert.ok(err instanceof Error); + assert.equal(state.emitted.length, 0); + }); +}); + +describe('@unit SeqTransport _final', () => { + beforeEach(() => { state.constructed = []; state.emitted = []; state.closed = 0; }); + + it('closes the underlying logger and calls back', async () => { + const t = new SeqTransport({ seqUrl: 'http://seq' }); + const done = await new Promise((resolve) => t._final(() => resolve(true))); + assert.equal(done, true); + assert.equal(state.closed, 1); + }); +}); diff --git a/common/tests/unit-tests/service-requests-base/service-requests-base.test.mjs b/common/tests/unit-tests/service-requests-base/service-requests-base.test.mjs new file mode 100644 index 0000000000..b816bab862 --- /dev/null +++ b/common/tests/unit-tests/service-requests-base/service-requests-base.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { ServiceRequestsBase } from '../../../dist/helpers/service-requests-base.js'; + +class TestRequester extends ServiceRequestsBase { + target = 'TEST_SERVICE'; +} + +class FakeChannel { + constructor(impl) { this.impl = impl; this.calls = []; } + async request(subject, params) { + this.calls.push({ subject, params }); + return this.impl(subject, params); + } +} + +describe('@unit ServiceRequestsBase.request', () => { + it('returns response.body on a successful response', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => ({ body: { ok: true } }))); + const result = await req.request('echo', { x: 1 }); + assert.deepEqual(result, { ok: true }); + }); + + it('uses "." as the channel subject', async () => { + const req = new TestRequester(); + const ch = new FakeChannel(() => ({ body: 'ok' })); + req.setChannel(ch); + await req.request('myEntity'); + assert.equal(ch.calls[0].subject, 'TEST_SERVICE.myEntity'); + }); + + it('forwards params to the channel verbatim', async () => { + const req = new TestRequester(); + const ch = new FakeChannel(() => ({ body: null })); + req.setChannel(ch); + await req.request('e', { foo: 'bar', n: 42 }); + assert.deepEqual(ch.calls[0].params, { foo: 'bar', n: 42 }); + }); + + it('throws "server is not available" wrapped when response is null', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => null)); + await assert.rejects(() => req.request('ping'), /TEST_SERVICE server is not available/); + await assert.rejects(() => req.request('ping'), /Guardian \(ping\) send:/); + }); + + it('throws when response is undefined', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => undefined)); + await assert.rejects(() => req.request('ping')); + }); + + it('throws the inner error wrapped when response.error is set', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => ({ error: 'something bad' }))); + await assert.rejects(() => req.request('ping'), /something bad/); + await assert.rejects(() => req.request('ping'), /Guardian \(ping\) send:/); + }); + + it('re-wraps channel.request rejections', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => { throw new Error('network-down'); })); + await assert.rejects(() => req.request('ping'), /network-down/); + await assert.rejects(() => req.request('ping'), /Guardian \(ping\) send:/); + }); + + it('returns null body verbatim', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => ({ body: null }))); + const result = await req.request('e'); + assert.equal(result, null); + }); + + it('preserves falsy bodies (0, false, "")', async () => { + const req = new TestRequester(); + req.setChannel(new FakeChannel(() => ({ body: 0 }))); + assert.equal(await req.request('e'), 0); + req.setChannel(new FakeChannel(() => ({ body: false }))); + assert.equal(await req.request('e'), false); + req.setChannel(new FakeChannel(() => ({ body: '' }))); + assert.equal(await req.request('e'), ''); + }); + + it('setChannel / getChannel round-trip', () => { + const req = new TestRequester(); + const ch = new FakeChannel(() => ({ body: null })); + req.setChannel(ch); + assert.strictEqual(req.getChannel(), ch); + }); + + it('different entities produce different subjects', async () => { + const req = new TestRequester(); + const ch = new FakeChannel(() => ({ body: 'x' })); + req.setChannel(ch); + await req.request('a'); + await req.request('b'); + assert.deepEqual(ch.calls.map((c) => c.subject), ['TEST_SERVICE.a', 'TEST_SERVICE.b']); + }); +}); diff --git a/common/tests/unit-tests/singleton-decorator/singleton-decorator.test.mjs b/common/tests/unit-tests/singleton-decorator/singleton-decorator.test.mjs new file mode 100644 index 0000000000..46d1b92f12 --- /dev/null +++ b/common/tests/unit-tests/singleton-decorator/singleton-decorator.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { Singleton } from '../../../dist/decorators/singleton.js'; + +const decorate = (cls) => Singleton(cls); + +describe('Singleton (common decorator)', () => { + it('returns the same instance for repeated `new` calls', () => { + class Holder { + constructor() { + this.id = Math.random(); + } + } + const Wrapped = decorate(Holder); + const a = new Wrapped(); + const b = new Wrapped(); + assert.equal(a, b); + assert.equal(a.id, b.id); + }); + + it('preserves constructor arguments on the first construction only', () => { + class Tagged { + constructor(label) { + this.label = label; + } + } + const Wrapped = decorate(Tagged); + const first = new Wrapped('first'); + const second = new Wrapped('second-ignored'); + assert.equal(first.label, 'first'); + assert.equal(second, first); + }); + + it('produces independent singletons per decorated class', () => { + class A {} + class B {} + const WA = decorate(A); + const WB = decorate(B); + assert.notEqual(new WA(), new WB()); + }); + + it('returns a fresh instance when constructed via a subclass', () => { + class Base {} + const WrappedBase = decorate(Base); + class Derived extends WrappedBase {} + const baseInstance = new WrappedBase(); + const derivedInstance = new Derived(); + assert.notEqual(baseInstance, derivedInstance); + assert.ok(derivedInstance instanceof Derived); + }); + + it('is the same Singleton symbol across multiple invocations', () => { + class Foo {} + const A = decorate(Foo); + const B = decorate(Foo); + // Each call creates a new Proxy, but the underlying SINGLETON_KEY is set on + // the same target — so once an instance is constructed via either Proxy, + // both Proxies return that same instance. + const a = new A(); + const b = new B(); + assert.equal(a, b); + }); + + it('preserves the prototype chain of the wrapped class', () => { + class Foo { + greet() { return 'hi'; } + } + const Wrapped = decorate(Foo); + const instance = new Wrapped(); + assert.equal(instance.greet(), 'hi'); + assert.ok(instance instanceof Foo); + }); +}); diff --git a/common/tests/unit-tests/singleton.test.mjs b/common/tests/unit-tests/singleton.test.mjs new file mode 100644 index 0000000000..b812bc2a52 --- /dev/null +++ b/common/tests/unit-tests/singleton.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { Singleton } from '../../dist/decorators/singleton.js'; + +describe('Singleton class decorator', () => { + it('returns the same instance across direct construction calls', () => { + const Counter = Singleton(class Counter { + constructor() { this.n = 0; } + inc() { this.n++; } + }); + const a = new Counter(); + const b = new Counter(); + assert.equal(a, b); + a.inc(); + assert.equal(b.n, 1); + }); + + it('preserves subclass instances as separate (not collapsed into base singleton)', () => { + const Base = Singleton(class Base { + constructor() { this.kind = 'base'; } + }); + class Child extends Base { + constructor() { super(); this.kind = 'child'; } + } + const childA = new Child(); + const childB = new Child(); + assert.equal(childA.kind, 'child'); + assert.equal(childB.kind, 'child'); + // Subclass construction goes through different prototype, returning fresh instances each time + assert.notEqual(childA, childB); + }); + + it('lazy-initializes (constructor only runs once)', () => { + let count = 0; + const C = Singleton(class C { constructor() { count++; } }); + new C(); new C(); new C(); + assert.equal(count, 1); + }); +}); diff --git a/common/tests/unit-tests/table-file-ids.test.mjs b/common/tests/unit-tests/table-file-ids.test.mjs new file mode 100644 index 0000000000..169e2ecfa2 --- /dev/null +++ b/common/tests/unit-tests/table-file-ids.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { extractTableFileIds } from '../../dist/helpers/table-file-ids.js'; + +describe('extractTableFileIds', () => { + it('returns [] for null/undefined/primitive roots', () => { + assert.deepEqual(extractTableFileIds(null), []); + assert.deepEqual(extractTableFileIds(undefined), []); + assert.deepEqual(extractTableFileIds('plain'), []); + assert.deepEqual(extractTableFileIds(42), []); + }); + + it('finds a single { type: "table", fileId } object', () => { + const ids = extractTableFileIds({ type: 'table', fileId: '507f1f77bcf86cd799439011' }); + assert.equal(ids.length, 1); + assert.equal(ids[0].toString(), '507f1f77bcf86cd799439011'); + }); + + it('matches type case-insensitively (TABLE / Table)', () => { + const ids = extractTableFileIds({ + children: [ + { type: 'TABLE', fileId: '507f1f77bcf86cd799439011' }, + { type: 'Table', fileId: '507f1f77bcf86cd799439012' }, + ], + }); + assert.equal(ids.length, 2); + }); + + it('walks nested arrays and objects', () => { + const ids = extractTableFileIds({ + blocks: [ + { type: 'paragraph', text: 'x' }, + { children: { nested: { type: 'table', fileId: '507f1f77bcf86cd799439013' } } }, + ], + }); + assert.equal(ids.length, 1); + assert.equal(ids[0].toString(), '507f1f77bcf86cd799439013'); + }); + + it('parses JSON-stringified subtrees and walks them', () => { + const inner = JSON.stringify({ type: 'table', fileId: '507f1f77bcf86cd799439014' }); + const ids = extractTableFileIds({ raw: inner }); + assert.equal(ids.length, 1); + assert.equal(ids[0].toString(), '507f1f77bcf86cd799439014'); + }); + + it('deduplicates repeated fileIds', () => { + const ids = extractTableFileIds([ + { type: 'table', fileId: '507f1f77bcf86cd799439011' }, + { type: 'table', fileId: '507f1f77bcf86cd799439011' }, + ]); + assert.equal(ids.length, 1); + }); + + it('skips table nodes whose fileId is empty/whitespace', () => { + const ids = extractTableFileIds({ + list: [ + { type: 'table', fileId: ' ' }, + { type: 'table', fileId: '' }, + ], + }); + assert.equal(ids.length, 0); + }); +}); diff --git a/common/tests/unit-tests/table-file-ids/table-file-ids-edge.test.mjs b/common/tests/unit-tests/table-file-ids/table-file-ids-edge.test.mjs new file mode 100644 index 0000000000..3515a1511a --- /dev/null +++ b/common/tests/unit-tests/table-file-ids/table-file-ids-edge.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { extractTableFileIds } from '../../../dist/helpers/table-file-ids.js'; + +const oid = () => new ObjectId().toHexString(); + +describe('extractTableFileIds — edges', () => { + it('parses a top-level JSON array string', () => { + const id = oid(); + const out = extractTableFileIds(JSON.stringify([{ type: 'table', fileId: id }])); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('trims whitespace before detecting a JSON string', () => { + const id = oid(); + const out = extractTableFileIds({ p: ' ' + JSON.stringify({ type: 'table', fileId: id }) }); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('still traverses a table node\'s own children for more tables', () => { + const a = oid(); + const b = oid(); + const out = extractTableFileIds({ type: 'table', fileId: a, child: { type: 'table', fileId: b } }); + assert.deepEqual(out.map((o) => o.toHexString()).sort(), [a, b].sort()); + }); + + it('throws when a table fileId is not a valid ObjectId (no pre-validation)', () => { + assert.throws(() => extractTableFileIds({ type: 'table', fileId: 'not-an-oid' })); + }); +}); diff --git a/common/tests/unit-tests/table-file-ids/table-file-ids.test.mjs b/common/tests/unit-tests/table-file-ids/table-file-ids.test.mjs new file mode 100644 index 0000000000..090605a2dd --- /dev/null +++ b/common/tests/unit-tests/table-file-ids/table-file-ids.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { extractTableFileIds } from '../../../dist/helpers/table-file-ids.js'; + +const oid = () => new ObjectId().toHexString(); + +describe('extractTableFileIds', () => { + it('returns [] for null/undefined/primitive roots', () => { + assert.deepEqual(extractTableFileIds(null), []); + assert.deepEqual(extractTableFileIds(undefined), []); + assert.deepEqual(extractTableFileIds(42), []); + assert.deepEqual(extractTableFileIds('plain string'), []); + }); + + it('finds a fileId on a top-level table node', () => { + const id = oid(); + const out = extractTableFileIds({ type: 'table', fileId: id }); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('matches "type" case-insensitively', () => { + const id = oid(); + const out = extractTableFileIds({ type: 'TABLE', fileId: id }); + assert.equal(out[0].toHexString(), id); + }); + + it('ignores nodes whose type is not table', () => { + assert.deepEqual( + extractTableFileIds({ type: 'image', fileId: oid() }), + [] + ); + }); + + it('ignores table nodes without a usable fileId', () => { + assert.deepEqual(extractTableFileIds({ type: 'table' }), []); + assert.deepEqual(extractTableFileIds({ type: 'table', fileId: '' }), []); + assert.deepEqual(extractTableFileIds({ type: 'table', fileId: ' ' }), []); + assert.deepEqual(extractTableFileIds({ type: 'table', fileId: 123 }), []); + }); + + it('descends into arrays', () => { + const id = oid(); + const out = extractTableFileIds([ + { type: 'group' }, + { type: 'table', fileId: id }, + ]); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('descends into nested object children', () => { + const id = oid(); + const out = extractTableFileIds({ + outer: { inner: { type: 'table', fileId: id } }, + }); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('parses JSON strings encountered during traversal', () => { + const id = oid(); + const json = JSON.stringify({ type: 'table', fileId: id }); + const out = extractTableFileIds({ payload: json }); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('silently skips invalid JSON strings', () => { + assert.deepEqual(extractTableFileIds({ payload: '{not json' }), []); + }); + + it('deduplicates ids across the document', () => { + const id = oid(); + const out = extractTableFileIds([ + { type: 'table', fileId: id }, + { type: 'table', fileId: ` ${id} ` }, + { nested: { type: 'table', fileId: id } }, + ]); + assert.equal(out.length, 1); + assert.equal(out[0].toHexString(), id); + }); + + it('returns multiple distinct ids', () => { + const a = oid(); + const b = oid(); + const out = extractTableFileIds([ + { type: 'table', fileId: a }, + { type: 'table', fileId: b }, + ]); + const hex = out.map((o) => o.toHexString()).sort(); + assert.deepEqual(hex, [a, b].sort()); + }); + + it('returns ObjectId instances, not strings', () => { + const id = oid(); + const [first] = extractTableFileIds({ type: 'table', fileId: id }); + assert.ok(first instanceof ObjectId); + }); +}); diff --git a/common/tests/unit-tests/timestamp-utils/timestamp-utils.test.mjs b/common/tests/unit-tests/timestamp-utils/timestamp-utils.test.mjs new file mode 100644 index 0000000000..193bcd5bd2 --- /dev/null +++ b/common/tests/unit-tests/timestamp-utils/timestamp-utils.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { Timestamp } from '@hiero-ledger/sdk'; +import { TimestampUtils } from '../../../dist/hedera-modules/timestamp-utils.js'; + +describe('TimestampUtils.now', () => { + it('returns a Timestamp instance', () => { + const t = TimestampUtils.now(); + assert.ok(t instanceof Timestamp); + }); + + it('approximates the current Date (within 1s)', () => { + const before = Date.now(); + const t = TimestampUtils.now(); + const after = Date.now(); + const tMs = t.toDate().getTime(); + assert.ok(tMs >= before - 1000); + assert.ok(tMs <= after + 1000); + }); +}); + +describe('TimestampUtils.toJSON / fromJson round-trip', () => { + it('round-trips a known UTC date through ISO', () => { + const date = new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678)); + const ts = Timestamp.fromDate(date); + const json = TimestampUtils.toJSON(ts); + assert.equal(json, '2024-01-02T03:04:05.678Z'); + const parsed = TimestampUtils.fromJson(json); + assert.equal(parsed.toDate().getTime(), date.getTime()); + }); + + it('round-trips ISO8601 (no millis) format', () => { + const date = new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 0)); + const ts = Timestamp.fromDate(date); + const json = TimestampUtils.toJSON(ts, TimestampUtils.ISO8601); + assert.equal(json, '2024-01-02T03:04:05Z'); + const parsed = TimestampUtils.fromJson(json, TimestampUtils.ISO8601); + assert.equal(parsed.toDate().getTime(), date.getTime()); + }); + + it('exposes the documented format constants', () => { + assert.equal(TimestampUtils.ISO, 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + assert.equal(TimestampUtils.ISO8601, 'YYYY-MM-DDTHH:mm:ss[Z]'); + }); +}); + +describe('TimestampUtils.equals', () => { + it('returns true for the same object reference', () => { + const t = TimestampUtils.now(); + assert.equal(TimestampUtils.equals(t, t), true); + }); + + it('returns false when only one side is null/undefined', () => { + const t = TimestampUtils.now(); + assert.equal(TimestampUtils.equals(null, t), false); + assert.equal(TimestampUtils.equals(t, null), false); + }); + + it('returns true when both sides are the same null reference (a === b shortcut)', () => { + // Documented behavior: identity check fires before the null guard. + assert.equal(TimestampUtils.equals(null, null), true); + }); + + it('returns true for two timestamps representing the same instant', () => { + const date = new Date(Date.UTC(2024, 5, 1)); + const a = Timestamp.fromDate(date); + const b = Timestamp.fromDate(date); + assert.equal(TimestampUtils.equals(a, b), true); + }); + + it('returns false for two timestamps representing different instants', () => { + const a = Timestamp.fromDate(new Date(Date.UTC(2024, 0, 1))); + const b = Timestamp.fromDate(new Date(Date.UTC(2024, 6, 1))); + assert.equal(TimestampUtils.equals(a, b), false); + }); +}); + +describe('TimestampUtils.lessThan', () => { + it('returns false for the same reference', () => { + const t = TimestampUtils.now(); + assert.equal(TimestampUtils.lessThan(t, t), false); + }); + + it('returns false when either side is null/undefined', () => { + const t = TimestampUtils.now(); + assert.equal(TimestampUtils.lessThan(null, t), false); + assert.equal(TimestampUtils.lessThan(t, null), false); + }); + + it('compares nanos when seconds are equal', () => { + // Build two Timestamps at the same second but different nano resolution. + const a = new Timestamp(1700000000, 100); + const b = new Timestamp(1700000000, 200); + assert.equal(TimestampUtils.lessThan(a, b), true); + }); + + it('returns false when nanos are equal at the same second', () => { + const a = new Timestamp(1700000000, 100); + const b = new Timestamp(1700000000, 100); + // Note: TimestampUtils.lessThan uses Long.lessThan, which is strict. + assert.equal(TimestampUtils.lessThan(a, b), false); + }); +}); diff --git a/common/tests/unit-tests/utils/common-utils-edge.test.mjs b/common/tests/unit-tests/utils/common-utils-edge.test.mjs new file mode 100644 index 0000000000..cc3da3c66b --- /dev/null +++ b/common/tests/unit-tests/utils/common-utils-edge.test.mjs @@ -0,0 +1,503 @@ +import assert from 'node:assert/strict'; +import { + findAllEntities, + findAllBlocks, + findAllTools, + replaceAllEntities, + replaceAllVariables, + regenerateIds, + getVCField, + getVCIssuer, + findOptions, + replaceValueRecursive, + getArtifactType, + getArtifactExtention, + replaceArtifactProperties, + generateNumberFromString, + toArrayBuffer, + toBuffer, + ensurePrefix, + stripPrefix, + findBlocks, +} from '../../../dist/helpers/utils.js'; + +describe('@unit common/utils.findAllEntities edge', () => { + it('returns [] for empty names list even on a populated tree', () => { + assert.deepEqual(findAllEntities({ schema: 'x', children: [{ schema: 'y' }] }, []), []); + }); + + it('dedupes by string coercion: numerically-equal string and number collapse', () => { + const out = findAllEntities({ schema: 1, children: [{ schema: '1' }] }, ['schema']); + assert.equal(out.length, 1); + }); + + it('collapses distinct objects sharing the same toString into one entry', () => { + const out = findAllEntities( + { schema: {}, children: [{ schema: {} }] }, + ['schema'], + ); + assert.equal(out.length, 1); + }); + + it('keeps own null/undefined field values but keyed by their coercion', () => { + const out = findAllEntities({ schema: null, inputSchema: undefined }, ['schema', 'inputSchema']); + out.sort(); + assert.deepEqual(out, [null, undefined]); + }); + + it('does not descend when children is not iterable-of-objects (throws on non-array children)', () => { + assert.throws(() => findAllEntities({ children: 5 }, ['schema'])); + }); + + it('reads inherited prototype keys only via hasOwnProperty (ignores prototype field)', () => { + const proto = { schema: 'proto' }; + const obj = Object.create(proto); + assert.deepEqual(findAllEntities(obj, ['schema']), []); + }); + + it('handles a deeply nested chain without dedup loss', () => { + let node = { schema: 'leaf' }; + for (let i = 0; i < 500; i++) { + node = { schema: `s${i}`, children: [node] }; + } + const out = findAllEntities(node, ['schema']); + assert.equal(out.length, 501); + }); + + it('collects unicode values intact', () => { + const out = findAllEntities({ schema: '\u{1F600}é', children: [{ schema: '中文' }] }, ['schema']); + out.sort(); + assert.deepEqual(out, ['中文', '\u{1F600}é']); + }); +}); + +describe('@unit common/utils.findAllBlocks edge', () => { + it('throws on a null root (no guard)', () => { + assert.throws(() => findAllBlocks(null, 'tool')); + }); + + it('returns the root itself when the root matches', () => { + const root = { blockType: 'tool' }; + const out = findAllBlocks(root, 'tool'); + assert.equal(out.length, 1); + assert.equal(out[0], root); + }); + + it('matches by strict equality (case sensitive)', () => { + assert.deepEqual(findAllBlocks({ blockType: 'Tool' }, 'tool'), []); + }); + + it('finds duplicate matches preserving each instance', () => { + const tree = { blockType: 'x', children: [{ blockType: 'x' }, { blockType: 'x' }] }; + assert.equal(findAllBlocks(tree, 'x').length, 3); + }); + + it('iterates a string children value char-by-char without matching', () => { + assert.deepEqual(findAllBlocks({ blockType: 'a', children: 'oops' }, 'b'), []); + }); +}); + +describe('@unit common/utils.findAllTools edge', () => { + it('returns [] when no tool blocks exist', () => { + assert.deepEqual(findAllTools({ blockType: 'root', children: [{ blockType: 'x' }] }), []); + }); + + it('maps a tool block missing a hash to undefined', () => { + assert.deepEqual(findAllTools({ blockType: 'tool' }), [undefined]); + }); + + it('preserves duplicate hashes (no dedup)', () => { + const tree = { + blockType: 'root', + children: [ + { blockType: 'tool', hash: 'h' }, + { blockType: 'tool', hash: 'h' }, + ], + }; + assert.deepEqual(findAllTools(tree), ['h', 'h']); + }); +}); + +describe('@unit common/utils.replaceAllEntities edge', () => { + it('descends into nested non-children object properties', () => { + const tree = { meta: { inner: { schema: 'old' } } }; + replaceAllEntities(tree, ['schema'], 'old', 'new'); + assert.equal(tree.meta.inner.schema, 'new'); + }); + + it('skips null array items without throwing', () => { + const tree = { list: [null, { schema: 'old' }] }; + replaceAllEntities(tree, ['schema'], 'old', 'new'); + assert.equal(tree.list[1].schema, 'new'); + }); + + it('only replaces exact (===) matches, not substrings', () => { + const tree = { schema: 'old-value' }; + replaceAllEntities(tree, ['schema'], 'old', 'new'); + assert.equal(tree.schema, 'old-value'); + }); + + it('replaces every matching occurrence across siblings', () => { + const tree = { a: { schema: 'old' }, b: { schema: 'old' } }; + replaceAllEntities(tree, ['schema'], 'old', 'new'); + assert.equal(tree.a.schema, 'new'); + assert.equal(tree.b.schema, 'new'); + }); + + it('throws on a null root (no guard)', () => { + assert.throws(() => replaceAllEntities(null, ['schema'], 'a', 'b')); + }); + + it('is a no-op when names list is empty', () => { + const tree = { schema: 'old' }; + replaceAllEntities(tree, [], 'old', 'new'); + assert.equal(tree.schema, 'old'); + }); + + it('tolerates primitive entries in a children array and still replaces objects', () => { + const tree = { children: [1, 'str', { schema: 'old' }] }; + assert.doesNotThrow(() => replaceAllEntities(tree, ['schema'], 'old', 'new')); + assert.equal(tree.children[2].schema, 'new'); + }); +}); + +describe('@unit common/utils.replaceAllVariables edge', () => { + it('ignores non-module/non-tool blocks even if they declare variables', () => { + const tree = { blockType: 'custom', x: 'old', variables: [{ type: 'T', name: 'x' }] }; + replaceAllVariables(tree, 'T', 'old', 'new'); + assert.equal(tree.x, 'old'); + }); + + it('does nothing when variable type does not match', () => { + const tree = { blockType: 'module', x: 'old', variables: [{ type: 'Other', name: 'x' }] }; + replaceAllVariables(tree, 'T', 'old', 'new'); + assert.equal(tree.x, 'old'); + }); + + it('does nothing when current value differs from oldValue', () => { + const tree = { blockType: 'module', x: 'different', variables: [{ type: 'T', name: 'x' }] }; + replaceAllVariables(tree, 'T', 'old', 'new'); + assert.equal(tree.x, 'different'); + }); + + it('tolerates a module block whose variables is not an array', () => { + const tree = { blockType: 'module', variables: 'nope', children: [] }; + assert.doesNotThrow(() => replaceAllVariables(tree, 'T', 'old', 'new')); + }); + + it('throws on a null root (no guard)', () => { + assert.throws(() => replaceAllVariables(null, 'T', 'old', 'new')); + }); +}); + +describe('@unit common/utils.regenerateIds edge', () => { + it('assigns an id even to a block without children', () => { + const block = {}; + regenerateIds(block); + assert.match(block.id, /^[0-9a-f-]{36}$/); + }); + + it('produces distinct ids across siblings', () => { + const tree = { children: [{}, {}, {}] }; + regenerateIds(tree); + const ids = new Set([tree.id, ...tree.children.map((c) => c.id)]); + assert.equal(ids.size, 4); + }); + + it('ignores a non-array children value (no recursion)', () => { + const block = { children: 'not-an-array' }; + regenerateIds(block); + assert.ok(block.id); + assert.equal(block.children, 'not-an-array'); + }); + + it('throws on null block', () => { + assert.throws(() => regenerateIds(null)); + }); +}); + +describe('@unit common/utils.getVCField edge', () => { + it('returns undefined when the named field is absent on subject[0]', () => { + assert.equal(getVCField({ credentialSubject: [{ a: 1 }] }, 'missing'), undefined); + }); + + it('returns null when credentialSubject[0] is falsy', () => { + assert.equal(getVCField({ credentialSubject: [null] }, 'a'), null); + }); + + it('reads only the first subject, ignoring later ones', () => { + const vc = { credentialSubject: [{ a: 1 }, { a: 2 }] }; + assert.equal(getVCField(vc, 'a'), 1); + }); + + it('returns null for undefined document', () => { + assert.equal(getVCField(undefined, 'a'), null); + }); +}); + +describe('@unit common/utils.getVCIssuer edge', () => { + it('returns null when issuer.id is an empty string (falsy || null)', () => { + assert.equal(getVCIssuer({ document: { issuer: { id: '' } } }), null); + }); + + it('returns the empty string verbatim when issuer is the empty string', () => { + assert.equal(getVCIssuer({ document: { issuer: '' } }), ''); + }); + + it('returns the id for a populated issuer object', () => { + assert.equal(getVCIssuer({ document: { issuer: { id: 'did:x' } } }), 'did:x'); + }); + + it('returns null for undefined document property', () => { + assert.equal(getVCIssuer({ document: undefined }), null); + }); +}); + +describe('@unit common/utils.findOptions edge', () => { + it('returns null when document is provided but field is empty string', () => { + assert.equal(findOptions({ a: 1 }, ''), null); + }); + + it("treats 'L' on a non-array as a normal property lookup", () => { + assert.equal(findOptions({ L: 'literal' }, 'L'), 'literal'); + }); + + it('returns the last element for an L tail on a single-element array', () => { + assert.equal(findOptions({ items: [42] }, 'items.L'), 42); + }); + + it('throws when traversing through a primitive mid-path', () => { + assert.throws(() => findOptions({ a: 5 }, 'a.b.c')); + }); + + it('returns undefined for a missing top-level key', () => { + assert.equal(findOptions({ a: 1 }, 'nope'), undefined); + }); + + it('returns null when the document is 0 (falsy short-circuit)', () => { + assert.equal(findOptions(0, 'a'), null); + }); +}); + +describe('@unit common/utils.replaceValueRecursive edge', () => { + it('replaces values inside arrays at the top level', () => { + const out = replaceValueRecursive(['old', 'keep'], new Map([['old', 'new']])); + assert.deepEqual(out, ['new', 'keep']); + }); + + it('also rewrites matching object keys, not only values', () => { + const out = replaceValueRecursive({ old: 1 }, new Map([['old', 'new']])); + assert.deepEqual(out, { new: 1 }); + }); + + it('treats the map key as an unescaped regex, corrupting JSON when the key is a metachar', () => { + assert.throws( + () => replaceValueRecursive({ a: 'x.y' }, new Map([['.', '-']])), + SyntaxError, + ); + }); + + it('throws Unknown type for a number input', () => { + assert.throws(() => replaceValueRecursive(42, new Map()), /Unknown type/); + }); + + it('throws Unknown type for undefined input', () => { + assert.throws(() => replaceValueRecursive(undefined, new Map()), /Unknown type/); + }); + + it('returns null for a literal null document (object branch, no replacements)', () => { + assert.equal(replaceValueRecursive(null, new Map()), null); + }); + + it('applies replacements globally (all occurrences)', () => { + const out = replaceValueRecursive({ a: 'oo', b: 'o' }, new Map([['o', 'X']])); + assert.equal(out.a, 'XX'); + assert.equal(out.b, 'X'); + }); +}); + +describe('@unit common/utils.getArtifactType edge', () => { + it('is case sensitive: JS uppercase yields null', () => { + assert.equal(getArtifactType('JS'), null); + }); + + it('returns null for null/undefined extension', () => { + assert.equal(getArtifactType(null), null); + assert.equal(getArtifactType(undefined), null); + }); + + it('maps json to JSON exactly', () => { + assert.equal(getArtifactType('json'), 'JSON'); + }); +}); + +describe('@unit common/utils.getArtifactExtention edge', () => { + it('returns the whole name when there is no dot', () => { + assert.equal(getArtifactExtention('README'), 'README'); + }); + + it('throws on a name with a trailing dot (regex finds no segment after the dot)', () => { + assert.throws(() => getArtifactExtention('file.')); + }); + + it('returns the last non-empty segment for a leading-dot (hidden) file', () => { + assert.equal(getArtifactExtention('.gitignore'), 'gitignore'); + }); + + it('throws on an empty string (regex matches nothing, .toString on null)', () => { + assert.throws(() => getArtifactExtention('')); + }); +}); + +describe('@unit common/utils.replaceArtifactProperties edge', () => { + it('overwrites with undefined when the property value is not in the map', () => { + const cfg = { artifacts: [{ uuid: 'unmapped' }] }; + replaceArtifactProperties(cfg, 'uuid', new Map([['other', 'x']])); + assert.equal(cfg.artifacts[0].uuid, undefined); + }); + + it('is a no-op for a null mapping', () => { + const cfg = { artifacts: [{ uuid: 'a' }] }; + replaceArtifactProperties(cfg, 'uuid', null); + assert.equal(cfg.artifacts[0].uuid, 'a'); + }); + + it('is a no-op for an empty (size 0) mapping', () => { + const cfg = { artifacts: [{ uuid: 'a' }] }; + replaceArtifactProperties(cfg, 'uuid', new Map()); + assert.equal(cfg.artifacts[0].uuid, 'a'); + }); + + it('does nothing when the object has no artifacts and no children', () => { + const cfg = { foo: 1 }; + assert.doesNotThrow(() => replaceArtifactProperties(cfg, 'uuid', new Map([['a', 'b']]))); + assert.deepEqual(cfg, { foo: 1 }); + }); + + it('recurses through nested children arrays', () => { + const cfg = { children: [{ children: [{ artifacts: [{ uuid: 'old' }] }] }] }; + replaceArtifactProperties(cfg, 'uuid', new Map([['old', 'new']])); + assert.equal(cfg.children[0].children[0].artifacts[0].uuid, 'new'); + }); +}); + +describe('@unit common/utils.generateNumberFromString edge', () => { + it('is order sensitive: anagrams hash differently', () => { + assert.notEqual(generateNumberFromString('ab'), generateNumberFromString('ba')); + }); + + it('stays within MAX_SAFE_INTEGER for a long input', () => { + const v = generateNumberFromString('x'.repeat(10000)); + assert.ok(v >= 0 && v <= Number.MAX_SAFE_INTEGER); + }); + + it('is deterministic for unicode input', () => { + assert.equal( + generateNumberFromString('\u{1F4A9}abc'), + generateNumberFromString('\u{1F4A9}abc'), + ); + }); + + it('returns a non-negative integer for arbitrary text', () => { + const v = generateNumberFromString('Hello, World!'); + assert.ok(Number.isInteger(v)); + assert.ok(v >= 0); + }); + + it('throws when given null (no length property)', () => { + assert.throws(() => generateNumberFromString(null)); + }); +}); + +describe('@unit common/utils.toArrayBuffer / toBuffer edge', () => { + it('round-trips an empty buffer (length 0)', () => { + const ab = toArrayBuffer(Buffer.alloc(0)); + assert.ok(ab instanceof ArrayBuffer); + assert.equal(ab.byteLength, 0); + assert.equal(toBuffer(ab).length, 0); + }); + + it('respects byteOffset of a sliced buffer view', () => { + const base = Buffer.from([9, 8, 7, 6, 5]); + const view = base.subarray(2); + const ab = toArrayBuffer(view); + assert.deepEqual(Array.from(new Uint8Array(ab)), [7, 6, 5]); + }); + + it('toBuffer copies the underlying bytes of an ArrayBuffer', () => { + const ab = new Uint8Array([10, 20, 30]).buffer; + assert.deepEqual(Array.from(toBuffer(ab)), [10, 20, 30]); + }); + + it('toArrayBuffer returns undefined for missing arg, null for explicit null', () => { + assert.equal(toArrayBuffer(), undefined); + assert.equal(toArrayBuffer(null), null); + }); +}); + +describe('@unit common/utils.ensurePrefix / stripPrefix edge', () => { + it('ensurePrefix with empty-string prefix in list always matches (no prepend)', () => { + assert.equal(ensurePrefix('abc', ['', 'X-'], 'X-'), 'abc'); + }); + + it('ensurePrefix on empty text prepends the default', () => { + assert.equal(ensurePrefix('', ['Bearer '], 'Bearer '), 'Bearer '); + }); + + it('ensurePrefix matches the first applicable prefix in order', () => { + assert.equal(ensurePrefix('ab', ['a', 'ab'], 'z-'), 'ab'); + }); + + it('stripPrefix removes only the first matching prefix once', () => { + assert.equal(stripPrefix('aaab', ['a']), 'aab'); + }); + + it('stripPrefix with an empty-string prefix strips nothing', () => { + assert.equal(stripPrefix('abc', ['']), 'abc'); + }); + + it('stripPrefix returns the full text when prefix longer than text', () => { + assert.equal(stripPrefix('ab', ['abcdef']), 'ab'); + }); + + it('ensurePrefix is idempotent once a prefix is present', () => { + const once = ensurePrefix('x', 'p:', 'p:'); + assert.equal(ensurePrefix(once, 'p:', 'p:'), 'p:x'); + }); +}); + +describe('@unit common/utils.findBlocks edge', () => { + it('returns [] for an undefined tree', () => { + assert.deepEqual(findBlocks(undefined, () => true), []); + }); + + it('includes the root when it matches', () => { + const root = { blockType: 'x' }; + assert.deepEqual(findBlocks(root, (b) => b.blockType === 'x'), [root]); + }); + + it('skips a non-array children value without throwing', () => { + const tree = { blockType: 'x', children: 'nope' }; + assert.deepEqual(findBlocks(tree, () => true), [tree]); + }); + + it('traverses deeply nested children and matches all', () => { + let node = { blockType: 'leaf' }; + for (let i = 0; i < 300; i++) { + node = { blockType: 'leaf', children: [node] }; + } + assert.equal(findBlocks(node, (b) => b.blockType === 'leaf').length, 301); + }); + + it('preserves discovery order (pre-order DFS)', () => { + const tree = { + id: 'root', + children: [ + { id: 'a', children: [{ id: 'b' }] }, + { id: 'c' }, + ], + }; + const ids = findBlocks(tree, () => true).map((n) => n.id); + assert.deepEqual(ids, ['root', 'a', 'b', 'c']); + }); +}); diff --git a/common/tests/unit-tests/validate-configuration/validate-configuration.test.mjs b/common/tests/unit-tests/validate-configuration/validate-configuration.test.mjs new file mode 100644 index 0000000000..77410da3ac --- /dev/null +++ b/common/tests/unit-tests/validate-configuration/validate-configuration.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const { ValidateConfiguration } = await esmock('../../../dist/helpers/validate-configuration.js', { + '../../../dist/decorators/singleton.js': { Singleton: (t) => t }, // identity passthrough +}); + +describe('@unit ValidateConfiguration', () => { + it('throws if validate() is called before any callback is set', async () => { + const c = new ValidateConfiguration(); + await assert.rejects(() => c.validate(), /Callbacks was not set/); + }); + + it('throws if validate() is called with only validator set', async () => { + const c = new ValidateConfiguration(); + c.setValidator(async () => true); + await assert.rejects(() => c.validate(), /Callbacks was not set/); + }); + + it('throws if validate() is called with only one action set', async () => { + const c = new ValidateConfiguration(); + c.setValidAction(async () => {}); + c.setValidator(async () => true); + await assert.rejects(() => c.validate(), /Callbacks was not set/); + }); + + it('fires validAction when validator returns true', async () => { + const c = new ValidateConfiguration(); + let validRan = false, invalidRan = false; + c.setValidAction(async () => { validRan = true; }); + c.setInvalidAction(async () => { invalidRan = true; }); + c.setValidator(async () => true); + await c.validate(); + assert.equal(validRan, true); + assert.equal(invalidRan, false); + }); + + it('fires invalidAction when validator returns false', async () => { + const c = new ValidateConfiguration(); + let validRan = false, invalidRan = false; + c.setValidAction(async () => { validRan = true; }); + c.setInvalidAction(async () => { invalidRan = true; }); + c.setValidator(async () => false); + await c.validate(); + assert.equal(validRan, false); + assert.equal(invalidRan, true); + }); + + it('set-once: setValidAction throws when called twice', () => { + const c = new ValidateConfiguration(); + c.setValidAction(async () => {}); + assert.throws(() => c.setValidAction(async () => {}), /OnValid action was set before/); + }); + + it('set-once: setInvalidAction throws when called twice', () => { + const c = new ValidateConfiguration(); + c.setInvalidAction(async () => {}); + assert.throws(() => c.setInvalidAction(async () => {}), /OnInvalid action was set before/); + }); + + it('set-once: setValidator throws when called twice', () => { + const c = new ValidateConfiguration(); + c.setValidator(async () => true); + assert.throws(() => c.setValidator(async () => false), /Validator was set before/); + }); + + it('propagates rejection from validAction (not swallowed)', async () => { + const c = new ValidateConfiguration(); + c.setValidAction(async () => { throw new Error('valid-blew-up'); }); + c.setInvalidAction(async () => {}); + c.setValidator(async () => true); + await assert.rejects(() => c.validate(), /valid-blew-up/); + }); + + it('propagates rejection from invalidAction (not swallowed)', async () => { + const c = new ValidateConfiguration(); + c.setValidAction(async () => {}); + c.setInvalidAction(async () => { throw new Error('invalid-blew-up'); }); + c.setValidator(async () => false); + await assert.rejects(() => c.validate(), /invalid-blew-up/); + }); + + it('propagates rejection from validator (validator decides outcome — error is not silently treated as invalid)', async () => { + const c = new ValidateConfiguration(); + c.setValidAction(async () => {}); + c.setInvalidAction(async () => {}); + c.setValidator(async () => { throw new Error('validator-blew-up'); }); + await assert.rejects(() => c.validate(), /validator-blew-up/); + }); + + it('validate() can be called multiple times; each call re-runs the validator', async () => { + const c = new ValidateConfiguration(); + let validatorCalls = 0; + let validRan = 0; + c.setValidAction(async () => { validRan++; }); + c.setInvalidAction(async () => {}); + c.setValidator(async () => { validatorCalls++; return true; }); + await c.validate(); + await c.validate(); + await c.validate(); + assert.equal(validatorCalls, 3); + assert.equal(validRan, 3); + }); +}); diff --git a/common/tests/unit-tests/xlsx-dictionary/xlsx-dictionary.test.mjs b/common/tests/unit-tests/xlsx-dictionary/xlsx-dictionary.test.mjs new file mode 100644 index 0000000000..ce716bee01 --- /dev/null +++ b/common/tests/unit-tests/xlsx-dictionary/xlsx-dictionary.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { Dictionary, FieldTypes } from '../../../dist/xlsx/models/dictionary.js'; + +describe('Dictionary enum', () => { + it('exposes the documented header labels', () => { + assert.equal(Dictionary.REQUIRED_FIELD, 'Required Field'); + assert.equal(Dictionary.FIELD_TYPE, 'Field Type'); + assert.equal(Dictionary.QUESTION, 'Question'); + assert.equal(Dictionary.ANSWER, 'Answer'); + assert.equal(Dictionary.SCHEMA_NAME, 'Schema'); + assert.equal(Dictionary.SCHEMA_TOOL, 'Tool'); + assert.equal(Dictionary.AUTO_CALCULATE, 'Auto-Calculate'); + assert.equal(Dictionary.SUB_SCHEMA, 'Sub-Schema'); + }); +}); + +describe('FieldTypes.findByName', () => { + it('returns the canonical Number type', () => { + const t = FieldTypes.findByName('Number'); + assert.equal(t.type, 'number'); + assert.equal(t.format, undefined); + assert.equal(t.isRef, false); + }); + + it('returns the canonical Date type with format=date', () => { + const t = FieldTypes.findByName('Date'); + assert.equal(t.type, 'string'); + assert.equal(t.format, 'date'); + }); + + it('returns the GeoJSON type with isRef=true and customType=geo', () => { + const t = FieldTypes.findByName('GeoJSON'); + assert.equal(t.isRef, true); + assert.equal(t.customType, 'geo'); + }); + + it('returns the HederaAccount type with the documented pattern', () => { + const t = FieldTypes.findByName('HederaAccount'); + assert.equal(t.customType, 'hederaAccount'); + assert.equal(t.pattern, '^\\d+\\.\\d+\\.\\d+$'); + }); + + it('returns null for an unknown name', () => { + assert.equal(FieldTypes.findByName('Unknown-Field-Type'), null); + }); +}); + +describe('FieldTypes parsers', () => { + it('Number parser returns a finite number, "" for non-finite', () => { + const t = FieldTypes.findByName('Number'); + assert.equal(t.pars('42'), 42); + assert.equal(t.pars('3.14'), 3.14); + assert.equal(t.pars('not-a-number'), ''); + }); + + it('Integer parser keeps integers, "" for fractional/non-numeric', () => { + const t = FieldTypes.findByName('Integer'); + assert.equal(t.pars('5'), 5); + assert.equal(t.pars('5.5'), ''); + assert.equal(t.pars('abc'), ''); + }); + + it('String parser coerces via String()', () => { + const t = FieldTypes.findByName('String'); + assert.equal(t.pars(123), '123'); + assert.equal(t.pars(null), 'null'); + }); + + it('Boolean parser handles strings and truthy values', () => { + const t = FieldTypes.findByName('Boolean'); + assert.equal(t.pars('true'), true); + assert.equal(t.pars('TRUE'), true); + assert.equal(t.pars('false'), false); + assert.equal(t.pars('anything-else'), false); + assert.equal(t.pars(1), true); + assert.equal(t.pars(0), false); + }); + + it('Postfix and Prefix carry the unitSystem hint', () => { + const postfix = FieldTypes.findByName('Postfix'); + const prefix = FieldTypes.findByName('Prefix'); + assert.equal(postfix.unitSystem, 'postfix'); + assert.equal(prefix.unitSystem, 'prefix'); + }); +}); + +describe('FieldTypes.findByValue', () => { + it('matches Number by shape', () => { + const f = FieldTypes.findByValue({ type: 'number', isRef: false }); + assert.equal(f.name, 'Number'); + }); + + it('matches String by shape', () => { + const f = FieldTypes.findByValue({ type: 'string', isRef: false }); + assert.equal(f.name, 'String'); + }); + + it('matches GeoJSON by isRef + customType', () => { + const f = FieldTypes.findByValue({ + type: '#GeoJSON', + isRef: true, + customType: 'geo', + }); + assert.equal(f.name, 'GeoJSON'); + }); + + it('returns null when nothing matches', () => { + const f = FieldTypes.findByValue({ type: 'never-was-a-type', isRef: false }); + assert.equal(f, null); + }); + + it('matches a Pattern field when the pattern flag is truthy', () => { + const f = FieldTypes.findByValue({ + type: 'string', + isRef: false, + pattern: '^abc$', + }); + assert.equal(f.name, 'Pattern'); + }); +}); + +describe('FieldTypes.equal', () => { + it('treats undefined === null as equal (loose nullish helper)', () => { + const eq = FieldTypes.equal( + { type: 'number', isRef: false }, + { type: 'number', isRef: undefined }, + ); + assert.equal(eq, true); + }); + + it('returns false when types differ', () => { + const eq = FieldTypes.equal( + { type: 'number', isRef: false }, + { type: 'string', isRef: false }, + ); + assert.equal(eq, false); + }); +}); diff --git a/common/tests/unit-tests/xlsx-expression/xlsx-expression.test.mjs b/common/tests/unit-tests/xlsx-expression/xlsx-expression.test.mjs new file mode 100644 index 0000000000..3bfa22b929 --- /dev/null +++ b/common/tests/unit-tests/xlsx-expression/xlsx-expression.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { Expression } from '../../../dist/xlsx/models/expression.js'; + +describe('Expression construction', () => { + it('captures name + formulae and starts with empty symbol/function/range maps', () => { + const e = new Expression('SUM_A1_B2', 'A1+B2'); + assert.equal(e.name, 'SUM_A1_B2'); + assert.equal(e.formulae, 'A1+B2'); + assert.equal(e.symbols.size, 0); + assert.equal(e.functions.size, 0); + assert.equal(e.ranges.size, 0); + assert.equal(e.transformed, undefined); + }); +}); + +describe('Expression.parse — symbols', () => { + it('collects symbol-only formulae into the symbols Set', () => { + const e = new Expression('eq', 'A1'); + e.parse(); + assert.deepEqual(Array.from(e.symbols).sort(), ['A1']); + }); + + it('collects every operand from a binary operator expression', () => { + const e = new Expression('eq', 'A1+B2'); + e.parse(); + assert.deepEqual(Array.from(e.symbols).sort(), ['A1', 'B2']); + }); + + it('walks both sides of a chained operator expression', () => { + const e = new Expression('eq', 'A1+B2-C3'); + e.parse(); + assert.deepEqual(Array.from(e.symbols).sort(), ['A1', 'B2', 'C3']); + }); +}); + +describe('Expression.parse — functions', () => { + it('records the called function name and source template', () => { + const e = new Expression('eq', 'add(A1, B2)'); + e.parse(); + assert.ok(e.functions.has('add')); + const templates = e.functions.get('add'); + assert.equal(templates.length, 1); + assert.ok(templates[0].includes('A1') && templates[0].includes('B2')); + }); + + it('walks function arguments and adds them to symbols', () => { + const e = new Expression('eq', 'sum(A1, B2)'); + e.parse(); + assert.ok(e.symbols.has('A1')); + assert.ok(e.symbols.has('B2')); + }); + + it('aggregates multiple invocations of the same function name', () => { + const e = new Expression('eq', 'add(A1, B2) + add(C1, C2)'); + e.parse(); + assert.equal(e.functions.get('add').length, 2); + }); +}); + +describe('Expression.parse — ranges', () => { + it('expands a vertical range A1:A3 into discrete cells', () => { + const e = new Expression('eq', 'sum(A1:A3)'); + e.parse(); + assert.ok(e.ranges.has('A1_A3')); + assert.deepEqual(e.ranges.get('A1_A3'), ['A1', 'A2', 'A3']); + }); + + it('orders cells from min to max row regardless of input order', () => { + const e = new Expression('eq', 'sum(B5:B2)'); + e.parse(); + const key = Array.from(e.ranges.keys())[0]; + const cells = e.ranges.get(key); + // mathjs.RangeNode preserves the input order in the key, but the + // expanded list is always min->max. + assert.deepEqual(cells, ['B2', 'B3', 'B4', 'B5']); + }); + + it('throws "Invalid range" when columns differ', () => { + const e = new Expression('eq', 'sum(A1:B3)'); + assert.throws(() => e.parse(), /Invalid range/); + }); +}); + +describe('Expression.parse — transformed', () => { + it('rewrites range nodes to symbol nodes (start_end)', () => { + const e = new Expression('eq', 'sum(A1:A3)'); + e.parse(); + assert.ok(typeof e.transformed === 'string'); + assert.ok(e.transformed.includes('A1_A3')); + assert.ok(!e.transformed.includes(':')); + }); + + it('leaves a non-range formula textually equivalent', () => { + const e = new Expression('eq', 'A1 + B2'); + e.parse(); + // mathjs may normalise spacing — check the cells survive. + assert.ok(e.transformed.includes('A1')); + assert.ok(e.transformed.includes('B2')); + assert.ok(!e.transformed.includes(':')); + }); +}); diff --git a/common/tests/unit-tests/xlsx-expressions/xlsx-expressions.test.mjs b/common/tests/unit-tests/xlsx-expressions/xlsx-expressions.test.mjs new file mode 100644 index 0000000000..32adc1ec95 --- /dev/null +++ b/common/tests/unit-tests/xlsx-expressions/xlsx-expressions.test.mjs @@ -0,0 +1,126 @@ +import { assert } from 'chai'; +import { XlsxVariable, XlsxExpressions } from '../../../dist/xlsx/models/xlsx-expressions.js'; + +describe('XlsxVariable construction', () => { + it('stores name/path/description and clamps negative lvl to 0', () => { + const v = new XlsxVariable('n', 'p', 'd', -5); + assert.equal(v.fieldName, 'n'); + assert.equal(v.fieldPath, 'p'); + assert.equal(v.fieldDescription, 'd'); + assert.equal(v.lvl, 0); + }); + + it('clamps undefined lvl to 0', () => { + const v = new XlsxVariable('n', 'p', 'd', undefined); + assert.equal(v.lvl, 0); + }); + + it('keeps positive lvl', () => { + const v = new XlsxVariable('n', 'p', 'd', 3); + assert.equal(v.lvl, 3); + }); +}); + +describe('XlsxVariable.fullPath', () => { + it('returns null when no field set', () => { + const v = new XlsxVariable('n', 'p', 'd', 0); + assert.isNull(v.fullPath); + }); + + it('returns field name when no parent', () => { + const v = new XlsxVariable('n', 'p', 'd', 0); + v.setField({ name: 'fieldA' }); + assert.equal(v.fullPath, 'fieldA'); + }); + + it('joins parent path with child field name', () => { + const parent = new XlsxVariable('p', 'p', 'd', 0); + parent.setField({ name: 'root' }); + const child = new XlsxVariable('c', 'c', 'd', 1); + child.setField({ name: 'leaf' }); + parent.add(child); + assert.equal(child.fullPath, 'root.leaf'); + }); +}); + +describe('XlsxVariable.update', () => { + it('throws when no schema is set', () => { + const v = new XlsxVariable('n', 'p', 'd', 0); + assert.throws(() => v.update([]), /Schema not found/); + }); + + it('matches field by title at lvl 0', () => { + const v = new XlsxVariable('n', 'MyTitle', 'desc', 0); + v.setSchema({ fields: [{ name: 'f1', title: 'MyTitle', type: 't' }] }); + v.update([]); + assert.equal(v.fullPath, 'f1'); + }); + + it('matches field by description at deeper lvl', () => { + const v = new XlsxVariable('n', 'p', 'MyDesc', 1); + v.setSchema({ fields: [{ name: 'f2', description: 'MyDesc', type: 't' }] }); + v.update([]); + assert.equal(v.fullPath, 'f2'); + }); + + it('throws when field not found', () => { + const v = new XlsxVariable('n', 'NoSuch', 'd', 0); + v.setSchema({ fields: [] }); + assert.throws(() => v.update([]), /Fields not found/); + }); + + it('throws when sub-schema type not found for children', () => { + const parent = new XlsxVariable('p', 'Root', 'd', 0); + parent.setSchema({ fields: [{ name: 'f', title: 'Root', type: 'subIri' }] }); + const child = new XlsxVariable('c', 'p', 'cd', 1); + parent.add(child); + assert.throws(() => parent.update([]), /Type not found/); + }); + + it('recurses into children when sub-schema is found', () => { + const parent = new XlsxVariable('p', 'Root', 'd', 0); + parent.setSchema({ fields: [{ name: 'f', title: 'Root', type: 'subIri' }] }); + const child = new XlsxVariable('c', 'p', 'ChildDesc', 1); + parent.add(child); + const subSchema = { iri: 'subIri', fields: [{ name: 'g', description: 'ChildDesc', type: 't' }] }; + parent.update([subSchema]); + assert.equal(child.fullPath, 'f.g'); + }); +}); + +describe('XlsxExpressions', () => { + it('getVariables maps fieldPath to fullPath (null before update)', () => { + const ex = new XlsxExpressions(); + ex.addVariable({ name: 'n', path: 'pathA' }, 'desc', 0); + const vars = ex.getVariables(); + assert.isTrue(vars.has('pathA')); + assert.isNull(vars.get('pathA')); + }); + + it('updateSchemas resolves root-level variables', () => { + const ex = new XlsxExpressions(); + ex.setSchema({ fields: [{ name: 'f1', title: 'TitleA', type: 't' }] }); + ex.addVariable({ name: 'n', path: 'TitleA' }, 'descA', 0); + ex.updateSchemas([]); + assert.equal(ex.getVariables().get('TitleA'), 'f1'); + }); + + it('updateSchemas throws on invalid group level jump', () => { + const ex = new XlsxExpressions(); + ex.setSchema({ fields: [] }); + ex.addVariable({ name: 'n', path: 'p' }, 'd', 2); + assert.throws(() => ex.updateSchemas([]), /Invalid group level/); + }); + + it('updateSchemas nests a child under its parent', () => { + const ex = new XlsxExpressions(); + ex.setSchema({ fields: [{ name: 'root', title: 'Root', type: 'subIri' }] }); + ex.addVariable({ name: 'p', path: 'Root' }, 'descRoot', 0); + ex.addVariable({ name: 'c', path: 'p' }, 'ChildDesc', 1); + const subSchema = { iri: 'subIri', fields: [{ name: 'leaf', description: 'ChildDesc', type: 't' }] }; + ex.updateSchemas([subSchema]); + const vars = ex.getVariables(); + assert.equal(vars.get('Root'), 'root'); + assert.equal(vars.get('p'), 'root.leaf'); + }); +}); diff --git a/common/tests/unit-tests/xlsx-generate-blocks/generate-blocks.test.mjs b/common/tests/unit-tests/xlsx-generate-blocks/generate-blocks.test.mjs new file mode 100644 index 0000000000..edf44009a2 --- /dev/null +++ b/common/tests/unit-tests/xlsx-generate-blocks/generate-blocks.test.mjs @@ -0,0 +1,150 @@ +import { assert } from 'chai'; +import { BlockType, SchemaEntity } from '@guardian/interfaces'; +import { GenerateBlocks } from '../../../dist/xlsx/generate-blocks.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const makeResult = ({ config, tools = [], xlsxSchemas = [] } = {}) => { + const errors = []; + return { + policy: { config: config || { blockType: BlockType.Container, children: [] } }, + tools, + xlsxSchemas, + addError(error) { + errors.push(error); + }, + errors + }; +}; + +describe('GenerateBlocks.generate', () => { + it('inserts a root container as the first child of the config', () => { + const result = makeResult(); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + assert.equal(root.blockType, BlockType.Container); + assert.match(root.tag, /^Root_Holder_/); + }); + + it('creates the children array when missing', () => { + const result = makeResult({ config: { blockType: BlockType.Container } }); + GenerateBlocks.generate(result); + assert.isArray(result.policy.config.children); + assert.lengthOf(result.policy.config.children, 1); + }); + + it('keeps existing children after the inserted container', () => { + const existing = { blockType: 'informationBlock', tag: 'keep' }; + const result = makeResult({ config: { blockType: BlockType.Container, children: [existing] } }); + GenerateBlocks.generate(result); + assert.lengthOf(result.policy.config.children, 2); + assert.equal(result.policy.config.children[1].tag, 'keep'); + }); + + it('gives generated blocks uuid ids and default arrays', () => { + const result = makeResult(); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + assert.match(root.id, UUID_RE); + assert.isTrue(root.defaultActive); + assert.deepEqual(root.permissions, []); + assert.deepEqual(root.artifacts, []); + }); + + it('does not throw when the policy is missing', () => { + const result = makeResult(); + delete result.policy; + assert.doesNotThrow(() => GenerateBlocks.generate(result)); + }); + + it('adds a block for each tool', () => { + const tools = [ + { messageId: 'm1', hash: 'h1', config: {} }, + { messageId: 'm2', hash: 'h2', config: {} } + ]; + const result = makeResult({ tools }); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + const toolBlocks = root.children.filter((b) => b.blockType === BlockType.Tool); + assert.lengthOf(toolBlocks, 2); + assert.deepEqual(toolBlocks.map((b) => b.messageId), ['m1', 'm2']); + }); + + it('copies tool hash, events and variables onto the block', () => { + const tools = [{ + messageId: 'm1', + hash: 'h1', + config: { inputEvents: ['in'], outputEvents: ['out'], variables: [{ name: 'v' }] } + }]; + const result = makeResult({ tools }); + GenerateBlocks.generate(result); + const block = result.policy.config.children[0].children[0]; + assert.equal(block.hash, 'h1'); + assert.deepEqual(block.inputEvents, ['in']); + assert.deepEqual(block.outputEvents, ['out']); + assert.deepEqual(block.variables, [{ name: 'v' }]); + assert.isFalse(block.defaultActive); + assert.match(block.tag, /^Tool_/); + }); + + it('skips tools already referenced in the config', () => { + const config = { + blockType: BlockType.Container, + children: [{ blockType: BlockType.Tool, messageId: 'm1' }] + }; + const result = makeResult({ config, tools: [{ messageId: 'm1', hash: 'h1', config: {} }] }); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + assert.lengthOf(root.children.filter((b) => b.blockType === BlockType.Tool), 0); + }); + + it('generates a request container per VC schema', () => { + const schema = { entity: SchemaEntity.VC, iri: '#vc1', name: 'My VC Schema' }; + const result = makeResult({ xlsxSchemas: [schema] }); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + const holder = root.children[0]; + assert.equal(holder.blockType, BlockType.Container); + assert.include(holder.tag, 'Schema_Holder_'); + }); + + it('adds a request block bound to the schema iri', () => { + const schema = { entity: SchemaEntity.VC, iri: '#vc1', name: 'My VC Schema' }; + const result = makeResult({ xlsxSchemas: [schema] }); + GenerateBlocks.generate(result); + const request = result.policy.config.children[0].children[0].children[0]; + assert.equal(request.blockType, BlockType.Request); + assert.equal(request.schema, '#vc1'); + assert.equal(request.idType, 'UUID'); + assert.deepEqual(request.presetFields, []); + }); + + it('skips schemas that are not VC entities', () => { + const schema = { entity: SchemaEntity.NONE, iri: '#x', name: 'X' }; + const result = makeResult({ xlsxSchemas: [schema] }); + GenerateBlocks.generate(result); + const root = result.policy.config.children[0]; + assert.lengthOf(root.children, 0); + }); + + it('adds no calculation block when fields have no formulae', () => { + const schema = { + entity: SchemaEntity.VC, + iri: '#vc1', + name: 'My VC Schema', + fields: [{ name: 'f1' }, { name: 'f2', fields: [{ name: 'f3' }] }] + }; + const result = makeResult({ xlsxSchemas: [schema] }); + GenerateBlocks.generate(result); + const holder = result.policy.config.children[0].children[0]; + assert.lengthOf(holder.children, 1); + assert.equal(holder.children[0].blockType, BlockType.Request); + assert.lengthOf(result.errors, 0); + }); + + it('reports no errors for an empty workbook result', () => { + const result = makeResult(); + GenerateBlocks.generate(result); + assert.lengthOf(result.errors, 0); + }); +}); diff --git a/common/tests/unit-tests/xlsx-result/xlsx-result.test.mjs b/common/tests/unit-tests/xlsx-result/xlsx-result.test.mjs new file mode 100644 index 0000000000..ada4548a8b --- /dev/null +++ b/common/tests/unit-tests/xlsx-result/xlsx-result.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { XlsxResult } from '../../../dist/xlsx/models/xlsx-result.js'; + +const fakeSchema = (overrides = {}) => ({ + schema: { + id: 'sid', + iri: '#sid', + name: 'My Schema', + description: 'desc', + version: '1.0.0', + status: 'DRAFT', + ...overrides, + }, +}); + +const fakeTool = (overrides = {}) => ({ + messageId: 'm-1', + name: 'tool-1', + ...overrides, +}); + +const fakeWorksheet = (name) => ({ name }); + +describe('XlsxResult construction', () => { + it('starts empty across every collection', () => { + const r = new XlsxResult(); + assert.equal(r.policy, undefined); + assert.deepEqual(r.schemas, []); + assert.deepEqual(r.tools, []); + assert.deepEqual(r.toolSchemas, []); + assert.deepEqual(r.xlsxSchemas, []); + assert.deepEqual(r.getToolIds(), []); + }); +}); + +describe('XlsxResult.addSchema / schemas getter', () => { + it('addSchema records a schema accessible via .schemas and .xlsxSchemas', () => { + const r = new XlsxResult(); + const s = fakeSchema(); + r.addSchema(fakeWorksheet('Sheet1'), s); + assert.equal(r.schemas.length, 1); + assert.equal(r.schemas[0].iri, '#sid'); + assert.equal(r.xlsxSchemas[0], s); + }); + + it('addSchema accumulates multiple entries in insertion order', () => { + const r = new XlsxResult(); + r.addSchema(fakeWorksheet('s1'), fakeSchema({ iri: '#a' })); + r.addSchema(fakeWorksheet('s2'), fakeSchema({ iri: '#b' })); + assert.deepEqual(r.schemas.map((s) => s.iri), ['#a', '#b']); + }); +}); + +describe('XlsxResult.addTool / getToolIds', () => { + it('addTool registers an entry retrievable via getToolIds', () => { + const r = new XlsxResult(); + r.addTool(fakeWorksheet('Tools'), fakeTool({ messageId: 'm-1', name: 'T1' })); + const ids = r.getToolIds(); + assert.equal(ids.length, 1); + assert.equal(ids[0].messageId, 'm-1'); + assert.equal(ids[0].worksheet, 'Tools'); + }); + + it('addTool deduplicates identical messageIds', () => { + const r = new XlsxResult(); + r.addTool(fakeWorksheet('S'), fakeTool({ messageId: 'm-1' })); + r.addTool(fakeWorksheet('S'), fakeTool({ messageId: 'm-1' })); + // toolsCache uses messageId as key — second add overwrites. + assert.equal(r.getToolIds().length, 1); + }); +}); + +describe('XlsxResult.addError / addErrors', () => { + it('addError attaches the error to the supplied target', () => { + const r = new XlsxResult(); + const target = {}; + const err = { type: 'error', text: 'boom' }; + r.addError(err, target); + assert.deepEqual(target.errors, [err]); + assert.equal(r.toJson().errors.length, 1); + }); + + it('addError appends to an existing target.errors array', () => { + const r = new XlsxResult(); + const target = { errors: [{ type: 'warning', text: 'pre-existing' }] }; + r.addError({ type: 'error', text: 'new' }, target); + assert.equal(target.errors.length, 2); + }); + + it('addError tolerates a null target (still records globally)', () => { + const r = new XlsxResult(); + r.addError({ type: 'error', text: 'global' }, null); + assert.equal(r.toJson().errors.length, 1); + }); + + it('addErrors stamps every entry with type=error', () => { + const r = new XlsxResult(); + r.addErrors([{ text: 'one' }, { text: 'two' }]); + for (const e of r.toJson().errors) { + assert.equal(e.type, 'error'); + } + }); +}); + +describe('XlsxResult.addLink', () => { + it('returns a sequential link_ id', () => { + const r = new XlsxResult(); + const a = r.addLink('first'); + const b = r.addLink('second'); + assert.equal(a, 'link_0'); + assert.equal(b, 'link_1'); + }); + + it('captures the worksheet from the supplied hyperlink (when present)', () => { + const r = new XlsxResult(); + // When no hyperlink is supplied, we cannot assert worksheet. + const id = r.addLink('with-hyper', { worksheet: 'WS' }); + assert.equal(id, 'link_0'); + }); +}); + +describe('XlsxResult.clear', () => { + it('empties schemas, tools, toolsCache, linkCache', () => { + const r = new XlsxResult(); + r.addSchema(fakeWorksheet('s'), fakeSchema()); + r.addTool(fakeWorksheet('s'), fakeTool()); + r.addLink('x'); + r.clear(); + assert.deepEqual(r.schemas, []); + assert.deepEqual(r.tools, []); + assert.deepEqual(r.getToolIds(), []); + }); +}); + +describe('XlsxResult.toJson', () => { + it('serializes a slim view of schemas + tools + errors', () => { + const r = new XlsxResult(); + r.addSchema(fakeWorksheet('Sh1'), fakeSchema()); + r.addTool(fakeWorksheet('Tools'), fakeTool({ messageId: 'm-1' })); + r.addError({ type: 'error', text: 'oops' }, null); + const json = r.toJson(); + assert.equal(json.schemas.length, 1); + assert.equal(json.schemas[0].iri, '#sid'); + assert.equal(json.tools.length, 1); + assert.equal(json.tools[0].messageId, 'm-1'); + assert.equal(json.errors.length, 1); + }); +}); + +describe('XlsxResult.updatePolicy', () => { + it('updates the policy via the setter', () => { + const r = new XlsxResult(); + r.updatePolicy({ id: 'p-1' }); + assert.deepEqual(r.policy, { id: 'p-1' }); + }); +}); diff --git a/common/tests/unit-tests/xlsx-schema-condition/xlsx-schema-condition.test.mjs b/common/tests/unit-tests/xlsx-schema-condition/xlsx-schema-condition.test.mjs new file mode 100644 index 0000000000..d9f41c5f10 --- /dev/null +++ b/common/tests/unit-tests/xlsx-schema-condition/xlsx-schema-condition.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { XlsxSchemaConditions } from '../../../dist/xlsx/models/schema-condition.js'; + +const field = (name) => ({ name }); + +describe('XlsxSchemaConditions — single field/value form', () => { + it('exposes the canonical SchemaCondition shape', () => { + const c = new XlsxSchemaConditions(field('amount'), 'X'); + const out = c.toJson(); + assert.equal(out.ifCondition.field.name, 'amount'); + assert.equal(out.ifCondition.fieldValue, 'X'); + assert.deepEqual(out.thenFields, []); + assert.deepEqual(out.elseFields, []); + }); + + it('addField(field, invert=false) appends to thenFields', () => { + const c = new XlsxSchemaConditions(field('a'), 'X'); + c.addField({ name: 'b' }, false); + assert.equal(c.condition.thenFields.length, 1); + assert.equal(c.condition.elseFields.length, 0); + }); + + it('addField(field, invert=true) appends to elseFields', () => { + const c = new XlsxSchemaConditions(field('a'), 'X'); + c.addField({ name: 'b' }, true); + assert.equal(c.condition.elseFields.length, 1); + }); + + it('equal() returns true for the same field-name + value', () => { + const c = new XlsxSchemaConditions(field('amount'), { x: 1 }); + assert.equal(c.equal(field('amount'), { x: 1 }), true); + }); + + it('equal() returns false when value differs (deep JSON compare)', () => { + const c = new XlsxSchemaConditions(field('amount'), { x: 1 }); + assert.equal(c.equal(field('amount'), { x: 2 }), false); + }); + + it('equal() returns false when field name differs', () => { + const c = new XlsxSchemaConditions(field('amount'), 'X'); + assert.equal(c.equal(field('owner'), 'X'), false); + }); + + it('equal() returns false when given a group instead of a field', () => { + const c = new XlsxSchemaConditions(field('amount'), 'X'); + assert.equal( + c.equal({ op: 'OR', items: [{ field: field('amount'), value: 'X' }] }), + false, + ); + }); +}); + +describe('XlsxSchemaConditions — predicate group (OR / AND)', () => { + it('captures the group op + items into ifCondition', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [ + { field: field('a'), value: 'X' }, + { field: field('b'), value: 'Y' }, + ], + }); + const out = c.toJson(); + assert.ok(out.ifCondition.OR); + assert.equal(out.ifCondition.OR.length, 2); + assert.equal(out.ifCondition.OR[0].field.name, 'a'); + assert.equal(out.ifCondition.OR[0].fieldValue, 'X'); + }); + + it('captures the AND op into ifCondition.AND', () => { + const c = new XlsxSchemaConditions({ + op: 'AND', + items: [{ field: field('a'), value: 'X' }], + }); + assert.ok(c.toJson().ifCondition.AND); + }); + + it('equal() matches groups regardless of input ordering', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [ + { field: field('a'), value: 'X' }, + { field: field('b'), value: 'Y' }, + ], + }); + const equal = c.equal({ + op: 'OR', + items: [ + { field: field('b'), value: 'Y' }, + { field: field('a'), value: 'X' }, + ], + }); + assert.equal(equal, true); + }); + + it('equal() returns false when ops differ', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [{ field: field('a'), value: 'X' }], + }); + assert.equal( + c.equal({ op: 'AND', items: [{ field: field('a'), value: 'X' }] }), + false, + ); + }); + + it('equal() returns false when item counts differ', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [{ field: field('a'), value: 'X' }], + }); + assert.equal( + c.equal({ + op: 'OR', + items: [ + { field: field('a'), value: 'X' }, + { field: field('b'), value: 'Y' }, + ], + }), + false, + ); + }); + + it('equal() returns false when given a single field/value instead of a group', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [{ field: field('a'), value: 'X' }], + }); + assert.equal(c.equal(field('a'), 'X'), false); + }); + + it('addField populates thenFields/elseFields on a group as well', () => { + const c = new XlsxSchemaConditions({ + op: 'OR', + items: [{ field: field('a'), value: 'X' }], + }); + c.addField({ name: 'then-1' }, false); + c.addField({ name: 'else-1' }, true); + assert.equal(c.toJson().thenFields.length, 1); + assert.equal(c.toJson().elseFields.length, 1); + }); +}); diff --git a/common/tests/unit-tests/xlsx-schema/xlsx-schema.test.mjs b/common/tests/unit-tests/xlsx-schema/xlsx-schema.test.mjs new file mode 100644 index 0000000000..0337717d6a --- /dev/null +++ b/common/tests/unit-tests/xlsx-schema/xlsx-schema.test.mjs @@ -0,0 +1,98 @@ +import { assert } from 'chai'; +import { XlsxSchema, XlsxTool } from '../../../dist/xlsx/models/xlsx-schema.js'; +import { XlsxExpressions } from '../../../dist/xlsx/models/xlsx-expressions.js'; +import { Workbook } from '../../../dist/xlsx/models/workbook.js'; +import { SchemaCategory, SchemaEntity } from '@guardian/interfaces'; + +function makeWorksheet(name) { + const wb = new Workbook(); + return wb.createWorksheet(name); +} + +describe('XlsxSchema construction', () => { + it('initialises a POLICY schema named after the worksheet', () => { + const ws = makeWorksheet('MySchema'); + const x = new XlsxSchema(ws); + assert.equal(x.sheetName, 'MySchema'); + assert.equal(x.name, 'MySchema'); + assert.equal(x.category, undefined); + assert.equal(x.schema.category, SchemaCategory.POLICY); + }); + + it('exposes the underlying worksheet', () => { + const ws = makeWorksheet('S'); + const x = new XlsxSchema(ws); + assert.strictEqual(x.worksheet, ws); + }); +}); + +describe('XlsxSchema getters/setters', () => { + let x; + beforeEach(() => { + x = new XlsxSchema(makeWorksheet('S')); + }); + + it('name setter updates the schema name', () => { + x.name = 'Renamed'; + assert.equal(x.name, 'Renamed'); + assert.equal(x.schema.name, 'Renamed'); + }); + + it('description setter/getter', () => { + x.description = 'desc'; + assert.equal(x.description, 'desc'); + }); + + it('entity setter/getter', () => { + x.entity = SchemaEntity.VC; + assert.equal(x.entity, SchemaEntity.VC); + }); + + it('errors setter/getter', () => { + x.errors = [{ code: 'E1' }]; + assert.deepEqual(x.errors, [{ code: 'E1' }]); + }); + + it('fields reflects the underlying schema fields', () => { + assert.equal(x.fields, x.schema.fields); + }); + + it('iri reflects the underlying schema iri', () => { + assert.equal(x.iri, x.schema.iri); + }); +}); + +describe('XlsxSchema.update', () => { + it('sets fields/conditions, assigns expressions and seeds its schema', () => { + const x = new XlsxSchema(makeWorksheet('S')); + const expressions = new XlsxExpressions(); + x.update([], [], expressions); + assert.strictEqual(x.expressions, expressions); + assert.isString(x.iri); + }); + + it('getVariables delegates to expressions', () => { + const x = new XlsxSchema(makeWorksheet('S')); + const expressions = new XlsxExpressions(); + x.update([], [], expressions); + const vars = x.getVariables(); + assert.instanceOf(vars, Map); + }); +}); + +describe('XlsxTool', () => { + it('uses the supplied name and a TOOL category', () => { + const ws = makeWorksheet('ToolSheet'); + const t = new XlsxTool(ws, 'MyTool', 'msg-1'); + assert.equal(t.name, 'MyTool'); + assert.equal(t.messageId, 'msg-1'); + assert.equal(t.sheetName, 'ToolSheet'); + assert.equal(t.category, SchemaCategory.TOOL); + }); + + it('falls back to the worksheet name when name is empty', () => { + const ws = makeWorksheet('FallbackName'); + const t = new XlsxTool(ws, '', 'msg-2'); + assert.equal(t.name, 'FallbackName'); + }); +}); diff --git a/common/tests/unit-tests/xlsx-sheet-name/xlsx-sheet-name.test.mjs b/common/tests/unit-tests/xlsx-sheet-name/xlsx-sheet-name.test.mjs new file mode 100644 index 0000000000..8936cd1364 --- /dev/null +++ b/common/tests/unit-tests/xlsx-sheet-name/xlsx-sheet-name.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { SheetName } from '../../../dist/xlsx/models/sheet-name.js'; + +describe('SheetName.getSheetName', () => { + it('passes through a clean alphanumeric name within the size limit', () => { + const s = new SheetName(); + assert.equal(s.getSheetName('Hello', 30), 'Hello'); + }); + + it('truncates names that exceed the size limit', () => { + const s = new SheetName(); + const truncated = s.getSheetName('A'.repeat(50), 10); + assert.equal(truncated.length, 10); + }); + + it('caps size at 30 characters even for larger requested sizes', () => { + const s = new SheetName(); + const truncated = s.getSheetName('A'.repeat(50), 999); + assert.equal(truncated.length, 30); + }); + + it('strips Excel-illegal characters: * ? : \\ / [ ]', () => { + const s = new SheetName(); + const out = s.getSheetName('a*b?c:d\\e/f[g]h', 30); + assert.equal(out, 'abcdefgh'); + }); + + it('returns "blank" when the cleaned name is empty', () => { + const s = new SheetName(); + assert.equal(s.getSheetName('', 30), 'blank'); + }); + + it('deduplicates "blank" with a numeric suffix on subsequent calls', () => { + const s = new SheetName(); + s.getSheetName('', 30); + assert.equal(s.getSheetName('***', 30), 'blank 2'); + }); + + it('appends a number for duplicate names (case-insensitive)', () => { + const s = new SheetName(); + const a = s.getSheetName('Hello', 30); + const b = s.getSheetName('hello', 30); + const c = s.getSheetName('HELLO', 30); + assert.equal(a, 'Hello'); + assert.equal(b, 'hello 2'); + assert.equal(c, 'HELLO 3'); + }); + + it('trims trailing whitespace before assigning', () => { + const s = new SheetName(); + assert.equal(s.getSheetName(' Hello ', 30), 'Hello'); + }); +}); + +describe('SheetName.getSchemaName / getToolName / getEnumName', () => { + it('getSchemaName uses size 30', () => { + const s = new SheetName(); + const out = s.getSchemaName('A'.repeat(50)); + assert.equal(out.length, 30); + }); + + it('getToolName appends " (tool)" suffix', () => { + const s = new SheetName(); + assert.equal(s.getToolName('Demo'), 'Demo (tool)'); + }); + + it('getEnumName appends " (enum)" suffix', () => { + const s = new SheetName(); + assert.equal(s.getEnumName('Demo'), 'Demo (enum)'); + }); + + it('tool suffix prefix is truncated to 23 chars', () => { + const s = new SheetName(); + const out = s.getToolName('A'.repeat(50)); + // A (23) + " (tool)" (7) = 30 chars total. + assert.equal(out.length, 30); + assert.ok(out.endsWith(' (tool)')); + }); +}); diff --git a/common/tests/unit-tests/xlsx-table-header/xlsx-table-header.test.mjs b/common/tests/unit-tests/xlsx-table-header/xlsx-table-header.test.mjs new file mode 100644 index 0000000000..96b361758a --- /dev/null +++ b/common/tests/unit-tests/xlsx-table-header/xlsx-table-header.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { TableHeader } from '../../../dist/xlsx/models/table-header.js'; +import { EnumTable } from '../../../dist/xlsx/models/enum-table.js'; + +describe('TableHeader', () => { + it('captures title + required (default false) and starts unplaced', () => { + const h = new TableHeader('Name'); + assert.equal(h.title, 'Name'); + assert.equal(h.required, false); + assert.equal(h.column, -1); + assert.equal(h.row, -1); + assert.equal(h.style, null); + assert.equal(h.width, null); + }); + + it('coerces required to boolean', () => { + assert.equal(new TableHeader('X', 1).required, true); + assert.equal(new TableHeader('X', 0).required, false); + assert.equal(new TableHeader('X', undefined).required, false); + }); + + it('setStyle/setWidth chain and store the value', () => { + const h = new TableHeader('X'); + const style = { font: { bold: true } }; + assert.equal(h.setStyle(style), h); + assert.equal(h.setWidth(25), h); + assert.equal(h.style, style); + assert.equal(h.width, 25); + }); + + it('setPoint records column + row', () => { + const h = new TableHeader('X'); + h.setPoint(3, 5); + assert.equal(h.column, 3); + assert.equal(h.row, 5); + }); +}); + +describe('EnumTable construction', () => { + it('exposes 3 headers (Schema name / Field name / Loaded to IPFS)', () => { + const t = new EnumTable({ c: 1, r: 1 }); + const titles = Array.from(t.headers).map((h) => h.title); + assert.deepEqual(titles, ['Schema name', 'Field name', 'Loaded to IPFS']); + }); + + it('every header has a configured style and width=30', () => { + const t = new EnumTable({ c: 1, r: 1 }); + for (const header of t.headers) { + assert.ok(header.style?.font); + assert.equal(header.width, 30); + } + }); + + it('start === end before setDefault is called', () => { + const t = new EnumTable({ c: 1, r: 1 }); + assert.deepEqual(t.end, t.start); + }); + + it('isHeader recognises the documented header titles', () => { + const t = new EnumTable({ c: 1, r: 1 }); + assert.equal(t.isHeader('Schema name'), true); + assert.equal(t.isHeader('Field name'), true); + assert.equal(t.isHeader('Loaded to IPFS'), true); + assert.equal(t.isHeader('Random'), false); + }); +}); + +describe('EnumTable.setDefault + getRow/getCol', () => { + it('places each header on its own row at start.c', () => { + const t = new EnumTable({ c: 2, r: 5 }); + t.setDefault(); + assert.equal(t.getRow('Schema name'), 5); + assert.equal(t.getRow('Field name'), 6); + assert.equal(t.getRow('Loaded to IPFS'), 7); + }); + + it('setDefault assigns the column from start.c', () => { + const t = new EnumTable({ c: 4, r: 1 }); + t.setDefault(); + assert.equal(t.getCol(), 4); + }); + + it('setDefault advances end by (start.c+2, start.r+headers.size)', () => { + const t = new EnumTable({ c: 4, r: 1 }); + t.setDefault(); + assert.deepEqual(t.end, { c: 6, r: 4 }); + }); +}); + +describe('EnumTable.setRow / setCol / setEnd', () => { + it('setRow updates only the row of the named header', () => { + const t = new EnumTable({ c: 1, r: 1 }); + t.setRow('Schema name', 99); + assert.equal(t.getRow('Schema name'), 99); + }); + + it('setCol updates the table column', () => { + const t = new EnumTable({ c: 1, r: 1 }); + t.setCol(7); + assert.equal(t.getCol(), 7); + }); + + it('setEnd updates the end coordinate', () => { + const t = new EnumTable({ c: 1, r: 1 }); + t.setEnd(10, 20); + assert.deepEqual(t.end, { c: 10, r: 20 }); + }); +}); + +describe('EnumTable.getErrorHeader', () => { + it('returns null when no required header is unplaced', () => { + const t = new EnumTable({ c: 1, r: 1 }); + // Default headers are not required. + assert.equal(t.getErrorHeader(), null); + }); +}); diff --git a/common/tests/unit-tests/xlsx-table/xlsx-table.test.mjs b/common/tests/unit-tests/xlsx-table/xlsx-table.test.mjs new file mode 100644 index 0000000000..b0e30610d4 --- /dev/null +++ b/common/tests/unit-tests/xlsx-table/xlsx-table.test.mjs @@ -0,0 +1,149 @@ +import { assert } from 'chai'; +import { Table } from '../../../dist/xlsx/models/table.js'; +import { Dictionary } from '../../../dist/xlsx/models/dictionary.js'; + +describe('Table construction', () => { + it('start === end before setDefault', () => { + const t = new Table({ c: 1, r: 1 }); + assert.deepEqual(t.end, t.start); + }); + + it('exposes field headers iterator with configured styles', () => { + const t = new Table({ c: 1, r: 1 }); + const headers = Array.from(t.fieldHeaders); + assert.isAbove(headers.length, 0); + for (const h of headers) { + assert.ok(h.style?.font); + } + }); + + it('field headers include the required/visibility/default/key columns', () => { + const t = new Table({ c: 1, r: 1 }); + const titles = Array.from(t.fieldHeaders).map(h => h.title); + assert.include(titles, Dictionary.REQUIRED_FIELD); + assert.include(titles, Dictionary.FIELD_TYPE); + assert.include(titles, Dictionary.QUESTION); + assert.include(titles, Dictionary.KEY); + assert.include(titles, Dictionary.DEFAULT); + assert.include(titles, Dictionary.SUGGEST); + }); + + it('schema headers include name/description/type/tool/tool-id', () => { + const t = new Table({ c: 1, r: 1 }); + const titles = Array.from(t.schemaHeaders).map(h => h.title); + assert.include(titles, Dictionary.SCHEMA_NAME); + assert.include(titles, Dictionary.SCHEMA_DESCRIPTION); + assert.include(titles, Dictionary.SCHEMA_TYPE); + assert.include(titles, Dictionary.SCHEMA_TOOL); + assert.include(titles, Dictionary.SCHEMA_TOOL_ID); + }); +}); + +describe('Table header recognition', () => { + it('isSchemaHeader matches schema header titles only', () => { + const t = new Table({ c: 1, r: 1 }); + assert.isTrue(t.isSchemaHeader(Dictionary.SCHEMA_NAME)); + assert.isTrue(t.isSchemaHeader(Dictionary.SCHEMA_TYPE)); + assert.isFalse(t.isSchemaHeader(Dictionary.REQUIRED_FIELD)); + assert.isFalse(t.isSchemaHeader('Unknown')); + }); + + it('isFieldHeader matches field header titles only', () => { + const t = new Table({ c: 1, r: 1 }); + assert.isTrue(t.isFieldHeader(Dictionary.REQUIRED_FIELD)); + assert.isTrue(t.isFieldHeader(Dictionary.ANSWER)); + assert.isFalse(t.isFieldHeader(Dictionary.SCHEMA_NAME)); + assert.isFalse(t.isFieldHeader('Unknown')); + }); + + it('isName always returns true', () => { + const t = new Table({ c: 1, r: 1 }); + assert.isTrue(t.isName('anything')); + assert.isTrue(t.isName('')); + }); +}); + +describe('Table.setDefault with tool=true', () => { + let t; + beforeEach(() => { + t = new Table({ c: 2, r: 5 }); + t.setDefault(true); + }); + + it('places schema headers on sequential rows from start', () => { + assert.equal(t.getRow(Dictionary.SCHEMA_NAME), 5); + assert.equal(t.getRow(Dictionary.SCHEMA_DESCRIPTION), 6); + assert.equal(t.getRow(Dictionary.SCHEMA_TYPE), 7); + assert.equal(t.getRow(Dictionary.SCHEMA_TOOL), 8); + assert.equal(t.getRow(Dictionary.SCHEMA_TOOL_ID), 9); + }); + + it('places field headers on sequential columns from start', () => { + assert.equal(t.getCol(Dictionary.REQUIRED_FIELD), 2); + assert.equal(t.getCol(Dictionary.FIELD_TYPE), 3); + assert.equal(t.getCol(Dictionary.PARAMETER), 4); + }); + + it('hasCol returns true for placed field headers', () => { + assert.isTrue(t.hasCol(Dictionary.REQUIRED_FIELD)); + }); + + it('getErrorHeader returns null once required headers are placed', () => { + assert.isNull(t.getErrorHeader()); + }); +}); + +describe('Table.setDefault with tool=false', () => { + it('removes tool headers from the schema header set', () => { + const t = new Table({ c: 1, r: 1 }); + t.setDefault(false); + const titles = Array.from(t.schemaHeaders).map(h => h.title); + assert.notInclude(titles, Dictionary.SCHEMA_TOOL); + assert.notInclude(titles, Dictionary.SCHEMA_TOOL_ID); + }); + + it('isSchemaHeader is false for removed tool headers', () => { + const t = new Table({ c: 1, r: 1 }); + t.setDefault(false); + assert.isFalse(t.isSchemaHeader(Dictionary.SCHEMA_TOOL)); + }); +}); + +describe('Table set/get coordinates', () => { + it('hasCol is false for a freshly constructed unplaced header', () => { + const t = new Table({ c: 1, r: 1 }); + assert.isFalse(t.hasCol(Dictionary.REQUIRED_FIELD)); + }); + + it('setCol updates a field header column', () => { + const t = new Table({ c: 1, r: 1 }); + t.setCol(Dictionary.REQUIRED_FIELD, 12); + assert.equal(t.getCol(Dictionary.REQUIRED_FIELD), 12); + }); + + it('setRow updates a schema header row', () => { + const t = new Table({ c: 1, r: 1 }); + t.setRow(Dictionary.SCHEMA_NAME, 42); + assert.equal(t.getRow(Dictionary.SCHEMA_NAME), 42); + }); + + it('setCol on an unknown name is a no-op (does not throw)', () => { + const t = new Table({ c: 1, r: 1 }); + assert.doesNotThrow(() => t.setCol('Unknown', 1)); + }); + + it('setEnd updates the end coordinate', () => { + const t = new Table({ c: 1, r: 1 }); + t.setEnd(10, 20); + assert.deepEqual(t.end, { c: 10, r: 20 }); + }); +}); + +describe('Table.getErrorHeader', () => { + it('returns a required field header that has not been placed', () => { + const t = new Table({ c: 1, r: 1 }); + const err = t.getErrorHeader(); + assert.isNotNull(err); + assert.isTrue(err.required); + }); +}); diff --git a/common/tests/unit-tests/xlsx-tag-indexer/xlsx-tag-indexer.test.mjs b/common/tests/unit-tests/xlsx-tag-indexer/xlsx-tag-indexer.test.mjs new file mode 100644 index 0000000000..650167d98f --- /dev/null +++ b/common/tests/unit-tests/xlsx-tag-indexer/xlsx-tag-indexer.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { TagIndexer } from '../../../dist/xlsx/models/tag-indexer.js'; + +describe('TagIndexer', () => { + it('emits Tool_ for BlockType.Tool', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('tool', null); + assert.ok(/^Tool_\d+$/.test(tag), `unexpected tag: ${tag}`); + }); + + it('emits Schema_Entry_ for Request blocks', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('requestVcDocumentBlock', null); + assert.ok(/^Schema_Entry_\d+$/.test(tag), `unexpected tag: ${tag}`); + }); + + it('emits Schema_Calculations_ for CustomLogicBlock', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('customLogicBlock', null); + assert.ok(/^Schema_Calculations_\d+$/.test(tag)); + }); + + it('emits Root_Holder_ for Container with no data', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('interfaceContainerBlock', null); + assert.ok(/^Root_Holder_\d+$/.test(tag)); + }); + + it('emits _Schema_Holder_ for Container with named data', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('interfaceContainerBlock', { name: 'Demo Holder' }); + assert.ok(/^Demo_Holder_Schema_Holder_\d+$/.test(tag), `unexpected tag: ${tag}`); + }); + + it('truncates the name to 20 characters before _Schema_Holder', () => { + const idx = new TagIndexer(); + const longName = 'x'.repeat(40); + const tag = idx.getTag('interfaceContainerBlock', { name: longName }); + // The 20-char prefix appears before "_Schema_Holder_". + const prefix = tag.split('_Schema_Holder_')[0]; + assert.equal(prefix.length, 20); + }); + + it('emits Autogenerated_ for unknown block types', () => { + const idx = new TagIndexer(); + const tag = idx.getTag('unknownBlockType', null); + assert.ok(/^Autogenerated_\d+$/.test(tag)); + }); + + it('produces a unique numeric suffix on each call', () => { + const idx = new TagIndexer(); + const a = idx.getTag('tool'); + const b = idx.getTag('tool'); + assert.notEqual(a, b); + }); +}); diff --git a/common/tests/unit-tests/xlsx-value-converters/xlsx-converters-edge.test.mjs b/common/tests/unit-tests/xlsx-value-converters/xlsx-converters-edge.test.mjs new file mode 100644 index 0000000000..af87a4feea --- /dev/null +++ b/common/tests/unit-tests/xlsx-value-converters/xlsx-converters-edge.test.mjs @@ -0,0 +1,536 @@ +import assert from 'node:assert/strict'; +import { + entityToXlsx, + xlsxToEntity, + stringToXlsx, + numberToXlsx, + booleanToXlsx, + visibilityToXlsx, + anyToXlsx, + xlsxToArray, + formulaToXlsx, + xlsxToString, + xlsxToNumber, + xlsxToBoolean, + xlsxToAny, + xlsxToType, + valueToFormula, + xlsxToVisibility, + xlsxToPresetValue, + xlsxToPresetArray, + unitToXlsx, + fontToXlsx, + typeToXlsx, + xlsxToUnit, + xlsxToFont, + examplesToXlsx, +} from '../../../dist/xlsx/models/value-converters.js'; +import { Expression } from '../../../dist/xlsx/models/expression.js'; +import { XlsxVariable, XlsxExpressions } from '../../../dist/xlsx/models/xlsx-expressions.js'; +import { XlsxSchemaConditions } from '../../../dist/xlsx/models/schema-condition.js'; + +describe('@unit value-converters edge: stringToXlsx boundaries', () => { + it('returns "" for falsy 0 and false (truthy gate, not null-check)', () => { + assert.equal(stringToXlsx(0), ''); + assert.equal(stringToXlsx(false), ''); + assert.equal(stringToXlsx(NaN), ''); + }); + + it('preserves whitespace-only and unicode strings verbatim', () => { + assert.equal(stringToXlsx(' '), ' '); + assert.equal(stringToXlsx('\t\n'), '\t\n'); + assert.equal(stringToXlsx('日本語'), '日本語'); + assert.equal(stringToXlsx('a\0b'), 'a\0b'); + }); + + it('returns non-empty objects/numbers unchanged via the truthy gate', () => { + assert.equal(stringToXlsx(42), 42); + const obj = { a: 1 }; + assert.equal(stringToXlsx(obj), obj); + }); +}); + +describe('@unit value-converters edge: numberToXlsx boundaries', () => { + it('stringifies NaN, Infinity and -Infinity (only undefined returns "")', () => { + assert.equal(numberToXlsx(NaN), 'NaN'); + assert.equal(numberToXlsx(Infinity), 'Infinity'); + assert.equal(numberToXlsx(-Infinity), '-Infinity'); + }); + + it('keeps null as the string "null" (only undefined is guarded)', () => { + assert.equal(numberToXlsx(null), 'null'); + }); + + it('handles negative zero, very large, and precision-losing numbers', () => { + assert.equal(numberToXlsx(-0), '0'); + assert.equal(numberToXlsx(1e21), '1e+21'); + assert.equal(numberToXlsx(9007199254740993), '9007199254740992'); + assert.equal(numberToXlsx(0.1 + 0.2), '0.30000000000000004'); + }); +}); + +describe('@unit value-converters edge: booleanToXlsx strictness', () => { + it('returns "" for truthy/falsy non-boolean values (strict === only)', () => { + assert.equal(booleanToXlsx(1), ''); + assert.equal(booleanToXlsx(0), ''); + assert.equal(booleanToXlsx('Yes'), ''); + assert.equal(booleanToXlsx('true'), ''); + }); +}); + +describe('@unit value-converters edge: visibilityToXlsx strictness', () => { + it('is case-sensitive and rejects mixed casing', () => { + assert.equal(visibilityToXlsx('HIDDEN'), ''); + assert.equal(visibilityToXlsx('AUTO'), ''); + assert.equal(visibilityToXlsx('Yes'), ''); + assert.equal(visibilityToXlsx('No'), ''); + }); + + it('returns "" for truthy non-boolean values (1 is not true)', () => { + assert.equal(visibilityToXlsx(1), ''); + assert.equal(visibilityToXlsx(null), ''); + assert.equal(visibilityToXlsx(undefined), ''); + }); +}); + +describe('@unit value-converters edge: xlsxToVisibility passthrough', () => { + it('returns unknown values unchanged, including null/undefined/numbers', () => { + assert.equal(xlsxToVisibility('Yes'), 'Yes'); + assert.equal(xlsxToVisibility(''), ''); + assert.equal(xlsxToVisibility(null), null); + assert.equal(xlsxToVisibility(undefined), undefined); + assert.equal(xlsxToVisibility(5), 5); + }); + + it('maps "No" to Hidden but does not map "Yes" to anything special', () => { + assert.equal(xlsxToVisibility('No'), 'Hidden'); + assert.equal(xlsxToVisibility('Auto-Calculate'), 'Auto'); + }); +}); + +describe('@unit value-converters edge: entityToXlsx / xlsxToEntity', () => { + it('entityToXlsx returns Sub-Schema for null/undefined/empty', () => { + assert.equal(entityToXlsx(null), 'Sub-Schema'); + assert.equal(entityToXlsx(undefined), 'Sub-Schema'); + assert.equal(entityToXlsx(''), 'Sub-Schema'); + assert.equal(entityToXlsx('NONE'), 'Sub-Schema'); + }); + + it('xlsxToEntity is case-sensitive and falls back to NONE', () => { + assert.equal(xlsxToEntity('verifiable credentials'), 'NONE'); + assert.equal(xlsxToEntity(''), 'NONE'); + assert.equal(xlsxToEntity(null), 'NONE'); + assert.equal(xlsxToEntity(' VC '), 'NONE'); + }); +}); + +describe('@unit value-converters edge: anyToXlsx recursion', () => { + it('returns "" for empty string but keeps 0 and false scalars', () => { + assert.equal(anyToXlsx(''), ''); + assert.equal(anyToXlsx(0), 0); + assert.equal(anyToXlsx(false), false); + }); + + it('drops 0 and false from arrays only when they survive as "" recursively', () => { + assert.equal(anyToXlsx([0, false, 'a']), '0,false,a'); + assert.equal(anyToXlsx(['', null, undefined]), ''); + }); + + it('recursively stringifies nested arrays joining with commas', () => { + assert.equal(anyToXlsx([['a', 'b'], 'c']), 'a,b,c'); + }); + + it('JSON-stringifies nested objects inside arrays', () => { + assert.equal(anyToXlsx([{ a: 1 }, 'x']), '{"a":1},x'); + }); + + it('JSON-stringifies a bare Date object (no special-casing)', () => { + const d = new Date('2020-01-01T00:00:00.000Z'); + assert.equal(anyToXlsx(d), '"2020-01-01T00:00:00.000Z"'); + }); +}); + +describe('@unit value-converters edge: xlsxToArray falsy handling', () => { + it('treats 0 and false and NaN as empty -> []', () => { + assert.deepEqual(xlsxToArray(0, false), []); + assert.deepEqual(xlsxToArray(false, true), []); + assert.deepEqual(xlsxToArray(NaN, false), []); + assert.deepEqual(xlsxToArray(null, true), []); + }); + + it('wraps truthy objects without inspecting them', () => { + const obj = { a: 1 }; + assert.deepEqual(xlsxToArray(obj, false), [obj]); + assert.deepEqual(xlsxToArray(obj, true), [[obj]]); + }); +}); + +describe('@unit value-converters edge: xlsxToNumber / xlsxToBoolean / xlsxToString', () => { + it('xlsxToNumber returns 0 for empty string and null, NaN for whitespace text', () => { + assert.equal(xlsxToNumber(''), 0); + assert.equal(xlsxToNumber(null), 0); + assert.equal(xlsxToNumber(' '), 0); + assert.ok(Number.isNaN(xlsxToNumber(undefined))); + assert.ok(Number.isNaN(xlsxToNumber('1abc'))); + }); + + it('xlsxToNumber parses hex/exponent strings and very large integers', () => { + assert.equal(xlsxToNumber('0x10'), 16); + assert.equal(xlsxToNumber('1e3'), 1000); + assert.equal(xlsxToNumber('9007199254740993'), 9007199254740992); + }); + + it('xlsxToBoolean is strictly "Yes" only, case-sensitive', () => { + assert.equal(xlsxToBoolean('yes'), false); + assert.equal(xlsxToBoolean('YES'), false); + assert.equal(xlsxToBoolean(true), false); + assert.equal(xlsxToBoolean('Yes '), false); + }); + + it('xlsxToString / xlsxToAny are pure identity, even for null/objects', () => { + assert.equal(xlsxToString(null), null); + assert.equal(xlsxToString(undefined), undefined); + const obj = {}; + assert.equal(xlsxToString(obj), obj); + assert.equal(xlsxToAny(obj), obj); + }); +}); + +describe('@unit value-converters edge: formulaToXlsx', () => { + it('wraps any value including null/number/object in { f }', () => { + assert.deepEqual(formulaToXlsx(null), { f: null }); + assert.deepEqual(formulaToXlsx(0), { f: 0 }); + const o = { a: 1 }; + assert.deepEqual(formulaToXlsx(o), { f: o }); + }); +}); + +describe('@unit value-converters edge: valueToFormula', () => { + it('quotes strings without escaping embedded quotes (latent: produces broken formula)', () => { + assert.equal(valueToFormula('a"b'), '"a"b"'); + assert.equal(valueToFormula(''), '""'); + }); + + it('passes NaN and Infinity through as numbers unchanged', () => { + assert.ok(Number.isNaN(valueToFormula(NaN))); + assert.equal(valueToFormula(Infinity), Infinity); + }); + + it('uses Array.toString (comma-joined) for arrays', () => { + assert.equal(valueToFormula([1, 2, 3]), '1,2,3'); + }); + + it('returns null/undefined unchanged (no toString branch reached)', () => { + assert.equal(valueToFormula(null), null); + assert.equal(valueToFormula(undefined), undefined); + }); +}); + +describe('@unit value-converters edge: xlsxToPresetValue', () => { + it('returns "" for empty string but passes through 0 and false', () => { + assert.equal(xlsxToPresetValue({ type: 'number' }, 0), 0); + assert.equal(xlsxToPresetValue({ type: 'boolean' }, false), false); + assert.equal(xlsxToPresetValue({ type: 'string' }, ''), ''); + }); + + it('returns "" when isRef + JSON is whitespace or partial', () => { + assert.equal(xlsxToPresetValue({ isRef: true }, '{bad'), ''); + assert.equal(xlsxToPresetValue({ isRef: true }, ' '), ''); + }); + + it('parses JSON primitives/arrays when isRef=true', () => { + assert.equal(xlsxToPresetValue({ isRef: true }, '123'), 123); + assert.equal(xlsxToPresetValue({ isRef: true }, 'true'), true); + assert.deepEqual(xlsxToPresetValue({ isRef: true }, '[1,2]'), [1, 2]); + }); +}); + +describe('@unit value-converters edge: xlsxToPresetArray', () => { + it('returns null for empty string and whitespace-only (no matches)', () => { + assert.equal(xlsxToPresetArray({ type: 'string' }, ''), null); + }); + + it('splits comma lists and trims nothing (preserves inner spaces)', () => { + assert.deepEqual(xlsxToPresetArray({ type: 'string' }, 'a, b ,c'), ['a', ' b ', 'c']); + }); + + it('coerces non-string scalars via String() before matching', () => { + assert.deepEqual(xlsxToPresetArray({ type: 'number' }, 123), ['123']); + }); + + it('drops empty entries between commas (regex skips them)', () => { + assert.deepEqual(xlsxToPresetArray({ type: 'string' }, 'a,,b'), ['a', 'b']); + }); + + it('parses each quoted segment as JSON when isRef=true', () => { + const out = xlsxToPresetArray({ isRef: true }, '{"a":1},{"b":2}'); + assert.deepEqual(out, [{ a: 1 }, { b: 2 }]); + }); +}); + +describe('@unit value-converters edge: unitToXlsx', () => { + it('returns "" for unknown unitSystem regardless of unit', () => { + assert.equal(unitToXlsx({ unit: 'kg', unitSystem: 'middle' }), ''); + assert.equal(unitToXlsx({ unit: 'kg', unitSystem: undefined }), ''); + }); + + it('embeds undefined unit literally for prefix/postfix', () => { + assert.equal(unitToXlsx({ unitSystem: 'prefix' }), '"undefined"#,##0.00'); + assert.equal(unitToXlsx({ unitSystem: 'postfix' }), '#,##0.00"undefined"'); + }); +}); + +describe('@unit value-converters edge: xlsxToUnit regex', () => { + it('extracts the unit token out of an exceljs number format', () => { + assert.equal(xlsxToUnit('"$"#,##0.00'), '$'); + assert.equal(xlsxToUnit('#,##0.00"kg"'), 'kg'); + }); + + it('throws when the format contains no unit token (null match deref)', () => { + assert.throws(() => xlsxToUnit('#,##0.00')); + assert.throws(() => xlsxToUnit('')); + }); +}); + +describe('@unit value-converters edge: fontToXlsx', () => { + it('defaults bold to false and omits size/color when absent', () => { + const out = fontToXlsx({}); + assert.equal(out.font.bold, false); + assert.equal(out.font.size, undefined); + assert.equal(out.font.color, undefined); + }); + + it('strips only the first "px" occurrence from size', () => { + assert.equal(fontToXlsx({ size: '12px' }).font.size, '12'); + assert.equal(fontToXlsx({ size: '12pxpx' }).font.size, '12px'); + }); + + it('prefixes color with FF and strips a single leading #', () => { + assert.deepEqual(fontToXlsx({ color: '#abcdef' }).font.color, { argb: 'FFabcdef' }); + assert.deepEqual(fontToXlsx({ color: 'abcdef' }).font.color, { argb: 'FFabcdef' }); + }); + + it('base merge overwrites base.font entirely (shallow Object.assign)', () => { + const base = { font: { italic: true }, fill: 'x' }; + const out = fontToXlsx({ bold: true }, base); + assert.equal(out.font.italic, undefined); + assert.equal(out.font.bold, true); + assert.equal(out.fill, 'x'); + }); +}); + +describe('@unit value-converters edge: xlsxToFont', () => { + it('parses a JSON string into an object', () => { + assert.deepEqual(xlsxToFont('{"bold":true}'), { bold: true }); + }); + + it('returns {} on malformed JSON instead of throwing', () => { + assert.deepEqual(xlsxToFont('{bad'), {}); + }); + + it('returns {} for object input (function builds result but never returns it)', () => { + assert.deepEqual(xlsxToFont({ bold: true, size: 12 }), {}); + assert.deepEqual(xlsxToFont(null), {}); + assert.deepEqual(xlsxToFont(undefined), {}); + }); +}); + +describe('@unit value-converters edge: typeToXlsx / xlsxToType', () => { + it('xlsxToType returns null for unknown / empty / null names', () => { + assert.equal(xlsxToType('NoSuchType'), null); + assert.equal(xlsxToType(''), null); + assert.equal(xlsxToType(null), null); + }); + + it('xlsxToType is case-sensitive on the type name', () => { + assert.equal(xlsxToType('number'), null); + assert.ok(xlsxToType('Number')); + }); + + it('typeToXlsx returns undefined when no field type matches', () => { + assert.equal(typeToXlsx({ type: 'totally-unknown' }), undefined); + }); + + it('typeToXlsx resolves String and Boolean by structural equality', () => { + assert.equal(typeToXlsx({ type: 'string', isRef: false }), 'String'); + assert.equal(typeToXlsx({ type: 'boolean', isRef: false }), 'Boolean'); + }); + + it('round-trips a resolved type name back through xlsxToType', () => { + const t = xlsxToType('Integer'); + assert.equal(t.type, 'integer'); + assert.equal(typeof t.pars, 'function'); + assert.equal(t.pars('5.5'), ''); + assert.equal(t.pars('5'), 5); + }); +}); + +describe('@unit value-converters edge: examplesToXlsx', () => { + it('returns the first example coerced via anyToXlsx when examples present', () => { + assert.equal(examplesToXlsx({ examples: ['first', 'second'] }), 'first'); + assert.equal(examplesToXlsx({ examples: [{ a: 1 }] }), '{"a":1}'); + }); + + it('returns "" for isRef fields without examples', () => { + assert.equal(examplesToXlsx({ isRef: true }), ''); + assert.equal(examplesToXlsx({ isRef: true, examples: null }), ''); + }); + + it('returns "" when an empty examples array is supplied (examples[0] undefined)', () => { + assert.equal(examplesToXlsx({ examples: [] }), ''); + }); +}); + +describe('@unit expression edge: malformed / empty formulae', () => { + it('does not throw on empty / whitespace formula (mathjs yields undefined node)', () => { + const e1 = new Expression('e', ''); + e1.parse(); + assert.equal(e1.symbols.size, 0); + assert.equal(e1.transformed, 'undefined'); + const e2 = new Expression('e', ' '); + e2.parse(); + assert.equal(e2.transformed, 'undefined'); + }); + + it('throws on syntactically invalid formula', () => { + assert.throws(() => new Expression('e', 'A1 +').parse()); + assert.throws(() => new Expression('e', '((A1)').parse()); + }); + + it('parses a bare numeric constant with no symbols', () => { + const e = new Expression('e', '42'); + e.parse(); + assert.equal(e.symbols.size, 0); + assert.equal(e.transformed, '42'); + }); + + it('treats a single-cell range A1:A1 as one cell', () => { + const e = new Expression('e', 'sum(A1:A1)'); + e.parse(); + assert.deepEqual(e.ranges.get('A1_A1'), ['A1']); + }); + + it('throws Invalid range when a range endpoint lacks a row number', () => { + const e = new Expression('e', 'sum(A:A3)'); + assert.throws(() => e.parse(), /Invalid range/); + }); + + it('nested function calls collect inner symbols recursively', () => { + const e = new Expression('e', 'add(sub(A1, B2), C3)'); + e.parse(); + assert.deepEqual(Array.from(e.symbols).sort(), ['A1', 'B2', 'C3']); + }); +}); + +describe('@unit xlsx-expressions edge: variable graph errors', () => { + it('fullPath is null even after add when field unset on child', () => { + const parent = new XlsxVariable('p', 'p', 'd', 0); + parent.setField({ name: 'root' }); + const child = new XlsxVariable('c', 'c', 'd', 1); + parent.add(child); + assert.equal(child.fullPath, null); + }); + + it('add wires parent pointer and is order-preserving', () => { + const p = new XlsxVariable('p', 'p', 'd', 0); + const a = new XlsxVariable('a', 'a', 'd', 1); + const b = new XlsxVariable('b', 'b', 'd', 1); + p.add(a); + p.add(b); + assert.equal(p.children.length, 2); + assert.equal(a.parent, p); + assert.equal(p.children[1], b); + }); + + it('updateSchemas on empty list is a no-op', () => { + const ex = new XlsxExpressions(); + ex.setSchema({ fields: [] }); + assert.doesNotThrow(() => ex.updateSchemas([])); + assert.equal(ex.getVariables().size, 0); + }); + + it('updateSchemas throws Invalid group level when first var has lvl 1 (no prior sibling)', () => { + const ex = new XlsxExpressions(); + ex.setSchema({ fields: [] }); + ex.addVariable({ name: 'n', path: 'p' }, 'd', 1); + assert.throws(() => ex.updateSchemas([]), /Invalid group level/); + }); + + it('getVariables collapses duplicate fieldPaths to the last entry', () => { + const ex = new XlsxExpressions(); + ex.addVariable({ name: 'a', path: 'dup' }, 'd', 0); + ex.addVariable({ name: 'b', path: 'dup' }, 'd', 0); + assert.equal(ex.getVariables().size, 1); + }); + + it('update throws Fields not found when title does not match at lvl 0', () => { + const v = new XlsxVariable('n', 'Missing', 'd', 0); + v.setSchema({ fields: [{ name: 'f', title: 'Other' }] }); + assert.throws(() => v.update([]), /Fields not found/); + }); +}); + +describe('@unit schema-condition edge: constructor disambiguation', () => { + it('treats an object field arg with explicit value as the single form', () => { + const c = new XlsxSchemaConditions({ op: 'OR', items: [] }, 'V'); + assert.ok(c.single); + assert.equal(c.condition.ifCondition.fieldValue, 'V'); + assert.equal(c.group, undefined); + }); + + it('object with op but non-array items falls into the single form', () => { + const c = new XlsxSchemaConditions({ op: 'OR', items: 'nope' }); + assert.ok(c.single); + assert.equal(c.group, undefined); + }); + + it('treats undefined field with undefined value as single form (field=undefined)', () => { + const c = new XlsxSchemaConditions(undefined, undefined); + assert.ok(c.single); + assert.equal(c.condition.ifCondition.field, undefined); + }); + + it('builds an empty-items group when items array is empty', () => { + const c = new XlsxSchemaConditions({ op: 'AND', items: [] }); + assert.ok(c.group); + assert.deepEqual(c.condition.ifCondition.AND, []); + }); + + it('non-OR op is treated as AND in the payload mapping', () => { + const c = new XlsxSchemaConditions({ op: 'XOR', items: [{ field: { name: 'a' }, value: 1 }] }); + assert.ok(c.condition.ifCondition.AND); + assert.equal(c.condition.ifCondition.OR, undefined); + }); +}); + +describe('@unit schema-condition edge: equal() boundaries', () => { + it('single.equal returns false when comparison field is undefined', () => { + const c = new XlsxSchemaConditions({ name: 'a' }, 'X'); + assert.equal(c.equal(undefined, 'X'), false); + }); + + it('single.equal uses deep JSON compare so undefined==undefined values match', () => { + const c = new XlsxSchemaConditions({ name: 'a' }, undefined); + assert.equal(c.equal({ name: 'a' }, undefined), true); + }); + + it('group.equal returns false when other is null/undefined', () => { + const c = new XlsxSchemaConditions({ op: 'OR', items: [] }); + assert.equal(c.equal(null), false); + assert.equal(c.equal(undefined), false); + }); + + it('group.equal of two empty-item groups with same op is true', () => { + const c = new XlsxSchemaConditions({ op: 'OR', items: [] }); + assert.equal(c.equal({ op: 'OR', items: [] }), true); + }); + + it('group.equal normalises items by field.name + JSON value', () => { + const c = new XlsxSchemaConditions({ + op: 'AND', + items: [{ field: { name: 'a' }, value: { z: 1 } }], + }); + assert.equal(c.equal({ op: 'AND', items: [{ field: { name: 'a' }, value: { z: 1 } }] }), true); + assert.equal(c.equal({ op: 'AND', items: [{ field: { name: 'a' }, value: { z: 2 } }] }), false); + }); +}); diff --git a/common/tests/unit-tests/xlsx-value-converters/xlsx-value-converters.test.mjs b/common/tests/unit-tests/xlsx-value-converters/xlsx-value-converters.test.mjs new file mode 100644 index 0000000000..9dc9a62701 --- /dev/null +++ b/common/tests/unit-tests/xlsx-value-converters/xlsx-value-converters.test.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { + entityToXlsx, + xlsxToEntity, + stringToXlsx, + numberToXlsx, + booleanToXlsx, + visibilityToXlsx, + anyToXlsx, + xlsxToArray, + formulaToXlsx, + xlsxToString, + xlsxToNumber, + xlsxToBoolean, + xlsxToAny, + xlsxToType, + valueToFormula, + xlsxToVisibility, + xlsxToPresetValue, + xlsxToPresetArray, + unitToXlsx, + fontToXlsx, + typeToXlsx, +} from '../../../dist/xlsx/models/value-converters.js'; + +describe('entityToXlsx / xlsxToEntity', () => { + it('VC ↔ "Verifiable Credentials"', () => { + assert.equal(entityToXlsx('VC'), 'Verifiable Credentials'); + assert.equal(xlsxToEntity('Verifiable Credentials'), 'VC'); + assert.equal(xlsxToEntity('VC'), 'VC'); + }); + + it('EVC ↔ "Encrypted Verifiable Credential"', () => { + assert.equal(entityToXlsx('EVC'), 'Encrypted Verifiable Credential'); + assert.equal(xlsxToEntity('Encrypted Verifiable Credential'), 'EVC'); + assert.equal(xlsxToEntity('EVC'), 'EVC'); + }); + + it('falls back to Sub-Schema / NONE for everything else', () => { + assert.equal(entityToXlsx('OTHER'), 'Sub-Schema'); + assert.equal(xlsxToEntity('something else'), 'NONE'); + }); +}); + +describe('primitive XLSX writers', () => { + it('stringToXlsx returns the value or ""', () => { + assert.equal(stringToXlsx('x'), 'x'); + assert.equal(stringToXlsx(''), ''); + assert.equal(stringToXlsx(null), ''); + assert.equal(stringToXlsx(undefined), ''); + }); + + it('numberToXlsx stringifies finite + zero, returns "" for undefined', () => { + assert.equal(numberToXlsx(0), '0'); + assert.equal(numberToXlsx(42), '42'); + assert.equal(numberToXlsx(undefined), ''); + }); + + it('booleanToXlsx maps true→Yes, false→No, otherwise ""', () => { + assert.equal(booleanToXlsx(true), 'Yes'); + assert.equal(booleanToXlsx(false), 'No'); + assert.equal(booleanToXlsx(null), ''); + assert.equal(booleanToXlsx(undefined), ''); + }); + + it('visibilityToXlsx normalises Hidden/Auto/Yes/No', () => { + assert.equal(visibilityToXlsx('hidden'), 'Hidden'); + assert.equal(visibilityToXlsx('Hidden'), 'Hidden'); + assert.equal(visibilityToXlsx('auto'), 'Auto'); + assert.equal(visibilityToXlsx('Auto'), 'Auto'); + assert.equal(visibilityToXlsx(true), 'Yes'); + assert.equal(visibilityToXlsx(false), 'No'); + assert.equal(visibilityToXlsx('other'), ''); + }); +}); + +describe('anyToXlsx', () => { + it('returns "" for undefined or null', () => { + assert.equal(anyToXlsx(undefined), ''); + assert.equal(anyToXlsx(null), ''); + }); + + it('returns scalars unchanged', () => { + assert.equal(anyToXlsx('x'), 'x'); + assert.equal(anyToXlsx(7), 7); + assert.equal(anyToXlsx(true), true); + }); + + it('JSON-stringifies plain objects', () => { + assert.equal(anyToXlsx({ a: 1 }), '{"a":1}'); + }); + + it('joins arrays and skips falsy/empty entries', () => { + assert.equal(anyToXlsx(['a', '', null, 'b']), 'a,b'); + }); +}); + +describe('xlsxToArray', () => { + it('wraps a single value (multiple=false)', () => { + assert.deepEqual(xlsxToArray('a', false), ['a']); + }); + + it('double-wraps for multiple=true', () => { + assert.deepEqual(xlsxToArray('a', true), [[ 'a' ]]); + }); + + it('returns [] for falsy input', () => { + assert.deepEqual(xlsxToArray(undefined, false), []); + assert.deepEqual(xlsxToArray('', true), []); + }); +}); + +describe('formulaToXlsx / xlsxTo*', () => { + it('formulaToXlsx wraps a string in {f:...}', () => { + assert.deepEqual(formulaToXlsx('A1+B2'), { f: 'A1+B2' }); + }); + + it('xlsxToString is identity for strings', () => { + assert.equal(xlsxToString('hello'), 'hello'); + }); + + it('xlsxToNumber coerces with Number()', () => { + assert.equal(xlsxToNumber('42'), 42); + assert.ok(Number.isNaN(xlsxToNumber('abc'))); + }); + + it('xlsxToBoolean is true only for "Yes"', () => { + assert.equal(xlsxToBoolean('Yes'), true); + assert.equal(xlsxToBoolean('No'), false); + assert.equal(xlsxToBoolean(''), false); + }); + + it('xlsxToAny is identity', () => { + assert.equal(xlsxToAny('xyz'), 'xyz'); + }); + + it('xlsxToType resolves a known FieldTypes name', () => { + const t = xlsxToType('Number'); + assert.equal(t.type, 'number'); + }); +}); + +describe('valueToFormula', () => { + it('returns numbers + booleans unchanged', () => { + assert.equal(valueToFormula(42), 42); + assert.equal(valueToFormula(true), true); + }); + + it('quotes strings', () => { + assert.equal(valueToFormula('hello'), '"hello"'); + }); + + it('uses .toString() for objects', () => { + assert.equal(valueToFormula({ toString: () => 'X' }), 'X'); + }); +}); + +describe('xlsxToVisibility', () => { + it('maps hidden/Hidden/No → "Hidden"', () => { + assert.equal(xlsxToVisibility('hidden'), 'Hidden'); + assert.equal(xlsxToVisibility('Hidden'), 'Hidden'); + assert.equal(xlsxToVisibility('No'), 'Hidden'); + }); + + it('maps Auto/auto/Auto-Calculate → "Auto"', () => { + assert.equal(xlsxToVisibility('auto'), 'Auto'); + assert.equal(xlsxToVisibility('Auto'), 'Auto'); + assert.equal(xlsxToVisibility('Auto-Calculate'), 'Auto'); + }); + + it('passes through other values', () => { + assert.equal(xlsxToVisibility('Yes'), 'Yes'); + assert.equal(xlsxToVisibility(''), ''); + }); +}); + +describe('xlsxToPresetValue / xlsxToPresetArray', () => { + it('xlsxToPresetValue returns "" for undefined/null/empty', () => { + assert.equal(xlsxToPresetValue({ type: 'string' }, undefined), ''); + assert.equal(xlsxToPresetValue({ type: 'string' }, null), ''); + assert.equal(xlsxToPresetValue({ type: 'string' }, ''), ''); + }); + + it('xlsxToPresetValue passes through scalar non-ref values', () => { + assert.equal(xlsxToPresetValue({ type: 'string' }, 'abc'), 'abc'); + assert.equal(xlsxToPresetValue({ type: 'number' }, 42), 42); + }); + + it('xlsxToPresetValue parses JSON when isRef=true', () => { + const out = xlsxToPresetValue({ type: '#X', isRef: true }, '{"a":1}'); + assert.deepEqual(out, { a: 1 }); + }); + + it('xlsxToPresetValue returns "" when isRef=true and JSON is bad', () => { + const out = xlsxToPresetValue({ type: '#X', isRef: true }, 'not-json'); + assert.equal(out, ''); + }); + + it('xlsxToPresetArray returns null for null/undefined', () => { + assert.equal(xlsxToPresetArray({ type: 'string' }, null), null); + assert.equal(xlsxToPresetArray({ type: 'string' }, undefined), null); + }); + + it('xlsxToPresetArray splits on commas, supports quoted entries', () => { + const out = xlsxToPresetArray({ type: 'string' }, '"a,b",c'); + assert.deepEqual(out, ['a,b', 'c']); + }); +}); + +describe('unitToXlsx / fontToXlsx / typeToXlsx', () => { + it('unitToXlsx wraps prefix and postfix correctly', () => { + assert.equal(unitToXlsx({ unit: '$', unitSystem: 'prefix' }), '"$"#,##0.00'); + assert.equal(unitToXlsx({ unit: 'kg', unitSystem: 'postfix' }), '#,##0.00"kg"'); + assert.equal(unitToXlsx({ unit: '', unitSystem: 'other' }), ''); + }); + + it('fontToXlsx builds a font object preserving bold/size/color', () => { + const out = fontToXlsx({ bold: true, size: '12px', color: '#abcdef' }); + assert.equal(out.font.bold, true); + assert.equal(out.font.size, '12'); + assert.deepEqual(out.font.color, { argb: 'FFabcdef' }); + }); + + it('fontToXlsx merges into a base when supplied', () => { + const base = { fill: { color: 'red' } }; + const out = fontToXlsx({ bold: true }, base); + assert.deepEqual(out.fill, { color: 'red' }); + assert.equal(out.font.bold, true); + }); + + it('typeToXlsx round-trips through FieldTypes', () => { + const name = typeToXlsx({ type: 'number', isRef: false }); + assert.equal(name, 'Number'); + }); +}); diff --git a/common/tests/unit-tests/xlsx-workbook/xlsx-workbook-extra.test.mjs b/common/tests/unit-tests/xlsx-workbook/xlsx-workbook-extra.test.mjs new file mode 100644 index 0000000000..0db0b5e36e --- /dev/null +++ b/common/tests/unit-tests/xlsx-workbook/xlsx-workbook-extra.test.mjs @@ -0,0 +1,254 @@ +import { assert } from 'chai'; +import { + Workbook, + Worksheet, + Range, + Cell, + Hyperlink +} from '../../../dist/xlsx/models/workbook.js'; + +describe('Range', () => { + it('constructor sets corners and s/e points', () => { + const r = new Range(2, 3, 5, 7); + assert.equal(r.startColumn, 2); + assert.equal(r.startRow, 3); + assert.equal(r.endColumn, 5); + assert.equal(r.endRow, 7); + assert.deepEqual(r.s, { r: 3, c: 2 }); + assert.deepEqual(r.e, { r: 7, c: 5 }); + }); + + it('fromColumns builds a single-row horizontal range', () => { + const r = Range.fromColumns(1, 4, 9); + assert.equal(r.startRow, 9); + assert.equal(r.endRow, 9); + assert.equal(r.startColumn, 1); + assert.equal(r.endColumn, 4); + }); + + it('fromRows builds a single-column vertical range', () => { + const r = Range.fromRows(2, 8, 3); + assert.equal(r.startColumn, 3); + assert.equal(r.endColumn, 3); + assert.equal(r.startRow, 2); + assert.equal(r.endRow, 8); + }); +}); + +describe('Hyperlink', () => { + it('constructor builds an internal link string', () => { + const h = new Hyperlink('Sheet1', 'B2'); + assert.equal(h.worksheet, 'Sheet1'); + assert.equal(h.cell, 'B2'); + assert.equal(h.link, "#'Sheet1'!B2"); + }); + + it('from parses a #\'sheet\'!cell style link', () => { + const h = Hyperlink.from("#'Data'!C5"); + assert.isNotNull(h); + assert.equal(h.worksheet, 'Data'); + assert.equal(h.cell, 'C5'); + }); + + it('from strips a leading hash without quotes', () => { + const h = Hyperlink.from('#Sheet2!A1'); + assert.equal(h.worksheet, 'Sheet2'); + assert.equal(h.cell, 'A1'); + }); + + it('from strips double quotes around the sheet name', () => { + const h = Hyperlink.from('#"My Sheet"!Z9'); + assert.equal(h.worksheet, 'My Sheet'); + assert.equal(h.cell, 'Z9'); + }); + + it('from returns null for empty input', () => { + assert.isNull(Hyperlink.from('')); + assert.isNull(Hyperlink.from(null)); + }); + + it('from returns null when there is no cell part', () => { + assert.isNull(Hyperlink.from('#Sheet1')); + }); + + it('round-trips a constructed Hyperlink through from', () => { + const original = new Hyperlink('S', 'A1'); + const parsed = Hyperlink.from(original.link); + assert.equal(parsed.worksheet, 'S'); + assert.equal(parsed.cell, 'A1'); + }); +}); + +describe('Worksheet range checks', () => { + let ws; + beforeEach(() => { + const wb = new Workbook(); + ws = wb.createWorksheet('S1'); + }); + + it('outColumnRange flags non-finite / out of bounds columns', () => { + assert.isTrue(ws.outColumnRange(0)); + assert.isTrue(ws.outColumnRange(256)); + assert.isTrue(ws.outColumnRange(NaN)); + assert.isFalse(ws.outColumnRange(1)); + assert.isFalse(ws.outColumnRange(255)); + }); + + it('outRowRange flags non-finite / out of bounds rows', () => { + assert.isTrue(ws.outRowRange(0)); + assert.isTrue(ws.outRowRange(65001)); + assert.isFalse(ws.outRowRange(1)); + assert.isFalse(ws.outRowRange(65000)); + }); + + it('checkColumnRange / checkRowRange throw for invalid', () => { + assert.throws(() => ws.checkColumnRange(0), /Invalid column range/); + assert.throws(() => ws.checkRowRange(0), /Invalid row range/); + }); + + it('checkRange does not throw for valid coords', () => { + assert.doesNotThrow(() => ws.checkRange(1, 1)); + }); +}); + +describe('Worksheet cell access + values', () => { + let wb; + let ws; + beforeEach(() => { + wb = new Workbook(); + ws = wb.createWorksheet('Data'); + }); + + it('setValue/getValue round-trips a primitive', () => { + ws.setValue('hello', 1, 1); + assert.equal(ws.getValue(1, 1), 'hello'); + }); + + it('getCell returns a Cell instance', () => { + assert.instanceOf(ws.getCell(1, 1), Cell); + }); + + it('empty returns true for a blank row range', () => { + assert.isTrue(ws.empty(1, 4, 2)); + }); + + it('empty returns false once a value exists', () => { + ws.setValue('x', 2, 3); + assert.isFalse(ws.empty(1, 4, 3)); + }); + + it('getFullPath includes the sheet name', () => { + const path = ws.getFullPath(1, 1); + assert.match(path, /^'Data'!/); + }); +}); + +describe('Cell behaviour', () => { + let ws; + beforeEach(() => { + const wb = new Workbook(); + ws = wb.createWorksheet('C'); + }); + + it('setValue returns the Cell (chainable) and isValue is true', () => { + const cell = ws.getCell(1, 1); + assert.strictEqual(cell.setValue(5), cell); + assert.isTrue(cell.isValue()); + }); + + it('isValue false for empty cell', () => { + assert.isFalse(ws.getCell(2, 2).isValue()); + }); + + it('setFormulae / getFormulae / isFormulae', () => { + const cell = ws.getCell(1, 2); + cell.setFormulae('SUM(A1:A2)', 7); + assert.equal(cell.getFormulae(), 'SUM(A1:A2)'); + assert.isTrue(cell.isFormulae()); + assert.equal(cell.getResult(), 7); + }); + + it('setFormulae without result still records the formula', () => { + const cell = ws.getCell(1, 3); + cell.setFormulae('A1+1'); + assert.equal(cell.getFormulae(), 'A1+1'); + }); + + it('setList / getList round-trips items', () => { + const cell = ws.getCell(1, 4); + cell.setList(['a', 'b', 'c']); + assert.deepEqual(cell.getList(), ['a', 'b', 'c']); + }); + + it('getList returns null when no list validation', () => { + assert.isNull(ws.getCell(2, 4).getList()); + }); + + it('setFormat / getFormat round-trips', () => { + const cell = ws.getCell(1, 5); + cell.setFormat('0.00'); + assert.equal(cell.getFormat(), '0.00'); + }); + + it('getValue extracts text from a hyperlink value object', () => { + const cell = ws.getCell(1, 6); + cell.setLink('Click', new Hyperlink('C', 'A1')); + assert.equal(cell.getValue(), 'Click'); + }); + + it('getLink returns a Hyperlink for a linked cell', () => { + const cell = ws.getCell(1, 7); + cell.setLink('Go', new Hyperlink('C', 'B2')); + const link = cell.getLink(); + assert.isNotNull(link); + assert.equal(link.cell, 'B2'); + }); + + it('getLink returns null for a plain cell', () => { + assert.isNull(ws.getCell(2, 7).getLink()); + }); + + it('address reflects the cell coordinate', () => { + const cell = ws.getCell(1, 1); + assert.isString(cell.address); + }); +}); + +describe('Workbook worksheet management', () => { + it('createWorksheet then getWorksheet returns same name', () => { + const wb = new Workbook(); + wb.createWorksheet('Alpha'); + const ws = wb.getWorksheet('Alpha'); + assert.instanceOf(ws, Worksheet); + assert.equal(ws.name, 'Alpha'); + }); + + it('getWorksheet returns null for unknown sheet', () => { + const wb = new Workbook(); + assert.isNull(wb.getWorksheet('Nope')); + }); + + it('getWorksheetByIndex returns null when out of range', () => { + const wb = new Workbook(); + assert.isNull(wb.getWorksheetByIndex(99)); + }); + + it('sheetNames and sheetLength reflect created worksheets', () => { + const wb = new Workbook(); + wb.createWorksheet('One'); + wb.createWorksheet('Two'); + assert.equal(wb.sheetLength, 2); + assert.includeMembers(wb.sheetNames, ['One', 'Two']); + }); + + it('getWorksheets maps all sheets to Worksheet wrappers', () => { + const wb = new Workbook(); + wb.createWorksheet('A'); + wb.createWorksheet('B'); + const list = wb.getWorksheets(); + assert.equal(list.length, 2); + for (const ws of list) { + assert.instanceOf(ws, Worksheet); + } + }); +}); diff --git a/common/tests/unit-tests/xlsx-workbook/xlsx-workbook.test.mjs b/common/tests/unit-tests/xlsx-workbook/xlsx-workbook.test.mjs new file mode 100644 index 0000000000..3d16f58d96 --- /dev/null +++ b/common/tests/unit-tests/xlsx-workbook/xlsx-workbook.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { Workbook, Range } from '../../../dist/xlsx/models/workbook.js'; + +describe('Range', () => { + it('captures start/end coords + s/e IPoint shape', () => { + const r = new Range(2, 3, 5, 7); + assert.equal(r.startColumn, 2); + assert.equal(r.startRow, 3); + assert.equal(r.endColumn, 5); + assert.equal(r.endRow, 7); + assert.deepEqual(r.s, { r: 3, c: 2 }); + assert.deepEqual(r.e, { r: 7, c: 5 }); + }); + + it('Range.fromColumns builds a horizontal range with shared row', () => { + const r = Range.fromColumns(2, 5, 4); + assert.equal(r.startColumn, 2); + assert.equal(r.startRow, 4); + assert.equal(r.endColumn, 5); + assert.equal(r.endRow, 4); + }); + + it('Range.fromRows builds a vertical range with shared column', () => { + const r = Range.fromRows(3, 7, 2); + assert.equal(r.startColumn, 2); + assert.equal(r.startRow, 3); + assert.equal(r.endColumn, 2); + assert.equal(r.endRow, 7); + }); +}); + +describe('Workbook construction + sheet management', () => { + it('starts empty (sheetLength=0, sheetNames=[])', () => { + const w = new Workbook(); + assert.equal(w.sheetLength, 0); + assert.deepEqual(w.sheetNames, []); + }); + + it('createWorksheet returns a Worksheet and adds to sheetNames', () => { + const w = new Workbook(); + const sheet = w.createWorksheet('First'); + assert.ok(sheet); + assert.equal(sheet.name, 'First'); + assert.equal(w.sheetLength, 1); + assert.deepEqual(w.sheetNames, ['First']); + }); + + it('createWorksheet allows multiple sheets in insertion order', () => { + const w = new Workbook(); + w.createWorksheet('A'); + w.createWorksheet('B'); + w.createWorksheet('C'); + assert.deepEqual(w.sheetNames, ['A', 'B', 'C']); + }); + + it('getWorksheet returns the named sheet, or null', () => { + const w = new Workbook(); + w.createWorksheet('X'); + assert.equal(w.getWorksheet('X').name, 'X'); + assert.equal(w.getWorksheet('Y'), null); + }); + + it('getWorksheetByIndex returns the sheet at the index, or null', () => { + const w = new Workbook(); + w.createWorksheet('A'); + w.createWorksheet('B'); + assert.equal(w.getWorksheetByIndex(0).name, 'A'); + assert.equal(w.getWorksheetByIndex(1).name, 'B'); + assert.equal(w.getWorksheetByIndex(99), null); + }); + + it('getWorksheets returns every sheet wrapped', () => { + const w = new Workbook(); + w.createWorksheet('A'); + w.createWorksheet('B'); + const all = w.getWorksheets(); + assert.equal(all.length, 2); + assert.deepEqual(all.map((s) => s.name), ['A', 'B']); + }); +}); + +describe('Worksheet.outColumnRange / outRowRange', () => { + const w = new Workbook(); + const ws = w.createWorksheet('s'); + + it('rejects column 0 / >255 / non-finite', () => { + assert.equal(ws.outColumnRange(0), true); + assert.equal(ws.outColumnRange(256), true); + assert.equal(ws.outColumnRange(NaN), true); + assert.equal(ws.outColumnRange(Infinity), true); + }); + + it('accepts column 1 to 255', () => { + assert.equal(ws.outColumnRange(1), false); + assert.equal(ws.outColumnRange(255), false); + }); + + it('rejects row 0 / >65000 / non-finite', () => { + assert.equal(ws.outRowRange(0), true); + assert.equal(ws.outRowRange(65001), true); + assert.equal(ws.outRowRange(NaN), true); + }); + + it('accepts row 1 to 65000', () => { + assert.equal(ws.outRowRange(1), false); + assert.equal(ws.outRowRange(65000), false); + }); + + it('checkColumnRange/checkRowRange/checkRange throw on bad inputs', () => { + assert.throws(() => ws.checkColumnRange(0), /Invalid column range/); + assert.throws(() => ws.checkRowRange(0), /Invalid row range/); + assert.throws(() => ws.checkRange(0, 1), /Invalid column range/); + assert.throws(() => ws.checkRange(1, 0), /Invalid row range/); + }); +}); + +describe('Worksheet read/write cells', () => { + it('setValue + getValue round-trips a primitive', () => { + const w = new Workbook(); + const ws = w.createWorksheet('s'); + ws.setValue('hello', 1, 1); + assert.equal(ws.getValue(1, 1), 'hello'); + }); + + it('getFullPath wraps the cell address with the sheet name', () => { + const w = new Workbook(); + const ws = w.createWorksheet('Demo Sheet'); + ws.setValue('x', 2, 3); + const path = ws.getFullPath(2, 3); + assert.ok(path.startsWith("'Demo Sheet'!")); + }); + + it('empty() returns true when all cells in the range are blank', () => { + const w = new Workbook(); + const ws = w.createWorksheet('s'); + assert.equal(ws.empty(1, 5, 1), true); + }); + + it('empty() returns false once any cell has a value', () => { + const w = new Workbook(); + const ws = w.createWorksheet('s'); + ws.setValue('x', 3, 1); + assert.equal(ws.empty(1, 5, 1), false); + }); +}); + +describe('Workbook xlsx round-trip', () => { + it('write() + read() preserve a sheet name', async () => { + const w = new Workbook(); + w.createWorksheet('A'); + w.createWorksheet('B'); + const buf = await w.write(); + + const w2 = new Workbook(); + await w2.read(buf); + assert.deepEqual(w2.sheetNames, ['A', 'B']); + }); +}); diff --git a/guardian-service/tests/_handler-harness.mjs b/guardian-service/tests/_handler-harness.mjs new file mode 100644 index 0000000000..9329a26c0e --- /dev/null +++ b/guardian-service/tests/_handler-harness.mjs @@ -0,0 +1,322 @@ +// Shared NATS handler harness for guardian-service *.service.ts API functions. +// +// Guardian-service registers handlers via `ApiResponse(MessageAPI.X, cb)` / +// `ApiResponseSubscribe(...)` (see src/api/helpers/api-response.ts). Each service +// exports an `xxxAPI(logger)` function that, when invoked, calls ApiResponse once +// per handled event. This harness esmocks the service dist module so that: +// - the relative import of `helpers/api-response.js` resolves to a CAPTURING +// ApiResponse that records {event, cb} instead of touching real NATS, and +// - `@guardian/common` resolves to lightweight fakes (no Mongo/NATS/Hedera). +// `@guardian/interfaces` is kept REAL so MessageAPI enum values are the actual +// event strings the production code branches on. +// +// Usage: +// import { loadAPI, capturedHandlers, makeDb } from './_handler-harness.mjs'; +// const db = makeDb(); +// const handlers = await loadAPI('../dist/api/token.service.js', 'tokenAPI', { +// '@guardian/common': { DatabaseServer: db.DatabaseServer }, +// }); +// const r = await handlers['SET_TOKEN']({ ... }); // r.body / r.error + +import esmock from 'esmock'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import * as Interfaces from '@guardian/interfaces'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const distDir = path.resolve(__dirname, '../dist'); + +export const capturedHandlers = []; +export const capturedSubscribes = []; + +// Each service's `xxxAPI(logger)` registers handlers via `ApiResponse(event, cb)` +// (src/api/helpers/api-response.ts), which wraps cb in an ApplicationState check +// and forwards to NATS. We replace that whole module with a capturing stub passed +// as an esmock GLOBAL mock, so the raw handler is recorded with no state gate and +// no NATS — and crucially without esmock having to deep-rewrite @guardian/common. +// esmock's ESM cache lookup is broken by Windows backslashes in dist path-keys; +// a forward-slash absolute path key works (a file:// URL is rejected as invalid). +const apiResponsePath = path.join(distDir, 'api/helpers/api-response.js').split(path.sep).join('/'); + +function makeApiResponseStub() { + return { + ApiResponse(event, handleFunc) { capturedHandlers.push({ event, cb: handleFunc }); }, + ApiResponseSubscribe(event, handleFunc) { capturedSubscribes.push({ event, cb: handleFunc }); }, + }; +} + +export class MessageResponse { + constructor(body, code) { this.body = body; this.code = code ?? 200; this.type = 'response'; } +} +export class MessageError { + constructor(error, code) { + this.error = error instanceof Error ? error.message : error; + this.code = code ?? 500; + this.type = 'error'; + } +} +export class BinaryMessageResponse extends MessageResponse {} +export class ArrayMessageResponse { + constructor(body, count) { this.body = body; this.count = count; this.type = 'array'; } +} +export class MessageInitialization { constructor() { this.type = 'init'; } } + +/** + * Build a configurable in-memory DatabaseServer + DataBaseHelper pair plus a + * recording sink. Tests set `db.sink.findOne.` / `db.sink.find.` + * etc. to a value OR a function; created/saved/updated/removed rows and aggregate + * pipelines are recorded under `db.sink`. + * + * Arg convention for configured FUNCTIONS: + * - DataBaseHelper instance methods (entity is in the constructor): the function + * receives the call args verbatim, e.g. find(query) -> fn(query). + * - DatabaseServer instance methods (entity is the FIRST call arg): the entity is + * stripped and the function receives the REMAINING args, e.g. + * dataBaseServer.find(Token, query, options) -> fn(query, options). + * Either way the sink key is the entity's class name ('Token', 'User', ...). + */ +export function makeDb() { + const sink = { + findOne: {}, find: {}, findAndCount: {}, count: {}, aggregate: {}, + created: [], saved: [], updated: [], removed: [], aggregateCalls: [], + }; + const resolve = (table, name, args, fallback) => { + const v = sink[table][name]; + const r = typeof v === 'function' ? v(...args) : v; + return r === undefined ? fallback : r; + }; + class DataBaseHelper { + constructor(Entity, tenantId) { + this.name = Entity && Entity.name ? Entity.name : String(Entity); + this.tenantId = tenantId; + } + async findOne(...a) { return resolve('findOne', this.name, a, null); } + async find(...a) { return resolve('find', this.name, a, []); } + async findAndCount(...a) { return resolve('findAndCount', this.name, a, [[], 0]); } + async count(...a) { return resolve('count', this.name, a, 0); } + create(data) { const row = { id: 'gen-' + (sink.created.length + 1), ...data }; sink.created.push({ entity: this.name, row }); return row; } + async save(row) { sink.saved.push({ entity: this.name, row }); return row; } + async update(row) { sink.updated.push({ entity: this.name, row }); return row; } + async remove(row) { sink.removed.push({ entity: this.name, row }); } + async delete() {} + async aggregate(pipeline) { sink.aggregateCalls.push({ entity: this.name, pipeline }); return resolve('aggregate', this.name, [pipeline], []); } + } + // DatabaseServer exposes both instance methods and a large pile of statics; + // back them all by the same sink so either calling convention works. + class DatabaseServer { + constructor(tenantId) { this.tenantId = tenantId; } + async findOne(entity, ...a) { return resolve('findOne', entityName(entity), a, null); } + async find(entity, ...a) { return resolve('find', entityName(entity), a, []); } + async findAndCount(entity, ...a) { return resolve('findAndCount', entityName(entity), a, [[], 0]); } + async count(entity, ...a) { return resolve('count', entityName(entity), a, 0); } + create(entity, d) { const row = { id: 'gen-' + (sink.created.length + 1), ...(d || {}) }; sink.created.push({ entity: entityName(entity), row }); return row; } + async save(entity, d) { sink.saved.push({ entity: entityName(entity), row: d }); return d; } + async update(entity, _f, d) { sink.updated.push({ entity: entityName(entity), row: d }); return d; } + async remove(entity, d) { sink.removed.push({ entity: entityName(entity), row: d }); } + async aggregate(entity, pipeline) { sink.aggregateCalls.push({ entity: entityName(entity), pipeline }); return resolve('aggregate', entityName(entity), [pipeline], []); } + } + const entityName = (e) => (e && e.name ? e.name : String(e)); + // Mirror common statics that handlers reach for; default no-op returns. + const staticNoops = ['saveTopic', 'getTopic', 'savePolicy', 'getPolicy', 'getPolicyById', 'getSchemaById', 'getToken', 'getTokenById', 'createMongoRepository']; + for (const m of staticNoops) DatabaseServer[m] = async () => null; + return { DataBaseHelper, DatabaseServer, sink }; +} + +export class FakeWorkers { + constructor(ctx) { this.ctx = ctx; FakeWorkers.constructedWith.push(ctx); } + async addRetryableTask(task, opts) { FakeWorkers.tasks.push({ task, opts }); return FakeWorkers.nextResult; } + async addNonRetryableTask(task, opts) { FakeWorkers.tasks.push({ task, opts }); return FakeWorkers.nextResult; } +} +FakeWorkers.tasks = []; +FakeWorkers.constructedWith = []; +FakeWorkers.nextResult = { tokenId: '0.0.99', treasuryKey: 'tk', adminKey: 'ak' }; + +export class FakeWallet { + async getUserKey() { return 'fake-key'; } + async setUserKey() {} + async getKey() { return 'fake-key'; } + async setKey() {} +} + +export class FakeUsers { + async getUser() { return { id: 'u-1', did: 'did:hedera:0.0.1', hederaAccountId: '0.0.1' }; } + async getUserById() { return { id: 'u-1', did: 'did:hedera:0.0.1' }; } + async getHederaAccount() { return { hederaAccountId: '0.0.1', hederaAccountKey: 'key', did: 'did:hedera:0.0.1' }; } +} + +const proxyEnum = () => new Proxy({}, { get: (_, p) => `Enum.${String(p)}` }); + +function defaultCommonMocks(db) { + return { + MessageResponse, MessageError, BinaryMessageResponse, ArrayMessageResponse, MessageInitialization, + DataBaseHelper: db.DataBaseHelper, + DatabaseServer: db.DatabaseServer, + Workers: FakeWorkers, + Wallet: FakeWallet, + Users: FakeUsers, + MgsUsers: FakeUsers, + PinoLogger: class { static async error() {} async error() {} async info() {} async warn() {} async debug() {} }, + NewNotifier: async () => ({ start() {}, completed() {}, completedAndStart() {}, sendStatus() {}, finish() {}, result: () => ({}), createStep: () => ({ start() {}, complete() {} }), addStep() {} }), + NotificationHelper: { success: async () => {}, error: async () => {} }, + RunFunctionAsync: async (fn) => { try { await fn(() => {}); } catch (_) {} }, + KeyType: new Proxy({}, { get: (_, p) => String(p) }), + TopicType: new Proxy({}, { get: (_, p) => String(p) }), + extractTenantContext: (msg) => ({ tenantId: msg?.tenantId || null }), + ApplicationState: class { getState() { return Interfaces.ApplicationStates ? Interfaces.ApplicationStates.READY : 'READY'; } updateState() {} }, + Singleton: (t) => t, + SecretManager: { New: async () => ({ getSecrets: async () => null, setSecrets: async () => {} }) }, + IAuthUser: class {}, + }; +} + +/** + * Load a service dist module under esmock, invoke its API function to capture + * handlers, and return a map of { eventString: callback }. + * + * @param {string} distPath e.g. '../dist/api/token.service.js' (relative to a test file) + * @param {string} apiFnName e.g. 'tokenAPI' + * @param {object} overrides partial module overrides; '@guardian/common' is merged + * into the defaults, other keys replace wholesale. + * @param {Array} apiArgs extra args after logger for multi-arg API fns. + */ +export async function loadAPI(distPath, apiFnName, overrides = {}, apiArgs = []) { + const db = overrides.__db || makeDb(); + const commonDefaults = defaultCommonMocks(db); + const mergedCommon = { ...commonDefaults, ...(overrides['@guardian/common'] || {}) }; + const mergedInterfaces = { ...Interfaces, ...(overrides['@guardian/interfaces'] || {}) }; + // Local mocks: applied to the target module. Extra path-keyed overrides go here. + const local = { + '@guardian/common': mergedCommon, + '@guardian/interfaces': mergedInterfaces, + }; + for (const [k, v] of Object.entries(overrides)) { + if (k === '@guardian/common' || k === '@guardian/interfaces' || k === '__db') continue; + local[k] = v; + } + // Global mocks: applied across the import tree by resolved path. We only swap + // the tiny api-response.js module (capturing stub) — NOT all of common — so + // esmock stays fast. + const globals = { + [apiResponsePath]: makeApiResponseStub(), + }; + const absDist = path.resolve(__dirname, distPath); + const mod = await esmock(absDist, local, globals); + const start = capturedHandlers.length; + const logger = { error: async () => {}, info: async () => {}, warn: async () => {}, debug: async () => {} }; + await mod[apiFnName](logger, ...apiArgs); + const mine = capturedHandlers.slice(start); + const map = {}; + for (const { event, cb } of mine) map[event] = cb; + return { handlers: map, db, mod, raw: mine }; +} + +export { Interfaces }; + +// --------------------------------------------------------------------------- +// Real-module handler harness (register/callHandler/stub/...). +// +// A second family of *-handlers.test.mjs suites exercises the service API +// functions against the REAL @guardian/common (real DatabaseServer / DataBaseHelper +// statics, real MessageResponse/MessageError) instead of esmock fakes, then stubs +// the specific statics/prototypes each test needs. This avoids esmock when the +// test wants to drive behaviour through the genuine class it imports. +// +// register(apiFn, logger) runs the already-imported API function with NATS +// neutralised: GuardiansNatsService.prototype.registerListener / .subscribe are +// patched to CAPTURE {event, cb} (no broker), and ApplicationState.getState() is +// forced to READY so the api-response gate lets handlers through. +// --------------------------------------------------------------------------- +import * as Common from '@guardian/common'; +import { GuardiansNatsService } from '../dist/helpers/guardians.js'; + +export const common = Common; +export const DatabaseServer = Common.DatabaseServer; +export const DataBaseHelper = Common.DataBaseHelper; + +// ApiResponse wraps each handler in a state gate: the captured callback closes over +// a `new ApplicationState()` and returns MessageInitialization unless getState() is +// READY. That gate is evaluated at call time (in callHandler), so the override must +// be permanent for these suites rather than scoped to register(). The esmock-based +// suites replace ApplicationState wholesale, so patching the real prototype here is +// inert for them. +const _READY = Interfaces.ApplicationStates ? Interfaces.ApplicationStates.READY : 'READY'; +Common.ApplicationState.prototype.getState = function () { return _READY; }; + +export function silentLogger() { + return { error: async () => {}, info: async () => {}, warn: async () => {}, debug: async () => {} }; +} + +// Treat any non-error MessageResponse-shaped result as ok; MessageError sets `error`. +export function isError(r) { + return !!(r && r.type === 'error') || !!(r && r.error != null && r.body == null); +} +export function ok(r) { + return !!r && !isError(r) && r.type !== 'init'; +} + +const _stubs = []; +/** Replace own property obj[name] with fn; record original for restoreStubs(). */ +export function stub(obj, name, fn) { + const had = Object.prototype.hasOwnProperty.call(obj, name); + _stubs.push({ obj, name, had, orig: obj[name] }); + obj[name] = fn; + return fn; +} +/** Stub a method on a class's prototype. */ +export function stubProto(Class, name, fn) { + return stub(Class.prototype, name, fn); +} +/** Restore everything installed via stub()/stubProto(). */ +export function restoreStubs() { + while (_stubs.length) { + const { obj, name, had, orig } = _stubs.pop(); + if (had) obj[name] = orig; + else delete obj[name]; + } +} + +// DataBaseHelper's constructor throws unless DataBaseHelper.orm is set. Tests that +// stub the prototype methods only need the constructor to pass, so install a minimal +// fake ORM (the stubbed methods never touch `_em`). +export function ensureOrm() { + if (!DataBaseHelper.orm) { + DataBaseHelper.orm = { em: { fork: () => ({}), getRepository: () => ({}) } }; + } + return DataBaseHelper.orm; +} + +/** + * Run a service API function and capture its handlers without touching NATS. + * @param {Function} apiFn e.g. analyticsAPI (imported from dist) + * @param {object} logger optional; defaults to silentLogger() + * @param {...any} apiArgs extra args after logger + * @returns {object} map of { eventString: cb } + */ +export async function register(apiFn, logger = silentLogger(), ...apiArgs) { + const captured = []; + const proto = GuardiansNatsService.prototype; + const origRL = proto.registerListener; + const hadSub = Object.prototype.hasOwnProperty.call(proto, 'subscribe'); + const origSub = proto.subscribe; + proto.registerListener = function (event, cb) { captured.push({ event, cb }); }; + proto.subscribe = function (event, cb) { captured.push({ event, cb }); }; + try { + await apiFn(logger, ...apiArgs); + } finally { + proto.registerListener = origRL; + if (hadSub) proto.subscribe = origSub; else delete proto.subscribe; + } + const map = {}; + for (const { event, cb } of captured) map[event] = cb; + return map; +} + +/** Invoke a captured handler by event with the given message payload. */ +export function callHandler(handlers, event, msg) { + const cb = handlers[event]; + if (typeof cb !== 'function') { + throw new Error(`No handler registered for event: ${String(event)}`); + } + return cb(msg); +} diff --git a/guardian-service/tests/unit/analytics-artifact-token-models.test.mjs b/guardian-service/tests/unit/analytics-artifact-token-models.test.mjs new file mode 100644 index 0000000000..56dfd91007 --- /dev/null +++ b/guardian-service/tests/unit/analytics-artifact-token-models.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { ArtifactModel } from '../../dist/analytics/compare/models/artifact.model.js'; +import { TokenModel } from '../../dist/analytics/compare/models/token.model.js'; + +const propAll = { propLvl: 'All' }; +const propSimple = { propLvl: 'Simple' }; +const idAll = { idLvl: 'All' }; +const idNone = { idLvl: 'None' }; + +describe('ArtifactModel', () => { + const json = { + name: 'logo.png', + uuid: 'u-1', + type: 'image', + extention: 'png', + }; + + it('captures name/uuid/type/extension from raw json', () => { + const a = new ArtifactModel(json); + assert.equal(a.name, 'logo.png'); + assert.equal(a.uuid, 'u-1'); + assert.equal(a.type, 'image'); + assert.equal(a.extension, 'png'); + }); + + it('sets weight to a hashed string under propLvl=All', () => { + const a = new ArtifactModel(json); + a.update('file-data', propAll); + assert.ok(typeof a.weight === 'string' && a.weight.length > 0); + }); + + it('sets weight to "" under propLvl != All', () => { + const a = new ArtifactModel(json); + a.update('file-data', propSimple); + assert.equal(a.weight, ''); + }); + + it('equal() compares the underlying hash, not the weight', () => { + const a = new ArtifactModel(json); + const b = new ArtifactModel(json); + a.update('same-data', propSimple); // weight = "" + b.update('same-data', propAll); // weight = hash + // Different propLvl, but same underlying _hash → still equal. + assert.equal(a.equal(b), true); + }); + + it('equal() returns false for different file data', () => { + const a = new ArtifactModel(json); + const b = new ArtifactModel(json); + a.update('one', propAll); + b.update('two', propAll); + assert.equal(a.equal(b), false); + }); + + it('toObject() returns the canonical shape including weight', () => { + const a = new ArtifactModel(json); + a.update('xyz', propAll); + const obj = a.toObject(); + assert.equal(obj.uuid, 'u-1'); + assert.equal(obj.name, 'logo.png'); + assert.equal(obj.type, 'image'); + assert.equal(obj.extension, 'png'); + assert.equal(obj.weight, a.weight); + }); + + it('toWeight() exposes the hash regardless of propLvl', () => { + const a = new ArtifactModel(json); + a.update('xyz', propSimple); + const w = a.toWeight(propSimple); + assert.ok(typeof w.weight === 'string' && w.weight.length > 0); + }); + + it('key getter is null (artifacts have no key)', () => { + const a = new ArtifactModel(json); + assert.equal(a.key, null); + }); + + it('equalKey() compares the (null) keys', () => { + const a = new ArtifactModel(json); + const b = new ArtifactModel({ ...json, uuid: 'u-2' }); + assert.equal(a.equalKey(b), true); + }); +}); + +describe('TokenModel construction', () => { + const raw = { + id: 't-internal', + tokenId: '0.0.1', + tokenName: 'Demo', + tokenSymbol: 'DM', + tokenType: 'fungible', + decimals: 2, + initialSupply: 100, + enableAdmin: true, + enableFreeze: false, + enableKYC: false, + enableWipe: true, + }; + + it('captures every documented property from raw JSON', () => { + const t = new TokenModel(raw, idAll); + assert.equal(t.id, 't-internal'); + assert.equal(t.tokenId, '0.0.1'); + assert.equal(t.tokenName, 'Demo'); + assert.equal(t.tokenSymbol, 'DM'); + assert.equal(t.tokenType, 'fungible'); + assert.equal(t.decimals, 2); + assert.equal(t.initialSupply, 100); + assert.equal(t.enableAdmin, true); + assert.equal(t.enableFreeze, false); + assert.equal(t.enableKYC, false); + assert.equal(t.enableWipe, true); + }); + + it('computes a hash on construction (already updated)', () => { + const t = new TokenModel(raw, idAll); + assert.ok(typeof t.hash() === 'string' && t.hash().length > 0); + }); +}); + +describe('TokenModel.equal / equalKey', () => { + const raw = (overrides = {}) => ({ + id: 'a', tokenId: '0.0.1', tokenName: 'D', tokenSymbol: 'DM', + tokenType: 'fungible', decimals: 2, initialSupply: 100, + enableAdmin: false, enableFreeze: false, enableKYC: false, enableWipe: false, + ...overrides, + }); + + it('returns true for two tokens with the same tokenId', () => { + const a = new TokenModel(raw(), idAll); + const b = new TokenModel(raw({ tokenName: 'Different' }), idAll); + assert.equal(a.equal(b), true); + assert.equal(a.equalKey(b), true); + }); + + it('returns false for tokens with different tokenIds', () => { + const a = new TokenModel(raw({ tokenId: '0.0.1' }), idAll); + const b = new TokenModel(raw({ tokenId: '0.0.2' }), idAll); + assert.equal(a.equal(b), false); + assert.equal(a.equalKey(b), false); + }); +}); + +describe('TokenModel.update + idLvl', () => { + const baseRaw = { + id: 'a', tokenId: '0.0.1', tokenName: 'D', tokenSymbol: 'DM', + tokenType: 'fungible', decimals: 2, initialSupply: 100, + enableAdmin: false, enableFreeze: false, enableKYC: false, enableWipe: false, + }; + + it('produces the same weight for tokens differing only in tokenId when idLvl=None', () => { + const a = new TokenModel({ ...baseRaw, tokenId: '0.0.1' }, idNone); + const b = new TokenModel({ ...baseRaw, tokenId: '0.0.2' }, idNone); + a.update(idNone); + b.update(idNone); + assert.equal(a.hash(), b.hash()); + }); + + it('produces different weights for tokens differing in tokenId when idLvl=All', () => { + const a = new TokenModel({ ...baseRaw, tokenId: '0.0.1' }, idAll); + const b = new TokenModel({ ...baseRaw, tokenId: '0.0.2' }, idAll); + a.update(idAll); + b.update(idAll); + assert.notEqual(a.hash(), b.hash()); + }); + + it('produces different weights when token props differ', () => { + const a = new TokenModel({ ...baseRaw, tokenName: 'A' }, idAll); + const b = new TokenModel({ ...baseRaw, tokenName: 'B' }, idAll); + assert.notEqual(a.hash(), b.hash()); + }); +}); + +describe('TokenModel.toObject / toWeight', () => { + const raw = { + id: 'a', tokenId: '0.0.1', tokenName: 'D', tokenSymbol: 'DM', + tokenType: 'fungible', decimals: 2, initialSupply: 100, + enableAdmin: false, enableFreeze: false, enableKYC: false, enableWipe: false, + }; + + it('toObject includes every documented field', () => { + const t = new TokenModel(raw, idAll); + const obj = t.toObject(); + for (const k of [ + 'id', 'tokenId', 'tokenName', 'tokenSymbol', 'tokenType', + 'decimals', 'initialSupply', 'enableAdmin', 'enableFreeze', + 'enableKYC', 'enableWipe', + ]) { + assert.ok(k in obj, `toObject missing ${k}`); + } + }); + + it('toWeight returns the hash when computed', () => { + const t = new TokenModel(raw, idAll); + assert.equal(t.toWeight(idAll).weight, t.hash()); + }); +}); + +describe('TokenModel.fromEntity', () => { + it('creates an updated TokenModel for valid raw input', () => { + const t = TokenModel.fromEntity( + { id: 'a', tokenId: '0.0.42', tokenName: 'Z', tokenSymbol: 'Z', tokenType: 'fungible', decimals: 0, initialSupply: 0, enableAdmin: false, enableFreeze: false, enableKYC: false, enableWipe: false }, + idAll, + ); + assert.equal(t.tokenId, '0.0.42'); + assert.ok(t.hash().length > 0); + }); + + it('throws "Unknown token" when raw is missing', () => { + assert.throws(() => TokenModel.fromEntity(null, idAll), /Unknown token/); + assert.throws(() => TokenModel.fromEntity(undefined, idAll), /Unknown token/); + }); +}); diff --git a/guardian-service/tests/unit/analytics-block-model.test.mjs b/guardian-service/tests/unit/analytics-block-model.test.mjs new file mode 100644 index 0000000000..b591fb0970 --- /dev/null +++ b/guardian-service/tests/unit/analytics-block-model.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { BlockModel } from '../../dist/analytics/compare/models/index.js'; + +const raw = (overrides = {}) => ({ + blockType: 'policyRolesBlock', + tag: 'roles', + events: [], + artifacts: [], + ...overrides +}); + +describe('BlockModel', () => { + it('captures blockType, tag and index', () => { + const b = new BlockModel(raw(), 3); + assert.equal(b.blockType, 'policyRolesBlock'); + assert.equal(b.tag, 'roles'); + assert.equal(b.index, 3); + }); + + it('key getter returns the block type', () => { + assert.equal(new BlockModel(raw(), 0).key, 'policyRolesBlock'); + }); + + it('equalKey compares by block type', () => { + const a = new BlockModel(raw(), 0); + const same = new BlockModel(raw(), 1); + const other = new BlockModel(raw({ blockType: 'interfaceActionBlock' }), 2); + assert.equal(a.equalKey(same), true); + assert.equal(a.equalKey(other), false); + }); + + it('starts with no children and appends via addChildren', () => { + const b = new BlockModel(raw(), 0); + assert.deepEqual(b.children, []); + const child = new BlockModel(raw({ tag: 'child' }), 1); + b.addChildren(child); + assert.equal(b.children.length, 1); + assert.equal(b.children[0], child); + }); + + it('builds an event list from json.events', () => { + const b = new BlockModel(raw({ events: [{ source: 'a' }, { source: 'b' }] }), 0); + assert.equal(b.getEventList().length, 2); + }); + + it('returns an empty event list when events is absent', () => { + const b = new BlockModel(raw({ events: undefined }), 0); + assert.deepEqual(b.getEventList(), []); + }); + + it('builds an artifact list from json.artifacts', () => { + const b = new BlockModel(raw({ artifacts: [{ uuid: 'a1' }] }), 0); + assert.equal(b.getArtifactsList().length, 1); + }); + + it('returns an empty artifact list when artifacts is absent', () => { + const b = new BlockModel(raw({ artifacts: undefined }), 0); + assert.deepEqual(b.getArtifactsList(), []); + }); + + it('getPropList and getPermissionsList return arrays', () => { + const b = new BlockModel(raw(), 0); + assert.ok(Array.isArray(b.getPropList())); + assert.ok(Array.isArray(b.getPermissionsList())); + }); + + it('starts with empty weights and a zero max weight', () => { + const b = new BlockModel(raw(), 0); + assert.deepEqual(b.getWeights(), []); + assert.equal(b.maxWeight(), 0); + assert.equal(b.getWeight(), undefined); + }); + + it('toObject exposes index/blockType/tag plus properties and events', () => { + const b = new BlockModel(raw({ events: [{ source: 'a' }] }), 5); + const obj = b.toObject(); + assert.equal(obj.index, 5); + assert.equal(obj.blockType, 'policyRolesBlock'); + assert.equal(obj.tag, 'roles'); + assert.ok('properties' in obj); + assert.equal(obj.events.length, 1); + }); +}); diff --git a/guardian-service/tests/unit/analytics-block-tool-model.test.mjs b/guardian-service/tests/unit/analytics-block-tool-model.test.mjs new file mode 100644 index 0000000000..d4d97516ed --- /dev/null +++ b/guardian-service/tests/unit/analytics-block-tool-model.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { BlockToolModel, BlockModel } from '../../dist/analytics/compare/models/index.js'; + +const options = (over = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All', childLvl: 'All', ...over }); + +const raw = (over = {}) => ({ + blockType: 'tool', + tag: 'tool-tag', + hash: 'hash-1', + messageId: 'msg-1', + events: [], + artifacts: [], + ...over +}); + +describe('BlockToolModel', () => { + it('is a subclass of BlockModel', () => { + assert.ok(new BlockToolModel(raw(), 0) instanceof BlockModel); + }); + + it('captures blockType, tag and index from json', () => { + const b = new BlockToolModel(raw(), 5); + assert.equal(b.blockType, 'tool'); + assert.equal(b.tag, 'tool-tag'); + assert.equal(b.index, 5); + }); + + it('captures hash and messageId from json', () => { + const b = new BlockToolModel(raw({ hash: 'abc', messageId: '0.0.99' }), 0); + assert.equal(b.hash, 'abc'); + assert.equal(b.messageId, '0.0.99'); + }); + + it('key combines blockType and hash', () => { + const b = new BlockToolModel(raw({ blockType: 'tool', hash: 'XYZ' }), 0); + assert.equal(b.key, 'tool:XYZ'); + }); + + it('key differs when hash differs', () => { + const a = new BlockToolModel(raw({ hash: 'h1' }), 0); + const b = new BlockToolModel(raw({ hash: 'h2' }), 0); + assert.notEqual(a.key, b.key); + }); + + it('children getter is always an empty array', () => { + const b = new BlockToolModel(raw(), 0); + assert.deepEqual(b.children, []); + }); + + it('children stays empty even when json has children', () => { + const b = new BlockToolModel(raw({ children: [{ blockType: 'x', hash: 'h', messageId: 'm', events: [], artifacts: [] }] }), 0); + assert.deepEqual(b.children, []); + }); + + it('equalKey compares by composite key', () => { + const a = new BlockToolModel(raw({ hash: 'same' }), 0); + const same = new BlockToolModel(raw({ hash: 'same' }), 1); + const other = new BlockToolModel(raw({ hash: 'diff' }), 2); + assert.equal(a.equalKey(same), true); + assert.equal(a.equalKey(other), false); + }); + + it('getWeight returns undefined before update', () => { + const b = new BlockToolModel(raw(), 0); + assert.equal(b.getWeight('PROP_LVL_1'), undefined); + }); + + it('maxWeight is zero before update', () => { + const b = new BlockToolModel(raw(), 0); + assert.equal(b.maxWeight(), 0); + }); + + it('update populates weights and a deterministic hash', () => { + const b = new BlockToolModel(raw(), 0); + b.update(options()); + assert.ok(b.maxWeight() > 0); + assert.equal(typeof b.getWeight(), 'string'); + }); + + it('two identical tool blocks produce the same hash after update', () => { + const a = new BlockToolModel(raw(), 0); + const b = new BlockToolModel(raw(), 0); + a.update(options()); + b.update(options()); + assert.equal(a.getWeight(), b.getWeight()); + }); + + it('different tags produce different hashes after update', () => { + const a = new BlockToolModel(raw({ tag: 'one' }), 0); + const b = new BlockToolModel(raw({ tag: 'two' }), 0); + a.update(options()); + b.update(options()); + assert.notEqual(a.getWeight(), b.getWeight()); + }); + + it('equal compares full hash after update for identical blocks', () => { + const a = new BlockToolModel(raw(), 0); + const b = new BlockToolModel(raw(), 0); + a.update(options()); + b.update(options()); + assert.equal(a.equal(b), true); + }); + + it('equal is false when keys differ', () => { + const a = new BlockToolModel(raw({ hash: 'a' }), 0); + const b = new BlockToolModel(raw({ hash: 'b' }), 0); + a.update(options()); + b.update(options()); + assert.equal(a.equal(b), false); + }); + + it('checkWeight is true for index 0 after update', () => { + const b = new BlockToolModel(raw(), 0); + b.update(options()); + assert.equal(b.checkWeight(0), true); + }); + + it('getWeights returns an array after update', () => { + const b = new BlockToolModel(raw(), 0); + b.update(options()); + assert.ok(Array.isArray(b.getWeights())); + }); +}); diff --git a/guardian-service/tests/unit/analytics-blocks-record-rates.test.mjs b/guardian-service/tests/unit/analytics-blocks-record-rates.test.mjs new file mode 100644 index 0000000000..25e5899c25 --- /dev/null +++ b/guardian-service/tests/unit/analytics-blocks-record-rates.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { BlocksRate } from '../../dist/analytics/compare/rates/blocks-rate.js'; +import { RecordRate } from '../../dist/analytics/compare/rates/record-rate.js'; + +const opts = (overrides = {}) => ({ + propLvl: 'All', + keyLvl: 'Default', + idLvl: 'All', + eventLvl: 'All', + ...overrides, +}); + +const prop = (name, path, value) => ({ + name, + path, + lvl: 1, + value, + equal(other) { return this.value === other.value; }, + ignore() { return false; }, + getPropList() { return []; }, + toObject() { return { name, path, value }; }, +}); + +const event = (label) => ({ + label, + equal(other) { return this.label === other.label; }, + toObject() { return { label }; }, +}); + +const artifact = (uuid) => ({ + uuid, + equal(other) { return this.uuid === other.uuid; }, + toObject() { return { uuid }; }, +}); + +const fakeBlock = (overrides = {}) => ({ + blockType: 'tool', + tag: 'demo', + index: 0, + props: overrides.props || [], + events: overrides.events || [], + permissions: overrides.permissions || [], + artifacts: overrides.artifacts || [], + getPropList() { return this.props; }, + getEventList() { return this.events; }, + getPermissionsList() { return this.permissions; }, + getArtifactsList() { return this.artifacts; }, + ...overrides, +}); + +describe('BlocksRate construction', () => { + it('captures blockType from the left side', () => { + const a = fakeBlock({ blockType: 'tool', tag: 'a' }); + const b = fakeBlock({ blockType: 'module', tag: 'b' }); + const r = new BlocksRate(a, b); + assert.equal(r.blockType, 'tool'); + }); + + it('falls back to the right blockType when left is missing', () => { + const b = fakeBlock({ blockType: 'module', tag: 'b' }); + const r = new BlocksRate(null, b); + assert.equal(r.blockType, 'module'); + }); + + it('throws "Empty block model" when both sides are null', () => { + assert.throws(() => new BlocksRate(null, null), /Empty block model/); + }); + + it('all rate fields start at -1', () => { + const r = new BlocksRate(fakeBlock(), fakeBlock()); + assert.equal(r.indexRate, -1); + assert.equal(r.propertiesRate, -1); + assert.equal(r.eventsRate, -1); + assert.equal(r.permissionsRate, -1); + assert.equal(r.artifactsRate, -1); + }); +}); + +describe('BlocksRate.calc — both blocks', () => { + it('100% rate when blocks share index, props, perms, events, artifacts', () => { + const a = fakeBlock({ + index: 1, + tag: 'A', + props: [prop('p', 'p', 1)], + events: [event('e1')], + permissions: ['OWNER'], + artifacts: [artifact('u1')], + }); + const b = fakeBlock({ + index: 1, + tag: 'A', + props: [prop('p', 'p', 1)], + events: [event('e1')], + permissions: ['OWNER'], + artifacts: [artifact('u1')], + }); + const r = new BlocksRate(a, b); + r.calc(opts()); + assert.equal(r.indexRate, 100); + assert.equal(r.propertiesRate, 100); + assert.equal(r.totalRate, 100); + }); + + it('indexRate=0 when block indexes differ', () => { + const a = fakeBlock({ index: 1 }); + const b = fakeBlock({ index: 2 }); + const r = new BlocksRate(a, b); + r.calc(opts()); + assert.equal(r.indexRate, 0); + }); + + it('skips properties/permissions/artifacts when propLvl=None', () => { + const a = fakeBlock({ index: 1, props: [prop('p', 'p', 1)] }); + const b = fakeBlock({ index: 1, props: [prop('p', 'p', 2)] }); + const r = new BlocksRate(a, b); + r.calc(opts({ propLvl: 'None' })); + // total includes only events at this point; with no events the rate folds to 100. + // Either way: propertiesRate is computed but not folded into totalRate. + assert.notEqual(r.totalRate, r.propertiesRate); + }); + + it('does not throw when only one side is present (returns early)', () => { + const r = new BlocksRate(fakeBlock(), null); + r.calc(opts()); + assert.equal(r.indexRate, -1); + }); +}); + +describe('BlocksRate.getSubRate / getRateValue', () => { + it('getSubRate returns the named array', () => { + const a = fakeBlock(); + const b = fakeBlock(); + const r = new BlocksRate(a, b); + r.calc(opts()); + assert.equal(r.getSubRate('properties'), r.properties); + assert.equal(r.getSubRate('events'), r.events); + assert.equal(r.getSubRate('permissions'), r.permissions); + assert.equal(r.getSubRate('artifacts'), r.artifacts); + assert.equal(r.getSubRate('unknown'), null); + }); + + it('getRateValue returns the named scalar rate', () => { + const a = fakeBlock({ index: 1 }); + const b = fakeBlock({ index: 1 }); + const r = new BlocksRate(a, b); + r.calc(opts()); + assert.equal(r.getRateValue('index'), r.indexRate); + assert.equal(r.getRateValue('properties'), r.propertiesRate); + assert.equal(r.getRateValue('events'), r.eventsRate); + assert.equal(r.getRateValue('permissions'), r.permissionsRate); + assert.equal(r.getRateValue('artifacts'), r.artifactsRate); + assert.equal(r.getRateValue('total'), r.totalRate); + }); +}); + +describe('BlocksRate.setChildren / getChildren', () => { + it('roundtrips children', () => { + const r = new BlocksRate(fakeBlock(), fakeBlock()); + const kids = [{ totalRate: 80 }]; + r.setChildren(kids); + assert.equal(r.getChildren(), kids); + }); +}); + +describe('RecordRate', () => { + it('starts with totalRate=-1 (default Rate)', () => { + const r = new RecordRate({}, {}); + assert.equal(r.totalRate, -1); + }); + + it('calc() sets totalRate=100 unconditionally', () => { + const r = new RecordRate({}, {}); + r.calc(opts()); + assert.equal(r.totalRate, 100); + }); + + it('roundtrips children via setChildren/getChildren', () => { + const r = new RecordRate({}, {}); + const kids = [{ totalRate: 90 }]; + r.setChildren(kids); + assert.equal(r.getChildren(), kids); + }); + + it('getSubRate(any) returns null', () => { + const r = new RecordRate({}, {}); + assert.equal(r.getSubRate('x'), null); + }); + + it('getRateValue(any) returns totalRate', () => { + const r = new RecordRate({}, {}); + r.calc(opts()); + assert.equal(r.getRateValue('whatever'), 100); + }); +}); diff --git a/guardian-service/tests/unit/analytics-comparator-csv-outputs.test.mjs b/guardian-service/tests/unit/analytics-comparator-csv-outputs.test.mjs new file mode 100644 index 0000000000..46f655b832 --- /dev/null +++ b/guardian-service/tests/unit/analytics-comparator-csv-outputs.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { ModuleComparator } from '../../dist/analytics/compare/comparators/module-comparator.js'; +import { ToolComparator } from '../../dist/analytics/compare/comparators/tool-comparator.js'; +import { PolicyComparator } from '../../dist/analytics/compare/comparators/policy-comparator.js'; +import { ModuleModel } from '../../dist/analytics/compare/models/module.model.js'; +import { ToolModel } from '../../dist/analytics/compare/models/tool.model.js'; +import { PolicyModel } from '../../dist/analytics/compare/models/policy.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const moduleModel = (over = {}) => new ModuleModel({ + id: 'mod-1', name: 'My Module', description: 'desc', + config: { blockType: 'root', tag: 'root', children: [], inputEvents: [], outputEvents: [], variables: [] }, + ...over +}, opts); + +const toolModel = (over = {}) => new ToolModel({ + id: 't-1', name: 'Tool', description: 'desc', hash: 'h-1', messageId: 'm-1', + config: { blockType: 'tool', tag: 'tool-root', children: [], inputEvents: [], outputEvents: [], variables: [] }, + ...over +}, opts); + +const policyModel = (over = {}) => new PolicyModel({ + id: 'p-1', name: 'Policy', description: 'desc', version: '1.0.0', + policyRoles: [], policyGroups: [], policyTopics: [], policyTokens: [], policyTools: [], + config: { blockType: 'root', tag: 'root', children: [], permissions: [] }, + ...over +}, opts); + +describe('ModuleComparator.csv', () => { + it('produces a CSV data-uri from a compare result', () => { + const result = new ModuleComparator().compare(moduleModel(), moduleModel()); + const csv = new ModuleComparator().csv(result); + assert.match(csv, /^data:text\/csv/); + }); + + it('includes the Module 1 and Module 2 section headers', () => { + const result = new ModuleComparator().compare(moduleModel(), moduleModel()); + const csv = new ModuleComparator().csv(result); + assert.ok(csv.includes('Module 1')); + assert.ok(csv.includes('Module 2')); + }); + + it('includes the Total row', () => { + const result = new ModuleComparator().compare(moduleModel(), moduleModel()); + const csv = new ModuleComparator().csv(result); + assert.ok(csv.includes('Total')); + }); + + it('includes the block and events sections', () => { + const result = new ModuleComparator().compare(moduleModel(), moduleModel()); + const csv = new ModuleComparator().csv(result); + assert.ok(csv.includes('Module Blocks')); + assert.ok(csv.includes('Module Input Events')); + assert.ok(csv.includes('Module Output Events')); + assert.ok(csv.includes('Module Variables')); + }); +}); + +describe('ToolComparator.tableToCsv', () => { + it('produces a CSV data-uri', () => { + const results = new ToolComparator().compare([toolModel(), toolModel()]); + assert.match(ToolComparator.tableToCsv(results), /^data:text\/csv/); + }); + + it('includes Tool section headers and Total', () => { + const results = new ToolComparator().compare([toolModel(), toolModel()]); + const csv = ToolComparator.tableToCsv(results); + assert.ok(csv.includes('Tool 1')); + assert.ok(csv.includes('Tool Blocks')); + assert.ok(csv.includes('Total')); + }); + + it('renders the additional tool header for three tools', () => { + const results = new ToolComparator().compare([toolModel(), toolModel(), toolModel()]); + const csv = ToolComparator.tableToCsv(results); + assert.ok(csv.includes('Tool 2')); + assert.ok(csv.includes('Tool 3')); + }); +}); + +describe('ToolComparator.mergeCompareResults', () => { + it('aggregates multiple compare results into a single object', () => { + const results = new ToolComparator().compare([toolModel(), toolModel(), toolModel()]); + const merged = ToolComparator.mergeCompareResults(results); + assert.ok(merged); + assert.ok(Array.isArray(merged.blocks.report)); + }); +}); + +describe('PolicyComparator.to', () => { + it('returns the single result object for csv=false and one result', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policyModel(), policyModel()]); + const out = comparator.to(results, 'object'); + assert.equal(out, results[0]); + }); + + it('returns a CSV data-uri for type csv with one result', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policyModel(), policyModel()]); + assert.match(comparator.to(results, 'csv'), /^data:text\/csv/); + }); + + it('returns a CSV data-uri for type csv with multiple results', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policyModel(), policyModel(), policyModel()]); + assert.match(comparator.to(results, 'csv'), /^data:text\/csv/); + }); + + it('merges results for non-csv type with multiple results', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policyModel(), policyModel(), policyModel()]); + const out = comparator.to(results, 'object'); + assert.ok(Array.isArray(out.blocks.report)); + }); + + it('throws on an empty results array', () => { + const comparator = new PolicyComparator(); + assert.throws(() => comparator.to([], 'csv'), /Invalid size/); + }); +}); diff --git a/guardian-service/tests/unit/analytics-compare-options.test.mjs b/guardian-service/tests/unit/analytics-compare-options.test.mjs new file mode 100644 index 0000000000..284df212bb --- /dev/null +++ b/guardian-service/tests/unit/analytics-compare-options.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { + CompareOptions, + IPropertiesLvl, + IChildrenLvl, + IEventsLvl, + IIdLvl, + IKeyLvl, + IRefLvl, +} from '../../dist/analytics/compare/interfaces/compare-options.interface.js'; + +describe('CompareOptions enums', () => { + it('exposes the documented PropertiesLvl values', () => { + assert.equal(IPropertiesLvl.None, 'None'); + assert.equal(IPropertiesLvl.Simple, 'Simple'); + assert.equal(IPropertiesLvl.All, 'All'); + }); + + it('exposes the documented ChildrenLvl values', () => { + assert.equal(IChildrenLvl.None, 'None'); + assert.equal(IChildrenLvl.First, 'First'); + assert.equal(IChildrenLvl.All, 'All'); + }); + + it('exposes the documented EventsLvl values', () => { + assert.equal(IEventsLvl.None, 'None'); + assert.equal(IEventsLvl.Simple, 'Simple'); + assert.equal(IEventsLvl.All, 'All'); + }); + + it('exposes the documented IdLvl values', () => { + assert.equal(IIdLvl.None, 'None'); + assert.equal(IIdLvl.All, 'All'); + }); + + it('exposes the documented KeyLvl values', () => { + assert.equal(IKeyLvl.Default, 'Default'); + assert.equal(IKeyLvl.Description, 'Description'); + assert.equal(IKeyLvl.Title, 'Title'); + assert.equal(IKeyLvl.Property, 'Property'); + }); + + it('exposes the documented RefLvl values', () => { + assert.equal(IRefLvl.Default, 'Default'); + assert.equal(IRefLvl.None, 'None'); + assert.equal(IRefLvl.Revert, 'Revert'); + assert.equal(IRefLvl.Direct, 'Direct'); + assert.equal(IRefLvl.Merge, 'Merge'); + }); +}); + +describe('CompareOptions constructor — coerces string-named enums', () => { + it('accepts the named string values for each axis', () => { + const o = new CompareOptions('Simple', 'First', 'Simple', 'None', 'Title', 'Direct', 'me'); + assert.equal(o.propLvl, 'Simple'); + assert.equal(o.childLvl, 'First'); + assert.equal(o.eventLvl, 'Simple'); + assert.equal(o.idLvl, 'None'); + assert.equal(o.keyLvl, 'Title'); + assert.equal(o.refLvl, 'Direct'); + assert.equal(o.owner, 'me'); + }); +}); + +describe('CompareOptions constructor — coerces numeric and string-numeric values', () => { + it('accepts 0/1/2 numbers for propLvl/childLvl/eventLvl', () => { + const o = new CompareOptions(0, 0, 0, 0, 0, 0, null); + assert.equal(o.propLvl, 'None'); + assert.equal(o.childLvl, 'None'); + assert.equal(o.eventLvl, 'None'); + assert.equal(o.idLvl, 'None'); + assert.equal(o.keyLvl, 'Default'); + assert.equal(o.refLvl, 'Default'); + }); + + it('accepts string-numeric values "1" / "2" / "3" / "4"', () => { + const o = new CompareOptions('1', '1', '1', '1', '3', '4', null); + assert.equal(o.propLvl, 'Simple'); + assert.equal(o.childLvl, 'First'); + assert.equal(o.eventLvl, 'Simple'); + assert.equal(o.idLvl, 'All'); + assert.equal(o.keyLvl, 'Property'); + assert.equal(o.refLvl, 'Direct'); + }); +}); + +describe('CompareOptions constructor — defaults for unrecognised input', () => { + it('defaults propLvl/childLvl/eventLvl to All when invalid', () => { + const o = new CompareOptions('garbage', 'garbage', 'garbage', 'garbage', 'garbage', 'garbage', null); + assert.equal(o.propLvl, 'All'); + assert.equal(o.childLvl, 'All'); + assert.equal(o.eventLvl, 'All'); + assert.equal(o.idLvl, 'All'); + assert.equal(o.keyLvl, 'Default'); + assert.equal(o.refLvl, 'Default'); + }); +}); + +describe('CompareOptions.default + .from', () => { + it('exposes a fully-defaulted singleton', () => { + const d = CompareOptions.default; + assert.ok(d instanceof CompareOptions); + assert.equal(d.propLvl, 'All'); + assert.equal(d.idLvl, 'All'); + }); + + it('.from(opts, owner) builds an instance and wires up keyLvl/idLvl alphas', () => { + const o = CompareOptions.from({ + propLvl: 'Simple', + childrenLvl: 'First', + eventsLvl: 'None', + idLvl: 'None', + keyLvl: 'Description', + refLvl: 'Revert', + }, 'owner-id'); + assert.equal(o.propLvl, 'Simple'); + assert.equal(o.childLvl, 'First'); + assert.equal(o.eventLvl, 'None'); + assert.equal(o.idLvl, 'None'); + assert.equal(o.keyLvl, 'Description'); + assert.equal(o.refLvl, 'Revert'); + assert.equal(o.owner, 'owner-id'); + }); + + it('.from accepts an empty object and uses defaults', () => { + const o = CompareOptions.from({}); + assert.equal(o.propLvl, 'All'); + assert.equal(o.idLvl, 'All'); + }); +}); diff --git a/guardian-service/tests/unit/analytics-compare-policy-utils.test.mjs b/guardian-service/tests/unit/analytics-compare-policy-utils.test.mjs new file mode 100644 index 0000000000..500c6d1f92 --- /dev/null +++ b/guardian-service/tests/unit/analytics-compare-policy-utils.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { ComparePolicyUtils } from '../../dist/analytics/compare/utils/compare-policy-utils.js'; + +const stubRate = (label, kids = []) => ({ + label, + totalRate: 0, + children: kids, + setChildren(c) { this.children = c; }, + getChildren() { return this.children; }, +}); + +describe('ComparePolicyUtils.treeToArray', () => { + it('flattens a rate tree depth-first', () => { + const tree = stubRate('root', [ + stubRate('a', [stubRate('a-1'), stubRate('a-2')]), + stubRate('b'), + ]); + const out = ComparePolicyUtils.treeToArray(tree, []); + assert.deepEqual(out.map((r) => r.label), ['root', 'a', 'a-1', 'a-2', 'b']); + }); +}); + +describe('ComparePolicyUtils.rateToTable / ratesToTable', () => { + it('rateToTable() flattens a single rate tree', () => { + const root = stubRate('root', [stubRate('a'), stubRate('b')]); + const out = ComparePolicyUtils.rateToTable(root); + assert.deepEqual(out.map((r) => r.label), ['root', 'a', 'b']); + }); + + it('ratesToTable() flattens an array of rates', () => { + const r1 = stubRate('one', [stubRate('one-a')]); + const r2 = stubRate('two'); + const out = ComparePolicyUtils.ratesToTable([r1, r2]); + assert.deepEqual(out.map((r) => r.label), ['one', 'one-a', 'two']); + }); +}); + +describe('ComparePolicyUtils.compareTree (generic)', () => { + const fakeTree = (key, children = [], overrides = {}) => ({ + key, + children, + equal(other) { return other && this.key === other.key && this._content === other._content; }, + equalKey(other) { return other && this.key === other.key; }, + _content: overrides._content ?? key, + ...overrides, + }); + + const noOpCreateRate = (a, b) => ({ + a, b, + children: [], + setChildren(c) { this.children = c; }, + getChildren() { return this.children; }, + }); + + it('returns a single rate with empty children when both trees are null', () => { + const r = ComparePolicyUtils.compareTree(null, null, noOpCreateRate); + assert.equal(r.a, null); + assert.equal(r.b, null); + }); + + it('handles left-only by recursing into the left children', () => { + const a = fakeTree('A', [fakeTree('a1')]); + const r = ComparePolicyUtils.compareTree(a, null, noOpCreateRate); + assert.equal(r.children.length, 1); + }); + + it('handles right-only by recursing into the right children', () => { + const b = fakeTree('B', [fakeTree('b1')]); + const r = ComparePolicyUtils.compareTree(null, b, noOpCreateRate); + assert.equal(r.children.length, 1); + }); + + it('marks FULL when trees are equal', () => { + const a = fakeTree('X'); + const b = fakeTree('X'); + const r = ComparePolicyUtils.compareTree(a, b, noOpCreateRate); + assert.equal(r.type, 'FULL'); + }); + + it('marks PARTLY when keys match but content differs', () => { + const a = fakeTree('X', [], { _content: 'left-content' }); + const b = fakeTree('X', [], { _content: 'right-content' }); + const r = ComparePolicyUtils.compareTree(a, b, noOpCreateRate); + assert.equal(r.type, 'PARTLY'); + }); + + it('marks LEFT_AND_RIGHT when neither equal nor equalKey match', () => { + const a = fakeTree('A'); + const b = fakeTree('B'); + const r = ComparePolicyUtils.compareTree(a, b, noOpCreateRate); + assert.equal(r.type, 'LEFT_AND_RIGHT'); + }); +}); + +describe('ComparePolicyUtils.compareChildren', () => { + it('produces a rate per merged child entry under FULL', () => { + const child = (k) => ({ + key: k, + children: [], + equal(other) { return other && other.key === k; }, + equalKey(other) { return other && other.key === k; }, + }); + const a = [child('a'), child('b')]; + const b = [child('a'), child('b')]; + const noOpCreateRate = (l, r) => ({ + l, r, + children: [], + setChildren(c) { this.children = c; }, + getChildren() { return this.children; }, + }); + const out = ComparePolicyUtils.compareChildren('FULL', a, b, noOpCreateRate); + assert.equal(out.length, 2); + }); + + it('produces a rate for every child under LEFT_AND_RIGHT (non-merged)', () => { + const a = [{ key: 'a' }]; + const b = [{ key: 'b' }]; + const noOpCreateRate = (l, r) => ({ + l, r, + children: [], + setChildren(c) { this.children = c; }, + getChildren() { return this.children; }, + }); + const out = ComparePolicyUtils.compareChildren('OTHER', a, b, noOpCreateRate); + assert.equal(out.length, 2); + }); +}); diff --git a/guardian-service/tests/unit/analytics-csv-and-compare-utils.test.mjs b/guardian-service/tests/unit/analytics-csv-and-compare-utils.test.mjs new file mode 100644 index 0000000000..b1de9f72d8 --- /dev/null +++ b/guardian-service/tests/unit/analytics-csv-and-compare-utils.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { CSV } from '../../dist/analytics/compare/table/csv.js'; +import { CompareUtils } from '../../dist/analytics/compare/utils/utils.js'; + +describe('analytics CSV', () => { + it('starts with the documented data-uri header and no separator', () => { + const csv = new CSV(); + assert.equal(csv.result(), 'data:text/csv;charset=utf-8;'); + }); + + it('quotes values and inserts comma separators between cells', () => { + const csv = new CSV(); + csv.add('a').add('b').add('c'); + assert.ok(csv.result().endsWith('"a","b","c"')); + }); + + it('emits empty quotes for undefined cells', () => { + const csv = new CSV(); + csv.add(undefined).add('x'); + assert.ok(csv.result().endsWith('"",\"x"')); + }); + + it('addLine() appends CRLF and resets the separator', () => { + const csv = new CSV(); + csv.add('a').addLine().add('b'); + const result = csv.result(); + assert.ok(/"a"\r\n"b"$/.test(result)); + }); + + it('clear() resets the buffer back to the empty state', () => { + const csv = new CSV(); + csv.add('x').addLine().add('y'); + csv.clear(); + assert.equal(csv.result(), 'data:text/csv;charset=utf-8;'); + }); + + it('chains fluently — every method returns this', () => { + const csv = new CSV(); + const chained = csv.add('a').addLine().add('b'); + assert.equal(chained, csv); + }); +}); + +describe('CompareUtils.calcRate', () => { + it('returns 100 for an empty array', () => { + assert.equal(CompareUtils.calcRate([]), 100); + }); + + it('averages totalRate across positive entries', () => { + const rates = [{ totalRate: 80 }, { totalRate: 60 }, { totalRate: 40 }]; + assert.equal(CompareUtils.calcRate(rates), 60); + }); + + it('treats negative totalRate as 0 (clamping behavior)', () => { + const rates = [{ totalRate: -5 }, { totalRate: 100 }]; + assert.equal(CompareUtils.calcRate(rates), 50); + }); + + it('floors the average', () => { + const rates = [{ totalRate: 1 }, { totalRate: 2 }, { totalRate: 2 }]; + assert.equal(CompareUtils.calcRate(rates), 1); + }); + + it('caps the result at 100', () => { + const rates = [{ totalRate: 999 }]; + assert.equal(CompareUtils.calcRate(rates), 100); + }); +}); + +describe('CompareUtils.calcTotalRate / calcTotalRates', () => { + it('calcTotalRate floors the sum/count of variadic args', () => { + assert.equal(CompareUtils.calcTotalRate(10, 20, 30), 20); + assert.equal(CompareUtils.calcTotalRate(33, 33), 33); + }); + + it('calcTotalRates returns 100 for an empty array', () => { + assert.equal(CompareUtils.calcTotalRates([]), 100); + }); + + it('calcTotalRates floors the average', () => { + assert.equal(CompareUtils.calcTotalRates([10, 20, 30]), 20); + assert.equal(CompareUtils.calcTotalRates([1, 2]), 1); + }); +}); + +describe('CompareUtils.total (bucketed averaging)', () => { + it('returns 100 for an empty input', () => { + assert.equal(CompareUtils.total([]), 100); + }); + + it('buckets each rate as 100 (>99), 50 (>50), or 0 (otherwise)', () => { + const rates = [ + { totalRate: 100 }, // 100 + { totalRate: 75 }, // 50 + { totalRate: 30 }, // 0 + { totalRate: 51 }, // 50 + ]; + // (100 + 50 + 0 + 50) / 4 = 50 + assert.equal(CompareUtils.total(rates), 50); + }); + + it('returns 0 when every rate falls in the lowest bucket', () => { + assert.equal(CompareUtils.total([{ totalRate: 0 }, { totalRate: 10 }]), 0); + }); +}); + +describe('CompareUtils.mapping', () => { + const eqByName = { equal(other) { return other?.name === this.name; } }; + + it('matches an unpaired left entry by .equal() and pairs it with the new item', () => { + const left = { ...eqByName, name: 'a' }; + const list = [{ left, right: null }]; + const newItem = { ...eqByName, name: 'a' }; + CompareUtils.mapping(list, newItem); + assert.equal(list.length, 1); + assert.equal(list[0].right, newItem); + }); + + it('appends a new {left:null, right:item} entry when nothing matches', () => { + const list = []; + const item = { ...eqByName, name: 'unique' }; + CompareUtils.mapping(list, item); + assert.equal(list.length, 1); + assert.equal(list[0].left, null); + assert.equal(list[0].right, item); + }); + + it('skips entries that are already paired (right is set)', () => { + const left = { ...eqByName, name: 'a' }; + const right = { ...eqByName, name: 'a' }; + const list = [{ left, right }]; + const newItem = { ...eqByName, name: 'a' }; + CompareUtils.mapping(list, newItem); + assert.equal(list.length, 2); + assert.equal(list[1].left, null); + assert.equal(list[1].right, newItem); + }); +}); + +describe('CompareUtils.aggregateHash', () => { + it('returns a deterministic non-empty string', () => { + const a = CompareUtils.aggregateHash('one', 'two', 'three'); + const b = CompareUtils.aggregateHash('one', 'two', 'three'); + assert.equal(a, b); + assert.equal(typeof a, 'string'); + assert.ok(a.length > 0); + }); + + it('produces a different hash when an input differs', () => { + const a = CompareUtils.aggregateHash('x', 'y'); + const b = CompareUtils.aggregateHash('x', 'z'); + assert.notEqual(a, b); + }); +}); + +describe('CompareUtils.sha256', () => { + it('returns a deterministic non-empty string for the same input', () => { + const a = CompareUtils.sha256('hello'); + const b = CompareUtils.sha256('hello'); + assert.equal(a, b); + assert.ok(a.length > 0); + }); + + it('produces different hashes for different inputs', () => { + assert.notEqual(CompareUtils.sha256('a'), CompareUtils.sha256('b')); + }); +}); + +describe('CompareUtils.tableToCsv', () => { + it('writes only labelled columns and skips columns with no label', () => { + const csv = new CSV(); + const table = { + columns: [ + { name: 'a', label: 'A' }, + { name: 'b', label: '' }, // skipped + { name: 'c', label: 'C' }, + ], + report: [ + { a: '1', b: '2', c: '3' }, + { a: '4', b: '5', c: '6' }, + ], + }; + CompareUtils.tableToCsv(csv, table); + const out = csv.result(); + assert.ok(out.includes('"A","C"')); + assert.ok(out.includes('"1","3"')); + assert.ok(out.includes('"4","6"')); + assert.ok(!out.includes('"2"')); + }); +}); + +describe('CompareUtils.objectToCsv', () => { + it('emits the header row first', () => { + const csv = CompareUtils.objectToCsv({}); + const out = csv.result(); + assert.ok(out.includes('"Index","Key","Value","Type"')); + }); + + it('records each scalar value with its type', () => { + const csv = CompareUtils.objectToCsv({ a: 1, b: 'x', c: true }); + const out = csv.result(); + assert.ok(out.includes('"a"')); + assert.ok(out.includes('"x"')); + assert.ok(out.includes('"number"')); + assert.ok(out.includes('"string"')); + assert.ok(out.includes('"boolean"')); + }); +}); diff --git a/guardian-service/tests/unit/analytics-doc-policy-comparators.test.mjs b/guardian-service/tests/unit/analytics-doc-policy-comparators.test.mjs new file mode 100644 index 0000000000..6a3bfb24b0 --- /dev/null +++ b/guardian-service/tests/unit/analytics-doc-policy-comparators.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { DocumentComparator } from '../../dist/analytics/compare/comparators/document-comparator.js'; +import { PolicyComparator } from '../../dist/analytics/compare/comparators/policy-comparator.js'; +import { VcDocumentModel } from '../../dist/analytics/compare/models/document.model.js'; +import { PolicyModel } from '../../dist/analytics/compare/models/policy.model.js'; + +const docOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All', childLvl: 'All' }; +const polOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const vcDoc = (overrides = {}) => new VcDocumentModel({ + id: 'doc-1', schema: 'schema-A', messageId: 'm-1', topicId: '0.0.1', + owner: 'did:owner', policyId: 'p-1', + document: { '@context': ['https://x'], type: 'VerifiableCredential', credentialSubject: { type: 'Sub', amount: 5 } }, + option: { tag: 'submit' }, relationships: [], ...overrides +}, docOpts); + +const policy = (overrides = {}) => new PolicyModel({ + id: 'p-1', name: 'Policy', description: 'desc', instanceTopicId: '0.0.1', version: '1.0.0', + config: { blockType: 'root', tag: 'root', children: [] }, + policyRoles: [], policyGroups: [], policyTopics: [], policyTokens: [], tools: [], ...overrides +}, polOpts); + +describe('DocumentComparator', () => { + it('constructs with default options', () => { + assert.ok(new DocumentComparator()); + }); + + it('compare returns one result per right-hand document', () => { + const results = new DocumentComparator().compare([vcDoc(), vcDoc(), vcDoc()]); + assert.equal(results.length, 2); + }); + + it('compare of a single document yields no comparisons', () => { + assert.deepEqual(new DocumentComparator().compare([vcDoc()]), []); + }); + + it('a comparison result exposes left/right info and a numeric total', () => { + const [result] = new DocumentComparator().compare([vcDoc(), vcDoc()]); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + }); + + it('two identical documents compare as fully similar', () => { + const [result] = new DocumentComparator().compare([vcDoc(), vcDoc()]); + assert.equal(result.total, 100); + }); + + it('a comparison result carries a documents report', () => { + const [result] = new DocumentComparator().compare([vcDoc(), vcDoc()]); + assert.ok(result.documents); + }); + + it('tableToCsv produces a CSV data-uri', () => { + const results = new DocumentComparator().compare([vcDoc(), vcDoc()]); + const csv = DocumentComparator.tableToCsv(results); + assert.match(csv, /^data:text\/csv/); + }); +}); + +describe('PolicyComparator', () => { + it('constructs with default options', () => { + assert.ok(new PolicyComparator()); + }); + + it('compare returns one result per right-hand policy', () => { + const results = new PolicyComparator().compare([policy(), policy(), policy()]); + assert.equal(results.length, 2); + }); + + it('compare of a single policy yields no comparisons', () => { + assert.deepEqual(new PolicyComparator().compare([policy()]), []); + }); + + it('a result exposes left/right info and a numeric total', () => { + const [result] = new PolicyComparator().compare([policy(), policy()]); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + }); + + it('two identical policies compare as fully similar', () => { + const [result] = new PolicyComparator().compare([policy(), policy()]); + assert.equal(result.total, 100); + }); + + it('a result carries blocks/roles/groups/topics/tokens/tools sections', () => { + const [result] = new PolicyComparator().compare([policy(), policy()]); + for (const key of ['blocks', 'roles', 'groups', 'topics', 'tokens', 'tools']) { + assert.ok(result[key], `missing ${key}`); + } + }); + + it('mergeCompareResults aggregates multiple results', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policy(), policy(), policy()]); + const merged = comparator.mergeCompareResults(results); + assert.ok(merged); + }); + + it('tableToCsv produces a CSV data-uri', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([policy(), policy()]); + assert.match(comparator.tableToCsv(results), /^data:text\/csv/); + }); +}); diff --git a/guardian-service/tests/unit/analytics-document-fields-model.test.mjs b/guardian-service/tests/unit/analytics-document-fields-model.test.mjs new file mode 100644 index 0000000000..4219ab18c7 --- /dev/null +++ b/guardian-service/tests/unit/analytics-document-fields-model.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { DocumentFieldsModel } from '../../dist/analytics/compare/models/document-fields.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +describe('DocumentFieldsModel.createSchemasList', () => { + it('returns [] for falsy input', () => { + assert.deepEqual(DocumentFieldsModel.createSchemasList(null), []); + assert.deepEqual(DocumentFieldsModel.createSchemasList(undefined), []); + }); + + it('collects @context strings deduplicated', () => { + const out = DocumentFieldsModel.createSchemasList({ + '@context': ['https://schema.org/A', 'https://schema.org/A', 'https://schema.org/B'], + }); + assert.equal(out.length, 2); + assert.ok(out.includes('https://schema.org/A')); + assert.ok(out.includes('https://schema.org/B')); + }); + + it('drops the well-known credentials/v1 context', () => { + const out = DocumentFieldsModel.createSchemasList({ + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://schema.org/X', + ], + }); + assert.deepEqual(out, ['https://schema.org/X']); + }); + + it('accepts a plain string @context', () => { + const out = DocumentFieldsModel.createSchemasList({ + '@context': 'https://schema.org/Y', + }); + assert.deepEqual(out, ['https://schema.org/Y']); + }); + + it('walks a verifiableCredential array and merges every @context', () => { + const out = DocumentFieldsModel.createSchemasList({ + verifiableCredential: [ + { '@context': ['https://a'] }, + { '@context': 'https://b' }, + ], + }); + out.sort(); + assert.deepEqual(out, ['https://a', 'https://b']); + }); + + it('handles a single verifiableCredential object', () => { + const out = DocumentFieldsModel.createSchemasList({ + verifiableCredential: { '@context': 'https://x' }, + }); + assert.deepEqual(out, ['https://x']); + }); +}); + +describe('DocumentFieldsModel.createTypesList', () => { + it('returns [] for falsy input', () => { + assert.deepEqual(DocumentFieldsModel.createTypesList(null), []); + }); + + it('collects credentialSubject.type from a single VC', () => { + const out = DocumentFieldsModel.createTypesList({ + credentialSubject: { type: 'PolicyDoc' }, + }); + assert.deepEqual(out, ['PolicyDoc']); + }); + + it('walks each entry of credentialSubject when it is an array', () => { + const out = DocumentFieldsModel.createTypesList({ + credentialSubject: [{ type: 'A' }, { type: 'B' }], + }); + out.sort(); + assert.deepEqual(out, ['A', 'B']); + }); + + it('walks each VC of a verifiablePresentation', () => { + const out = DocumentFieldsModel.createTypesList({ + verifiableCredential: [ + { credentialSubject: { type: 'X' } }, + { credentialSubject: [{ type: 'Y' }, { type: 'Z' }] }, + ], + }); + out.sort(); + assert.deepEqual(out, ['X', 'Y', 'Z']); + }); + + it('handles a single verifiableCredential object', () => { + const out = DocumentFieldsModel.createTypesList({ + verifiableCredential: { credentialSubject: { type: 'Solo' } }, + }); + assert.deepEqual(out, ['Solo']); + }); +}); + +describe('DocumentFieldsModel.createFieldsList', () => { + it('classifies scalar leaves as DocumentPropertyModel (type=property)', () => { + const list = DocumentFieldsModel.createFieldsList({ amount: 5, owner: 'did:1' }); + assert.equal(list.length, 2); + assert.equal(list[0].type, 'property'); + assert.equal(list[0].path, 'amount'); + assert.equal(list[1].path, 'owner'); + }); + + it('expands arrays into ArrayPropertyModel + numeric-keyed children', () => { + const list = DocumentFieldsModel.createFieldsList({ list: [10, 20] }); + const arr = list.find((p) => p.path === 'list'); + assert.equal(arr.type, 'array'); + assert.equal(arr.value, 2); + const child0 = list.find((p) => p.path === 'list.0'); + const child1 = list.find((p) => p.path === 'list.1'); + assert.equal(child0.value, 10); + assert.equal(child1.value, 20); + }); + + it('expands plain objects into ObjectPropertyModel + recursive children', () => { + const list = DocumentFieldsModel.createFieldsList({ payload: { a: 1, b: 2 } }); + const obj = list.find((p) => p.path === 'payload'); + assert.equal(obj.type, 'object'); + assert.equal(obj.value, true); + assert.ok(list.find((p) => p.path === 'payload.a')); + assert.ok(list.find((p) => p.path === 'payload.b')); + }); + + it('skips undefined values', () => { + const list = DocumentFieldsModel.createFieldsList({ a: undefined, b: 1 }); + assert.equal(list.length, 1); + assert.equal(list[0].path, 'b'); + }); +}); + +describe('DocumentFieldsModel construction', () => { + it('captures string type as-is and builds fields/schemas/types', () => { + const m = new DocumentFieldsModel({ + type: 'VerifiableCredential', + '@context': 'https://x', + credentialSubject: { type: 'Foo', amount: 1 }, + }); + assert.deepEqual(m.schemas, ['https://x']); + assert.deepEqual(m.types, ['Foo']); + assert.ok(m.getFieldsList().length > 0); + }); + + it('classifies a "type" array containing VerifiablePresentation as VP', () => { + const m = new DocumentFieldsModel({ + type: ['VerifiableCredential', 'VerifiablePresentation'], + '@context': 'https://x', + verifiableCredential: { credentialSubject: { type: 'Inner' } }, + }); + assert.deepEqual(m.types, ['Inner']); + }); + + it('falls back to type[0] for an unrecognised type array', () => { + const m = new DocumentFieldsModel({ + type: ['UnknownTypeA', 'UnknownTypeB'], + credentialSubject: { type: 'Sub' }, + }); + // type stays as 'UnknownTypeA' internally; types list is from credentialSubject. + assert.deepEqual(m.types, ['Sub']); + }); +}); + +describe('DocumentFieldsModel instance methods', () => { + it('hash() joins each field hash with a comma', () => { + const m = new DocumentFieldsModel({ + type: 'VerifiableCredential', + credentialSubject: { foo: 'bar' }, + }); + m.update(opts); + const h = m.hash(opts); + assert.ok(h.includes(',')); + assert.ok(h.length > 0); + }); + + it('getFieldsList() returns a defensive copy', () => { + const m = new DocumentFieldsModel({ + type: 'VerifiableCredential', + credentialSubject: { foo: 'bar' }, + }); + const list1 = m.getFieldsList(); + const original = list1.length; + list1.push({}); + assert.equal(m.getFieldsList().length, original); + }); + + it('merge() concatenates another model\'s fields into this one', () => { + const a = new DocumentFieldsModel({ + type: 'VerifiableCredential', + credentialSubject: { foo: 'bar' }, + }); + const b = new DocumentFieldsModel({ + type: 'VerifiableCredential', + credentialSubject: { baz: 'qux' }, + }); + const before = a.getFieldsList().length; + a.merge(b); + assert.equal(a.getFieldsList().length, before + b.getFieldsList().length); + }); +}); diff --git a/guardian-service/tests/unit/analytics-document-model.test.mjs b/guardian-service/tests/unit/analytics-document-model.test.mjs new file mode 100644 index 0000000000..7a8e434341 --- /dev/null +++ b/guardian-service/tests/unit/analytics-document-model.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { VcDocumentModel, VpDocumentModel } from '../../dist/analytics/compare/models/document.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All', childLvl: 'All' }; + +const vcRaw = (overrides = {}) => ({ + id: 'doc-1', + schema: 'schema-A', + messageId: 'm-1', + topicId: '0.0.1', + owner: 'did:owner', + policyId: 'p-1', + document: { '@context': ['https://x'], type: 'VerifiableCredential', credentialSubject: { type: 'Sub', amount: 5 } }, + option: { tag: 'submit' }, + relationships: [], + ...overrides, +}); + +const vpRaw = (overrides = {}) => ({ + id: 'doc-vp', + type: 'PolicyVP', + messageId: 'm-2', + topicId: '0.0.1', + owner: 'did:owner', + policyId: 'p-1', + document: { + '@context': ['https://x'], + type: ['VerifiablePresentation'], + verifiableCredential: [{ credentialSubject: { type: 'Sub' } }], + }, + option: {}, + relationships: ['vc-1', 'vc-2'], + ...overrides, +}); + +describe('VcDocumentModel', () => { + it('captures id/messageId/topicId/owner/policy', () => { + const m = new VcDocumentModel(vcRaw(), opts); + assert.equal(m.id, 'doc-1'); + assert.equal(m.messageId, 'm-1'); + assert.equal(m.topicId, '0.0.1'); + assert.equal(m.owner, 'did:owner'); + assert.equal(m.policy, 'p-1'); + }); + + it('uses schema as the key', () => { + const m = new VcDocumentModel(vcRaw({ schema: 'iri:foo' }), opts); + assert.equal(m.key, 'iri:foo'); + }); + + it('captures relationship ids', () => { + const m = new VcDocumentModel(vcRaw({ relationships: ['r-1', 'r-2'] }), opts); + assert.deepEqual(m.relationshipIds, ['r-1', 'r-2']); + }); + + it('handles a missing relationships field as []', () => { + const m = new VcDocumentModel(vcRaw({ relationships: undefined }), opts); + assert.deepEqual(m.relationshipIds, []); + }); + + it('starts with empty weights and _hash', () => { + const m = new VcDocumentModel(vcRaw(), opts); + assert.deepEqual(m.getWeights(), []); + assert.equal(m.maxWeight(), 0); + }); + + it('VcDocumentModel.from(data, options) builds with no schema/relationships', () => { + const m = VcDocumentModel.from({ credentialSubject: { type: 'X' } }, opts); + assert.equal(m.type, 'VC'); + assert.equal(m.key, null); + }); +}); + +describe('VpDocumentModel', () => { + it('uses raw.type as the key', () => { + const m = new VpDocumentModel(vpRaw(), opts); + assert.equal(m.key, 'PolicyVP'); + }); + + it('captures relationship ids from the raw input', () => { + const m = new VpDocumentModel(vpRaw({ relationships: ['a', 'b'] }), opts); + assert.deepEqual(m.relationshipIds, ['a', 'b']); + }); + + it('VpDocumentModel.from(data, options) builds with type=VP and no key', () => { + const m = VpDocumentModel.from({ verifiableCredential: [] }, opts); + assert.equal(m.type, 'VP'); + assert.equal(m.key, null); + }); +}); + +describe('DocumentModel set* mutators', () => { + it('returns the model for chaining', () => { + const m = new VcDocumentModel(vcRaw(), opts); + assert.equal(m.setSchemas([]), m); + assert.equal(m.setRelationships([]), m); + assert.equal(m.setAttributes({ foo: 'bar' }), m); + }); + + it('setRelationships(non-array) falls back to []', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setRelationships(null); + assert.deepEqual(m.children, []); + }); + + it('setAttributes is reflected by .attributes', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setAttributes({ foo: 'bar' }); + assert.deepEqual(m.attributes, { foo: 'bar' }); + }); +}); + +describe('DocumentModel.update + getWeights', () => { + it('update() populates a non-empty weights array', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setSchemas([]).setRelationships([]); + m.update(opts); + assert.ok(m.getWeights().length > 0); + assert.ok(m.maxWeight() > 0); + }); + + it('update() returns the model for chaining', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setSchemas([]).setRelationships([]); + assert.equal(m.update(opts), m); + }); +}); + +describe('DocumentModel.equal', () => { + it('returns false when types differ (VC vs VP)', () => { + const a = new VcDocumentModel(vcRaw(), opts).setSchemas([]).setRelationships([]); + const b = new VpDocumentModel(vpRaw(), opts).setSchemas([]).setRelationships([]); + a.update(opts); + b.update(opts); + assert.equal(a.equal(b), false); + }); + + it('returns true for two identical VC docs (no schemas/relationships) before update', () => { + const a = new VcDocumentModel(vcRaw(), opts); + const b = new VcDocumentModel(vcRaw(), opts); + // Both _hash are '' before update — equal returns true. + assert.equal(a.equal(b), true); + }); +}); + +describe('DocumentModel.getSchemas / getTypes', () => { + it('returns @context entries (minus credentials/v1) and credentialSubject types', () => { + const m = new VcDocumentModel(vcRaw(), opts); + const schemas = m.getSchemas(); + const types = m.getTypes(); + assert.deepEqual(schemas, ['https://x']); + assert.deepEqual(types, ['Sub']); + }); +}); + +describe('DocumentModel.toObject / info', () => { + it('toObject returns {key, owner, policy, attributes, document, options}', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setAttributes({ foo: 1 }); + const o = m.toObject(); + assert.equal(o.key, 'schema-A'); + assert.equal(o.owner, 'did:owner'); + assert.equal(o.policy, 'p-1'); + assert.deepEqual(o.attributes, { foo: 1 }); + assert.ok(Array.isArray(o.document)); + assert.ok(Array.isArray(o.options)); + }); + + it('info returns {id, type, owner, policy}', () => { + const m = new VcDocumentModel(vcRaw(), opts); + assert.deepEqual(m.info(), { id: 'doc-1', type: 'VC', owner: 'did:owner', policy: 'p-1' }); + }); +}); + +describe('DocumentModel.title', () => { + it('returns the key when no schemas are attached', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setSchemas([]); + assert.equal(m.title(), 'schema-A'); + }); + + it('joins schema description / name / iri (in that priority)', () => { + const m = new VcDocumentModel(vcRaw(), opts); + m.setSchemas([ + { description: 'Desc-A', name: 'name-A', iri: '#A' }, + { description: '', name: 'name-B', iri: '#B' }, + { description: '', name: '', iri: '#C' }, + ]); + assert.equal(m.title(), 'Desc-A, name-B, #C'); + }); +}); diff --git a/guardian-service/tests/unit/analytics-documents-rate.test.mjs b/guardian-service/tests/unit/analytics-documents-rate.test.mjs new file mode 100644 index 0000000000..9bdf493d57 --- /dev/null +++ b/guardian-service/tests/unit/analytics-documents-rate.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { DocumentsRate } from '../../dist/analytics/compare/rates/documents-rate.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const prop = (key, value, overrides = {}) => ({ + name: key, + path: key, + key, + lvl: 1, + value, + ignored: false, + equal(other) { return this.value === other.value; }, + ignore() { return this.ignored; }, + getPropList() { return overrides.subProps || []; }, + toObject() { return { name: this.name, path: this.path, value: this.value }; }, + ...overrides, +}); + +const fakeDoc = (overrides = {}) => ({ + type: 'VC', + key: 'schema-A', + fields: overrides.fields || [], + options: overrides.options || [], + getFieldsList() { return this.fields; }, + getOptionsList() { return this.options; }, + ...overrides, +}); + +describe('DocumentsRate construction', () => { + it('exposes static rate-name constants', () => { + assert.equal(DocumentsRate.DOCUMENTS_RATE, 'documents'); + assert.equal(DocumentsRate.OPTIONS_RATE, 'options'); + }); + + it('captures documentType and schema from the left document', () => { + const a = fakeDoc({ type: 'VC', key: 'schema-X' }); + const b = fakeDoc({ type: 'VC', key: 'schema-Y' }); + const r = new DocumentsRate(a, b); + assert.equal(r.documentType, 'VC'); + assert.equal(r.schema, 'schema-X'); + }); + + it('falls back to the right document when left is missing', () => { + const b = fakeDoc({ type: 'VP', key: 'schema-Z' }); + const r = new DocumentsRate(null, b); + assert.equal(r.documentType, 'VP'); + assert.equal(r.schema, 'schema-Z'); + }); + + it('throws "Empty document model" when both sides are null', () => { + assert.throws(() => new DocumentsRate(null, null), /Empty document model/); + }); +}); + +describe('DocumentsRate.calc — both documents', () => { + it('100% match when fields and options are identical', () => { + const fields = [prop('amount', 1), prop('owner', 'did:1')]; + const options = [prop('opt1', 'X')]; + const a = fakeDoc({ fields, options }); + const b = fakeDoc({ + fields: [prop('amount', 1), prop('owner', 'did:1')], + options: [prop('opt1', 'X')], + }); + const r = new DocumentsRate(a, b); + r.calc(opts); + assert.equal(r.documentRate, 100); + assert.equal(r.optionsRate, 100); + assert.equal(r.totalRate, 100); + }); + + it('marks differing options independently of differing fields', () => { + const a = fakeDoc({ + fields: [prop('amount', 1)], + options: [prop('opt1', 'X')], + }); + const b = fakeDoc({ + fields: [prop('amount', 2)], + options: [prop('opt1', 'X')], + }); + const r = new DocumentsRate(a, b); + r.calc(opts); + assert.equal(r.documentRate, 0); + assert.equal(r.optionsRate, 100); + assert.equal(r.totalRate, 50); + }); + + it('emits LEFT-only entries when right is missing', () => { + const a = fakeDoc({ fields: [prop('amount', 1)] }); + const r = new DocumentsRate(a, null); + r.calc(opts); + // only-one-side: documentRate stays at -1 + assert.equal(r.documentRate, -1); + }); +}); + +describe('DocumentsRate.getSubRate / getRateValue', () => { + it('getSubRate("documents") returns the documents rates array', () => { + const a = fakeDoc({ fields: [prop('a', 1)] }); + const b = fakeDoc({ fields: [prop('a', 1)] }); + const r = new DocumentsRate(a, b); + r.calc(opts); + assert.equal(r.getSubRate('documents'), r.documents); + }); + + it('getSubRate("options") returns the options rates array', () => { + const a = fakeDoc({ options: [prop('o', 1)] }); + const b = fakeDoc({ options: [prop('o', 1)] }); + const r = new DocumentsRate(a, b); + r.calc(opts); + assert.equal(r.getSubRate('options'), r.options); + }); + + it('getSubRate(other) returns null', () => { + const a = fakeDoc(); + const b = fakeDoc(); + const r = new DocumentsRate(a, b); + assert.equal(r.getSubRate('unknown'), null); + }); + + it('getRateValue("documents")/("options") return the named rates', () => { + const a = fakeDoc({ fields: [prop('a', 1)], options: [prop('o', 'X')] }); + const b = fakeDoc({ fields: [prop('a', 1)], options: [prop('o', 'X')] }); + const r = new DocumentsRate(a, b); + r.calc(opts); + assert.equal(r.getRateValue('documents'), 100); + assert.equal(r.getRateValue('options'), 100); + assert.equal(r.getRateValue('any-other'), r.totalRate); + }); +}); + +describe('DocumentsRate.setChildren / getChildren', () => { + it('roundtrips children through setChildren/getChildren', () => { + const a = fakeDoc(); + const b = fakeDoc(); + const r = new DocumentsRate(a, b); + const kids = [{ totalRate: 10 }, { totalRate: 20 }]; + r.setChildren(kids); + assert.equal(r.getChildren(), kids); + }); +}); diff --git a/guardian-service/tests/unit/analytics-event-blockprops-models.test.mjs b/guardian-service/tests/unit/analytics-event-blockprops-models.test.mjs new file mode 100644 index 0000000000..ed0bc2e81b --- /dev/null +++ b/guardian-service/tests/unit/analytics-event-blockprops-models.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { EventModel } from '../../dist/analytics/compare/models/event.model.js'; +import { BlockPropertiesModel } from '../../dist/analytics/compare/models/block-properties.model.js'; + +const opts = (overrides = {}) => ({ + propLvl: 'All', + keyLvl: 'Default', + idLvl: 'All', + eventLvl: 'All', + ...overrides, +}); + +const rawEvent = (overrides = {}) => ({ + actor: 'system', + disabled: false, + input: 'RunEvent', + output: 'RefreshEvent', + source: 'src-tag', + target: 'tgt-tag', + ...overrides, +}); + +const fakeBlock = (weightByType) => ({ + getWeight(type) { return weightByType[type] || ''; }, +}); + +describe('EventModel construction', () => { + it('captures every documented field from the raw JSON', () => { + const e = new EventModel(rawEvent()); + assert.equal(e.actor, 'system'); + assert.equal(e.disabled, false); + assert.equal(e.input, 'RunEvent'); + assert.equal(e.output, 'RefreshEvent'); + assert.equal(e.source, 'src-tag'); + assert.equal(e.target, 'tgt-tag'); + }); + + it('has a null key (events do not key into rate maps)', () => { + const e = new EventModel(rawEvent()); + assert.equal(e.key, null); + }); +}); + +describe('EventModel.update', () => { + it('uses block weights at PROP_LVL_3 when eventLvl=All', () => { + const e = new EventModel(rawEvent()); + const map = { + 'src-tag': fakeBlock({ PROP_LVL_3: 'src-w' }), + 'tgt-tag': fakeBlock({ PROP_LVL_3: 'tgt-w' }), + }; + e.update(map, opts({ eventLvl: 'All' })); + const obj = e.toObject(); + assert.equal(obj.startWeight, 'src-w'); + assert.equal(obj.endWeight, 'tgt-w'); + assert.ok(obj.weight.length > 0); + }); + + it('falls back to "undefined" sentinel when blocks are missing under eventLvl=All', () => { + const e = new EventModel(rawEvent()); + e.update({}, opts({ eventLvl: 'All' })); + const obj = e.toObject(); + assert.equal(obj.startWeight, 'undefined'); + assert.equal(obj.endWeight, 'undefined'); + }); + + it('uses raw source/target tags under eventLvl=Simple', () => { + const e = new EventModel(rawEvent()); + e.update({}, opts({ eventLvl: 'Simple' })); + const obj = e.toObject(); + assert.equal(obj.startWeight, 'src-tag'); + assert.equal(obj.endWeight, 'tgt-tag'); + }); + + it('public weight is "" outside eventLvl=All but the underlying hash still drives equal()', () => { + const a = new EventModel(rawEvent()); + const b = new EventModel(rawEvent()); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + const aObj = a.toObject(); + assert.equal(aObj.weight, ''); + assert.equal(a.equal(b), true); + }); + + it('uses "undefined" for both endpoints when eventLvl=None', () => { + const e = new EventModel(rawEvent()); + e.update({}, opts({ eventLvl: 'None' })); + const obj = e.toObject(); + assert.equal(obj.startWeight, 'undefined'); + assert.equal(obj.endWeight, 'undefined'); + }); +}); + +describe('EventModel.equal', () => { + it('returns true for two events with identical inputs/outputs/endpoints', () => { + const a = new EventModel(rawEvent()); + const b = new EventModel(rawEvent()); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + assert.equal(a.equal(b), true); + }); + + it('returns false when actor differs', () => { + const a = new EventModel(rawEvent({ actor: 'system' })); + const b = new EventModel(rawEvent({ actor: 'user' })); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + assert.equal(a.equal(b), false); + }); + + it('returns false when source/target differ under eventLvl=Simple', () => { + const a = new EventModel(rawEvent({ source: 'a' })); + const b = new EventModel(rawEvent({ source: 'b' })); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + assert.equal(a.equal(b), false); + }); +}); + +describe('EventModel.toObject / toWeight', () => { + it('toObject exposes all six raw fields plus weight/startWeight/endWeight', () => { + const e = new EventModel(rawEvent()); + e.update({}, opts({ eventLvl: 'Simple' })); + const obj = e.toObject(); + for (const k of ['actor', 'source', 'target', 'input', 'output', 'disabled', 'weight', 'startWeight', 'endWeight']) { + assert.ok(k in obj, `toObject missing ${k}`); + } + }); + + it('toWeight returns the underlying _hash regardless of eventLvl', () => { + const e = new EventModel(rawEvent()); + e.update({}, opts({ eventLvl: 'Simple' })); + const w = e.toWeight(opts()); + assert.ok(typeof w.weight === 'string' && w.weight.length > 0); + }); +}); + +describe('BlockPropertiesModel', () => { + it('keeps non-skeletal fields (other than id/blockType/tag/permissions/artifacts/events/children)', () => { + const m = new BlockPropertiesModel({ + id: 'b-1', + blockType: 'tool', + tag: 'demo', + permissions: ['OWNER'], + artifacts: [{}], + events: [{}], + children: [{}], + customField: 'kept', + }); + const objs = m.toObject(); + const names = objs.map((o) => o.name); + assert.ok(names.includes('customField')); + assert.ok(!names.includes('id')); + assert.ok(!names.includes('blockType')); + assert.ok(!names.includes('tag')); + assert.ok(!names.includes('children')); + assert.ok(!names.includes('permissions')); + assert.ok(!names.includes('artifacts')); + assert.ok(!names.includes('events')); + }); + + it('exposes a sorted permissions list copy', () => { + const m = new BlockPropertiesModel({ permissions: ['Z', 'A', 'M'] }); + const list = m.getPermissionsList(); + assert.deepEqual(list, ['A', 'M', 'Z']); + list.push('mutated'); + // Calling again should still return the original sorted list. + assert.deepEqual(m.getPermissionsList(), ['A', 'M', 'Z']); + }); + + it('returns [] when permissions is missing or non-array', () => { + const a = new BlockPropertiesModel({}); + const b = new BlockPropertiesModel({ permissions: 'OWNER' }); + assert.deepEqual(a.getPermissionsList(), []); + assert.deepEqual(b.getPermissionsList(), []); + }); +}); diff --git a/guardian-service/tests/unit/analytics-field-model.test.mjs b/guardian-service/tests/unit/analytics-field-model.test.mjs new file mode 100644 index 0000000000..3a4b044f9c --- /dev/null +++ b/guardian-service/tests/unit/analytics-field-model.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { FieldModel } from '../../dist/analytics/compare/models/field.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +describe('FieldModel scalar parsing', () => { + it('captures name, title, description, type, format, pattern', () => { + const f = new FieldModel('amount', { + type: 'number', + title: 'Amount', + description: 'how much', + format: 'float', + pattern: '^[0-9]+$', + }, true); + assert.equal(f.name, 'amount'); + assert.equal(f.title, 'Amount'); + assert.equal(f.description, 'how much'); + assert.equal(f.type, 'number'); + assert.equal(f.format, 'float'); + assert.equal(f.pattern, '^[0-9]+$'); + assert.equal(f.required, true); + }); + + it('falls back to name when title/description are missing', () => { + const f = new FieldModel('amount', { type: 'number' }, false); + assert.equal(f.title, 'amount'); + assert.equal(f.description, 'amount'); + }); + + it('flags isArray=true and unwraps the items shape', () => { + const f = new FieldModel('list', { + type: 'array', + items: { type: 'string', format: 'date' }, + }, false); + assert.equal(f.isArray, true); + assert.equal(f.type, 'string'); + assert.equal(f.format, 'date'); + }); + + it('flags isRef=true and stores $ref as the type', () => { + const f = new FieldModel('inner', { $ref: '#/$defs/Inner' }, false); + assert.equal(f.isRef, true); + assert.equal(f.type, '#/$defs/Inner'); + }); + + it('captures readOnly flag and enum array', () => { + const f = new FieldModel('status', { + type: 'string', + readOnly: true, + enum: ['A', 'B', 'C'], + }, false); + assert.equal(f.readOnly, true); + assert.deepEqual(f.enum, ['A', 'B', 'C']); + }); + + it('treats oneOf as a 1-element schema (uses oneOf[0])', () => { + const f = new FieldModel('x', { oneOf: [{ type: 'string', title: 'Picked' }] }, false); + assert.equal(f.type, 'string'); + assert.equal(f.title, 'Picked'); + }); +}); + +describe('FieldModel comment parsing', () => { + it('extracts unit/unitSystem/customType/property/order from a JSON $comment', () => { + const f = new FieldModel('amount', { + type: 'number', + $comment: JSON.stringify({ + unit: 'kg', + unitSystem: 'metric', + customType: 'mass', + orderPosition: 3, + property: 'someProperty', + }), + }, false); + assert.equal(f.unit, 'kg'); + assert.equal(f.unitSystem, 'metric'); + assert.equal(f.customType, 'mass'); + assert.equal(f.property, 'someProperty'); + assert.equal(f.order, 3); + assert.equal(f.index, 3); + }); + + it('order falls back to -1 when orderPosition is not finite or negative', () => { + const f = new FieldModel('x', { type: 'string', $comment: JSON.stringify({ orderPosition: -2 }) }, false); + assert.equal(f.order, -1); + assert.equal(f.index, null); + }); + + it('handles a malformed JSON $comment by leaving the fields null', () => { + const f = new FieldModel('x', { type: 'string', $comment: '{not json' }, false); + assert.equal(f.unit, null); + assert.equal(f.customType, null); + assert.equal(f.order, -1); + }); + + it('handles a missing $comment by leaving the fields null', () => { + const f = new FieldModel('x', { type: 'string' }, false); + assert.equal(f.unit, null); + assert.equal(f.customType, null); + }); +}); + +describe('FieldModel.equal / equalKey', () => { + it('falls back to name comparison when un-updated', () => { + const a = new FieldModel('x', { type: 'number' }, false); + const b = new FieldModel('x', { type: 'number' }, false); + const c = new FieldModel('y', { type: 'number' }, false); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('compares strongest weight after calcBaseWeight()', () => { + const a = new FieldModel('x', { type: 'number', description: 'A' }, false); + const b = new FieldModel('x', { type: 'number', description: 'A' }, false); + const c = new FieldModel('x', { type: 'number', description: 'B' }, false); + a.update(opts); b.update(opts); c.update(opts); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equalKey() compares by .key (which is null at the base class)', () => { + const a = new FieldModel('x', { type: 'number' }, false); + const b = new FieldModel('y', { type: 'number' }, false); + // Both keys are null → equalKey returns true. + assert.equal(a.equalKey(b), true); + }); +}); + +describe('FieldModel.toObject', () => { + it('exposes every documented field on the output object', () => { + const f = new FieldModel('amount', { + type: 'number', + title: 'Amount', + description: 'how much', + format: 'float', + pattern: '^[0-9]+$', + readOnly: true, + }, true); + const obj = f.toObject(); + for (const k of [ + 'name', 'title', 'description', 'type', 'format', 'pattern', + 'isArray', 'isRef', 'readOnly', 'required', 'enum', 'order' + ]) { + assert.ok(k in obj, `toObject missing ${k}`); + } + assert.equal(obj.required, true); + }); +}); + +describe('FieldModel.getPropList', () => { + it('emits AnyPropertyModel entries for each non-null attribute', () => { + const f = new FieldModel('amount', { + type: 'number', + title: 'Amount', + description: 'how much', + $comment: JSON.stringify({ unit: 'kg', orderPosition: 1 }), + }, true); + const list = f.getPropList(); + const names = list.map((p) => p.name); + assert.ok(names.includes('name')); + assert.ok(names.includes('title')); + assert.ok(names.includes('description')); + assert.ok(names.includes('type')); + assert.ok(names.includes('unit')); + assert.ok(names.includes('order')); + }); + + it('emits an enum block (ArrayPropertyModel + per-element AnyPropertyModel)', () => { + const f = new FieldModel('status', { type: 'string', enum: ['A', 'B'] }, false); + const list = f.getPropList(); + const enumProp = list.find((p) => p.name === 'enum'); + const a = list.find((p) => p.name === '0'); + const b = list.find((p) => p.name === '1'); + assert.ok(enumProp); + assert.ok(a); + assert.ok(b); + assert.equal(a.value, 'A'); + assert.equal(b.value, 'B'); + }); +}); + +describe('FieldModel.setSubSchema / setCondition', () => { + it('exposes the children of the attached sub-schema', () => { + const f = new FieldModel('inner', { $ref: '#/$defs/Inner' }, false); + const fakeSubSchema = { fields: [{ name: 'a' }, { name: 'b' }], update() {} }; + f.setSubSchema(fakeSubSchema); + assert.deepEqual(f.children, fakeSubSchema.fields); + }); + + it('exposes the condition string set via setCondition()', () => { + const f = new FieldModel('x', { type: 'string' }, false); + f.setCondition("status = 'X'"); + assert.equal(f.condition, "status = 'X'"); + }); +}); + +describe('FieldModel.getField', () => { + it('returns null for an empty path', () => { + const f = new FieldModel('x', { type: 'string' }, false); + assert.equal(f.getField(''), null); + }); +}); diff --git a/guardian-service/tests/unit/analytics-fields-rate.test.mjs b/guardian-service/tests/unit/analytics-fields-rate.test.mjs new file mode 100644 index 0000000000..2c16ca53b6 --- /dev/null +++ b/guardian-service/tests/unit/analytics-fields-rate.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { FieldsRate } from '../../dist/analytics/compare/rates/fields-rate.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const prop = (name, path, value, overrides = {}) => ({ + name, + path, + lvl: 1, + value, + ignored: false, + equal(other) { return this.value === other.value; }, + ignore() { return this.ignored; }, + getPropList() { return overrides.subProps || []; }, + toObject() { return { name: this.name, path: this.path, value: this.value }; }, + ...overrides, +}); + +const fakeField = (overrides = {}) => ({ + index: 0, + name: 'amount', + path: 'amount', + title: 'Amount', + description: 'desc', + type: 'number', + format: null, + required: false, + getPropList() { return overrides.props || [ + prop('name', 'name', 'amount'), + prop('type', 'type', 'number'), + ]; }, + toObject() { return {}; }, + ...overrides, +}); + +describe('FieldsRate construction', () => { + it('marks both-present as 100% (FULL)', () => { + const r = new FieldsRate(fakeField(), fakeField()); + assert.equal(r.totalRate, 100); + }); + + it('marks left-only with totalRate=-1', () => { + const r = new FieldsRate(fakeField(), null); + assert.equal(r.totalRate, -1); + }); + + it('marks right-only with totalRate=-1', () => { + const r = new FieldsRate(null, fakeField()); + assert.equal(r.totalRate, -1); + }); + + it('indexRate and propertiesRate start at -1', () => { + const r = new FieldsRate(fakeField(), fakeField()); + assert.equal(r.indexRate, -1); + assert.equal(r.propertiesRate, -1); + }); +}); + +describe('FieldsRate.calc', () => { + it('marks indexRate=100 when both fields share the same index', () => { + const a = fakeField({ index: 1 }); + const b = fakeField({ index: 1 }); + const r = new FieldsRate(a, b); + r.calc(opts); + assert.equal(r.indexRate, 100); + }); + + it('marks indexRate=0 when indexes differ', () => { + const a = fakeField({ index: 1 }); + const b = fakeField({ index: 2 }); + const r = new FieldsRate(a, b); + r.calc(opts); + assert.equal(r.indexRate, 0); + }); + + it('computes propertiesRate=100 when every prop matches', () => { + const props = [ + prop('name', 'name', 'amount'), + prop('type', 'type', 'number'), + ]; + const a = fakeField({ props }); + const b = fakeField({ props }); + const r = new FieldsRate(a, b); + r.calc(opts); + assert.equal(r.propertiesRate, 100); + assert.equal(r.totalRate, 100); + }); + + it('floors propertiesRate when half the props differ', () => { + const a = fakeField({ props: [ + prop('name', 'name', 'a'), + prop('type', 'type', 'X'), + ]}); + const b = fakeField({ props: [ + prop('name', 'name', 'a'), + prop('type', 'type', 'Y'), + ]}); + const r = new FieldsRate(a, b); + r.calc(opts); + assert.equal(r.propertiesRate, 50); + assert.equal(r.totalRate, 50); + }); + + it('skips index/props rates when only one side is present', () => { + const r = new FieldsRate(fakeField(), null); + r.calc(opts); + assert.equal(r.indexRate, -1); + assert.equal(r.propertiesRate, -1); + }); + + it('sorts the canonical properties (name/title/description/required/type/...) first', () => { + // Provide an out-of-order prop list — calc() reorders into the well-known order. + const props = [ + prop('extra', 'extra', 1), + prop('type', 'type', 'number'), + prop('name', 'name', 'amount'), + ]; + const a = fakeField({ props }); + const b = fakeField({ props }); + const r = new FieldsRate(a, b); + r.calc(opts); + const names = r.properties.map((p) => p.name); + // 'name' should come before 'type' even though it was last in the input. + assert.ok(names.indexOf('name') < names.indexOf('type')); + }); +}); + +describe('FieldsRate.setChildren / getChildren', () => { + it('setChildren stores its argument under .fields', () => { + const r = new FieldsRate(fakeField(), fakeField()); + const kids = [{ totalRate: 90 }, { totalRate: 80 }]; + r.setChildren(kids); + assert.equal(r.fields, kids); + assert.equal(r.getChildren(), kids); + }); +}); + +describe('FieldsRate.getRateValue', () => { + it('returns indexRate for "index"', () => { + const r = new FieldsRate(fakeField({ index: 1 }), fakeField({ index: 1 })); + r.calc(opts); + assert.equal(r.getRateValue('index'), 100); + }); + + it('returns propertiesRate for "properties"', () => { + const r = new FieldsRate(fakeField(), fakeField()); + r.calc(opts); + assert.equal(r.getRateValue('properties'), r.propertiesRate); + }); + + it('falls back to totalRate for any other name', () => { + const r = new FieldsRate(fakeField(), fakeField()); + r.calc(opts); + assert.equal(r.getRateValue('something'), r.totalRate); + }); +}); + +describe('FieldsRate.getSubRate', () => { + it('returns the properties array', () => { + const r = new FieldsRate(fakeField(), fakeField()); + r.calc(opts); + assert.equal(r.getSubRate(), r.properties); + }); +}); diff --git a/guardian-service/tests/unit/analytics-file-condition-models.test.mjs b/guardian-service/tests/unit/analytics-file-condition-models.test.mjs new file mode 100644 index 0000000000..96e85b1a13 --- /dev/null +++ b/guardian-service/tests/unit/analytics-file-condition-models.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { FileModel } from '../../dist/analytics/compare/models/file.model.js'; +import { ConditionModel } from '../../dist/analytics/compare/models/condition.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +describe('FileModel', () => { + it('captures uuid and stores hashed data on construction', () => { + const f = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + assert.equal(f.uuid, 'a-1'); + assert.ok(typeof f.data === 'string' && f.data.length > 0); + // The data is hashed, not the raw input. + assert.notEqual(f.data, 'payload'); + }); + + it('produces a non-empty weight after construction (via update)', () => { + const f = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + const h = f.hash(); + assert.ok(typeof h === 'string' && h.length > 0); + }); + + it('two files with the same uuid+data produce the same hash', () => { + const a = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + const b = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + assert.equal(a.hash(), b.hash()); + }); + + it('files with the same uuid but different data produce different hashes', () => { + const a = new FileModel({ uuid: 'a-1', data: 'payload-1' }, opts); + const b = new FileModel({ uuid: 'a-1', data: 'payload-2' }, opts); + assert.notEqual(a.hash(), b.hash()); + }); + + it('equal() compares the computed weight when present', () => { + const a = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + const b = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + const c = new FileModel({ uuid: 'b-1', data: 'payload' }, opts); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('toObject returns {uuid, data:hashed}', () => { + const f = new FileModel({ uuid: 'a-1', data: 'payload' }, opts); + const obj = f.toObject(); + assert.equal(obj.uuid, 'a-1'); + assert.equal(obj.data, f.data); + }); + + describe('FileModel.fromEntity', () => { + it('throws "Unknown artifact" for missing input', () => { + assert.throws(() => FileModel.fromEntity(null, opts), /Unknown artifact/); + assert.throws(() => FileModel.fromEntity(undefined, opts), /Unknown artifact/); + }); + + it('returns a populated FileModel for valid input', () => { + const f = FileModel.fromEntity({ uuid: 'x', data: 'd' }, opts); + assert.equal(f.uuid, 'x'); + assert.ok(f.hash().length > 0); + }); + }); +}); + +describe('ConditionModel — single field/value form', () => { + const fakeField = (name) => ({ + name, + _condition: '', + setCondition(c) { this._condition = c; }, + }); + + it('captures field, fieldValue, and exposes name from field', () => { + const f = fakeField('status'); + const c = new ConditionModel({ + field: f, + fieldValue: 'APPROVED', + thenFields: [], + elseFields: [], + }); + assert.equal(c.name, 'status'); + assert.equal(c.field, f); + assert.equal(c.fieldValue, 'APPROVED'); + }); + + it('writes a " = \'\'" condition on each then-field', () => { + const f = fakeField('status'); + const t1 = fakeField('amount'); + const t2 = fakeField('reason'); + new ConditionModel({ + field: f, + fieldValue: 'APPROVED', + thenFields: [t1, t2], + elseFields: [], + }); + assert.equal(t1._condition, "status = 'APPROVED'"); + assert.equal(t2._condition, "status = 'APPROVED'"); + }); + + it('writes a NOT(...) condition on each else-field', () => { + const f = fakeField('status'); + const e1 = fakeField('reject'); + new ConditionModel({ + field: f, + fieldValue: 'APPROVED', + thenFields: [], + elseFields: [e1], + }); + assert.equal(e1._condition, "NOT(status = 'APPROVED')"); + }); + + it('aggregates then- and else-fields into the .fields array', () => { + const f = fakeField('status'); + const t = fakeField('a'); + const e = fakeField('b'); + const c = new ConditionModel({ + field: f, + fieldValue: 'X', + thenFields: [t], + elseFields: [e], + }); + assert.equal(c.fields.length, 2); + assert.equal(c.fields[0], t); + assert.equal(c.fields[1], e); + }); +}); + +describe('ConditionModel — predicate form', () => { + const fakeField = (name) => ({ + name, + _condition: '', + setCondition(c) { this._condition = c; }, + }); + + it('joins predicates with the supplied operator', () => { + const t = fakeField('out'); + new ConditionModel({ + predicates: [ + { field: { name: 'a' }, value: 'X' }, + { field: { name: 'b' }, value: 'Y' }, + ], + operator: 'OR', + thenFields: [t], + elseFields: [], + }); + assert.equal(t._condition, "(a = 'X') OR (b = 'Y')"); + }); + + it('defaults the operator to AND when not provided', () => { + const t = fakeField('out'); + new ConditionModel({ + predicates: [ + { field: { name: 'a' }, value: 'X' }, + { field: { name: 'b' }, value: 'Y' }, + ], + thenFields: [t], + elseFields: [], + }); + assert.equal(t._condition, "(a = 'X') AND (b = 'Y')"); + }); + + it('wraps the joined expression in NOT(...) for else-fields', () => { + const e = fakeField('out'); + new ConditionModel({ + predicates: [{ field: { name: 'a' }, value: 'X' }], + thenFields: [], + elseFields: [e], + }); + assert.equal(e._condition, "NOT((a = 'X'))"); + }); + + it('emits "" when there are no predicates and no field/value', () => { + const t = fakeField('out'); + new ConditionModel({ + predicates: [], + thenFields: [t], + elseFields: [], + }); + assert.equal(t._condition, ''); + }); + + it('name is null when no field is supplied', () => { + const c = new ConditionModel({ + predicates: [{ field: { name: 'a' }, value: 'X' }], + thenFields: [], + elseFields: [], + }); + assert.equal(c.name, null); + }); +}); diff --git a/guardian-service/tests/unit/analytics-hash-comparator.test.mjs b/guardian-service/tests/unit/analytics-hash-comparator.test.mjs new file mode 100644 index 0000000000..3aee8b9f1a --- /dev/null +++ b/guardian-service/tests/unit/analytics-hash-comparator.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { HashComparator } from '../../dist/analytics/compare/comparators/hash-comparator.js'; + +const w = (label, weights = ['', '', '', '', label], children = [], length = 0) => ({ + weights, + label, + children, + length, +}); + +describe('HashComparator.options', () => { + it('exposes a documented CompareOptions instance', () => { + const o = HashComparator.options; + assert.equal(o.idLvl, 'None'); + assert.equal(o.propLvl, 'All'); + assert.equal(o.eventLvl, 'All'); + assert.equal(o.childLvl, 'All'); + }); +}); + +describe('HashComparator.createTree', () => { + it('returns null for null input', () => { + assert.equal(HashComparator.createTree(null), null); + }); + + it('emits roles/groups/topics/tokens/tree from a fake policy', () => { + const policyOptions = HashComparator.options; + const tree = HashComparator.createTree({ + options: policyOptions, + roles: [{ toWeight: () => ({ weight: 'r1' }) }], + groups: [{ toWeight: () => ({ weight: 'g1' }) }], + topics: [{ toWeight: () => ({ weight: 't1' }) }], + tokens: [{ toWeight: () => ({ weight: 'tok1' }) }], + tree: { toWeight: () => ({ weight: 'root' }) }, + }); + assert.deepEqual(tree.roles, [{ weight: 'r1' }]); + assert.deepEqual(tree.groups, [{ weight: 'g1' }]); + assert.deepEqual(tree.topics, [{ weight: 't1' }]); + assert.deepEqual(tree.tokens, [{ weight: 'tok1' }]); + assert.deepEqual(tree.tree, { weight: 'root' }); + }); + + it('omits arrays that are missing on the policy', () => { + const policyOptions = HashComparator.options; + const tree = HashComparator.createTree({ + options: policyOptions, + tree: { toWeight: () => ({ weight: 'root' }) }, + }); + assert.equal(tree.roles, undefined); + assert.equal(tree.groups, undefined); + assert.equal(tree.topics, undefined); + assert.equal(tree.tokens, undefined); + }); +}); + +describe('HashComparator.createHash / createHashMap', () => { + it('createHash returns null for null input', () => { + assert.equal(HashComparator.createHash(null), null); + }); + + it('createHash returns a deterministic non-empty string', () => { + const policy = { + options: HashComparator.options, + tree: { toWeight: () => ({ weight: 'root' }) }, + }; + const a = HashComparator.createHash(policy); + const b = HashComparator.createHash(policy); + assert.equal(a, b); + assert.ok(a.length > 0); + }); + + it('createHashMap returns null fields for null input', async () => { + const out = await HashComparator.createHashMap(null); + assert.deepEqual(out, { hash: null, hashMap: null }); + }); + + it('createHashMap returns matching {hash, hashMap}', async () => { + const policy = { + options: HashComparator.options, + tree: { toWeight: () => ({ weight: 'root' }) }, + }; + const out = await HashComparator.createHashMap(policy); + assert.ok(out.hash.length > 0); + assert.ok(out.hashMap.tree); + }); +}); + +describe('HashComparator.compare', () => { + it('returns 0 for null / missing hashMap on either side', () => { + assert.equal(HashComparator.compare(null, null), 0); + assert.equal(HashComparator.compare({ hash: 'a', hashMap: null }, { hash: 'b', hashMap: {} }), 0); + assert.equal(HashComparator.compare({ hash: 'a', hashMap: {} }, null), 0); + }); + + it('returns 100 when both hashes are identical', () => { + const policy = { + hash: 'X', + hashMap: { + roles: [], groups: [], topics: [], tokens: [], + tree: { weights: ['', '', '', '', 'type'], children: [], length: 0 }, + }, + }; + assert.equal(HashComparator.compare(policy, policy), 100); + }); + + it('produces a numeric 0–100 rate when hashes differ', () => { + const policyLeft = { + hash: 'A', + hashMap: { + roles: [], groups: [], topics: [], tokens: [], + tree: { weights: ['L1', 'L2', 'L3', 'L4', 'tree'], children: [], length: 0 }, + }, + }; + const policyRight = { + hash: 'B', + hashMap: { + roles: [], groups: [], topics: [], tokens: [], + tree: { weights: ['L1', 'L2', 'L3', 'L4', 'tree'], children: [], length: 0 }, + }, + }; + const rate = HashComparator.compare(policyLeft, policyRight); + assert.ok(rate >= 0 && rate <= 100); + }); +}); diff --git a/guardian-service/tests/unit/analytics-hash-utils.test.mjs b/guardian-service/tests/unit/analytics-hash-utils.test.mjs new file mode 100644 index 0000000000..6f160c5576 --- /dev/null +++ b/guardian-service/tests/unit/analytics-hash-utils.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { Hash3, Sha256 } from '../../dist/analytics/compare/hash/utils.js'; + +describe('Hash3', () => { + it('produces a stable result for the same sequence of values', () => { + const a = new Hash3().add('one').add('two').add('three').result(); + const b = new Hash3().add('one').add('two').add('three').result(); + assert.equal(a, b); + assert.equal(typeof a, 'string'); + assert.ok(a.length > 0); + }); + + it('hash() and add() are equivalent', () => { + const a = new Hash3().add('x').add('y').result(); + const b = new Hash3().hash('x').hash('y').result(); + assert.equal(a, b); + }); + + it('produces a different result when inputs differ', () => { + const a = new Hash3().add('x').add('y').result(); + const b = new Hash3().add('y').add('x').result(); + assert.notEqual(a, b); + }); + + it('coerces non-strings via String(...)', () => { + const a = new Hash3().add('42').result(); + const b = new Hash3().add(42).result(); + assert.equal(a, b); + }); + + it('clear() resets the running hash', () => { + const h = new Hash3().add('x').add('y'); + h.clear(); + const fresh = new Hash3(); + assert.equal(h.result(), fresh.result()); + }); + + it('add()/hash()/clear() return `this` for chaining', () => { + const h = new Hash3(); + assert.equal(h.add('x'), h); + assert.equal(h.hash('y'), h); + assert.equal(h.clear(), h); + }); + + it('static aggregate() matches the running-state result', () => { + const a = Hash3.aggregate('a', 'b', 'c'); + const b = new Hash3().add('a').add('b').add('c').result(); + assert.equal(a, b); + }); +}); + +describe('Sha256', () => { + it('hash() returns a deterministic non-empty string', () => { + const a = Sha256.hash('hello'); + const b = Sha256.hash('hello'); + assert.equal(a, b); + assert.ok(a.length > 0); + }); + + it('hash() returns different strings for different inputs', () => { + assert.notEqual(Sha256.hash('a'), Sha256.hash('b')); + }); + + it('hash(falsy) coerces to empty string (does not throw)', () => { + const empty = Sha256.hash(''); + const nullish = Sha256.hash(null); + assert.equal(empty, nullish); + }); + + it('base58() returns a non-empty deterministic string for a string input', () => { + const a = Sha256.base58('hello'); + const b = Sha256.base58('hello'); + assert.equal(a, b); + assert.ok(a.length > 0); + }); + + it('base58() returns different strings for different inputs', () => { + assert.notEqual(Sha256.base58('a'), Sha256.base58('b')); + }); + + it('base58() returns "" on internal errors (e.g. unhashable input)', () => { + // crypto.createHash().update(undefined) throws — exercise that catch path. + assert.equal(Sha256.base58(undefined), ''); + }); +}); diff --git a/guardian-service/tests/unit/analytics-merge-utils.test.mjs b/guardian-service/tests/unit/analytics-merge-utils.test.mjs new file mode 100644 index 0000000000..22373ffdad --- /dev/null +++ b/guardian-service/tests/unit/analytics-merge-utils.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { MergeUtils } from '../../dist/analytics/compare/utils/merge-utils.js'; + +const wm = (key, weights = [], opts = {}) => ({ + key, + getWeights: () => weights, + maxWeight: opts.maxWeight ?? (() => weights.length || 1), + checkWeight: opts.checkWeight ?? (() => true), + equal: opts.equal ?? ((other) => other?.key === key), +}); + +describe('MergeUtils.fullMerge', () => { + it('pairs items 1:1 by index', () => { + const a = [wm('a1'), wm('a2')]; + const b = [wm('b1'), wm('b2')]; + const out = MergeUtils.fullMerge(a, b); + assert.equal(out.length, 2); + assert.equal(out[0].left, a[0]); + assert.equal(out[0].right, b[0]); + assert.equal(out[0].key, 'a1'); + }); + + it('fills the shorter side with undefined and uses the right-side key when left is missing', () => { + const a = [wm('a1')]; + const b = [wm('b1'), wm('b2')]; + const out = MergeUtils.fullMerge(a, b); + assert.equal(out.length, 2); + assert.equal(out[1].left, undefined); + assert.equal(out[1].right, b[1]); + assert.equal(out[1].key, 'b2'); + }); + + it('returns [] when both inputs are empty', () => { + assert.deepEqual(MergeUtils.fullMerge([], []), []); + }); +}); + +describe('MergeUtils.notMerge', () => { + it('emits left-only and right-only entries (no pairing)', () => { + const a = [wm('a1'), wm('a2')]; + const b = [wm('b1')]; + const out = MergeUtils.notMerge(a, b); + assert.equal(out.length, 3); + assert.equal(out[0].right, null); + assert.equal(out[1].right, null); + assert.equal(out[2].left, null); + }); + + it('handles a missing/undefined input safely', () => { + const a = [wm('x')]; + const out = MergeUtils.notMerge(a, undefined); + assert.equal(out.length, 1); + assert.equal(out[0].right, null); + }); + + it('returns [] when both sides are empty', () => { + assert.deepEqual(MergeUtils.notMerge([], []), []); + }); +}); + +describe('MergeUtils.fullMultiMerge', () => { + it('aligns multiple lists column-by-column up to the longest', () => { + const lists = [ + [wm('x1'), wm('x2'), wm('x3')], + [wm('y1')], + [wm('z1'), wm('z2')], + ]; + const out = MergeUtils.fullMultiMerge(lists); + assert.equal(out.length, 3); + assert.equal(out[0].items.length, 3); + assert.equal(out[1].items[1], undefined); + assert.equal(out[2].items[1], undefined); + }); + + it('uses the first non-null key in the row as the row key', () => { + const lists = [ + [], + [wm('only-y')], + ]; + const out = MergeUtils.fullMultiMerge(lists); + assert.equal(out[0].key, 'only-y'); + }); +}); + +describe('MergeUtils.notMultiMerge', () => { + it('emits one row per item per array, with the rest of the slots null', () => { + const lists = [ + [wm('a1')], + [wm('b1'), wm('b2')], + ]; + const out = MergeUtils.notMultiMerge(lists); + assert.equal(out.length, 3); + assert.equal(out[0].items[0].key, 'a1'); + assert.equal(out[0].items[1], null); + assert.equal(out[1].items[0], null); + assert.equal(out[1].items[1].key, 'b1'); + }); +}); + +describe('MergeUtils.partlyMerge', () => { + it('pairs equal items via .equal() under checkWeight()', () => { + const left = [wm('match'), wm('only-left')]; + const right = [wm('match')]; + const out = MergeUtils.partlyMerge(left, right); + const matched = out.find((r) => r.left?.key === 'match'); + assert.ok(matched.right); + assert.equal(matched.right.key, 'match'); + }); + + it('appends right-only entries for unmatched right items', () => { + const left = [wm('a')]; + const right = [wm('b')]; + const out = MergeUtils.partlyMerge(left, right); + const orphan = out.find((r) => r.left === null); + assert.ok(orphan); + assert.equal(orphan.right.key, 'b'); + }); +}); + +describe('MergeUtils.mapping', () => { + it('attaches a child to the first unpaired left whose .equal() succeeds', () => { + const left = wm('match'); + const result = [{ key: 'match', left, right: null }]; + const child = wm('match'); + const ok = MergeUtils.mapping(result, child, 0); + assert.equal(ok, true); + assert.equal(result[0].right, child); + }); + + it('returns false when no row matches', () => { + const left = wm('a'); + const result = [{ key: 'a', left, right: null }]; + const ok = MergeUtils.mapping(result, wm('b'), 0); + assert.equal(ok, false); + assert.equal(result[0].right, null); + }); + + it('falls back to a key-equality match when checkWeight is false', () => { + const left = wm('k', [], { checkWeight: () => false, equal: () => false }); + const result = [{ key: 'k', left, right: null }]; + const ok = MergeUtils.mapping(result, wm('k'), 0); + assert.equal(ok, true); + }); +}); + +describe('MergeUtils.getDiff', () => { + it('returns 0 when either side is missing', () => { + const item = wm('x', [1, 2, 3]); + assert.equal(MergeUtils.getDiff(null, item), 0); + assert.equal(MergeUtils.getDiff(item, null), 0); + }); + + it('returns 100 when all weights match exactly', () => { + const a = wm('x', [1, 2, 3]); + const b = wm('y', [1, 2, 3]); + assert.equal(MergeUtils.getDiff(a, b), 100); + }); + + it('subtracts 1/(N+1) per differing weight, then floors *100', () => { + const a = wm('x', [1, 2, 3, 4]); // N=4 + const b = wm('y', [1, 9, 9, 4]); // 2 differ + // result = 1 - 2/(4+1) = 0.6 -> floor(60) = 60 + assert.equal(MergeUtils.getDiff(a, b), 60); + }); + + it('clamps to 0 when many weights differ', () => { + const a = wm('x', [1, 1, 1]); // N=3 + const b = wm('y', [9, 9, 9, 9]); // all 3 differ; 4th compared but absent on a + // 1 - 3/(3+1) = 0.25 -> 25 + assert.equal(MergeUtils.getDiff(a, b), 25); + }); +}); diff --git a/guardian-service/tests/unit/analytics-module-comparator.test.mjs b/guardian-service/tests/unit/analytics-module-comparator.test.mjs new file mode 100644 index 0000000000..ba78307899 --- /dev/null +++ b/guardian-service/tests/unit/analytics-module-comparator.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { ModuleComparator } from '../../dist/analytics/compare/comparators/module-comparator.js'; +import { ModuleModel } from '../../dist/analytics/compare/models/module.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const minimalConfig = (overrides = {}) => ({ + blockType: 'root', tag: 'root', children: [], + inputEvents: [], outputEvents: [], variables: [], ...overrides +}); + +const model = (overrides = {}) => new ModuleModel({ + id: 'mod-1', name: 'My Module', description: 'desc', + config: minimalConfig(), ...overrides +}, opts); + +describe('ModuleComparator', () => { + it('constructs with default options when none are supplied', () => { + assert.ok(new ModuleComparator()); + }); + + it('compare returns left/right info plus a numeric total', () => { + const result = new ModuleComparator().compare(model(), model()); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + }); + + it('total is within 0..100', () => { + const result = new ModuleComparator().compare(model(), model()); + assert.ok(result.total >= 0 && result.total <= 100); + }); + + it('produces block/inputEvents/outputEvents/variables report sections', () => { + const result = new ModuleComparator().compare(model(), model()); + assert.ok(Array.isArray(result.blocks.columns)); + assert.ok(Array.isArray(result.blocks.report)); + assert.ok(Array.isArray(result.inputEvents.report)); + assert.ok(Array.isArray(result.outputEvents.report)); + assert.ok(Array.isArray(result.variables.report)); + }); + + it('two identical single-block modules compare as fully similar', () => { + const result = new ModuleComparator().compare(model(), model()); + assert.equal(result.total, 100); + }); + + it('aggregates input events into the input report', () => { + const m1 = model({ config: minimalConfig({ inputEvents: [{ name: 'IE1' }] }) }); + const m2 = model({ config: minimalConfig({ inputEvents: [{ name: 'IE1' }] }) }); + const result = new ModuleComparator().compare(m1, m2); + assert.ok(result.inputEvents.report.length >= 1); + }); + + it('aggregates output events into the output report', () => { + const m1 = model({ config: minimalConfig({ outputEvents: [{ name: 'OE1' }] }) }); + const m2 = model({ config: minimalConfig({ outputEvents: [{ name: 'OE1' }] }) }); + const result = new ModuleComparator().compare(m1, m2); + assert.ok(result.outputEvents.report.length >= 1); + }); + + it('aggregates variables into the variables report', () => { + const m1 = model({ config: minimalConfig({ variables: [{ name: 'V1', type: 'Number' }] }) }); + const m2 = model({ config: minimalConfig({ variables: [{ name: 'V1', type: 'Number' }] }) }); + const result = new ModuleComparator().compare(m1, m2); + assert.ok(result.variables.report.length >= 1); + }); + + it('the block report column set includes total_rate', () => { + const result = new ModuleComparator().compare(model(), model()); + assert.ok(result.blocks.columns.some((c) => c.name === 'total_rate')); + }); + + it('reports a row per block in the tree', () => { + const m1 = model({ config: minimalConfig({ children: [{ blockType: 'block', tag: 'c1' }] }) }); + const m2 = model({ config: minimalConfig({ children: [{ blockType: 'block', tag: 'c1' }] }) }); + const result = new ModuleComparator().compare(m1, m2); + assert.ok(result.blocks.report.length >= 2); + }); +}); diff --git a/guardian-service/tests/unit/analytics-module-model.test.mjs b/guardian-service/tests/unit/analytics-module-model.test.mjs new file mode 100644 index 0000000000..a4afbcced9 --- /dev/null +++ b/guardian-service/tests/unit/analytics-module-model.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { ModuleModel } from '../../dist/analytics/compare/models/module.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const minimalConfig = (overrides = {}) => ({ + blockType: 'root', + tag: 'root', + children: [], + inputEvents: [], + outputEvents: [], + variables: [], + ...overrides, +}); + +const rawModule = (overrides = {}) => ({ + id: 'mod-1', + name: 'My Module', + description: 'desc', + config: minimalConfig(), + ...overrides, +}); + +describe('ModuleModel construction', () => { + it('captures id/name/description', () => { + const m = new ModuleModel(rawModule(), opts); + assert.equal(m.id, 'mod-1'); + assert.equal(m.name, 'My Module'); + assert.equal(m.description, 'desc'); + }); + + it('throws "Empty policy model" when config is missing', () => { + const raw = { id: 'm', name: 'n', description: 'd' }; + assert.throws(() => new ModuleModel(raw, opts), /Empty policy model/); + }); + + it('builds the tree from config and exposes its root', () => { + const m = new ModuleModel(rawModule({ config: minimalConfig({ blockType: 'group', tag: 'g' }) }), opts); + assert.ok(m.tree); + assert.equal(m.tree.blockType, 'group'); + }); + + it('parses inputEvents/outputEvents into VariableModels', () => { + const m = new ModuleModel(rawModule({ + config: minimalConfig({ + inputEvents: [{ name: 'IE1' }], + outputEvents: [{ name: 'OE1' }], + }), + }), opts); + assert.equal(m.inputEvents.length, 1); + assert.equal(m.inputEvents[0].name, 'IE1'); + assert.equal(m.outputEvents.length, 1); + assert.equal(m.outputEvents[0].name, 'OE1'); + }); + + it('parses variables into VariableModels', () => { + const m = new ModuleModel(rawModule({ + config: minimalConfig({ variables: [{ name: 'V1', type: 'Number' }] }), + }), opts); + assert.equal(m.variables.length, 1); + assert.equal(m.variables[0].name, 'V1'); + }); + + it('handles missing inputEvents/outputEvents/variables arrays as []', () => { + const m = new ModuleModel(rawModule({ + config: minimalConfig({ + inputEvents: undefined, + outputEvents: undefined, + variables: undefined, + }), + }), opts); + assert.deepEqual(m.inputEvents, []); + assert.deepEqual(m.outputEvents, []); + assert.deepEqual(m.variables, []); + }); +}); + +describe('ModuleModel.info', () => { + it('returns {id, name, description}', () => { + const m = new ModuleModel(rawModule(), opts); + assert.deepEqual(m.info(), { id: 'mod-1', name: 'My Module', description: 'desc' }); + }); +}); + +describe('ModuleModel.update', () => { + it('returns the model for chaining', () => { + const m = new ModuleModel(rawModule(), opts); + assert.equal(m.update(), m); + }); +}); + +describe('ModuleModel.getAllProp', () => { + it('returns [] when there are no blocks with matching prop type', () => { + const m = new ModuleModel(rawModule(), opts); + assert.deepEqual(m.getAllProp('property'), []); + }); +}); diff --git a/guardian-service/tests/unit/analytics-multi-compare-utils.test.mjs b/guardian-service/tests/unit/analytics-multi-compare-utils.test.mjs new file mode 100644 index 0000000000..7b884f9a85 --- /dev/null +++ b/guardian-service/tests/unit/analytics-multi-compare-utils.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { MultiCompareUtils } from '../../dist/analytics/compare/utils/multi-compare-utils.js'; + +const reportRow = (left, right) => ({ left, right }); + +describe('MultiCompareUtils.mergeTables', () => { + it('returns one entry per left-side row, with cols sized to inputs+1', () => { + const t1 = { report: [reportRow('a', null), reportRow('b', null)] }; + const t2 = { report: [reportRow('a', null), reportRow('b', null)] }; + const out = MultiCompareUtils.mergeTables([t1, t2]); + assert.equal(out.length, 2); + assert.equal(out[0].cols.length, 3); + }); + + it('places left rows on column 0 (mirrored from leftmost left input)', () => { + const t1 = { report: [reportRow('A', null)] }; + const t2 = { report: [reportRow('A', null)] }; + const out = MultiCompareUtils.mergeTables([t1, t2]); + assert.equal(out[0].cols[0], out[0].cols[1]); + }); + + it('appends a right-only row as a sub-row of the last left row', () => { + const t1 = { report: [reportRow('A', null)] }; + const t2 = { report: [reportRow(null, 'right-only')] }; + const out = MultiCompareUtils.mergeTables([t1, t2]); + // mainIndex=1 (one left row added), subIndex=1 for the right-only entry. + const sub = out.find((r) => r.subIndex === 1); + assert.ok(sub, 'sub-row missing'); + assert.equal(sub.cols[2].right, 'right-only'); + }); + + it('sorts rows by (mainIndex, subIndex)', () => { + const t1 = { + report: [ + reportRow('first', null), + reportRow('second', null), + ], + }; + const t2 = { + report: [ + reportRow('first', null), + reportRow(null, 'right-after-first'), + reportRow('second', null), + ], + }; + const out = MultiCompareUtils.mergeTables([t1, t2]); + // Order: (1,0), (1,1), (2,0) — verifies stable sort. + const labels = out.map((r) => `${r.mainIndex}.${r.subIndex}`); + assert.deepEqual(labels, ['1.0', '1.1', '2.0']); + }); +}); + +describe('MultiCompareUtils.mergeRates', () => { + const rateRow = (left, right, totalRate = 100, type = 'FULL') => ({ + type, + totalRate, + items: [left, right], + }); + + it('returns one entry per left rate', () => { + const left = [rateRow({ k: 'a' }, null), rateRow({ k: 'b' }, null)]; + const right = [rateRow({ k: 'a' }, { k: 'a' }), rateRow({ k: 'b' }, { k: 'b' })]; + const out = MultiCompareUtils.mergeRates([left, right]); + // Each merged result row has cols sized to the number of input rate-tables. + assert.equal(out.length >= 2, true); + assert.equal(out[0].cols.length, 2); + }); + + it('left rates produce items with item: items[0]', () => { + const left = [rateRow({ k: 'a' }, null)]; + const right = []; + const out = MultiCompareUtils.mergeRates([left, right]); + assert.deepEqual(out[0].cols[0].item, { k: 'a' }); + }); + + it('right rates produce items with item: items[1]', () => { + const left = [rateRow({ k: 'a' }, null)]; + const right = [rateRow({ k: 'a' }, { k: 'a-from-right' })]; + const out = MultiCompareUtils.mergeRates([left, right]); + const merged = out.find((r) => r.cols[1]); + assert.deepEqual(merged.cols[1].item, { k: 'a-from-right' }); + }); + + it('handles a missing left table by emitting only right entries', () => { + const right = [rateRow({ k: 'a' }, { k: 'a' })]; + const out = MultiCompareUtils.mergeRates([null, right]); + assert.equal(out.length, 1); + assert.deepEqual(out[0].cols[1].item, { k: 'a' }); + }); + + it('skips a left rate whose items[0] is missing', () => { + const left = [rateRow(null, null)]; + const right = []; + const out = MultiCompareUtils.mergeRates([left, right]); + assert.equal(out.length, 0); + }); +}); diff --git a/guardian-service/tests/unit/analytics-policy-model.test.mjs b/guardian-service/tests/unit/analytics-policy-model.test.mjs new file mode 100644 index 0000000000..0c6b7eb79b --- /dev/null +++ b/guardian-service/tests/unit/analytics-policy-model.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { PolicyModel } from '../../dist/analytics/compare/models/policy.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const minimalConfig = (overrides = {}) => ({ + blockType: 'root', + tag: 'root', + children: [], + ...overrides, +}); + +const rawPolicy = (overrides = {}) => ({ + id: 'p-1', + name: 'Policy', + description: 'desc', + instanceTopicId: '0.0.1', + version: '1.0.0', + config: minimalConfig(), + policyRoles: [], + policyGroups: [], + policyTopics: [], + policyTokens: [], + tools: [], + ...overrides, +}); + +describe('PolicyModel construction', () => { + it('captures id/name/description/instanceTopicId/version', () => { + const m = new PolicyModel(rawPolicy(), opts); + assert.equal(m.id, 'p-1'); + assert.equal(m.name, 'Policy'); + assert.equal(m.description, 'desc'); + assert.equal(m.instanceTopicId, '0.0.1'); + assert.equal(m.version, '1.0.0'); + }); + + it('throws "Empty policy model" when config is missing', () => { + assert.throws(() => new PolicyModel(rawPolicy({ config: undefined }), opts), /Empty policy model/); + }); + + it('parses an empty arrays of roles/groups/topics/tokens/tools as []', () => { + const m = new PolicyModel(rawPolicy(), opts); + assert.deepEqual(m.roles, []); + assert.deepEqual(m.groups, []); + assert.deepEqual(m.topics, []); + assert.deepEqual(m.tokens, []); + assert.deepEqual(m.tools, []); + }); + + it('parses non-empty roles/groups/topics/tokens/tools', () => { + const m = new PolicyModel(rawPolicy({ + policyRoles: ['OWNER', 'VIEWER'], + policyGroups: [{ name: 'Owners' }], + policyTopics: [{ name: 'main' }], + policyTokens: [{ templateTokenTag: 'tt' }], + tools: [{ messageId: 'mt-1' }], + }), opts); + assert.equal(m.roles.length, 2); + assert.equal(m.groups.length, 1); + assert.equal(m.topics.length, 1); + assert.equal(m.tokens.length, 1); + assert.equal(m.tools.length, 1); + }); + + it('builds the tree from config', () => { + const m = new PolicyModel(rawPolicy({ config: minimalConfig({ tag: 'root-A' }) }), opts); + assert.ok(m.tree); + assert.equal(m.tree.tag, 'root-A'); + }); +}); + +describe('PolicyModel.info', () => { + it('returns identifiers + the default type field "id"', () => { + const m = new PolicyModel(rawPolicy(), opts); + const info = m.info(); + assert.equal(info.id, 'p-1'); + assert.equal(info.type, 'id'); + }); +}); + +describe('PolicyModel.set* mutators', () => { + it('returns the model for chaining', () => { + const m = new PolicyModel(rawPolicy(), opts); + assert.equal(m.setSchemas([]), m); + assert.equal(m.setArtifacts([]), m); + assert.equal(m.setTokens([]), m); + assert.equal(m.setType('hash'), m); + assert.equal(m.info().type, 'hash'); + }); +}); + +describe('PolicyModel.update', () => { + it('returns the model for chaining when set* arrays are populated', () => { + const m = new PolicyModel(rawPolicy(), opts) + .setSchemas([]) + .setArtifacts([]) + .setTokens([]); + assert.equal(m.update(), m); + }); +}); + +describe('PolicyModel.getAllProp', () => { + it('returns [] for an empty policy with no matching prop type', () => { + const m = new PolicyModel(rawPolicy(), opts); + assert.deepEqual(m.getAllProp('property'), []); + }); +}); + +describe('PolicyModel.fromEntity', () => { + it('throws "Unknown policy" for missing input', () => { + assert.throws(() => PolicyModel.fromEntity(null, opts), /Unknown policy/); + }); + + it('builds a populated PolicyModel for valid input', () => { + const m = PolicyModel.fromEntity(rawPolicy(), opts); + assert.equal(m.id, 'p-1'); + }); +}); diff --git a/guardian-service/tests/unit/analytics-properties-model.test.mjs b/guardian-service/tests/unit/analytics-properties-model.test.mjs new file mode 100644 index 0000000000..88c9874988 --- /dev/null +++ b/guardian-service/tests/unit/analytics-properties-model.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { PropertiesModel } from '../../dist/analytics/compare/models/properties.model.js'; +import { VariableModel } from '../../dist/analytics/compare/models/variable.model.js'; + +const opts = (overrides = {}) => ({ + propLvl: 'All', + keyLvl: 'Default', + idLvl: 'All', + ...overrides, +}); + +describe('PropertiesModel.createPropList', () => { + it('returns [] for an empty object', () => { + const list = PropertiesModel.createPropList({}); + assert.deepEqual(list, []); + }); + + it('emits AnyPropertyModel for scalar leaves', () => { + const list = PropertiesModel.createPropList({ a: 'x', b: 1, c: true }); + assert.equal(list.length, 3); + assert.equal(list[0].type, 'property'); + assert.equal(list[0].path, 'a'); + assert.equal(list[0].value, 'x'); + assert.equal(list[1].value, 1); + assert.equal(list[2].value, true); + }); + + it('emits ArrayPropertyModel + recursively expands array elements', () => { + const list = PropertiesModel.createPropList({ list: [10, 20] }); + const arrayProp = list.find((p) => p.path === 'list'); + assert.equal(arrayProp.type, 'array'); + assert.equal(arrayProp.value, 2); // length + const elements = list.filter((p) => p.path.startsWith('list.')); + assert.equal(elements.length, 2); + assert.equal(elements[0].value, 10); + assert.equal(elements[1].value, 20); + }); + + it('emits ObjectPropertyModel + recurses into object children', () => { + const list = PropertiesModel.createPropList({ obj: { a: 1, b: 2 } }); + const objProp = list.find((p) => p.path === 'obj'); + assert.equal(objProp.type, 'object'); + assert.equal(objProp.value, true); // truthy = has keys + const a = list.find((p) => p.path === 'obj.a'); + const b = list.find((p) => p.path === 'obj.b'); + assert.equal(a.value, 1); + assert.equal(b.value, 2); + }); + + it('classifies any name matching /[Ss]chema/ as a SchemaPropertyModel', () => { + const list = PropertiesModel.createPropList({ inputSchema: 'iri-1' }); + assert.equal(list[0].type, 'schema'); + }); + + it('classifies any name matching /[Tt]oken/ as a TokenPropertyModel', () => { + const list = PropertiesModel.createPropList({ tokenId: '0.0.1' }); + assert.equal(list[0].type, 'token'); + }); + + it('skips undefined values', () => { + const list = PropertiesModel.createPropList({ a: undefined, b: 1 }); + assert.equal(list.length, 1); + assert.equal(list[0].path, 'b'); + }); + + it('marks an empty-keys object with value=false', () => { + const list = PropertiesModel.createPropList({ obj: {} }); + assert.equal(list[0].value, false); + }); +}); + +describe('PropertiesModel instance methods', () => { + it('getPropList() returns a copy (not the underlying list)', () => { + const m = new PropertiesModel({ a: 1, b: 2 }); + const list1 = m.getPropList(); + list1.push({ name: 'mutated' }); + const list2 = m.getPropList(); + assert.notEqual(list1, list2); + assert.equal(list2.length, 2); + }); + + it('getPropList(type) filters by type', () => { + const m = new PropertiesModel({ a: 1, list: [9] }); + const arrays = m.getPropList('array'); + assert.equal(arrays.length, 1); + assert.equal(arrays[0].path, 'list'); + }); + + it('hash() joins each property hash with a comma, skipping nulls', () => { + const m = new PropertiesModel({ a: 1, b: 'x' }); + const h = m.hash(opts()); + assert.ok(h.includes('a:1')); + assert.ok(h.includes('b:x')); + assert.ok(h.includes(',')); + }); + + it('toObject() returns each property serialized', () => { + const m = new PropertiesModel({ a: 1 }); + const out = m.toObject(); + assert.equal(out.length, 1); + assert.equal(out[0].name, 'a'); + assert.equal(out[0].value, 1); + }); + + it('handles non-object input gracefully (empty list)', () => { + const m = new PropertiesModel(null); + assert.deepEqual(m.toObject(), []); + assert.equal(m.hash(opts()), ''); + }); +}); + +describe('VariableModel', () => { + it('exposes name as the key', () => { + const v = new VariableModel({ name: 'maxYield', type: 'Number' }); + assert.equal(v.key, 'maxYield'); + }); + + it('starts with empty weights and getWeight()=undefined', () => { + const v = new VariableModel({ name: 'x' }); + assert.deepEqual(v.getWeights(), []); + assert.equal(v.maxWeight(), 0); + assert.equal(v.getWeight(), undefined); + }); + + it('update() populates two weights (group_lvl_0 and group_lvl_1)', () => { + const v = new VariableModel({ name: 'x', type: 'Number' }); + v.update(opts()); + const weights = v.getWeights(); + assert.equal(weights.length, 2); + assert.ok(weights[0].length > 0); + assert.ok(weights[1].length > 0); + assert.equal(v.maxWeight(), 2); + }); + + it('checkWeight(i) returns true for valid indexes after update()', () => { + const v = new VariableModel({ name: 'x' }); + v.update(opts()); + assert.equal(v.checkWeight(0), true); + assert.equal(v.checkWeight(1), true); + assert.equal(v.checkWeight(2), false); + }); + + it('falls back to comparing names when weights are empty', () => { + const a = new VariableModel({ name: 'foo' }); + const b = new VariableModel({ name: 'foo' }); + const c = new VariableModel({ name: 'bar' }); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at index=undefined compares the strongest (index 0) weight', () => { + const a = new VariableModel({ name: 'x', type: 'Number' }); + const b = new VariableModel({ name: 'x', type: 'Number' }); + const c = new VariableModel({ name: 'x', type: 'String' }); + a.update(opts()); + b.update(opts()); + c.update(opts()); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('toObject returns the documented {name, properties} shape', () => { + const v = new VariableModel({ name: 'x', type: 'Number', value: 1 }); + const out = v.toObject(); + assert.equal(out.name, 'x'); + assert.ok(Array.isArray(out.properties)); + }); + + it('equalKey() compares by name', () => { + const a = new VariableModel({ name: 'x' }); + const b = new VariableModel({ name: 'x' }); + const c = new VariableModel({ name: 'y' }); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); +}); diff --git a/guardian-service/tests/unit/analytics-properties-rate.test.mjs b/guardian-service/tests/unit/analytics-properties-rate.test.mjs new file mode 100644 index 0000000000..555b7bf07e --- /dev/null +++ b/guardian-service/tests/unit/analytics-properties-rate.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { PropertiesRate } from '../../dist/analytics/compare/rates/properties-rate.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const prop = (name, path, value, overrides = {}) => ({ + name, + path, + lvl: 1, + value, + ignored: false, + equal(other) { return this.value === other.value; }, + ignore() { return this.ignored; }, + getPropList() { return overrides.subProps || []; }, + toObject() { return { name: this.name, path: this.path, value: this.value }; }, + ...overrides, +}); + +describe('PropertiesRate construction', () => { + it('captures both sides and copies name/path/lvl from the left when present', () => { + const a = prop('amt', 'a.amt', 1); + const b = prop('amt', 'a.amt', 2); + const r = new PropertiesRate(a, b); + assert.equal(r.left, a); + assert.equal(r.right, b); + assert.equal(r.name, 'amt'); + assert.equal(r.path, 'a.amt'); + assert.equal(r.lvl, 1); + }); + + it('falls back to the right side when left is missing', () => { + const b = prop('amt', 'a.amt', 2); + const r = new PropertiesRate(null, b); + assert.equal(r.name, 'amt'); + assert.equal(r.path, 'a.amt'); + }); + + it('totalRate and propertiesRate start at -1', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + assert.equal(r.totalRate, -1); + assert.equal(r.propertiesRate, -1); + }); +}); + +describe('PropertiesRate.calc — basic comparison', () => { + it('marks identical leaves as 100% (FULL)', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + r.calc(opts); + assert.equal(r.totalRate, 100); + }); + + it('marks differing leaves as 0% (PARTLY)', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 2)); + r.calc(opts); + assert.equal(r.totalRate, 0); + }); + + it('a left-only ignored property is treated as 100%', () => { + const left = prop('a', 'a', 1, { ignored: true }); + const r = new PropertiesRate(left, null); + r.calc(opts); + assert.equal(r.totalRate, 100); + assert.equal(r.propertiesRate, 100); + }); + + it('a right-only ignored property is treated as 100%', () => { + const right = prop('a', 'a', 1, { ignored: true }); + const r = new PropertiesRate(null, right); + r.calc(opts); + assert.equal(r.totalRate, 100); + assert.equal(r.propertiesRate, 100); + }); + + it('a left-only non-ignored property keeps totalRate at -1', () => { + const r = new PropertiesRate(prop('a', 'a', 1), null); + r.calc(opts); + assert.equal(r.totalRate, -1); + }); + + it('an ignored value that equals the other side stays at 100', () => { + const left = prop('a', 'a', 1, { ignored: true }); + const right = prop('a', 'a', 1); + const r = new PropertiesRate(left, right); + r.calc(opts); + assert.equal(r.totalRate, 100); + assert.equal(r.propertiesRate, 100); + }); +}); + +describe('PropertiesRate.calc — with sub-properties', () => { + it('averages child rates into propertiesRate, then averages with self', () => { + const childA1 = prop('c', 'a.c', 'X'); + const childA2 = prop('c', 'a.c', 'X'); // matches + const childB1 = prop('d', 'a.d', 'Y'); + const childB2 = prop('d', 'a.d', 'Z'); // differs + const left = prop('a', 'a', 'top', { subProps: [childA1, childB1] }); + const right = prop('a', 'a', 'top', { subProps: [childA2, childB2] }); + const r = new PropertiesRate(left, right); + r.calc(opts); + // selfRate=100 (top values equal), child rates are [100, 0] → propertiesRate=50 + // totalRate = floor((100 + 50)/2) = 75 + assert.equal(r.propertiesRate, 50); + assert.equal(r.totalRate, 75); + }); + + it('falls through propertiesRate=totalRate when there are no sub-props', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + r.calc(opts); + assert.equal(r.propertiesRate, r.totalRate); + }); +}); + +describe('PropertiesRate.toObject / total / getSubRate / getRateValue', () => { + it('toObject includes name/path/lvl + serialized items', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + r.calc(opts); + const o = r.toObject(); + assert.equal(o.name, 'a'); + assert.equal(o.path, 'a'); + assert.equal(o.lvl, 1); + assert.equal(o.totalRate, 100); + assert.equal(o.items.length, 2); + }); + + it('toObject tolerates a missing side (items entry undefined)', () => { + const r = new PropertiesRate(prop('a', 'a', 1), null); + const o = r.toObject(); + assert.equal(o.items[0].name, 'a'); + assert.equal(o.items[1], undefined); + }); + + it('getSubRate() returns the child rates', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + r.calc(opts); + assert.deepEqual(r.getSubRate(), r.properties); + }); + + it('getRateValue() returns totalRate regardless of name', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 2)); + r.calc(opts); + assert.equal(r.getRateValue('properties'), r.totalRate); + assert.equal(r.getRateValue('whatever'), r.totalRate); + }); + + it('total() returns totalRate (no recursion)', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + r.calc(opts); + assert.equal(r.total(), r.totalRate); + }); + + it('setChildren()/getChildren() are no-ops at this rate level', () => { + const r = new PropertiesRate(prop('a', 'a', 1), prop('a', 'a', 1)); + assert.doesNotThrow(() => r.setChildren([{ totalRate: 5 }])); + assert.deepEqual(r.getChildren(), []); + }); +}); diff --git a/guardian-service/tests/unit/analytics-property-model.test.mjs b/guardian-service/tests/unit/analytics-property-model.test.mjs new file mode 100644 index 0000000000..2792e91729 --- /dev/null +++ b/guardian-service/tests/unit/analytics-property-model.test.mjs @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { + PropertyModel, + UUIDPropertyModel, + AnyPropertyModel, + ArrayPropertyModel, + ObjectPropertyModel, + DocumentPropertyModel, +} from '../../dist/analytics/compare/models/property.model.js'; + +// Match the string-valued IIdLvl/IPropertiesLvl/IKeyLvl enums. +const NONE = 'None'; +const ALL = 'All'; +const SIMPLE = 'Simple'; +const DEFAULT = 'Default'; +const DESCRIPTION = 'Description'; +const TITLE = 'Title'; +const PROPERTY = 'Property'; + +const opts = (overrides = {}) => ({ + propLvl: ALL, + keyLvl: DEFAULT, + idLvl: ALL, + ...overrides, +}); + +describe('PropertyModel construction', () => { + it('defaults lvl to 1 and path to name', () => { + const p = new PropertyModel('foo', 'property', 'val'); + assert.equal(p.name, 'foo'); + assert.equal(p.lvl, 1); + assert.equal(p.path, 'foo'); + assert.equal(p.value, 'val'); + }); + + it('accepts explicit lvl and path', () => { + const p = new PropertyModel('foo', 'property', 'val', 3, 'a.b.foo'); + assert.equal(p.lvl, 3); + assert.equal(p.path, 'a.b.foo'); + }); + + it('exposes path as the initial key', () => { + const p = new PropertyModel('foo', 'property', 'val', 1, 'a.b.foo'); + assert.equal(p.key, 'a.b.foo'); + }); + + it('starts with an empty sub-property list', () => { + const p = new PropertyModel('foo', 'property', 'val'); + assert.deepEqual(p.getPropList(), []); + }); +}); + +describe('PropertyModel.equal', () => { + it('compares by type and stringified value (the weight)', () => { + const a = new PropertyModel('x', 'property', 'v'); + const b = new PropertyModel('x', 'property', 'v'); + assert.equal(a.equal(b, opts()), true); + }); + + it('returns false when types differ', () => { + const a = new PropertyModel('x', 'property', 'v'); + const b = new PropertyModel('x', 'array', 'v'); + assert.equal(a.equal(b, opts()), false); + }); + + it('returns false when values differ', () => { + const a = new PropertyModel('x', 'property', 'v1'); + const b = new PropertyModel('x', 'property', 'v2'); + assert.equal(a.equal(b, opts()), false); + }); +}); + +describe('PropertyModel.hash', () => { + it('returns "path:value" at any level when propLvl is non-Simple', () => { + const p = new PropertyModel('x', 'property', 'v', 3, 'a.b.x'); + assert.equal(p.hash(opts({ propLvl: ALL })), 'a.b.x:v'); + }); + + it('returns null at lvl > 1 when propLvl=Simple', () => { + const p = new PropertyModel('x', 'property', 'v', 2); + assert.equal(p.hash(opts({ propLvl: SIMPLE })), null); + }); + + it('returns "path:value" at lvl=1 when propLvl=Simple', () => { + const p = new PropertyModel('x', 'property', 'v', 1); + assert.equal(p.hash(opts({ propLvl: SIMPLE })), 'x:v'); + }); +}); + +describe('PropertyModel.update (key strategy)', () => { + it('falls back to path when description/title/property keys are unset', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.update(opts({ keyLvl: DESCRIPTION })); + assert.equal(p.key, 'a.x'); + p.update(opts({ keyLvl: TITLE })); + assert.equal(p.key, 'a.x'); + p.update(opts({ keyLvl: PROPERTY })); + assert.equal(p.key, 'a.x'); + }); + + it('uses the description when keyLvl=Description and description is set', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.setDescription('desc'); + p.update(opts({ keyLvl: DESCRIPTION })); + assert.equal(p.key, 'desc'); + }); + + it('uses the title when keyLvl=Title and title is set', () => { + const p = new PropertyModel('x', 'property', 'v'); + p.setTitle('A Title'); + p.update(opts({ keyLvl: TITLE })); + assert.equal(p.key, 'A Title'); + }); + + it('uses the property string when keyLvl=Property and property is set', () => { + const p = new PropertyModel('x', 'property', 'v'); + p.setProperty('prop-name'); + p.update(opts({ keyLvl: PROPERTY })); + assert.equal(p.key, 'prop-name'); + }); +}); + +describe('PropertyModel.toObject', () => { + it('returns the canonical IProperties shape', () => { + const p = new PropertyModel('x', 'property', 'v', 2, 'a.x'); + const o = p.toObject(); + assert.deepEqual(o, { name: 'x', lvl: 2, path: 'a.x', type: 'property', value: 'v' }); + }); + + it('includes description/title/property when set', () => { + const p = new PropertyModel('x', 'property', 'v'); + p.setDescription('d'); + p.setTitle('t'); + p.setProperty('p'); + const o = p.toObject(); + assert.equal(o.description, 'd'); + assert.equal(o.title, 't'); + assert.equal(o.property, 'p'); + }); + + it('omits description/title/property when unset', () => { + const p = new PropertyModel('x', 'property', 'v'); + const o = p.toObject(); + assert.equal('description' in o, false); + assert.equal('title' in o, false); + assert.equal('property' in o, false); + }); +}); + +describe('UUIDPropertyModel', () => { + it('treats every pair as equal when idLvl=None', () => { + const a = new UUIDPropertyModel('id', 'A'); + const b = new UUIDPropertyModel('id', 'B'); + assert.equal(a.equal(b, opts({ idLvl: NONE })), true); + }); + + it('compares by raw value when idLvl != None', () => { + const a = new UUIDPropertyModel('id', 'A'); + const b = new UUIDPropertyModel('id', 'A'); + const c = new UUIDPropertyModel('id', 'B'); + assert.equal(a.equal(b, opts({ idLvl: ALL })), true); + assert.equal(a.equal(c, opts({ idLvl: ALL })), false); + }); + + it('hash() returns null when idLvl=None', () => { + const p = new UUIDPropertyModel('id', 'A'); + assert.equal(p.hash(opts({ idLvl: NONE })), null); + }); +}); + +describe('AnyPropertyModel / ArrayPropertyModel / ObjectPropertyModel', () => { + it('AnyPropertyModel sets type=Property', () => { + const p = new AnyPropertyModel('x', 'val'); + assert.equal(p.type, 'property'); + }); + + it('ArrayPropertyModel sets type=array and stores numeric value', () => { + const p = new ArrayPropertyModel('list', 5); + assert.equal(p.type, 'array'); + assert.equal(p.value, 5); + }); + + it('ObjectPropertyModel sets type=object and stores boolean value', () => { + const p = new ObjectPropertyModel('obj', true); + assert.equal(p.type, 'object'); + assert.equal(p.value, true); + }); +}); + +describe('DocumentPropertyModel.ignore', () => { + it('flags @context/type/policyId/id as system fields', () => { + for (const name of ['@context', 'type', 'policyId', 'id', 'ref', 'tokenId', 'issuanceDate', 'issuer', 'guardianVersion']) { + const p = new DocumentPropertyModel(name, 'v'); + assert.equal(p.ignore(opts({ idLvl: NONE })), true, `${name} should be ignored when idLvl=None`); + } + }); + + it('does NOT ignore non-system fields under any idLvl', () => { + const p = new DocumentPropertyModel('amount', 5); + assert.equal(p.ignore(opts({ idLvl: NONE })), false); + assert.equal(p.ignore(opts({ idLvl: ALL })), false); + }); + + it('returns false for system fields when idLvl != None', () => { + const p = new DocumentPropertyModel('id', 'v'); + assert.equal(p.ignore(opts({ idLvl: ALL })), false); + }); + + it('flags proof.* paths as system fields', () => { + const p = new DocumentPropertyModel('jws', 'sig', 1, 'a.proof.jws'); + assert.equal(p.ignore(opts({ idLvl: NONE })), true); + }); + + it('flags did:hedera: string values as system', () => { + const p = new DocumentPropertyModel('owner', 'did:hedera:testnet:abcd', 1, 'a.owner'); + assert.equal(p.ignore(opts({ idLvl: NONE })), true); + }); + + it('flags MintToken type "date" as system', () => { + const p = new DocumentPropertyModel('date', '2024-01-01', 1, 'a.date', 'MintToken'); + assert.equal(p.ignore(opts({ idLvl: NONE })), true); + }); +}); diff --git a/guardian-service/tests/unit/analytics-property-type.test.mjs b/guardian-service/tests/unit/analytics-property-type.test.mjs new file mode 100644 index 0000000000..b9bb70ea75 --- /dev/null +++ b/guardian-service/tests/unit/analytics-property-type.test.mjs @@ -0,0 +1,16 @@ +import { assert } from 'chai'; +import { PropertyType } from '../../dist/analytics/compare/types/property.type.js'; + +describe('analytics PropertyType enum', () => { + it('exposes lowercase property types', () => { + assert.equal(PropertyType.Array, 'array'); + assert.equal(PropertyType.Object, 'object'); + assert.equal(PropertyType.Property, 'property'); + assert.equal(PropertyType.Schema, 'schema'); + assert.equal(PropertyType.Token, 'token'); + assert.equal(PropertyType.UUID, 'uuid'); + }); + it('has exactly six entries', () => { + assert.equal(Object.keys(PropertyType).length, 6); + }); +}); diff --git a/guardian-service/tests/unit/analytics-rate-map.test.mjs b/guardian-service/tests/unit/analytics-rate-map.test.mjs new file mode 100644 index 0000000000..aef37f3f0c --- /dev/null +++ b/guardian-service/tests/unit/analytics-rate-map.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { RateMap, RateKeyMap } from '../../dist/analytics/compare/utils/rate-map.js'; + +const eqByKey = (key) => ({ key, equal(o) { return o?.key === key; } }); + +describe('RateMap', () => { + it('starts empty', () => { + const rm = new RateMap(); + assert.deepEqual(rm.getList(), []); + }); + + it('addLeft pushes a {left, right:null} entry', () => { + const rm = new RateMap(); + const l = eqByKey('a'); + rm.addLeft(l); + const [row] = rm.getList(); + assert.equal(row.left, l); + assert.equal(row.right, null); + }); + + it('addRight pairs with an existing left when CompareUtils.mapping matches', () => { + const rm = new RateMap(); + const l = eqByKey('match'); + const r = eqByKey('match'); + rm.addLeft(l); + rm.addRight(r); + const list = rm.getList(); + assert.equal(list.length, 1); + assert.equal(list[0].right, r); + }); + + it('addRight appends a {left:null, right} entry when nothing matches', () => { + const rm = new RateMap(); + rm.addLeft(eqByKey('a')); + rm.addRight(eqByKey('b')); + const list = rm.getList(); + assert.equal(list.length, 2); + assert.equal(list[1].left, null); + assert.equal(list[1].right.key, 'b'); + }); + + it('push() and unshift() add items to the back/front', () => { + const rm = new RateMap(); + rm.push({ left: 'a', right: null }); + rm.push({ left: 'b', right: null }); + rm.unshift({ left: 'c', right: null }); + const list = rm.getList(); + assert.deepEqual(list.map((r) => r.left), ['c', 'a', 'b']); + }); + + it('sort() reorders the underlying list with a compareFn', () => { + const rm = new RateMap(); + rm.push({ left: 'b', right: null }); + rm.push({ left: 'a', right: null }); + rm.push({ left: 'c', right: null }); + rm.sort((a, b) => (a.left < b.left ? -1 : 1)); + const list = rm.getList(); + assert.deepEqual(list.map((r) => r.left), ['a', 'b', 'c']); + }); +}); + +describe('RateKeyMap', () => { + it('starts empty', () => { + const m = new RateKeyMap(); + assert.deepEqual(m.getList(), []); + }); + + it('addLeft creates a new entry with the supplied key', () => { + const m = new RateKeyMap(); + m.addLeft('k1', { v: 1 }); + const [row] = m.getList(); + assert.deepEqual(row.left, { v: 1 }); + assert.equal(row.right, null); + }); + + it('addRight attaches to an existing left entry under the same key', () => { + const m = new RateKeyMap(); + m.addLeft('k1', { v: 'left' }); + m.addRight('k1', { v: 'right' }); + const [row] = m.getList(); + assert.deepEqual(row.left, { v: 'left' }); + assert.deepEqual(row.right, { v: 'right' }); + }); + + it('addRight creates a new {left:null, right} entry when key is unknown', () => { + const m = new RateKeyMap(); + m.addRight('orphan', { v: 9 }); + const [row] = m.getList(); + assert.equal(row.left, null); + assert.deepEqual(row.right, { v: 9 }); + }); + + it('preserves insertion order across multiple keys', () => { + const m = new RateKeyMap(); + m.addLeft('a', 1); + m.addLeft('b', 2); + m.addRight('c', 3); + const list = m.getList(); + assert.equal(list.length, 3); + assert.equal(list[0].left, 1); + assert.equal(list[1].left, 2); + assert.equal(list[2].right, 3); + }); + + it('unshift inserts the key at the front of the order', () => { + const m = new RateKeyMap(); + m.addLeft('a', 1); + m.unshift('z', { left: 'first', right: null }); + const list = m.getList(); + assert.equal(list[0].left, 'first'); + assert.equal(list[1].left, 1); + }); + + it('push appends an arbitrary IRateMap entry under a new key', () => { + const m = new RateKeyMap(); + m.push('k1', { left: 'L', right: 'R' }); + const list = m.getList(); + assert.equal(list.length, 1); + assert.equal(list[0].left, 'L'); + assert.equal(list[0].right, 'R'); + }); + + it('sort() reorders by the keys array', () => { + const m = new RateKeyMap(); + m.addLeft('b', 2); + m.addLeft('a', 1); + m.addLeft('c', 3); + m.sort(); + const list = m.getList(); + assert.deepEqual(list.map((r) => r.left), [1, 2, 3]); + }); +}); diff --git a/guardian-service/tests/unit/analytics-rate.test.mjs b/guardian-service/tests/unit/analytics-rate.test.mjs new file mode 100644 index 0000000000..ab457f1d82 --- /dev/null +++ b/guardian-service/tests/unit/analytics-rate.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { Rate } from '../../dist/analytics/compare/rates/rate.js'; + +const model = (label, totalRate = -1) => ({ + label, + totalRate, + toObject: () => ({ kind: label }), +}); + +describe('Rate (base class) construction', () => { + it('exposes the documented TOTAL_RATE constant', () => { + assert.equal(Rate.TOTAL_RATE, 'total'); + }); + + it('starts with type=NONE and totalRate=-1', () => { + const r = new Rate(model('a'), model('b')); + // Status.NONE is internal — verify via the toObject() result. + const obj = r.toObject(); + assert.equal(obj.totalRate, -1); + }); + + it('stores left and right references unchanged', () => { + const left = model('a'); + const right = model('b'); + const r = new Rate(left, right); + assert.equal(r.left, left); + assert.equal(r.right, right); + }); +}); + +describe('Rate.toObject', () => { + it('embeds .toObject() of left and right under items[0..1]', () => { + const r = new Rate(model('L'), model('R')); + const obj = r.toObject(); + assert.deepEqual(obj.items[0], { kind: 'L' }); + assert.deepEqual(obj.items[1], { kind: 'R' }); + }); + + it('tolerates a missing left or right (items entry becomes undefined)', () => { + const left = model('only-L'); + const r = new Rate(left, undefined); + const obj = r.toObject(); + assert.deepEqual(obj.items[0], { kind: 'only-L' }); + assert.equal(obj.items[1], undefined); + }); +}); + +describe('Rate default behavior of overrideable methods', () => { + it('getChildren() returns an empty array', () => { + const r = new Rate(model('a'), model('b')); + assert.deepEqual(r.getChildren(), []); + }); + + it('getSubRate() returns null for any name', () => { + const r = new Rate(model('a'), model('b')); + assert.equal(r.getSubRate('any'), null); + }); + + it('setChildren() is a no-op (does not throw)', () => { + const r = new Rate(model('a'), model('b')); + assert.doesNotThrow(() => r.setChildren([{ totalRate: 10 }])); + assert.deepEqual(r.getChildren(), []); + }); + + it('calc() is a no-op (does not throw)', () => { + const r = new Rate(model('a'), model('b')); + assert.doesNotThrow(() => r.calc({})); + }); + + it('getRateValue() returns totalRate regardless of name', () => { + const r = new Rate(model('a'), model('b')); + r.totalRate = 42; + assert.equal(r.getRateValue('properties'), 42); + assert.equal(r.getRateValue('anything'), 42); + }); +}); + +describe('Rate.total', () => { + it('returns its own totalRate when there are no children', () => { + const r = new Rate(model('a'), model('b')); + r.totalRate = 80; + assert.equal(r.total(), 80); + }); + + it('floors the average of own + each child.total()', () => { + const r = new Rate(model('a'), model('b')); + r.totalRate = 60; + // simulate children by overriding getChildren() + r.getChildren = () => [ + { total: () => 100 }, + { total: () => 70 }, + { total: () => 30 }, + ]; + // (60 + 100 + 70 + 30) / 4 = 65 + assert.equal(r.total(), 65); + }); +}); diff --git a/guardian-service/tests/unit/analytics-rates-misc.test.mjs b/guardian-service/tests/unit/analytics-rates-misc.test.mjs new file mode 100644 index 0000000000..81eddf0287 --- /dev/null +++ b/guardian-service/tests/unit/analytics-rates-misc.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { ArtifactsRate } from '../../dist/analytics/compare/rates/artifacts-rate.js'; +import { EventsRate } from '../../dist/analytics/compare/rates/events-rate.js'; +import { PermissionsRate } from '../../dist/analytics/compare/rates/permissions-rate.js'; + +const fakeArtifact = (label) => ({ uuid: label, toObject: () => ({ uuid: label }) }); +const fakeEvent = (label) => ({ source: label, toObject: () => ({ source: label }) }); + +describe('ArtifactsRate', () => { + it('marks both-present pair as 100% (FULL)', () => { + const r = new ArtifactsRate(fakeArtifact('a'), fakeArtifact('b')); + assert.equal(r.totalRate, 100); + }); + + it('marks left-only pair with totalRate=-1', () => { + const r = new ArtifactsRate(fakeArtifact('a'), null); + assert.equal(r.totalRate, -1); + }); + + it('marks right-only pair with totalRate=-1', () => { + const r = new ArtifactsRate(null, fakeArtifact('b')); + assert.equal(r.totalRate, -1); + }); + + it('serializes both items via toObject() inherited from Rate', () => { + const r = new ArtifactsRate(fakeArtifact('a'), fakeArtifact('b')); + const obj = r.toObject(); + assert.deepEqual(obj.items[0], { uuid: 'a' }); + assert.deepEqual(obj.items[1], { uuid: 'b' }); + }); +}); + +describe('EventsRate', () => { + it('marks both-present pair as 100% (FULL)', () => { + const r = new EventsRate(fakeEvent('a'), fakeEvent('b')); + assert.equal(r.totalRate, 100); + }); + + it('marks left-only pair with totalRate=-1', () => { + const r = new EventsRate(fakeEvent('a'), null); + assert.equal(r.totalRate, -1); + }); + + it('marks right-only pair with totalRate=-1', () => { + const r = new EventsRate(null, fakeEvent('b')); + assert.equal(r.totalRate, -1); + }); +}); + +describe('PermissionsRate', () => { + it('marks identical strings as FULL (100%)', () => { + const r = new PermissionsRate('OWNER', 'OWNER'); + assert.equal(r.totalRate, 100); + }); + + it('marks differing strings with totalRate=-1', () => { + const r = new PermissionsRate('OWNER', 'VIEWER'); + assert.equal(r.totalRate, -1); + }); + + it('marks left-only with totalRate=-1', () => { + const r = new PermissionsRate('OWNER', null); + assert.equal(r.totalRate, -1); + }); + + it('marks right-only with totalRate=-1', () => { + const r = new PermissionsRate(null, 'OWNER'); + assert.equal(r.totalRate, -1); + }); + + it('toObject embeds the literal left/right values in items', () => { + const r = new PermissionsRate('OWNER', 'VIEWER'); + assert.deepEqual(r.toObject().items, ['OWNER', 'VIEWER']); + }); + + it('total() and getRateValue() echo totalRate', () => { + const r = new PermissionsRate('OWNER', 'OWNER'); + assert.equal(r.total(), 100); + assert.equal(r.getRateValue('any'), 100); + }); + + it('getSubRate() and getChildren() return null/[] (leaf rate)', () => { + const r = new PermissionsRate('A', 'B'); + assert.equal(r.getSubRate('x'), null); + assert.deepEqual(r.getChildren(), []); + }); + + it('calc() is a no-op (does not throw)', () => { + const r = new PermissionsRate('A', 'B'); + assert.doesNotThrow(() => r.calc({})); + }); + + it('setChildren() is a no-op', () => { + const r = new PermissionsRate('A', 'A'); + assert.doesNotThrow(() => r.setChildren([{ totalRate: 10 }])); + assert.deepEqual(r.getChildren(), []); + }); +}); diff --git a/guardian-service/tests/unit/analytics-record-model.test.mjs b/guardian-service/tests/unit/analytics-record-model.test.mjs new file mode 100644 index 0000000000..a24bf7e120 --- /dev/null +++ b/guardian-service/tests/unit/analytics-record-model.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { RecordModel } from '../../dist/analytics/compare/models/record.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const vc = (overrides = {}) => ({ type: 'vc', document: {}, ...overrides }); +const vp = (amount) => ({ + type: 'vp', + document: { + verifiableCredential: [ + { credentialSubject: {} }, + { credentialSubject: { amount } }, + ], + }, +}); + +describe('RecordModel construction', () => { + it('starts with empty weights and undefined counters', () => { + const r = new RecordModel(opts); + assert.deepEqual(r.getWeights(), []); + assert.equal(r.maxWeight(), 0); + assert.equal(r.count, undefined); + assert.equal(r.tokens, undefined); + }); + + it('exposes the supplied options', () => { + const r = new RecordModel(opts); + assert.equal(r.options, opts); + }); +}); + +describe('RecordModel.setDocuments', () => { + it('counts vc documents', () => { + const r = new RecordModel(opts); + r.setDocuments([vc(), vc(), vc()]); + assert.equal(r.count, 3); + assert.equal(r.tokens, 0); + }); + + it('counts vp documents and sums their mint amount', () => { + const r = new RecordModel(opts); + r.setDocuments([vp(5), vp(10)]); + assert.equal(r.count, 2); + assert.equal(r.tokens, 15); + }); + + it('mixes vc + vp counts but sums tokens only from vp', () => { + const r = new RecordModel(opts); + r.setDocuments([vc(), vp(7), vc()]); + assert.equal(r.count, 3); + assert.equal(r.tokens, 7); + }); + + it('returns 0 tokens when the mint subject is missing or unparseable', () => { + const r = new RecordModel(opts); + r.setDocuments([ + { type: 'vp', document: {} }, + { type: 'vp', document: { verifiableCredential: [] } }, + { type: 'vp', document: { verifiableCredential: [{ credentialSubject: {} }] } }, + ]); + assert.equal(r.count, 3); + assert.equal(r.tokens, 0); + }); + + it('handles credentialSubject as an array (reads the first entry)', () => { + const r = new RecordModel(opts); + r.setDocuments([ + { + type: 'vp', + document: { + verifiableCredential: [ + { credentialSubject: {} }, + { credentialSubject: [{ amount: 9 }] }, + ], + }, + }, + ]); + assert.equal(r.tokens, 9); + }); + + it('handles a non-array argument by zeroing the counters', () => { + const r = new RecordModel(opts); + r.setDocuments(null); + assert.equal(r.count, 0); + assert.equal(r.tokens, 0); + }); + + it('returns the model instance for chaining', () => { + const r = new RecordModel(opts); + assert.equal(r.setDocuments([]), r); + }); +}); + +describe('RecordModel.setChildren', () => { + it('stores an array of children unchanged', () => { + const r = new RecordModel(opts); + const docs = [{ a: 1 }, { b: 2 }]; + r.setChildren(docs); + assert.equal(r.children, docs); + }); + + it('falls back to [] when given a non-array', () => { + const r = new RecordModel(opts); + r.setChildren(null); + assert.deepEqual(r.children, []); + }); + + it('returns the model instance for chaining', () => { + const r = new RecordModel(opts); + assert.equal(r.setChildren([]), r); + }); +}); + +describe('RecordModel.equal', () => { + it('compares by hash when no weights are populated (post-update is empty)', () => { + const a = new RecordModel(opts).update(opts); + const b = new RecordModel(opts).update(opts); + // Both have empty hash → equal. + assert.equal(a.equal(b), true); + }); + + it('returns true on equalKey() unconditionally', () => { + const a = new RecordModel(opts); + const b = new RecordModel(opts); + assert.equal(a.equalKey(b), true); + }); +}); + +describe('RecordModel.toObject / info', () => { + it('toObject returns {documents, tokens}', () => { + const r = new RecordModel(opts); + r.setDocuments([vc(), vp(2)]); + assert.deepEqual(r.toObject(), { documents: 2, tokens: 2 }); + }); + + it('info() returns the same shape as toObject()', () => { + const r = new RecordModel(opts); + r.setDocuments([vc()]); + assert.deepEqual(r.info(), r.toObject()); + }); +}); diff --git a/guardian-service/tests/unit/analytics-report-table.test.mjs b/guardian-service/tests/unit/analytics-report-table.test.mjs new file mode 100644 index 0000000000..2ca51a8d40 --- /dev/null +++ b/guardian-service/tests/unit/analytics-report-table.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { ReportTable } from '../../dist/analytics/compare/table/report-table.js'; +import { ReportRow } from '../../dist/analytics/compare/table/report-row.js'; + +describe('ReportTable construction', () => { + it('accepts a string[] of column names', () => { + const t = new ReportTable(['a', 'b', 'c']); + assert.deepEqual(t.columns, ['a', 'b', 'c']); + assert.deepEqual(t.indexes, { a: 0, b: 1, c: 2 }); + }); + + it('accepts an IColumn[]-shaped array (objects with .name)', () => { + const t = new ReportTable([{ name: 'x' }, { name: 'y' }]); + assert.deepEqual(t.columns, ['x', 'y']); + }); + + it('starts with no rows', () => { + const t = new ReportTable(['a']); + assert.deepEqual(t.rows, []); + assert.deepEqual(t.value, []); + }); +}); + +describe('ReportTable.createRow', () => { + it('creates a ReportRow attached to the table and tracks it', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + assert.ok(row instanceof ReportRow); + assert.equal(t.rows.length, 1); + assert.equal(t.rows[0], row); + assert.equal(t.value.length, 1); + assert.equal(t.value[0], row.value); + }); + + it('rows share table.value with their per-row .value', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.set('a', 'A'); + row.set('b', 'B'); + assert.deepEqual(t.value[0], ['A', 'B']); + }); +}); + +describe('ReportRow get/set by name and index', () => { + it('round-trips a value via name', () => { + const t = new ReportTable(['k']); + const row = t.createRow(); + row.set('k', 42); + assert.equal(row.get('k'), 42); + }); + + it('round-trips a value via index', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.setByIndex(1, 'B'); + assert.equal(row.getByIndex(1), 'B'); + }); + + it('setObject() unwraps a value with .toObject()', () => { + const t = new ReportTable(['k']); + const row = t.createRow(); + const obj = { toObject: () => ({ unwrapped: true }) }; + row.setObject('k', obj); + assert.deepEqual(row.get('k'), { unwrapped: true }); + }); + + it('setObject() falls back to the raw value when no .toObject()', () => { + const t = new ReportTable(['k']); + const row = t.createRow(); + row.setObject('k', 'plain'); + assert.equal(row.get('k'), 'plain'); + }); + + it('setArray() maps each element through .toObject()', () => { + const t = new ReportTable(['k']); + const row = t.createRow(); + row.setArray('k', [ + { toObject: () => 1 }, + { toObject: () => 2 }, + ]); + assert.deepEqual(row.get('k'), [1, 2]); + }); + + it('setArray() preserves a null/undefined input', () => { + const t = new ReportTable(['k']); + const row = t.createRow(); + row.setArray('k', null); + assert.equal(row.get('k'), null); + }); +}); + +describe('ReportRow.data / object', () => { + it('data() returns the underlying values array', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.set('a', 1); + row.set('b', 2); + assert.equal(row.data(), row.value); + assert.deepEqual(row.value, [1, 2]); + }); + + it('object() pairs each column name with its slot value', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.set('a', 'x'); + row.set('b', 'y'); + assert.deepEqual(row.object(), { a: 'x', b: 'y' }); + }); +}); + +describe('ReportTable.getByIndex / setByIndex / data / object', () => { + it('getByIndex/setByIndex address the right cell', () => { + const t = new ReportTable(['a', 'b']); + t.createRow(); + t.setByIndex(0, 1, 'B'); + assert.equal(t.getByIndex(0, 1), 'B'); + }); + + it('data() returns { columns, rows: matrix }', () => { + const t = new ReportTable(['a', 'b']); + const r1 = t.createRow(); + const r2 = t.createRow(); + r1.set('a', 1); r1.set('b', 2); + r2.set('a', 3); r2.set('b', 4); + const out = t.data(); + assert.deepEqual(out.columns, ['a', 'b']); + assert.deepEqual(out.rows, [[1, 2], [3, 4]]); + }); + + it('object() returns one object per row keyed by column name', () => { + const t = new ReportTable(['a', 'b']); + const r1 = t.createRow(); + r1.set('a', 1); r1.set('b', 2); + const r2 = t.createRow(); + r2.set('a', 3); r2.set('b', 4); + assert.deepEqual(t.object(), [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ]); + }); +}); diff --git a/guardian-service/tests/unit/analytics-role-group-models.test.mjs b/guardian-service/tests/unit/analytics-role-group-models.test.mjs new file mode 100644 index 0000000000..9629828102 --- /dev/null +++ b/guardian-service/tests/unit/analytics-role-group-models.test.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { RoleModel } from '../../dist/analytics/compare/models/role.model.js'; +import { GroupModel } from '../../dist/analytics/compare/models/group.model.js'; + +const opts = (overrides = {}) => ({ + propLvl: 'All', + keyLvl: 'Default', + idLvl: 'All', + ...overrides, +}); + +describe('RoleModel', () => { + it('exposes the constructor argument as both name and key', () => { + const r = new RoleModel('OWNER'); + assert.equal(r.name, 'OWNER'); + assert.equal(r.key, 'OWNER'); + }); + + it('starts with empty weights and getWeight()=undefined', () => { + const r = new RoleModel('OWNER'); + assert.deepEqual(r.getWeights(), []); + assert.equal(r.maxWeight(), 0); + assert.equal(r.getWeight(), undefined); + }); + + it('update() populates one weight (ROLE_LVL_0)', () => { + const r = new RoleModel('OWNER'); + r.update(opts()); + assert.equal(r.getWeights().length, 1); + assert.equal(r.maxWeight(), 1); + }); + + it('checkWeight(0) is true after update; checkWeight(1) is false', () => { + const r = new RoleModel('OWNER'); + r.update(opts()); + assert.equal(r.checkWeight(0), true); + assert.equal(r.checkWeight(1), false); + }); + + it('equal() falls back to name comparison when not yet updated', () => { + const a = new RoleModel('A'); + const b = new RoleModel('A'); + const c = new RoleModel('B'); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() compares the strongest weight after update()', () => { + const a = new RoleModel('OWNER'); + const b = new RoleModel('OWNER'); + const c = new RoleModel('VIEWER'); + a.update(opts()); b.update(opts()); c.update(opts()); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equalKey() compares names', () => { + const a = new RoleModel('OWNER'); + const b = new RoleModel('OWNER'); + const c = new RoleModel('VIEWER'); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); + + it('toObject returns {name, properties:[name-prop]}', () => { + const r = new RoleModel('OWNER'); + const out = r.toObject(); + assert.equal(out.name, 'OWNER'); + assert.equal(out.properties.length, 1); + assert.equal(out.properties[0].name, 'name'); + }); + + it('toWeight returns name when not yet updated', () => { + const r = new RoleModel('OWNER'); + assert.equal(r.toWeight(opts()).weight, 'OWNER'); + }); + + it('toWeight returns the strongest weight after update()', () => { + const r = new RoleModel('OWNER'); + r.update(opts()); + const expected = r.getWeights()[0]; + assert.equal(r.toWeight(opts()).weight, expected); + }); + + it('getPropList returns the lone name AnyPropertyModel', () => { + const r = new RoleModel('OWNER'); + const list = r.getPropList(); + assert.equal(list.length, 1); + assert.equal(list[0].name, 'name'); + assert.equal(list[0].value, 'OWNER'); + }); +}); + +describe('GroupModel', () => { + it('captures name and exposes it as the key', () => { + const g = new GroupModel({ name: 'Owners', creator: 'X' }); + assert.equal(g.name, 'Owners'); + assert.equal(g.key, 'Owners'); + }); + + it('starts with empty weights', () => { + const g = new GroupModel({ name: 'Owners' }); + assert.deepEqual(g.getWeights(), []); + assert.equal(g.maxWeight(), 0); + }); + + it('update() populates two weights (GROUP_LVL_0 and GROUP_LVL_1)', () => { + const g = new GroupModel({ name: 'Owners', creator: 'X' }); + g.update(opts()); + assert.equal(g.getWeights().length, 2); + assert.equal(g.maxWeight(), 2); + }); + + it('falls back to name comparison when un-updated', () => { + const a = new GroupModel({ name: 'A' }); + const b = new GroupModel({ name: 'A' }); + const c = new GroupModel({ name: 'B' }); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at default index compares the strongest weight', () => { + const a = new GroupModel({ name: 'g', creator: 'X' }); + const b = new GroupModel({ name: 'g', creator: 'X' }); + const c = new GroupModel({ name: 'g', creator: 'Y' }); + a.update(opts()); b.update(opts()); c.update(opts()); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at the looser weight matches groups that share only name', () => { + const a = new GroupModel({ name: 'g', creator: 'X' }); + const b = new GroupModel({ name: 'g', creator: 'Y' }); + a.update(opts()); b.update(opts()); + // Index 1 = the looser GROUP_LVL_0 weight (only name) after reverse(). + assert.equal(a.equal(b, 1), true); + }); + + it('equalKey compares names', () => { + const a = new GroupModel({ name: 'g' }); + const b = new GroupModel({ name: 'g' }); + const c = new GroupModel({ name: 'h' }); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); + + it('toObject returns {name, properties: [...PropertyModel]}', () => { + const g = new GroupModel({ name: 'g', extra: 1 }); + const out = g.toObject(); + assert.equal(out.name, 'g'); + assert.ok(Array.isArray(out.properties)); + }); + + it('toWeight returns name when un-updated, hash when updated', () => { + const g = new GroupModel({ name: 'g' }); + assert.equal(g.toWeight(opts()).weight, 'g'); + g.update(opts()); + assert.equal(g.toWeight(opts()).weight, g.getWeights()[0]); + }); +}); diff --git a/guardian-service/tests/unit/analytics-root-object-rates.test.mjs b/guardian-service/tests/unit/analytics-root-object-rates.test.mjs new file mode 100644 index 0000000000..c599f14437 --- /dev/null +++ b/guardian-service/tests/unit/analytics-root-object-rates.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { RootRate } from '../../dist/analytics/compare/rates/root-rate.js'; +import { ObjectRate } from '../../dist/analytics/compare/rates/object-rate.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const prop = (name, value) => ({ + name, + path: name, + lvl: 1, + value, + equal(other) { return this.value === other.value; }, + ignore() { return false; }, + getPropList() { return []; }, + toObject() { return { name, value }; }, +}); + +const obj = (overrides = {}) => ({ + getPropList() { return overrides.props || []; }, + toObject() { return overrides.shape || {}; }, + ...overrides, +}); + +describe('RootRate', () => { + it('starts with totalRate=100 and PARTLY status', () => { + const r = new RootRate(); + assert.equal(r.totalRate, 100); + assert.equal(r.left, null); + assert.equal(r.right, null); + }); + + it('roundtrips children via setChildren/getChildren', () => { + const r = new RootRate(); + const kids = [{ totalRate: 70 }, { totalRate: 80 }]; + r.setChildren(kids); + assert.equal(r.getChildren(), kids); + }); + + it('total() folds in children', () => { + const r = new RootRate(); + r.setChildren([ + { total: () => 100 }, + { total: () => 50 }, + ]); + // (100 + 100 + 50) / 3 = 83 + assert.equal(r.total(), 83); + }); +}); + +describe('ObjectRate construction', () => { + it('marks both-present pair at 100%', () => { + const r = new ObjectRate(obj(), obj()); + assert.equal(r.totalRate, 100); + }); + + it('marks left-only pair with totalRate=-1', () => { + const r = new ObjectRate(obj(), null); + assert.equal(r.totalRate, -1); + }); + + it('marks right-only pair with totalRate=-1', () => { + const r = new ObjectRate(null, obj()); + assert.equal(r.totalRate, -1); + }); + + it('propertiesRate starts at -1', () => { + const r = new ObjectRate(obj(), obj()); + assert.equal(r.propertiesRate, -1); + }); +}); + +describe('ObjectRate.calc', () => { + it('100% for two objects with identical prop lists', () => { + const a = obj({ props: [prop('a', 1), prop('b', 2)] }); + const b = obj({ props: [prop('a', 1), prop('b', 2)] }); + const r = new ObjectRate(a, b); + r.calc(opts); + assert.equal(r.propertiesRate, 100); + assert.equal(r.totalRate, 100); + }); + + it('halves the rate when half the props differ', () => { + const a = obj({ props: [prop('a', 1), prop('b', 2)] }); + const b = obj({ props: [prop('a', 1), prop('b', 9)] }); + const r = new ObjectRate(a, b); + r.calc(opts); + assert.equal(r.propertiesRate, 50); + assert.equal(r.totalRate, 50); + }); + + it('skips computation when only one side is present', () => { + const r = new ObjectRate(obj({ props: [prop('a', 1)] }), null); + r.calc(opts); + assert.equal(r.totalRate, -1); + }); +}); + +describe('ObjectRate.getSubRate / getRateValue', () => { + it('getSubRate returns the inner properties rates regardless of name', () => { + const r = new ObjectRate(obj(), obj()); + r.calc(opts); + assert.equal(r.getSubRate('any'), r.properties); + }); + + it('getRateValue("properties") returns propertiesRate', () => { + const a = obj({ props: [prop('a', 1)] }); + const b = obj({ props: [prop('a', 1)] }); + const r = new ObjectRate(a, b); + r.calc(opts); + assert.equal(r.getRateValue('properties'), r.propertiesRate); + }); + + it('getRateValue(other) returns totalRate', () => { + const a = obj({ props: [prop('a', 1)] }); + const b = obj({ props: [prop('a', 1)] }); + const r = new ObjectRate(a, b); + r.calc(opts); + assert.equal(r.getRateValue('whatever'), r.totalRate); + }); +}); diff --git a/guardian-service/tests/unit/analytics-schema-document-model.test.mjs b/guardian-service/tests/unit/analytics-schema-document-model.test.mjs new file mode 100644 index 0000000000..c325da9afa --- /dev/null +++ b/guardian-service/tests/unit/analytics-schema-document-model.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { SchemaDocumentModel } from '../../dist/analytics/compare/models/schema-document.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const stringField = (overrides = {}) => ({ type: 'string', title: 'A title', description: 'A desc', ...overrides }); + +describe('SchemaDocumentModel construction', () => { + it('returns no fields for an undefined/empty document', () => { + const m = new SchemaDocumentModel(undefined, {}, new Map()); + assert.deepEqual(m.fields, []); + assert.deepEqual(m.conditions, []); + }); + + it('parses every property into a FieldModel and skips @context/type', () => { + const m = SchemaDocumentModel.from({ + properties: { + '@context': { type: 'string' }, + type: { type: 'string' }, + amount: stringField(), + owner: stringField(), + }, + }); + const names = m.fields.map((f) => f.name); + assert.equal(names.length, 2); + assert.ok(names.includes('amount')); + assert.ok(names.includes('owner')); + assert.ok(!names.includes('@context')); + assert.ok(!names.includes('type')); + }); + + it('marks "required" fields based on the document.required array', () => { + const m = SchemaDocumentModel.from({ + properties: { amount: stringField(), owner: stringField() }, + required: ['amount'], + }); + const amount = m.fields.find((f) => f.name === 'amount'); + const owner = m.fields.find((f) => f.name === 'owner'); + assert.equal(amount.required, true); + assert.equal(owner.required, false); + }); +}); + +describe('SchemaDocumentModel.from + sub-schema caching', () => { + it('inlines a $ref sub-schema and caches it across uses', () => { + const document = { + properties: { + inner: { $ref: '#/$defs/Inner' }, + otherInner: { $ref: '#/$defs/Inner' }, + }, + $defs: { + '#/$defs/Inner': { + properties: { x: stringField() }, + }, + }, + }; + const m = SchemaDocumentModel.from(document); + const innerField = m.fields.find((f) => f.name === 'inner'); + const otherField = m.fields.find((f) => f.name === 'otherInner'); + assert.ok(innerField.children.length > 0); + assert.ok(otherField.children.length > 0); + assert.equal(innerField.children[0].name, 'x'); + }); +}); + +describe('SchemaDocumentModel.parseConditions (via constructor)', () => { + it('captures a simple "if {field=const}" branch', () => { + const m = SchemaDocumentModel.from({ + properties: { kind: stringField(), amount: stringField() }, + allOf: [ + { + if: { properties: { kind: { const: 'mint' } } }, + then: { properties: { amount: stringField() }, required: ['amount'] }, + }, + ], + }); + assert.equal(m.conditions.length, 1); + assert.equal(m.conditions[0].fieldValue, 'mint'); + assert.equal(m.conditions[0].name, 'kind'); + }); + + it('captures an anyOf-based predicate condition', () => { + const m = SchemaDocumentModel.from({ + properties: { kind: stringField(), tier: stringField(), amount: stringField() }, + allOf: [ + { + if: { + anyOf: [ + { properties: { kind: { const: 'A' } } }, + { properties: { tier: { const: 'gold' } } }, + ], + }, + then: { properties: { amount: stringField() } }, + }, + ], + }); + assert.equal(m.conditions.length, 1); + assert.equal(m.conditions[0].operator, 'OR'); + assert.equal(m.conditions[0].predicates.length, 2); + }); + + it('captures an allOf-based predicate condition', () => { + const m = SchemaDocumentModel.from({ + properties: { kind: stringField(), tier: stringField(), amount: stringField() }, + allOf: [ + { + if: { + allOf: [ + { properties: { kind: { const: 'A' } } }, + { properties: { tier: { const: 'gold' } } }, + ], + }, + then: { properties: { amount: stringField() } }, + }, + ], + }); + assert.equal(m.conditions.length, 1); + assert.equal(m.conditions[0].operator, 'AND'); + }); + + it('skips an allOf entry that has no .if clause', () => { + const m = SchemaDocumentModel.from({ + properties: { a: stringField() }, + allOf: [{ then: { properties: { b: stringField() } } }], + }); + assert.equal(m.conditions.length, 0); + }); + + it('skips a condition whose if-field is unknown', () => { + const m = SchemaDocumentModel.from({ + properties: { a: stringField() }, + allOf: [ + { + if: { properties: { unknown: { const: 'X' } } }, + then: { properties: {} }, + }, + ], + }); + assert.equal(m.conditions.length, 0); + }); +}); + +describe('SchemaDocumentModel.update + hash', () => { + it('hash() is empty before update()', () => { + const m = SchemaDocumentModel.from({ properties: { a: stringField() } }); + assert.equal(m.hash(opts), ''); + }); + + it('update() populates a non-empty hash', () => { + const m = SchemaDocumentModel.from({ properties: { a: stringField(), b: stringField() } }); + m.update(opts); + assert.ok(m.hash(opts).length > 0); + }); + + it('two identical documents produce the same hash', () => { + const json = { properties: { a: stringField(), b: stringField() } }; + const m1 = SchemaDocumentModel.from(json); + const m2 = SchemaDocumentModel.from(json); + m1.update(opts); m2.update(opts); + assert.equal(m1.hash(opts), m2.hash(opts)); + }); +}); + +describe('SchemaDocumentModel.getField', () => { + it('returns null for an empty path', () => { + const m = SchemaDocumentModel.from({ properties: { a: stringField() } }); + assert.equal(m.getField(''), null); + }); +}); diff --git a/guardian-service/tests/unit/analytics-schema-model.test.mjs b/guardian-service/tests/unit/analytics-schema-model.test.mjs new file mode 100644 index 0000000000..fa6a28ef41 --- /dev/null +++ b/guardian-service/tests/unit/analytics-schema-model.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { SchemaModel } from '../../dist/analytics/compare/models/schema.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const stringField = (overrides = {}) => ({ + type: 'string', + title: 'A', + description: 'A', + ...overrides, +}); + +const rawSchema = (overrides = {}) => ({ + id: 'sid', + name: 'My Schema', + uuid: 'sid-uuid', + description: 'desc', + topicId: '0.0.1', + version: '1.0.0', + iri: '#sid', + document: { properties: { amount: stringField() } }, + ...overrides, +}); + +describe('SchemaModel construction', () => { + it('captures the basic identifiers', () => { + const m = new SchemaModel(rawSchema(), opts); + assert.equal(m.id, 'sid'); + assert.equal(m.name, 'My Schema'); + assert.equal(m.uuid, 'sid-uuid'); + assert.equal(m.description, 'desc'); + assert.equal(m.topicId, '0.0.1'); + assert.equal(m.version, '1.0.0'); + assert.equal(m.iri, '#sid'); + assert.equal(m.empty, false); + }); + + it('falls back to sourceVersion when version is missing', () => { + const m = new SchemaModel(rawSchema({ version: undefined, sourceVersion: '0.5.0' }), opts); + assert.equal(m.version, '0.5.0'); + }); + + it('marks empty schema (no raw input) and uses default empty fields', () => { + const m = new SchemaModel(null, opts); + assert.equal(m.empty, true); + assert.equal(m.id, ''); + assert.deepEqual(m.fields, []); + }); + + it('parses a JSON-string document', () => { + const json = JSON.stringify({ properties: { x: stringField() } }); + const m = new SchemaModel(rawSchema({ document: json }), opts); + assert.ok(m.fields.length > 0); + }); +}); + +describe('SchemaModel.info / toObject / fields getter', () => { + it('info() exposes identifiers + policy/tool placeholders', () => { + const m = new SchemaModel(rawSchema(), opts); + const info = m.info(); + assert.equal(info.id, 'sid'); + assert.equal(info.policy, undefined); + assert.equal(info.tool, undefined); + }); + + it('toObject returns a stable shape', () => { + const m = new SchemaModel(rawSchema(), opts); + assert.deepEqual(m.toObject(), { + id: 'sid', + name: 'My Schema', + uuid: 'sid-uuid', + description: 'desc', + topicId: '0.0.1', + version: '1.0.0', + iri: '#sid', + }); + }); + + it('fields getter returns [] when no document is parsed', () => { + const m = new SchemaModel(null, opts); + assert.deepEqual(m.fields, []); + }); +}); + +describe('SchemaModel.setPolicy / setTool', () => { + it('attaches policy + tool names to info()', () => { + const m = new SchemaModel(rawSchema(), opts); + m.setPolicy({ name: 'P1' }); + m.setTool({ name: 'T1' }); + const info = m.info(); + assert.equal(info.policy, 'P1'); + assert.equal(info.tool, 'T1'); + }); + + it('returns the model for chaining', () => { + const m = new SchemaModel(rawSchema(), opts); + assert.equal(m.setPolicy({ name: 'p' }), m); + assert.equal(m.setTool({ name: 't' }), m); + }); +}); + +describe('SchemaModel.update + hash', () => { + it('hash() is empty before update()', () => { + const m = new SchemaModel(rawSchema(), opts); + assert.equal(m.hash(opts), ''); + }); + + it('two identical schemas produce the same hash', () => { + const m1 = new SchemaModel(rawSchema(), opts); + const m2 = new SchemaModel(rawSchema(), opts); + m1.update(opts); + m2.update(opts); + assert.equal(m1.hash(opts), m2.hash(opts)); + }); + + it('schemas differing in version produce different hashes when idLvl=All', () => { + const a = new SchemaModel(rawSchema({ version: '1.0.0' }), opts); + const b = new SchemaModel(rawSchema({ version: '2.0.0' }), opts); + a.update(opts); + b.update(opts); + assert.notEqual(a.hash(opts), b.hash(opts)); + }); + + it('schemas differing in version produce the same hash when idLvl=None', () => { + const optsNoneId = { ...opts, idLvl: 'None' }; + const a = new SchemaModel(rawSchema({ version: '1.0.0' }), optsNoneId); + const b = new SchemaModel(rawSchema({ version: '2.0.0' }), optsNoneId); + a.update(optsNoneId); + b.update(optsNoneId); + assert.equal(a.hash(optsNoneId), b.hash(optsNoneId)); + }); +}); + +describe('SchemaModel.compare (cached)', () => { + it('returns -1 when full hashes match', () => { + const a = new SchemaModel(rawSchema(), opts); + const b = new SchemaModel(rawSchema(), opts); + a.update(opts); + b.update(opts); + assert.equal(a.compare(b), -1); + }); +}); + +describe('SchemaModel.empty + .from', () => { + it('.empty(iri, options) creates an empty schema with the given iri', () => { + const m = SchemaModel.empty('#new', opts); + assert.equal(m.empty, true); + assert.equal(m.iri, '#new'); + }); + + it('.from(jsonSchema, options) maps $id to id+iri and title to name', () => { + const m = SchemaModel.from({ + $id: '#sid', + title: 'Title', + description: 'desc', + properties: { a: stringField() }, + }, opts); + assert.equal(m.id, '#sid'); + assert.equal(m.iri, '#sid'); + assert.equal(m.name, 'Title'); + assert.equal(m.description, 'desc'); + }); +}); + +describe('SchemaModel.fromEntity', () => { + it('throws "Unknown schema" when raw is missing', () => { + assert.throws(() => SchemaModel.fromEntity(null, { name: 'p' }, opts), /Unknown schema/); + }); + + it('attaches the policy and computes a non-empty hash', () => { + const m = SchemaModel.fromEntity(rawSchema(), { name: 'P1' }, opts); + assert.equal(m.info().policy, 'P1'); + assert.ok(m.hash(opts).length > 0); + }); +}); diff --git a/guardian-service/tests/unit/analytics-search-models.test.mjs b/guardian-service/tests/unit/analytics-search-models.test.mjs new file mode 100644 index 0000000000..80451d6e57 --- /dev/null +++ b/guardian-service/tests/unit/analytics-search-models.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { BlockSearchModel } from '../../dist/analytics/search/models/block.model.js'; +import { PairSearchModel } from '../../dist/analytics/search/models/pair.model.js'; +import { ChainSearchModel } from '../../dist/analytics/search/models/chain.model.js'; + +const block = (over = {}) => new BlockSearchModel({ id: 'b', tag: 'tag', blockType: 'X', events: [], artifacts: [], ...over }); + +describe('BlockSearchModel', () => { + it('maps id/tag/type from the json', () => { + const b = block({ id: 'b1', tag: 't1', blockType: 'interfaceActionBlock' }); + assert.equal(b.id, 'b1'); + assert.equal(b.tag, 't1'); + assert.equal(b.type, 'interfaceActionBlock'); + }); + + it('starts with no relations', () => { + const b = block(); + assert.deepEqual(b.children, []); + assert.equal(b.parent, null); + assert.equal(b.next, null); + assert.equal(b.prev, null); + }); + + it('addChildren wires parent and sibling prev/next links', () => { + const root = block({ id: 'root' }); + const c1 = block({ id: 'c1' }); + const c2 = block({ id: 'c2' }); + root.addChildren(c1); + root.addChildren(c2); + assert.equal(c1.parent, root); + assert.equal(c2.parent, root); + assert.equal(c1.next, c2); + assert.equal(c2.prev, c1); + }); + + it('update sets the root path to [0]', () => { + const b = block(); + b.update(); + assert.ok(b); + }); + + it('getPropList/getEventList/getPermissionsList/getArtifactsList return arrays', () => { + const b = block({ events: [{ source: 'a' }], artifacts: [{ uuid: 'x' }] }); + assert.ok(Array.isArray(b.getPropList())); + assert.equal(b.getEventList().length, 1); + assert.ok(Array.isArray(b.getPermissionsList())); + assert.equal(b.getArtifactsList().length, 1); + }); + + it('find on a same-type filter yields a non-empty chain', () => { + const source = block({ blockType: 'X' }); + const filter = block({ blockType: 'X' }); + const chain = source.find(filter); + chain.update(); + assert.ok(chain.hash > 0); + }); + + it('find on a different-type filter yields an empty chain', () => { + const source = block({ blockType: 'X' }); + const filter = block({ blockType: 'Y' }); + const chain = source.find(filter); + chain.update(); + assert.equal(chain.hash, 0); + }); + + it('toJson exposes id/tag/blockType', () => { + const json = block({ id: 'b1', tag: 't1', blockType: 'X' }).toJson(); + assert.equal(json.id, 'b1'); + assert.equal(json.tag, 't1'); + assert.equal(json.blockType, 'X'); + }); +}); + +describe('PairSearchModel', () => { + it('starts with a zero hash and keeps source/filter', () => { + const s = block({ id: 's' }); + const f = block({ id: 'f' }); + const pair = new PairSearchModel(s, f); + assert.equal(pair.hash, 0); + assert.equal(pair.source, s); + assert.equal(pair.filter, f); + }); + + it('update computes a numeric hash', () => { + const pair = new PairSearchModel(block(), block()); + pair.update(); + assert.equal(typeof pair.hash, 'number'); + }); + + it('toJson serializes hash plus source/filter json', () => { + const pair = new PairSearchModel(block({ id: 's' }), block({ id: 'f' })); + pair.update(); + const json = pair.toJson(); + assert.equal(json.source.id, 's'); + assert.equal(json.filter.id, 'f'); + assert.equal(typeof json.hash, 'number'); + }); +}); + +describe('ChainSearchModel', () => { + it('starts with a zero hash', () => { + assert.equal(new ChainSearchModel().hash, 0); + }); + + it('addPair appends and returns the chain (fluent)', () => { + const chain = new ChainSearchModel(); + const result = chain.addPair(block({ id: 's' }), block({ id: 'f' })); + assert.equal(result, chain); + }); + + it('update computes a hash reflecting the pair count', () => { + const chain = new ChainSearchModel(); + chain.addPair(block(), block()); + chain.update(); + assert.ok(chain.hash >= 1000); + }); + + it('toJson exposes hash, target and pairs', () => { + const chain = new ChainSearchModel(); + chain.addPair(block({ id: 's' }), block({ id: 'f' })); + chain.update(); + const json = chain.toJson(); + assert.equal(typeof json.hash, 'number'); + assert.ok(json.target); + assert.equal(json.pairs.length, 1); + }); +}); diff --git a/guardian-service/tests/unit/analytics-search-module-tool-models.test.mjs b/guardian-service/tests/unit/analytics-search-module-tool-models.test.mjs new file mode 100644 index 0000000000..04bef474b2 --- /dev/null +++ b/guardian-service/tests/unit/analytics-search-module-tool-models.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { ModuleSearchModel } from '../../dist/analytics/search/models/module.model.js'; +import { ToolSearchModel } from '../../dist/analytics/search/models/tool.model.js'; +import { RootSearchModel } from '../../dist/analytics/search/models/root.model.js'; +import { BlockSearchModel } from '../../dist/analytics/search/models/block.model.js'; + +const config = () => ({ + blockType: 'root', id: 'root', tag: 'root', + children: [ + { blockType: 'interfaceActionBlock', id: 'b1', tag: 't1', children: [] }, + { blockType: 'policyRolesBlock', id: 'b2', tag: 't2' } + ] +}); + +describe('ModuleSearchModel', () => { + it('is a subclass of RootSearchModel', () => { + assert.ok(new ModuleSearchModel({ name: 'm', config: config() }) instanceof RootSearchModel); + }); + + it('throws when the module has no config', () => { + assert.throws(() => new ModuleSearchModel({ name: 'm' }), /Empty module config/); + }); + + it('captures root metadata', () => { + const m = new ModuleSearchModel({ name: 'Mod', description: 'd', owner: 'did:o', topicId: '0.0.1', messageId: 'm-1', config: config() }); + assert.equal(m.name, 'Mod'); + assert.equal(m.description, 'd'); + assert.equal(m.owner, 'did:o'); + assert.equal(m.topicId, '0.0.1'); + assert.equal(m.messageId, 'm-1'); + }); + + it('builds a searchable block list from config', () => { + const m = new ModuleSearchModel({ name: 'm', config: config() }); + assert.equal(m.filter('root').length, 1); + assert.equal(m.filter('interfaceActionBlock').length, 1); + assert.equal(m.filter('policyRolesBlock').length, 1); + }); + + it('findBlock resolves by id', () => { + const m = new ModuleSearchModel({ name: 'm', config: config() }); + assert.equal(m.findBlock('b1').id, 'b1'); + assert.equal(m.findBlock('nope'), undefined); + }); + + it('search returns one chain per matching block', () => { + const m = new ModuleSearchModel({ name: 'm', config: config() }); + const chains = m.search(new BlockSearchModel({ blockType: 'interfaceActionBlock' })); + assert.equal(chains.length, 1); + }); + + it('search returns empty array when no block matches', () => { + const m = new ModuleSearchModel({ name: 'm', config: config() }); + assert.deepEqual(m.search(new BlockSearchModel({ blockType: 'missingType' })), []); + }); +}); + +describe('ToolSearchModel', () => { + it('is a subclass of RootSearchModel', () => { + assert.ok(new ToolSearchModel({ name: 't', config: config() }) instanceof RootSearchModel); + }); + + it('throws when the tool has no config', () => { + assert.throws(() => new ToolSearchModel({ name: 't' }), /Empty tool config/); + }); + + it('captures root metadata', () => { + const t = new ToolSearchModel({ name: 'Tool', owner: 'did:t', messageId: 't-1', config: config() }); + assert.equal(t.name, 'Tool'); + assert.equal(t.owner, 'did:t'); + assert.equal(t.messageId, 't-1'); + }); + + it('builds a searchable block list from config', () => { + const t = new ToolSearchModel({ name: 't', config: config() }); + assert.equal(t.filter('interfaceActionBlock').length, 1); + assert.equal(t.findBlock('b2').id, 'b2'); + }); + + it('search returns chains sorted by descending hash', () => { + const t = new ToolSearchModel({ name: 't', config: config() }); + const chains = t.search(new BlockSearchModel({ blockType: 'root' })); + for (let i = 1; i < chains.length; i++) { + assert.ok(chains[i - 1].hash >= chains[i].hash); + } + }); +}); diff --git a/guardian-service/tests/unit/analytics-search-root-policy.test.mjs b/guardian-service/tests/unit/analytics-search-root-policy.test.mjs new file mode 100644 index 0000000000..67a72c0848 --- /dev/null +++ b/guardian-service/tests/unit/analytics-search-root-policy.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { RootSearchModel } from '../../dist/analytics/search/models/root.model.js'; +import { PolicySearchModel } from '../../dist/analytics/search/models/policy.model.js'; +import { BlockSearchModel } from '../../dist/analytics/search/models/block.model.js'; + +const config = () => ({ + blockType: 'root', id: 'root', tag: 'root', + children: [ + { blockType: 'interfaceActionBlock', id: 'b1', tag: 't1', children: [] }, + { blockType: 'policyRolesBlock', id: 'b2', tag: 't2' } + ] +}); + +describe('RootSearchModel.fromConfig', () => { + it('throws on an empty config', () => { + assert.throws(() => RootSearchModel.fromConfig(null), /Empty config/); + }); + + it('builds a searchable block list including the root', () => { + const model = RootSearchModel.fromConfig(config()); + assert.equal(model.filter('root').length, 1); + assert.equal(model.filter('interfaceActionBlock').length, 1); + }); + + it('filter returns only the blocks of a given type', () => { + const model = RootSearchModel.fromConfig(config()); + const roles = model.filter('policyRolesBlock'); + assert.equal(roles.length, 1); + assert.equal(roles[0].id, 'b2'); + }); + + it('findBlock resolves by id and returns undefined for unknown', () => { + const model = RootSearchModel.fromConfig(config()); + assert.equal(model.findBlock('b1').id, 'b1'); + assert.equal(model.findBlock('missing'), undefined); + }); + + it('search returns one chain per matching block', () => { + const model = RootSearchModel.fromConfig(config()); + const chains = model.search(new BlockSearchModel({ blockType: 'interfaceActionBlock' })); + assert.equal(chains.length, 1); + }); + + it('search returns chains sorted by descending hash', () => { + const model = RootSearchModel.fromConfig(config()); + const chains = model.search(new BlockSearchModel({ blockType: 'root' })); + for (let i = 1; i < chains.length; i++) { + assert.ok(chains[i - 1].hash >= chains[i].hash); + } + }); +}); + +describe('PolicySearchModel', () => { + const policy = (over = {}) => ({ + name: 'Policy', description: 'd', owner: 'did:o', topicId: '0.0.1', messageId: 'm-1', + version: '1.0.0', config: config(), ...over + }); + + it('throws when the policy has no config', () => { + assert.throws(() => new PolicySearchModel({ name: 'x' }), /Empty policy config/); + }); + + it('captures policy metadata and version', () => { + const model = new PolicySearchModel(policy()); + assert.equal(model.name, 'Policy'); + assert.equal(model.owner, 'did:o'); + assert.equal(model.version, '1.0.0'); + }); + + it('builds a searchable tree from the policy config', () => { + const model = new PolicySearchModel(policy()); + assert.equal(model.filter('interfaceActionBlock').length, 1); + assert.equal(model.findBlock('b2').id, 'b2'); + }); +}); diff --git a/guardian-service/tests/unit/analytics-search-utils.test.mjs b/guardian-service/tests/unit/analytics-search-utils.test.mjs new file mode 100644 index 0000000000..c102e5bfa5 --- /dev/null +++ b/guardian-service/tests/unit/analytics-search-utils.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { SearchUtils } from '../../dist/analytics/search/utils/utils.js'; + +describe('SearchUtils.comparePath', () => { + it('returns 1 when a is greater at the first differing index', () => { + assert.equal(SearchUtils.comparePath([1, 2, 5], [1, 2, 3]), 1); + }); + + it('returns -1 when a is smaller at the first differing index', () => { + assert.equal(SearchUtils.comparePath([1, 1], [1, 2]), -1); + }); + + it('returns 1 when a is a longer extension of b', () => { + assert.equal(SearchUtils.comparePath([1, 2, 3], [1, 2]), 1); + }); + + it('returns -1 when a is a shorter prefix of b', () => { + assert.equal(SearchUtils.comparePath([1, 2], [1, 2, 3]), -1); + }); + + it('returns -1 for two equal paths (no positive equality branch)', () => { + assert.equal(SearchUtils.comparePath([1, 2, 3], [1, 2, 3]), -1); + }); + + it('compares from the first element', () => { + assert.equal(SearchUtils.comparePath([2], [1, 9, 9]), 1); + assert.equal(SearchUtils.comparePath([0], [1]), -1); + }); +}); + +describe('SearchUtils.calcTotalRates', () => { + it('returns 0 for empty rates', () => { + assert.equal(SearchUtils.calcTotalRates([], []), 0); + }); + + it('returns 0 when the lengths differ', () => { + assert.equal(SearchUtils.calcTotalRates([1, 2], [1]), 0); + }); + + it('returns the floored weighted average', () => { + assert.equal(SearchUtils.calcTotalRates([10, 20], [1, 3]), 17); + }); + + it('handles single-element arrays', () => { + assert.equal(SearchUtils.calcTotalRates([42], [2]), 42); + }); + + it('returns 0 when all rates are 0', () => { + assert.equal(SearchUtils.calcTotalRates([0, 0], [1, 2]), 0); + }); + + it('floors a non-integer average down', () => { + assert.equal(SearchUtils.calcTotalRates([1, 2], [1, 1]), 1); + }); +}); diff --git a/guardian-service/tests/unit/analytics-status.test.mjs b/guardian-service/tests/unit/analytics-status.test.mjs new file mode 100644 index 0000000000..4bf2038ff7 --- /dev/null +++ b/guardian-service/tests/unit/analytics-status.test.mjs @@ -0,0 +1,11 @@ +import { assert } from 'chai'; +import { Status } from '../../dist/analytics/compare/types/status.type.js'; + +describe('analytics Status (merge) enum', () => { + it('exposes the six merge-status sentinels', () => { + for (const k of ['NONE', 'FULL', 'LEFT', 'RIGHT', 'LEFT_AND_RIGHT', 'PARTLY']) { + assert.equal(Status[k], k); + } + assert.equal(Object.keys(Status).length, 6); + }); +}); diff --git a/guardian-service/tests/unit/analytics-template-tool-model.test.mjs b/guardian-service/tests/unit/analytics-template-tool-model.test.mjs new file mode 100644 index 0000000000..9c17711346 --- /dev/null +++ b/guardian-service/tests/unit/analytics-template-tool-model.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { TemplateToolModel } from '../../dist/analytics/compare/models/template-tool.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +describe('TemplateToolModel', () => { + it('captures messageId as both messageId and key', () => { + const t = new TemplateToolModel({ messageId: 'm-1', other: 1 }); + assert.equal(t.messageId, 'm-1'); + assert.equal(t.key, 'm-1'); + }); + + it('starts with empty weights and getWeight()=undefined', () => { + const t = new TemplateToolModel({ messageId: 'm-1' }); + assert.deepEqual(t.getWeights(), []); + assert.equal(t.maxWeight(), 0); + assert.equal(t.getWeight(), undefined); + assert.equal(t.checkWeight(0), false); + }); + + it('update() populates two weights stored under TOPIC_LVL_0/1', () => { + const t = new TemplateToolModel({ messageId: 'm-1', label: 'A' }); + t.update(opts); + assert.equal(t.getWeights().length, 2); + assert.ok(t.getWeight('TOPIC_LVL_0').length > 0); + assert.ok(t.getWeight('TOPIC_LVL_1').length > 0); + assert.equal(t.maxWeight(), 2); + assert.equal(t.checkWeight(0), true); + assert.equal(t.checkWeight(1), true); + assert.equal(t.checkWeight(2), false); + }); + + it('falls back to messageId comparison when un-updated', () => { + const a = new TemplateToolModel({ messageId: 'x' }); + const b = new TemplateToolModel({ messageId: 'x' }); + const c = new TemplateToolModel({ messageId: 'y' }); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() compares the strongest weight after update', () => { + const a = new TemplateToolModel({ messageId: 'm-1', label: 'A' }); + const b = new TemplateToolModel({ messageId: 'm-1', label: 'A' }); + const c = new TemplateToolModel({ messageId: 'm-1', label: 'B' }); + a.update(opts); b.update(opts); c.update(opts); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at index=1 (looser TOPIC_LVL_0 weight) matches by messageId only', () => { + const a = new TemplateToolModel({ messageId: 'm', label: 'A' }); + const b = new TemplateToolModel({ messageId: 'm', label: 'B' }); + a.update(opts); b.update(opts); + assert.equal(a.equal(b, 1), true); + }); + + it('equalKey compares by messageId', () => { + const a = new TemplateToolModel({ messageId: 'x' }); + const b = new TemplateToolModel({ messageId: 'x' }); + const c = new TemplateToolModel({ messageId: 'y' }); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); + + it('toObject returns {messageId, properties}', () => { + const t = new TemplateToolModel({ messageId: 'm-1', extra: 1 }); + const out = t.toObject(); + assert.equal(out.messageId, 'm-1'); + assert.ok(Array.isArray(out.properties)); + }); + + it('toWeight returns messageId before update, hash after', () => { + const t = new TemplateToolModel({ messageId: 'm-1' }); + assert.equal(t.toWeight(opts).weight, 'm-1'); + t.update(opts); + assert.equal(t.toWeight(opts).weight, t.getWeights()[0]); + }); +}); diff --git a/guardian-service/tests/unit/analytics-tool-model.test.mjs b/guardian-service/tests/unit/analytics-tool-model.test.mjs new file mode 100644 index 0000000000..1b057377e8 --- /dev/null +++ b/guardian-service/tests/unit/analytics-tool-model.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { ToolModel } from '../../dist/analytics/compare/models/tool.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const minimalConfig = (overrides = {}) => ({ + blockType: 'tool', + tag: 'tool-root', + children: [], + inputEvents: [], + outputEvents: [], + variables: [], + ...overrides, +}); + +const rawTool = (overrides = {}) => ({ + id: 't-1', + name: 'Tool', + description: 'desc', + hash: 'h-1', + messageId: 'm-1', + config: minimalConfig(), + ...overrides, +}); + +describe('ToolModel construction', () => { + it('captures id/name/description/hash/messageId', () => { + const t = new ToolModel(rawTool(), opts); + assert.equal(t.id, 't-1'); + assert.equal(t.name, 'Tool'); + assert.equal(t.description, 'desc'); + assert.equal(t.hash, 'h-1'); + assert.equal(t.messageId, 'm-1'); + }); + + it('throws "Empty tool model" when config is missing', () => { + const raw = { id: 't', name: 'n', description: 'd', hash: '', messageId: '' }; + assert.throws(() => new ToolModel(raw, opts), /Empty tool model/); + }); + + it('builds the tree from config and exposes its root', () => { + const t = new ToolModel(rawTool(), opts); + assert.ok(t.tree); + assert.equal(t.tree.tag, 'tool-root'); + }); + + it('parses inputEvents / outputEvents / variables', () => { + const t = new ToolModel(rawTool({ + config: minimalConfig({ + inputEvents: [{ name: 'in1' }], + outputEvents: [{ name: 'out1' }], + variables: [{ name: 'v1' }], + }), + }), opts); + assert.equal(t.inputEvents.length, 1); + assert.equal(t.outputEvents.length, 1); + assert.equal(t.variables.length, 1); + assert.equal(t.inputEvents[0].name, 'in1'); + assert.equal(t.outputEvents[0].name, 'out1'); + assert.equal(t.variables[0].name, 'v1'); + }); + + it('handles missing inputEvents / outputEvents / variables as []', () => { + const t = new ToolModel(rawTool({ + config: minimalConfig({ + inputEvents: undefined, + outputEvents: undefined, + variables: undefined, + }), + }), opts); + assert.deepEqual(t.inputEvents, []); + assert.deepEqual(t.outputEvents, []); + assert.deepEqual(t.variables, []); + }); +}); + +describe('ToolModel.info', () => { + it('returns {id, name, description, hash, messageId}', () => { + const t = new ToolModel(rawTool(), opts); + assert.deepEqual(t.info(), { + id: 't-1', + name: 'Tool', + description: 'desc', + hash: 'h-1', + messageId: 'm-1', + }); + }); +}); + +describe('ToolModel.setSchemas / setArtifacts', () => { + it('returns the model for chaining', () => { + const t = new ToolModel(rawTool(), opts); + assert.equal(t.setSchemas([]), t); + assert.equal(t.setArtifacts([]), t); + }); +}); + +describe('ToolModel.update', () => { + it('returns the model for chaining (no schemas/artifacts → empty maps)', () => { + const t = new ToolModel(rawTool(), opts).setSchemas([]).setArtifacts([]); + assert.equal(t.update(), t); + }); +}); diff --git a/guardian-service/tests/unit/analytics-tool-schema-record-comparators.test.mjs b/guardian-service/tests/unit/analytics-tool-schema-record-comparators.test.mjs new file mode 100644 index 0000000000..98cb55142e --- /dev/null +++ b/guardian-service/tests/unit/analytics-tool-schema-record-comparators.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { ToolComparator } from '../../dist/analytics/compare/comparators/tool-comparator.js'; +import { SchemaComparator } from '../../dist/analytics/compare/comparators/schema-comparator.js'; +import { RecordComparator } from '../../dist/analytics/compare/comparators/record-comparator.js'; +import { ToolModel } from '../../dist/analytics/compare/models/tool.model.js'; +import { SchemaModel } from '../../dist/analytics/compare/models/schema.model.js'; +import { RecordModel } from '../../dist/analytics/compare/models/record.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; +const schemaOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +const tool = (overrides = {}) => new ToolModel({ + id: 't-1', name: 'Tool', description: 'desc', hash: 'h-1', messageId: 'm-1', + config: { blockType: 'tool', tag: 'tool-root', children: [], inputEvents: [], outputEvents: [], variables: [] }, + ...overrides +}, opts); + +const schema = (overrides = {}) => new SchemaModel({ + id: 'sid', name: 'My Schema', uuid: 'sid-uuid', description: 'desc', topicId: '0.0.1', + version: '1.0.0', iri: '#sid', + document: { properties: { amount: { type: 'string', title: 'A', description: 'A' } } }, + ...overrides +}, schemaOpts); + +const record = (docs = []) => { + const r = new RecordModel(schemaOpts); + r.setDocuments(docs); + return r; +}; + +describe('ToolComparator', () => { + it('constructs with default options', () => { + assert.ok(new ToolComparator()); + }); + + it('compare returns one result per right-hand tool', () => { + assert.equal(new ToolComparator().compare([tool(), tool(), tool()]).length, 2); + }); + + it('compare of a single tool yields no comparisons', () => { + assert.deepEqual(new ToolComparator().compare([tool()]), []); + }); + + it('a result exposes left/right info and a numeric total', () => { + const [result] = new ToolComparator().compare([tool(), tool()]); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + }); + + it('two identical tools compare as fully similar', () => { + const [result] = new ToolComparator().compare([tool(), tool()]); + assert.equal(result.total, 100); + }); +}); + +describe('SchemaComparator', () => { + it('constructs with default options', () => { + assert.ok(new SchemaComparator()); + }); + + it('compare returns a single result object with left/right/total', () => { + const result = new SchemaComparator().compare(schema(), schema()); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + }); + + it('two identical schemas compare as fully similar', () => { + const result = new SchemaComparator().compare(schema(), schema()); + assert.equal(result.total, 100); + }); + + it('differing schemas produce a lower-or-equal total', () => { + const a = schema(); + const b = schema({ document: { properties: { other: { type: 'number', title: 'B', description: 'B' } } } }); + const result = new SchemaComparator().compare(a, b); + assert.ok(result.total <= 100); + }); +}); + +describe('RecordComparator', () => { + it('constructs with default options', () => { + assert.ok(new RecordComparator()); + }); + + it('compare of a single record yields no comparisons', () => { + assert.deepEqual(new RecordComparator().compare([record([])]), []); + }); + + it('compare of an empty list yields no comparisons', () => { + assert.deepEqual(new RecordComparator().compare([]), []); + }); +}); diff --git a/guardian-service/tests/unit/analytics-topic-template-token-models.test.mjs b/guardian-service/tests/unit/analytics-topic-template-token-models.test.mjs new file mode 100644 index 0000000000..a2f4353c10 --- /dev/null +++ b/guardian-service/tests/unit/analytics-topic-template-token-models.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { TopicModel } from '../../dist/analytics/compare/models/topic.model.js'; +import { TemplateTokenModel } from '../../dist/analytics/compare/models/template-token.model.js'; + +const opts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; + +describe('TopicModel', () => { + it('captures name and exposes it as the key', () => { + const t = new TopicModel({ name: 'GeneralTopic', description: 'd' }); + assert.equal(t.name, 'GeneralTopic'); + assert.equal(t.key, 'GeneralTopic'); + }); + + it('starts with empty weights', () => { + const t = new TopicModel({ name: 'GeneralTopic' }); + assert.deepEqual(t.getWeights(), []); + assert.equal(t.maxWeight(), 0); + assert.equal(t.checkWeight(0), false); + }); + + it('update() populates two weights (TOPIC_LVL_0 and TOPIC_LVL_1)', () => { + const t = new TopicModel({ name: 'GeneralTopic', description: 'd' }); + t.update(opts); + assert.equal(t.getWeights().length, 2); + assert.equal(t.maxWeight(), 2); + assert.equal(t.checkWeight(0), true); + assert.equal(t.checkWeight(1), true); + assert.equal(t.checkWeight(2), false); + }); + + it('falls back to name comparison when un-updated', () => { + const a = new TopicModel({ name: 'a' }); + const b = new TopicModel({ name: 'a' }); + const c = new TopicModel({ name: 'b' }); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at default index compares the strongest weight', () => { + const a = new TopicModel({ name: 't', description: 'X' }); + const b = new TopicModel({ name: 't', description: 'X' }); + const c = new TopicModel({ name: 't', description: 'Y' }); + a.update(opts); b.update(opts); c.update(opts); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() at index=1 (looser) matches topics that share name only', () => { + const a = new TopicModel({ name: 't', description: 'X' }); + const b = new TopicModel({ name: 't', description: 'Y' }); + a.update(opts); b.update(opts); + assert.equal(a.equal(b, 1), true); + }); + + it('equalKey compares names', () => { + const a = new TopicModel({ name: 't' }); + const b = new TopicModel({ name: 't' }); + const c = new TopicModel({ name: 'q' }); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); + + it('toObject returns {name, properties}', () => { + const t = new TopicModel({ name: 't', extra: 1 }); + const out = t.toObject(); + assert.equal(out.name, 't'); + assert.ok(Array.isArray(out.properties)); + }); + + it('toWeight returns name when un-updated, hash when updated', () => { + const t = new TopicModel({ name: 't' }); + assert.equal(t.toWeight(opts).weight, 't'); + t.update(opts); + assert.equal(t.toWeight(opts).weight, t.getWeights()[0]); + }); + + it('getWeight(type) reads the named weight from the map', () => { + const t = new TopicModel({ name: 't' }); + t.update(opts); + assert.ok(t.getWeight('TOPIC_LVL_0').length > 0); + assert.ok(t.getWeight('TOPIC_LVL_1').length > 0); + }); +}); + +describe('TemplateTokenModel', () => { + it('captures templateTokenTag as both name and key', () => { + const t = new TemplateTokenModel({ templateTokenTag: 'token-A', tokenName: 'X' }); + assert.equal(t.name, 'token-A'); + assert.equal(t.key, 'token-A'); + }); + + it('toObject uses tag (not name) as the top-level field', () => { + const t = new TemplateTokenModel({ templateTokenTag: 'token-A' }); + const out = t.toObject(); + assert.equal(out.tag, 'token-A'); + assert.ok(Array.isArray(out.properties)); + assert.equal(out.name, undefined); + }); + + it('update() populates two weights stored under TOPIC_LVL_0/1', () => { + const t = new TemplateTokenModel({ templateTokenTag: 't', tokenName: 'A' }); + t.update(opts); + assert.equal(t.getWeights().length, 2); + assert.ok(t.getWeight('TOPIC_LVL_0').length > 0); + assert.ok(t.getWeight('TOPIC_LVL_1').length > 0); + }); + + it('equal() falls back to name comparison when un-updated', () => { + const a = new TemplateTokenModel({ templateTokenTag: 'x' }); + const b = new TemplateTokenModel({ templateTokenTag: 'x' }); + const c = new TemplateTokenModel({ templateTokenTag: 'y' }); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equal() returns true for two identical raws after update', () => { + const a = new TemplateTokenModel({ templateTokenTag: 't', tokenName: 'A' }); + const b = new TemplateTokenModel({ templateTokenTag: 't', tokenName: 'A' }); + a.update(opts); b.update(opts); + assert.equal(a.equal(b), true); + }); + + it('equal() returns false when properties differ at the strongest level', () => { + const a = new TemplateTokenModel({ templateTokenTag: 't', tokenName: 'A' }); + const b = new TemplateTokenModel({ templateTokenTag: 't', tokenName: 'B' }); + a.update(opts); b.update(opts); + assert.equal(a.equal(b), false); + }); + + it('equalKey compares names', () => { + const a = new TemplateTokenModel({ templateTokenTag: 'x' }); + const b = new TemplateTokenModel({ templateTokenTag: 'x' }); + const c = new TemplateTokenModel({ templateTokenTag: 'y' }); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); +}); diff --git a/guardian-service/tests/unit/analytics-weight-type.test.mjs b/guardian-service/tests/unit/analytics-weight-type.test.mjs new file mode 100644 index 0000000000..d52fd41b1a --- /dev/null +++ b/guardian-service/tests/unit/analytics-weight-type.test.mjs @@ -0,0 +1,19 @@ +import { assert } from 'chai'; +import { WeightType } from '../../dist/analytics/compare/types/weight.type.js'; + +describe('analytics WeightType enum', () => { + it('exposes representative CHILD/PROP/SCHEMA/GROUP/TOPIC/ROLE level keys', () => { + for (const k of ['CHILD_LVL_1', 'CHILD_LVL_2', 'PROP_LVL_1', 'PROP_LVL_2', + 'SCHEMA_LVL_0', 'SCHEMA_LVL_1', 'SCHEMA_LVL_2', 'SCHEMA_LVL_3', 'SCHEMA_LVL_4', + 'GROUP_LVL_0', 'GROUP_LVL_1', + 'TOPIC_LVL_0', 'TOPIC_LVL_1', + 'ROLE_LVL_0', + 'PROP_AND_CHILD', 'PROP_AND_CHILD_1', 'PROP_AND_CHILD_2', 'PROP_AND_CHILD_3', + 'PROP_LVL_3']) { + assert.equal(WeightType[k], k); + } + }); + it('has 19 entries (covers all granularity levels)', () => { + assert.equal(Object.keys(WeightType).length, 19); + }); +}); diff --git a/guardian-service/tests/unit/api-helper.test.mjs b/guardian-service/tests/unit/api-helper.test.mjs new file mode 100644 index 0000000000..4e533037d6 --- /dev/null +++ b/guardian-service/tests/unit/api-helper.test.mjs @@ -0,0 +1,88 @@ +import { assert } from 'chai'; +import { getPageOptions, escapeRegExp } from '../../dist/api/helpers/api-helper.js'; + +describe('getPageOptions', () => { + it('returns "all" sentinel: only orderBy DESC, no limit/offset', () => { + const opts = getPageOptions({ pageSize: 'all' }); + assert.deepEqual(opts.orderBy, { createDate: 'DESC' }); + assert.isUndefined(opts.limit); + assert.isUndefined(opts.offset); + }); + + it('parses integer pageSize/pageIndex and sets limit/offset', () => { + const opts = getPageOptions({ pageSize: 25, pageIndex: 2 }); + assert.deepEqual(opts.orderBy, { createDate: 'DESC' }); + assert.equal(opts.limit, 25); + assert.equal(opts.offset, 50); + }); + + it('parses string-numeric pageSize/pageIndex', () => { + const opts = getPageOptions({ pageSize: '10', pageIndex: '3' }); + assert.equal(opts.limit, 10); + assert.equal(opts.offset, 30); + }); + + it('caps limit at 1000 when pageSize is huge', () => { + const opts = getPageOptions({ pageSize: 50000, pageIndex: 0 }); + assert.equal(opts.limit, 1000); + assert.equal(opts.offset, 0); + }); + + it('falls back to limit=1000, offset=0 when pageSize is non-numeric', () => { + const opts = getPageOptions({ pageSize: 'abc', pageIndex: 'def' }); + assert.equal(opts.limit, 1000); + assert.equal(opts.offset, 0); + assert.deepEqual(opts.orderBy, { createDate: 'DESC' }); + }); + + it('falls back when pageIndex/pageSize are missing', () => { + const opts = getPageOptions({}); + assert.equal(opts.limit, 1000); + assert.equal(opts.offset, 0); + }); + + it('passes fields through onto returned options', () => { + const opts = getPageOptions({ pageSize: 5, pageIndex: 0, fields: ['id', 'name'] }); + assert.deepEqual(opts.fields, ['id', 'name']); + }); + + it('merges into a caller-supplied options object', () => { + const base = { custom: 'value' }; + const opts = getPageOptions({ pageSize: 10, pageIndex: 1 }, base); + assert.equal(opts.custom, 'value'); + assert.equal(opts.limit, 10); + assert.equal(opts.offset, 10); + assert.strictEqual(opts, base, 'should mutate and return the same object'); + }); + + it('does not set fields when not provided', () => { + const opts = getPageOptions({ pageSize: 10, pageIndex: 0 }); + assert.isUndefined(opts.fields); + }); +}); + +describe('escapeRegExp', () => { + it('escapes regex special characters', () => { + const escaped = escapeRegExp('a.b*c+d?e^f$'); + // Each special char must now be backslash-prefixed + assert.match(escaped, /^a\\\.b\\\*c\\\+d\\\?e\\\^f\\\$$/); + }); + + it('leaves alphanumerics unchanged', () => { + assert.equal(escapeRegExp('hello world 123'), 'hello world 123'); + }); + + it('escapes parens, braces, brackets, pipes, slashes, backslashes', () => { + const escaped = escapeRegExp('(){}[]|/\\'); + for (const ch of '(){}[]|/\\') { + assert.include(escaped, '\\' + ch); + } + }); + + it('result is a safe substring matcher when wrapped in RegExp', () => { + const needle = 'a+b.c'; + const re = new RegExp(escapeRegExp(needle)); + assert.isTrue(re.test('zz a+b.c yy')); + assert.isFalse(re.test('axxbXc')); + }); +}); diff --git a/guardian-service/tests/unit/block-model-weights.test.mjs b/guardian-service/tests/unit/block-model-weights.test.mjs new file mode 100644 index 0000000000..994bad3e41 --- /dev/null +++ b/guardian-service/tests/unit/block-model-weights.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { BlockModel } from '../../dist/analytics/compare/models/index.js'; +import { IChildrenLvl, IPropertiesLvl, IIdLvl, IKeyLvl, IRefLvl } from '../../dist/analytics/compare/interfaces/index.js'; + +const OPT = { + childLvl: IChildrenLvl.All, + propLvl: IPropertiesLvl.All, + idLvl: IIdLvl.All, + keyLvl: IKeyLvl.Default, + refLvl: IRefLvl.Revert, +}; + +const raw = (over = {}) => ({ + blockType: 'interfaceContainerBlock', + tag: 'block-tag', + ...over, +}); + +describe('BlockModel construction', () => { + it('exposes blockType, tag, index and key', () => { + const b = new BlockModel(raw(), 3); + assert.equal(b.blockType, 'interfaceContainerBlock'); + assert.equal(b.tag, 'block-tag'); + assert.equal(b.index, 3); + assert.equal(b.key, 'interfaceContainerBlock'); + }); + it('starts with empty children and weights', () => { + const b = new BlockModel(raw(), 0); + assert.deepEqual(b.children, []); + assert.deepEqual(b.getWeights(), []); + assert.equal(b.maxWeight(), 0); + }); + it('createEvents reads events array', () => { + const b = new BlockModel(raw({ events: [{ source: 'a', target: 'b', actor: '', input: 'i', output: 'o' }] }), 0); + assert.equal(b.getEventList().length, 1); + }); + it('createEvents tolerates missing events', () => { + const b = new BlockModel(raw({ events: undefined }), 0); + assert.deepEqual(b.getEventList(), []); + }); + it('createArtifacts reads artifacts array', () => { + const b = new BlockModel(raw({ artifacts: [{ uuid: 'u1' }] }), 0); + assert.equal(b.getArtifactsList().length, 1); + }); + it('createArtifacts tolerates non-array', () => { + const b = new BlockModel(raw({ artifacts: 'nope' }), 0); + assert.deepEqual(b.getArtifactsList(), []); + }); +}); + +describe('BlockModel.addChildren / children', () => { + it('appends children', () => { + const parent = new BlockModel(raw(), 0); + const child = new BlockModel(raw({ blockType: 'child', tag: 'c' }), 1); + parent.addChildren(child); + assert.equal(parent.children.length, 1); + assert.equal(parent.children[0], child); + }); +}); + +describe('BlockModel.update weights', () => { + it('populates weights array after update with All levels', () => { + const b = new BlockModel(raw(), 0); + b.update(OPT); + assert.ok(b.getWeights().length > 0); + assert.equal(typeof b.getWeight(), 'string'); + }); + it('childLvl None yields fewer weights than All', () => { + const a = new BlockModel(raw(), 0); + a.update({ ...OPT, childLvl: IChildrenLvl.None }); + const c = new BlockModel(raw(), 0); + c.update(OPT); + assert.ok(c.getWeights().length >= a.getWeights().length); + }); + it('propLvl None drops property weights', () => { + const a = new BlockModel(raw(), 0); + a.update({ ...OPT, propLvl: IPropertiesLvl.None }); + const c = new BlockModel(raw(), 0); + c.update(OPT); + assert.ok(c.getWeights().length > a.getWeights().length); + }); + it('childLvl First uses child-level-1 weighting', () => { + const parent = new BlockModel(raw(), 0); + parent.addChildren(new BlockModel(raw({ blockType: 'k', tag: 't' }), 1)); + parent.update({ ...OPT, childLvl: IChildrenLvl.First }); + assert.ok(parent.getWeights().length > 0); + }); + it('getWeight by named type returns from weightMap', () => { + const b = new BlockModel(raw(), 0); + b.update(OPT); + const named = b.getWeight('PROP_LVL_2'); + assert.equal(typeof named, 'string'); + }); + it('blocks without tag still compute weights', () => { + const b = new BlockModel(raw({ tag: undefined }), 0); + b.update(OPT); + assert.ok(b.getWeights().length > 0); + }); +}); + +describe('BlockModel.equal', () => { + it('false when keys differ', () => { + const a = new BlockModel(raw({ blockType: 'A' }), 0); + const b = new BlockModel(raw({ blockType: 'B' }), 0); + a.update(OPT); b.update(OPT); + assert.equal(a.equal(b), false); + }); + it('true for identical blocks (hash compare, no index)', () => { + const a = new BlockModel(raw(), 0); + const b = new BlockModel(raw(), 0); + a.update(OPT); b.update(OPT); + assert.equal(a.equal(b), true); + }); + it('falls back to key compare when no weights', () => { + const a = new BlockModel(raw(), 0); + const b = new BlockModel(raw(), 0); + assert.equal(a.equal(b), true); + }); + it('index-based equal compares the weight at that index', () => { + const a = new BlockModel(raw(), 0); + const b = new BlockModel(raw(), 0); + a.update(OPT); b.update(OPT); + assert.equal(a.equal(b, 0), true); + }); + it('index-based equal returns false when both weights are zero', () => { + const a = new BlockModel(raw({ tag: undefined }), 0); + const b = new BlockModel(raw({ tag: undefined }), 0); + a.update({ ...OPT, propLvl: IPropertiesLvl.None, childLvl: IChildrenLvl.None }); + b.update({ ...OPT, propLvl: IPropertiesLvl.None, childLvl: IChildrenLvl.None }); + const len = a.getWeights().length; + let sawZero = false; + for (let i = 0; i < len; i++) { + if (a.getWeights()[i] === '0') { sawZero = true; assert.equal(a.equal(b, i), false); } + } + assert.ok(sawZero || len >= 0); + }); + it('equalKey compares keys only', () => { + const a = new BlockModel(raw({ blockType: 'X' }), 0); + const b = new BlockModel(raw({ blockType: 'X' }), 5); + assert.equal(a.equalKey(b), true); + }); +}); + +describe('BlockModel.checkWeight / maxWeight', () => { + it('checkWeight true within bounds, false beyond', () => { + const b = new BlockModel(raw(), 0); + b.update(OPT); + const n = b.maxWeight(); + assert.equal(b.checkWeight(0), true); + assert.equal(b.checkWeight(n), false); + }); +}); + +describe('BlockModel.toObject / toWeight', () => { + it('toObject returns the documented shape', () => { + const b = new BlockModel(raw({ events: [{ source: 's', target: 't', actor: '', input: 'i', output: 'o' }] }), 2); + const o = b.toObject(); + assert.equal(o.index, 2); + assert.equal(o.blockType, 'interfaceContainerBlock'); + assert.ok(Array.isArray(o.properties)); + assert.ok(Array.isArray(o.events)); + }); + it('toWeight returns weights, children and length', () => { + const parent = new BlockModel(raw(), 0); + const child = new BlockModel(raw({ blockType: 'c', tag: 'ct' }), 1); + child.update(OPT); + parent.addChildren(child); + parent.update(OPT); + const w = parent.toWeight(OPT); + assert.equal(w.weights.length, 5); + assert.equal(w.children.length, 1); + assert.equal(typeof w.length, 'number'); + }); + it('toWeight on a leaf returns length 0 children', () => { + const b = new BlockModel(raw(), 0); + b.update(OPT); + const w = b.toWeight(OPT); + assert.equal(w.children.length, 0); + assert.equal(w.length, 0); + }); +}); + +describe('BlockModel.updateArtifacts', () => { + it('updates matching artifact by uuid', () => { + const b = new BlockModel(raw({ artifacts: [{ uuid: 'u1' }] }), 0); + b.updateArtifacts([{ uuid: 'u1', data: 'payload' }], OPT); + assert.equal(b.getArtifactsList().length, 1); + }); + it('handles artifact with no matching data row', () => { + const b = new BlockModel(raw({ artifacts: [{ uuid: 'u1' }] }), 0); + b.updateArtifacts([], OPT); + assert.equal(b.getArtifactsList().length, 1); + }); +}); diff --git a/guardian-service/tests/unit/compare-comparators-csv.test.mjs b/guardian-service/tests/unit/compare-comparators-csv.test.mjs new file mode 100644 index 0000000000..8ec7bf7aa4 --- /dev/null +++ b/guardian-service/tests/unit/compare-comparators-csv.test.mjs @@ -0,0 +1,227 @@ +import { assert } from 'chai'; +import { DocumentComparator } from '../../dist/analytics/compare/comparators/document-comparator.js'; +import { ModuleComparator } from '../../dist/analytics/compare/comparators/module-comparator.js'; +import { PolicyComparator } from '../../dist/analytics/compare/comparators/policy-comparator.js'; + +const HEADER = 'data:text/csv;charset=utf-8;'; +const emptyTable = () => ({ columns: [{ name: 'x', label: '', type: 'object' }], report: [] }); + +describe('DocumentComparator.tableToCsv', () => { + const make = () => ([{ + left: { id: 'L', type: 'VC', owner: 'owner-l', policy: 'pol-l' }, + right: { id: 'R', type: 'VP', owner: 'owner-r', policy: 'pol-r' }, + total: 88, + documents: emptyTable(), + }]); + + it('starts with the csv data-uri header', () => { + const csv = DocumentComparator.tableToCsv(make()); + assert.isTrue(csv.startsWith(HEADER)); + }); + + it('emits the Document 1 left header block', () => { + const csv = DocumentComparator.tableToCsv(make()); + assert.include(csv, '"Document 1"'); + assert.include(csv, '"Document ID","Document Type","Document Owner","Policy"'); + }); + + it('writes the left document values', () => { + const csv = DocumentComparator.tableToCsv(make()); + assert.include(csv, '"L","VC","owner-l","pol-l"'); + }); + + it('writes the right document under a Document 2 heading', () => { + const csv = DocumentComparator.tableToCsv(make()); + assert.include(csv, '"Document 2"'); + assert.include(csv, '"R","VP","owner-r","pol-r"'); + }); + + it('appends the total as a percentage', () => { + const csv = DocumentComparator.tableToCsv(make()); + assert.include(csv, '"Total","88%"'); + }); + + it('numbers additional documents sequentially', () => { + const results = [ + { left: { id: 'L' }, right: { id: 'R1' }, total: 10, documents: emptyTable() }, + { left: { id: 'L' }, right: { id: 'R2' }, total: 20, documents: emptyTable() }, + ]; + const csv = DocumentComparator.tableToCsv(results); + assert.include(csv, '"Document 2"'); + assert.include(csv, '"Document 3"'); + }); + + it('is a static method (callable without an instance)', () => { + assert.isFunction(DocumentComparator.tableToCsv); + }); +}); + +describe('DocumentComparator.mergeCompareResults', () => { + const comparator = new DocumentComparator(); + const results = [ + { left: { id: 'L' }, right: { id: 'R1' }, total: 30, documents: emptyTable() }, + { left: { id: 'L' }, right: { id: 'R2' }, total: 70, documents: emptyTable() }, + ]; + + it('sets size to results length + 1', () => { + assert.equal(comparator.mergeCompareResults(results).size, 3); + }); + + it('carries the left info from the first result', () => { + assert.deepEqual(comparator.mergeCompareResults(results).left, { id: 'L' }); + }); + + it('collects all rights', () => { + const merged = comparator.mergeCompareResults(results); + assert.deepEqual(merged.rights, [{ id: 'R1' }, { id: 'R2' }]); + }); + + it('collects all totals', () => { + assert.deepEqual(comparator.mergeCompareResults(results).totals, [30, 70]); + }); + + it('produces a documents table object with columns and report', () => { + const merged = comparator.mergeCompareResults(results); + assert.property(merged.documents, 'columns'); + assert.property(merged.documents, 'report'); + }); +}); + +describe('ModuleComparator.csv', () => { + const comparator = new ModuleComparator(); + const result = { + left: { id: 'm1', name: 'Mod1', description: 'first' }, + right: { id: 'm2', name: 'Mod2', description: 'second' }, + total: 64, + inputEvents: emptyTable(), + outputEvents: emptyTable(), + variables: emptyTable(), + blocks: emptyTable(), + }; + + it('starts with the csv data-uri header', () => { + assert.isTrue(comparator.csv(result).startsWith(HEADER)); + }); + + it('emits a Module 1 section with the left id/name/description', () => { + const csv = comparator.csv(result); + assert.include(csv, '"Module 1"'); + assert.include(csv, '"m1","Mod1","first"'); + }); + + it('emits a Module 2 section with the right id/name/description', () => { + const csv = comparator.csv(result); + assert.include(csv, '"Module 2"'); + assert.include(csv, '"m2","Mod2","second"'); + }); + + it('labels the four sub-tables', () => { + const csv = comparator.csv(result); + assert.include(csv, '"Module Input Events"'); + assert.include(csv, '"Module Output Events"'); + assert.include(csv, '"Module Variables"'); + assert.include(csv, '"Module Blocks"'); + }); + + it('appends the total percentage', () => { + assert.include(comparator.csv(result), '"Total","64%"'); + }); +}); + +describe('PolicyComparator.tableToCsv', () => { + const comparator = new PolicyComparator(); + const make = () => ([{ + left: { id: 'p1', name: 'Pol1', description: 'd1', instanceTopicId: 't1', version: '1.0' }, + right: { id: 'p2', name: 'Pol2', description: 'd2', instanceTopicId: 't2', version: '2.0' }, + total: 51, + roles: emptyTable(), + groups: emptyTable(), + topics: emptyTable(), + tokens: emptyTable(), + tools: emptyTable(), + blocks: emptyTable(), + }]); + + it('starts with the csv data-uri header', () => { + assert.isTrue(comparator.tableToCsv(make()).startsWith(HEADER)); + }); + + it('emits the Policy 1 left header and values', () => { + const csv = comparator.tableToCsv(make()); + assert.include(csv, '"Policy 1"'); + assert.include(csv, '"Policy ID","Policy Name","Policy Description","Policy Topic","Policy Version"'); + assert.include(csv, '"p1","Pol1","d1","t1","1.0"'); + }); + + it('emits the Policy 2 right values', () => { + const csv = comparator.tableToCsv(make()); + assert.include(csv, '"Policy 2"'); + assert.include(csv, '"p2","Pol2","d2","t2","2.0"'); + }); + + it('labels all six policy sub-tables', () => { + const csv = comparator.tableToCsv(make()); + for (const label of ['Policy Roles', 'Policy Groups', 'Policy Topics', 'Policy Tokens', 'Policy Tools', 'Policy Blocks']) { + assert.include(csv, `"${label}"`); + } + }); + + it('appends the total percentage', () => { + assert.include(comparator.tableToCsv(make()), '"Total","51%"'); + }); +}); + +describe('PolicyComparator.to dispatch', () => { + const comparator = new PolicyComparator(); + const single = () => ([{ + left: { id: 'p1', name: 'n', description: 'd', instanceTopicId: 't', version: 'v' }, + right: { id: 'p2', name: 'n', description: 'd', instanceTopicId: 't', version: 'v' }, + total: 0, + roles: emptyTable(), groups: emptyTable(), topics: emptyTable(), + tokens: emptyTable(), tools: emptyTable(), blocks: emptyTable(), + }]); + + it('returns a csv string when type is "csv" for a single result', () => { + const out = comparator.to(single(), 'csv'); + assert.isString(out); + assert.isTrue(out.startsWith(HEADER)); + }); + + it('returns the single result object as-is for a non-csv type', () => { + const results = single(); + const out = comparator.to(results, 'json'); + assert.strictEqual(out, results[0]); + }); + + it('throws "Invalid size" when given an empty results array', () => { + assert.throws(() => comparator.to([], 'json'), /Invalid size/); + }); + + const multi = () => ([ + { + left: { id: 'p1' }, right: { id: 'r1' }, total: 10, + roles: emptyTable(), groups: emptyTable(), topics: emptyTable(), + tokens: emptyTable(), tools: emptyTable(), blocks: emptyTable(), + }, + { + left: { id: 'p1' }, right: { id: 'r2' }, total: 20, + roles: emptyTable(), groups: emptyTable(), topics: emptyTable(), + tokens: emptyTable(), tools: emptyTable(), blocks: emptyTable(), + }, + ]); + + it('returns a merged multi-result object for >1 results when type is not csv', () => { + const out = comparator.to(multi(), 'json'); + assert.equal(out.size, 3); + assert.deepEqual(out.totals, [10, 20]); + assert.equal(out.rights.length, 2); + assert.property(out, 'blocks'); + }); + + it('returns a csv string for >1 results when type is csv', () => { + const out = comparator.to(multi(), 'csv'); + assert.isString(out); + assert.include(out, '"Policy 2"'); + assert.include(out, '"Policy 3"'); + }); +}); diff --git a/guardian-service/tests/unit/compare-policy-utils-tree.test.mjs b/guardian-service/tests/unit/compare-policy-utils-tree.test.mjs new file mode 100644 index 0000000000..709614fe2b --- /dev/null +++ b/guardian-service/tests/unit/compare-policy-utils-tree.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { ComparePolicyUtils } from '../../dist/analytics/compare/utils/compare-policy-utils.js'; +import { Status } from '../../dist/analytics/compare/types/index.js'; + +const node = (key, children = [], opts = {}) => ({ + key, + children, + getChildren: () => children, + equal: opts.equal ?? ((o) => !!o && o.key === key), + equalKey: opts.equalKey ?? ((o) => !!o && o.key === key), +}); + +const makeRate = () => { + let kids = []; + return { + type: null, + setChildren: function (c) { kids = c; }, + getChildren: () => kids, + }; +}; + +describe('ComparePolicyUtils.treeToArray', () => { + it('flattens a tree depth-first', () => { + const tree = node('root', [node('a', [node('a1')]), node('b')]); + const out = ComparePolicyUtils.treeToArray(tree, []); + assert.deepEqual(out.map((n) => n.key), ['root', 'a', 'a1', 'b']); + }); + it('returns single element for a leaf', () => { + const out = ComparePolicyUtils.treeToArray(node('leaf'), []); + assert.equal(out.length, 1); + }); + it('appends into the provided result array', () => { + const acc = [{ key: 'pre' }]; + const out = ComparePolicyUtils.treeToArray(node('x'), acc); + assert.equal(out[0].key, 'pre'); + assert.equal(out[1].key, 'x'); + }); +}); + +describe('ComparePolicyUtils._rateToTable / rateToTable / ratesToTable', () => { + const rnode = (key, children = []) => ({ key, getChildren: () => children }); + it('rateToTable flattens a single rate tree', () => { + const r = rnode('r', [rnode('c1'), rnode('c2', [rnode('c2a')])]); + const table = ComparePolicyUtils.rateToTable(r); + assert.deepEqual(table.map((x) => x.key), ['r', 'c1', 'c2', 'c2a']); + }); + it('ratesToTable flattens a list of rate trees', () => { + const table = ComparePolicyUtils.ratesToTable([rnode('a', [rnode('a1')]), rnode('b')]); + assert.deepEqual(table.map((x) => x.key), ['a', 'a1', 'b']); + }); + it('ratesToTable returns empty for empty input', () => { + assert.deepEqual(ComparePolicyUtils.ratesToTable([]), []); + }); + it('_rateToTable pushes into given table', () => { + const table = []; + ComparePolicyUtils._rateToTable(rnode('only'), table); + assert.equal(table.length, 1); + assert.equal(table[0].key, 'only'); + }); +}); + +describe('ComparePolicyUtils.compareTree status branches', () => { + it('marks FULL when both equal', () => { + const createRate = () => makeRate(); + const t1 = node('s', [], { equal: () => true }); + const t2 = node('s'); + const rate = ComparePolicyUtils.compareTree(t1, t2, createRate); + assert.equal(rate.type, Status.FULL); + }); + it('marks PARTLY when keys equal but not fully equal', () => { + const createRate = () => makeRate(); + const t1 = node('s', [], { equal: () => false, equalKey: () => true }); + const t2 = node('s'); + const rate = ComparePolicyUtils.compareTree(t1, t2, createRate); + assert.equal(rate.type, Status.PARTLY); + }); + it('marks LEFT_AND_RIGHT when neither equal nor key-equal', () => { + const createRate = () => makeRate(); + const t1 = node('a', [], { equal: () => false, equalKey: () => false }); + const t2 = node('b'); + const rate = ComparePolicyUtils.compareTree(t1, t2, createRate); + assert.equal(rate.type, Status.LEFT_AND_RIGHT); + }); + it('marks LEFT when right is missing', () => { + const createRate = () => makeRate(); + const rate = ComparePolicyUtils.compareTree(node('a'), null, createRate); + assert.equal(rate.type, Status.LEFT); + }); + it('marks RIGHT when left is missing', () => { + const createRate = () => makeRate(); + const rate = ComparePolicyUtils.compareTree(null, node('b'), createRate); + assert.equal(rate.type, Status.RIGHT); + }); + it('returns bare rate when both trees missing', () => { + const createRate = () => makeRate(); + const rate = ComparePolicyUtils.compareTree(null, null, createRate); + assert.equal(rate.type, null); + }); + it('recurses children on FULL match', () => { + const createRate = () => makeRate(); + const t1 = node('s', [node('c', [], { equal: () => true })], { equal: () => true }); + const t2 = node('s', [node('c')]); + const rate = ComparePolicyUtils.compareTree(t1, t2, createRate); + assert.equal(rate.getChildren().length, 1); + }); +}); + +describe('ComparePolicyUtils.compareChildren', () => { + it('FULL uses 1:1 index pairing', () => { + const createRate = () => makeRate(); + const c1 = [node('a', [], { equal: () => true })]; + const c2 = [node('a')]; + const out = ComparePolicyUtils.compareChildren(Status.FULL, c1, c2, createRate); + assert.equal(out.length, 1); + assert.equal(out[0].type, Status.FULL); + }); + it('non-FULL non-PARTLY status uses notMerge (left only)', () => { + const createRate = () => makeRate(); + const out = ComparePolicyUtils.compareChildren(Status.LEFT, [node('a')], null, createRate); + assert.equal(out.length, 1); + assert.equal(out[0].type, Status.LEFT); + }); + it('returns empty when both child lists null via notMerge', () => { + const createRate = () => makeRate(); + const out = ComparePolicyUtils.compareChildren(Status.RIGHT, null, null, createRate); + assert.equal(out.length, 0); + }); +}); diff --git a/guardian-service/tests/unit/compare-utils-branches.test.mjs b/guardian-service/tests/unit/compare-utils-branches.test.mjs new file mode 100644 index 0000000000..b644596787 --- /dev/null +++ b/guardian-service/tests/unit/compare-utils-branches.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { CompareUtils } from '../../dist/analytics/compare/utils/utils.js'; +import { CSV } from '../../dist/analytics/compare/table/csv.js'; + +describe('CompareUtils.equal', () => { + it('delegates to .equal when present', () => { + const a = { equal: (o) => o === 'match' }; + assert.equal(CompareUtils.equal(a, 'match'), true); + assert.equal(CompareUtils.equal(a, 'no'), false); + }); + it('uses strict equality for primitives', () => { + assert.equal(CompareUtils.equal(5, 5), true); + assert.equal(CompareUtils.equal('x', 'y'), false); + }); +}); + +describe('CompareUtils.mapping', () => { + it('matches an unpaired left and assigns right', () => { + const left = { equal: (o) => o.id === 1 }; + const list = [{ left, right: null }]; + CompareUtils.mapping(list, { id: 1 }); + assert.equal(list[0].right.id, 1); + }); + it('pushes a new row when nothing matches', () => { + const list = [{ left: { equal: () => false }, right: null }]; + CompareUtils.mapping(list, { id: 9 }); + assert.equal(list.length, 2); + assert.equal(list[1].left, null); + assert.equal(list[1].right.id, 9); + }); + it('skips rows that already have a right', () => { + const left = { equal: () => true }; + const list = [{ left, right: { id: 'taken' } }]; + CompareUtils.mapping(list, { id: 'new' }); + assert.equal(list.length, 2); + assert.equal(list[0].right.id, 'taken'); + }); +}); + +describe('CompareUtils.calcRate', () => { + it('returns 100 for empty list', () => { + assert.equal(CompareUtils.calcRate([]), 100); + }); + it('averages positive totalRate values, ignoring non-positive', () => { + const out = CompareUtils.calcRate([{ totalRate: 100 }, { totalRate: -5 }, { totalRate: 50 }]); + assert.equal(out, Math.floor(150 / 3)); + }); + it('clamps result to a maximum of 100', () => { + const out = CompareUtils.calcRate([{ totalRate: 100 }, { totalRate: 100 }]); + assert.equal(out, 100); + }); + it('returns 0 when all rates are non-positive', () => { + assert.equal(CompareUtils.calcRate([{ totalRate: 0 }, { totalRate: -1 }]), 0); + }); +}); + +describe('CompareUtils.calcTotalRate / calcTotalRates', () => { + it('calcTotalRate averages variadic args', () => { + assert.equal(CompareUtils.calcTotalRate(10, 20, 30), 20); + }); + it('calcTotalRate floors the average', () => { + assert.equal(CompareUtils.calcTotalRate(10, 11), 10); + }); + it('calcTotalRates returns 100 for empty', () => { + assert.equal(CompareUtils.calcTotalRates([]), 100); + }); + it('calcTotalRates floors the average', () => { + assert.equal(CompareUtils.calcTotalRates([10, 11, 12]), 11); + }); +}); + +describe('CompareUtils.total (bucketed)', () => { + it('returns 100 for empty', () => { + assert.equal(CompareUtils.total([]), 100); + }); + it('buckets >99 as 100', () => { + assert.equal(CompareUtils.total([{ totalRate: 100 }]), 100); + }); + it('buckets 51..99 as 50', () => { + assert.equal(CompareUtils.total([{ totalRate: 75 }]), 50); + }); + it('buckets <=50 as 0', () => { + assert.equal(CompareUtils.total([{ totalRate: 50 }]), 0); + }); + it('averages mixed buckets', () => { + assert.equal(CompareUtils.total([{ totalRate: 100 }, { totalRate: 0 }]), 50); + }); +}); + +describe('CompareUtils.compareSchemas', () => { + it('returns 0 when either side empty', () => { + assert.equal(CompareUtils.compareSchemas([], [{}]), 0); + assert.equal(CompareUtils.compareSchemas(null, null), 0); + }); + it('directly compares single-vs-single', () => { + const s1 = { compare: () => 88 }; + const s2 = {}; + assert.equal(CompareUtils.compareSchemas([s1], [s2]), 88); + }); + it('returns best-min for many-vs-many high match', () => { + const mk = (v) => ({ compare: () => v }); + const out = CompareUtils.compareSchemas([mk(90), mk(80)], [mk(90), mk(70)]); + assert.equal(typeof out, 'number'); + assert.ok(out <= 100); + }); + it('returns -1 when every pair is near-fully unmatched', () => { + const mk = () => ({ compare: () => -1 }); + const out = CompareUtils.compareSchemas([mk(), mk()], [mk(), mk()]); + assert.equal(out, -1); + }); + it('returns -3 for the just-over-100 band', () => { + const mk = () => ({ compare: () => -3 }); + const out = CompareUtils.compareSchemas([mk(), mk()], [mk(), mk()]); + assert.equal(out, -3); + }); +}); + +describe('CompareUtils.objectToCsv / convertToCsvRecursive', () => { + it('serialises a flat object to rows with header', () => { + const csv = CompareUtils.objectToCsv({ a: 1, b: 'x' }); + const text = csv.result(); + assert.ok(text.includes('Index')); + assert.ok(text.includes('Key')); + }); + it('handles arrays with array type marker', () => { + const csv = new CSV().add('Index').add('Key').add('Value').add('Type').addLine(); + CompareUtils.convertToCsvRecursive(csv, [1, 2]); + assert.ok(csv.result().includes('array')); + }); + it('handles primitive values with typeof', () => { + const csv = new CSV().add('Index').add('Key').add('Value').add('Type').addLine(); + CompareUtils.convertToCsvRecursive(csv, 42, '0', 'num'); + assert.ok(csv.result().includes('number')); + }); + it('handles null as a primitive object branch', () => { + const csv = new CSV().add('h').addLine(); + CompareUtils.convertToCsvRecursive(csv, null, '0', 'n'); + assert.ok(csv.result().includes('object')); + }); + it('recurses nested objects and arrays', () => { + const csv = CompareUtils.objectToCsv({ list: [{ k: 'v' }], n: 3 }); + const text = csv.result(); + assert.ok(text.includes('array')); + assert.ok(text.includes('object') || text.includes('string')); + }); +}); + +describe('CompareUtils.createBlockModel / createToolModel', () => { + it('creates a tool model for blockType tool', () => { + const m = CompareUtils.createBlockModel({ blockType: 'tool' }, 0); + assert.ok(m); + }); + it('creates a nested block model with children', () => { + const m = CompareUtils.createBlockModel({ + blockType: 'container', + children: [{ blockType: 'leaf' }], + }, 0); + assert.ok(m); + }); + it('createToolModel wraps a block with children', () => { + const m = CompareUtils.createToolModel({ blockType: 'root', children: [{ blockType: 'x' }] }, 0); + assert.ok(m); + }); + it('createBlockModel tolerates missing children', () => { + const m = CompareUtils.createBlockModel({ blockType: 'x' }, 1); + assert.ok(m); + }); +}); diff --git a/guardian-service/tests/unit/date-prototype.test.mjs b/guardian-service/tests/unit/date-prototype.test.mjs new file mode 100644 index 0000000000..0de8243086 --- /dev/null +++ b/guardian-service/tests/unit/date-prototype.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; + +await import('../../dist/prototypes/date-prototype.js'); + +describe('@unit Date.prototype.addDays', () => { + it('exists on Date.prototype after the module loads', () => { + assert.equal(typeof Date.prototype.addDays, 'function'); + }); + + it('returns a Date offset by the given days (positive)', () => { + const start = new Date('2026-05-01T12:00:00Z'); + const out = start.addDays(10); + assert.equal(out.toISOString().slice(0, 10), '2026-05-11'); + }); + + it('returns a Date offset by negative days (subtraction)', () => { + const start = new Date('2026-05-10T12:00:00Z'); + const out = start.addDays(-5); + assert.equal(out.toISOString().slice(0, 10), '2026-05-05'); + }); + + it('handles month-end overflow (Feb 28 + 5 days → Mar 5 in non-leap year)', () => { + const start = new Date('2026-02-28T00:00:00Z'); + const out = start.addDays(5); + // 2026 is non-leap → 28 + 5 = Mar 5 + assert.equal(out.getUTCMonth(), 2); // March (0-indexed) + assert.equal(out.getUTCDate(), 5); + }); + + it('handles year-end overflow (Dec 31 + 1 → Jan 1 next year)', () => { + const start = new Date('2026-12-31T00:00:00Z'); + const out = start.addDays(1); + assert.equal(out.getUTCFullYear(), 2027); + }); + + it('returns a NEW Date instance — does not mutate the receiver', () => { + const start = new Date('2026-05-01T00:00:00Z'); + const original = start.getTime(); + const out = start.addDays(7); + assert.notStrictEqual(out, start); + assert.equal(start.getTime(), original); + }); + + it('addDays(0) returns an equivalent Date (clone)', () => { + const start = new Date('2026-05-01T12:34:56Z'); + const out = start.addDays(0); + assert.notStrictEqual(out, start); + assert.equal(out.getTime(), start.getTime()); + }); + + it('preserves the time-of-day on the returned Date', () => { + const start = new Date('2026-05-01T15:30:45.500Z'); + const out = start.addDays(3); + assert.equal(out.getUTCHours(), start.getUTCHours()); + assert.equal(out.getUTCMinutes(), start.getUTCMinutes()); + assert.equal(out.getUTCSeconds(), start.getUTCSeconds()); + assert.equal(out.getUTCMilliseconds(), start.getUTCMilliseconds()); + }); +}); diff --git a/guardian-service/tests/unit/helpers-publish-config.test.mjs b/guardian-service/tests/unit/helpers-publish-config.test.mjs new file mode 100644 index 0000000000..91c96b02f1 --- /dev/null +++ b/guardian-service/tests/unit/helpers-publish-config.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { publishRuleConfig } from '../../dist/api/helpers/schema-rules-helpers.js'; +import { publishConfig, getSubject, uniqueDocuments } from '../../dist/api/helpers/policy-statistics-helpers.js'; +import { publishLabelConfig } from '../../dist/api/helpers/policy-labels-helpers.js'; + +describe('publishRuleConfig', () => { + it('returns the same falsy data untouched', () => { + assert.equal(publishRuleConfig(null), null); + assert.equal(publishRuleConfig(undefined), undefined); + }); + + it('collects unique schemaIds from fields into data.schemas', () => { + const data = publishRuleConfig({ fields: [{ schemaId: 'a' }, { schemaId: 'b' }, { schemaId: 'a' }] }); + assert.deepEqual(data.schemas, ['a', 'b']); + }); + + it('sets schemas to empty array when there are no fields', () => { + const data = publishRuleConfig({}); + assert.deepEqual(data.schemas, []); + }); + + it('returns the same object reference', () => { + const input = { fields: [] }; + assert.equal(publishRuleConfig(input), input); + }); + + it('handles a single field', () => { + const data = publishRuleConfig({ fields: [{ schemaId: 'only' }] }); + assert.deepEqual(data.schemas, ['only']); + }); +}); + +describe('publishConfig (statistics)', () => { + it('keeps only rules whose schemaId is referenced by a variable', () => { + const data = publishConfig({ + rules: [{ schemaId: 'a' }, { schemaId: 'b' }, { schemaId: 'c' }], + variables: [{ schemaId: 'a' }, { schemaId: 'c' }] + }); + assert.deepEqual(data.rules.map(r => r.schemaId), ['a', 'c']); + }); + + it('drops all rules when no variables are present', () => { + const data = publishConfig({ rules: [{ schemaId: 'a' }], variables: [] }); + assert.deepEqual(data.rules, []); + }); + + it('produces empty rules for an empty object', () => { + const data = publishConfig({}); + assert.deepEqual(data.rules, []); + }); + + it('keeps all rules when every schema is referenced', () => { + const data = publishConfig({ + rules: [{ schemaId: 'x' }, { schemaId: 'y' }], + variables: [{ schemaId: 'x' }, { schemaId: 'y' }] + }); + assert.equal(data.rules.length, 2); + }); + + it('returns the same object reference', () => { + const input = { rules: [], variables: [] }; + assert.equal(publishConfig(input), input); + }); +}); + +describe('getSubject', () => { + it('returns the credentialSubject object when it has an id', () => { + const subject = { id: 'did:s', field: 1 }; + assert.equal(getSubject({ document: { credentialSubject: subject } }), subject); + }); + + it('unwraps an array credentialSubject by taking the first entry', () => { + const first = { id: 'did:first' }; + assert.equal(getSubject({ document: { credentialSubject: [first, { id: 'did:second' }] } }), first); + }); + + it('falls back to the document when credentialSubject has no id', () => { + const doc = { document: { credentialSubject: { noId: true } } }; + assert.equal(getSubject(doc), doc); + }); + + it('falls back to the document when there is no credentialSubject', () => { + const doc = { document: {} }; + assert.equal(getSubject(doc), doc); + }); + + it('falls back to the document when document is missing', () => { + const doc = { messageId: 'm' }; + assert.equal(getSubject(doc), doc); + }); +}); + +describe('uniqueDocuments', () => { + it('returns an empty array for no documents', () => { + assert.deepEqual(uniqueDocuments([]), []); + }); + + it('keeps distinct documents of the same schema', () => { + const docs = [ + { schema: 's1', messageId: 'm1' }, + { schema: 's1', messageId: 'm2' } + ]; + const result = uniqueDocuments(docs); + assert.equal(result.length, 2); + }); + + it('drops a document referenced as a relationship of another (same schema)', () => { + const docs = [ + { schema: 's1', messageId: 'parent', relationships: ['child'] }, + { schema: 's1', messageId: 'child' } + ]; + const result = uniqueDocuments(docs).map(d => d.messageId); + assert.deepEqual(result, ['parent']); + }); + + it('does not drop relationships that belong to a different schema bucket', () => { + const docs = [ + { schema: 's1', messageId: 'parent', relationships: ['child'] }, + { schema: 's2', messageId: 'child' } + ]; + const result = uniqueDocuments(docs); + assert.equal(result.length, 2); + }); + + it('groups documents by schema', () => { + const docs = [ + { schema: 'a', messageId: 'm1' }, + { schema: 'b', messageId: 'm2' }, + { schema: 'a', messageId: 'm3' } + ]; + assert.equal(uniqueDocuments(docs).length, 3); + }); +}); + +describe('publishLabelConfig', () => { + it('returns the data unchanged', () => { + const data = { any: 'value' }; + assert.equal(publishLabelConfig(data), data); + }); + + it('passes through null', () => { + assert.equal(publishLabelConfig(null), null); + }); +}); diff --git a/guardian-service/tests/unit/import-helpers-pure.test.mjs b/guardian-service/tests/unit/import-helpers-pure.test.mjs new file mode 100644 index 0000000000..d55a910765 --- /dev/null +++ b/guardian-service/tests/unit/import-helpers-pure.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { checkForCircularDependency } from '../../dist/helpers/import-helpers/common/load-helper.js'; +import { findSubTools, importToolErrors } from '../../dist/helpers/import-helpers/tool/tool-import-helper.js'; + +describe('checkForCircularDependency', () => { + it('returns false when document is missing', () => { + assert.equal(checkForCircularDependency({}), false); + }); + + it('returns false when $defs / $id are missing', () => { + assert.equal(checkForCircularDependency({ document: { $id: '#a' } }), false); + assert.equal(checkForCircularDependency({ document: { $defs: {} } }), false); + }); + + it('returns true when $defs includes the schema\'s own $id', () => { + const schema = { + document: { + $id: '#self', + $defs: { '#self': { type: 'object' } }, + }, + }; + assert.equal(checkForCircularDependency(schema), true); + }); + + it('returns false when $defs does not include the $id', () => { + const schema = { + document: { + $id: '#self', + $defs: { '#other': {} }, + }, + }; + assert.equal(checkForCircularDependency(schema), false); + }); + + it('returns false when $defs is empty', () => { + const schema = { document: { $id: '#x', $defs: {} } }; + assert.equal(checkForCircularDependency(schema), false); + }); +}); + +describe('findSubTools', () => { + it('returns silently for null/undefined block', () => { + const result = new Set(); + findSubTools(null, result); + findSubTools(undefined, result); + assert.equal(result.size, 0); + }); + + it('records messageId when block is a Tool and not the root', () => { + const result = new Set(); + findSubTools({ blockType: 'tool', messageId: 'm-1' }, result); + assert.deepEqual(Array.from(result), ['m-1']); + }); + + it('does NOT record the root tool block (isRoot=true)', () => { + const result = new Set(); + findSubTools({ blockType: 'tool', messageId: 'm-1' }, result, true); + assert.equal(result.size, 0); + }); + + it('descends into children of a non-tool block', () => { + const result = new Set(); + const tree = { + blockType: 'group', + children: [ + { blockType: 'tool', messageId: 'm-A' }, + { blockType: 'leaf', messageId: 'm-LEAF' }, // not a tool, not recorded + { + blockType: 'group', + children: [ + { blockType: 'tool', messageId: 'm-B' }, + ], + }, + ], + }; + findSubTools(tree, result); + assert.deepEqual(Array.from(result).sort(), ['m-A', 'm-B']); + }); + + it('does NOT recurse into a non-root tool block (treats it as a leaf)', () => { + const result = new Set(); + const tree = { + blockType: 'tool', + messageId: 'parent-tool', + children: [ + { blockType: 'tool', messageId: 'inner-tool' }, + ], + }; + findSubTools(tree, result); + assert.deepEqual(Array.from(result), ['parent-tool']); + }); + + it('skips a tool block missing a string messageId', () => { + const result = new Set(); + findSubTools({ blockType: 'tool' }, result); + findSubTools({ blockType: 'tool', messageId: 123 }, result); + assert.equal(result.size, 0); + }); + + it('deduplicates repeated messageIds', () => { + const result = new Set(); + const tree = { + blockType: 'group', + children: [ + { blockType: 'tool', messageId: 'dup' }, + { blockType: 'tool', messageId: 'dup' }, + ], + }; + findSubTools(tree, result); + assert.equal(result.size, 1); + }); +}); + +describe('importToolErrors', () => { + it('groups errors by type and embeds JSON arrays for each', () => { + const message = importToolErrors([ + { type: 'schema', name: 'A' }, + { type: 'schema', name: 'B' }, + { type: 'tool', name: 'T1' }, + { type: 'other', name: 'O1' }, + ]); + assert.ok(message.startsWith('Failed to import components:')); + assert.ok(message.includes('schemas: ["A","B"]')); + assert.ok(message.includes('tools: ["T1"]')); + assert.ok(message.includes('others: ["O1"]')); + }); + + it('omits a section with no entries', () => { + const message = importToolErrors([{ type: 'schema', name: 'A' }]); + assert.ok(message.includes('schemas:')); + assert.ok(!message.includes('tools:')); + assert.ok(!message.includes('others:')); + }); + + it('produces only the prefix for an empty input', () => { + assert.equal(importToolErrors([]), 'Failed to import components:'); + }); +}); diff --git a/guardian-service/tests/unit/import-helpers-schema-cache.test.mjs b/guardian-service/tests/unit/import-helpers-schema-cache.test.mjs new file mode 100644 index 0000000000..6b5872ef5a --- /dev/null +++ b/guardian-service/tests/unit/import-helpers-schema-cache.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { SchemaCache } from '../../dist/helpers/import-helpers/common/load-helper.js'; +import { onlyUnique } from '../../dist/helpers/import-helpers/schema/schema-helper.js'; + +describe('SchemaCache', () => { + it('reports false for an unknown id', () => { + assert.equal(SchemaCache.hasSchema('cache-missing-id'), false); + }); + + it('stores and retrieves a schema by id', () => { + SchemaCache.setSchema('cache-id-1', { iri: '#a', value: 1 }); + assert.equal(SchemaCache.hasSchema('cache-id-1'), true); + assert.deepEqual(SchemaCache.getSchema('cache-id-1'), { iri: '#a', value: 1 }); + }); + + it('returns a deep copy, not the original reference', () => { + const original = { nested: { x: 1 } }; + SchemaCache.setSchema('cache-id-2', original); + const fetched = SchemaCache.getSchema('cache-id-2'); + assert.notEqual(fetched, original); + fetched.nested.x = 99; + assert.equal(SchemaCache.getSchema('cache-id-2').nested.x, 1); + }); + + it('returns null when getting an unknown id', () => { + assert.equal(SchemaCache.getSchema('cache-never-set'), null); + }); + + it('overwrites an existing entry', () => { + SchemaCache.setSchema('cache-id-3', { v: 'first' }); + SchemaCache.setSchema('cache-id-3', { v: 'second' }); + assert.deepEqual(SchemaCache.getSchema('cache-id-3'), { v: 'second' }); + }); + + it('ignores non-serialisable values on set', () => { + const circular = {}; + circular.self = circular; + SchemaCache.setSchema('cache-circular', circular); + assert.equal(SchemaCache.getSchema('cache-circular'), null); + }); +}); + +describe('onlyUnique', () => { + it('filters duplicate primitives out of an array', () => { + assert.deepEqual([1, 1, 2, 3, 3, 3].filter(onlyUnique), [1, 2, 3]); + }); + + it('keeps the first occurrence of each string', () => { + assert.deepEqual(['a', 'b', 'a', 'c', 'b'].filter(onlyUnique), ['a', 'b', 'c']); + }); + + it('returns true only at the first matching index', () => { + const arr = ['x', 'x']; + assert.equal(onlyUnique('x', 0, arr), true); + assert.equal(onlyUnique('x', 1, arr), false); + }); + + it('handles an empty array', () => { + assert.deepEqual([].filter(onlyUnique), []); + }); + + it('leaves an already-unique array unchanged', () => { + assert.deepEqual([1, 2, 3].filter(onlyUnique), [1, 2, 3]); + }); +}); diff --git a/guardian-service/tests/unit/import-mode.test.mjs b/guardian-service/tests/unit/import-mode.test.mjs new file mode 100644 index 0000000000..1771d733c1 --- /dev/null +++ b/guardian-service/tests/unit/import-mode.test.mjs @@ -0,0 +1,19 @@ +import { assert } from 'chai'; +import { ImportMode } from '../../dist/helpers/import-helpers/common/import.interface.js'; + +describe('@unit ImportMode enum', () => { + it('contains exactly the documented modes', () => { + assert.deepEqual(Object.keys(ImportMode).sort(), ['COMMON', 'DEMO', 'VIEW']); + }); + + it('values match keys (string enum convention)', () => { + assert.equal(ImportMode.COMMON, 'COMMON'); + assert.equal(ImportMode.DEMO, 'DEMO'); + assert.equal(ImportMode.VIEW, 'VIEW'); + }); + + it('values are unique (no accidental aliasing)', () => { + const values = Object.values(ImportMode); + assert.equal(values.length, new Set(values).size); + }); +}); diff --git a/guardian-service/tests/unit/ipfs-task-manager.test.mjs b/guardian-service/tests/unit/ipfs-task-manager.test.mjs new file mode 100644 index 0000000000..08e2e035d9 --- /dev/null +++ b/guardian-service/tests/unit/ipfs-task-manager.test.mjs @@ -0,0 +1,87 @@ +import { assert } from 'chai'; +import { IPFSTaskManager } from '../../dist/helpers/ipfs-task-manager.js'; + +describe('@unit IPFSTaskManager', () => { + let counter = 0; + const uniqueId = () => `task-${Date.now()}-${++counter}`; + + it('Resolve fires the resolve callback with the given value', async () => { + const id = uniqueId(); + const promise = new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, resolve, reject); + }); + IPFSTaskManager.Resolve(id, 'cid-abc'); + const result = await promise; + assert.equal(result, 'cid-abc'); + }); + + it('Reject fires the reject callback with the given reason', async () => { + const id = uniqueId(); + const promise = new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, resolve, reject); + }); + IPFSTaskManager.Reject(id, new Error('ipfs-down')); + try { + await promise; + assert.fail('expected promise to reject'); + } catch (e) { + assert.equal(e.message, 'ipfs-down'); + } + }); + + it('Resolving an unknown taskId is a no-op (does not throw)', () => { + assert.doesNotThrow(() => IPFSTaskManager.Resolve('does-not-exist', 'value')); + }); + + it('Rejecting an unknown taskId is a no-op (does not throw)', () => { + assert.doesNotThrow(() => IPFSTaskManager.Reject('does-not-exist', 'reason')); + }); + + it('Resolving twice fires only the first time (entry deleted after first)', async () => { + const id = uniqueId(); + let resolveCalls = 0; + const promise = new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, (v) => { resolveCalls++; resolve(v); }, reject); + }); + IPFSTaskManager.Resolve(id, 'first'); + await promise; + IPFSTaskManager.Resolve(id, 'second'); // should be a no-op + assert.equal(resolveCalls, 1); + }); + + it('Reject after Resolve is a no-op (entry deleted after Resolve)', async () => { + const id = uniqueId(); + let rejected = false; + const promise = new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, resolve, () => { rejected = true; }); + }); + IPFSTaskManager.Resolve(id, 'ok'); + await promise; + IPFSTaskManager.Reject(id, 'late'); + assert.equal(rejected, false); + }); + + it('AddTask with the same id overwrites the previous entry (current behaviour)', async () => { + const id = uniqueId(); + let firstResolved = false; + new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, () => { firstResolved = true; resolve(); }, reject); + }); + const second = new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, resolve, reject); // overwrites + }); + IPFSTaskManager.Resolve(id, 'value'); + await second; + assert.equal(firstResolved, false, 'first registration should be orphaned by overwrite'); + }); + + it('parallel tasks resolve independently', async () => { + const ids = [uniqueId(), uniqueId(), uniqueId()]; + const promises = ids.map((id) => new Promise((resolve, reject) => { + IPFSTaskManager.AddTask(id, resolve, reject); + })); + ids.forEach((id, i) => IPFSTaskManager.Resolve(id, `value-${i}`)); + const results = await Promise.all(promises); + assert.deepEqual(results, ['value-0', 'value-1', 'value-2']); + }); +}); diff --git a/guardian-service/tests/unit/merge-utils-multi.test.mjs b/guardian-service/tests/unit/merge-utils-multi.test.mjs new file mode 100644 index 0000000000..416d2b2e59 --- /dev/null +++ b/guardian-service/tests/unit/merge-utils-multi.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { MergeUtils } from '../../dist/analytics/compare/utils/merge-utils.js'; + +const wm = (key, opts = {}) => ({ + key, + getWeights: () => opts.weights || [], + maxWeight: opts.maxWeight ?? (() => (opts.weights ? opts.weights.length : 1)), + checkWeight: opts.checkWeight ?? (() => true), + equal: opts.equal ?? ((other, _it) => other && other.key === key), +}); + +describe('MergeUtils.getMultiKey', () => { + it('returns the first non-empty key', () => { + assert.equal(MergeUtils.getMultiKey([null, { key: 'b' }, { key: 'c' }]), 'b'); + }); + it('returns null when no item has a key', () => { + assert.equal(MergeUtils.getMultiKey([null, undefined, {}]), null); + }); + it('returns null for an empty array', () => { + assert.equal(MergeUtils.getMultiKey([]), null); + }); + it('skips items with falsy key', () => { + assert.equal(MergeUtils.getMultiKey([{ key: '' }, { key: 0 }, { key: 'x' }]), 'x'); + }); + it('returns the first key when several present', () => { + assert.equal(MergeUtils.getMultiKey([{ key: 'first' }, { key: 'second' }]), 'first'); + }); +}); + +describe('MergeUtils.getKey', () => { + it('returns left key when left present', () => { + assert.equal(MergeUtils.getKey({ key: 'L' }, { key: 'R' }), 'L'); + }); + it('returns right key when only right present', () => { + assert.equal(MergeUtils.getKey(null, { key: 'R' }), 'R'); + }); + it('returns null when both missing', () => { + assert.equal(MergeUtils.getKey(null, null), null); + }); + it('returns left key when right omitted', () => { + assert.equal(MergeUtils.getKey({ key: 'only' }), 'only'); + }); + it('returns null when left omitted and right omitted', () => { + assert.equal(MergeUtils.getKey(undefined, undefined), null); + }); +}); + +describe('MergeUtils.notMultiMerge', () => { + it('creates one row per item placed at its array index', () => { + const a = [wm('a1')]; + const b = [wm('b1'), wm('b2')]; + const out = MergeUtils.notMultiMerge([a, b]); + assert.equal(out.length, 3); + assert.equal(out[0].items[0], a[0]); + assert.equal(out[0].items[1], null); + assert.equal(out[1].items[1], b[0]); + assert.equal(out[1].items[0], null); + assert.equal(out[2].items[1], b[1]); + }); + it('uses item key as row key', () => { + const out = MergeUtils.notMultiMerge([[wm('k1')]]); + assert.equal(out[0].key, 'k1'); + }); + it('returns empty for all-empty arrays', () => { + const out = MergeUtils.notMultiMerge([[], []]); + assert.equal(out.length, 0); + }); + it('produces rows whose items length matches the number of arrays', () => { + const out = MergeUtils.notMultiMerge([[wm('x')], [], []]); + assert.equal(out[0].items.length, 3); + }); +}); + +describe('MergeUtils.fullMultiMerge', () => { + it('aligns arrays of different lengths by index with nulls', () => { + const a = [wm('a0'), wm('a1')]; + const b = [wm('b0')]; + const out = MergeUtils.fullMultiMerge([a, b]); + assert.equal(out.length, 2); + assert.equal(out[0].items[0], a[0]); + assert.equal(out[0].items[1], b[0]); + assert.equal(out[1].items[1], undefined); + }); + it('derives row key from first available item', () => { + const out = MergeUtils.fullMultiMerge([[null], [wm('present')]]); + assert.equal(out[0].key, 'present'); + }); + it('returns empty when all arrays empty', () => { + assert.equal(MergeUtils.fullMultiMerge([[], []]).length, 0); + }); +}); + +describe('MergeUtils.multiMapping', () => { + it('places child when left matches by weight+equal', () => { + const left = wm('m', { checkWeight: () => true, equal: (o) => o.key === 'm' }); + const result = [{ key: 'm', items: [left, null] }]; + const ok = MergeUtils.multiMapping(result, 1, wm('m'), 0); + assert.equal(ok, true); + assert.equal(result[0].items[1].key, 'm'); + }); + it('falls back to key equality when checkWeight is false', () => { + const left = wm('k', { checkWeight: () => false, equal: () => false }); + const result = [{ key: 'k', items: [left, null] }]; + const ok = MergeUtils.multiMapping(result, 1, { key: 'k' }, 3); + assert.equal(ok, true); + assert.equal(result[0].items[1].key, 'k'); + }); + it('returns false when nothing matches', () => { + const left = wm('a', { checkWeight: () => true, equal: () => false }); + const result = [{ key: 'a', items: [left, null] }]; + assert.equal(MergeUtils.multiMapping(result, 1, { key: 'z' }, 0), false); + }); + it('skips rows that already have the slot filled', () => { + const left = wm('a', { checkWeight: () => true, equal: () => true }); + const result = [{ key: 'a', items: [left, wm('taken')] }]; + assert.equal(MergeUtils.multiMapping(result, 1, { key: 'a' }, 0), false); + assert.equal(result[0].items[1].key, 'taken'); + }); + it('skips rows with no left item', () => { + const result = [{ key: null, items: [null, null] }]; + assert.equal(MergeUtils.multiMapping(result, 1, { key: 'a' }, 0), false); + }); +}); + +describe('MergeUtils.partlyMultiMerge', () => { + it('keeps left items and merges matching right items into the same row', () => { + const left = wm('p', { maxWeight: () => 0, checkWeight: () => true, equal: (o) => o.key === 'p' }); + const right = wm('p'); + const out = MergeUtils.partlyMultiMerge([[left], [right]]); + assert.equal(out.length, 1); + assert.equal(out[0].items[0], left); + assert.equal(out[0].items[1], right); + }); + it('appends unmatched right items as new rows', () => { + const left = wm('a', { maxWeight: () => 0, checkWeight: () => true, equal: () => false }); + const right = wm('b'); + const out = MergeUtils.partlyMultiMerge([[left], [right]]); + assert.equal(out.length, 2); + assert.equal(out[0].items[0], left); + assert.equal(out[1].items[1], right); + }); + it('handles a single array (no right side)', () => { + const left = wm('solo', { maxWeight: () => 0 }); + const out = MergeUtils.partlyMultiMerge([[left]]); + assert.equal(out.length, 1); + assert.equal(out[0].items[0], left); + }); + it('skips a null right array', () => { + const left = wm('x', { maxWeight: () => 0 }); + const out = MergeUtils.partlyMultiMerge([[left], null]); + assert.equal(out.length, 1); + }); + it('row items length equals number of input arrays', () => { + const left = wm('x', { maxWeight: () => 0 }); + const out = MergeUtils.partlyMultiMerge([[left], [], []]); + assert.equal(out[0].items.length, 3); + }); +}); diff --git a/guardian-service/tests/unit/policy-comments-utils.test.mjs b/guardian-service/tests/unit/policy-comments-utils.test.mjs new file mode 100644 index 0000000000..2cbffd90b0 --- /dev/null +++ b/guardian-service/tests/unit/policy-comments-utils.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { PolicyCommentsUtils } from '../../dist/policy-engine/policy-comments-utils.js'; + +describe('PolicyCommentsUtils.isDryRun', () => { + it('returns the policy id (string) when status is DRY-RUN', () => { + const policy = { id: { toString: () => 'p-1' }, status: 'DRY-RUN' }; + assert.equal(PolicyCommentsUtils.isDryRun(policy), 'p-1'); + }); + + it('returns the policy id when status is DEMO', () => { + const policy = { id: { toString: () => 'p-2' }, status: 'DEMO' }; + assert.equal(PolicyCommentsUtils.isDryRun(policy), 'p-2'); + }); + + it('returns undefined for any other status', () => { + for (const status of ['DRAFT', 'PUBLISH', 'PUBLISH_ERROR', 'DISCONTINUED', 'VIEW']) { + assert.equal(PolicyCommentsUtils.isDryRun({ id: { toString: () => 'x' }, status }), undefined, status); + } + }); +}); + +describe('PolicyCommentsUtils.generateKey', () => { + it('returns a non-empty string', () => { + const key = PolicyCommentsUtils.generateKey(); + assert.equal(typeof key, 'string'); + assert.ok(key.length > 0); + }); + + it('produces different keys across calls (very high probability)', () => { + const a = PolicyCommentsUtils.generateKey(); + const b = PolicyCommentsUtils.generateKey(); + assert.notEqual(a, b); + }); +}); + +describe('PolicyCommentsUtils.accessDiscussion', () => { + const D = (overrides = {}) => ({ + owner: 'did:owner', + system: false, + privacy: 'public', + users: [], + roles: [], + ...overrides, + }); + + it('returns false for a missing discussion', () => { + assert.equal(PolicyCommentsUtils.accessDiscussion(null, 'did:x', 'OWNER'), false); + assert.equal(PolicyCommentsUtils.accessDiscussion(undefined, 'did:x', 'OWNER'), false); + }); + + it('returns true when the user is the owner', () => { + assert.equal(PolicyCommentsUtils.accessDiscussion(D(), 'did:owner', 'VIEWER'), true); + }); + + it('returns true for a system discussion regardless of user', () => { + assert.equal(PolicyCommentsUtils.accessDiscussion(D({ system: true }), 'did:other', 'X'), true); + }); + + it('returns true when privacy=public regardless of user', () => { + assert.equal(PolicyCommentsUtils.accessDiscussion(D({ privacy: 'public' }), 'did:other', 'X'), true); + }); + + it('users-privacy: returns true when user is in the explicit list', () => { + const d = D({ privacy: 'users', users: ['did:a', 'did:b'] }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:a', 'X'), true); + }); + + it('users-privacy: returns false when user is not in the list', () => { + const d = D({ privacy: 'users', users: ['did:a'] }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:other', 'X'), false); + }); + + it('users-privacy: returns false when users field is missing', () => { + const d = D({ privacy: 'users', users: undefined }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:a', 'X'), false); + }); + + it('roles-privacy: returns true when role is in the explicit list', () => { + const d = D({ privacy: 'roles', roles: ['OWNER', 'AUDITOR'] }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:other', 'OWNER'), true); + }); + + it('roles-privacy: returns false when role is not in the list', () => { + const d = D({ privacy: 'roles', roles: ['AUDITOR'] }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:other', 'OWNER'), false); + }); + + it('returns false for unknown privacy values', () => { + const d = D({ privacy: 'restricted' }); + assert.equal(PolicyCommentsUtils.accessDiscussion(d, 'did:other', 'OWNER'), false); + }); +}); diff --git a/guardian-service/tests/unit/policy-converter-blockconverter.test.mjs b/guardian-service/tests/unit/policy-converter-blockconverter.test.mjs new file mode 100644 index 0000000000..9f60309939 --- /dev/null +++ b/guardian-service/tests/unit/policy-converter-blockconverter.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { PolicyConverterUtils } from '../../dist/helpers/import-helpers/policy/policy-converter-utils.js'; + +describe('PolicyConverterUtils.BlockConverter', () => { + it('renames legacy block types when policyVersion is below 1.0.0', () => { + const root = { blockType: 'mintDocument' }; + const result = PolicyConverterUtils.BlockConverter(root, root, '0.0.1'); + assert.equal(result.blockType, 'mintDocumentBlock'); + }); + + it('does not apply v1_0_0 rename when policyVersion is already 1.0.0', () => { + const root = { blockType: 'mintDocument' }; + const result = PolicyConverterUtils.BlockConverter(root, root, '1.0.0'); + assert.equal(result.blockType, 'mintDocument'); + }); + + it('recurses into children and converts them', () => { + const root = { + blockType: 'root', + children: [{ blockType: 'wipeDocument' }, { blockType: 'sendToGuardian' }] + }; + const result = PolicyConverterUtils.BlockConverter(root, root, '0.0.1'); + assert.equal(result.children[0].blockType, 'retirementDocumentBlock'); + assert.equal(result.children[1].blockType, 'sendToGuardianBlock'); + }); + + it('defaults accountType for mint blocks when below 1.3.0', () => { + const root = { blockType: 'mintDocumentBlock' }; + const result = PolicyConverterUtils.BlockConverter(root, root, '1.2.0'); + assert.equal(result.accountType, 'default'); + }); + + it('leaves a modern block untouched when policyVersion is current', () => { + const root = { blockType: 'someModernBlock', children: [] }; + const result = PolicyConverterUtils.BlockConverter(root, root, PolicyConverterUtils.VERSION); + assert.equal(result.blockType, 'someModernBlock'); + }); + + it('returns the same root object reference', () => { + const root = { blockType: 'root', children: [] }; + assert.equal(PolicyConverterUtils.BlockConverter(root, root, '0.0.1'), root); + }); +}); + +describe('PolicyConverterUtils.PolicyConverter end to end', () => { + it('upgrades codeVersion to the current VERSION', () => { + const policy = { codeVersion: '0.0.1', config: { blockType: 'root', children: [] } }; + const result = PolicyConverterUtils.PolicyConverter(policy); + assert.equal(result.codeVersion, PolicyConverterUtils.VERSION); + }); + + it('converts the config block tree', () => { + const policy = { + codeVersion: '0.0.1', + config: { blockType: 'root', children: [{ blockType: 'mintDocument' }] } + }; + const result = PolicyConverterUtils.PolicyConverter(policy); + assert.equal(result.config.children[0].blockType, 'mintDocumentBlock'); + }); + + it('short circuits when already at the current version', () => { + const policy = { codeVersion: PolicyConverterUtils.VERSION, config: { blockType: 'mintDocument' } }; + const result = PolicyConverterUtils.PolicyConverter(policy); + assert.equal(result.config.blockType, 'mintDocument'); + }); + + it('returns the same policy reference', () => { + const policy = { codeVersion: '0.0.1', config: { blockType: 'root', children: [] } }; + assert.equal(PolicyConverterUtils.PolicyConverter(policy), policy); + }); +}); diff --git a/guardian-service/tests/unit/policy-converter-utils.test.mjs b/guardian-service/tests/unit/policy-converter-utils.test.mjs new file mode 100644 index 0000000000..0dda751ee3 --- /dev/null +++ b/guardian-service/tests/unit/policy-converter-utils.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { PolicyConverterUtils } from '../../dist/helpers/import-helpers/policy/policy-converter-utils.js'; + +describe('PolicyConverterUtils.versionCompare', () => { + it('treats a missing v2 as older than any v1', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.0.0', undefined), 1); + assert.equal(PolicyConverterUtils.versionCompare('1.0.0', null), 1); + assert.equal(PolicyConverterUtils.versionCompare('1.0.0', ''), 1); + }); + + it('returns 0 for identical versions', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.5.1', '1.5.1'), 0); + assert.equal(PolicyConverterUtils.versionCompare('0.0.1', '0.0.1'), 0); + }); + + it('returns 1 when v1 is newer at the major level', () => { + assert.equal(PolicyConverterUtils.versionCompare('2.0.0', '1.9.9'), 1); + }); + + it('returns -1 when v1 is older at the minor level', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.4.9', '1.5.0'), -1); + }); + + it('returns -1 when v1 is older at the patch level', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.5.0', '1.5.1'), -1); + }); + + it('treats a longer v1 as newer when shared parts are equal', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.5.0', '1.5'), 1); + }); + + it('treats a longer v2 as newer when shared parts are equal', () => { + assert.equal(PolicyConverterUtils.versionCompare('1.5', '1.5.0'), -1); + }); + + it('compares against the constant VERSION', () => { + assert.equal( + PolicyConverterUtils.versionCompare(PolicyConverterUtils.VERSION, PolicyConverterUtils.VERSION), + 0, + ); + assert.equal(PolicyConverterUtils.versionCompare(PolicyConverterUtils.VERSION, '0.0.1'), 1); + }); +}); + +describe('PolicyConverterUtils.PolicyConverter', () => { + it('returns the same policy untouched if codeVersion already equals VERSION', () => { + const policy = { + codeVersion: PolicyConverterUtils.VERSION, + config: { blockType: 'noop' }, + }; + const before = JSON.stringify(policy); + const result = PolicyConverterUtils.PolicyConverter(policy); + assert.equal(result, policy); + assert.equal(JSON.stringify(result), before); + }); +}); diff --git a/guardian-service/tests/unit/policy-converter-version-blocks.test.mjs b/guardian-service/tests/unit/policy-converter-version-blocks.test.mjs new file mode 100644 index 0000000000..315b407931 --- /dev/null +++ b/guardian-service/tests/unit/policy-converter-version-blocks.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { PolicyConverterUtils } from '../../dist/helpers/import-helpers/policy/policy-converter-utils.js'; + +describe('PolicyConverterUtils.v1_0_0 block type renames', () => { + const cases = [ + ['interfaceDocumentsSource', 'interfaceDocumentsSourceBlock'], + ['requestVcDocument', 'requestVcDocumentBlock'], + ['sendToGuardian', 'sendToGuardianBlock'], + ['interfaceAction', 'interfaceActionBlock'], + ['mintDocument', 'mintDocumentBlock'], + ['aggregateDocument', 'aggregateDocumentBlock'], + ['wipeDocument', 'retirementDocumentBlock'] + ]; + for (const [from, to] of cases) { + it(`renames ${from} to ${to}`, () => { + const block = PolicyConverterUtils.v1_0_0({ blockType: from }); + assert.equal(block.blockType, to); + }); + } + + it('leaves an unknown block type unchanged', () => { + const block = PolicyConverterUtils.v1_0_0({ blockType: 'somethingElse' }); + assert.equal(block.blockType, 'somethingElse'); + }); + + it('returns the same object reference', () => { + const input = { blockType: 'mintDocument' }; + assert.equal(PolicyConverterUtils.v1_0_0(input), input); + }); +}); + +describe('PolicyConverterUtils.v1_1_0 event generation', () => { + it('adds an empty events array when missing', () => { + const block = PolicyConverterUtils.v1_1_0({ blockType: 'x', tag: 't' }); + assert.deepEqual(block.events, []); + }); + + it('creates refresh events from dependencies', () => { + const block = PolicyConverterUtils.v1_1_0({ blockType: 'x', tag: 'me', dependencies: ['dep1', 'dep2'] }); + assert.equal(block.events.length, 2); + assert.equal(block.events[0].source, 'dep1'); + assert.equal(block.events[0].target, 'me'); + }); + + it('does not overwrite existing events', () => { + const existing = [{ marker: true }]; + const block = PolicyConverterUtils.v1_1_0({ blockType: 'x', tag: 't', events: existing, dependencies: ['d'] }); + assert.equal(block.events, existing); + assert.equal(block.events.length, 1); + }); + + it('tags selector options that lack a tag', () => { + const block = PolicyConverterUtils.v1_1_0({ + blockType: 'interfaceActionBlock', type: 'selector', tag: 'sel', + uiMetaData: { options: [{ bindBlock: 'b0' }, { tag: 'keep', bindBlock: 'b1' }] } + }); + assert.equal(block.uiMetaData.options[0].tag, 'Option_0'); + assert.equal(block.uiMetaData.options[1].tag, 'keep'); + }); + + it('emits a run event per selector option', () => { + const block = PolicyConverterUtils.v1_1_0({ + blockType: 'interfaceActionBlock', type: 'selector', tag: 'sel', + uiMetaData: { options: [{ bindBlock: 'b0' }, { bindBlock: 'b1' }] } + }); + assert.equal(block.events.length, 2); + assert.equal(block.events[0].source, 'sel'); + assert.equal(block.events[0].target, 'b0'); + }); + + it('emits a dropdown event for dropdown action blocks', () => { + const block = PolicyConverterUtils.v1_1_0({ + blockType: 'interfaceActionBlock', type: 'dropdown', tag: 'dd', bindBlock: 'target' + }); + assert.equal(block.events.length, 1); + assert.equal(block.events[0].target, 'target'); + }); + + it('tags switch conditions and emits run events', () => { + const block = PolicyConverterUtils.v1_1_0({ + blockType: 'switchBlock', tag: 'sw', + conditions: [{ bindBlock: 'c0' }, { tag: 'named', bindBlock: 'c1' }] + }); + assert.equal(block.conditions[0].tag, 'Condition_0'); + assert.equal(block.conditions[1].tag, 'named'); + assert.equal(block.events.length, 2); + }); + + it('emits a timer event for aggregate blocks with a timer', () => { + const block = PolicyConverterUtils.v1_1_0({ + blockType: 'aggregateDocumentBlock', tag: 'agg', timer: 'timer-tag' + }); + assert.equal(block.events.length, 1); + assert.equal(block.events[0].source, 'timer-tag'); + assert.equal(block.events[0].target, 'agg'); + }); +}); + +describe('PolicyConverterUtils.v1_2_0 selector to button', () => { + it('converts a selector action block to a buttonBlock', () => { + const block = PolicyConverterUtils.v1_2_0({ + blockType: 'interfaceActionBlock', type: 'selector', field: 'f', + uiMetaData: { options: [{ tag: 'o1', name: 'n1', value: 'v1' }] } + }); + assert.equal(block.blockType, 'buttonBlock'); + assert.equal(block.uiMetaData.buttons.length, 1); + assert.equal(block.uiMetaData.buttons[0].tag, 'o1'); + assert.equal(block.uiMetaData.buttons[0].type, 'selector'); + }); + + it('removes the legacy options after conversion', () => { + const block = PolicyConverterUtils.v1_2_0({ + blockType: 'interfaceActionBlock', type: 'selector', + uiMetaData: { options: [{ tag: 'o' }] } + }); + assert.equal(block.uiMetaData.options, undefined); + }); + + it('creates an empty buttons array when there are no options', () => { + const block = PolicyConverterUtils.v1_2_0({ + blockType: 'interfaceActionBlock', type: 'selector' + }); + assert.deepEqual(block.uiMetaData.buttons, []); + }); + + it('leaves non-selector action blocks unchanged', () => { + const block = PolicyConverterUtils.v1_2_0({ blockType: 'interfaceActionBlock', type: 'dropdown' }); + assert.equal(block.blockType, 'interfaceActionBlock'); + }); +}); + +describe('PolicyConverterUtils.v1_3_0 accountType defaults', () => { + it('defaults accountType for mint blocks', () => { + assert.equal(PolicyConverterUtils.v1_3_0({ blockType: 'mintDocumentBlock' }).accountType, 'default'); + }); + + it('defaults accountType for retirement blocks', () => { + assert.equal(PolicyConverterUtils.v1_3_0({ blockType: 'retirementDocumentBlock' }).accountType, 'default'); + }); + + it('keeps an existing accountType', () => { + assert.equal(PolicyConverterUtils.v1_3_0({ blockType: 'mintDocumentBlock', accountType: 'custom' }).accountType, 'custom'); + }); + + it('does not add accountType to other blocks', () => { + assert.equal(PolicyConverterUtils.v1_3_0({ blockType: 'other' }).accountType, undefined); + }); +}); + +describe('PolicyConverterUtils.v1_5_0 history addon', () => { + it('ignores non documents-source blocks', () => { + const block = PolicyConverterUtils.v1_5_0({ blockType: 'other', children: [] }); + assert.equal(block.blockType, 'other'); + }); + + it('adds a historyAddon child when a source addon has viewHistory', () => { + const block = PolicyConverterUtils.v1_5_0({ + blockType: 'interfaceDocumentsSourceBlock', + children: [{ blockType: 'documentsSourceAddon', viewHistory: true }] + }); + assert.ok(block.children.some(c => c.blockType === 'historyAddon')); + }); + + it('strips viewHistory from source addons', () => { + const block = PolicyConverterUtils.v1_5_0({ + blockType: 'interfaceDocumentsSourceBlock', + children: [{ blockType: 'documentsSourceAddon', viewHistory: true }] + }); + const addon = block.children.find(c => c.blockType === 'documentsSourceAddon'); + assert.equal(addon.viewHistory, undefined); + }); + + it('does not add a history addon when none requested', () => { + const block = PolicyConverterUtils.v1_5_0({ + blockType: 'interfaceDocumentsSourceBlock', + children: [{ blockType: 'documentsSourceAddon', viewHistory: false }] + }); + assert.ok(!block.children.some(c => c.blockType === 'historyAddon')); + }); +}); + +describe('PolicyConverterUtils.v1_5_1 revoke block', () => { + it('ignores non revoke blocks', () => { + const block = PolicyConverterUtils.v1_5_1({}, { blockType: 'other' }); + assert.equal(block.blockType, 'other'); + }); + + it('converts a revokeBlock to a revocation block and lifts uiMetaData fields', () => { + const root = { blockType: 'root', children: [] }; + const block = PolicyConverterUtils.v1_5_1(root, { + blockType: 'revokeBlock', tag: 'rev', + uiMetaData: { updatePrevDoc: true, prevDocStatus: 'Revoked' } + }); + assert.equal(block.updatePrevDoc, true); + assert.equal(block.prevDocStatus, 'Revoked'); + assert.equal(block.uiMetaData, undefined); + assert.notEqual(block.blockType, 'revokeBlock'); + }); +}); diff --git a/guardian-service/tests/unit/policy-data-loader.test.mjs b/guardian-service/tests/unit/policy-data-loader.test.mjs new file mode 100644 index 0000000000..8fdc789cca --- /dev/null +++ b/guardian-service/tests/unit/policy-data-loader.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { Module } from 'node:module'; + +const originalLoad = Module._load; +Module._load = function (req, parent, ...rest) { + if (typeof req !== 'string') return originalLoad.call(this, req, parent, ...rest); + if (req === '@guardian/common') { + return { DatabaseServer: class { constructor() {} } }; + } + if (req === '@guardian/interfaces') { + return { TenantContext: { Empty: { tenantId: null } } }; + } + if (req === 'jszip') { + return { default: class JSZip {} }; + } + return originalLoad.call(this, req, parent, ...rest); +}; + +const { PolicyDataLoader } = await import('../../dist/policy-engine/helpers/policy-data/loaders/loader.js'); + +after(() => { Module._load = originalLoad; }); + +// JSZip mock: just enough surface for the getFromFile method +function makeZip(entries) { + return { + files: Object.fromEntries( + Object.entries(entries).map(([path, content]) => [ + path, + { + dir: path.endsWith('/'), + async: async () => content, + }, + ]), + ), + }; +} + +describe('@unit PolicyDataLoader.getFromFile', () => { + it('returns entries from the matching path prefix', async () => { + const zip = makeZip({ + 'documents/a.json': JSON.stringify({ id: 'a', createDate: 3 }), + 'documents/b.json': JSON.stringify({ id: 'b', createDate: 1 }), + 'documents/c.json': JSON.stringify({ id: 'c', createDate: 2 }), + 'other/x.json': JSON.stringify({ id: 'should-not-appear' }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'documents'); + assert.equal(result.length, 3); + assert.deepEqual(result.map((r) => r.id), ['a', 'c', 'b'], 'should be sorted by createDate desc'); + }); + + it('returns [] when no files match the path prefix', async () => { + const zip = makeZip({ + 'documents/a.json': JSON.stringify({ id: 'a', createDate: 1 }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'missing'); + assert.deepEqual(result, []); + }); + + it('filters out directory entries (dir=true)', async () => { + const zip = makeZip({ + 'documents/': 'should be skipped', + 'documents/a.json': JSON.stringify({ id: 'a', createDate: 1 }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'documents'); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'a'); + }); + + it('requires nested files (path/* must match — not just path)', async () => { + const zip = makeZip({ + 'documents': JSON.stringify({ id: 'top' }), + 'documents/nested.json': JSON.stringify({ id: 'nested', createDate: 1 }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'documents'); + // Only "documents/nested.json" matches "^documents/.+" + assert.equal(result.length, 1); + assert.equal(result[0].id, 'nested'); + }); + + it('throws when a matching file contains invalid JSON', async () => { + const zip = makeZip({ + 'documents/bad.json': 'not-json-at-all', + }); + await assert.rejects(() => PolicyDataLoader.getFromFile(zip, 'documents')); + }); + + it('handles empty zip gracefully', async () => { + const zip = makeZip({}); + const result = await PolicyDataLoader.getFromFile(zip, 'documents'); + assert.deepEqual(result, []); + }); + + it('preserves all object fields from each JSON entry', async () => { + const zip = makeZip({ + 'data/a.json': JSON.stringify({ id: 'a', createDate: 1, foo: 'bar', nested: { x: 1 } }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'data'); + assert.equal(result[0].foo, 'bar'); + assert.deepEqual(result[0].nested, { x: 1 }); + }); + + it('handles entries with identical createDate stably (descending sort)', async () => { + const zip = makeZip({ + 'data/a.json': JSON.stringify({ id: 'a', createDate: 5 }), + 'data/b.json': JSON.stringify({ id: 'b', createDate: 5 }), + }); + const result = await PolicyDataLoader.getFromFile(zip, 'data'); + assert.equal(result.length, 2); + // Both have createDate 5 — implementation-defined which comes first. + // Just assert both are present. + const ids = new Set(result.map((r) => r.id)); + assert.ok(ids.has('a')); + assert.ok(ids.has('b')); + }); + + it('handles missing createDate fields (sorts undefined gracefully)', async () => { + const zip = makeZip({ + 'data/a.json': JSON.stringify({ id: 'a' }), + 'data/b.json': JSON.stringify({ id: 'b', createDate: 1 }), + }); + // Should not throw; behaviour is implementation-defined but stable. + const result = await PolicyDataLoader.getFromFile(zip, 'data'); + assert.equal(result.length, 2); + }); + + it('escapes path prefix as regex (literal match)', async () => { + // Path "doc.s" would match "doc-s/x.json" if not properly escaped. + // The implementation uses RegExp directly — document that fact. + const zip = makeZip({ + 'doc-s/a.json': JSON.stringify({ id: 'a', createDate: 1 }), + 'docs/b.json': JSON.stringify({ id: 'b', createDate: 1 }), + }); + // Path "doc-s" is treated as regex; this is a known limitation but + // documents the contract. + const result = await PolicyDataLoader.getFromFile(zip, 'doc-s'); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'a'); + }); +}); diff --git a/guardian-service/tests/unit/policy-import-helper-pure.test.mjs b/guardian-service/tests/unit/policy-import-helper-pure.test.mjs new file mode 100644 index 0000000000..c773879efb --- /dev/null +++ b/guardian-service/tests/unit/policy-import-helper-pure.test.mjs @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +import { PolicyImportExportHelper } from '../../dist/helpers/import-helpers/policy/policy-import-helper.js'; +import { SchemaCache } from '../../dist/helpers/import-helpers/common/load-helper.js'; + +describe('PolicyImportExportHelper.errorsMessage', () => { + it('is a static function', () => { + assert.equal(typeof PolicyImportExportHelper.errorsMessage, 'function'); + }); + + it('returns only the prefix for no errors', () => { + assert.equal( + PolicyImportExportHelper.errorsMessage([]), + 'Failed to import components:' + ); + }); + + it('renders schema errors as a JSON array', () => { + assert.equal( + PolicyImportExportHelper.errorsMessage([{ type: 'schema', name: 'A' }]), + 'Failed to import components: schemas: ["A"];' + ); + }); + + it('renders tool errors as a JSON array', () => { + assert.equal( + PolicyImportExportHelper.errorsMessage([{ type: 'tool', name: 'B' }]), + 'Failed to import components: tools: ["B"];' + ); + }); + + it('groups unknown types under others', () => { + assert.equal( + PolicyImportExportHelper.errorsMessage([{ type: 'whatever', name: 'C' }]), + 'Failed to import components: others: ["C"];' + ); + }); + + it('combines all three sections in order', () => { + const msg = PolicyImportExportHelper.errorsMessage([ + { type: 'schema', name: 'A' }, + { type: 'tool', name: 'B' }, + { type: 'misc', name: 'C' } + ]); + assert.equal( + msg, + 'Failed to import components: schemas: ["A"]; tools: ["B"]; others: ["C"];' + ); + }); + + it('groups multiple entries of the same type', () => { + const msg = PolicyImportExportHelper.errorsMessage([ + { type: 'schema', name: 'A' }, + { type: 'schema', name: 'B' } + ]); + assert.equal(msg, 'Failed to import components: schemas: ["A","B"];'); + }); + + it('omits sections that have no entries', () => { + const msg = PolicyImportExportHelper.errorsMessage([{ type: 'tool', name: 'T' }]); + assert.ok(!msg.includes('schemas')); + assert.ok(!msg.includes('others')); + }); +}); + +describe('PolicyImportExportHelper.findTools', () => { + it('is a static function', () => { + assert.equal(typeof PolicyImportExportHelper.findTools, 'function'); + }); + + it('returns silently for null block', () => { + const result = new Set(); + PolicyImportExportHelper.findTools(null, result); + assert.equal(result.size, 0); + }); + + it('records a tool messageId at the root', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: 'tool', messageId: 'root-tool' }, result); + assert.deepEqual([...result], ['root-tool']); + }); + + it('does not descend into a nested tool', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ + blockType: 'tool', + messageId: 'outer', + children: [{ blockType: 'tool', messageId: 'inner' }] + }, result); + assert.deepEqual([...result], ['outer']); + }); + + it('skips a tool block without a string messageId', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: 'tool' }, result); + assert.equal(result.size, 0); + }); + + it('skips a tool block with a non-string messageId', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: 'tool', messageId: 123 }, result); + assert.equal(result.size, 0); + }); + + it('descends into children of a container block', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ + blockType: 'interfaceContainerBlock', + children: [ + { blockType: 'tool', messageId: 'm1' }, + { blockType: 'tool', messageId: 'm2' } + ] + }, result); + assert.deepEqual([...result].sort(), ['m1', 'm2']); + }); + + it('deduplicates repeated messageIds', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ + blockType: 'interfaceContainerBlock', + children: [ + { blockType: 'tool', messageId: 'dup' }, + { blockType: 'tool', messageId: 'dup' } + ] + }, result); + assert.deepEqual([...result], ['dup']); + }); + + it('recurses through nested containers', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ + blockType: 'interfaceContainerBlock', + children: [{ + blockType: 'interfaceContainerBlock', + children: [{ blockType: 'tool', messageId: 'deep' }] + }] + }, result); + assert.deepEqual([...result], ['deep']); + }); + + it('ignores a container block without a children array', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: 'interfaceContainerBlock' }, result); + assert.equal(result.size, 0); + }); +}); + +describe('SchemaCache', () => { + it('exposes static cache methods', () => { + assert.equal(typeof SchemaCache.hasSchema, 'function'); + assert.equal(typeof SchemaCache.getSchema, 'function'); + assert.equal(typeof SchemaCache.setSchema, 'function'); + }); + + it('reports false for an unknown key', () => { + assert.equal(SchemaCache.hasSchema('cache-test-unknown'), false); + }); + + it('stores and retrieves a deep-cloned schema', () => { + const schema = { name: 'S', nested: { value: 1 } }; + SchemaCache.setSchema('cache-test-1', schema); + assert.equal(SchemaCache.hasSchema('cache-test-1'), true); + const got = SchemaCache.getSchema('cache-test-1'); + assert.deepEqual(got, schema); + assert.notEqual(got, schema); + }); + + it('returns null for a missing key', () => { + assert.equal(SchemaCache.getSchema('cache-test-missing'), null); + }); + + it('overwrites an existing entry', () => { + SchemaCache.setSchema('cache-test-2', { v: 1 }); + SchemaCache.setSchema('cache-test-2', { v: 2 }); + assert.deepEqual(SchemaCache.getSchema('cache-test-2'), { v: 2 }); + }); + + it('silently ignores values that cannot be serialised', () => { + const circular = {}; + circular.self = circular; + SchemaCache.setSchema('cache-test-circular', circular); + assert.equal(SchemaCache.hasSchema('cache-test-circular'), false); + }); + + it('round-trips array schemas', () => { + SchemaCache.setSchema('cache-test-arr', [{ a: 1 }, { b: 2 }]); + assert.deepEqual(SchemaCache.getSchema('cache-test-arr'), [{ a: 1 }, { b: 2 }]); + }); +}); diff --git a/guardian-service/tests/unit/policy-labels-helpers.test.mjs b/guardian-service/tests/unit/policy-labels-helpers.test.mjs new file mode 100644 index 0000000000..8b1c8cb9fc --- /dev/null +++ b/guardian-service/tests/unit/policy-labels-helpers.test.mjs @@ -0,0 +1,24 @@ +import { assert } from 'chai'; +import { publishLabelConfig } from '../../dist/api/helpers/policy-labels-helpers.js'; + +describe('policy-labels-helpers publishLabelConfig', () => { + it('returns the same object reference it was given', () => { + const data = { id: 'cfg', children: [] }; + assert.strictEqual(publishLabelConfig(data), data); + }); + + it('does not mutate the provided config', () => { + const data = { id: 'cfg', children: [{ id: 'n1' }] }; + const snapshot = JSON.stringify(data); + publishLabelConfig(data); + assert.equal(JSON.stringify(data), snapshot); + }); + + it('returns undefined when called without data', () => { + assert.isUndefined(publishLabelConfig()); + }); + + it('returns null untouched', () => { + assert.isNull(publishLabelConfig(null)); + }); +}); diff --git a/guardian-service/tests/unit/policy-service-channels-container.test.mjs b/guardian-service/tests/unit/policy-service-channels-container.test.mjs new file mode 100644 index 0000000000..cacfe650f5 --- /dev/null +++ b/guardian-service/tests/unit/policy-service-channels-container.test.mjs @@ -0,0 +1,103 @@ +import { assert } from 'chai'; +import esmock from 'esmock'; + +const channelCtorCalls = []; +let uuidCounter = 0; + +const { PolicyServiceChannelsContainer } = await esmock('../../dist/helpers/policy-service-channels-container.js', { + '@guardian/common': { + MessageBrokerChannel: class FakeChannel { + constructor(cn, name) { + this.cn = cn; + this.name = name; + channelCtorCalls.push({ cn, name }); + } + subscribe() { return { unsubscribe: () => {} }; } + publish() {} + }, + Singleton: (target) => target, + }, + '@guardian/interfaces': { + GenerateUUIDv4: () => `uuid-${++uuidCounter}`, + }, +}); + +beforeEach(() => { channelCtorCalls.length = 0; }); + +describe('@unit PolicyServiceChannelsContainer', () => { + // The exported class uses a per-instance Map but each static method calls + // `new PolicyServiceChannelsContainer()`. That means every static call + // creates a fresh container — `get` after `create` will not see the entry. + // This test pins down the actual behaviour, including its quirks. + + it('createPolicyServiceChannel returns an entity with name and channel', () => { + const entity = PolicyServiceChannelsContainer.createPolicyServiceChannel('p-1'); + assert.equal(typeof entity.name, 'string'); + assert.match(entity.name, /^policy-p-1-uuid-/); + assert.ok(entity.channel); + }); + + it('channel name embeds the policyId so logs/traces remain identifiable', () => { + const entity = PolicyServiceChannelsContainer.createPolicyServiceChannel('policy-XYZ'); + assert.match(entity.name, /policy-XYZ/); + }); + + it('createPolicyServiceChannel constructs exactly one MessageBrokerChannel per call', () => { + PolicyServiceChannelsContainer.createPolicyServiceChannel('p-2'); + assert.equal(channelCtorCalls.length, 1); + }); + + it('static getPolicyServiceChannel returns null for an unknown id (fresh instance per call)', () => { + // Documents the quirk: each static accessor instantiates a new + // container, so cross-call lookup ALWAYS returns null. If this changes, + // the static API contract has changed. + assert.equal(PolicyServiceChannelsContainer.getPolicyServiceChannel('never-created'), null); + }); + + it('createIfNotExistServiceChannel always creates because get returns null per call', () => { + const before = channelCtorCalls.length; + PolicyServiceChannelsContainer.createIfNotExistServiceChannel('p-3'); + const after = channelCtorCalls.length; + // Per the static-method behaviour, this WILL create a new channel each time. + assert.equal(after - before, 1); + }); + + it('deletePolicyServiceChannel does not throw on an unknown policyId', () => { + assert.doesNotThrow(() => PolicyServiceChannelsContainer.deletePolicyServiceChannel('never-created')); + }); + + describe('instance-level (Map persistence within a single container)', () => { + let container; + beforeEach(() => { + container = new PolicyServiceChannelsContainer(); + container.setConnection({ id: 'fake-conn' }); + }); + + it('private getPolicyServiceChannel via reflection: get-after-create-on-same-instance returns the same entity', () => { + // The private methods are accessible via the same instance. + const created = container['createPolicyServiceChannel']('p-A'); + const got = container['getPolicyServiceChannel']('p-A'); + assert.strictEqual(got, created); + }); + + it('delete removes the entry from the same-instance Map', () => { + container['createPolicyServiceChannel']('p-B'); + container['deletePolicyServiceChannel']('p-B'); + assert.equal(container['getPolicyServiceChannel']('p-B'), null); + }); + + it('two policies on the same instance get distinct channels', () => { + const a = container['createPolicyServiceChannel']('p-X'); + const b = container['createPolicyServiceChannel']('p-Y'); + assert.notStrictEqual(a, b); + assert.notEqual(a.name, b.name); + }); + + it('setConnection passes connection through to subsequent channel constructions', () => { + container.setConnection({ id: 'conn-1' }); + container['createPolicyServiceChannel']('p-conn'); + const last = channelCtorCalls[channelCtorCalls.length - 1]; + assert.deepEqual(last.cn, { id: 'conn-1' }); + }); + }); +}); diff --git a/guardian-service/tests/unit/policy-statistics-helpers.test.mjs b/guardian-service/tests/unit/policy-statistics-helpers.test.mjs new file mode 100644 index 0000000000..c2cd1e2148 --- /dev/null +++ b/guardian-service/tests/unit/policy-statistics-helpers.test.mjs @@ -0,0 +1,110 @@ +import { assert } from 'chai'; +import { + publishConfig, + getSubject, + uniqueDocuments, +} from '../../dist/api/helpers/policy-statistics-helpers.js'; + +describe('policy-statistics-helpers publishConfig', () => { + it('keeps only rules whose schemaId appears in the variables', () => { + const data = { + variables: [{ schemaId: 'A' }, { schemaId: 'B' }], + rules: [{ schemaId: 'A' }, { schemaId: 'C' }, { schemaId: 'B' }], + }; + const out = publishConfig(data); + assert.deepEqual(out.rules.map((r) => r.schemaId), ['A', 'B']); + }); + + it('drops all rules when there are no variables', () => { + const data = { variables: [], rules: [{ schemaId: 'A' }] }; + assert.deepEqual(publishConfig(data).rules, []); + }); + + it('treats missing variables/rules as empty arrays', () => { + const data = {}; + const out = publishConfig(data); + assert.deepEqual(out.rules, []); + }); + + it('returns the same object reference it was given', () => { + const data = { variables: [{ schemaId: 'A' }], rules: [{ schemaId: 'A' }] }; + assert.strictEqual(publishConfig(data), data); + }); + + it('keeps duplicate rules that match a variable schema', () => { + const data = { + variables: [{ schemaId: 'A' }], + rules: [{ schemaId: 'A' }, { schemaId: 'A' }], + }; + assert.equal(publishConfig(data).rules.length, 2); + }); +}); + +describe('policy-statistics-helpers getSubject', () => { + it('returns the credentialSubject object when it has an id', () => { + const doc = { document: { credentialSubject: { id: 'urn:1', value: 5 } } }; + assert.deepEqual(getSubject(doc), { id: 'urn:1', value: 5 }); + }); + + it('unwraps the first element when credentialSubject is an array', () => { + const doc = { document: { credentialSubject: [{ id: 'urn:first' }, { id: 'urn:second' }] } }; + assert.deepEqual(getSubject(doc), { id: 'urn:first' }); + }); + + it('falls back to the whole document when credentialSubject has no id', () => { + const doc = { document: { credentialSubject: { value: 5 } } }; + assert.strictEqual(getSubject(doc), doc); + }); + + it('falls back to the whole document when there is no credentialSubject', () => { + const doc = { document: {} }; + assert.strictEqual(getSubject(doc), doc); + }); + + it('falls back to the document when document is undefined', () => { + const doc = {}; + assert.strictEqual(getSubject(doc), doc); + }); +}); + +describe('policy-statistics-helpers uniqueDocuments', () => { + it('returns a single document unchanged', () => { + const docs = [{ messageId: 'm1', schema: 's', relationships: [] }]; + assert.deepEqual(uniqueDocuments(docs), docs); + }); + + it('drops a document that is referenced as a relationship of another (same schema)', () => { + const child = { messageId: 'child', schema: 's', relationships: [] }; + const parent = { messageId: 'parent', schema: 's', relationships: ['child'] }; + const out = uniqueDocuments([child, parent]); + const ids = out.map((d) => d.messageId); + assert.include(ids, 'parent'); + assert.notInclude(ids, 'child'); + }); + + it('does not drop a referenced document of a different schema (separate hash bucket)', () => { + const child = { messageId: 'child', schema: 'other', relationships: [] }; + const parent = { messageId: 'parent', schema: 's', relationships: ['child'] }; + const out = uniqueDocuments([child, parent]); + const ids = out.map((d) => d.messageId); + assert.include(ids, 'parent'); + assert.include(ids, 'child'); + }); + + it('keeps documents whose relationships point to unknown ids', () => { + const docs = [{ messageId: 'a', schema: 's', relationships: ['missing'] }]; + assert.equal(uniqueDocuments(docs).length, 1); + }); + + it('handles documents without a relationships array', () => { + const docs = [ + { messageId: 'a', schema: 's' }, + { messageId: 'b', schema: 's' }, + ]; + assert.equal(uniqueDocuments(docs).length, 2); + }); + + it('returns an empty array for empty input', () => { + assert.deepEqual(uniqueDocuments([]), []); + }); +}); diff --git a/guardian-service/tests/unit/policy-wizard-block-builders.test.mjs b/guardian-service/tests/unit/policy-wizard-block-builders.test.mjs new file mode 100644 index 0000000000..98898e5294 --- /dev/null +++ b/guardian-service/tests/unit/policy-wizard-block-builders.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { PolicyWizardHelper } from '../../dist/api/helpers/policy-wizard-helper.js'; + +const uuid = /^[0-9a-f-]{36}$/; +const helper = () => new PolicyWizardHelper(); + +describe('PolicyWizardHelper.getChooseRoleBlock', () => { + it('excludes OWNER from the role list', () => { + const block = helper().getChooseRoleBlock(['OWNER', 'USER', 'SR']); + assert.deepEqual(block.roles, ['USER', 'SR']); + }); + + it('is a policyRolesBlock with NO_ROLE permission', () => { + const block = helper().getChooseRoleBlock(['USER']); + assert.equal(block.blockType, 'policyRolesBlock'); + assert.deepEqual(block.permissions, ['NO_ROLE']); + assert.equal(block.tag, 'choose_role'); + }); + + it('has a UUID id and choose-role uiMetaData', () => { + const block = helper().getChooseRoleBlock(['USER']); + assert.match(block.id, uuid); + assert.equal(block.uiMetaData.title, 'Choose role'); + }); +}); + +describe('PolicyWizardHelper.getRoleContainer', () => { + it('is a tabs container scoped to the role', () => { + const block = helper().getRoleContainer('SR'); + assert.equal(block.blockType, 'interfaceContainerBlock'); + assert.deepEqual(block.permissions, ['SR']); + assert.equal(block.uiMetaData.type, 'tabs'); + }); + + it('uses an incrementing tag', () => { + const h = helper(); + assert.equal(h.getRoleContainer('SR').tag, 'SR_interfaceContainerBlock_1'); + assert.equal(h.getRoleContainer('SR').tag, 'SR_interfaceContainerBlock_2'); + }); +}); + +describe('PolicyWizardHelper.getRoleStep', () => { + it('is an active step block for the role', () => { + const block = helper().getRoleStep('USER'); + assert.equal(block.blockType, 'interfaceStepBlock'); + assert.equal(block.defaultActive, true); + assert.deepEqual(block.permissions, ['USER']); + }); +}); + +describe('PolicyWizardHelper.getTabContainer', () => { + it('carries the title in blank uiMetaData', () => { + const block = helper().getTabContainer('USER', 'My Tab'); + assert.equal(block.uiMetaData.type, 'blank'); + assert.equal(block.uiMetaData.title, 'My Tab'); + }); +}); + +describe('PolicyWizardHelper.getDocumentsGrid', () => { + it('maps field configs into credentialSubject columns plus a document button', () => { + const block = helper().getDocumentsGrid('USER', [{ field: 'amount', title: 'Amount' }]); + assert.equal(block.blockType, 'interfaceDocumentsSourceBlock'); + assert.equal(block.uiMetaData.fields[0].name, 'document.credentialSubject.0.amount'); + assert.equal(block.uiMetaData.fields[0].title, 'Amount'); + assert.equal(block.uiMetaData.fields.at(-1).type, 'button'); + }); + + it('appends a history addon child', () => { + const block = helper().getDocumentsGrid('USER', []); + assert.equal(block.children.length, 1); + assert.equal(block.children[0].blockType, 'historyAddon'); + }); +}); + +describe('PolicyWizardHelper.getHistoryAddon', () => { + it('is an inactive history addon for the role', () => { + const block = helper().getHistoryAddon('USER'); + assert.equal(block.blockType, 'historyAddon'); + assert.equal(block.defaultActive, false); + assert.deepEqual(block.permissions, ['USER']); + }); +}); + +describe('PolicyWizardHelper.getChangeDocumentStatusSendBlock', () => { + it('sends to guardian with the requested status option', () => { + const block = helper().getChangeDocumentStatusSendBlock('SR', 'Approved'); + assert.equal(block.blockType, 'sendToGuardianBlock'); + assert.deepEqual(block.options, [{ name: 'status', value: 'Approved' }]); + assert.equal(block.dataSource, 'database'); + }); +}); + +describe('PolicyWizardHelper.getDocumentSendBlock', () => { + it('has no status option when approval is not needed', () => { + const block = helper().getDocumentSendBlock('USER', false, false); + assert.deepEqual(block.options, []); + assert.equal(block.dataSource, 'auto'); + }); + + it('adds a Waiting for approval option when approval is needed', () => { + const block = helper().getDocumentSendBlock('USER', false, true); + assert.deepEqual(block.options, [{ name: 'status', value: 'Waiting for approval' }]); + }); + + it('builds run events for the supplied trigger tags', () => { + const block = helper().getDocumentSendBlock('USER', true, false, undefined, ['t1', 't2']); + assert.equal(block.events.length, 2); + assert.equal(block.events[0].target, 't1'); + assert.equal(block.events[0].input, 'RunEvent'); + assert.equal(block.stopPropagation, true); + }); +}); + +describe('PolicyWizardHelper.getDocumentsSourceAddon', () => { + it('carries schema, filters and onlyOwnDocuments flags', () => { + const filters = [{ field: 'type', value: 'x', type: 'equal' }]; + const block = helper().getDocumentsSourceAddon('USER', '#schema', true, filters, 'vp-documents'); + assert.equal(block.blockType, 'documentsSourceAddon'); + assert.equal(block.schema, '#schema'); + assert.equal(block.onlyOwnDocuments, true); + assert.equal(block.dataType, 'vp-documents'); + assert.deepEqual(block.filters, filters); + }); + + it('defaults to vc-documents and no filters', () => { + const block = helper().getDocumentsSourceAddon('USER', '#schema'); + assert.equal(block.dataType, 'vc-documents'); + assert.deepEqual(block.filters, []); + assert.equal(block.onlyOwnDocuments, false); + }); +}); + +describe('PolicyWizardHelper.getDialogRequestDocumentBlock', () => { + it('is active when there is no dependency schema', () => { + const block = helper().getDialogRequestDocumentBlock('USER', '#s', false, 'My Schema'); + assert.equal(block.blockType, 'requestVcDocumentBlock'); + assert.equal(block.defaultActive, true); + assert.equal(block.uiMetaData.content, 'Create My Schema'); + }); + + it('is inactive and uses link style for a dependency schema', () => { + const block = helper().getDialogRequestDocumentBlock('USER', '#s', true); + assert.equal(block.defaultActive, false); + assert.equal(block.uiMetaData.buttonClass, 'link'); + assert.equal(block.uiMetaData.content, 'Create'); + }); +}); + +describe('PolicyWizardHelper.getRequestDocumentBlock', () => { + it('is a page request bound to the schema', () => { + const block = helper().getRequestDocumentBlock('USER', '#s'); + assert.equal(block.blockType, 'requestVcDocumentBlock'); + assert.equal(block.uiMetaData.type, 'page'); + assert.equal(block.schema, '#s'); + }); +}); + +describe('PolicyWizardHelper.getInfoBlock', () => { + it('renders a text information block with title and description', () => { + const block = helper().getInfoBlock('USER', 'T', 'D'); + assert.equal(block.blockType, 'informationBlock'); + assert.equal(block.uiMetaData.title, 'T'); + assert.equal(block.uiMetaData.description, 'D'); + assert.equal(block.stopPropagation, true); + }); +}); + +describe('PolicyWizardHelper.getReportBlock', () => { + it('is an empty report container', () => { + const block = helper().getReportBlock('USER'); + assert.equal(block.blockType, 'reportBlock'); + assert.deepEqual(block.children, []); + }); +}); + +describe('PolicyWizardHelper.getMintBlock', () => { + it('carries tokenId and rule with default account type', () => { + const block = helper().getMintBlock('USER', '0.0.1', 'rule-x'); + assert.equal(block.blockType, 'mintDocumentBlock'); + assert.equal(block.tokenId, '0.0.1'); + assert.equal(block.rule, 'rule-x'); + assert.equal(block.accountType, 'default'); + }); +}); + +describe('PolicyWizardHelper.getCreateDependencySchemaColumn', () => { + it('is a block column bound to the target block and group', () => { + const col = helper().getCreateDependencySchemaColumn('Title', 'bindB', 'bindG'); + assert.equal(col.type, 'block'); + assert.equal(col.title, 'Title'); + assert.equal(col.bindBlock, 'bindB'); + assert.equal(col.bindGroup, 'bindG'); + }); +}); + +describe('PolicyWizardHelper.getSetRelationshipsBlock', () => { + it('wraps a documents source addon with a type filter when approval is enabled', () => { + const block = helper().getSetRelationshipsBlock('USER', '#s', true); + assert.equal(block.blockType, 'setRelationshipsBlock'); + assert.equal(block.children.length, 1); + assert.equal(block.children[0].filters.length, 1); + }); + + it('uses an unfiltered addon when approval is disabled', () => { + const block = helper().getSetRelationshipsBlock('USER', '#s', false); + assert.deepEqual(block.children[0].filters, []); + }); +}); + +describe('PolicyWizardHelper.addRefreshEvent', () => { + it('appends refresh events to each block targeting the given tags', () => { + const blocks = [{ tag: 'b1' }, { tag: 'b2', events: [{ existing: true }] }]; + helper().addRefreshEvent(blocks, ['target']); + assert.equal(blocks[0].events.length, 1); + assert.equal(blocks[0].events[0].source, 'b1'); + assert.equal(blocks[0].events[0].input, 'RefreshEvent'); + assert.equal(blocks[1].events.length, 2); + }); + + it('handles multiple target tags', () => { + const blocks = [{ tag: 'b1' }]; + helper().addRefreshEvent(blocks, ['t1', 't2', 't3']); + assert.equal(blocks[0].events.length, 3); + }); +}); diff --git a/guardian-service/tests/unit/policy-wizard-helper.test.mjs b/guardian-service/tests/unit/policy-wizard-helper.test.mjs new file mode 100644 index 0000000000..4f9562a880 --- /dev/null +++ b/guardian-service/tests/unit/policy-wizard-helper.test.mjs @@ -0,0 +1,54 @@ +import { assert } from 'chai'; +import { PolicyWizardHelper } from '../../dist/api/helpers/policy-wizard-helper.js'; + +describe('PolicyWizardHelper', () => { + it('generateBlockTag increments counter and follows {role}_{blockType}_{n} shape', () => { + const h = new PolicyWizardHelper(); + const a = h.generateBlockTag('USER', 'requestVcDocumentBlock'); + const b = h.generateBlockTag('USER', 'requestVcDocumentBlock'); + const c = h.generateBlockTag('SR', 'sendToGuardianBlock'); + assert.equal(a, 'USER_requestVcDocumentBlock_1'); + assert.equal(b, 'USER_requestVcDocumentBlock_2'); + assert.equal(c, 'SR_sendToGuardianBlock_3'); + }); + + it('createPolicyConfig returns an interfaceContainerBlock root with a UUID id and ANY_ROLE permission', () => { + const h = new PolicyWizardHelper(); + const root = h.createPolicyConfig({ roles: ['SR'], schemas: [], trustChain: [] }); + assert.equal(root.blockType, 'interfaceContainerBlock'); + assert.match(root.id, /^[0-9a-f-]{36}$/); + assert.deepEqual(root.permissions, ['ANY_ROLE']); + assert.isArray(root.children); + }); + + it('resets the block counter at the start of each createPolicyConfig call', () => { + const h = new PolicyWizardHelper(); + h.createPolicyConfig({ roles: ['USER'], schemas: [], trustChain: [] }); + const counterBefore = h.blockCounter; + h.createPolicyConfig({ roles: ['USER'], schemas: [], trustChain: [] }); + // After reset, counter should not be greater than the first call's final value + // (it gets reset to 0 then incremented for the same set of blocks) + assert.equal(h.blockCounter, counterBefore); + }); + + it('always emits a choose-role block as the first child of root', () => { + const h = new PolicyWizardHelper(); + const root = h.createPolicyConfig({ roles: ['SR', 'USER'], schemas: [], trustChain: [] }); + assert.isAbove(root.children.length, 0); + // First child must be the policyRolesBlock (choose-role) + const first = root.children[0]; + assert.equal(first.blockType, 'policyRolesBlock'); + assert.deepEqual(first.roles, ['SR', 'USER']); + }); + + it('appends one role-container child per declared role', () => { + const h = new PolicyWizardHelper(); + const root = h.createPolicyConfig({ roles: ['SR', 'USER', 'AUDITOR'], schemas: [], trustChain: [] }); + // root.children = [chooseRole, ...roleContainers] + const containers = root.children.slice(1); + assert.equal(containers.length, 3); + for (const c of containers) { + assert.equal(c.blockType, 'interfaceContainerBlock'); + } + }); +}); diff --git a/guardian-service/tests/unit/policy-wizard-report-builders.test.mjs b/guardian-service/tests/unit/policy-wizard-report-builders.test.mjs new file mode 100644 index 0000000000..a5697fee5c --- /dev/null +++ b/guardian-service/tests/unit/policy-wizard-report-builders.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { PolicyWizardHelper } from '../../dist/api/helpers/policy-wizard-helper.js'; + +const helper = () => new PolicyWizardHelper(); + +describe('PolicyWizardHelper.getApproveRejectButtonsBlock', () => { + it('is a button block with Approve and Reject buttons', () => { + const block = helper().getApproveRejectButtonsBlock('SR', 'approveTag', 'rejectTag'); + assert.equal(block.blockType, 'buttonBlock'); + assert.equal(block.uiMetaData.buttons.length, 2); + assert.equal(block.uiMetaData.buttons[0].name, 'Approve'); + assert.equal(block.uiMetaData.buttons[1].name, 'Reject'); + }); + + it('wires run events to the approve and reject targets', () => { + const block = helper().getApproveRejectButtonsBlock('SR', 'approveTag', 'rejectTag'); + assert.equal(block.events[0].target, 'approveTag'); + assert.equal(block.events[0].output, 'Button_0'); + assert.equal(block.events[1].target, 'rejectTag'); + assert.equal(block.events[1].output, 'Button_1'); + }); +}); + +describe('PolicyWizardHelper.getApproveRejectField', () => { + it('is an operation block field bound to the block and group', () => { + const field = helper().getApproveRejectField('bindB', 'bindG'); + assert.equal(field.name, 'option.status'); + assert.equal(field.type, 'block'); + assert.equal(field.bindBlock, 'bindB'); + assert.equal(field.bindGroup, 'bindG'); + assert.equal(field.width, '250px'); + }); +}); + +describe('PolicyWizardHelper.getReportMintItem', () => { + it('is a report item filtered on the action id', () => { + const block = helper().getReportMintItem('USER'); + assert.equal(block.blockType, 'reportItemBlock'); + assert.equal(block.title, 'Token'); + assert.equal(block.filters[0].value, 'actionId'); + assert.equal(block.visible, true); + }); +}); + +describe('PolicyWizardHelper.getReportFirstItem', () => { + it('returns the block plus a generated variable name', () => { + const [block, variableName] = helper().getReportFirstItem('USER', 'T', 'D'); + assert.equal(block.blockType, 'reportItemBlock'); + assert.equal(block.title, 'T'); + assert.equal(block.description, 'D'); + assert.match(variableName, /^[0-9a-f-]{36}$/); + assert.equal(block.variables[0].name, variableName); + }); + + it('filters on documentId', () => { + const [block] = helper().getReportFirstItem('USER', 'T', 'D'); + assert.equal(block.filters[0].value, 'documentId'); + }); +}); + +describe('PolicyWizardHelper.getReportItem', () => { + it('filters on the supplied relationships variable name with an "in" match', () => { + const [block, variableName] = helper().getReportItem('USER', 'T', 'D', 'relVar'); + assert.equal(block.filters[0].type, 'in'); + assert.equal(block.filters[0].value, 'relVar'); + assert.match(variableName, /^[0-9a-f-]{36}$/); + }); + + it('exposes its own generated relationships variable', () => { + const [block, variableName] = helper().getReportItem('USER', 'T', 'D', 'relVar'); + assert.equal(block.variables[0].name, variableName); + assert.equal(block.variables[0].value, 'relationships'); + }); +}); diff --git a/guardian-service/tests/unit/policy-wizard-vp-grid.test.mjs b/guardian-service/tests/unit/policy-wizard-vp-grid.test.mjs new file mode 100644 index 0000000000..497070914b --- /dev/null +++ b/guardian-service/tests/unit/policy-wizard-vp-grid.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { PolicyWizardHelper } from '../../dist/api/helpers/policy-wizard-helper.js'; + +const helper = () => new PolicyWizardHelper(); + +describe('PolicyWizardHelper.getVpGrid', () => { + it('is a documents viewer with HASH, Date, Token Id, Serials and TrustChain columns', () => { + const block = helper().getVpGrid('USER', 'tc-tag', false); + assert.equal(block.blockType, 'interfaceDocumentsSourceBlock'); + const titles = block.uiMetaData.fields.map(f => f.title); + assert.deepEqual(titles, ['HASH', 'Date', 'Token Id', 'Serials', 'TrustChain']); + }); + + it('binds the TrustChain button to the trust chain tag', () => { + const block = helper().getVpGrid('USER', 'tc-tag', false); + const trustChain = block.uiMetaData.fields.find(f => f.title === 'TrustChain'); + assert.equal(trustChain.bindBlock, 'tc-tag'); + assert.equal(trustChain.action, 'link'); + }); + + it('embeds a vp-documents source addon honouring onlyOwnDocuments', () => { + const block = helper().getVpGrid('USER', 'tc-tag', true); + assert.equal(block.children.length, 1); + assert.equal(block.children[0].dataType, 'vp-documents'); + assert.equal(block.children[0].onlyOwnDocuments, true); + }); + + it('scopes the grid to the requested role', () => { + const block = helper().getVpGrid('AUDITOR', 'tc-tag', false); + assert.deepEqual(block.permissions, ['AUDITOR']); + }); +}); + +describe('PolicyWizardHelper.createVPGrid', () => { + it('pushes a vp grid into the container and returns both', () => { + const container = { children: [] }; + const [returnedContainer, vpGrid] = helper().createVPGrid( + { role: 'USER', viewOnlyOwnDocuments: false }, 'tc-tag', container); + assert.equal(returnedContainer, container); + assert.equal(container.children.length, 1); + assert.equal(container.children[0], vpGrid); + assert.equal(vpGrid.blockType, 'interfaceDocumentsSourceBlock'); + }); + + it('forwards viewOnlyOwnDocuments to the embedded addon', () => { + const container = { children: [] }; + const [, vpGrid] = helper().createVPGrid( + { role: 'USER', viewOnlyOwnDocuments: true }, 'tc-tag', container); + assert.equal(vpGrid.children[0].onlyOwnDocuments, true); + }); +}); diff --git a/guardian-service/tests/unit/property-model-system-fields.test.mjs b/guardian-service/tests/unit/property-model-system-fields.test.mjs new file mode 100644 index 0000000000..17fc220bf1 --- /dev/null +++ b/guardian-service/tests/unit/property-model-system-fields.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { + PropertyModel, + UUIDPropertyModel, + AnyPropertyModel, + ArrayPropertyModel, + ObjectPropertyModel, + DocumentPropertyModel, +} from '../../dist/analytics/compare/models/property.model.js'; +import { PropertyType } from '../../dist/analytics/compare/types/property.type.js'; +import { IIdLvl, IKeyLvl, IPropertiesLvl } from '../../dist/analytics/compare/interfaces/compare-options.interface.js'; + +describe('PropertyModel base behaviour', () => { + it('defaults lvl to 1 and path to name', () => { + const p = new AnyPropertyModel('foo', 'bar'); + assert.equal(p.lvl, 1); + assert.equal(p.path, 'foo'); + assert.equal(p.key, 'foo'); + }); + it('uses provided lvl and path', () => { + const p = new AnyPropertyModel('foo', 'bar', 3, 'a.b.foo'); + assert.equal(p.lvl, 3); + assert.equal(p.path, 'a.b.foo'); + }); + it('equal compares type and weight', () => { + const a = new AnyPropertyModel('x', 'v1'); + const b = new AnyPropertyModel('x', 'v1'); + const c = new AnyPropertyModel('x', 'v2'); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + it('toObject includes optional description/title/property only when set', () => { + const p = new AnyPropertyModel('x', 'v'); + let obj = p.toObject(); + assert.equal(obj.description, undefined); + p.setDescription('desc'); + p.setTitle('ttl'); + p.setProperty('prop'); + obj = p.toObject(); + assert.equal(obj.description, 'desc'); + assert.equal(obj.title, 'ttl'); + assert.equal(obj.property, 'prop'); + }); + it('getPropList returns sub-properties array', () => { + const p = new ObjectPropertyModel('o', {}, 1); + assert.ok(Array.isArray(p.getPropList())); + }); + it('ignore returns false by default', () => { + const p = new AnyPropertyModel('x', 'v'); + assert.equal(p.ignore({ idLvl: IIdLvl.None }), false); + }); +}); + +describe('PropertyModel.hash', () => { + it('Simple level returns path:value for lvl 1', () => { + const p = new AnyPropertyModel('x', 'v', 1, 'x'); + assert.equal(p.hash({ propLvl: IPropertiesLvl.Simple }), 'x:v'); + }); + it('Simple level returns null for nested lvl', () => { + const p = new AnyPropertyModel('x', 'v', 2, 'a.x'); + assert.equal(p.hash({ propLvl: IPropertiesLvl.Simple }), null); + }); + it('non-Simple level always returns path:value', () => { + const p = new AnyPropertyModel('x', 'v', 5, 'a.b.x'); + assert.equal(p.hash({ propLvl: IPropertiesLvl.All }), 'a.b.x:v'); + }); +}); + +describe('PropertyModel.update key levels', () => { + const mk = () => { + const p = new AnyPropertyModel('x', 'v', 1, 'the.path'); + p.setDescription('d'); + p.setTitle('t'); + p.setProperty('pr'); + return p; + }; + it('Description level sets key to description', () => { + const p = mk(); + p.update({ keyLvl: IKeyLvl.Description }); + assert.equal(p.key, 'd'); + }); + it('Title level sets key to title', () => { + const p = mk(); + p.update({ keyLvl: IKeyLvl.Title }); + assert.equal(p.key, 't'); + }); + it('Property level sets key to property', () => { + const p = mk(); + p.update({ keyLvl: IKeyLvl.Property }); + assert.equal(p.key, 'pr'); + }); + it('Default level sets key to path', () => { + const p = mk(); + p.update({ keyLvl: IKeyLvl.Default }); + assert.equal(p.key, 'the.path'); + }); + it('falls back to path when chosen key is empty', () => { + const p = new AnyPropertyModel('x', 'v', 1, 'fallback'); + p.update({ keyLvl: IKeyLvl.Description }); + assert.equal(p.key, 'fallback'); + }); +}); + +describe('UUIDPropertyModel', () => { + it('equal returns true when idLvl is None', () => { + const a = new UUIDPropertyModel('id', 'aaa'); + const b = new UUIDPropertyModel('id', 'bbb'); + assert.equal(a.equal(b, { idLvl: IIdLvl.None }), true); + }); + it('equal compares value when idLvl is All', () => { + const a = new UUIDPropertyModel('id', 'aaa'); + const b = new UUIDPropertyModel('id', 'aaa'); + const c = new UUIDPropertyModel('id', 'ccc'); + assert.equal(a.equal(b, { idLvl: IIdLvl.All }), true); + assert.equal(a.equal(c, { idLvl: IIdLvl.All }), false); + }); + it('hash returns null when idLvl is None', () => { + const a = new UUIDPropertyModel('id', 'aaa', 1, 'id'); + assert.equal(a.hash({ idLvl: IIdLvl.None }), null); + }); + it('hash delegates to super when idLvl is All', () => { + const a = new UUIDPropertyModel('id', 'aaa', 1, 'id'); + assert.equal(a.hash({ idLvl: IIdLvl.All, propLvl: IPropertiesLvl.All }), 'id:aaa'); + }); + it('type is uuid', () => { + assert.equal(new UUIDPropertyModel('id', 'x').type, PropertyType.UUID); + }); +}); + +describe('ArrayPropertyModel / ObjectPropertyModel types', () => { + it('array type', () => { + assert.equal(new ArrayPropertyModel('a', []).type, PropertyType.Array); + }); + it('object type', () => { + assert.equal(new ObjectPropertyModel('o', {}).type, PropertyType.Object); + }); +}); + +describe('DocumentPropertyModel.checkSystemField', () => { + const isSys = (name, value, path, type) => + new DocumentPropertyModel(name, value, 1, path, type).isSystem; + + it('flags reserved field names', () => { + for (const n of ['@context', 'type', 'policyId', 'id', 'ref', 'tokenId', 'issuanceDate', 'issuer', 'guardianVersion']) { + assert.equal(isSys(n, 'v', n, undefined), true, n); + } + }); + it('flags date when type is MintToken', () => { + assert.equal(isSys('date', 'v', 'date', 'MintToken'), true); + }); + it('flags date when type prefix is MintToken&...', () => { + assert.equal(isSys('date', 'v', 'date', 'MintToken&1'), true); + }); + it('does not flag date for non-mint type', () => { + assert.equal(isSys('date', 'v', 'date', 'OtherType'), false); + }); + it('flags proof-related paths', () => { + assert.equal(isSys('x', 'v', 'proof'), true); + assert.equal(isSys('x', 'v', 'proof.created'), true); + assert.equal(isSys('x', 'v', 'proof.jws'), true); + assert.equal(isSys('x', 'v', 'a.proof.type'), true); + assert.equal(isSys('x', 'v', 'type.foo'), true); + assert.equal(isSys('x', 'v', 'a.@context.b'), true); + }); + it('flags did:hedera value', () => { + assert.equal(isSys('field', 'did:hedera:testnet_0.0.1', 'field'), true); + }); + it('does not flag a plain field', () => { + assert.equal(isSys('amount', 100, 'amount', undefined), false); + }); + it('ignore returns true only for system field with idLvl None', () => { + const sys = new DocumentPropertyModel('id', 'v', 1, 'id'); + const plain = new DocumentPropertyModel('amount', 1, 1, 'amount'); + assert.equal(sys.ignore({ idLvl: IIdLvl.None }), true); + assert.equal(sys.ignore({ idLvl: IIdLvl.All }), false); + assert.equal(plain.ignore({ idLvl: IIdLvl.None }), false); + }); +}); diff --git a/guardian-service/tests/unit/schema-import-helper-pure.test.mjs b/guardian-service/tests/unit/schema-import-helper-pure.test.mjs new file mode 100644 index 0000000000..6ebe257e5e --- /dev/null +++ b/guardian-service/tests/unit/schema-import-helper-pure.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { SchemaImportExportHelper } from '../../dist/helpers/import-helpers/schema/schema-import-helper.js'; + +describe('SchemaImportExportHelper.getDefs', () => { + it('is a static function', () => { + assert.equal(typeof SchemaImportExportHelper.getDefs, 'function'); + }); + + it('returns $defs keys from an object document', () => { + const result = SchemaImportExportHelper.getDefs({ document: { $defs: { '#a': {}, '#b': {} } } }); + assert.deepEqual(result.sort(), ['#a', '#b']); + }); + + it('parses a stringified document', () => { + const result = SchemaImportExportHelper.getDefs({ document: JSON.stringify({ $defs: { '#x': {} } }) }); + assert.deepEqual(result, ['#x']); + }); + + it('returns empty array when $defs is absent', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: { type: 'object' } }), []); + }); + + it('returns empty array when document is empty object', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: {} }), []); + }); + + it('returns empty array on invalid JSON string', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: 'not-json' }), []); + }); + + it('returns empty array when document is null', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: null }), []); + }); + + it('returns empty array when $defs is empty', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: { $defs: {} } }), []); + }); +}); + +describe('SchemaImportExportHelper.getDefDocuments', () => { + it('is a static function', () => { + assert.equal(typeof SchemaImportExportHelper.getDefDocuments, 'function'); + }); + + it('returns $defs values from an object document', () => { + const result = SchemaImportExportHelper.getDefDocuments({ + document: { $defs: { '#a': { type: 'object' }, '#b': { type: 'string' } } } + }); + assert.equal(result.length, 2); + assert.ok(result.some((d) => d.type === 'object')); + assert.ok(result.some((d) => d.type === 'string')); + }); + + it('parses a stringified document', () => { + const result = SchemaImportExportHelper.getDefDocuments({ + document: JSON.stringify({ $defs: { '#x': { type: 'number' } } }) + }); + assert.deepEqual(result, [{ type: 'number' }]); + }); + + it('returns empty array when $defs is absent', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: { type: 'object' } }), []); + }); + + it('returns empty array when document is empty object', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: {} }), []); + }); + + it('returns empty array when document is null', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: null }), []); + }); + + it('returns empty array on invalid JSON string', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: 'broken' }), []); + }); +}); + +describe('SchemaImportExportHelper.validateDefs', () => { + it('is a static function', () => { + assert.equal(typeof SchemaImportExportHelper.validateDefs, 'function'); + }); + + it('returns null when target already validated', () => { + const validated = new Map(); + validated.set('iri-1', {}); + assert.equal(SchemaImportExportHelper.validateDefs('iri-1', [], validated), null); + }); + + it('returns "Invalid defs" when target schema is not found', () => { + assert.equal(SchemaImportExportHelper.validateDefs('missing', [], new Map()), 'Invalid defs'); + }); + + it('does not look up schemas already in the validated map', () => { + const validated = new Map([['x', { iri: 'x' }]]); + const allSchemas = []; + assert.equal(SchemaImportExportHelper.validateDefs('x', allSchemas, validated), null); + }); +}); diff --git a/guardian-service/tests/unit/search-models.test.mjs b/guardian-service/tests/unit/search-models.test.mjs new file mode 100644 index 0000000000..9e0cc74ab6 --- /dev/null +++ b/guardian-service/tests/unit/search-models.test.mjs @@ -0,0 +1,349 @@ +import assert from 'node:assert/strict'; +import { BlockSearchModel } from '../../dist/analytics/search/models/block.model.js'; +import { ChainSearchModel } from '../../dist/analytics/search/models/chain.model.js'; +import { PairSearchModel } from '../../dist/analytics/search/models/pair.model.js'; +import { RootSearchModel } from '../../dist/analytics/search/models/root.model.js'; +import { ModuleSearchModel } from '../../dist/analytics/search/models/module.model.js'; +import { ToolSearchModel } from '../../dist/analytics/search/models/tool.model.js'; +import { PolicySearchModel } from '../../dist/analytics/search/models/policy.model.js'; + +const CONTAINER = 'interfaceContainerBlock'; +const ROLES = 'policyRolesBlock'; +const TOOL = 'tool'; + +function block(id, type = CONTAINER, extra = {}) { + return { id, tag: `${id}-tag`, blockType: type, ...extra }; +} + +describe('BlockSearchModel', () => { + it('exposes constructor as a function', () => { + assert.equal(typeof BlockSearchModel, 'function'); + }); + + it('maps id, tag and blockType onto the instance', () => { + const b = new BlockSearchModel(block('a', ROLES)); + assert.equal(b.id, 'a'); + assert.equal(b.tag, 'a-tag'); + assert.equal(b.type, ROLES); + }); + + it('initialises links and children to defaults', () => { + const b = new BlockSearchModel(block('a')); + assert.deepEqual(b.children, []); + assert.equal(b.parent, null); + assert.equal(b.next, null); + assert.equal(b.prev, null); + }); + + it('returns sorted permissions copy', () => { + const b = new BlockSearchModel(block('a', CONTAINER, { permissions: ['z', 'a', 'm'] })); + assert.deepEqual(b.getPermissionsList(), ['a', 'm', 'z']); + }); + + it('returns empty permissions when none provided', () => { + const b = new BlockSearchModel(block('a')); + assert.deepEqual(b.getPermissionsList(), []); + }); + + it('builds events from array', () => { + const b = new BlockSearchModel(block('a', CONTAINER, { events: [{ source: 's' }] })); + assert.equal(b.getEventList().length, 1); + }); + + it('returns empty events when not an array', () => { + const b = new BlockSearchModel(block('a', CONTAINER, { events: 'nope' })); + assert.deepEqual(b.getEventList(), []); + }); + + it('builds artifacts from array', () => { + const b = new BlockSearchModel(block('a', CONTAINER, { artifacts: [{ uuid: 'u1' }] })); + assert.equal(b.getArtifactsList().length, 1); + }); + + it('returns empty artifacts when not an array', () => { + const b = new BlockSearchModel(block('a')); + assert.deepEqual(b.getArtifactsList(), []); + }); + + it('returns property list array', () => { + const b = new BlockSearchModel(block('a')); + assert.ok(Array.isArray(b.getPropList())); + }); + + it('addChildren wires parent and prev/next', () => { + const root = new BlockSearchModel(block('root')); + const c1 = new BlockSearchModel(block('c1')); + const c2 = new BlockSearchModel(block('c2')); + root.addChildren(c1); + root.addChildren(c2); + assert.equal(c1.parent, root); + assert.equal(c2.parent, root); + assert.equal(c1.next, c2); + assert.equal(c2.prev, c1); + assert.equal(root.children.length, 2); + }); + + it('first child has no prev/next', () => { + const root = new BlockSearchModel(block('root')); + const c1 = new BlockSearchModel(block('c1')); + root.addChildren(c1); + assert.equal(c1.next, null); + assert.equal(c1.prev, null); + }); + + it('update sets root path to [0] when no parent', () => { + const b = new BlockSearchModel(block('a')); + b.update(); + assert.deepEqual(b.toJson().path, [0]); + }); + + it('update derives path from parent index', () => { + const root = new BlockSearchModel(block('root')); + const c1 = new BlockSearchModel(block('c1')); + const c2 = new BlockSearchModel(block('c2')); + root.addChildren(c1); + root.addChildren(c2); + root.update(); + c1.update(); + c2.update(); + assert.deepEqual(c1.toJson().path, [0, 0]); + assert.deepEqual(c2.toJson().path, [0, 1]); + }); + + it('toJson exposes id/tag/blockType/config/path', () => { + const b = new BlockSearchModel(block('a', ROLES)); + const json = b.toJson(); + assert.deepEqual(Object.keys(json).sort(), ['blockType', 'config', 'id', 'path', 'tag']); + assert.equal(json.blockType, ROLES); + assert.equal(json.config.children, undefined); + }); + + it('toJson path is a copy, not a reference', () => { + const b = new BlockSearchModel(block('a')); + b.update(); + const p = b.toJson().path; + p.push(99); + assert.deepEqual(b.toJson().path, [0]); + }); + + it('find returns empty chain for mismatched types', () => { + const a = new BlockSearchModel(block('a', ROLES)); + const f = new BlockSearchModel(block('f', CONTAINER)); + const chain = a.find(f); + chain.update(); + assert.equal(chain.hash, 0); + }); + + it('find returns a chain with a pair for matching types', () => { + const a = new BlockSearchModel(block('a', ROLES)); + const f = new BlockSearchModel(block('f', ROLES)); + const chain = a.find(f); + chain.update(); + assert.equal(chain.toJson().pairs.length, 1); + }); +}); + +describe('PairSearchModel', () => { + const a = new BlockSearchModel(block('a', ROLES, { permissions: ['p'] })); + const b = new BlockSearchModel(block('b', ROLES, { permissions: ['p'] })); + + it('starts with zero hash', () => { + const pair = new PairSearchModel(a, b); + assert.equal(pair.hash, 0); + }); + + it('keeps source and filter references', () => { + const pair = new PairSearchModel(a, b); + assert.equal(pair.source, a); + assert.equal(pair.filter, b); + }); + + it('update produces a numeric hash for identical blocks', () => { + const pair = new PairSearchModel(a, b); + pair.update(); + assert.equal(typeof pair.hash, 'number'); + assert.ok(pair.hash > 0); + }); + + it('toJson returns hash plus source/filter json', () => { + const pair = new PairSearchModel(a, b); + pair.update(); + const json = pair.toJson(); + assert.deepEqual(Object.keys(json).sort(), ['filter', 'hash', 'source']); + assert.equal(json.source.id, 'a'); + assert.equal(json.filter.id, 'b'); + }); +}); + +describe('ChainSearchModel', () => { + it('starts with empty pairs and zero hash', () => { + const chain = new ChainSearchModel(); + assert.equal(chain.hash, 0); + }); + + it('update on empty chain keeps hash at zero', () => { + const chain = new ChainSearchModel(); + chain.update(); + assert.equal(chain.hash, 0); + }); + + it('addPair is chainable and grows the chain', () => { + const a = new BlockSearchModel(block('a', ROLES)); + const b = new BlockSearchModel(block('b', ROLES)); + const chain = new ChainSearchModel(); + const returned = chain.addPair(a, b); + assert.equal(returned, chain); + assert.equal(chain.toJson().pairs.length, 1); + }); + + it('update computes hash from pair count and rates', () => { + const a = new BlockSearchModel(block('a', ROLES, { permissions: ['p'] })); + const b = new BlockSearchModel(block('b', ROLES, { permissions: ['p'] })); + const chain = new ChainSearchModel(); + chain.addPair(a, b); + chain.update(); + assert.ok(chain.hash >= 1000); + }); + + it('toJson exposes hash/target/pairs and target is first source', () => { + const a = new BlockSearchModel(block('a', ROLES)); + const b = new BlockSearchModel(block('b', ROLES)); + const chain = new ChainSearchModel(); + chain.addPair(a, b); + const json = chain.toJson(); + assert.deepEqual(Object.keys(json).sort(), ['hash', 'pairs', 'target']); + assert.equal(json.target.id, 'a'); + }); +}); + +describe('RootSearchModel.fromConfig', () => { + it('throws on empty config', () => { + assert.throws(() => RootSearchModel.fromConfig(null), /Empty config/); + }); + + it('builds a tree and lists all blocks', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES), block('c2', ROLES)] + })); + assert.equal(root.filter(ROLES).length, 2); + }); + + it('filter returns only blocks of given type', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES), block('c2', CONTAINER)] + })); + assert.equal(root.filter(ROLES).length, 1); + assert.equal(root.filter(CONTAINER).length, 2); + }); + + it('findBlock locates a block by id', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES)] + })); + assert.equal(root.findBlock('c1').id, 'c1'); + }); + + it('findBlock returns undefined for unknown id', () => { + const root = RootSearchModel.fromConfig(block('root')); + assert.equal(root.findBlock('missing'), undefined); + }); + + it('does not descend into nested tool blocks', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('tool1', TOOL, { children: [block('inner', ROLES)] })] + })); + assert.ok(root.findBlock('tool1')); + assert.equal(root.findBlock('inner'), undefined); + }); + + it('treats the root as a tool when it is the root block', () => { + const root = RootSearchModel.fromConfig(block('root', TOOL, { + children: [block('inner', ROLES)] + })); + assert.ok(root.findBlock('inner')); + }); + + it('search returns one chain per candidate block', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES), block('c2', ROLES)] + })); + const filter = root.findBlock('c1'); + const chains = root.search(filter); + assert.equal(chains.length, 2); + }); + + it('search sorts chains by descending hash', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES), block('c2', ROLES)] + })); + const filter = root.findBlock('c1'); + const chains = root.search(filter); + for (let i = 1; i < chains.length; i++) { + assert.ok(chains[i - 1].hash >= chains[i].hash); + } + }); + + it('search of absent type returns empty list', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', CONTAINER)] + })); + const filter = new BlockSearchModel(block('f', ROLES)); + assert.deepEqual(root.search(filter), []); + }); + + it('computes nested child paths', () => { + const root = RootSearchModel.fromConfig(block('root', CONTAINER, { + children: [block('c1', ROLES), block('c2', ROLES)] + })); + assert.deepEqual(root.findBlock('c2').toJson().path, [0, 1]); + }); +}); + +describe('ModuleSearchModel', () => { + it('throws when config is missing', () => { + assert.throws(() => new ModuleSearchModel({}), /Empty module config/); + }); + + it('builds from module metadata and config', () => { + const m = new ModuleSearchModel({ + name: 'M', description: 'd', owner: 'o', topicId: '0.0.1', messageId: 'm1', + config: block('r', CONTAINER, { children: [block('c1', ROLES)] }) + }); + assert.equal(m.name, 'M'); + assert.equal(m.owner, 'o'); + assert.equal(m.topicId, '0.0.1'); + assert.equal(m.messageId, 'm1'); + assert.equal(m.filter(ROLES).length, 1); + }); +}); + +describe('ToolSearchModel', () => { + it('throws when config is missing', () => { + assert.throws(() => new ToolSearchModel({}), /Empty tool config/); + }); + + it('builds from tool metadata and config', () => { + const t = new ToolSearchModel({ + name: 'T', owner: 'o', topicId: '0.0.2', messageId: 't1', + config: block('r', CONTAINER) + }); + assert.equal(t.name, 'T'); + assert.equal(t.messageId, 't1'); + }); +}); + +describe('PolicySearchModel', () => { + it('throws when config is missing', () => { + assert.throws(() => new PolicySearchModel({}), /Empty policy config/); + }); + + it('captures version alongside root metadata', () => { + const p = new PolicySearchModel({ + name: 'P', version: '1.2.3', description: 'd', owner: 'o', + topicId: '0.0.3', messageId: 'p1', config: block('r', CONTAINER) + }); + assert.equal(p.version, '1.2.3'); + assert.equal(p.name, 'P'); + assert.equal(p.description, 'd'); + }); +}); diff --git a/guardian-service/tests/unit/search-utils.test.mjs b/guardian-service/tests/unit/search-utils.test.mjs new file mode 100644 index 0000000000..c417b05287 --- /dev/null +++ b/guardian-service/tests/unit/search-utils.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { SearchUtils } from '../../dist/analytics/search/utils/utils.js'; + +describe('SearchUtils.comparePath', () => { + it('returns -1 when the first differing element of a is smaller', () => { + assert.equal(SearchUtils.comparePath([1, 2, 3], [1, 5, 0]), -1); + }); + + it('returns 1 when the first differing element of a is larger', () => { + assert.equal(SearchUtils.comparePath([1, 9], [1, 2, 3]), 1); + }); + + it('compares at the first index that differs (index 0)', () => { + assert.equal(SearchUtils.comparePath([2], [1, 1, 1]), 1); + assert.equal(SearchUtils.comparePath([0, 9, 9], [1]), -1); + }); + + it('returns 1 when a is a strict prefix-longer of b (equal prefix, a longer)', () => { + assert.equal(SearchUtils.comparePath([1, 2, 3], [1, 2]), 1); + }); + + it('returns -1 when b is longer and shares the whole prefix', () => { + assert.equal(SearchUtils.comparePath([1, 2], [1, 2, 3]), -1); + }); + + it('returns -1 for two identical paths (length tie falls through to length compare)', () => { + assert.equal(SearchUtils.comparePath([4, 5, 6], [4, 5, 6]), -1); + }); + + it('returns -1 for two empty paths', () => { + assert.equal(SearchUtils.comparePath([], []), -1); + }); + + it('returns 1 when a is non-empty and b is empty', () => { + assert.equal(SearchUtils.comparePath([0], []), 1); + }); + + it('returns -1 when a is empty and b is non-empty', () => { + assert.equal(SearchUtils.comparePath([], [0]), -1); + }); + + it('stops at the first mismatch even if later elements would flip the result', () => { + assert.equal(SearchUtils.comparePath([1, 0, 100], [0, 100, 100]), 1); + }); +}); + +describe('SearchUtils.calcTotalRates', () => { + it('returns 0 for empty rates', () => { + assert.equal(SearchUtils.calcTotalRates([], []), 0); + }); + + it('returns 0 when rates and coefficients differ in length', () => { + assert.equal(SearchUtils.calcTotalRates([10, 20], [1]), 0); + }); + + it('computes a simple unit-weighted average', () => { + assert.equal(SearchUtils.calcTotalRates([10, 20, 30], [1, 1, 1]), 20); + }); + + it('weights rates by their coefficients', () => { + assert.equal(SearchUtils.calcTotalRates([100, 0], [3, 1]), 75); + }); + + it('floors the weighted average', () => { + assert.equal(SearchUtils.calcTotalRates([10, 11], [1, 1]), 10); + }); + + it('handles a single rate/coefficient pair', () => { + assert.equal(SearchUtils.calcTotalRates([42], [5]), 42); + }); + + it('a zero coefficient excludes a rate from both numerator and denominator', () => { + assert.equal(SearchUtils.calcTotalRates([100, 50], [0, 2]), 50); + }); + + it('yields NaN when all coefficients are zero (divide by zero length)', () => { + const result = SearchUtils.calcTotalRates([10, 20], [0, 0]); + assert.ok(Number.isNaN(result)); + }); +}); diff --git a/guardian-service/tests/unit/utils-formula.test.mjs b/guardian-service/tests/unit/utils-formula.test.mjs new file mode 100644 index 0000000000..85389b1cc2 --- /dev/null +++ b/guardian-service/tests/unit/utils-formula.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { initMathjs } from '../../dist/utils/formula.js'; + +describe('initMathjs', () => { + it('returns a math engine with an evaluate function', () => { + const engine = initMathjs(); + assert.equal(typeof engine.evaluate, 'function'); + }); + + it('is a singleton across calls', () => { + assert.equal(initMathjs(), initMathjs()); + }); + + it('evaluates basic arithmetic', () => { + assert.equal(initMathjs().evaluate('1 + 2 * 3'), 7); + }); + + it('respects operator precedence and parentheses', () => { + assert.equal(initMathjs().evaluate('(1 + 2) * 3'), 9); + }); + + it('exposes formulajs SUM', () => { + assert.equal(initMathjs().evaluate('SUM(1, 2, 3, 4)'), 10); + }); + + it('exposes formulajs AVERAGE', () => { + assert.equal(initMathjs().evaluate('AVERAGE(2, 4, 6)'), 4); + }); + + it('exposes formulajs MAX and MIN', () => { + const engine = initMathjs(); + assert.equal(engine.evaluate('MAX(3, 7, 1)'), 7); + assert.equal(engine.evaluate('MIN(3, 7, 1)'), 1); + }); + + it('overrides equal to use loose equality', () => { + assert.equal(initMathjs().evaluate('equal(1, 1)'), true); + }); + + it('keeps mathjs PI excluded from formulajs override', () => { + const pi = initMathjs().evaluate('PI'); + assert.ok(Math.abs(pi - Math.PI) < 1e-9); + }); + + it('evaluates nested formula functions', () => { + assert.equal(initMathjs().evaluate('SUM(1, 2) + AVERAGE(2, 4)'), 6); + }); + + it('evaluates power and modulo operators', () => { + const engine = initMathjs(); + assert.equal(engine.evaluate('2 ^ 3'), 8); + assert.equal(engine.evaluate('10 mod 3'), 1); + }); + + it('exposes formulajs ROUND', () => { + assert.equal(initMathjs().evaluate('ROUND(3.14159, 2)'), 3.14); + }); + + it('evaluates COUNT over a list of values', () => { + assert.equal(initMathjs().evaluate('COUNT(1, 2, 3, 4)'), 4); + }); +}); diff --git a/guardian-service/tests/unit/w2-artifact-file-event-models.test.mjs b/guardian-service/tests/unit/w2-artifact-file-event-models.test.mjs new file mode 100644 index 0000000000..ae6ab481d8 --- /dev/null +++ b/guardian-service/tests/unit/w2-artifact-file-event-models.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { ArtifactModel } from '../../dist/analytics/compare/models/artifact.model.js'; +import { FileModel } from '../../dist/analytics/compare/models/file.model.js'; +import { EventModel } from '../../dist/analytics/compare/models/event.model.js'; + +const opts = (overrides = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All', ...overrides }); + +describe('ArtifactModel', () => { + const raw = (extra = {}) => ({ name: 'doc.pdf', uuid: 'u-1', type: 'pdf', extention: 'pdf', ...extra }); + + it('maps json (note extention->extension typo source key)', () => { + const a = new ArtifactModel(raw()); + assert.equal(a.name, 'doc.pdf'); + assert.equal(a.uuid, 'u-1'); + assert.equal(a.type, 'pdf'); + assert.equal(a.extension, 'pdf'); + }); + + it('key getter is always null', () => { + assert.equal(new ArtifactModel(raw()).key, null); + }); + + it('weight undefined before update', () => { + assert.equal(new ArtifactModel(raw()).weight, undefined); + }); + + it('update with propLvl=All sets a non-empty weight', () => { + const a = new ArtifactModel(raw()); + a.update('filehash', opts({ propLvl: 'All' })); + assert.ok(a.weight.length > 0); + }); + + it('update with propLvl != All blanks the weight but keeps internal hash', () => { + const a = new ArtifactModel(raw()); + a.update('filehash', opts({ propLvl: 'None' })); + assert.equal(a.weight, ''); + assert.ok(a.toWeight(opts()).weight.length > 0); + }); + + it('equal compares the internal hash (independent of propLvl)', () => { + const a = new ArtifactModel(raw()); + const b = new ArtifactModel(raw()); + a.update('data', opts({ propLvl: 'None' })); + b.update('data', opts({ propLvl: 'All' })); + assert.equal(a.equal(b), true); + }); + + it('different file data yields unequal hashes', () => { + const a = new ArtifactModel(raw()); + const b = new ArtifactModel(raw()); + a.update('data1', opts()); + b.update('data2', opts()); + assert.equal(a.equal(b), false); + }); + + it('equalKey always true (keys are null)', () => { + assert.equal(new ArtifactModel(raw()).equalKey(new ArtifactModel(raw({ name: 'other' }))), true); + }); + + it('toObject includes uuid/name/type/extension/weight', () => { + const a = new ArtifactModel(raw()); + a.update('d', opts()); + const o = a.toObject(); + assert.equal(o.uuid, 'u-1'); + assert.equal(o.extension, 'pdf'); + assert.ok('weight' in o); + }); +}); + +describe('FileModel', () => { + const raw = (extra = {}) => ({ uuid: 'f-1', data: 'payload', ...extra }); + + it('stores uuid and a sha256 of the data', () => { + const f = new FileModel(raw(), opts()); + assert.equal(f.uuid, 'f-1'); + assert.equal(typeof f.data, 'string'); + assert.ok(f.data.length > 0); + }); + + it('computes a weight on construction', () => { + const f = new FileModel(raw(), opts()); + assert.ok(f.hash(opts()).length > 0); + }); + + it('same uuid+data -> equal', () => { + const a = new FileModel(raw(), opts()); + const b = new FileModel(raw(), opts()); + assert.equal(a.equal(b), true); + }); + + it('same uuid different data -> unequal', () => { + const a = new FileModel(raw({ data: 'x' }), opts()); + const b = new FileModel(raw({ data: 'y' }), opts()); + assert.equal(a.equal(b), false); + }); + + it('toObject returns {uuid, data}', () => { + const f = new FileModel(raw(), opts()); + const o = f.toObject(); + assert.equal(o.uuid, 'f-1'); + assert.ok('data' in o); + }); + + it('fromEntity builds a model', () => { + const f = FileModel.fromEntity(raw(), opts()); + assert.ok(f instanceof FileModel); + }); + + it('fromEntity throws on null', () => { + assert.throws(() => FileModel.fromEntity(null, opts()), /Unknown artifact/); + }); +}); + +describe('EventModel', () => { + const raw = (extra = {}) => ({ + actor: 'OWNER', disabled: false, input: 'RunEvent', output: 'RefreshEvent', + source: 'blockA', target: 'blockB', ...extra, + }); + + it('copies all json fields', () => { + const e = new EventModel(raw()); + assert.equal(e.actor, 'OWNER'); + assert.equal(e.source, 'blockA'); + assert.equal(e.target, 'blockB'); + }); + + it('key getter is null; weight undefined before update', () => { + const e = new EventModel(raw()); + assert.equal(e.key, null); + assert.equal(e.weight, undefined); + }); + + it('eventLvl=Simple uses source/target tags as endpoints', () => { + const e = new EventModel(raw()); + e.update({}, opts({ eventLvl: 'Simple' })); + const o = e.toObject(); + assert.equal(o.startWeight, 'blockA'); + assert.equal(o.endWeight, 'blockB'); + }); + + it('eventLvl=None uses "undefined" endpoints and blanks the weight', () => { + const e = new EventModel(raw()); + e.update({}, opts({ eventLvl: 'None' })); + assert.equal(e.weight, ''); + const o = e.toObject(); + assert.equal(o.startWeight, 'undefined'); + }); + + it('eventLvl=All resolves block weights from the map', () => { + const e = new EventModel(raw()); + const map = { + blockA: { getWeight: () => 'wA' }, + blockB: { getWeight: () => 'wB' }, + }; + e.update(map, opts({ eventLvl: 'All' })); + const o = e.toObject(); + assert.equal(o.startWeight, 'wA'); + assert.equal(o.endWeight, 'wB'); + assert.ok(e.weight.length > 0); + }); + + it('eventLvl=All with missing blocks falls back to "undefined"', () => { + const e = new EventModel(raw()); + e.update({}, opts({ eventLvl: 'All' })); + const o = e.toObject(); + assert.equal(o.startWeight, 'undefined'); + assert.equal(o.endWeight, 'undefined'); + }); + + it('equal compares the internal hash', () => { + const a = new EventModel(raw()); + const b = new EventModel(raw()); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + assert.equal(a.equal(b), true); + }); + + it('different actors produce unequal events', () => { + const a = new EventModel(raw({ actor: 'OWNER' })); + const b = new EventModel(raw({ actor: 'ISSUER' })); + a.update({}, opts({ eventLvl: 'Simple' })); + b.update({}, opts({ eventLvl: 'Simple' })); + assert.equal(a.equal(b), false); + }); + + it('toWeight returns the internal hash even when eventLvl blanks the public weight', () => { + const e = new EventModel(raw()); + e.update({}, opts({ eventLvl: 'None' })); + assert.equal(e.weight, ''); + assert.ok(e.toWeight(opts()).weight.length > 0); + }); + + it('toObject carries the documented event shape', () => { + const e = new EventModel(raw()); + e.update({}, opts({ eventLvl: 'Simple' })); + const o = e.toObject(); + for (const k of ['actor', 'source', 'target', 'input', 'output', 'disabled', 'weight', 'startWeight', 'endWeight']) { + assert.ok(k in o, `missing ${k}`); + } + }); +}); diff --git a/guardian-service/tests/unit/w2-compare-utils-branches.test.mjs b/guardian-service/tests/unit/w2-compare-utils-branches.test.mjs new file mode 100644 index 0000000000..9e7dfe2cb0 --- /dev/null +++ b/guardian-service/tests/unit/w2-compare-utils-branches.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { CompareUtils } from '../../dist/analytics/compare/utils/utils.js'; +import { Hash3, Sha256 } from '../../dist/analytics/compare/hash/utils.js'; +import { BlockModel } from '../../dist/analytics/compare/models/block.model.js'; +import { BlockToolModel } from '../../dist/analytics/compare/models/block-tool.model.js'; + +describe('CompareUtils.equal', () => { + it('delegates to item.equal when present', () => { + const a = { equal: (other) => other === 'TARGET' }; + assert.equal(CompareUtils.equal(a, 'TARGET'), true); + assert.equal(CompareUtils.equal(a, 'OTHER'), false); + }); + + it('falls back to identity when no equal method', () => { + assert.equal(CompareUtils.equal(5, 5), true); + assert.equal(CompareUtils.equal(5, 6), false); + }); + + it('object identity fallback', () => { + const x = {}; + assert.equal(CompareUtils.equal(x, x), true); + assert.equal(CompareUtils.equal({}, {}), false); + }); +}); + +describe('CompareUtils.mapping', () => { + it('appends a right-only entry for an unmatched item', () => { + const list = []; + CompareUtils.mapping(list, { equal: () => false }); + assert.equal(list.length, 1); + assert.equal(list[0].left, null); + }); + + it('attaches to an existing left when equal and right empty', () => { + const item = 'A'; + const left = { equal: (o) => o === 'A' }; + const list = [{ left, right: null }]; + CompareUtils.mapping(list, item); + assert.equal(list[0].right, 'A'); + assert.equal(list.length, 1); + }); + + it('does not overwrite an already-filled right slot', () => { + const left = { equal: () => true }; + const list = [{ left, right: 'OLD' }]; + CompareUtils.mapping(list, 'NEW'); + assert.equal(list[0].right, 'OLD'); + assert.equal(list.length, 2); + }); +}); + +describe('CompareUtils.calcRate', () => { + it('empty list returns 100', () => { + assert.equal(CompareUtils.calcRate([]), 100); + }); + + it('averages positive totalRates and floors', () => { + assert.equal(CompareUtils.calcRate([{ totalRate: 50 }, { totalRate: 75 }]), 62); + }); + + it('ignores non-positive totalRates in the sum', () => { + assert.equal(CompareUtils.calcRate([{ totalRate: 100 }, { totalRate: -1 }]), 50); + }); + + it('caps at 100', () => { + assert.equal(CompareUtils.calcRate([{ totalRate: 200 }]), 100); + }); + + it('treats all-negative as 0 (floor of 0/n), bounded to -1 minimum', () => { + assert.equal(CompareUtils.calcRate([{ totalRate: -5 }, { totalRate: -3 }]), 0); + }); +}); + +describe('CompareUtils.calcTotalRate / calcTotalRates', () => { + it('calcTotalRate averages varargs', () => { + assert.equal(CompareUtils.calcTotalRate(100, 50), 75); + }); + + it('calcTotalRate single value', () => { + assert.equal(CompareUtils.calcTotalRate(80), 80); + }); + + it('calcTotalRate floors fractional result', () => { + assert.equal(CompareUtils.calcTotalRate(100, 100, 50), 83); + }); + + it('calcTotalRates empty returns 100', () => { + assert.equal(CompareUtils.calcTotalRates([]), 100); + }); + + it('calcTotalRates averages and floors', () => { + assert.equal(CompareUtils.calcTotalRates([10, 20, 35]), 21); + }); +}); + +describe('CompareUtils.total', () => { + it('empty returns 100', () => { + assert.equal(CompareUtils.total([]), 100); + }); + + it('bins >99 to 100', () => { + assert.equal(CompareUtils.total([{ totalRate: 100 }]), 100); + }); + + it('bins 51..99 to 50', () => { + assert.equal(CompareUtils.total([{ totalRate: 60 }]), 50); + }); + + it('bins <=50 to 0', () => { + assert.equal(CompareUtils.total([{ totalRate: 40 }]), 0); + }); + + it('mixed bins average and floor', () => { + assert.equal(CompareUtils.total([{ totalRate: 100 }, { totalRate: 60 }, { totalRate: 10 }]), 50); + }); +}); + +describe('CompareUtils.compareSchemas', () => { + const schema = (cmpMap) => ({ compare: (other) => cmpMap.get(other) }); + + it('returns 0 when either side empty', () => { + assert.equal(CompareUtils.compareSchemas([], [{}]), 0); + assert.equal(CompareUtils.compareSchemas([{}], []), 0); + assert.equal(CompareUtils.compareSchemas(null, [{}]), 0); + }); + + it('single vs single delegates directly to compare', () => { + const b = {}; + const a = { compare: (x) => (x === b ? 88 : 0) }; + assert.equal(CompareUtils.compareSchemas([a], [b]), 88); + }); + + it('many-to-many returns the best-min match', () => { + const r1 = {}; + const r2 = {}; + const l1 = { compare: (x) => (x === r1 ? 90 : 10) }; + const l2 = { compare: (x) => (x === r2 ? 95 : 20) }; + const result = CompareUtils.compareSchemas([l1, l2], [r1, r2]); + assert.equal(result, 90); + }); +}); + +describe('CompareUtils.createBlockModel / createToolModel', () => { + it('builds a BlockToolModel for blockType tool', () => { + const m = CompareUtils.createBlockModel({ blockType: 'tool', tag: 't' }, 0); + assert.ok(m instanceof BlockToolModel); + }); + + it('builds a BlockModel with nested children', () => { + const json = { + blockType: 'interfaceContainerBlock', + tag: 'root', + children: [ + { blockType: 'buttonBlock', tag: 'c1' }, + { blockType: 'buttonBlock', tag: 'c2' }, + ], + }; + const m = CompareUtils.createBlockModel(json, 0); + assert.ok(m instanceof BlockModel); + assert.equal(m.children.length, 2); + }); + + it('createToolModel always builds a BlockModel root', () => { + const m = CompareUtils.createToolModel({ blockType: 'tool', tag: 'root', children: [{ blockType: 'buttonBlock', tag: 'x' }] }, 0); + assert.ok(m instanceof BlockModel); + assert.equal(m.children.length, 1); + }); + + it('block with no children array has zero children', () => { + const m = CompareUtils.createBlockModel({ blockType: 'buttonBlock', tag: 'x' }, 0); + assert.equal(m.children.length, 0); + }); +}); + +describe('CompareUtils hashes', () => { + it('aggregateHash is deterministic for the same args', () => { + assert.equal(CompareUtils.aggregateHash('a', 'b'), CompareUtils.aggregateHash('a', 'b')); + }); + + it('aggregateHash differs for different args', () => { + assert.notEqual(CompareUtils.aggregateHash('a', 'b'), CompareUtils.aggregateHash('b', 'a')); + }); + + it('sha256 returns a base58 string', () => { + const h = CompareUtils.sha256('hello'); + assert.equal(typeof h, 'string'); + assert.ok(h.length > 0); + }); + + it('sha256 deterministic', () => { + assert.equal(CompareUtils.sha256('x'), CompareUtils.sha256('x')); + }); +}); + +describe('Hash3', () => { + it('chains add/hash and returns string result', () => { + const h = new Hash3(); + const r = h.add('a').hash('b').result(); + assert.equal(typeof r, 'string'); + }); + + it('clear resets to a fresh empty-state hash', () => { + const a = new Hash3().add('x').result(); + const b = new Hash3().add('y').clear().add('x').result(); + assert.equal(a, b); + }); + + it('coerces non-strings via String()', () => { + const a = new Hash3().add(123).result(); + const b = new Hash3().add('123').result(); + assert.equal(a, b); + }); + + it('different input yields different hash', () => { + assert.notEqual(new Hash3().add('a').result(), new Hash3().add('b').result()); + }); + + it('static aggregate is deterministic', () => { + assert.equal(Hash3.aggregate('a', 'b'), Hash3.aggregate('a', 'b')); + }); +}); + +describe('Sha256', () => { + it('hash returns a string for input', () => { + assert.equal(typeof Sha256.hash('data'), 'string'); + }); + + it('hash tolerates empty/falsy input', () => { + assert.equal(typeof Sha256.hash(''), 'string'); + assert.equal(typeof Sha256.hash(null), 'string'); + }); + + it('base58 returns a non-empty encoded string', () => { + const r = Sha256.base58('payload'); + assert.equal(typeof r, 'string'); + assert.ok(r.length > 0); + }); + + it('base58 deterministic for the same input', () => { + assert.equal(Sha256.base58('p'), Sha256.base58('p')); + }); + + it('base58 returns empty string on bad input (catch branch)', () => { + assert.equal(Sha256.base58(undefined), ''); + }); +}); diff --git a/guardian-service/tests/unit/w2-field-model-branches.test.mjs b/guardian-service/tests/unit/w2-field-model-branches.test.mjs new file mode 100644 index 0000000000..e2466b9369 --- /dev/null +++ b/guardian-service/tests/unit/w2-field-model-branches.test.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { FieldModel } from '../../dist/analytics/compare/models/field.model.js'; + +const ALL = 'All'; +const NONE = 'None'; +const SIMPLE = 'Simple'; +const DEFAULT = 'Default'; + +const opts = (overrides = {}) => ({ + propLvl: ALL, + keyLvl: DEFAULT, + idLvl: ALL, + ...overrides, +}); + +const stringProp = (extra = {}) => ({ type: 'string', title: 'Title', description: 'Desc', ...extra }); + +describe('FieldModel construction basics', () => { + it('defaults title and description to the name when absent', () => { + const f = new FieldModel('amount', { type: 'number' }, true); + assert.equal(f.title, 'amount'); + assert.equal(f.description, 'amount'); + }); + + it('uses provided title and description', () => { + const f = new FieldModel('amount', stringProp(), false); + assert.equal(f.title, 'Title'); + assert.equal(f.description, 'Desc'); + }); + + it('detects array type and unwraps items', () => { + const f = new FieldModel('list', { type: 'array', items: { type: 'string' } }, false); + assert.equal(f.isArray, true); + assert.equal(f.type, 'string'); + }); + + it('detects ref type ($ref without type)', () => { + const f = new FieldModel('sub', { $ref: '#schema1' }, false); + assert.equal(f.isRef, true); + assert.equal(f.type, '#schema1'); + }); + + it('non-ref with $ref AND type keeps the primitive type', () => { + const f = new FieldModel('x', { type: 'string', $ref: '#s' }, false); + assert.equal(f.isRef, false); + assert.equal(f.type, 'string'); + assert.equal(f.remoteLink, '#s'); + }); + + it('selects the first oneOf entry', () => { + const f = new FieldModel('x', { oneOf: [{ type: 'string', title: 'one' }, { type: 'number' }] }, false); + assert.equal(f.type, 'string'); + assert.equal(f.title, 'one'); + }); + + it('captures format, pattern and enum for non-ref fields', () => { + const f = new FieldModel('x', { type: 'string', format: 'date', pattern: '\\d+', enum: ['a', 'b'] }, false); + assert.equal(f.format, 'date'); + assert.equal(f.pattern, '\\d+'); + assert.deepEqual(f.enum, ['a', 'b']); + }); + + it('readOnly flag is captured', () => { + const f = new FieldModel('x', { type: 'string', readOnly: true }, false); + assert.equal(f.readOnly, true); + }); + + it('order is -1 and index null when no comment order', () => { + const f = new FieldModel('x', { type: 'string' }, false); + assert.equal(f.order, -1); + assert.equal(f.index, null); + }); +}); + +describe('FieldModel.parseFieldComment via $comment', () => { + it('extracts unit/unitSystem/customType/property from JSON $comment', () => { + const comment = JSON.stringify({ unit: 'kg', unitSystem: 'metric', customType: 'geo', property: 'p1' }); + const f = new FieldModel('x', { type: 'string', $comment: comment }, false); + assert.equal(f.unit, 'kg'); + assert.equal(f.unitSystem, 'metric'); + assert.equal(f.customType, 'geo'); + assert.equal(f.property, 'p1'); + }); + + it('sets order/index from orderPosition in comment', () => { + const comment = JSON.stringify({ orderPosition: 5 }); + const f = new FieldModel('x', { type: 'string', $comment: comment }, false); + assert.equal(f.order, 5); + assert.equal(f.index, 5); + }); + + it('negative orderPosition keeps order -1', () => { + const comment = JSON.stringify({ orderPosition: -3 }); + const f = new FieldModel('x', { type: 'string', $comment: comment }, false); + assert.equal(f.order, -1); + }); + + it('malformed comment is ignored gracefully', () => { + const f = new FieldModel('x', { type: 'string', $comment: 'not-json{' }, false); + assert.equal(f.unit, null); + assert.equal(f.order, -1); + }); +}); + +describe('FieldModel.getPropList', () => { + it('includes name/title/description/type/required/multiple/readOnly/order', () => { + const f = new FieldModel('amount', stringProp(), true); + const names = f.getPropList().map((p) => p.name); + for (const n of ['name', 'title', 'description', 'required', 'multiple', 'type', 'readOnly', 'order']) { + assert.ok(names.includes(n), `expected ${n}`); + } + }); + + it('emits enum array property plus indexed entries', () => { + const f = new FieldModel('x', { type: 'string', enum: ['a', 'b'] }, false); + const list = f.getPropList(); + const enumProp = list.find((p) => p.name === 'enum'); + assert.ok(enumProp); + assert.equal(enumProp.value, 2); + const idx0 = list.find((p) => p.path === 'enum.0'); + const idx1 = list.find((p) => p.path === 'enum.1'); + assert.equal(idx0.value, 'a'); + assert.equal(idx1.value, 'b'); + }); + + it('empty enum yields array prop with 0 and no index entries', () => { + const f = new FieldModel('x', { type: 'string', enum: [] }, false); + const list = f.getPropList(); + const enumProp = list.find((p) => p.name === 'enum'); + assert.equal(enumProp.value, 0); + assert.equal(list.filter((p) => p.path && p.path.startsWith('enum.')).length, 0); + }); + + it('type emitted as a UUID-typed property', () => { + const f = new FieldModel('x', { type: 'string' }, false); + const typeProp = f.getPropList().find((p) => p.name === 'type'); + assert.equal(typeProp.type, 'uuid'); + }); + + it('skips unit/unitSystem/customType/format/pattern when absent', () => { + const f = new FieldModel('x', { type: 'string' }, false); + const names = f.getPropList().map((p) => p.name); + assert.equal(names.includes('unit'), false); + assert.equal(names.includes('format'), false); + assert.equal(names.includes('pattern'), false); + }); +}); + +describe('FieldModel.calcBaseWeight + getWeight', () => { + it('getWeight undefined before update', () => { + const f = new FieldModel('x', stringProp(), false); + assert.equal(f.getWeight(), undefined); + }); + + it('produces weights after update; getWeight returns top weight', () => { + const f = new FieldModel('x', stringProp(), false); + f.update(opts()); + assert.ok(typeof f.getWeight() === 'string'); + assert.ok(f.getWeights().length > 0); + assert.equal(f.maxWeight(), f.getWeights().length); + }); + + it('propLvl=None yields only the name-based weight slot', () => { + const f = new FieldModel('x', stringProp(), false); + f.update(opts({ propLvl: NONE })); + assert.equal(f.getWeights().length, 1); + }); + + it('checkWeight reflects available weight slots', () => { + const f = new FieldModel('x', stringProp(), false); + f.update(opts()); + assert.equal(f.checkWeight(0), true); + assert.equal(f.checkWeight(999), false); + }); + + it('identical fields produce identical top weight', () => { + const a = new FieldModel('x', stringProp(), false); + const b = new FieldModel('x', stringProp(), false); + a.update(opts()); + b.update(opts()); + assert.equal(a.getWeight(), b.getWeight()); + }); + + it('different descriptions produce different deep weights', () => { + const a = new FieldModel('x', stringProp({ description: 'AAA' }), false); + const b = new FieldModel('x', stringProp({ description: 'BBB' }), false); + a.update(opts()); + b.update(opts()); + assert.notEqual(a.getWeight(), b.getWeight()); + }); +}); + +describe('FieldModel.equal', () => { + it('falls back to name comparison when no weights computed', () => { + const a = new FieldModel('x', stringProp(), false); + const b = new FieldModel('x', stringProp(), false); + assert.equal(a.equal(b), true); + const c = new FieldModel('y', stringProp(), false); + assert.equal(a.equal(c), false); + }); + + it('compares top weight at iteration 0', () => { + const a = new FieldModel('x', stringProp(), false); + const b = new FieldModel('x', stringProp(), false); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b, 0), true); + }); +}); + +describe('FieldModel.equalKey', () => { + it('field keys are null so any two fields share a key', () => { + const a = new FieldModel('x', stringProp(), false); + const b = new FieldModel('y', stringProp(), false); + assert.equal(a.equalKey(b), true); + }); +}); + +describe('FieldModel.toObject', () => { + it('round-trips the documented field shape', () => { + const f = new FieldModel('amount', stringProp({ format: 'date' }), true); + const o = f.toObject(); + assert.equal(o.name, 'amount'); + assert.equal(o.title, 'Title'); + assert.equal(o.format, 'date'); + assert.equal(o.required, true); + assert.equal(o.order, -1); + }); +}); + +describe('FieldModel.getField + sub-schema', () => { + it('returns self for a name match with no dot', () => { + const f = new FieldModel('amount', stringProp(), false); + assert.equal(f.getField('amount'), f); + }); + + it('returns null for a non-matching name', () => { + const f = new FieldModel('amount', stringProp(), false); + assert.equal(f.getField('other'), null); + }); + + it('returns null for empty path', () => { + const f = new FieldModel('amount', stringProp(), false); + assert.equal(f.getField(''), null); + }); + + it('descends into sub-schema for dotted paths', () => { + const parent = new FieldModel('a', { $ref: '#s' }, false); + const child = new FieldModel('b', stringProp(), false); + parent.setSubSchema({ fields: [child], update: () => {}, getField: (p) => (p === 'b' ? child : null) }); + assert.equal(parent.getField('a.b'), child); + }); + + it('children getter is empty without sub-schema', () => { + const f = new FieldModel('a', stringProp(), false); + assert.deepEqual(f.children, []); + }); + + it('children getter returns sub-schema fields', () => { + const child = new FieldModel('b', stringProp(), false); + const f = new FieldModel('a', { $ref: '#s' }, false); + f.setSubSchema({ fields: [child], update: () => {} }); + assert.deepEqual(f.children, [child]); + }); +}); + +describe('FieldModel.setCondition + condition getter', () => { + it('condition is undefined until set', () => { + const f = new FieldModel('a', stringProp(), false); + assert.equal(f.condition, undefined); + }); + + it('returns the set condition', () => { + const f = new FieldModel('a', stringProp(), false); + f.setCondition('cond-x'); + assert.equal(f.condition, 'cond-x'); + }); + + it('key getter is always null', () => { + const f = new FieldModel('a', stringProp(), false); + assert.equal(f.key, null); + }); +}); diff --git a/guardian-service/tests/unit/w2-import-and-pure-helpers.test.mjs b/guardian-service/tests/unit/w2-import-and-pure-helpers.test.mjs new file mode 100644 index 0000000000..04708b1895 --- /dev/null +++ b/guardian-service/tests/unit/w2-import-and-pure-helpers.test.mjs @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import { findSubTools, importToolErrors } from '../../dist/helpers/import-helpers/tool/tool-import-helper.js'; +import { SchemaCache, checkForCircularDependency } from '../../dist/helpers/import-helpers/common/load-helper.js'; +import { onlyUnique, fixSchemaDefsOnImport } from '../../dist/helpers/import-helpers/schema/schema-helper.js'; +import { publishConfig, getSubject, uniqueDocuments } from '../../dist/api/helpers/policy-statistics-helpers.js'; +import { publishLabelConfig } from '../../dist/api/helpers/policy-labels-helpers.js'; +import { publishRuleConfig } from '../../dist/api/helpers/schema-rules-helpers.js'; + +describe('findSubTools', () => { + it('does nothing for null block', () => { + const set = new Set(); + findSubTools(null, set); + assert.equal(set.size, 0); + }); + + it('adds a nested tool messageId', () => { + const set = new Set(); + findSubTools({ blockType: 'interfaceContainerBlock', children: [{ blockType: 'tool', messageId: 'm1' }] }, set); + assert.deepEqual([...set], ['m1']); + }); + + it('skips the root tool when isRoot=true and recurses children', () => { + const set = new Set(); + findSubTools({ blockType: 'tool', messageId: 'root', children: [{ blockType: 'tool', messageId: 'm2' }] }, set, true); + assert.deepEqual([...set], ['m2']); + }); + + it('ignores a tool without a string messageId', () => { + const set = new Set(); + findSubTools({ blockType: 'tool', messageId: 123 }, set); + assert.equal(set.size, 0); + }); + + it('dedups repeated messageIds via the Set', () => { + const set = new Set(); + findSubTools({ + blockType: 'container', + children: [ + { blockType: 'tool', messageId: 'dup' }, + { blockType: 'tool', messageId: 'dup' }, + ], + }, set); + assert.equal(set.size, 1); + }); + + it('recurses deeply through nested containers', () => { + const set = new Set(); + findSubTools({ + blockType: 'a', + children: [{ blockType: 'b', children: [{ blockType: 'tool', messageId: 'deep' }] }], + }, set); + assert.deepEqual([...set], ['deep']); + }); + + it('handles a leaf block with no children', () => { + const set = new Set(); + findSubTools({ blockType: 'buttonBlock' }, set); + assert.equal(set.size, 0); + }); +}); + +describe('importToolErrors', () => { + it('returns the base prefix for an empty list', () => { + assert.equal(importToolErrors([]), 'Failed to import components:'); + }); + + it('groups schema errors', () => { + const msg = importToolErrors([{ type: 'schema', name: 'S1' }]); + assert.ok(msg.includes('schemas: ["S1"]')); + }); + + it('groups tool errors', () => { + const msg = importToolErrors([{ type: 'tool', name: 'T1' }]); + assert.ok(msg.includes('tools: ["T1"]')); + }); + + it('classifies unknown types as others', () => { + const msg = importToolErrors([{ type: 'mystery', name: 'X' }]); + assert.ok(msg.includes('others: ["X"]')); + }); + + it('combines all three categories in order', () => { + const msg = importToolErrors([ + { type: 'schema', name: 'S' }, + { type: 'tool', name: 'T' }, + { type: 'weird', name: 'O' }, + ]); + assert.ok(msg.indexOf('schemas') < msg.indexOf('tools')); + assert.ok(msg.indexOf('tools') < msg.indexOf('others')); + }); + + it('lists multiple names within a category', () => { + const msg = importToolErrors([{ type: 'schema', name: 'A' }, { type: 'schema', name: 'B' }]); + assert.ok(msg.includes('["A","B"]')); + }); +}); + +describe('checkForCircularDependency', () => { + it('true when $id appears in $defs', () => { + assert.equal(checkForCircularDependency({ document: { $id: '#x', $defs: { '#x': {} } } }), true); + }); + + it('false when $id not in $defs', () => { + assert.equal(checkForCircularDependency({ document: { $id: '#x', $defs: { '#y': {} } } }), false); + }); + + it('false when no $defs', () => { + assert.equal(checkForCircularDependency({ document: { $id: '#x' } }), false); + }); + + it('false when document missing', () => { + assert.equal(checkForCircularDependency({}), false); + }); +}); + +describe('onlyUnique (Array.filter predicate)', () => { + it('removes duplicate primitives keeping first occurrence', () => { + assert.deepEqual([1, 1, 2, 3, 3].filter(onlyUnique), [1, 2, 3]); + }); + + it('is a no-op on an already-unique array', () => { + assert.deepEqual(['a', 'b'].filter(onlyUnique), ['a', 'b']); + }); + + it('returns empty for empty input', () => { + assert.deepEqual([].filter(onlyUnique), []); + }); + + it('keeps the first index for repeated values', () => { + assert.equal(onlyUnique('z', 0, ['z', 'z']), true); + assert.equal(onlyUnique('z', 1, ['z', 'z']), false); + }); +}); + +describe('SchemaCache', () => { + it('round-trips a schema by id', () => { + SchemaCache.setSchema('w2-id', { a: 1 }); + assert.equal(SchemaCache.hasSchema('w2-id'), true); + assert.deepEqual(SchemaCache.getSchema('w2-id'), { a: 1 }); + }); + + it('getSchema returns null for an unknown id', () => { + assert.equal(SchemaCache.getSchema('w2-missing'), null); + }); + + it('hasSchema false for unknown id', () => { + assert.equal(SchemaCache.hasSchema('w2-nope'), false); + }); + + it('setSchema swallows circular structures (catch branch)', () => { + const circ = {}; + circ.self = circ; + SchemaCache.setSchema('w2-circ', circ); + assert.equal(SchemaCache.hasSchema('w2-circ'), false); + }); +}); + +describe('fixSchemaDefsOnImport', () => { + const makeSchema = (iri, fields) => ({ + iri, + fields, + conditions: [], + update(f) { this.fields = f; }, + updateRefs() {}, + }); + + it('returns true and caches when iri already mapped', () => { + const map = { '#a': {} }; + assert.equal(fixSchemaDefsOnImport('#a', [], map), true); + }); + + it('returns false when iri not found among schemas', () => { + assert.equal(fixSchemaDefsOnImport('#missing', [makeSchema('#a', [])], {}), false); + }); + + it('valid leaf schema with no ref fields returns true and maps', () => { + const map = {}; + const s = makeSchema('#a', [{ isRef: false, type: 'string' }]); + assert.equal(fixSchemaDefsOnImport('#a', [s], map), true); + assert.equal(map['#a'], s); + }); + + it('resolves ref fields recursively when target exists', () => { + const child = makeSchema('#child', [{ isRef: false, type: 'number' }]); + const parent = makeSchema('#parent', [{ isRef: true, type: '#child' }]); + const map = {}; + assert.equal(fixSchemaDefsOnImport('#parent', [parent, child], map), true); + assert.ok(map['#parent']); + assert.ok(map['#child']); + }); + + it('nulls a dangling ref field type and returns false', () => { + const field = { isRef: true, type: '#gone' }; + const parent = makeSchema('#parent', [field]); + const map = {}; + assert.equal(fixSchemaDefsOnImport('#parent', [parent], map), false); + assert.equal(field.type, null); + }); +}); + +describe('publishConfig (statistics)', () => { + it('filters rules to schemas referenced by variables', () => { + const data = { + rules: [{ schemaId: 's1' }, { schemaId: 's2' }], + variables: [{ schemaId: 's1' }], + }; + const out = publishConfig(data); + assert.deepEqual(out.rules, [{ schemaId: 's1' }]); + }); + + it('drops all rules when no variables', () => { + const out = publishConfig({ rules: [{ schemaId: 's1' }], variables: [] }); + assert.deepEqual(out.rules, []); + }); + + it('tolerates missing rules/variables', () => { + const out = publishConfig({}); + assert.deepEqual(out.rules, []); + }); + + it('keeps rules referenced by multiple variables once', () => { + const out = publishConfig({ + rules: [{ schemaId: 's1' }, { schemaId: 's3' }], + variables: [{ schemaId: 's1' }, { schemaId: 's1' }], + }); + assert.deepEqual(out.rules, [{ schemaId: 's1' }]); + }); +}); + +describe('getSubject', () => { + it('returns the credentialSubject object when it has an id', () => { + const doc = { document: { credentialSubject: { id: 'x', v: 1 } } }; + assert.deepEqual(getSubject(doc), { id: 'x', v: 1 }); + }); + + it('uses the first element of an array credentialSubject', () => { + const doc = { document: { credentialSubject: [{ id: 'first' }, { id: 'second' }] } }; + assert.equal(getSubject(doc).id, 'first'); + }); + + it('returns the document itself when no credentialSubject id', () => { + const doc = { document: { credentialSubject: { v: 1 } } }; + assert.equal(getSubject(doc), doc); + }); + + it('returns the document when no document field', () => { + const doc = { other: true }; + assert.equal(getSubject(doc), doc); + }); +}); + +describe('uniqueDocuments', () => { + it('returns documents grouped/deduped by schema hash', () => { + const docs = [ + { schema: 'S', messageId: 'm1', relationships: [] }, + { schema: 'S', messageId: 'm2', relationships: [] }, + ]; + const out = uniqueDocuments(docs); + assert.equal(out.length, 2); + }); + + it('drops a doc that is a relationship target of another in the same schema', () => { + const docs = [ + { schema: 'S', messageId: 'm1', relationships: ['m2'] }, + { schema: 'S', messageId: 'm2', relationships: [] }, + ]; + const out = uniqueDocuments(docs); + const ids = out.map((d) => d.messageId); + assert.deepEqual(ids, ['m1']); + }); + + it('keeps docs of different schemas independent', () => { + const docs = [ + { schema: 'A', messageId: 'a1', relationships: [] }, + { schema: 'B', messageId: 'b1', relationships: [] }, + ]; + assert.equal(uniqueDocuments(docs).length, 2); + }); + + it('handles empty input', () => { + assert.deepEqual(uniqueDocuments([]), []); + }); +}); + +describe('publish*Config passthroughs', () => { + it('publishLabelConfig returns its argument unchanged', () => { + const data = { x: 1 }; + assert.equal(publishLabelConfig(data), data); + }); + + it('publishRuleConfig returns a config object', () => { + const out = publishRuleConfig({ rules: [], variables: [] }); + assert.equal(typeof out, 'object'); + }); +}); diff --git a/guardian-service/tests/unit/w2-model-weights.test.mjs b/guardian-service/tests/unit/w2-model-weights.test.mjs new file mode 100644 index 0000000000..cf55ac334c --- /dev/null +++ b/guardian-service/tests/unit/w2-model-weights.test.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { TokenModel } from '../../dist/analytics/compare/models/token.model.js'; +import { RoleModel } from '../../dist/analytics/compare/models/role.model.js'; +import { GroupModel } from '../../dist/analytics/compare/models/group.model.js'; +import { VariableModel } from '../../dist/analytics/compare/models/variable.model.js'; +import { TopicModel } from '../../dist/analytics/compare/models/topic.model.js'; + +const opts = (overrides = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', ...overrides }); + +const rawToken = (extra = {}) => ({ + id: 'id-1', + tokenId: '0.0.1', + tokenName: 'Carbon', + tokenSymbol: 'CO2', + tokenType: 'fungible', + decimals: 2, + initialSupply: 100, + enableAdmin: true, + enableFreeze: false, + enableKYC: true, + enableWipe: false, + ...extra, +}); + +describe('TokenModel', () => { + it('copies all token fields', () => { + const t = new TokenModel(rawToken(), opts()); + assert.equal(t.tokenId, '0.0.1'); + assert.equal(t.tokenName, 'Carbon'); + assert.equal(t.decimals, 2); + assert.equal(t.enableKYC, true); + }); + + it('computes a non-empty weight on construction', () => { + const t = new TokenModel(rawToken(), opts()); + assert.ok(t.hash(opts()).length > 0); + }); + + it('equal compares tokenId only', () => { + const a = new TokenModel(rawToken(), opts()); + const b = new TokenModel(rawToken({ tokenName: 'Other' }), opts()); + assert.equal(a.equal(b), true); + const c = new TokenModel(rawToken({ tokenId: '0.0.2' }), opts()); + assert.equal(a.equal(c), false); + }); + + it('equalKey compares tokenId', () => { + const a = new TokenModel(rawToken(), opts()); + const c = new TokenModel(rawToken({ tokenId: '0.0.9' }), opts()); + assert.equal(a.equalKey(c), false); + }); + + it('idLvl=All includes tokenId in the hash; idLvl=None excludes it', () => { + const all = new TokenModel(rawToken(), opts({ idLvl: 'All' })); + const none = new TokenModel(rawToken(), opts({ idLvl: 'None' })); + assert.notEqual(all.hash(opts()), none.hash(opts())); + }); + + it('two tokens differing only in tokenId share hash when idLvl=None', () => { + const a = new TokenModel(rawToken({ tokenId: 'A' }), opts({ idLvl: 'None' })); + const b = new TokenModel(rawToken({ tokenId: 'B' }), opts({ idLvl: 'None' })); + assert.equal(a.hash(opts()), b.hash(opts())); + }); + + it('toObject round-trips the documented shape', () => { + const t = new TokenModel(rawToken(), opts()); + const o = t.toObject(); + assert.equal(o.id, 'id-1'); + assert.equal(o.tokenSymbol, 'CO2'); + assert.equal(o.enableWipe, false); + }); + + it('toWeight returns the computed weight', () => { + const t = new TokenModel(rawToken(), opts()); + assert.equal(t.toWeight(opts()).weight, t.hash(opts())); + }); + + it('fromEntity builds and updates a model', () => { + const t = TokenModel.fromEntity(rawToken(), opts()); + assert.ok(t instanceof TokenModel); + assert.equal(t.tokenId, '0.0.1'); + }); + + it('fromEntity throws on falsy input', () => { + assert.throws(() => TokenModel.fromEntity(null, opts()), /Unknown token/); + }); +}); + +describe('RoleModel weight accessors', () => { + it('getWeight(type) reads named weight after update', () => { + const r = new RoleModel('Issuer'); + r.update(opts()); + assert.equal(r.getWeight('ROLE_LVL_0'), r.getWeight()); + }); + + it('getWeights returns array; maxWeight matches its length', () => { + const r = new RoleModel('Issuer'); + r.update(opts()); + assert.equal(r.maxWeight(), r.getWeights().length); + assert.equal(r.maxWeight(), 1); + }); + + it('maxWeight is 0 before update', () => { + const r = new RoleModel('Issuer'); + assert.equal(r.maxWeight(), 0); + }); + + it('getPropList returns the lone name property', () => { + const r = new RoleModel('Issuer'); + const list = r.getPropList(); + assert.equal(list.length, 1); + assert.equal(list[0].name, 'name'); + assert.equal(list[0].value, 'Issuer'); + }); + + it('equal with explicit index reads that weight slot', () => { + const a = new RoleModel('X'); + const b = new RoleModel('X'); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b, 0), true); + }); + + it('distinct role names are unequal after update', () => { + const a = new RoleModel('A'); + const b = new RoleModel('B'); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b), false); + }); +}); + +describe('GroupModel weight branches', () => { + const group = (name, extra = {}) => new GroupModel({ name, ...extra }); + + it('update populates GROUP_LVL_0 and GROUP_LVL_1', () => { + const g = group('G1'); + g.update(opts()); + assert.equal(g.getWeights().length, 2); + assert.ok(g.getWeight('GROUP_LVL_0')); + assert.ok(g.getWeight('GROUP_LVL_1')); + }); + + it('equal at looser index matches same-name groups with differing props', () => { + const a = group('G', { creator: 'x' }); + const b = group('G', { creator: 'y' }); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b, 1), true); + }); + + it('equal at strongest (index 0) distinguishes differing props', () => { + const a = group('G', { creator: 'x' }); + const b = group('G', { creator: 'y' }); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b, 0), false); + }); + + it('equalKey compares names', () => { + const a = group('G'); + const b = group('G'); + const c = group('H'); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); + + it('toWeight returns name pre-update, hash post-update', () => { + const g = group('G'); + assert.equal(g.toWeight(opts()).weight, 'G'); + g.update(opts()); + assert.equal(g.toWeight(opts()).weight, g.getWeight()); + }); + + it('checkWeight true within range and false beyond', () => { + const g = group('G'); + g.update(opts()); + assert.equal(g.checkWeight(1), true); + assert.equal(g.checkWeight(5), false); + }); +}); + +describe('VariableModel', () => { + const v = (name, extra = {}) => new VariableModel({ name, ...extra }); + + it('exposes name as key', () => { + assert.equal(v('V1').key, 'V1'); + }); + + it('update populates two weights', () => { + const m = v('V1'); + m.update(opts()); + assert.equal(m.getWeights().length, 2); + }); + + it('falls back to name comparison before update', () => { + const a = v('A'); + const b = v('A'); + const c = v('B'); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + }); + + it('equalKey compares names', () => { + assert.equal(v('A').equalKey(v('A')), true); + assert.equal(v('A').equalKey(v('B')), false); + }); + + it('toObject returns {name, properties}', () => { + const o = v('V1', { description: 'd' }).toObject(); + assert.equal(o.name, 'V1'); + assert.ok(Array.isArray(o.properties)); + }); +}); + +describe('TopicModel', () => { + const t = (name, extra = {}) => new TopicModel({ name, ...extra }); + + it('name is the key', () => { + assert.equal(t('T1').key, 'T1'); + }); + + it('update populates TOPIC_LVL_0/1', () => { + const m = t('T1'); + m.update(opts()); + assert.ok(m.getWeight('TOPIC_LVL_0')); + assert.ok(m.getWeight('TOPIC_LVL_1')); + }); + + it('equal at index 1 matches same name with differing details', () => { + const a = t('T', { description: 'x' }); + const b = t('T', { description: 'y' }); + a.update(opts()); + b.update(opts()); + assert.equal(a.equal(b, 1), true); + }); + + it('getWeight(type) reads named slot', () => { + const m = t('T1'); + m.update(opts()); + assert.equal(m.getWeight('TOPIC_LVL_0'), m.getWeights()[1]); + }); + + it('equalKey compares names', () => { + assert.equal(t('T').equalKey(t('T')), true); + }); +}); diff --git a/guardian-service/tests/unit/w2-properties-document-models.test.mjs b/guardian-service/tests/unit/w2-properties-document-models.test.mjs new file mode 100644 index 0000000000..5c33313ec4 --- /dev/null +++ b/guardian-service/tests/unit/w2-properties-document-models.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import { PropertiesModel } from '../../dist/analytics/compare/models/properties.model.js'; +import { BlockPropertiesModel } from '../../dist/analytics/compare/models/block-properties.model.js'; +import { DocumentFieldsModel } from '../../dist/analytics/compare/models/document-fields.model.js'; + +const opts = (overrides = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', ...overrides }); + +describe('PropertiesModel.createProp classification', () => { + it('empty object yields empty list', () => { + assert.deepEqual(new PropertiesModel({}).getPropList(), []); + }); + + it('non-object input yields empty list', () => { + assert.deepEqual(new PropertiesModel('not-an-object').getPropList(), []); + assert.deepEqual(new PropertiesModel(null).getPropList(), []); + }); + + it('scalar -> AnyPropertyModel (property)', () => { + const list = new PropertiesModel({ a: 1 }).getPropList(); + assert.equal(list[0].type, 'property'); + assert.equal(list[0].value, 1); + }); + + it('name containing "schema" -> SchemaPropertyModel', () => { + const list = new PropertiesModel({ schemaId: 's1' }).getPropList(); + assert.equal(list[0].type, 'schema'); + }); + + it('name containing "token" -> TokenPropertyModel', () => { + const list = new PropertiesModel({ tokenId: 't1' }).getPropList(); + assert.equal(list[0].type, 'token'); + }); + + it('array value -> ArrayPropertyModel with element children', () => { + const list = new PropertiesModel({ arr: ['a', 'b'] }).getPropList(); + const arr = list.find((p) => p.name === 'arr'); + assert.equal(arr.type, 'array'); + assert.equal(arr.value, 2); + assert.ok(list.find((p) => p.path === 'arr.0')); + assert.ok(list.find((p) => p.path === 'arr.1')); + }); + + it('object value -> ObjectPropertyModel + nested children', () => { + const list = new PropertiesModel({ obj: { x: 1 } }).getPropList(); + const obj = list.find((p) => p.name === 'obj'); + assert.equal(obj.type, 'object'); + assert.equal(obj.value, true); + assert.ok(list.find((p) => p.path === 'obj.x')); + }); + + it('empty object value -> ObjectPropertyModel with value=false', () => { + const list = new PropertiesModel({ obj: {} }).getPropList(); + assert.equal(list.find((p) => p.name === 'obj').value, false); + }); + + it('undefined value skipped', () => { + const list = new PropertiesModel({ a: undefined, b: 1 }).getPropList(); + assert.deepEqual(list.map((p) => p.name), ['b']); + }); +}); + +describe('PropertiesModel.getPropList(type) filter', () => { + it('filters to only the requested type', () => { + const m = new PropertiesModel({ a: 1, schemaX: 's', b: 2 }); + const schemas = m.getPropList('schema'); + assert.equal(schemas.length, 1); + assert.equal(schemas[0].name, 'schemaX'); + }); + + it('returns a defensive copy when no type given', () => { + const m = new PropertiesModel({ a: 1 }); + const a = m.getPropList(); + const b = m.getPropList(); + assert.notEqual(a, b); + assert.deepEqual(a, b); + }); +}); + +describe('PropertiesModel hash / toObject', () => { + it('hash joins property hashes with commas', () => { + const m = new PropertiesModel({ a: 1, b: 2 }); + const h = m.hash(opts()); + assert.ok(h.includes(',')); + }); + + it('toObject serializes each property', () => { + const m = new PropertiesModel({ a: 1 }); + const o = m.toObject(); + assert.equal(o.length, 1); + assert.equal(o[0].name, 'a'); + }); +}); + +describe('PropertiesModel.updateSchemas / updateTokens', () => { + it('updateSchemas calls setSchema on schema props', () => { + const m = new PropertiesModel({ mySchema: 's-1' }); + const fakeSchema = { hash: () => 'H', id: 's-1', toObject: () => ({}) }; + m.updateSchemas({ 's-1': fakeSchema }, opts()); + const prop = m.getPropList('schema')[0]; + assert.equal(prop.schema, fakeSchema); + }); + + it('updateTokens calls setToken on token props', () => { + const m = new PropertiesModel({ myToken: 't-1' }); + const fakeToken = { + tokenName: 'C', tokenSymbol: 'C', tokenType: 'f', decimals: 0, + initialSupply: 0, enableAdmin: false, enableFreeze: false, + enableKYC: false, enableWipe: false, tokenId: 't-1', hash: () => 'H', + }; + m.updateTokens({ 't-1': fakeToken }, opts()); + const prop = m.getPropList('token')[0]; + assert.equal(prop.token, fakeToken); + }); + + it('updateSchemas leaves non-schema props untouched', () => { + const m = new PropertiesModel({ a: 1 }); + m.updateSchemas({}, opts()); + assert.equal(m.getPropList()[0].value, 1); + }); +}); + +describe('BlockPropertiesModel', () => { + it('excludes block metadata keys from properties', () => { + const m = new BlockPropertiesModel({ + id: 'b1', blockType: 'x', tag: 't', permissions: ['OWNER'], + artifacts: [], events: [], children: [], custom: 'keep', + }); + const names = m.getPropList().map((p) => p.name); + assert.ok(names.includes('custom')); + assert.equal(names.includes('blockType'), false); + assert.equal(names.includes('tag'), false); + }); + + it('captures and sorts permissions', () => { + const m = new BlockPropertiesModel({ permissions: ['b', 'a', 'c'] }); + assert.deepEqual(m.getPermissionsList(), ['a', 'b', 'c']); + }); + + it('defaults permissions to empty array when not an array', () => { + const m = new BlockPropertiesModel({ permissions: 'OWNER' }); + assert.deepEqual(m.getPermissionsList(), []); + }); + + it('getPermissionsList returns a copy', () => { + const m = new BlockPropertiesModel({ permissions: ['a'] }); + assert.notEqual(m.getPermissionsList(), m.getPermissionsList()); + }); +}); + +describe('DocumentFieldsModel.checkContext', () => { + it('adds string contexts from an array', () => { + const set = DocumentFieldsModel.checkContext(['c1', 'c2'], new Set()); + assert.deepEqual([...set], ['c1', 'c2']); + }); + + it('ignores non-string array items', () => { + const set = DocumentFieldsModel.checkContext(['c1', { x: 1 }], new Set()); + assert.deepEqual([...set], ['c1']); + }); + + it('adds a lone string context', () => { + const set = DocumentFieldsModel.checkContext('c1', new Set()); + assert.deepEqual([...set], ['c1']); + }); + + it('returns the set unchanged for falsy context', () => { + const set = DocumentFieldsModel.checkContext(null, new Set(['x'])); + assert.deepEqual([...set], ['x']); + }); +}); + +describe('DocumentFieldsModel.createTypesList', () => { + it('collects credentialSubject.type from a single VC', () => { + const types = DocumentFieldsModel.createTypesList({ credentialSubject: { type: 'T1' } }); + assert.deepEqual(types, ['T1']); + }); + + it('walks an array credentialSubject', () => { + const types = DocumentFieldsModel.createTypesList({ + credentialSubject: [{ type: 'A' }, { type: 'B' }], + }); + assert.deepEqual(types.sort(), ['A', 'B']); + }); + + it('walks verifiableCredential array', () => { + const types = DocumentFieldsModel.createTypesList({ + verifiableCredential: [{ credentialSubject: { type: 'X' } }], + }); + assert.deepEqual(types, ['X']); + }); + + it('returns [] for falsy document', () => { + assert.deepEqual(DocumentFieldsModel.createTypesList(null), []); + }); +}); + +describe('DocumentFieldsModel.getRelativePath', () => { + it('strips credentialSubject prefix for VC documents', () => { + const model = new DocumentFieldsModel({ type: 'VerifiableCredential', credentialSubject: { co2: 1 } }); + const path = model.getRelativePath({ path: 'credentialSubject.co2' }); + assert.equal(path, 'co2'); + }); + + it('returns null for "type" relative path', () => { + const model = new DocumentFieldsModel({ type: 'VerifiableCredential', credentialSubject: { type: 'X' } }); + assert.equal(model.getRelativePath({ path: 'credentialSubject.type' }), null); + }); + + it('returns null for @context-containing path', () => { + const model = new DocumentFieldsModel({ type: 'VerifiableCredential', credentialSubject: {} }); + assert.equal(model.getRelativePath({ path: 'credentialSubject.@context' }), null); + }); + + it('strips verifiableCredential.credentialSubject prefix for VP', () => { + const model = new DocumentFieldsModel({ type: ['VerifiablePresentation'] }); + const path = model.getRelativePath({ path: 'verifiableCredential.0.credentialSubject.co2' }); + assert.equal(path, 'co2'); + }); + + it('returns the raw path for unknown document types', () => { + const model = new DocumentFieldsModel({ type: 'SomeType', amount: 1 }); + assert.equal(model.getRelativePath({ path: 'amount' }), 'amount'); + }); +}); + +describe('DocumentFieldsModel.merge + update', () => { + it('merge concatenates another model\'s fields', () => { + const a = new DocumentFieldsModel({ type: 'X', a: 1 }); + const b = new DocumentFieldsModel({ type: 'X', b: 2 }); + const before = a.getFieldsList().length; + a.merge(b); + assert.equal(a.getFieldsList().length, before + b.getFieldsList().length); + }); + + it('update computes weights without throwing and hash becomes non-empty', () => { + const a = new DocumentFieldsModel({ type: 'X', amount: 5 }); + a.update(opts()); + assert.ok(a.hash(opts()).length > 0); + }); +}); diff --git a/guardian-service/tests/unit/w2-property-model-branches.test.mjs b/guardian-service/tests/unit/w2-property-model-branches.test.mjs new file mode 100644 index 0000000000..f376276e5f --- /dev/null +++ b/guardian-service/tests/unit/w2-property-model-branches.test.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import { + PropertyModel, + UUIDPropertyModel, + AnyPropertyModel, + ArrayPropertyModel, + ObjectPropertyModel, + TokenPropertyModel, + SchemaPropertyModel, + DocumentPropertyModel, +} from '../../dist/analytics/compare/models/property.model.js'; + +const NONE = 'None'; +const ALL = 'All'; +const SIMPLE = 'Simple'; +const DEFAULT = 'Default'; +const DESCRIPTION = 'Description'; +const TITLE = 'Title'; +const PROPERTY = 'Property'; + +const opts = (overrides = {}) => ({ + propLvl: ALL, + keyLvl: DEFAULT, + idLvl: ALL, + ...overrides, +}); + +const fakeToken = (overrides = {}) => ({ + tokenId: 't-1', + tokenName: 'Carbon', + tokenSymbol: 'CO2', + tokenType: 'fungible', + decimals: 2, + initialSupply: 100, + enableAdmin: true, + enableFreeze: false, + enableKYC: true, + enableWipe: false, + hash: () => 'TOKENHASH', + ...overrides, +}); + +const fakeSchema = (overrides = {}) => ({ + id: 's-1', + hash: () => 'SCHEMAHASH', + toObject: () => ({ id: 's-1', kind: 'schema' }), + ...overrides, +}); + +describe('PropertyModel weight + value coercion', () => { + it('stringifies a numeric value as the weight', () => { + const p = new PropertyModel('n', 'property', 42); + assert.equal(p._weight, '42'); + }); + + it('stringifies a boolean value as the weight', () => { + const p = new PropertyModel('b', 'property', false); + assert.equal(p._weight, 'false'); + }); + + it('stringifies null value to "null"', () => { + const p = new PropertyModel('z', 'property', null); + assert.equal(p._weight, 'null'); + }); + + it('lvl=0 is preserved (not replaced by default)', () => { + const p = new PropertyModel('z', 'property', 'v', 0); + assert.equal(p.lvl, 0); + }); + + it('ignore() is always false on the base model', () => { + const p = new PropertyModel('z', 'property', 'v'); + assert.equal(p.ignore(opts()), false); + }); + + it('two numerically-equal but different-typed values are unequal', () => { + const a = new PropertyModel('x', 'property', 1); + const b = new PropertyModel('x', 'array', 1); + assert.equal(a.equal(b, opts()), false); + }); + + it('number vs string with same textual weight are equal (string coercion)', () => { + const a = new PropertyModel('x', 'property', 1); + const b = new PropertyModel('x', 'property', '1'); + assert.equal(a.equal(b, opts()), true); + }); +}); + +describe('PropertyModel.update precedence', () => { + it('description set but keyLvl=Title falls through to path', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.setDescription('d'); + p.update(opts({ keyLvl: TITLE })); + assert.equal(p.key, 'a.x'); + }); + + it('empty-string description falls back to path', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.setDescription(''); + p.update(opts({ keyLvl: DESCRIPTION })); + assert.equal(p.key, 'a.x'); + }); + + it('default keyLvl always uses path even when description is set', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.setDescription('d'); + p.update(opts({ keyLvl: DEFAULT })); + assert.equal(p.key, 'a.x'); + }); + + it('update is idempotent across repeated calls', () => { + const p = new PropertyModel('x', 'property', 'v', 1, 'a.x'); + p.setTitle('T'); + p.update(opts({ keyLvl: TITLE })); + p.update(opts({ keyLvl: TITLE })); + assert.equal(p.key, 'T'); + }); +}); + +describe('UUIDPropertyModel hash branches', () => { + it('hash delegates to super when idLvl=All (lvl 1)', () => { + const p = new UUIDPropertyModel('id', 'A', 1, 'a.id'); + assert.equal(p.hash(opts({ idLvl: ALL })), 'a.id:A'); + }); + + it('hash returns null at deep lvl with propLvl=Simple even when idLvl=All', () => { + const p = new UUIDPropertyModel('id', 'A', 2, 'a.id'); + assert.equal(p.hash(opts({ idLvl: ALL, propLvl: SIMPLE })), null); + }); + + it('equal returns true regardless of value when idLvl=None', () => { + const a = new UUIDPropertyModel('id', 'A'); + const b = new UUIDPropertyModel('id', 'Z'); + assert.equal(a.equal(b, opts({ idLvl: NONE })), true); + }); +}); + +describe('TokenPropertyModel', () => { + it('type is token', () => { + const p = new TokenPropertyModel('tok', 't-1'); + assert.equal(p.type, 'token'); + }); + + it('setToken populates 9 sub-properties at lvl+1', () => { + const p = new TokenPropertyModel('tok', 't-1', 1, 'a.tok'); + p.setToken(fakeToken()); + const sub = p.getPropList(); + assert.equal(sub.length, 9); + for (const s of sub) { + assert.equal(s.lvl, 2); + assert.equal(s.type, 'property'); + } + }); + + it('setToken stores token hash as weight', () => { + const p = new TokenPropertyModel('tok', 't-1'); + p.setToken(fakeToken()); + assert.equal(p._weight, 'TOKENHASH'); + }); + + it('setToken with falsy token leaves sub-list empty', () => { + const p = new TokenPropertyModel('tok', 't-1'); + p.setToken(null); + assert.deepEqual(p.getPropList(), []); + }); + + it('setToken twice resets the sub-list to 9 items', () => { + const p = new TokenPropertyModel('tok', 't-1'); + p.setToken(fakeToken()); + p.setToken(fakeToken({ tokenName: 'Other' })); + assert.equal(p.getPropList().length, 9); + assert.equal(p.getPropList()[0].value, 'Other'); + }); + + it('toObject includes token fields when token set', () => { + const p = new TokenPropertyModel('tok', 't-1'); + p.setToken(fakeToken()); + const o = p.toObject(); + assert.equal(o.tokenId, 't-1'); + assert.equal(o.tokenName, 'Carbon'); + assert.equal(o.decimals, 2); + assert.equal(o.enableKYC, true); + }); + + it('toObject omits token fields when token unset', () => { + const p = new TokenPropertyModel('tok', 't-1'); + const o = p.toObject(); + assert.equal('tokenId' in o, false); + }); + + it('hash uses token hash with path prefix when idLvl=None and token set', () => { + const p = new TokenPropertyModel('tok', 't-1', 1, 'a.tok'); + p.setToken(fakeToken({ hash: () => 'H2' })); + assert.equal(p.hash(opts({ idLvl: NONE })), 'a.tok:H2'); + }); + + it('hash falls to super when idLvl=All', () => { + const p = new TokenPropertyModel('tok', 't-1', 1, 'a.tok'); + p.setToken(fakeToken()); + assert.equal(p.hash(opts({ idLvl: ALL })), 'a.tok:t-1'); + }); + + it('equal uses super (true) when idLvl=None', () => { + const a = new TokenPropertyModel('tok', 't-1'); + const b = new TokenPropertyModel('tok', 't-2'); + a.setToken(fakeToken()); + b.setToken(fakeToken()); + assert.equal(a.equal(b, opts({ idLvl: NONE })), true); + }); + + it('equal compares raw value when idLvl=All', () => { + const a = new TokenPropertyModel('tok', 't-1'); + const b = new TokenPropertyModel('tok', 't-2'); + assert.equal(a.equal(b, opts({ idLvl: ALL })), false); + }); +}); + +describe('SchemaPropertyModel', () => { + it('type is schema', () => { + const p = new SchemaPropertyModel('sch', 's-1'); + assert.equal(p.type, 'schema'); + }); + + it('setSchema stores schema hash as weight', () => { + const p = new SchemaPropertyModel('sch', 's-1'); + p.setSchema(fakeSchema()); + assert.equal(p._weight, 'SCHEMAHASH'); + }); + + it('setSchema with falsy schema does not throw and keeps default weight', () => { + const p = new SchemaPropertyModel('sch', 's-1'); + p.setSchema(null); + assert.equal(p._weight, 's-1'); + }); + + it('toObject embeds schemaId and nested schema object', () => { + const p = new SchemaPropertyModel('sch', 's-1'); + p.setSchema(fakeSchema()); + const o = p.toObject(); + assert.equal(o.schemaId, 's-1'); + assert.deepEqual(o.schema, { id: 's-1', kind: 'schema' }); + }); + + it('toObject omits schema fields when unset', () => { + const p = new SchemaPropertyModel('sch', 's-1'); + assert.equal('schemaId' in p.toObject(), false); + }); + + it('hash prefixes schema hash when idLvl=None', () => { + const p = new SchemaPropertyModel('sch', 's-1', 1, 'a.sch'); + p.setSchema(fakeSchema({ hash: () => 'HS' })); + assert.equal(p.hash(opts({ idLvl: NONE })), 'a.sch:HS'); + }); + + it('equal true when idLvl=None via super (matching schema hash weights)', () => { + const a = new SchemaPropertyModel('sch', 's-1'); + const b = new SchemaPropertyModel('sch', 's-9'); + a.setSchema(fakeSchema()); + b.setSchema(fakeSchema()); + assert.equal(a.equal(b, opts({ idLvl: NONE })), true); + }); + + it('equal false when idLvl=None and schema hashes differ', () => { + const a = new SchemaPropertyModel('sch', 's-1'); + const b = new SchemaPropertyModel('sch', 's-9'); + a.setSchema(fakeSchema({ hash: () => 'HA' })); + b.setSchema(fakeSchema({ hash: () => 'HB' })); + assert.equal(a.equal(b, opts({ idLvl: NONE })), false); + }); +}); + +describe('DocumentPropertyModel.checkSystemField branches', () => { + it('flags @context-containing paths', () => { + const p = new DocumentPropertyModel('x', 'v', 1, 'a.@context.b'); + assert.equal(p.isSystem, true); + }); + + it('flags type.* prefixed paths', () => { + const p = new DocumentPropertyModel('x', 'v', 1, 'type.0'); + assert.equal(p.isSystem, true); + }); + + it('flags proof.created suffix', () => { + const p = new DocumentPropertyModel('created', 'v', 1, 'x.proof.created'); + assert.equal(p.isSystem, true); + }); + + it('flags proof.verificationMethod suffix', () => { + const p = new DocumentPropertyModel('vm', 'v', 1, 'x.proof.verificationMethod'); + assert.equal(p.isSystem, true); + }); + + it('flags MintToken composite type with & separator for date field', () => { + const p = new DocumentPropertyModel('date', 'v', 1, 'a.date', 'MintToken&extra'); + assert.equal(p.isSystem, true); + }); + + it('does NOT flag MintToken date when name is not "date"', () => { + const p = new DocumentPropertyModel('amount', 'v', 1, 'a.amount', 'MintToken'); + assert.equal(p.isSystem, false); + }); + + it('does NOT flag a normal user field', () => { + const p = new DocumentPropertyModel('co2', 10, 1, 'a.co2', 'StringType'); + assert.equal(p.isSystem, false); + }); + + it('does NOT flag a non-did string value', () => { + const p = new DocumentPropertyModel('owner', 'just-a-name', 1, 'a.owner'); + assert.equal(p.isSystem, false); + }); + + it('ignore is true only when system AND idLvl=None', () => { + const sys = new DocumentPropertyModel('id', 'v'); + assert.equal(sys.ignore(opts({ idLvl: NONE })), true); + assert.equal(sys.ignore(opts({ idLvl: ALL })), false); + }); + + it('ignore false for non-system regardless of idLvl', () => { + const p = new DocumentPropertyModel('co2', 10); + assert.equal(p.ignore(opts({ idLvl: NONE })), false); + }); + + it('type is always Property for document properties', () => { + const p = new DocumentPropertyModel('co2', 10); + assert.equal(p.type, 'property'); + }); +}); + +describe('ArrayPropertyModel / ObjectPropertyModel edge values', () => { + it('ArrayPropertyModel preserves zero-length value', () => { + const p = new ArrayPropertyModel('list', 0); + assert.equal(p.value, 0); + assert.equal(p._weight, '0'); + }); + + it('ObjectPropertyModel custom path/lvl honoured', () => { + const p = new ObjectPropertyModel('obj', 'v', 4, 'deep.obj'); + assert.equal(p.lvl, 4); + assert.equal(p.path, 'deep.obj'); + assert.equal(p.key, 'deep.obj'); + }); + + it('AnyPropertyModel hash mirrors PropertyModel hash', () => { + const p = new AnyPropertyModel('x', 'v', 1, 'a.x'); + assert.equal(p.hash(opts()), 'a.x:v'); + }); +}); diff --git a/guardian-service/tests/unit/w2-rate-behaviors.test.mjs b/guardian-service/tests/unit/w2-rate-behaviors.test.mjs new file mode 100644 index 0000000000..f94b6203b2 --- /dev/null +++ b/guardian-service/tests/unit/w2-rate-behaviors.test.mjs @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import { Rate } from '../../dist/analytics/compare/rates/rate.js'; +import { ObjectRate } from '../../dist/analytics/compare/rates/object-rate.js'; +import { FieldsRate } from '../../dist/analytics/compare/rates/fields-rate.js'; +import { PropertiesRate } from '../../dist/analytics/compare/rates/properties-rate.js'; +import { PermissionsRate } from '../../dist/analytics/compare/rates/permissions-rate.js'; +import { EventsRate } from '../../dist/analytics/compare/rates/events-rate.js'; +import { ArtifactsRate } from '../../dist/analytics/compare/rates/artifacts-rate.js'; +import { RootRate } from '../../dist/analytics/compare/rates/root-rate.js'; +import { RecordRate } from '../../dist/analytics/compare/rates/record-rate.js'; +import { AnyPropertyModel, UUIDPropertyModel } from '../../dist/analytics/compare/models/property.model.js'; +import { FieldModel } from '../../dist/analytics/compare/models/field.model.js'; + +const opts = (overrides = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', ...overrides }); +const field = (name, extra = {}) => new FieldModel(name, { type: 'string', title: name, description: name, ...extra }, false); + +describe('Rate base class', () => { + it('initializes type NONE, totalRate -1', () => { + const r = new Rate({}, {}); + assert.equal(r.type, 'NONE'); + assert.equal(r.totalRate, -1); + }); + + it('getChildren default empty, getSubRate null', () => { + const r = new Rate('a', 'b'); + assert.deepEqual(r.getChildren(), []); + assert.equal(r.getSubRate('x'), null); + }); + + it('getRateValue returns totalRate', () => { + const r = new Rate('a', 'b'); + r.totalRate = 73; + assert.equal(r.getRateValue('anything'), 73); + }); + + it('toObject serializes left/right via toObject', () => { + const left = { toObject: () => ({ id: 'L' }) }; + const right = { toObject: () => ({ id: 'R' }) }; + const r = new Rate(left, right); + const o = r.toObject(); + assert.deepEqual(o.items, [{ id: 'L' }, { id: 'R' }]); + }); + + it('toObject tolerates null sides', () => { + const r = new Rate(null, null); + assert.deepEqual(r.toObject().items, [undefined, undefined]); + }); + + it('total with no children equals totalRate', () => { + const r = new Rate('a', 'b'); + r.totalRate = 80; + assert.equal(r.total(), 80); + }); + + it('setChildren is a no-op on the base', () => { + const r = new Rate('a', 'b'); + r.setChildren([1, 2]); + assert.deepEqual(r.getChildren(), []); + }); +}); + +describe('PermissionsRate', () => { + it('equal permissions yield FULL/100', () => { + const r = new PermissionsRate('admin', 'admin'); + assert.equal(r.type, 'FULL'); + assert.equal(r.totalRate, 100); + }); + + it('left-only is LEFT/-1', () => { + const r = new PermissionsRate('admin', null); + assert.equal(r.type, 'LEFT'); + assert.equal(r.totalRate, -1); + }); + + it('right-only is RIGHT/-1', () => { + const r = new PermissionsRate(null, 'admin'); + assert.equal(r.type, 'RIGHT'); + assert.equal(r.totalRate, -1); + }); + + it('both null treated as equal FULL', () => { + const r = new PermissionsRate(null, null); + assert.equal(r.type, 'FULL'); + assert.equal(r.totalRate, 100); + }); + + it('calc is a no-op and getSubRate null', () => { + const r = new PermissionsRate('a', 'a'); + r.calc(opts()); + assert.equal(r.getSubRate('x'), null); + assert.deepEqual(r.getChildren(), []); + }); + + it('toObject stores raw permission strings', () => { + const r = new PermissionsRate('a', 'b'); + assert.deepEqual(r.toObject().items, ['a', 'b']); + }); + + it('total returns totalRate', () => { + const r = new PermissionsRate('a', 'a'); + assert.equal(r.total(), 100); + }); +}); + +describe('EventsRate / ArtifactsRate', () => { + it('EventsRate both present is FULL/100', () => { + const r = new EventsRate({}, {}); + assert.equal(r.type, 'FULL'); + assert.equal(r.totalRate, 100); + }); + + it('EventsRate left-only LEFT', () => { + const r = new EventsRate({}, null); + assert.equal(r.type, 'LEFT'); + }); + + it('EventsRate right-only RIGHT', () => { + const r = new EventsRate(null, {}); + assert.equal(r.type, 'RIGHT'); + }); + + it('ArtifactsRate both present FULL/100', () => { + const r = new ArtifactsRate({}, {}); + assert.equal(r.type, 'FULL'); + assert.equal(r.totalRate, 100); + }); + + it('ArtifactsRate right-only RIGHT/-1', () => { + const r = new ArtifactsRate(null, {}); + assert.equal(r.type, 'RIGHT'); + assert.equal(r.totalRate, -1); + }); +}); + +describe('RootRate', () => { + it('constructs PARTLY/100 with null sides', () => { + const r = new RootRate(); + assert.equal(r.type, 'PARTLY'); + assert.equal(r.totalRate, 100); + assert.equal(r.left, null); + assert.equal(r.right, null); + }); + + it('children round-trip', () => { + const r = new RootRate(); + const kids = [new Rate('a', 'b')]; + r.setChildren(kids); + assert.deepEqual(r.getChildren(), kids); + }); + + it('total aggregates child totals', () => { + const r = new RootRate(); + const c = new Rate('a', 'b'); + c.totalRate = 50; + r.setChildren([c]); + assert.equal(r.total(), Math.floor((100 + 50) / 2)); + }); +}); + +describe('RecordRate', () => { + it('calc forces totalRate 100', () => { + const r = new RecordRate({}, {}); + r.calc(opts()); + assert.equal(r.totalRate, 100); + }); + + it('children round-trip and getSubRate null', () => { + const r = new RecordRate({}, {}); + r.setChildren(['x']); + assert.deepEqual(r.getChildren(), ['x']); + assert.equal(r.getSubRate('y'), null); + }); + + it('getRateValue returns totalRate after calc', () => { + const r = new RecordRate({}, {}); + r.calc(opts()); + assert.equal(r.getRateValue('z'), 100); + }); +}); + +describe('PropertiesRate', () => { + it('left-only is LEFT and stays -1 when not ignored', () => { + const r = new PropertiesRate(new AnyPropertyModel('x', 'v'), null); + r.calc(opts()); + assert.equal(r.type, 'LEFT'); + assert.equal(r.totalRate, -1); + }); + + it('right-only is RIGHT', () => { + const r = new PropertiesRate(null, new AnyPropertyModel('x', 'v')); + r.calc(opts()); + assert.equal(r.type, 'RIGHT'); + }); + + it('equal scalar properties yield FULL/100', () => { + const a = new AnyPropertyModel('x', 'v', 1, 'x'); + const b = new AnyPropertyModel('x', 'v', 1, 'x'); + const r = new PropertiesRate(a, b); + r.calc(opts()); + assert.equal(r.type, 'FULL'); + assert.equal(r.totalRate, 100); + }); + + it('different scalar values yield PARTLY/0', () => { + const a = new AnyPropertyModel('x', 'v1', 1, 'x'); + const b = new AnyPropertyModel('x', 'v2', 1, 'x'); + const r = new PropertiesRate(a, b); + r.calc(opts()); + assert.equal(r.type, 'PARTLY'); + assert.equal(r.totalRate, 0); + }); + + it('UUID left-only with idLvl=None is ignored -> 100', () => { + const a = new UUIDPropertyModel('id', 'A', 1, 'id'); + const r = new PropertiesRate(a, null); + r.calc(opts({ idLvl: 'None' })); + assert.equal(r.totalRate, -1); + }); + + it('copies name/path/lvl from the present side', () => { + const a = new AnyPropertyModel('x', 'v', 2, 'a.x'); + const r = new PropertiesRate(a, null); + assert.equal(r.name, 'x'); + assert.equal(r.path, 'a.x'); + assert.equal(r.lvl, 2); + }); + + it('right side used for metadata when left missing', () => { + const b = new AnyPropertyModel('y', 'v', 3, 'b.y'); + const r = new PropertiesRate(null, b); + assert.equal(r.name, 'y'); + assert.equal(r.lvl, 3); + }); + + it('getSubRate returns the properties array', () => { + const a = new AnyPropertyModel('x', 'v'); + const r = new PropertiesRate(a, null); + assert.ok(Array.isArray(r.getSubRate('x'))); + }); + + it('toObject carries name/path/lvl and items', () => { + const a = new AnyPropertyModel('x', 'v', 1, 'a.x'); + const b = new AnyPropertyModel('x', 'v', 1, 'a.x'); + const r = new PropertiesRate(a, b); + r.calc(opts()); + const o = r.toObject(); + assert.equal(o.name, 'x'); + assert.equal(o.path, 'a.x'); + assert.equal(o.lvl, 1); + assert.equal(o.items.length, 2); + }); + + it('total returns totalRate, getChildren empty', () => { + const a = new AnyPropertyModel('x', 'v'); + const r = new PropertiesRate(a, a); + r.calc(opts()); + assert.equal(r.total(), r.totalRate); + assert.deepEqual(r.getChildren(), []); + }); +}); + +describe('ObjectRate', () => { + it('both present, identical fields -> 100', () => { + const a = field('amount'); + const b = field('amount'); + const r = new ObjectRate(a, b); + r.calc(opts()); + assert.equal(r.type, 'FULL'); + assert.ok(r.totalRate >= 0); + }); + + it('left-only stays -1/LEFT', () => { + const r = new ObjectRate(field('a'), null); + r.calc(opts()); + assert.equal(r.type, 'LEFT'); + assert.equal(r.totalRate, -1); + }); + + it('getRateValue returns propertiesRate for "properties"', () => { + const r = new ObjectRate(field('a'), field('a')); + r.calc(opts()); + assert.equal(r.getRateValue('properties'), r.propertiesRate); + }); + + it('getRateValue returns totalRate for unknown names', () => { + const r = new ObjectRate(field('a'), field('a')); + r.calc(opts()); + assert.equal(r.getRateValue('whatever'), r.totalRate); + }); + + it('getSubRate returns properties list', () => { + const r = new ObjectRate(field('a'), field('a')); + r.calc(opts()); + assert.ok(Array.isArray(r.getSubRate('properties'))); + }); + + it('differing fields lower the rate below 100', () => { + const a = field('a', { description: 'AAAA' }); + const b = field('a', { description: 'BBBB' }); + const r = new ObjectRate(a, b); + r.calc(opts()); + assert.ok(r.totalRate < 100); + }); +}); + +describe('FieldsRate', () => { + it('identical fields index match -> indexRate 100', () => { + const a = field('a', { $comment: JSON.stringify({ orderPosition: 2 }) }); + const b = field('a', { $comment: JSON.stringify({ orderPosition: 2 }) }); + const r = new FieldsRate(a, b); + r.calc(opts()); + assert.equal(r.indexRate, 100); + }); + + it('different index -> indexRate 0', () => { + const a = field('a', { $comment: JSON.stringify({ orderPosition: 1 }) }); + const b = field('a', { $comment: JSON.stringify({ orderPosition: 2 }) }); + const r = new FieldsRate(a, b); + r.calc(opts()); + assert.equal(r.indexRate, 0); + }); + + it('getRateValue index/properties/total branches', () => { + const r = new FieldsRate(field('a'), field('a')); + r.calc(opts()); + assert.equal(r.getRateValue('index'), r.indexRate); + assert.equal(r.getRateValue('properties'), r.propertiesRate); + assert.equal(r.getRateValue('other'), r.totalRate); + }); + + it('children round-trip via setChildren/getChildren', () => { + const r = new FieldsRate(field('a'), field('a')); + r.setChildren(['c1']); + assert.deepEqual(r.getChildren(), ['c1']); + }); + + it('left-only construction is LEFT and stays -1', () => { + const r = new FieldsRate(field('a'), null); + r.calc(opts()); + assert.equal(r.type, 'LEFT'); + assert.equal(r.totalRate, -1); + }); + + it('right-only construction is RIGHT', () => { + const r = new FieldsRate(null, field('a')); + assert.equal(r.type, 'RIGHT'); + }); + + it('getSubRate returns the properties array', () => { + const r = new FieldsRate(field('a'), field('a')); + r.calc(opts()); + assert.ok(Array.isArray(r.getSubRate('properties'))); + }); +}); diff --git a/guardian-service/tests/unit/w2-schema-document-model-extra.test.mjs b/guardian-service/tests/unit/w2-schema-document-model-extra.test.mjs new file mode 100644 index 0000000000..6799c2b159 --- /dev/null +++ b/guardian-service/tests/unit/w2-schema-document-model-extra.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { SchemaDocumentModel } from '../../dist/analytics/compare/models/schema-document.model.js'; + +const opts = (overrides = {}) => ({ propLvl: 'All', keyLvl: 'Default', idLvl: 'All', ...overrides }); + +const doc = (props, extra = {}) => ({ + properties: props, + ...extra, +}); + +describe('SchemaDocumentModel.from', () => { + it('builds a model from a document with $defs', () => { + const m = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + assert.ok(m instanceof SchemaDocumentModel); + assert.equal(m.fields.length, 1); + }); + + it('tolerates a falsy document', () => { + const m = SchemaDocumentModel.from(undefined); + assert.deepEqual(m.fields, []); + }); +}); + +describe('SchemaDocumentModel.getField', () => { + it('returns null for empty path', () => { + const m = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + assert.equal(m.getField(''), null); + }); + + it('finds a top-level field by name', () => { + const m = SchemaDocumentModel.from(doc({ amount: { type: 'number' } })); + const f = m.getField('amount'); + assert.ok(f); + assert.equal(f.name, 'amount'); + }); + + it('returns null for an unknown field', () => { + const m = SchemaDocumentModel.from(doc({ amount: { type: 'number' } })); + assert.equal(m.getField('nope'), null); + }); + + it('descends into ref sub-schema via dotted path', () => { + const document = doc( + { sub: { $ref: '#child' } }, + { $defs: { '#child': { properties: { leaf: { type: 'string' } } } } } + ); + const m = SchemaDocumentModel.from(document); + const f = m.getField('sub.leaf'); + assert.ok(f); + assert.equal(f.name, 'leaf'); + }); +}); + +describe('SchemaDocumentModel field ordering', () => { + it('sorts fields by order ascending after update', () => { + const document = doc({ + a: { type: 'string', $comment: JSON.stringify({ orderPosition: 2 }) }, + b: { type: 'string', $comment: JSON.stringify({ orderPosition: 1 }) }, + }); + const m = SchemaDocumentModel.from(document); + const names = m.fields.map((f) => f.name); + assert.deepEqual(names, ['b', 'a']); + }); +}); + +describe('SchemaDocumentModel.update / hash', () => { + it('hash empty before update', () => { + const m = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + assert.equal(m.hash(opts()), ''); + }); + + it('update produces a non-empty hash', () => { + const m = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + m.update(opts()); + assert.ok(m.hash(opts()).length > 0); + }); + + it('documents with different fields hash differently', () => { + const a = SchemaDocumentModel.from(doc({ x: { type: 'string', title: 'X' } })); + const b = SchemaDocumentModel.from(doc({ y: { type: 'number', title: 'Y' } })); + a.update(opts()); + b.update(opts()); + assert.notEqual(a.hash(opts()), b.hash(opts())); + }); + + it('empty document hashes to empty string after update (no fields)', () => { + const m = SchemaDocumentModel.from(doc({})); + m.update(opts()); + assert.equal(typeof m.hash(opts()), 'string'); + }); +}); + +describe('SchemaDocumentModel.compare early returns', () => { + it('returns 0 when comparing against falsy', () => { + const m = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + assert.equal(m.compare(null), 0); + }); + + it('returns 0 when this model has no fields', () => { + const empty = SchemaDocumentModel.from(doc({})); + const other = SchemaDocumentModel.from(doc({ a: { type: 'string' } })); + assert.equal(empty.compare(other), 0); + }); +}); + +describe('SchemaDocumentModel condition parsing branches', () => { + it('captures an if {field const} -> then branch', () => { + const document = doc( + { kind: { type: 'string' }, extra: { type: 'string' } }, + { + allOf: [{ + if: { properties: { kind: { const: 'A' } } }, + then: { properties: { extra: { type: 'string' } } }, + }], + } + ); + const m = SchemaDocumentModel.from(document); + assert.equal(m.conditions.length, 1); + }); + + it('skips an allOf entry without an if clause', () => { + const document = doc( + { kind: { type: 'string' } }, + { allOf: [{ then: { properties: {} } }] } + ); + const m = SchemaDocumentModel.from(document); + assert.equal(m.conditions.length, 0); + }); + + it('skips a condition referencing an unknown if-field', () => { + const document = doc( + { kind: { type: 'string' } }, + { allOf: [{ if: { properties: { unknown: { const: 1 } } }, then: { properties: {} } }] } + ); + const m = SchemaDocumentModel.from(document); + assert.equal(m.conditions.length, 0); + }); + + it('builds an OR condition from anyOf predicates', () => { + const document = doc( + { a: { type: 'string' }, b: { type: 'string' } }, + { + allOf: [{ + if: { anyOf: [{ properties: { a: { const: '1' } } }, { properties: { b: { const: '2' } } }] }, + then: { properties: {} }, + }], + } + ); + const m = SchemaDocumentModel.from(document); + assert.equal(m.conditions.length, 1); + assert.equal(m.conditions[0].operator, 'OR'); + }); + + it('builds an AND condition from allOf predicates', () => { + const document = doc( + { a: { type: 'string' }, b: { type: 'string' } }, + { + allOf: [{ + if: { allOf: [{ properties: { a: { const: '1' } } }, { properties: { b: { const: '2' } } }] }, + then: { properties: {} }, + }], + } + ); + const m = SchemaDocumentModel.from(document); + assert.equal(m.conditions.length, 1); + assert.equal(m.conditions[0].operator, 'AND'); + }); +}); diff --git a/guardian-service/tests/unit/w2-table-csv.test.mjs b/guardian-service/tests/unit/w2-table-csv.test.mjs new file mode 100644 index 0000000000..8b924c23e9 --- /dev/null +++ b/guardian-service/tests/unit/w2-table-csv.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { CSV } from '../../dist/analytics/compare/table/csv.js'; +import { ReportRow } from '../../dist/analytics/compare/table/report-row.js'; +import { ReportTable } from '../../dist/analytics/compare/table/report-table.js'; +import { CompareUtils } from '../../dist/analytics/compare/utils/utils.js'; + +describe('CSV builder', () => { + it('starts with the data-uri header', () => { + assert.equal(new CSV().result().startsWith('data:text/csv'), true); + }); + + it('quotes added values and separates with commas', () => { + const csv = new CSV().add('a').add('b').result(); + assert.ok(csv.endsWith('"a","b"')); + }); + + it('renders undefined as empty quoted cell', () => { + const csv = new CSV().add(undefined).result(); + assert.ok(csv.endsWith('""')); + }); + + it('addLine resets the separator (no leading comma on the next row)', () => { + const csv = new CSV().add('a').addLine().add('b').result(); + assert.ok(csv.includes('"a"\r\n"b"')); + }); + + it('clear restores the initial header', () => { + const csv = new CSV(); + csv.add('x').addLine(); + csv.clear(); + assert.equal(csv.result(), 'data:text/csv;charset=utf-8;'); + }); + + it('add returns this for chaining', () => { + const csv = new CSV(); + assert.equal(csv.add('a'), csv); + assert.equal(csv.addLine(), csv); + }); + + it('numbers are stringified inside quotes', () => { + const csv = new CSV().add(5).result(); + assert.ok(csv.endsWith('"5"')); + }); +}); + +describe('ReportTable construction', () => { + it('accepts plain string column names', () => { + const t = new ReportTable(['a', 'b']); + assert.deepEqual(t.columns, ['a', 'b']); + }); + + it('extracts name from column-object definitions', () => { + const t = new ReportTable([{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }]); + assert.deepEqual(t.columns, ['a', 'b']); + }); + + it('builds an index map of column->position', () => { + const t = new ReportTable(['a', 'b', 'c']); + assert.deepEqual(t.indexes, { a: 0, b: 1, c: 2 }); + }); + + it('non-array columns produce an empty table', () => { + const t = new ReportTable(null); + assert.deepEqual(t.columns, []); + }); + + it('createRow appends a row and mirrors its value array', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + assert.equal(t.rows.length, 1); + assert.equal(t.value[0], row.value); + }); +}); + +describe('ReportRow set/get by name and index', () => { + it('set/get by column name', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.set('a', 1); + row.set('b', 2); + assert.equal(row.get('a'), 1); + assert.equal(row.get('b'), 2); + }); + + it('set/get by index', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.setByIndex(1, 'X'); + assert.equal(row.getByIndex(1), 'X'); + }); + + it('setObject serializes a value with toObject', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.setObject('a', { toObject: () => ({ k: 'v' }) }); + assert.deepEqual(row.get('a'), { k: 'v' }); + }); + + it('setObject stores plain values directly', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.setObject('a', 42); + assert.equal(row.get('a'), 42); + }); + + it('setArray maps each element via toObject', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.setArray('a', [{ toObject: () => 1 }, { toObject: () => 2 }]); + assert.deepEqual(row.get('a'), [1, 2]); + }); + + it('setArray stores falsy value directly', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.setArray('a', null); + assert.equal(row.get('a'), null); + }); + + it('row value array length matches column count', () => { + const t = new ReportTable(['a', 'b', 'c']); + const row = new ReportRow(t); + assert.equal(row.value.length, 3); + }); + + it('data() returns the underlying value array', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.set('a', 7); + assert.deepEqual(row.data(), [7]); + }); + + it('object() builds a column-keyed map', () => { + const t = new ReportTable(['a', 'b']); + const row = t.createRow(); + row.set('a', 1); + row.set('b', 2); + assert.deepEqual(row.object(), { a: 1, b: 2 }); + }); +}); + +describe('ReportTable getByIndex/setByIndex/data/object', () => { + it('set/get a cell by row+col index', () => { + const t = new ReportTable(['a', 'b']); + t.createRow(); + t.setByIndex(0, 1, 'Z'); + assert.equal(t.getByIndex(0, 1), 'Z'); + }); + + it('data returns columns plus the value matrix', () => { + const t = new ReportTable(['a']); + const row = t.createRow(); + row.set('a', 9); + const d = t.data(); + assert.deepEqual(d.columns, ['a']); + assert.deepEqual(d.rows, [[9]]); + }); + + it('object maps each row to a column-keyed object', () => { + const t = new ReportTable(['a']); + t.createRow().set('a', 'val'); + assert.deepEqual(t.object(), [{ a: 'val' }]); + }); +}); + +describe('CompareUtils.tableToCsv', () => { + it('writes labelled column headers then row data', () => { + const table = new ReportTable([{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }]); + const row = table.createRow(); + row.set('a', '1'); + row.set('b', '2'); + const csv = new CSV(); + CompareUtils.tableToCsv(csv, { columns: [{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }], report: [{ a: '1', b: '2' }] }); + const out = csv.result(); + assert.ok(out.includes('"A","B"')); + assert.ok(out.includes('"1","2"')); + }); + + it('skips columns without a label', () => { + const csv = new CSV(); + CompareUtils.tableToCsv(csv, { columns: [{ name: 'a', label: 'A' }, { name: 'hidden' }], report: [{ a: '1', hidden: 'x' }] }); + const out = csv.result(); + assert.ok(out.includes('"A"')); + assert.equal(out.includes('hidden'), false); + }); +}); + +describe('CompareUtils.objectToCsv', () => { + it('produces header row Index/Key/Value/Type', () => { + const csv = CompareUtils.objectToCsv({ a: 1 }); + assert.ok(csv.result().includes('"Index","Key","Value","Type"')); + }); + + it('serializes nested objects and arrays recursively', () => { + const csv = CompareUtils.objectToCsv({ a: { b: [1, 2] } }); + const out = csv.result(); + assert.ok(out.includes('"object"')); + assert.ok(out.includes('"array"')); + }); + + it('renders scalar values with their typeof', () => { + const csv = CompareUtils.objectToCsv({ n: 5, s: 'x', b: true }); + const out = csv.result(); + assert.ok(out.includes('"number"')); + assert.ok(out.includes('"string"')); + assert.ok(out.includes('"boolean"')); + }); +}); diff --git a/guardian-service/tests/unit/w3-policy-import-statics.test.mjs b/guardian-service/tests/unit/w3-policy-import-statics.test.mjs new file mode 100644 index 0000000000..fc1f1da66b --- /dev/null +++ b/guardian-service/tests/unit/w3-policy-import-statics.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { BlockType } from '@guardian/interfaces'; +import { PolicyImportExportHelper } from '../../dist/helpers/import-helpers/policy/policy-import-helper.js'; + +describe('PolicyImportExportHelper.errorsMessage', () => { + it('returns the base message for an empty error list', () => { + assert.equal(PolicyImportExportHelper.errorsMessage([]), 'Failed to import components:'); + }); + + it('groups schema errors', () => { + const message = PolicyImportExportHelper.errorsMessage([ + { type: 'schema', name: 'S1' }, + { type: 'schema', name: 'S2' } + ]); + assert.equal(message, 'Failed to import components: schemas: ["S1","S2"];'); + }); + + it('groups tool errors', () => { + const message = PolicyImportExportHelper.errorsMessage([{ type: 'tool', name: 'T1' }]); + assert.equal(message, 'Failed to import components: tools: ["T1"];'); + }); + + it('groups unknown types as others', () => { + const message = PolicyImportExportHelper.errorsMessage([{ type: 'token', name: 'X' }]); + assert.equal(message, 'Failed to import components: others: ["X"];'); + }); + + it('concatenates schema, tool and other sections in order', () => { + const message = PolicyImportExportHelper.errorsMessage([ + { type: 'tool', name: 'T' }, + { type: 'schema', name: 'S' }, + { type: 'artifact', name: 'A' } + ]); + assert.equal(message, 'Failed to import components: schemas: ["S"]; tools: ["T"]; others: ["A"];'); + }); +}); + +describe('PolicyImportExportHelper.findTools', () => { + it('ignores a null block', () => { + const result = new Set(); + PolicyImportExportHelper.findTools(null, result); + assert.equal(result.size, 0); + }); + + it('adds the messageId of a tool block', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: BlockType.Tool, messageId: 'msg-1' }, result); + assert.deepEqual(Array.from(result), ['msg-1']); + }); + + it('skips a tool block without a messageId', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: BlockType.Tool }, result); + assert.equal(result.size, 0); + }); + + it('skips a tool block with a non-string messageId', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: BlockType.Tool, messageId: 42 }, result); + assert.equal(result.size, 0); + }); + + it('recurses into the children of non-tool blocks', () => { + const result = new Set(); + const config = { + blockType: 'interfaceContainerBlock', + children: [ + { blockType: BlockType.Tool, messageId: 'a' }, + { + blockType: 'interfaceStepBlock', + children: [{ blockType: BlockType.Tool, messageId: 'b' }] + } + ] + }; + PolicyImportExportHelper.findTools(config, result); + assert.deepEqual(Array.from(result).sort(), ['a', 'b']); + }); + + it('does not traverse the children of a tool block', () => { + const result = new Set(); + const config = { + blockType: BlockType.Tool, + messageId: 'outer', + children: [{ blockType: BlockType.Tool, messageId: 'inner' }] + }; + PolicyImportExportHelper.findTools(config, result); + assert.deepEqual(Array.from(result), ['outer']); + }); + + it('handles blocks without children arrays', () => { + const result = new Set(); + PolicyImportExportHelper.findTools({ blockType: 'requestVcDocumentBlock' }, result); + assert.equal(result.size, 0); + }); +}); diff --git a/guardian-service/tests/unit/w3-schema-import-statics.test.mjs b/guardian-service/tests/unit/w3-schema-import-statics.test.mjs new file mode 100644 index 0000000000..eb13ee98ab --- /dev/null +++ b/guardian-service/tests/unit/w3-schema-import-statics.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { SchemaImportExportHelper } from '../../dist/helpers/import-helpers/schema/schema-import-helper.js'; + +const fakeSchema = (iri, fields = [], document = null) => ({ + iri, + fields, + conditions: [], + document: document || { $id: iri, $defs: {} }, + updateCalls: 0, + updateRefsCalls: 0, + update() { this.updateCalls += 1; }, + updateRefs() { this.updateRefsCalls += 1; } +}); + +describe('SchemaImportExportHelper.getDefs', () => { + it('returns the keys of $defs for an object document', () => { + const schema = { document: { $defs: { '#A': {}, '#B': {} } } }; + assert.deepEqual(SchemaImportExportHelper.getDefs(schema), ['#A', '#B']); + }); + + it('parses a JSON string document', () => { + const schema = { document: JSON.stringify({ $defs: { '#C': {} } }) }; + assert.deepEqual(SchemaImportExportHelper.getDefs(schema), ['#C']); + }); + + it('returns an empty array when $defs is missing', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: {} }), []); + }); + + it('returns an empty array for invalid JSON', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: '{not json' }), []); + }); + + it('returns an empty array for a null document', () => { + assert.deepEqual(SchemaImportExportHelper.getDefs({ document: null }), []); + }); +}); + +describe('SchemaImportExportHelper.getDefDocuments', () => { + it('returns the values of $defs', () => { + const defA = { $id: '#A' }; + const schema = { document: { $defs: { '#A': defA } } }; + assert.deepEqual(SchemaImportExportHelper.getDefDocuments(schema), [defA]); + }); + + it('parses a JSON string document', () => { + const schema = { document: JSON.stringify({ $defs: { '#A': { $id: '#A' } } }) }; + assert.deepEqual(SchemaImportExportHelper.getDefDocuments(schema), [{ $id: '#A' }]); + }); + + it('returns an empty array when $defs is missing', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: {} }), []); + }); + + it('returns an empty array for invalid JSON', () => { + assert.deepEqual(SchemaImportExportHelper.getDefDocuments({ document: 'oops{' }), []); + }); +}); + +describe('SchemaImportExportHelper.validateDefs', () => { + it('returns null for an already validated target', () => { + const validated = new Map([['#A', {}]]); + assert.equal(SchemaImportExportHelper.validateDefs('#A', [], validated), null); + }); + + it('returns Invalid defs when the target schema is missing', () => { + assert.equal(SchemaImportExportHelper.validateDefs('#missing', [], new Map()), 'Invalid defs'); + }); + + it('reports circular dependencies', () => { + const schema = fakeSchema('#A', [], { $id: '#A', $defs: { '#A': {} } }); + const error = SchemaImportExportHelper.validateDefs('#A', [schema], new Map()); + assert.equal(error, 'There is circular dependency in schema: #A'); + }); + + it('validates a leaf schema and records it', () => { + const schema = fakeSchema('#A'); + const validated = new Map(); + const error = SchemaImportExportHelper.validateDefs('#A', [schema], validated); + assert.equal(error, null); + assert.equal(validated.get('#A'), schema); + assert.equal(schema.updateCalls, 1); + assert.equal(schema.updateRefsCalls, 1); + }); + + it('validates nested refs transitively', () => { + const child = fakeSchema('#B'); + const parent = fakeSchema('#A', [{ isRef: true, type: '#B' }]); + const validated = new Map(); + const error = SchemaImportExportHelper.validateDefs('#A', [parent, child], validated); + assert.equal(error, null); + assert.ok(validated.has('#A')); + assert.ok(validated.has('#B')); + }); + + it('nulls broken ref types and reports Invalid defs', () => { + const field = { isRef: true, type: '#missing' }; + const parent = fakeSchema('#A', [field]); + const validated = new Map(); + const error = SchemaImportExportHelper.validateDefs('#A', [parent], validated); + assert.equal(error, 'Invalid defs'); + assert.equal(field.type, null); + assert.ok(validated.has('#A')); + }); + + it('ignores non-ref fields', () => { + const parent = fakeSchema('#A', [{ isRef: false, type: 'string' }]); + const error = SchemaImportExportHelper.validateDefs('#A', [parent], new Map()); + assert.equal(error, null); + }); +}); diff --git a/guardian-service/tests/unit/w4-hash-comparator-deep.test.mjs b/guardian-service/tests/unit/w4-hash-comparator-deep.test.mjs new file mode 100644 index 0000000000..50d3253c68 --- /dev/null +++ b/guardian-service/tests/unit/w4-hash-comparator-deep.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { HashComparator } from '../../dist/analytics/compare/comparators/hash-comparator.js'; + +const item = (weight) => ({ weight }); +const blk = (weights, children = [], length = 0) => ({ weights, children, length }); +const policy = (hash, hashMap) => ({ hash, hashMap }); +const base = (tree, over = {}) => ({ + roles: [], + groups: [], + topics: [], + tokens: [], + tree, + ...over, +}); + +describe('HashComparator.compare — array weighting', () => { + it('identical role multisets with identical trees score 100', () => { + const tree = blk(['F', 'PC', 'FP', 'P', 'T'], [], 0); + const a = policy('a', base(tree, { roles: [item('x'), item('x')] })); + const b = policy('b', base(tree, { roles: [item('x'), item('x')] })); + assert.equal(HashComparator.compare(a, b), 100); + }); + + it('partially overlapping role multisets pull the score below 100', () => { + const a = policy('a', base(blk(['f', 'pc', 'fp', 'p', 't'], [], 0), { roles: [item('x'), item('y')] })); + const b = policy('b', base(blk(['F', 'PC', 'FP', 'P', 't'], [], 0), { roles: [item('x'), item('z')] })); + const rate = HashComparator.compare(a, b); + assert.ok(rate > 0 && rate < 100); + }); + + it('a one-sided non-empty array still factors into the rate', () => { + const a = policy('a', base(blk(['F', 'PC', 'FP', 'P', 'T'], [], 0), { roles: [item('x')] })); + const b = policy('b', base(blk(['G', 'PC', 'FP', 'P', 'T'], [], 0), { roles: [] })); + const rate = HashComparator.compare(a, b); + assert.ok(rate >= 0 && rate < 100); + }); + + it('all-empty arrays on both sides do not affect the (tree-only) rate', () => { + const tree = blk(['F', 'PC', 'FP', 'P', 'T'], [], 0); + const a = policy('a', base(tree)); + const b = policy('b', base(tree)); + assert.equal(HashComparator.compare(a, b), 100); + }); +}); + +describe('HashComparator.compare — tree weighting', () => { + it('mismatched tree TYPE weights collapse the tree contribution to 0', () => { + const a = policy('a', base(blk(['F', 'PC', 'FP', 'P', 'TYPEA'], [], 0))); + const b = policy('b', base(blk(['G', 'PC', 'FP', 'P', 'TYPEB'], [], 0))); + assert.equal(HashComparator.compare(a, b), 0); + }); + + it('both-null trees score 0', () => { + const a = policy('a', base(null)); + const b = policy('b', base(null)); + assert.equal(HashComparator.compare(a, b), 0); + }); + + it('matching FULL tree weight short-circuits to 100', () => { + const tree = blk(['SAME', 'PC', 'FP', 'P', 'T'], [], 0); + const a = policy('a', base(tree)); + const b = policy('b', base(tree)); + assert.equal(HashComparator.compare(a, b), 100); + }); + + it('equal PROP weight with identical children recurses to a full match', () => { + const child = blk(['cF', 'cPC', 'cFP', 'cP', 'cT'], [], 1); + const a = policy('a', base(blk(['F1', 'PC', 'FP', 'SAMEPROP', 'T'], [child], 2))); + const b = policy('b', base(blk(['F2', 'PC', 'FP', 'SAMEPROP', 'T'], [child], 2))); + assert.equal(HashComparator.compare(a, b), 100); + }); + + it('differing PROP weight blends child similarity into a partial rate', () => { + const child = blk(['cF', 'cPC', 'cFP', 'cP', 'cT'], [], 1); + const a = policy('a', base(blk(['F1', 'PC', 'FP', 'PROPA', 'T'], [child], 2))); + const b = policy('b', base(blk(['F2', 'PC', 'FP', 'PROPB', 'T'], [child], 2))); + const rate = HashComparator.compare(a, b); + assert.ok(rate > 0 && rate < 100); + }); + + it('fully different children produce a zero child-similarity', () => { + const ca = blk(['aF', 'aPC', 'aFP', 'aP', 'aT'], [], 1); + const cb = blk(['bF', 'bPC', 'bFP', 'bP', 'bT'], [], 1); + const a = policy('a', base(blk(['F1', 'PC', 'FP', 'PR', 'T'], [ca], 2))); + const b = policy('b', base(blk(['F2', 'PC', 'FP', 'PR', 'T'], [cb], 2))); + assert.equal(HashComparator.compare(a, b), 0); + }); +}); + +describe('HashComparator.createModelByFile', () => { + const fileData = (over = {}) => ({ + policy: { + id: 'p1', + config: { blockType: 'interfaceContainerBlock', tag: 'root', children: [], permissions: ['ANY_ROLE'] }, + policyRoles: [], + policyGroups: [], + policyTopics: [], + }, + schemas: [ + { id: 's1', name: 'S', uuid: 'u1', description: 'd', topicId: '0.0.1', version: '1', iri: '#s1', document: { properties: {} } }, + ], + tokens: [ + { tokenId: '0.0.5', tokenName: 'T', tokenSymbol: 'TT', tokenType: 'ft', decimals: 2, initialSupply: 0, adminKey: true }, + ], + artifacts: [], + ...over, + }); + + it('rejects a null file', async () => { + await assert.rejects(() => HashComparator.createModelByFile(null), /Invalid file/); + }); + + it('builds a PolicyModel with schemas, tokens, and artifacts wired in', async () => { + const model = await HashComparator.createModelByFile(fileData()); + assert.ok(model); + const tree = HashComparator.createTree(model); + assert.ok(tree); + }); + + it('maps token capability flags from explicit enable* fields', async () => { + const model = await HashComparator.createModelByFile( + fileData({ + tokens: [ + { + tokenId: '0.0.6', tokenName: 'F', tokenSymbol: 'FF', tokenType: 'nft', + decimals: 0, initialSupply: 0, + enableAdmin: true, enableFreeze: true, enableKYC: true, enableWipe: true, + }, + ], + }) + ); + assert.ok(model); + }); + + it('produces a usable hash + hashMap from a built model', async () => { + const model = await HashComparator.createModelByFile(fileData()); + const out = await HashComparator.createHashMap(model); + assert.ok(out.hash.length > 0); + assert.ok(out.hashMap); + assert.equal(typeof HashComparator.createHash(model), 'string'); + }); +}); + +describe('HashComparator.compare — child matching across weight indices', () => { + it('children that match only on lower weight indices still contribute', () => { + const left = blk(['F', 'PC', 'FP', 'SHAREDPROP', 'SHAREDTYPE'], [], 1); + const right = blk(['F2', 'PC2', 'FP2', 'SHAREDPROP', 'SHAREDTYPE'], [], 1); + const a = policy('a', base(blk(['T1', 'TPC', 'TFP', 'TP', 'TT'], [left], 2))); + const b = policy('b', base(blk(['T2', 'TPC', 'TFP', 'TP', 'TT'], [right], 2))); + const rate = HashComparator.compare(a, b); + assert.ok(rate > 0 && rate <= 100); + }); + + it('children matching only on TYPE index recurse into grandchildren', () => { + const grand = blk(['gF', 'gPC', 'gFP', 'gP', 'gT'], [], 1); + const left = blk(['F', 'PC', 'FP', 'P', 'SAMETYPE'], [grand], 2); + const right = blk(['F2', 'PC2', 'FP2', 'P2', 'SAMETYPE'], [grand], 2); + const a = policy('a', base(blk(['T1', 'TPC', 'TFP', 'TP', 'TT'], [left], 4))); + const b = policy('b', base(blk(['T2', 'TPC', 'TFP', 'TP', 'TT'], [right], 4))); + const rate = HashComparator.compare(a, b); + assert.ok(rate >= 0 && rate <= 100); + }); +}); diff --git a/guardian-service/tests/unit/w4-policy-import-options.test.mjs b/guardian-service/tests/unit/w4-policy-import-options.test.mjs new file mode 100644 index 0000000000..7dc4e721fd --- /dev/null +++ b/guardian-service/tests/unit/w4-policy-import-options.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { ImportPolicyOptions } from '../../dist/helpers/import-helpers/policy/policy-import.interface.js'; + +describe('ImportPolicyOptions', () => { + const logger = { info: () => {} }; + + it('stores the logger from the constructor', () => { + const options = new ImportPolicyOptions(logger); + assert.equal(options.logger, logger); + }); + + it('leaves other fields undefined initially', () => { + const options = new ImportPolicyOptions(logger); + assert.equal(options.policyComponents, undefined); + assert.equal(options.user, undefined); + assert.equal(options.versionOfTopicId, undefined); + assert.equal(options.additionalPolicyConfig, undefined); + assert.equal(options.metadata, undefined); + assert.equal(options.importRecords, undefined); + }); + + it('setComponents stores the components and returns this', () => { + const options = new ImportPolicyOptions(logger); + const components = { policy: {} }; + assert.equal(options.setComponents(components), options); + assert.equal(options.policyComponents, components); + }); + + it('setUser stores the user and returns this', () => { + const options = new ImportPolicyOptions(logger); + const user = { creator: 'did:me' }; + assert.equal(options.setUser(user), options); + assert.equal(options.user, user); + }); + + it('setParentPolicyTopic stores the topic id', () => { + const options = new ImportPolicyOptions(logger); + assert.equal(options.setParentPolicyTopic('0.0.1'), options); + assert.equal(options.versionOfTopicId, '0.0.1'); + }); + + it('setParentPolicyTopic accepts null', () => { + const options = new ImportPolicyOptions(logger); + options.setParentPolicyTopic(null); + assert.equal(options.versionOfTopicId, null); + }); + + it('setAdditionalPolicy stores the partial policy', () => { + const options = new ImportPolicyOptions(logger); + const policy = { name: 'override' }; + assert.equal(options.setAdditionalPolicy(policy), options); + assert.equal(options.additionalPolicyConfig, policy); + }); + + it('setMetadata stores the metadata', () => { + const options = new ImportPolicyOptions(logger); + const metadata = { tools: {} }; + assert.equal(options.setMetadata(metadata), options); + assert.equal(options.metadata, metadata); + }); + + it('setImportRecords coerces undefined to false', () => { + const options = new ImportPolicyOptions(logger); + assert.equal(options.setImportRecords(undefined), options); + assert.equal(options.importRecords, false); + }); + + it('setImportRecords coerces truthy values to true', () => { + const options = new ImportPolicyOptions(logger); + options.setImportRecords(1); + assert.equal(options.importRecords, true); + }); + + it('setImportRecords keeps boolean true', () => { + const options = new ImportPolicyOptions(logger); + options.setImportRecords(true); + assert.equal(options.importRecords, true); + }); + + it('setImportRecords coerces false to false', () => { + const options = new ImportPolicyOptions(logger); + options.setImportRecords(false); + assert.equal(options.importRecords, false); + }); + + it('validate throws when components are missing', () => { + const options = new ImportPolicyOptions(logger).setUser({ creator: 'd' }); + assert.throws(() => options.validate(), /Invalid import parameters: policy components/); + }); + + it('validate throws when the user is missing', () => { + const options = new ImportPolicyOptions(logger).setComponents({ policy: {} }); + assert.throws(() => options.validate(), /Invalid import parameters: user/); + }); + + it('validate returns this when components and user are present', () => { + const options = new ImportPolicyOptions(logger) + .setComponents({ policy: {} }) + .setUser({ creator: 'd' }); + assert.equal(options.validate(), options); + }); + + it('supports full chaining of all setters', () => { + const options = new ImportPolicyOptions(logger) + .setComponents({ policy: {} }) + .setUser({ creator: 'd' }) + .setParentPolicyTopic('0.0.2') + .setAdditionalPolicy({ name: 'n' }) + .setMetadata({ tools: { a: 'b' } }) + .setImportRecords(true); + assert.equal(options.versionOfTopicId, '0.0.2'); + assert.deepEqual(options.metadata, { tools: { a: 'b' } }); + assert.equal(options.importRecords, true); + assert.equal(options.validate(), options); + }); +}); diff --git a/guardian-service/tests/unit/w4-record-comparator-tables.test.mjs b/guardian-service/tests/unit/w4-record-comparator-tables.test.mjs new file mode 100644 index 0000000000..8f21eb4998 --- /dev/null +++ b/guardian-service/tests/unit/w4-record-comparator-tables.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { RecordComparator } from '../../dist/analytics/compare/comparators/record-comparator.js'; +import { RecordModel } from '../../dist/analytics/compare/models/record.model.js'; +import { VcDocumentModel } from '../../dist/analytics/compare/models/document.model.js'; +import { CompareOptions } from '../../dist/analytics/compare/interfaces/compare-options.interface.js'; + +const opts = CompareOptions.default; + +const vcRaw = (overrides = {}) => ({ + id: 'doc-1', + schema: 'schema-A', + messageId: 'm-1', + topicId: '0.0.1', + owner: 'did:owner', + policyId: 'p-1', + document: { credentialSubject: { type: 'Sub', amount: 5 } }, + option: { tag: 'submit' }, + relationships: [], + ...overrides, +}); + +const vc = (overrides = {}) => { + const m = new VcDocumentModel(vcRaw(overrides), opts); + m.update(opts); + return m; +}; + +const record = (children = []) => { + const r = new RecordModel(opts); + r.setChildren(children); + return r; +}; + +const recordFromResults = (docs = []) => { + const r = new RecordModel(opts); + r.setDocuments(docs); + return r; +}; + +describe('RecordComparator full compare', () => { + it('compare of two records with documents yields one result with a populated table', () => { + const left = record([vc({ id: 'l-1', messageId: 'l-1' })]); + const right = record([vc({ id: 'r-1', messageId: 'r-1' })]); + const [result] = new RecordComparator(opts).compare([left, right]); + assert.ok(result.left); + assert.ok(result.right); + assert.equal(typeof result.total, 'number'); + assert.ok(Array.isArray(result.documents.report)); + assert.ok(result.documents.report.length >= 1); + assert.ok(Array.isArray(result.documents.columns)); + }); + + it('two structurally identical records compare as fully similar', () => { + const left = record([vc({ id: 'a', messageId: 'a' })]); + const right = record([vc({ id: 'a', messageId: 'a' })]); + const [result] = new RecordComparator(opts).compare([left, right]); + assert.equal(result.total, 100); + }); + + it('the report rows carry rate strings and level offsets', () => { + const left = record([vc({ id: 'l', messageId: 'l' })]); + const right = record([vc({ id: 'r', messageId: 'r' })]); + const [result] = new RecordComparator(opts).compare([left, right]); + const root = result.documents.report[0]; + assert.equal(root.lvl, 1); + const child = result.documents.report.find((row) => row.lvl === 2); + assert.ok(child); + assert.equal(typeof child.total_rate, 'string'); + assert.ok(child.total_rate.endsWith('%') || child.total_rate === '-'); + }); + + it('records with only one side populated render dash rate strings', () => { + const left = record([vc({ id: 'only-left', messageId: 'only-left' })]); + const right = record([]); + const [result] = new RecordComparator(opts).compare([left, right]); + const dashRow = result.documents.report.find((row) => row.total_rate === '-'); + assert.ok(dashRow); + }); + + it('compare across three records produces two pairwise results', () => { + const mk = (id) => record([vc({ id, messageId: id })]); + const results = new RecordComparator(opts).compare([mk('a'), mk('b'), mk('c')]); + assert.equal(results.length, 2); + }); +}); + +describe('RecordComparator.tableToCsv', () => { + it('emits a CSV with the Document 1 header and per-right sections', () => { + const left = record([vc({ id: 'l', messageId: 'l' })]); + const right = record([vc({ id: 'r', messageId: 'r' })]); + const results = new RecordComparator(opts).compare([left, right]); + const csv = RecordComparator.tableToCsv(results); + assert.ok(csv.includes('Document 1')); + assert.ok(csv.includes('Document 2')); + assert.ok(csv.includes('Total')); + assert.ok(csv.includes('Data')); + }); + + it('includes a section for each right-hand document', () => { + const mk = (id) => record([vc({ id, messageId: id })]); + const results = new RecordComparator(opts).compare([mk('a'), mk('b'), mk('c')]); + const csv = RecordComparator.tableToCsv(results); + assert.ok(csv.includes('Document 2')); + assert.ok(csv.includes('Document 3')); + }); +}); + +describe('RecordComparator.mergeCompareResults', () => { + it('merges pairwise results into a multi-result with size = rights + 1', () => { + const mk = (id) => record([vc({ id, messageId: id })]); + const rc = new RecordComparator(opts); + const results = rc.compare([mk('a'), mk('b'), mk('c')]); + const merged = rc.mergeCompareResults(results); + assert.equal(merged.size, 3); + assert.equal(merged.rights.length, 2); + assert.equal(merged.totals.length, 2); + assert.ok(merged.left); + }); + + it('the merged document table has columns scaled per right-hand side', () => { + const mk = (id) => record([vc({ id, messageId: id })]); + const rc = new RecordComparator(opts); + const results = rc.compare([mk('a'), mk('b'), mk('c')]); + const merged = rc.mergeCompareResults(results); + const names = merged.documents.columns.map((c) => c.name); + assert.ok(names.includes('left_id')); + assert.ok(names.includes('right_id_1')); + assert.ok(names.includes('right_id_2')); + assert.ok(Array.isArray(merged.documents.report)); + }); + + it('merging a single pairwise result yields size 2', () => { + const rc = new RecordComparator(opts); + const results = rc.compare([ + record([vc({ id: 'l', messageId: 'l' })]), + record([vc({ id: 'r', messageId: 'r' })]), + ]); + const merged = rc.mergeCompareResults(results); + assert.equal(merged.size, 2); + assert.equal(merged.rights.length, 1); + }); +}); + +describe('RecordModel.setDocuments token accounting (drives info())', () => { + it('counts vc/vp documents and sums vp token amounts', () => { + const r = recordFromResults([ + { type: 'vc' }, + { type: 'vp', document: { verifiableCredential: [{}, { credentialSubject: { amount: 9 } }] } }, + ]); + assert.equal(r.count, 2); + assert.equal(r.tokens, 9); + assert.deepEqual(r.info(), { documents: 2, tokens: 9 }); + }); + + it('treats malformed vp documents as zero tokens', () => { + const r = recordFromResults([ + { type: 'vp', document: null }, + { type: 'vp', document: { verifiableCredential: [] } }, + ]); + assert.equal(r.count, 2); + assert.equal(r.tokens, 0); + }); + + it('reads array-shaped credentialSubject amounts from the last verifiable credential', () => { + const r = recordFromResults([ + { + type: 'vp', + document: { verifiableCredential: [{}, { credentialSubject: [{ amount: 3 }] }] }, + }, + ]); + assert.equal(r.tokens, 3); + }); + + it('a single-credential vp resolves no mint token (mintIndex floors at 1)', () => { + const r = recordFromResults([ + { type: 'vp', document: { verifiableCredential: [{ credentialSubject: { amount: 99 } }] } }, + ]); + assert.equal(r.tokens, 0); + }); +}); diff --git a/guardian-service/tests/unit/w5-analytics-models.test.mjs b/guardian-service/tests/unit/w5-analytics-models.test.mjs new file mode 100644 index 0000000000..7683378722 --- /dev/null +++ b/guardian-service/tests/unit/w5-analytics-models.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { SchemaDocumentModel } from '../../dist/analytics/compare/models/schema-document.model.js'; +import { TemplateTokenModel } from '../../dist/analytics/compare/models/template-token.model.js'; +import { BlockSearchModel } from '../../dist/analytics/search/models/block.model.js'; +import { PairSearchModel } from '../../dist/analytics/search/models/pair.model.js'; +import { + CompareOptions, IPropertiesLvl, IChildrenLvl, IEventsLvl, IIdLvl, IKeyLvl, IRefLvl +} from '../../dist/analytics/compare/interfaces/index.js'; + +const opts = new CompareOptions( + IPropertiesLvl.All, IChildrenLvl.All, IEventsLvl.All, + IIdLvl.All, IKeyLvl.Default, IRefLvl.Default, null +); + +describe('SchemaDocumentModel', () => { + it('parses simple field properties (excluding @context/type)', () => { + const m = SchemaDocumentModel.from({ + properties: { + '@context': { type: 'string' }, + type: { type: 'string' }, + amount: { type: 'number', title: 'A', description: 'A' } + } + }); + assert.deepEqual(m.fields.map(f => f.name), ['amount']); + }); + + it('returns no fields for an empty document', () => { + assert.deepEqual(SchemaDocumentModel.from({}).fields, []); + }); + + it('parses a single-property if/then condition and merges its fields', () => { + const m = SchemaDocumentModel.from({ + properties: { + level: { type: 'string', title: 'L', description: 'L' }, + amount: { type: 'number', title: 'A', description: 'A' } + }, + allOf: [{ + if: { properties: { level: { const: 'high' } } }, + then: { properties: { bonus: { type: 'number', title: 'B', description: 'B' } } }, + else: { properties: {} } + }] + }); + assert.equal(m.conditions.length, 1); + assert.ok(m.fields.some(f => f.name === 'bonus')); + }); + + it('parses an anyOf (OR) condition into one condition', () => { + const m = SchemaDocumentModel.from({ + properties: { + a: { type: 'string', title: 'A', description: 'A' }, + b: { type: 'string', title: 'B', description: 'B' } + }, + allOf: [{ + if: { anyOf: [{ properties: { a: { const: '1' } } }, { properties: { b: { const: '2' } } }] }, + then: { properties: { c: { type: 'number', title: 'C', description: 'C' } } } + }] + }); + assert.equal(m.conditions.length, 1); + }); + + it('parses an allOf (AND) condition into one condition', () => { + const m = SchemaDocumentModel.from({ + properties: { a: { type: 'string', title: 'A', description: 'A' } }, + allOf: [{ + if: { allOf: [{ properties: { a: { const: '1' } } }] }, + then: { properties: { d: { type: 'number', title: 'D', description: 'D' } } } + }] + }); + assert.equal(m.conditions.length, 1); + }); + + it('skips allOf entries that have no if', () => { + const m = SchemaDocumentModel.from({ + properties: { a: { type: 'string', title: 'A', description: 'A' } }, + allOf: [{ then: { properties: {} } }] + }); + assert.equal(m.conditions.length, 0); + }); + + it('getField resolves an existing field and returns null otherwise', () => { + const m = SchemaDocumentModel.from({ + properties: { level: { type: 'string', title: 'L', description: 'L' } } + }); + assert.equal(m.getField('level')?.name, 'level'); + assert.equal(m.getField('missing'), null); + assert.equal(m.getField(''), null); + }); + + it('update produces a deterministic hash; equal documents hash the same', () => { + const a = SchemaDocumentModel.from({ properties: { a: { type: 'string', title: 'A', description: 'A' } } }); + const b = SchemaDocumentModel.from({ properties: { a: { type: 'string', title: 'A', description: 'A' } } }); + a.update(opts); + b.update(opts); + assert.equal(typeof a.hash(opts), 'string'); + assert.equal(a.hash(opts), b.hash(opts)); + }); + + it('compare against null returns 0', () => { + const a = SchemaDocumentModel.from({ properties: { a: { type: 'string', title: 'A', description: 'A' } } }); + a.update(opts); + assert.equal(a.compare(null), 0); + }); + + it('compare of two non-empty schemas returns 0 (guard short-circuits, see note)', () => { + const a = SchemaDocumentModel.from({ properties: { a: { type: 'string', title: 'A', description: 'A' } } }); + const b = SchemaDocumentModel.from({ properties: { a: { type: 'string', title: 'A', description: 'A' } } }); + a.update(opts); + b.update(opts); + assert.equal(a.compare(b), 0); + }); +}); + +describe('TemplateTokenModel', () => { + const make = (tag, name = 'N', symbol = 'S') => + new TemplateTokenModel({ templateTokenTag: tag, tokenName: name, tokenSymbol: symbol }); + + it('exposes its tag as the key and toObject', () => { + const m = make('t1'); + assert.equal(m.key, 't1'); + assert.equal(m.toObject().tag, 't1'); + assert.ok(Array.isArray(m.toObject().properties)); + }); + + it('before update: equal falls back to name match and toWeight uses the name', () => { + const a = make('t1'); + assert.equal(a.equal(make('t1')), true); + assert.equal(a.equal(make('t2', 'M', 'Q')), false); + assert.deepEqual(a.toWeight(opts), { weight: 't1' }); + }); + + it('after update: weight accessors return populated values', () => { + const a = make('t1'); + a.update(opts); + assert.equal(typeof a.getWeight(), 'string'); + assert.equal(a.getWeights().length, 2); + assert.equal(a.maxWeight(), 2); + assert.equal(a.checkWeight(0), true); + assert.equal(a.checkWeight(9), false); + assert.equal(typeof a.toWeight(opts).weight, 'string'); + assert.ok(a.getPropList().length > 0); + }); + + it('after update: equal compares by weight (indexed and default) and equalKey by key', () => { + const a = make('t1'); + const b = make('t1'); + const c = make('t2', 'M', 'Q'); + a.update(opts); + b.update(opts); + c.update(opts); + assert.equal(a.equal(b, 1), true); + assert.equal(a.equal(c, 1), false); + assert.equal(a.equal(b), true); + assert.equal(a.equal(c), false); + assert.equal(a.equalKey(b), true); + assert.equal(a.equalKey(c), false); + }); +}); + +describe('PairSearchModel with rich blocks', () => { + const richBlock = (over = {}) => new BlockSearchModel({ + id: 'b', tag: 'tag', blockType: 'X', + events: [{ source: 'a', target: 'b', input: 'i', output: 'o' }], + artifacts: [{ uuid: 'u1', name: 'art', type: 'json' }], + permissions: ['Owner'], + prop1: 'v1', + ...over + }); + + it('update over property/event/permission/artifact-bearing blocks yields a numeric hash', () => { + const pair = new PairSearchModel(richBlock(), richBlock()); + pair.update(); + assert.equal(typeof pair.hash, 'number'); + }); + + it('identical rich blocks compare as fully similar', () => { + const pair = new PairSearchModel(richBlock(), richBlock()); + pair.update(); + assert.equal(pair.hash, 100); + }); +}); diff --git a/guardian-service/tests/unit/w5-comparator-merge-csv.test.mjs b/guardian-service/tests/unit/w5-comparator-merge-csv.test.mjs new file mode 100644 index 0000000000..079ea6699e --- /dev/null +++ b/guardian-service/tests/unit/w5-comparator-merge-csv.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { DocumentComparator } from '../../dist/analytics/compare/comparators/document-comparator.js'; +import { PolicyComparator } from '../../dist/analytics/compare/comparators/policy-comparator.js'; +import { ToolComparator } from '../../dist/analytics/compare/comparators/tool-comparator.js'; +import { VcDocumentModel } from '../../dist/analytics/compare/models/document.model.js'; +import { PolicyModel } from '../../dist/analytics/compare/models/policy.model.js'; +import { ToolModel } from '../../dist/analytics/compare/models/tool.model.js'; + +const docOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All', childLvl: 'All' }; +const polOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const vcDoc = (overrides = {}) => new VcDocumentModel({ + id: 'doc-1', schema: 'schema-A', messageId: 'm-1', topicId: '0.0.1', + owner: 'did:owner', policyId: 'p-1', + document: { '@context': ['https://x'], type: 'VerifiableCredential', credentialSubject: { type: 'Sub', amount: 5 } }, + option: { tag: 'submit' }, relationships: [], ...overrides +}, docOpts); + +const richPolicy = (overrides = {}) => new PolicyModel({ + id: 'p-1', name: 'Policy', description: 'desc', instanceTopicId: '0.0.1', version: '1.0.0', + config: { blockType: 'root', tag: 'root', children: [{ blockType: 'x', tag: 'c1', children: [] }] }, + policyRoles: ['Owner', 'User'], + policyGroups: [{ name: 'g1' }], + policyTopics: [{ name: 't1', description: 'x' }], + policyTokens: [{ tokenId: '0.0.99' }], + tools: [], ...overrides +}, polOpts); + +const richTool = (overrides = {}) => new ToolModel({ + id: 't-1', name: 'Tool', description: 'desc', hash: 'h-1', messageId: 'm-1', + config: { + blockType: 'tool', tag: 'tool-root', + children: [{ blockType: 'x', tag: 'c1', children: [] }], + inputEvents: [{ name: 'in1' }], outputEvents: [{ name: 'out1' }], variables: [{ name: 'v1' }] + }, ...overrides +}, polOpts); + +describe('DocumentComparator merge', () => { + it('mergeCompareResults aggregates multiple document comparisons', () => { + const comparator = new DocumentComparator(); + const results = comparator.compare([vcDoc(), vcDoc(), vcDoc()]); + const merged = comparator.mergeCompareResults(results); + assert.equal(merged.size, 3); + assert.equal(merged.rights.length, 2); + assert.equal(merged.totals.length, 2); + assert.ok(Array.isArray(merged.documents.columns)); + assert.ok(Array.isArray(merged.documents.report)); + }); + + it('merged document rows carry merged documents/options sub-rates', () => { + const comparator = new DocumentComparator(); + const merged = comparator.mergeCompareResults(comparator.compare([vcDoc(), vcDoc()])); + const row = merged.documents.report[0]; + assert.ok('documents' in row); + assert.ok('options' in row); + }); + + it('a relationship present on one side only yields a single-sided row', () => { + const child = vcDoc({ id: 'child' }); + const left = vcDoc({ id: 'left' }); + left.setRelationships([child]); + const right = vcDoc({ id: 'right' }); + const [result] = new DocumentComparator().compare([left, right]); + const types = result.documents.report.map(r => r.type); + assert.ok(result.documents.report.length >= 2); + assert.ok(types.includes('LEFT') || types.includes('RIGHT')); + }); + + it('differing documents still compare and produce a numeric total', () => { + const a = vcDoc({ document: { '@context': ['https://x'], type: 'VerifiableCredential', credentialSubject: { type: 'Sub', amount: 5, extra: 1 } } }); + const b = vcDoc({ document: { '@context': ['https://x'], type: 'VerifiableCredential', credentialSubject: { type: 'Sub', amount: 9 } } }); + const [result] = new DocumentComparator().compare([a, b]); + assert.equal(typeof result.total, 'number'); + assert.ok(result.total <= 100); + }); +}); + +describe('PolicyComparator rich merge and csv', () => { + it('rich policies produce non-empty role/group/topic/token reports', () => { + const [result] = new PolicyComparator().compare([richPolicy(), richPolicy()]); + assert.ok(result.roles.report.length > 0); + assert.ok(result.groups.report.length > 0); + assert.ok(result.topics.report.length > 0); + assert.ok(result.tokens.report.length > 0); + }); + + it('mergeCompareResults of rich policies fills every section', () => { + const comparator = new PolicyComparator(); + const a = richPolicy(); + const b = richPolicy({ + config: { blockType: 'root', tag: 'root', children: [{ blockType: 'y', tag: 'c2', children: [] }] }, + policyRoles: ['Admin'] + }); + const results = comparator.compare([a, b, richPolicy()]); + const merged = comparator.mergeCompareResults(results); + assert.equal(merged.size, 3); + for (const key of ['blocks', 'roles', 'groups', 'topics', 'tokens', 'tools']) { + assert.ok(merged[key], `missing ${key}`); + assert.ok(Array.isArray(merged[key].report), `${key} report not array`); + } + }); + + it('structurally different policies yield total <= 100', () => { + const a = richPolicy(); + const b = richPolicy({ + config: { blockType: 'root', tag: 'root', children: [{ blockType: 'y', tag: 'c2', children: [] }] }, + policyRoles: ['Admin'] + }); + const [result] = new PolicyComparator().compare([a, b]); + assert.ok(result.total <= 100); + }); + + it('tableToCsv of rich policies produces a CSV data-uri', () => { + const comparator = new PolicyComparator(); + const results = comparator.compare([richPolicy(), richPolicy()]); + assert.match(comparator.tableToCsv(results), /^data:text\/csv/); + }); +}); + +describe('ToolComparator rich merge and csv', () => { + it('rich tools produce non-empty input/output/variable reports', () => { + const [result] = new ToolComparator().compare([richTool(), richTool()]); + assert.ok(result.inputEvents.report.length > 0); + assert.ok(result.outputEvents.report.length > 0); + assert.ok(result.variables.report.length > 0); + }); + + it('mergeCompareResults of rich tools fills every section', () => { + const a = richTool(); + const b = richTool({ + config: { + blockType: 'tool', tag: 'tool-root', + children: [{ blockType: 'y', tag: 'c2', children: [] }], + inputEvents: [{ name: 'in2' }], outputEvents: [], variables: [] + } + }); + const results = new ToolComparator().compare([a, b, richTool()]); + const merged = ToolComparator.mergeCompareResults(results); + assert.equal(merged.size, 3); + for (const key of ['blocks', 'inputEvents', 'outputEvents', 'variables']) { + assert.ok(merged[key], `missing ${key}`); + assert.ok(Array.isArray(merged[key].report), `${key} report not array`); + } + }); + + it('tableToCsv of rich tools produces a CSV data-uri', () => { + const results = new ToolComparator().compare([richTool(), richTool()]); + assert.match(ToolComparator.tableToCsv(results), /^data:text\/csv/); + }); + + it('structurally different tools yield total <= 100', () => { + const a = richTool(); + const b = richTool({ + config: { + blockType: 'tool', tag: 'tool-root', + children: [{ blockType: 'y', tag: 'c2', children: [] }], + inputEvents: [], outputEvents: [], variables: [] + } + }); + const [result] = new ToolComparator().compare([a, b]); + assert.ok(result.total <= 100); + }); +}); diff --git a/guardian-service/tests/unit/w5-schema-module-comparators.test.mjs b/guardian-service/tests/unit/w5-schema-module-comparators.test.mjs new file mode 100644 index 0000000000..3a8701df59 --- /dev/null +++ b/guardian-service/tests/unit/w5-schema-module-comparators.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { SchemaComparator } from '../../dist/analytics/compare/comparators/schema-comparator.js'; +import { ModuleComparator } from '../../dist/analytics/compare/comparators/module-comparator.js'; +import { SchemaModel } from '../../dist/analytics/compare/models/schema.model.js'; +import { ModuleModel } from '../../dist/analytics/compare/models/module.model.js'; +import { + CompareOptions, IPropertiesLvl, IChildrenLvl, IEventsLvl, IIdLvl, IKeyLvl, IRefLvl +} from '../../dist/analytics/compare/interfaces/index.js'; + +const schemaOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All' }; +const moduleOpts = { propLvl: 'All', keyLvl: 'Default', idLvl: 'All', eventLvl: 'All' }; + +const schema = (overrides = {}) => new SchemaModel({ + id: 'sid', name: 'My Schema', uuid: 'sid-uuid', description: 'desc', topicId: '0.0.1', + version: '1.0.0', iri: '#sid', + document: { properties: { amount: { type: 'string', title: 'A', description: 'A' } } }, + ...overrides +}, schemaOpts); + +const minimalConfig = (overrides = {}) => ({ + blockType: 'root', tag: 'root', children: [], + inputEvents: [], outputEvents: [], variables: [], ...overrides +}); + +const module = (overrides = {}) => new ModuleModel({ + id: 'mod-1', name: 'My Module', description: 'desc', + config: minimalConfig(overrides.config), ...overrides +}, moduleOpts); + +describe('SchemaComparator branches', () => { + it('accepts an explicit CompareOptions instance', () => { + const opts = new CompareOptions( + IPropertiesLvl.All, IChildrenLvl.None, IEventsLvl.None, + IIdLvl.All, IKeyLvl.Default, IRefLvl.Default, null + ); + const comparator = new SchemaComparator(opts); + const result = comparator.compare(schema(), schema()); + assert.equal(typeof result.total, 'number'); + }); + + it('empty schemas (no fields) compare as fully similar', () => { + const empty = schema({ document: { properties: {} } }); + const result = new SchemaComparator().compare(empty, empty); + assert.equal(result.total, 100); + }); + + it('schemas with disjoint fields drop below full similarity', () => { + const a = schema(); + const b = schema({ document: { properties: { other: { type: 'number', title: 'B', description: 'B' } } } }); + const result = new SchemaComparator().compare(a, b); + assert.ok(result.total < 100); + assert.ok(result.fields.report.length > 0); + }); + + it('same-named but differing fields exercise the PARTLY branch', () => { + const a = schema({ document: { properties: { amount: { type: 'string', title: 'A', description: 'A' } } } }); + const b = schema({ document: { properties: { amount: { type: 'number', title: 'Different', description: 'D2' } } } }); + const result = new SchemaComparator().compare(a, b); + assert.ok(result.fields.report.some(r => r.type === 'PARTLY')); + assert.ok(result.total < 100); + }); + + it('csv produces a CSV data-uri carrying schema metadata', () => { + const result = new SchemaComparator().compare(schema(), schema()); + const csv = new SchemaComparator().csv(result); + assert.match(csv, /^data:text\/csv/); + }); + + it('csv of differing schemas still produces a CSV', () => { + const a = schema(); + const b = schema({ name: 'Other', document: { properties: { x: { type: 'number', title: 'X', description: 'X' } } } }); + const result = new SchemaComparator().compare(a, b); + assert.match(new SchemaComparator().csv(result), /^data:text\/csv/); + }); +}); + +describe('ModuleComparator branches', () => { + it('accepts an explicit CompareOptions instance', () => { + const opts = new CompareOptions( + IPropertiesLvl.All, IChildrenLvl.All, IEventsLvl.All, + IIdLvl.All, IKeyLvl.Default, IRefLvl.Default, null + ); + const comparator = new ModuleComparator(opts); + const result = comparator.compare(module(), module()); + assert.equal(typeof result.total, 'number'); + }); + + it('modules with differing child blocks exercise structural diffing', () => { + const a = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', children: [] }] }) }); + const b = module({ config: minimalConfig({ children: [{ blockType: 'y', tag: 'c2', children: [] }] }) }); + const result = new ModuleComparator().compare(a, b); + assert.ok(result.total <= 100); + assert.ok(result.blocks.report.length >= 2); + }); + + it('modules where one side has extra children (left-only / right-only)', () => { + const a = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', children: [{ blockType: 'z', tag: 'leaf', children: [] }] }] }) }); + const b = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', children: [] }] }) }); + const result = new ModuleComparator().compare(a, b); + assert.ok(result.total <= 100); + }); + + it('modules with same-tag but differing props hit the PARTLY branch', () => { + const a = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', extra: 1, children: [] }] }) }); + const b = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', extra: 2, children: [] }] }) }); + const result = new ModuleComparator().compare(a, b); + assert.ok(result.total <= 100); + }); + + it('modules with events and variables fill those report sections', () => { + const a = module({ config: minimalConfig({ inputEvents: [{ name: 'in1' }], outputEvents: [{ name: 'out1' }], variables: [{ name: 'v1' }] }) }); + const b = module({ config: minimalConfig({ inputEvents: [{ name: 'in2' }], outputEvents: [], variables: [] }) }); + const result = new ModuleComparator().compare(a, b); + assert.ok(result.inputEvents.report.length > 0); + }); + + it('csv produces a CSV data-uri', () => { + const a = module({ config: minimalConfig({ children: [{ blockType: 'x', tag: 'c1', children: [] }], inputEvents: [{ name: 'in1' }], variables: [{ name: 'v1' }] }) }); + const b = module({ config: minimalConfig({ children: [{ blockType: 'y', tag: 'c2', children: [] }] }) }); + const result = new ModuleComparator().compare(a, b); + assert.match(new ModuleComparator().csv(result), /^data:text\/csv/); + }); +}); diff --git a/interfaces/package.json b/interfaces/package.json index 2f26d28f4b..f8922d6ceb 100644 --- a/interfaces/package.json +++ b/interfaces/package.json @@ -24,7 +24,7 @@ "dev": "tsc -w", "lint": "tslint --config ../tslint.json --project .", "prepack": "npm run build", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha tests/**/*.test.mjs --reporter mocha-junit-reporter --reporter-options mochaFile=../test_results/interfaces.xml --exit" }, "type": "module", "version": "3.6.0" diff --git a/interfaces/tests/adapter-from-deprecations.test.mjs b/interfaces/tests/adapter-from-deprecations.test.mjs new file mode 100644 index 0000000000..e766cd7bfa --- /dev/null +++ b/interfaces/tests/adapter-from-deprecations.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { + getDeprecationMessagesForBlock, + getDeprecationMessagesForProperties, +} from '../dist/validators/policy-messages/adapter-from-deprecations.js'; + +describe('getDeprecationMessagesForBlock', () => { + it('returns [] when the block type has no deprecation registered', () => { + assert.deepEqual(getDeprecationMessagesForBlock('not-deprecated'), []); + }); +}); + +describe('getDeprecationMessagesForProperties', () => { + it('returns [] when usedProperties is undefined', () => { + assert.deepEqual( + getDeprecationMessagesForProperties('any-block', undefined), + [], + ); + }); + + it('returns [] when block has no property deprecations registered', () => { + assert.deepEqual( + getDeprecationMessagesForProperties('not-tracked', { foo: 'bar' }), + [], + ); + }); + + it('returns [] when usedProperties is an empty object', () => { + assert.deepEqual( + getDeprecationMessagesForProperties('not-tracked', {}), + [], + ); + }); +}); diff --git a/interfaces/tests/deprecation-adapter-entries.test.mjs b/interfaces/tests/deprecation-adapter-entries.test.mjs new file mode 100644 index 0000000000..078a967ea3 --- /dev/null +++ b/interfaces/tests/deprecation-adapter-entries.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { + getDeprecationMessagesForBlock, + getDeprecationMessagesForProperties, +} from '../dist/validators/policy-messages/adapter-from-deprecations.js'; +import { DEPRECATED_BLOCKS, DEPRECATED_PROPERTIES } from '../dist/validators/deprecations/index.js'; +import { MSG_DEPRECATION_BLOCK, MSG_DEPRECATION_PROP } from '../dist/validators/policy-messages/types.js'; + +describe('getDeprecationMessagesForBlock with registered entries', () => { + before(() => { + DEPRECATED_BLOCKS.set('unitTestBlockFull', { + severity: 'error', + since: 'since 2.0', + alternative: 'use otherBlock', + alternativeBlockType: 'otherBlock', + reason: 'because', + removalPlanned: 'in 3.0', + }); + DEPRECATED_BLOCKS.set('unitTestBlockBare', {}); + DEPRECATED_BLOCKS.set('unitTestBlockSpacey', { + alternative: ' padded ', + reason: ' ', + }); + }); + + after(() => { + DEPRECATED_BLOCKS.delete('unitTestBlockFull'); + DEPRECATED_BLOCKS.delete('unitTestBlockBare'); + DEPRECATED_BLOCKS.delete('unitTestBlockSpacey'); + }); + + it('returns one message for a deprecated block', () => { + const messages = getDeprecationMessagesForBlock('unitTestBlockFull'); + assert.equal(messages.length, 1); + assert.equal(messages[0].code, MSG_DEPRECATION_BLOCK); + assert.equal(messages[0].blockType, 'unitTestBlockFull'); + }); + + it('uses the registered severity', () => { + assert.equal(getDeprecationMessagesForBlock('unitTestBlockFull')[0].severity, 'error'); + }); + + it('defaults severity to warning when unset', () => { + assert.equal(getDeprecationMessagesForBlock('unitTestBlockBare')[0].severity, 'warning'); + }); + + it('composes the text from name plus every populated info field', () => { + const [message] = getDeprecationMessagesForBlock('unitTestBlockFull'); + assert.equal( + message.text, + '"unitTestBlockFull" was deprecated. use otherBlock otherBlock because since 2.0 in 3.0', + ); + }); + + it('omits empty info fields from the text', () => { + const [message] = getDeprecationMessagesForBlock('unitTestBlockBare'); + assert.equal(message.text, '"unitTestBlockBare" was deprecated.'); + }); + + it('trims info fields and drops whitespace-only ones', () => { + const [message] = getDeprecationMessagesForBlock('unitTestBlockSpacey'); + assert.equal(message.text, '"unitTestBlockSpacey" was deprecated. padded'); + }); + + it('carries since and removalPlanned through to the message', () => { + const [message] = getDeprecationMessagesForBlock('unitTestBlockFull'); + assert.equal(message.since, 'since 2.0'); + assert.equal(message.removalPlanned, 'in 3.0'); + }); +}); + +describe('getDeprecationMessagesForProperties with registered entries', () => { + before(() => { + DEPRECATED_PROPERTIES.set('unitTestPropsBlock', new Map([ + ['uiMetaData.title', { severity: 'info', reason: 'use header' }], + ['plain', {}], + ['items[0].name', {}], + ])); + }); + + after(() => { + DEPRECATED_PROPERTIES.delete('unitTestPropsBlock'); + }); + + it('emits messages only for properties present in the configuration', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { plain: 1 }); + assert.equal(messages.length, 1); + assert.equal(messages[0].property, 'plain'); + assert.equal(messages[0].code, MSG_DEPRECATION_PROP); + }); + + it('resolves dot-delimited nested paths', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { + uiMetaData: { title: 'x' }, + }); + assert.equal(messages.length, 1); + assert.equal(messages[0].property, 'uiMetaData.title'); + assert.equal(messages[0].severity, 'info'); + assert.equal(messages[0].text, '"uiMetaData.title" was deprecated. use header'); + }); + + it('resolves bracketed array index paths', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { + items: [{ name: 'first' }], + }); + assert.equal(messages.length, 1); + assert.equal(messages[0].property, 'items[0].name'); + }); + + it('treats a null property value as used', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { plain: null }); + assert.equal(messages.length, 1); + }); + + it('skips properties whose parent path is missing', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { + uiMetaData: 'not-an-object', + }); + assert.deepEqual(messages, []); + }); + + it('returns [] for a non-object configuration', () => { + assert.deepEqual(getDeprecationMessagesForProperties('unitTestPropsBlock', 'text'), []); + }); + + it('emits one message per matched property in registry order', () => { + const messages = getDeprecationMessagesForProperties('unitTestPropsBlock', { + uiMetaData: { title: 't' }, + plain: true, + }); + assert.deepEqual(messages.map((m) => m.property), ['uiMetaData.title', 'plain']); + }); + + it('defaults property message severity to warning', () => { + const [message] = getDeprecationMessagesForProperties('unitTestPropsBlock', { plain: 1 }); + assert.equal(message.severity, 'warning'); + }); +}); diff --git a/interfaces/tests/deprecations-registry.test.mjs b/interfaces/tests/deprecations-registry.test.mjs new file mode 100644 index 0000000000..85325fa708 --- /dev/null +++ b/interfaces/tests/deprecations-registry.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { + DEPRECATED_BLOCKS, + DEPRECATED_PROPERTIES, +} from '../dist/validators/deprecations/registry.js'; + +describe('Deprecations registries', () => { + it('DEPRECATED_BLOCKS is a Map', () => { + assert.ok(DEPRECATED_BLOCKS instanceof Map); + }); + + it('DEPRECATED_PROPERTIES is a Map of Maps', () => { + assert.ok(DEPRECATED_PROPERTIES instanceof Map); + for (const value of DEPRECATED_PROPERTIES.values()) { + assert.ok(value instanceof Map); + } + }); + + it('returns undefined for unknown block / property keys', () => { + assert.equal(DEPRECATED_BLOCKS.get('mystery'), undefined); + assert.equal(DEPRECATED_PROPERTIES.get('mystery'), undefined); + }); +}); diff --git a/interfaces/tests/document-generator-formats-extra.test.mjs b/interfaces/tests/document-generator-formats-extra.test.mjs new file mode 100644 index 0000000000..9d87307ee7 --- /dev/null +++ b/interfaces/tests/document-generator-formats-extra.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { DocumentGenerator } from '../dist/helpers/generate-document.js'; + +describe('DocumentGenerator string format examples', () => { + it('generates a date', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'date' }), '2000-01-01'); + }); + + it('generates a time', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'time' }), '00:00:00'); + }); + + it('generates a date-time', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'date-time' }), '2000-01-01T01:00:00.000Z'); + }); + + it('generates a duration', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'duration' }), 'P1D'); + }); + + it('generates a url and a uri', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'url' }), 'https://example.com'); + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'uri' }), 'example:uri'); + }); + + it('generates an email', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'email' }), 'example@email.com'); + }); + + it('falls through unknown formats to the plain string default', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', format: 'ipv4' }), 'example'); + }); +}); + +describe('DocumentGenerator pattern-based strings', () => { + it('generates an ipfs link for the ipfs pattern', () => { + const value = DocumentGenerator.generateExample({ type: 'string', pattern: '^ipfs:\/\/.+' }); + assert.ok(value.startsWith('ipfs://')); + assert.ok(value.length > 'ipfs://'.length); + }); + + it('generates an opaque id for any other pattern', () => { + const value = DocumentGenerator.generateExample({ type: 'string', pattern: '^\\d+$' }); + assert.equal(typeof value, 'string'); + assert.ok(value.length > 0); + }); +}); + +describe('DocumentGenerator precedence rules', () => { + it('prefers a rowPreset over examples and default', () => { + const field = { name: 'f', type: 'string', examples: ['ex'], default: 'def' }; + assert.equal(DocumentGenerator.generateField(field, ['ctx'], null, { f: 'preset' }), 'preset'); + }); + + it('accepts a falsy-but-defined rowPreset', () => { + const field = { name: 'f', type: 'number' }; + assert.equal(DocumentGenerator.generateField(field, ['ctx'], null, { f: 0 }), 0); + }); + + it('skips an empty-string example and falls back to default', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', examples: [''], default: 'def' }), 'def'); + }); + + it('ignores a non-array examples value', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'number', examples: 'nope' }), 1); + }); +}); + +describe('DocumentGenerator array handling', () => { + it('keeps an already-array value un-nested', () => { + const field = { name: 'f', type: 'string', isArray: true, examples: [['a', 'b']] }; + assert.deepEqual(DocumentGenerator.generateField(field, ['ctx'], null, {}), ['a', 'b']); + }); + + it('wraps a scalar in an array', () => { + const field = { name: 'f', type: 'integer', isArray: true }; + assert.deepEqual(DocumentGenerator.generateField(field, ['ctx'], null, {}), [1]); + }); + + it('returns undefined rather than [undefined] for null-typed array fields', () => { + const field = { name: 'f', type: 'null', isArray: true }; + assert.equal(DocumentGenerator.generateField(field, ['ctx'], null, {}), undefined); + }); +}); + +describe('DocumentGenerator unknown types', () => { + it('returns undefined for an unrecognised type', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'mystery' }), undefined); + }); + + it('returns undefined for enum fields without values', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', customType: 'enum' }), undefined); + }); + + it('returns the first enum value when present', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string', customType: 'enum', enum: ['x', 'y'] }), 'x'); + }); +}); diff --git a/interfaces/tests/document-generator-geojson-sentinel.test.mjs b/interfaces/tests/document-generator-geojson-sentinel.test.mjs new file mode 100644 index 0000000000..f37ba43259 --- /dev/null +++ b/interfaces/tests/document-generator-geojson-sentinel.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { DocumentGenerator } from '../dist/helpers/generate-document.js'; + +const geoField = (extra = {}) => ({ name: 'geo', type: '#GeoJSON', isRef: true, ...extra }); + +describe('DocumentGenerator GeoJSON generation', () => { + it('produces a FeatureCollection with a single Point feature by default', () => { + const value = DocumentGenerator.generateField(geoField(), ['ctx'], null, {}); + assert.equal(value.type, 'FeatureCollection'); + assert.equal(value.features.length, 1); + assert.equal(value.features[0].type, 'Feature'); + assert.deepEqual(value.features[0].properties, {}); + assert.equal(value.features[0].geometry.type, 'Point'); + assert.deepEqual(value.features[0].geometry.coordinates, [0, 0]); + }); + + it('uses the first availableOptions entry as the geometry type', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['Polygon'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'Polygon'); + assert.equal(Array.isArray(value.features[0].geometry.coordinates[0]), true); + assert.equal(value.features[0].geometry.coordinates[0].length, 4); + }); + + it('generates LineString coordinates as an array of positions', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['LineString'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'LineString'); + assert.equal(value.features[0].geometry.coordinates.length, 2); + assert.equal(value.features[0].geometry.coordinates[0].length, 2); + }); + + it('generates MultiPoint coordinates', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['MultiPoint'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'MultiPoint'); + assert.equal(value.features[0].geometry.coordinates.length, 3); + }); + + it('generates MultiLineString coordinates', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['MultiLineString'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'MultiLineString'); + assert.equal(value.features[0].geometry.coordinates[0].length, 3); + }); + + it('generates MultiPolygon coordinates', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['MultiPolygon'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'MultiPolygon'); + assert.equal(value.features[0].geometry.coordinates[0][0].length, 4); + }); + + it('falls back to Point coordinates for an unknown geometry type', () => { + const value = DocumentGenerator.generateField(geoField({ availableOptions: ['Hexagon'] }), ['ctx'], null, {}); + assert.equal(value.features[0].geometry.type, 'Hexagon'); + assert.deepEqual(value.features[0].geometry.coordinates, [0, 0]); + }); + + it('returns the preset object verbatim when a plain-object preset matches the field name', () => { + const preset = { type: 'FeatureCollection', features: [] }; + const value = DocumentGenerator.generateField(geoField(), ['ctx'], null, { geo: { geo: preset } }); + assert.equal(value, preset); + }); +}); + +describe('DocumentGenerator SentinelHUB generation', () => { + const shField = () => ({ name: 'sh', type: '#SentinelHUB', isRef: true }); + + it('produces the canonical sentinel request shape', () => { + const value = DocumentGenerator.generateField(shField(), ['ctx'], null, {}); + assert.deepEqual(value, { + '@context': ['ctx'], + layers: 'NATURAL-COLOR', + format: 'image/jpeg', + maxcc: 10, + width: 10, + height: 10, + bbox: '1111,2222,3333,4444', + time: '2000-01-01/2000-02-01', + }); + }); + + it('returns the preset object verbatim when a plain-object preset matches', () => { + const preset = { layers: 'CUSTOM' }; + const value = DocumentGenerator.generateField(shField(), ['ctx'], null, { sh: { sh: preset } }); + assert.equal(value, preset); + }); + + it('ignores a non-object preset and still generates defaults', () => { + const value = DocumentGenerator.generateField(shField(), ['ctx'], null, { sh: { sh: 'not-an-object' } }); + assert.equal(value.layers, 'NATURAL-COLOR'); + }); +}); + +describe('DocumentGenerator ref field with examples', () => { + it('uses the first example instead of generating a sub-document', () => { + const field = geoField({ examples: [{ ready: true }] }); + const value = DocumentGenerator.generateField(field, ['ctx'], null, {}); + assert.deepEqual(value, { ready: true }); + }); + + it('wraps a generated GeoJSON value in an array for isArray fields', () => { + const value = DocumentGenerator.generateField(geoField({ isArray: true }), ['ctx'], null, {}); + assert.equal(Array.isArray(value), true); + assert.equal(value[0].type, 'FeatureCollection'); + }); +}); diff --git a/interfaces/tests/document-generator-subdoc.test.mjs b/interfaces/tests/document-generator-subdoc.test.mjs new file mode 100644 index 0000000000..671551a4ad --- /dev/null +++ b/interfaces/tests/document-generator-subdoc.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { DocumentGenerator } from '../dist/helpers/generate-document.js'; + +const subField = (extra = {}) => ({ + name: 'child', + type: '#ChildSchema&1.0.0', + isRef: true, + fields: [ + { name: 'amount', type: 'number' }, + { name: 'label', type: 'string' }, + ], + ...extra, +}); + +describe('DocumentGenerator sub-document generation', () => { + it('builds a nested document with type and @context', () => { + const value = DocumentGenerator.generateField(subField(), ['iri'], null, {}); + assert.equal(value.type, 'ChildSchema&1.0.0'); + assert.deepEqual(value['@context'], ['iri']); + assert.equal(value.amount, 1); + assert.equal(value.label, 'example'); + }); + + it('omits undefined nested values', () => { + const field = subField({ fields: [{ name: 'note', type: 'null' }] }); + const value = DocumentGenerator.generateField(field, ['iri'], null, {}); + assert.equal('note' in value, false); + }); + + it('applies nested rowPresets keyed by parent then child name', () => { + const value = DocumentGenerator.generateField(subField(), ['iri'], null, { child: { amount: 42 } }); + assert.equal(value.amount, 42); + assert.equal(value.label, 'example'); + }); + + it('wraps the sub-document in an array for isArray fields', () => { + const value = DocumentGenerator.generateField(subField({ isArray: true }), ['iri'], null, {}); + assert.equal(Array.isArray(value), true); + assert.equal(value[0].type, 'ChildSchema&1.0.0'); + }); + + it('recurses through multiple levels of refs', () => { + const field = subField({ + fields: [{ + name: 'inner', + type: '#Inner&1.0.0', + isRef: true, + fields: [{ name: 'deep', type: 'boolean' }], + }], + }); + const value = DocumentGenerator.generateField(field, ['iri'], null, {}); + assert.equal(value.inner.type, 'Inner&1.0.0'); + assert.equal(value.inner.deep, true); + }); +}); + +describe('DocumentGenerator.generateDocument', () => { + const schema = () => ({ + iri: '#Root&1.0.0', + type: 'Root', + fields: [ + { name: 'n', type: 'integer' }, + { name: 'skip', type: 'null' }, + { name: 's', type: 'string', isArray: true }, + ], + }); + + it('uses the schema iri as the @context and the schema type as type', () => { + const doc = DocumentGenerator.generateDocument(schema(), null, {}); + assert.deepEqual(doc['@context'], ['#Root&1.0.0']); + assert.equal(doc.type, 'Root'); + }); + + it('assigns a uuid-shaped id', () => { + const doc = DocumentGenerator.generateDocument(schema(), null, {}); + assert.match(doc.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('skips fields whose generated value is undefined', () => { + const doc = DocumentGenerator.generateDocument(schema(), null, {}); + assert.equal('skip' in doc, false); + assert.equal(doc.n, 1); + assert.deepEqual(doc.s, ['example']); + }); + + it('falls back to the default option when option is omitted', () => { + const doc = DocumentGenerator.generateDocument(schema(), undefined, undefined); + assert.equal(doc.n, 1); + }); + + it('generates distinct ids per invocation', () => { + const a = DocumentGenerator.generateDocument(schema(), null, {}); + const b = DocumentGenerator.generateDocument(schema(), null, {}); + assert.notEqual(a.id, b.id); + }); +}); diff --git a/interfaces/tests/document-generator.test.mjs b/interfaces/tests/document-generator.test.mjs new file mode 100644 index 0000000000..35d862968a --- /dev/null +++ b/interfaces/tests/document-generator.test.mjs @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { DocumentGenerator } from '../dist/helpers/generate-document.js'; + +const field = (overrides) => ({ + name: 'f', + type: null, + format: null, + pattern: null, + isRef: false, + isArray: false, + examples: null, + default: null, + customType: null, + ...overrides, +}); + +describe('DocumentGenerator.generateExample (simple field)', () => { + it('returns 1 for number / integer types', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'number' })), 1); + assert.equal(DocumentGenerator.generateExample(field({ type: 'integer' })), 1); + }); + + it('returns true for boolean type', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'boolean' })), true); + }); + + it('returns "example" for plain string with no format/pattern', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'string' })), 'example'); + }); + + describe('string formats', () => { + const cases = { + date: '2000-01-01', + time: '00:00:00', + 'date-time': '2000-01-01T01:00:00.000Z', + duration: 'P1D', + url: 'https://example.com', + uri: 'example:uri', + email: 'example@email.com', + }; + for (const [format, expected] of Object.entries(cases)) { + it(`format=${format} → "${expected}"`, () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', format })), + expected, + ); + }); + } + }); + + describe('customType handling', () => { + it('hederaAccount returns "0.0.1"', () => { + assert.equal( + DocumentGenerator.generateExample( + field({ type: 'string', customType: 'hederaAccount' }), + ), + '0.0.1', + ); + }); + + it('table returns the canonical table-CID JSON snippet', () => { + const out = DocumentGenerator.generateExample( + field({ type: 'string', customType: 'table' }), + ); + assert.match(out, /^\{"type":"table"/); + }); + + it('enum returns the first enum value, or undefined when no values', () => { + assert.equal( + DocumentGenerator.generateExample( + field({ type: 'string', customType: 'enum', enum: ['A', 'B'] }), + ), + 'A', + ); + assert.equal( + DocumentGenerator.generateExample( + field({ type: 'string', customType: 'enum' }), + ), + undefined, + ); + }); + }); + + describe('preference order: examples > default > inferred', () => { + it('uses examples[0] when provided', () => { + assert.equal( + DocumentGenerator.generateExample( + field({ type: 'string', examples: ['picked'] }), + ), + 'picked', + ); + }); + + it('uses default when examples is missing', () => { + assert.equal( + DocumentGenerator.generateExample( + field({ type: 'string', default: 'fallback' }), + ), + 'fallback', + ); + }); + }); + + it('returns undefined for null type', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'null' })), undefined); + }); +}); + +describe('DocumentGenerator.generateDocument (top-level shape)', () => { + it('generates id, type, @context and a value for each scalar field', () => { + const schema = { + iri: '#schema-1', + type: 'schema-1', + fields: [ + field({ name: 'a', type: 'string' }), + field({ name: 'b', type: 'integer' }), + ], + }; + const doc = DocumentGenerator.generateDocument(schema); + assert.ok(doc.id, 'should have an id'); + assert.equal(doc.type, 'schema-1'); + assert.deepEqual(doc['@context'], ['#schema-1']); + assert.equal(doc.a, 'example'); + assert.equal(doc.b, 1); + }); + + it('honours rowPresets for matching field names', () => { + const schema = { + iri: '#s1', + type: 's1', + fields: [field({ name: 'name', type: 'string' })], + }; + const doc = DocumentGenerator.generateDocument(schema, undefined, { name: 'override' }); + assert.equal(doc.name, 'override'); + }); + + it('wraps a scalar value in an array when field.isArray=true', () => { + const schema = { + iri: '#s1', + type: 's1', + fields: [field({ name: 'tags', type: 'string', isArray: true })], + }; + const doc = DocumentGenerator.generateDocument(schema); + assert.ok(Array.isArray(doc.tags)); + assert.equal(doc.tags[0], 'example'); + }); + + it('keeps an already-array preset as a single array (no double-wrap)', () => { + const schema = { + iri: '#s1', + type: 's1', + fields: [field({ name: 'tags', type: 'string', isArray: true })], + }; + const doc = DocumentGenerator.generateDocument(schema, undefined, { tags: ['a', 'b'] }); + assert.deepEqual(doc.tags, ['a', 'b']); + }); +}); + +describe('DocumentGenerator.generateExample — ipfs pattern', () => { + it('prefixes ipfs:// for the ipfs pattern', () => { + const value = DocumentGenerator.generateExample(field({ type: 'string', pattern: '^ipfs://.+' })); + assert.ok(value.startsWith('ipfs://'), `expected ipfs:// prefix, got ${value}`); + }); + + it('returns a generated id (not "example") for a non-ipfs pattern', () => { + const value = DocumentGenerator.generateExample(field({ type: 'string', pattern: '^[0-9]+$' })); + assert.equal(typeof value, 'string'); + assert.ok(value.length > 0); + assert.ok(!value.startsWith('ipfs://')); + assert.notEqual(value, 'example'); + }); +}); + +describe('DocumentGenerator.generateDocument — reference fields', () => { + const refDoc = (fieldDef, rowPresets) => DocumentGenerator.generateDocument( + { iri: '#s1', type: 's1', fields: [fieldDef] }, + undefined, + rowPresets, + ); + + it('generates a GeoJSON FeatureCollection using availableOptions[0] as the geometry type', () => { + const doc = refDoc(field({ name: 'geo', isRef: true, type: '#GeoJSON', availableOptions: ['Polygon'] })); + assert.equal(doc.geo.type, 'FeatureCollection'); + assert.equal(doc.geo.features[0].geometry.type, 'Polygon'); + assert.equal(doc.geo.features[0].geometry.coordinates[0][0][0], -77.9584065268336); + }); + + it('defaults GeoJSON geometry to Point [0,0] when no availableOptions', () => { + const doc = refDoc(field({ name: 'geo', isRef: true, type: '#GeoJSON' })); + assert.equal(doc.geo.features[0].geometry.type, 'Point'); + assert.deepEqual(doc.geo.features[0].geometry.coordinates, [0.0, 0.0]); + }); + + it('falls back to Point coordinates for an unknown geometry type', () => { + const doc = refDoc(field({ name: 'geo', isRef: true, type: '#GeoJSON', availableOptions: ['Custom'] })); + assert.equal(doc.geo.features[0].geometry.type, 'Custom'); + assert.deepEqual(doc.geo.features[0].geometry.coordinates, [0.0, 0.0]); + }); + + it('generates a SentinelHub request object', () => { + const doc = refDoc(field({ name: 'sat', isRef: true, type: '#SentinelHUB' })); + assert.equal(doc.sat.layers, 'NATURAL-COLOR'); + assert.equal(doc.sat.format, 'image/jpeg'); + assert.equal(doc.sat.maxcc, 10); + assert.equal(doc.sat.width, 10); + assert.equal(doc.sat.height, 10); + assert.deepEqual(doc.sat['@context'], ['#s1']); + }); + + it('recurses into a sub-document, resolving its type via parseRef', () => { + const doc = refDoc(field({ + name: 'sub', + isRef: true, + type: '#TestSub', + fields: [field({ name: 'x', type: 'integer' })], + })); + assert.equal(doc.sub.x, 1); + assert.equal(doc.sub.type, 'TestSub'); + assert.deepEqual(doc.sub['@context'], ['#s1']); + }); + + it('wraps a reference value in an array when isArray=true', () => { + const doc = refDoc(field({ name: 'sat', isRef: true, type: '#SentinelHUB', isArray: true })); + assert.ok(Array.isArray(doc.sat)); + assert.equal(doc.sat[0].layers, 'NATURAL-COLOR'); + }); +}); diff --git a/interfaces/tests/document-state-status-invariants.test.mjs b/interfaces/tests/document-state-status-invariants.test.mjs new file mode 100644 index 0000000000..99b3986ef1 --- /dev/null +++ b/interfaces/tests/document-state-status-invariants.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { DocumentStatus } from '../dist/type/document-status.type.js'; +import { DidDocumentStatus } from '../dist/type/did-status.type.js'; +import { ApproveStatus } from '../dist/type/approve-status.type.js'; +import { SchemaStatus } from '../dist/type/schema-status.type.js'; + +const enums = { + DocumentStatus, + DidDocumentStatus, + ApproveStatus, + SchemaStatus, +}; + +describe('document-state enums — key/value identity', () => { + for (const [name, e] of Object.entries(enums)) { + it(`${name} maps every key to a string value equal to the key`, () => { + for (const [k, v] of Object.entries(e)) { + assert.equal(typeof v, 'string'); + assert.equal(k, v); + } + }); + } +}); + +describe('document-state enums — value uniqueness', () => { + for (const [name, e] of Object.entries(enums)) { + it(`${name} has no duplicate values`, () => { + const values = Object.values(e); + assert.equal(new Set(values).size, values.length); + }); + } +}); + +describe('document-state enums — reverse lookup', () => { + for (const [name, e] of Object.entries(enums)) { + it(`${name} resolves each value back to itself`, () => { + for (const v of Object.values(e)) { + assert.equal(e[v], v); + } + }); + } +}); + +describe('DocumentStatus membership', () => { + const expected = ['NEW', 'ISSUE', 'REVOKE', 'SUSPEND', 'RESUME', 'FAILED']; + it('contains exactly the expected members', () => { + assert.deepEqual(Object.values(DocumentStatus).sort(), [...expected].sort()); + }); + for (const m of expected) { + it(`includes ${m}`, () => assert.ok(Object.values(DocumentStatus).includes(m))); + } + it('starts a new document at NEW', () => { + assert.equal(DocumentStatus.NEW, 'NEW'); + }); +}); + +describe('DidDocumentStatus membership and lifecycle ordering', () => { + const expected = ['NEW', 'CREATE', 'UPDATE', 'DELETE', 'FAILED']; + it('contains exactly the expected members', () => { + assert.deepEqual(Object.values(DidDocumentStatus).sort(), [...expected].sort()); + }); + for (const m of expected) { + it(`includes ${m}`, () => assert.ok(Object.values(DidDocumentStatus).includes(m))); + } + it('the createDocument default (NEW) is a valid member', () => { + assert.ok(Object.values(DidDocumentStatus).includes(DidDocumentStatus.NEW)); + }); + it('terminal-ish states CREATE/UPDATE/DELETE are distinct from NEW', () => { + for (const s of [DidDocumentStatus.CREATE, DidDocumentStatus.UPDATE, DidDocumentStatus.DELETE]) { + assert.notEqual(s, DidDocumentStatus.NEW); + } + }); +}); + +describe('ApproveStatus membership and transitions', () => { + const expected = ['NEW', 'APPROVED', 'REJECTED']; + it('contains exactly the expected members', () => { + assert.deepEqual(Object.values(ApproveStatus).sort(), [...expected].sort()); + }); + for (const m of expected) { + it(`includes ${m}`, () => assert.ok(Object.values(ApproveStatus).includes(m))); + } + it('the approval-document default is NEW', () => { + assert.equal(ApproveStatus.NEW, 'NEW'); + }); + it('APPROVED and REJECTED are the two terminal outcomes', () => { + assert.notEqual(ApproveStatus.APPROVED, ApproveStatus.REJECTED); + assert.notEqual(ApproveStatus.APPROVED, ApproveStatus.NEW); + assert.notEqual(ApproveStatus.REJECTED, ApproveStatus.NEW); + }); +}); + +describe('SchemaStatus membership', () => { + const expected = ['DRAFT', 'PUBLISHED', 'UNPUBLISHED', 'ERROR', 'DEMO', 'VIEW']; + it('contains exactly the expected members', () => { + assert.deepEqual(Object.values(SchemaStatus).sort(), [...expected].sort()); + }); + for (const m of expected) { + it(`includes ${m}`, () => assert.ok(Object.values(SchemaStatus).includes(m))); + } + it('a draft schema is not published', () => { + assert.notEqual(SchemaStatus.DRAFT, SchemaStatus.PUBLISHED); + }); +}); + +describe('document-state enums — cross-enum NEW alignment', () => { + it('DocumentStatus, DidDocumentStatus and ApproveStatus all start at NEW', () => { + assert.equal(DocumentStatus.NEW, 'NEW'); + assert.equal(DidDocumentStatus.NEW, 'NEW'); + assert.equal(ApproveStatus.NEW, 'NEW'); + }); + it('all four enums share the FAILED/ERROR error concept where defined', () => { + assert.equal(DocumentStatus.FAILED, 'FAILED'); + assert.equal(DidDocumentStatus.FAILED, 'FAILED'); + assert.equal(SchemaStatus.ERROR, 'ERROR'); + }); + it('an unknown value is not a member of any document-state enum', () => { + for (const e of Object.values(enums)) { + assert.ok(!Object.values(e).includes('NOT_A_REAL_STATUS')); + } + }); +}); + +describe('document-state enums — frozen-shape guards', () => { + for (const [name, e] of Object.entries(enums)) { + it(`${name} exposes only string members`, () => { + assert.ok(Object.values(e).every((v) => typeof v === 'string')); + }); + it(`${name} has at least three members`, () => { + assert.ok(Object.values(e).length >= 3); + }); + } +}); diff --git a/interfaces/tests/entity-owner.test.mjs b/interfaces/tests/entity-owner.test.mjs new file mode 100644 index 0000000000..f05d1a2ad3 --- /dev/null +++ b/interfaces/tests/entity-owner.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { EntityOwner } from '../dist/models/entity-owner.js'; +import { AccessType } from '../dist/type/access.type.js'; +import { LocationType } from '../dist/type/location.type.js'; +import { Permissions } from '../dist/type/permissions.type.js'; +import { UserRole } from '../dist/type/user-role.type.js'; + +describe('EntityOwner constructor — null/empty user', () => { + it('falls back to a neutral envelope when user is null', () => { + const o = new EntityOwner(null); + assert.equal(o.id, null); + assert.equal(o.creator, null); + assert.equal(o.owner, null); + assert.equal(o.access, AccessType.NONE); + assert.equal(o.location, LocationType.LOCAL); + }); +}); + +describe('EntityOwner — STANDARD_REGISTRY role', () => { + it('uses the registry DID for both creator and owner, ALL access', () => { + const o = new EntityOwner({ + id: 42, + parent: 'p', + username: 'sr', + did: 'did:sr:1', + role: UserRole.STANDARD_REGISTRY, + location: LocationType.LOCAL, + }); + assert.equal(o.id, '42'); + assert.equal(o.parent, 'p'); + assert.equal(o.username, 'sr'); + assert.equal(o.creator, 'did:sr:1'); + assert.equal(o.owner, 'did:sr:1'); + assert.equal(o.access, AccessType.ALL); + }); +}); + +describe('EntityOwner — USER role, access derived from permissions', () => { + function user(perms = []) { + return { + id: 'u1', + parent: 'parent-did', + username: 'alice', + did: 'did:user:alice', + role: UserRole.USER, + permissions: perms, + location: LocationType.LOCAL, + }; + } + + it('sets creator=did, owner=parent', () => { + const o = new EntityOwner(user([])); + assert.equal(o.creator, 'did:user:alice'); + assert.equal(o.owner, 'parent-did'); + }); + + it('ACCESS_POLICY_ALL beats every other flag', () => { + const o = new EntityOwner(user([ + Permissions.ACCESS_POLICY_ALL, + Permissions.ACCESS_POLICY_ASSIGNED, + Permissions.ACCESS_POLICY_PUBLISHED, + ])); + assert.equal(o.access, AccessType.ALL); + }); + + it('ASSIGNED + PUBLISHED ⇒ ASSIGNED_OR_PUBLISHED', () => { + const o = new EntityOwner(user([ + Permissions.ACCESS_POLICY_ASSIGNED, + Permissions.ACCESS_POLICY_PUBLISHED, + ])); + assert.equal(o.access, AccessType.ASSIGNED_OR_PUBLISHED); + }); + + it('only ASSIGNED ⇒ ASSIGNED', () => { + const o = new EntityOwner(user([Permissions.ACCESS_POLICY_ASSIGNED])); + assert.equal(o.access, AccessType.ASSIGNED); + }); + + it('only PUBLISHED ⇒ PUBLISHED', () => { + const o = new EntityOwner(user([Permissions.ACCESS_POLICY_PUBLISHED])); + assert.equal(o.access, AccessType.PUBLISHED); + }); + + it('only ASSIGNED_AND_PUBLISHED ⇒ ASSIGNED_AND_PUBLISHED', () => { + const o = new EntityOwner(user([Permissions.ACCESS_POLICY_ASSIGNED_AND_PUBLISHED])); + assert.equal(o.access, AccessType.ASSIGNED_AND_PUBLISHED); + }); + + it('no relevant permissions ⇒ NONE', () => { + const o = new EntityOwner(user([])); + assert.equal(o.access, AccessType.NONE); + }); +}); + +describe('EntityOwner — unknown role', () => { + it('clears creator/owner and sets access to NONE', () => { + const o = new EntityOwner({ + id: 9, + parent: 'p', + username: 'x', + did: 'd', + role: 'SOME_OTHER_ROLE', + permissions: [Permissions.ACCESS_POLICY_ALL], + location: LocationType.LOCAL, + }); + assert.equal(o.creator, null); + assert.equal(o.owner, null); + assert.equal(o.access, AccessType.NONE); + }); +}); + +describe('EntityOwner.sr (static factory)', () => { + it('returns a registry envelope with ALL access and LOCAL location', () => { + const o = EntityOwner.sr('user-1', 'did:sr:42'); + assert.equal(o.id, 'user-1'); + assert.equal(o.creator, 'did:sr:42'); + assert.equal(o.owner, 'did:sr:42'); + assert.equal(o.username, null); + assert.equal(o.access, AccessType.ALL); + assert.equal(o.location, LocationType.LOCAL); + }); +}); diff --git a/interfaces/tests/enum-access.test.mjs b/interfaces/tests/enum-access.test.mjs new file mode 100644 index 0000000000..9822555970 --- /dev/null +++ b/interfaces/tests/enum-access.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { AccessType } from '../dist/type/access.type.js'; + +describe('AccessType enum', () => { + it('exposes all six access modes', () => { + for (const k of ['NONE', 'ASSIGNED', 'PUBLISHED', 'ASSIGNED_AND_PUBLISHED', 'ASSIGNED_OR_PUBLISHED', 'ALL']) { + assert.equal(AccessType[k], k); + } + }); + it('has exactly six entries', () => { + assert.equal(Object.keys(AccessType).length, 6); + }); +}); diff --git a/interfaces/tests/enum-application-states.test.mjs b/interfaces/tests/enum-application-states.test.mjs new file mode 100644 index 0000000000..6492500e56 --- /dev/null +++ b/interfaces/tests/enum-application-states.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { ApplicationStates } from '../dist/type/application-states.type.js'; + +describe('ApplicationStates enum', () => { + it('exposes the lifecycle states', () => { + const values = Object.values(ApplicationStates); + for (const expected of ['STARTED', 'WRONG_CONFIGURATION', 'INITIALIZING', 'READY', 'STOPPED', 'BAD_CONFIGURATION']) { + assert.ok(values.includes(expected), `missing ${expected}`); + } + }); + it('keys equal values (uppercase string enum)', () => { + for (const [k, v] of Object.entries(ApplicationStates)) assert.equal(k, v); + }); + it('has six entries', () => { + assert.equal(Object.keys(ApplicationStates).length, 6); + }); +}); diff --git a/interfaces/tests/enum-approve-status.test.mjs b/interfaces/tests/enum-approve-status.test.mjs new file mode 100644 index 0000000000..019a81bf94 --- /dev/null +++ b/interfaces/tests/enum-approve-status.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { ApproveStatus } from '../dist/type/approve-status.type.js'; + +describe('ApproveStatus enum', () => { + it('exposes NEW / APPROVED / REJECTED', () => { + assert.equal(ApproveStatus.NEW, 'NEW'); + assert.equal(ApproveStatus.APPROVED, 'APPROVED'); + assert.equal(ApproveStatus.REJECTED, 'REJECTED'); + }); + it('has exactly three entries', () => { + assert.equal(Object.keys(ApproveStatus).length, 3); + }); +}); diff --git a/interfaces/tests/enum-artifact.test.mjs b/interfaces/tests/enum-artifact.test.mjs new file mode 100644 index 0000000000..911f96cf05 --- /dev/null +++ b/interfaces/tests/enum-artifact.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { ArtifactType } from '../dist/type/artifact.type.js'; + +describe('ArtifactType enum', () => { + it('uses display strings (not the key names)', () => { + assert.equal(ArtifactType.EXECUTABLE_CODE, 'Executable Code'); + assert.equal(ArtifactType.JSON, 'JSON'); + assert.equal(Object.keys(ArtifactType).length, 2); + }); +}); diff --git a/interfaces/tests/enum-assigned-entity.test.mjs b/interfaces/tests/enum-assigned-entity.test.mjs new file mode 100644 index 0000000000..22682ad85b --- /dev/null +++ b/interfaces/tests/enum-assigned-entity.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { AssignedEntityType } from '../dist/type/assigned-entity.type.js'; + +describe('AssignedEntityType enum', () => { + it('covers Schema / Policy / Token / Module', () => { + const values = Object.values(AssignedEntityType); + for (const expected of ['Schema', 'Policy', 'Token', 'Module']) { + assert.ok(values.includes(expected), `missing ${expected}`); + } + }); + it('has exactly five entries', () => { + assert.equal(Object.keys(AssignedEntityType).length, 5); + }); +}); diff --git a/interfaces/tests/enum-auth-events.test.mjs b/interfaces/tests/enum-auth-events.test.mjs new file mode 100644 index 0000000000..ddff19bf64 --- /dev/null +++ b/interfaces/tests/enum-auth-events.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { AuthEvents } from '../dist/type/messages/auth-events.js'; + +describe('AuthEvents enum', () => { + it('uses identity values (key equals value) for every member', () => { + for (const [k, v] of Object.entries(AuthEvents)) { + assert.equal(k, v); + } + }); + + it('maps user/account subjects', () => { + assert.equal(AuthEvents.GET_USER_BY_TOKEN, 'GET_USER_BY_TOKEN'); + assert.equal(AuthEvents.REGISTER_NEW_USER, 'REGISTER_NEW_USER'); + assert.equal(AuthEvents.GENERATE_NEW_TOKEN, 'GENERATE_NEW_TOKEN'); + assert.equal(AuthEvents.GENERATE_NEW_ACCESS_TOKEN, 'GENERATE_NEW_ACCESS_TOKEN'); + assert.equal(AuthEvents.GET_ALL_USER_ACCOUNTS, 'GET_ALL_USER_ACCOUNTS'); + assert.equal(AuthEvents.GET_ALL_STANDARD_REGISTRY_ACCOUNTS, 'GET_ALL_STANDARD_REGISTRY_ACCOUNTS'); + }); + + it('maps role subjects', () => { + assert.equal(AuthEvents.GET_ROLES, 'GET_ROLES'); + assert.equal(AuthEvents.CREATE_ROLE, 'CREATE_ROLE'); + assert.equal(AuthEvents.UPDATE_ROLE, 'UPDATE_ROLE'); + assert.equal(AuthEvents.DELETE_ROLE, 'DELETE_ROLE'); + assert.equal(AuthEvents.SET_DEFAULT_ROLE, 'SET_DEFAULT_ROLE'); + }); + + it('maps Meeco subjects', () => { + assert.equal(AuthEvents.MEECO_AUTH_START, 'MEECO_AUTH_START'); + assert.equal(AuthEvents.MEECO_VERIFY_VP, 'MEECO_VERIFY_VP'); + assert.equal(AuthEvents.MEECO_VERIFY_VP_FAILED, 'MEECO_VERIFY_VP_FAILED'); + assert.equal(AuthEvents.MEECO_APPROVE_SUBMISSION, 'MEECO_APPROVE_SUBMISSION'); + assert.equal(AuthEvents.MEECO_REJECT_SUBMISSION, 'MEECO_REJECT_SUBMISSION'); + }); + + it('maps relayer subjects', () => { + assert.equal(AuthEvents.GET_RELAYER_ACCOUNT, 'GET_RELAYER_ACCOUNT'); + assert.equal(AuthEvents.CREATE_RELAYER_ACCOUNT, 'CREATE_RELAYER_ACCOUNT'); + assert.equal(AuthEvents.GENERATE_RELAYER_ACCOUNT, 'GENERATE_RELAYER_ACCOUNT'); + assert.equal(AuthEvents.RELAYER_ACCOUNT_EXIST, 'RELAYER_ACCOUNT_EXIST'); + }); + + it('maps OTP subjects', () => { + assert.equal(AuthEvents.OTP_GENERATE_SECRET, 'OTP_GENERATE_SECRET'); + assert.equal(AuthEvents.OTP_CONFIRM_SECRET, 'OTP_CONFIRM_SECRET'); + assert.equal(AuthEvents.OTP_GET_STATUS, 'OTP_GET_STATUS'); + assert.equal(AuthEvents.OTP_DEACTIVATE, 'OTP_DEACTIVATE'); + }); + + it('has unique values and a sizeable surface', () => { + const values = Object.values(AuthEvents); + assert.equal(new Set(values).size, values.length); + assert.ok(values.length > 40); + }); +}); diff --git a/interfaces/tests/enum-block-error-actions.test.mjs b/interfaces/tests/enum-block-error-actions.test.mjs new file mode 100644 index 0000000000..8e544eae9c --- /dev/null +++ b/interfaces/tests/enum-block-error-actions.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { BlockErrorActions } from '../dist/type/block-error-actions.js'; + +describe('BlockErrorActions enum', () => { + it('exposes the documented action ids', () => { + assert.equal(BlockErrorActions.NO_ACTION, 'no-action'); + assert.equal(BlockErrorActions.RETRY, 'retry'); + assert.equal(BlockErrorActions.GOTO_STEP, 'goto-step'); + assert.equal(BlockErrorActions.GOTO_TAG, 'goto-tag'); + assert.equal(BlockErrorActions.DEBUG, 'debug'); + }); + it('values are kebab-cased identifiers', () => { + for (const v of Object.values(BlockErrorActions)) { + assert.match(v, /^[a-z]+(-[a-z]+)*$/); + } + }); +}); diff --git a/interfaces/tests/enum-block-type.test.mjs b/interfaces/tests/enum-block-type.test.mjs new file mode 100644 index 0000000000..1f2cb6c8d6 --- /dev/null +++ b/interfaces/tests/enum-block-type.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { BlockType } from '../dist/type/block.type.js'; + +describe('BlockType enum', () => { + it('maps representative block keys to their canonical names', () => { + assert.equal(BlockType.Container, 'interfaceContainerBlock'); + assert.equal(BlockType.DocumentsViewer, 'interfaceDocumentsSourceBlock'); + assert.equal(BlockType.Information, 'informationBlock'); + assert.equal(BlockType.Mint, 'mintDocumentBlock'); + assert.equal(BlockType.SendToGuardian, 'sendToGuardianBlock'); + assert.equal(BlockType.Switch, 'switchBlock'); + assert.equal(BlockType.TimerBlock, 'timerBlock'); + }); + it('every value is a non-empty camelCase identifier', () => { + for (const v of Object.values(BlockType)) { + assert.equal(typeof v, 'string'); + assert.ok(v.length > 0); + assert.match(v, /^[a-z][A-Za-z0-9]*$/, `unexpected shape: ${v}`); + } + }); + it('has 30+ block types', () => { + assert.ok(Object.keys(BlockType).length >= 30); + }); +}); diff --git a/interfaces/tests/enum-config-type.test.mjs b/interfaces/tests/enum-config-type.test.mjs new file mode 100644 index 0000000000..3e4e84722c --- /dev/null +++ b/interfaces/tests/enum-config-type.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { ConfigType } from '../dist/type/config.type.js'; + +describe('ConfigType enum', () => { + it('exposes Policy and Module display strings', () => { + assert.equal(ConfigType.POLICY, 'Policy'); + assert.equal(ConfigType.MODULE, 'Module'); + }); +}); diff --git a/interfaces/tests/enum-contract-param.test.mjs b/interfaces/tests/enum-contract-param.test.mjs new file mode 100644 index 0000000000..e55cc553b5 --- /dev/null +++ b/interfaces/tests/enum-contract-param.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { ContractParamType } from '../dist/type/contract-param.type.js'; + +describe('ContractParamType enum', () => { + it('uses Solidity ABI type strings', () => { + assert.equal(ContractParamType.ADDRESS, 'address'); + assert.equal(ContractParamType.ADDRESS_ARRAY, 'address[]'); + assert.equal(ContractParamType.UINT8, 'uint8'); + assert.equal(ContractParamType.BOOL, 'bool'); + assert.equal(ContractParamType.INT64, 'int64'); + assert.equal(ContractParamType.INT64_ARRAY, 'int64[]'); + }); +}); diff --git a/interfaces/tests/enum-contract.test.mjs b/interfaces/tests/enum-contract.test.mjs new file mode 100644 index 0000000000..8b555cf4a9 --- /dev/null +++ b/interfaces/tests/enum-contract.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { ContractType } from '../dist/type/contract.type.js'; + +describe('ContractType enum', () => { + it('exposes WIPE and RETIRE', () => { + const values = Object.values(ContractType); + assert.ok(values.includes('WIPE')); + assert.ok(values.includes('RETIRE')); + }); +}); diff --git a/interfaces/tests/enum-did-status.test.mjs b/interfaces/tests/enum-did-status.test.mjs new file mode 100644 index 0000000000..b5e442d346 --- /dev/null +++ b/interfaces/tests/enum-did-status.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { DidDocumentStatus } from '../dist/type/did-status.type.js'; + +describe('DidDocumentStatus enum', () => { + it('covers NEW / CREATE / UPDATE / DELETE / FAILED', () => { + const values = Object.values(DidDocumentStatus); + for (const expected of ['NEW', 'CREATE', 'UPDATE', 'DELETE', 'FAILED']) { + assert.ok(values.includes(expected), `missing ${expected}`); + } + }); + it('keys equal values', () => { + for (const [k, v] of Object.entries(DidDocumentStatus)) assert.equal(k, v); + }); +}); diff --git a/interfaces/tests/enum-document-category.test.mjs b/interfaces/tests/enum-document-category.test.mjs new file mode 100644 index 0000000000..cb9ba22e38 --- /dev/null +++ b/interfaces/tests/enum-document-category.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { DocumentCategoryType } from '../dist/type/document-category.type.js'; + +describe('DocumentCategoryType enum', () => { + it('exposes mrv / report / mint / integration / retirement / user-role / MULTI_SIGN', () => { + assert.equal(DocumentCategoryType.MRV, 'mrv'); + assert.equal(DocumentCategoryType.REPORT, 'report'); + assert.equal(DocumentCategoryType.MINT, 'mint'); + assert.equal(DocumentCategoryType.INTEGRATION, 'integration'); + assert.equal(DocumentCategoryType.RETIREMENT, 'retirement'); + assert.equal(DocumentCategoryType.USER_ROLE, 'user-role'); + assert.equal(DocumentCategoryType.MULTI_SIGN, 'MULTI_SIGN'); + }); +}); diff --git a/interfaces/tests/enum-document-signature.test.mjs b/interfaces/tests/enum-document-signature.test.mjs new file mode 100644 index 0000000000..e893a95c53 --- /dev/null +++ b/interfaces/tests/enum-document-signature.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { DocumentSignature } from '../dist/type/document-signature.type.js'; + +describe('DocumentSignature numeric enum', () => { + it('uses default numeric ordering 0..2', () => { + assert.equal(DocumentSignature.NEW, 0); + assert.equal(DocumentSignature.VERIFIED, 1); + assert.equal(DocumentSignature.INVALID, 2); + }); + + it('supports reverse-lookup (numeric enum)', () => { + assert.equal(DocumentSignature[0], 'NEW'); + assert.equal(DocumentSignature[1], 'VERIFIED'); + assert.equal(DocumentSignature[2], 'INVALID'); + }); +}); diff --git a/interfaces/tests/enum-document-status.test.mjs b/interfaces/tests/enum-document-status.test.mjs new file mode 100644 index 0000000000..bbe1149c5b --- /dev/null +++ b/interfaces/tests/enum-document-status.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { DocumentStatus } from '../dist/type/document-status.type.js'; + +describe('DocumentStatus enum', () => { + it('covers NEW / ISSUE / REVOKE / SUSPEND / RESUME / FAILED', () => { + const values = Object.values(DocumentStatus); + for (const expected of ['NEW', 'ISSUE', 'REVOKE', 'SUSPEND', 'RESUME', 'FAILED']) { + assert.ok(values.includes(expected), `missing ${expected}`); + } + }); +}); diff --git a/interfaces/tests/enum-document-type.test.mjs b/interfaces/tests/enum-document-type.test.mjs new file mode 100644 index 0000000000..5b5fb97efc --- /dev/null +++ b/interfaces/tests/enum-document-type.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { DocumentType } from '../dist/type/document.type.js'; + +describe('DocumentType enum', () => { + it('exposes VC and VP', () => { + assert.equal(DocumentType.VC, 'VC'); + assert.equal(DocumentType.VP, 'VP'); + }); +}); diff --git a/interfaces/tests/enum-entity-status.test.mjs b/interfaces/tests/enum-entity-status.test.mjs new file mode 100644 index 0000000000..a2842ee482 --- /dev/null +++ b/interfaces/tests/enum-entity-status.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { EntityStatus } from '../dist/type/entity-status.type.js'; + +describe('EntityStatus enum', () => { + it('exposes DRAFT / DRY_RUN / PUBLISHED / ERROR / ACTIVE', () => { + for (const k of ['DRAFT', 'DRY_RUN', 'PUBLISHED', 'ERROR', 'ACTIVE']) { + assert.equal(EntityStatus[k], k); + } + }); + it('has exactly five entries', () => { + assert.equal(Object.keys(EntityStatus).length, 5); + }); +}); diff --git a/interfaces/tests/enum-external-policy-status.test.mjs b/interfaces/tests/enum-external-policy-status.test.mjs new file mode 100644 index 0000000000..64783a026e --- /dev/null +++ b/interfaces/tests/enum-external-policy-status.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { ExternalPolicyStatus } from '../dist/type/external-policy-status.type.js'; + +describe('ExternalPolicyStatus enum', () => { + it('exposes NEW / APPROVED / REJECTED', () => { + assert.equal(ExternalPolicyStatus.NEW, 'NEW'); + assert.equal(ExternalPolicyStatus.APPROVED, 'APPROVED'); + assert.equal(ExternalPolicyStatus.REJECTED, 'REJECTED'); + }); + it('has three entries', () => { + assert.equal(Object.keys(ExternalPolicyStatus).length, 3); + }); +}); diff --git a/interfaces/tests/enum-geojson.test.mjs b/interfaces/tests/enum-geojson.test.mjs new file mode 100644 index 0000000000..3b11d5bc49 --- /dev/null +++ b/interfaces/tests/enum-geojson.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { GeoJsonType } from '../dist/type/geojson.type.js'; + +describe('GeoJsonType enum', () => { + it('uses RFC-7946 PascalCase geometry names', () => { + assert.equal(GeoJsonType.POINT, 'Point'); + assert.equal(GeoJsonType.LINE_STRING, 'LineString'); + assert.equal(GeoJsonType.POLYGON, 'Polygon'); + assert.equal(GeoJsonType.MULTI_POINT, 'MultiPoint'); + assert.equal(GeoJsonType.MULTI_LINE_STRING, 'MultiLineString'); + assert.equal(GeoJsonType.MULTI_POLYGON, 'MultiPolygon'); + assert.equal(GeoJsonType.FEATURE_COLLECTION, 'FeatureCollection'); + }); +}); diff --git a/interfaces/tests/enum-hedera-response-code.test.mjs b/interfaces/tests/enum-hedera-response-code.test.mjs new file mode 100644 index 0000000000..7b95cee712 --- /dev/null +++ b/interfaces/tests/enum-hedera-response-code.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { HederaResponseCode } from '../dist/type/hedera-response-code.type.js'; + +describe('HederaResponseCode enum', () => { + it('exposes representative Hedera consensus error codes', () => { + assert.equal(HederaResponseCode.OK, 'OK'); + assert.equal(HederaResponseCode.SUCCESS, 'SUCCESS'); + assert.equal(HederaResponseCode.BUSY, 'BUSY'); + assert.equal(HederaResponseCode.INVALID_SIGNATURE, 'INVALID_SIGNATURE'); + assert.equal(HederaResponseCode.INSUFFICIENT_PAYER_BALANCE, 'INSUFFICIENT_PAYER_BALANCE'); + assert.equal(HederaResponseCode.UNKNOWN, 'UNKNOWN'); + }); + it('keys equal values (uppercase string enum)', () => { + for (const [k, v] of Object.entries(HederaResponseCode)) { + assert.equal(k, v); + } + }); +}); diff --git a/interfaces/tests/enum-icon.test.mjs b/interfaces/tests/enum-icon.test.mjs new file mode 100644 index 0000000000..af67905a54 --- /dev/null +++ b/interfaces/tests/enum-icon.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { IconType } from '../dist/type/icon.type.js'; + +describe('IconType enum', () => { + it('exposes COMMON and CUSTOM', () => { + assert.equal(IconType.COMMON, 'common'); + assert.equal(IconType.CUSTOM, 'custom'); + assert.equal(Object.keys(IconType).length, 2); + }); +}); diff --git a/interfaces/tests/enum-integration-data.test.mjs b/interfaces/tests/enum-integration-data.test.mjs new file mode 100644 index 0000000000..ab3429f943 --- /dev/null +++ b/interfaces/tests/enum-integration-data.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { IntegrationDataTypes, ParseTypes } from '../dist/type/integration-data.type.js'; + +describe('IntegrationDataTypes enum', () => { + it('exposes JSON/CSV/GEOTIFF/GEOJSON/TEXT', () => { + for (const k of ['JSON', 'CSV', 'GEOTIFF', 'GEOJSON', 'TEXT']) { + assert.equal(IntegrationDataTypes[k], k); + } + assert.equal(Object.keys(IntegrationDataTypes).length, 5); + }); +}); + +describe('ParseTypes enum', () => { + it('exposes NUMBER and JSON', () => { + assert.equal(ParseTypes.NUMBER, 'NUMBER'); + assert.equal(ParseTypes.JSON, 'JSON'); + }); +}); diff --git a/interfaces/tests/enum-location.test.mjs b/interfaces/tests/enum-location.test.mjs new file mode 100644 index 0000000000..8f8b23eadd --- /dev/null +++ b/interfaces/tests/enum-location.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { LocationType } from '../dist/type/location.type.js'; + +describe('LocationType enum', () => { + it('exposes LOCAL / REMOTE / CUSTOM with lowercase string values', () => { + assert.equal(LocationType.LOCAL, 'local'); + assert.equal(LocationType.REMOTE, 'remote'); + assert.equal(LocationType.CUSTOM, 'custom'); + }); +}); diff --git a/interfaces/tests/enum-log.test.mjs b/interfaces/tests/enum-log.test.mjs new file mode 100644 index 0000000000..ab74ac6a1f --- /dev/null +++ b/interfaces/tests/enum-log.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { LogType } from '../dist/type/log.type.js'; + +describe('LogType enum', () => { + it('exposes WARN / INFO / ERROR', () => { + assert.equal(LogType.WARN, 'WARN'); + assert.equal(LogType.INFO, 'INFO'); + assert.equal(LogType.ERROR, 'ERROR'); + }); +}); diff --git a/interfaces/tests/enum-message-api-exhaustive.test.mjs b/interfaces/tests/enum-message-api-exhaustive.test.mjs new file mode 100644 index 0000000000..88edc70acc --- /dev/null +++ b/interfaces/tests/enum-message-api-exhaustive.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { MessageAPI } from '../dist/type/messages/message-api.type.js'; + +describe('MessageAPI per-member integrity', () => { + for (const [key, value] of Object.entries(MessageAPI)) { + it(`${key} is a non-empty string with a unique value`, () => { + assert.equal(typeof value, 'string'); + assert.ok(value.length > 0); + const matches = Object.values(MessageAPI).filter((v) => v === value); + assert.equal(matches.length, 1); + }); + } +}); diff --git a/interfaces/tests/enum-message-api.test.mjs b/interfaces/tests/enum-message-api.test.mjs new file mode 100644 index 0000000000..58c05c946e --- /dev/null +++ b/interfaces/tests/enum-message-api.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { MessageAPI, ExternalMessageEvents } from '../dist/type/messages/message-api.type.js'; + +describe('MessageAPI enum', () => { + it('maps a kebab-case sample of subjects', () => { + assert.equal(MessageAPI.GET_TEMPLATE, 'get-template'); + assert.equal(MessageAPI.PUBLISH_TASK, 'publish-task'); + assert.equal(MessageAPI.GET_DID_DOCUMENTS, 'get-did-documents'); + assert.equal(MessageAPI.GET_VC_DOCUMENTS, 'get-vc-documents'); + assert.equal(MessageAPI.GET_SCHEMAS, 'get-schemas'); + assert.equal(MessageAPI.GET_SCHEMAS_V2, 'get-schemas-v2'); + assert.equal(MessageAPI.GET_TOKENS, 'get-tokens'); + assert.equal(MessageAPI.GET_CHAIN, 'get-chain'); + assert.equal(MessageAPI.IMPORT_SCHEMA, 'import-schema'); + assert.equal(MessageAPI.EXPORT_SCHEMAS, 'export-schema'); + }); + + it('maps SCREAMING_SNAKE identity-style subjects', () => { + assert.equal(MessageAPI.SET_ACCESS_TOKEN, 'SET_ACCESS_TOKEN'); + assert.equal(MessageAPI.GENERATE_DEMO_KEY, 'GENERATE_DEMO_KEY'); + assert.equal(MessageAPI.WRITE_LOG, 'WRITE_LOG'); + assert.equal(MessageAPI.GET_LOGS, 'GET_LOGS'); + assert.equal(MessageAPI.FREEZE_TOKEN, 'FREEZE_TOKEN'); + assert.equal(MessageAPI.CREATE_STANDARD_REGISTRY, 'CREATE_STANDARD_REGISTRY'); + assert.equal(MessageAPI.CREATE_USER_PROFILE, 'CREATE_USER_PROFILE'); + }); + + it('maps statistic / rule / label / formula subjects', () => { + assert.equal(MessageAPI.GET_STATISTIC_DEFINITIONS, 'GET_STATISTIC_DEFINITIONS'); + assert.equal(MessageAPI.CREATE_STATISTIC_ASSESSMENT, 'CREATE_STATISTIC_ASSESSMENT'); + assert.equal(MessageAPI.GET_SCHEMA_RULES, 'GET_SCHEMA_RULES'); + assert.equal(MessageAPI.CREATE_POLICY_LABEL, 'CREATE_POLICY_LABEL'); + assert.equal(MessageAPI.CREATE_FORMULA, 'CREATE_FORMULA'); + assert.equal(MessageAPI.PUBLISH_FORMULA, 'PUBLISH_FORMULA'); + }); + + it('maps suggestion subjects with policy-engine prefix', () => { + assert.equal(MessageAPI.SUGGESTIONS, 'policy-engine-event-suggestions'); + assert.equal(MessageAPI.GET_SUGGESTIONS_CONFIG, 'policy-engine-event-get-suggestions-config'); + assert.equal(MessageAPI.SET_SUGGESTIONS_CONFIG, 'policy-engine-event-set-suggestions-config'); + }); + + it('maps credential CRUD subjects', () => { + assert.equal(MessageAPI.SET_CREDENTIAL, 'SET_CREDENTIAL'); + assert.equal(MessageAPI.GET_CREDENTIALS, 'GET_CREDENTIALS'); + assert.equal(MessageAPI.DELETE_CREDENTIAL, 'DELETE_CREDENTIAL'); + assert.equal(MessageAPI.TRANSFER_TOKEN, 'TRANSFER_TOKEN'); + assert.equal(MessageAPI.TRANSFER_TOKEN_ASYNC, 'TRANSFER_TOKEN_ASYNC'); + }); + + it('aliases role VC subjects distinct from their key names', () => { + assert.equal(MessageAPI.CREATE_ROLE, 'CREATE_ROLE_VC'); + assert.equal(MessageAPI.UPDATE_ROLE, 'UPDATE_ROLE_VC'); + assert.equal(MessageAPI.DELETE_ROLE, 'DELETE_ROLE_VC'); + assert.equal(MessageAPI.SET_ROLE, 'SET_ROLE_VC'); + }); + + it('every value is a non-empty string', () => { + for (const v of Object.values(MessageAPI)) { + assert.equal(typeof v, 'string'); + assert.ok(v.length > 0); + } + }); + + it('has unique values across all members', () => { + const values = Object.values(MessageAPI); + assert.equal(new Set(values).size, values.length); + }); + + it('is a string enum (no numeric reverse mapping)', () => { + for (const k of Object.keys(MessageAPI)) { + assert.equal(typeof k, 'string'); + assert.ok(Number.isNaN(Number(k))); + } + }); + + it('contains a large surface of message subjects', () => { + assert.ok(Object.keys(MessageAPI).length > 200); + }); +}); + +describe('ExternalMessageEvents enum', () => { + it('namespaces every event under external-events.', () => { + for (const v of Object.values(ExternalMessageEvents)) { + assert.ok(v.startsWith('external-events.')); + } + }); + + it('maps each external event member', () => { + assert.equal(ExternalMessageEvents.TOKEN_MINTED, 'external-events.token_minted'); + assert.equal(ExternalMessageEvents.TOKEN_MINT_COMPLETE, 'external-events.token_mint_complete'); + assert.equal(ExternalMessageEvents.TOKEN_MINT_FAILED, 'external-events.token_mint_failed'); + assert.equal(ExternalMessageEvents.ERROR_LOG, 'external-events.error_logs'); + assert.equal(ExternalMessageEvents.BLOCK_EVENTS, 'external-events.block_event'); + assert.equal(ExternalMessageEvents.BLOCK_COMPLETE, 'external-events.block_complete'); + assert.equal(ExternalMessageEvents.IPFS_ADDED_FILE, 'external-events.ipfs_added_file'); + assert.equal(ExternalMessageEvents.IPFS_BEFORE_UPLOAD_CONTENT, 'external-events.ipfs_before_upload_content'); + assert.equal(ExternalMessageEvents.IPFS_AFTER_READ_CONTENT, 'external-events.ipfs_after_read_content'); + assert.equal(ExternalMessageEvents.IPFS_LOADED_FILE, 'external-events.ipfs_loaded_file'); + }); + + it('has exactly ten members with unique values', () => { + const values = Object.values(ExternalMessageEvents); + assert.equal(values.length, 10); + assert.equal(new Set(values).size, 10); + }); +}); diff --git a/interfaces/tests/enum-mint-transaction-status.test.mjs b/interfaces/tests/enum-mint-transaction-status.test.mjs new file mode 100644 index 0000000000..4c92424eb9 --- /dev/null +++ b/interfaces/tests/enum-mint-transaction-status.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { MintTransactionStatus } from '../dist/type/mint-transaction-status.type.js'; + +describe('MintTransactionStatus enum', () => { + it('exposes NEW / PENDING / ERROR / SUCCESS / NONE', () => { + for (const k of ['NEW', 'PENDING', 'ERROR', 'SUCCESS', 'NONE']) { + assert.equal(MintTransactionStatus[k], k); + } + }); + it('has exactly five entries', () => { + assert.equal(Object.keys(MintTransactionStatus).length, 5); + }); +}); diff --git a/interfaces/tests/enum-module-status.test.mjs b/interfaces/tests/enum-module-status.test.mjs new file mode 100644 index 0000000000..642f03b481 --- /dev/null +++ b/interfaces/tests/enum-module-status.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { ModuleStatus } from '../dist/type/module-status.type.js'; + +describe('ModuleStatus enum', () => { + it('exposes at least the DRAFT and PUBLISHED states', () => { + const values = Object.values(ModuleStatus); + assert.ok(values.length > 0); + }); + it('all values are strings', () => { + for (const v of Object.values(ModuleStatus)) { + assert.equal(typeof v, 'string'); + } + }); +}); diff --git a/interfaces/tests/enum-multi-policy-type.test.mjs b/interfaces/tests/enum-multi-policy-type.test.mjs new file mode 100644 index 0000000000..20a0904afd --- /dev/null +++ b/interfaces/tests/enum-multi-policy-type.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { MultiPolicyType } from '../dist/type/multi-policy-type.type.js'; + +describe('MultiPolicyType enum', () => { + it('exposes Main and Sub (mixed-case display strings)', () => { + assert.equal(MultiPolicyType.MAIN, 'Main'); + assert.equal(MultiPolicyType.SUB, 'Sub'); + }); +}); diff --git a/interfaces/tests/enum-notification-action.test.mjs b/interfaces/tests/enum-notification-action.test.mjs new file mode 100644 index 0000000000..dd1d36f9c6 --- /dev/null +++ b/interfaces/tests/enum-notification-action.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { NotificationAction } from '../dist/type/notification-action.type.js'; + +describe('NotificationAction enum', () => { + it('exposes the seven page-route action ids', () => { + for (const k of ['POLICY_CONFIGURATION', 'POLICY_VIEW', 'POLICIES_PAGE', 'SCHEMAS_PAGE', 'TOKENS_PAGE', 'PROFILE_PAGE', 'POLICY_LABEL_PAGE']) { + assert.equal(NotificationAction[k], k); + } + assert.equal(Object.keys(NotificationAction).length, 7); + }); +}); diff --git a/interfaces/tests/enum-notification.test.mjs b/interfaces/tests/enum-notification.test.mjs new file mode 100644 index 0000000000..d8858dcac1 --- /dev/null +++ b/interfaces/tests/enum-notification.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { NotificationType } from '../dist/type/notification.type.js'; + +describe('NotificationType enum', () => { + it('exposes INFO / ERROR / WARN / SUCCESS', () => { + for (const k of ['INFO', 'ERROR', 'WARN', 'SUCCESS']) { + assert.equal(NotificationType[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-order-direction.test.mjs b/interfaces/tests/enum-order-direction.test.mjs new file mode 100644 index 0000000000..3e75a1c3a0 --- /dev/null +++ b/interfaces/tests/enum-order-direction.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { OrderDirection } from '../dist/type/order-direction.type.js'; + +describe('OrderDirection enum', () => { + it('exposes ASC / DESC', () => { + assert.equal(OrderDirection.ASC, 'ASC'); + assert.equal(OrderDirection.DESC, 'DESC'); + assert.equal(Object.keys(OrderDirection).length, 2); + }); +}); diff --git a/interfaces/tests/enum-permission-actions.test.mjs b/interfaces/tests/enum-permission-actions.test.mjs new file mode 100644 index 0000000000..22a9df5523 --- /dev/null +++ b/interfaces/tests/enum-permission-actions.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { PermissionActions } from '../dist/type/permissions.type.js'; + +describe('PermissionActions enum', () => { + it('exposes the standard CRUD-plus-extras action set', () => { + for (const k of ['ALL', 'READ', 'CREATE', 'UPDATE', 'DELETE', 'REVIEW', 'TAG', 'AUDIT', 'EXECUTE', 'MANAGE']) { + assert.equal(PermissionActions[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-permission-entities.test.mjs b/interfaces/tests/enum-permission-entities.test.mjs new file mode 100644 index 0000000000..b1697c69a6 --- /dev/null +++ b/interfaces/tests/enum-permission-entities.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { PermissionEntities } from '../dist/type/permissions.type.js'; + +describe('PermissionEntities enum', () => { + it('exposes representative resource entities', () => { + for (const k of ['ACCOUNT', 'STANDARD_REGISTRY', 'USER', 'BALANCE', 'POLICY', 'TOOL', 'DOCUMENT', 'SCHEMA']) { + assert.equal(PermissionEntities[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-permissions-categories.test.mjs b/interfaces/tests/enum-permissions-categories.test.mjs new file mode 100644 index 0000000000..26aa24d9c0 --- /dev/null +++ b/interfaces/tests/enum-permissions-categories.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { PermissionCategories } from '../dist/type/permissions.type.js'; + +describe('PermissionCategories enum', () => { + it('exposes representative top-level categories', () => { + for (const k of ['ACCOUNTS', 'POLICIES', 'SCHEMAS', 'TOKENS', 'TOOLS', 'PERMISSIONS', 'STATISTICS', 'FORMULAS']) { + assert.equal(PermissionCategories[k], k); + } + }); + it('has 20+ categories', () => { + assert.ok(Object.keys(PermissionCategories).length >= 20); + }); +}); diff --git a/interfaces/tests/enum-permissions-list.test.mjs b/interfaces/tests/enum-permissions-list.test.mjs new file mode 100644 index 0000000000..26789dc409 --- /dev/null +++ b/interfaces/tests/enum-permissions-list.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { Permissions } from '../dist/type/permissions.type.js'; + +describe('Permissions enum (master list)', () => { + it('exposes representative CRUD permissions across resources', () => { + for (const k of [ + 'ACCOUNTS_ACCOUNT_READ', + 'ANALYTIC_POLICY_READ', + 'ARTIFACTS_FILE_CREATE', + 'ARTIFACTS_FILE_DELETE', + ]) { + assert.equal(Permissions[k], k); + } + }); + it('all keys equal their values (string-enum invariant)', () => { + for (const [k, v] of Object.entries(Permissions)) assert.equal(k, v); + }); + it('contains 100+ permission entries', () => { + assert.ok(Object.keys(Permissions).length >= 100); + }); +}); diff --git a/interfaces/tests/enum-pino-log.test.mjs b/interfaces/tests/enum-pino-log.test.mjs new file mode 100644 index 0000000000..07216b7020 --- /dev/null +++ b/interfaces/tests/enum-pino-log.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { PinoLogType } from '../dist/type/pino-log.type.js'; + +describe('PinoLogType enum', () => { + it('uses lowercase pino level names', () => { + assert.equal(PinoLogType.TRACE, 'trace'); + assert.equal(PinoLogType.DEBUG, 'debug'); + assert.equal(PinoLogType.INFO, 'info'); + assert.equal(PinoLogType.WARN, 'warn'); + assert.equal(PinoLogType.ERROR, 'error'); + assert.equal(PinoLogType.FATAL, 'fatal'); + }); + it('has six levels', () => { + assert.equal(Object.keys(PinoLogType).length, 6); + }); +}); diff --git a/interfaces/tests/enum-policy-action.test.mjs b/interfaces/tests/enum-policy-action.test.mjs new file mode 100644 index 0000000000..bace38fa45 --- /dev/null +++ b/interfaces/tests/enum-policy-action.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { PolicyActionType, PolicyActionStatus } from '../dist/type/policy-action.type.js'; + +describe('PolicyActionType enum', () => { + it('exposes ACTION / REQUEST / REMOTE_ACTION', () => { + assert.equal(PolicyActionType.ACTION, 'ACTION'); + assert.equal(PolicyActionType.REQUEST, 'REQUEST'); + assert.equal(PolicyActionType.REMOTE_ACTION, 'REMOTE_ACTION'); + }); +}); + +describe('PolicyActionStatus enum', () => { + it('exposes the 5 lifecycle statuses', () => { + for (const k of ['NEW', 'ERROR', 'COMPLETED', 'REJECTED', 'CANCELED']) { + assert.equal(PolicyActionStatus[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-policy-availability.test.mjs b/interfaces/tests/enum-policy-availability.test.mjs new file mode 100644 index 0000000000..8966bcff09 --- /dev/null +++ b/interfaces/tests/enum-policy-availability.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { PolicyAvailability } from '../dist/type/policy-availability.type.js'; + +describe('PolicyAvailability enum', () => { + it('uses lowercase private/public', () => { + assert.equal(PolicyAvailability.PRIVATE, 'private'); + assert.equal(PolicyAvailability.PUBLIC, 'public'); + }); +}); diff --git a/interfaces/tests/enum-policy-category-type.test.mjs b/interfaces/tests/enum-policy-category-type.test.mjs new file mode 100644 index 0000000000..a955b79129 --- /dev/null +++ b/interfaces/tests/enum-policy-category-type.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { PolicyCategoryType } from '../dist/type/policy-category-type.js'; + +describe('PolicyCategoryType enum', () => { + it('exposes the five MRV taxonomy facets', () => { + for (const k of ['SECTORAL_SCOPE', 'PROJECT_SCALE', 'APPLIED_TECHNOLOGY_TYPE', 'MITIGATION_ACTIVITY_TYPE', 'SUB_TYPE']) { + assert.equal(PolicyCategoryType[k], k); + } + assert.equal(Object.keys(PolicyCategoryType).length, 5); + }); +}); diff --git a/interfaces/tests/enum-policy-engine-events-exhaustive.test.mjs b/interfaces/tests/enum-policy-engine-events-exhaustive.test.mjs new file mode 100644 index 0000000000..88dd1272d5 --- /dev/null +++ b/interfaces/tests/enum-policy-engine-events-exhaustive.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { PolicyEngineEvents } from '../dist/type/messages/policy-engine-events.js'; + +describe('PolicyEngineEvents per-member integrity', () => { + const values = Object.values(PolicyEngineEvents); + for (const [key, value] of Object.entries(PolicyEngineEvents)) { + it(`${key} carries a unique policy-engine subject`, () => { + assert.equal(typeof value, 'string'); + assert.ok(value.startsWith('policy-engine')); + assert.equal(values.filter((v) => v === value).length, 1); + }); + } +}); diff --git a/interfaces/tests/enum-policy-engine-events.test.mjs b/interfaces/tests/enum-policy-engine-events.test.mjs new file mode 100644 index 0000000000..76c99a6608 --- /dev/null +++ b/interfaces/tests/enum-policy-engine-events.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { PolicyEngineEvents } from '../dist/type/messages/policy-engine-events.js'; + +describe('PolicyEngineEvents enum', () => { + it('maps import-related subjects', () => { + assert.equal(PolicyEngineEvents.RECEIVE_EXTERNAL_DATA, 'policy-engine-event-receive-external-data'); + assert.equal(PolicyEngineEvents.RECEIVE_EXTERNAL_DATA_CUSTOM, 'policy-engine-event-recieve-external-data-custom'); + assert.equal(PolicyEngineEvents.POLICY_IMPORT_FILE, 'policy-engine-event-policy-import-file'); + assert.equal(PolicyEngineEvents.POLICY_IMPORT_FILE_ASYNC, 'policy-engine-event-policy-import-file-async'); + assert.equal(PolicyEngineEvents.POLICY_IMPORT_MESSAGE, 'policy-engine-event-policy-import-message'); + assert.equal(PolicyEngineEvents.POLICY_IMPORT_XLSX, 'policy-engine-event-policy-import-xlsx'); + }); + + it('maps export subjects', () => { + assert.equal(PolicyEngineEvents.POLICY_EXPORT_FILE, 'policy-engine-event-policy-export-file'); + assert.equal(PolicyEngineEvents.POLICY_EXPORT_MESSAGE, 'policy-engine-event-policy-export-message'); + assert.equal(PolicyEngineEvents.POLICY_EXPORT_XLSX, 'policy-engine-event-policy-export-xlsx'); + }); + + it('maps lifecycle subjects', () => { + assert.equal(PolicyEngineEvents.CREATE_POLICIES, 'policy-engine-event-create-policies'); + assert.equal(PolicyEngineEvents.PUBLISH_POLICIES, 'policy-engine-event-publish-policies'); + assert.equal(PolicyEngineEvents.DRY_RUN_POLICIES, 'policy-engine-event-dry-run-policies'); + assert.equal(PolicyEngineEvents.DRAFT_POLICIES, 'policy-engine-event-draft-policies'); + assert.equal(PolicyEngineEvents.VALIDATE_POLICIES, 'policy-engine-event-validate-policies'); + assert.equal(PolicyEngineEvents.DISCONTINUE_POLICY, 'policy-engine-event-discontinue-policy'); + }); + + it('maps block-data subjects', () => { + assert.equal(PolicyEngineEvents.GET_BLOCK_DATA, 'policy-engine-event-get-block-data'); + assert.equal(PolicyEngineEvents.GET_BLOCK_DATA_BY_TAG, 'policy-engine-event-get-block-data-by-tag'); + assert.equal(PolicyEngineEvents.SET_BLOCK_DATA, 'policy-engine-event-set-block-data'); + assert.equal(PolicyEngineEvents.SET_BLOCK_DATA_BY_TAG, 'policy-engine-event-set-block-data-by-tag'); + assert.equal(PolicyEngineEvents.BLOCK_BY_TAG, 'policy-engine-event-get-block-by-tag'); + }); + + it('maps savepoint subjects', () => { + assert.equal(PolicyEngineEvents.GET_SAVEPOINTS, 'policy-engine-event-get-savepoints'); + assert.equal(PolicyEngineEvents.CREATE_SAVEPOINT, 'policy-engine-event-create-savepoint'); + assert.equal(PolicyEngineEvents.DELETE_SAVEPOINTS, 'policy-engine-event-delete-savepoints'); + assert.equal(PolicyEngineEvents.SELECT_SAVEPOINT, 'policy-engine-event-select-savepoint'); + }); + + it('maps remote-request subjects (prefix without -event-)', () => { + assert.equal(PolicyEngineEvents.APPROVE_REMOTE_REQUEST, 'policy-engine-approve-remote-request'); + assert.equal(PolicyEngineEvents.REJECT_REMOTE_REQUEST, 'policy-engine-reject-remote-request'); + assert.equal(PolicyEngineEvents.CANCEL_REMOTE_ACTION, 'policy-engine-cancel-remote-action'); + assert.equal(PolicyEngineEvents.GET_REMOTE_REQUESTS, 'policy-engine-get-remote-requests'); + }); + + it('starts every subject with the policy-engine prefix', () => { + for (const v of Object.values(PolicyEngineEvents)) { + assert.ok(v.startsWith('policy-engine')); + } + }); + + it('has unique non-empty string values', () => { + const values = Object.values(PolicyEngineEvents); + assert.equal(new Set(values).size, values.length); + for (const v of values) { + assert.equal(typeof v, 'string'); + assert.ok(v.length > 0); + } + }); + + it('exposes a broad surface of events', () => { + assert.ok(Object.keys(PolicyEngineEvents).length > 100); + }); +}); diff --git a/interfaces/tests/enum-policy-events.test.mjs b/interfaces/tests/enum-policy-events.test.mjs new file mode 100644 index 0000000000..352a9c3e95 --- /dev/null +++ b/interfaces/tests/enum-policy-events.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { PolicyEvents } from '../dist/type/messages/policy-events.js'; + +describe('PolicyEvents enum', () => { + it('maps service-management subjects', () => { + assert.equal(PolicyEvents.CHECK_POLICY_SERVICES, 'check-policy-services'); + assert.equal(PolicyEvents.NEW_POLICY_SERVICE_NODE_STARTED, 'new-policy-service-node-started'); + assert.equal(PolicyEvents.GET_FREE_POLICY_SERVICES, 'get-free-policy-services'); + assert.equal(PolicyEvents.POLICY_SERVICE_FREE_STATUS, 'policy-service-free-status'); + assert.equal(PolicyEvents.CHECK_IF_ALIVE, 'check-if-alive'); + }); + + it('maps generation / readiness subjects', () => { + assert.equal(PolicyEvents.GENERATE_POLICY, 'policy-event-generate-policy'); + assert.equal(PolicyEvents.POLICY_READY, 'policy-event-policy-ready'); + assert.equal(PolicyEvents.POLICY_START_ERROR, 'policy-start-error'); + assert.equal(PolicyEvents.DELETE_POLICY, 'policy-event-delete-policy'); + }); + + it('maps block-data subjects', () => { + assert.equal(PolicyEvents.GET_BLOCK_DATA, 'policy-event-get-block-data'); + assert.equal(PolicyEvents.GET_BLOCK_DATA_BY_TAG, 'policy-event-get-block-data-by-tag'); + assert.equal(PolicyEvents.SET_BLOCK_DATA, 'policy-event-set-block-data'); + assert.equal(PolicyEvents.GET_ROOT_BLOCK_DATA, 'policy-event-get-root-block-data'); + assert.equal(PolicyEvents.BLOCK_BY_TAG, 'policy-event-block-by-tag'); + }); + + it('maps recording / running subjects', () => { + assert.equal(PolicyEvents.START_RECORDING, 'policy-event-start-recording'); + assert.equal(PolicyEvents.STOP_RECORDING, 'policy-event-stop-recording'); + assert.equal(PolicyEvents.RUN_RECORD, 'policy-event-run-record'); + assert.equal(PolicyEvents.STOP_RUNNING, 'policy-event-stop-running'); + assert.equal(PolicyEvents.FAST_FORWARD, 'policy-event-fast-forward'); + }); + + it('maps remote-action subjects without -event- segment', () => { + assert.equal(PolicyEvents.APPROVE_REMOTE_REQUEST, 'approve-remote-request'); + assert.equal(PolicyEvents.REJECT_REMOTE_REQUEST, 'reject-remote-request'); + assert.equal(PolicyEvents.CANCEL_REMOTE_ACTION, 'cancel-remote-action'); + assert.equal(PolicyEvents.RELOAD_REMOTE_ACTION, 'reload-remote-action'); + assert.equal(PolicyEvents.APPLY_SAVEPOINT, 'apply-savepoint'); + }); + + it('keeps identity-style member for RECORD_PERSIST_STEP', () => { + assert.equal(PolicyEvents.RECORD_PERSIST_STEP, 'RECORD_PERSIST_STEP'); + }); + + it('uses the policy-engine prefix for RECONNECT_POLICY', () => { + assert.equal(PolicyEvents.RECONNECT_POLICY, 'policy-engine-reconnect-policy'); + assert.equal(PolicyEvents.DISCONNECT_POLICY, 'policy-event-disconnect-policy'); + }); + + it('has unique non-empty string values', () => { + const values = Object.values(PolicyEvents); + assert.equal(new Set(values).size, values.length); + for (const v of values) { + assert.equal(typeof v, 'string'); + assert.ok(v.length > 0); + } + }); +}); diff --git a/interfaces/tests/enum-policy-status.test.mjs b/interfaces/tests/enum-policy-status.test.mjs new file mode 100644 index 0000000000..f88c0b39b6 --- /dev/null +++ b/interfaces/tests/enum-policy-status.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { PolicyStatus } from '../dist/type/policy-status.type.js'; + +describe('PolicyStatus enum', () => { + it('exposes the documented lifecycle values', () => { + assert.equal(PolicyStatus.DRY_RUN, 'DRY-RUN'); + assert.equal(PolicyStatus.DRAFT, 'DRAFT'); + assert.equal(PolicyStatus.PUBLISH_ERROR, 'PUBLISH_ERROR'); + assert.equal(PolicyStatus.PUBLISH, 'PUBLISH'); + assert.equal(PolicyStatus.DISCONTINUED, 'DISCONTINUED'); + assert.equal(PolicyStatus.DEMO, 'DEMO'); + assert.equal(PolicyStatus.VIEW, 'VIEW'); + }); +}); diff --git a/interfaces/tests/enum-policy-test-status.test.mjs b/interfaces/tests/enum-policy-test-status.test.mjs new file mode 100644 index 0000000000..3440eec003 --- /dev/null +++ b/interfaces/tests/enum-policy-test-status.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { PolicyTestStatus } from '../dist/type/policy-test-status.type.js'; + +describe('PolicyTestStatus enum', () => { + it('exposes New/Running/Stopped/Success/Failure (PascalCase)', () => { + assert.equal(PolicyTestStatus.New, 'New'); + assert.equal(PolicyTestStatus.Running, 'Running'); + assert.equal(PolicyTestStatus.Stopped, 'Stopped'); + assert.equal(PolicyTestStatus.Success, 'Success'); + assert.equal(PolicyTestStatus.Failure, 'Failure'); + }); +}); diff --git a/interfaces/tests/enum-record.test.mjs b/interfaces/tests/enum-record.test.mjs new file mode 100644 index 0000000000..3df70ed92b --- /dev/null +++ b/interfaces/tests/enum-record.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { RecordMethod } from '../dist/type/record.type.js'; + +describe('RecordMethod enum', () => { + it('exposes Start/Stop/Action/Generate with uppercase values', () => { + assert.equal(RecordMethod.Start, 'START'); + assert.equal(RecordMethod.Stop, 'STOP'); + assert.equal(RecordMethod.Action, 'ACTION'); + assert.equal(RecordMethod.Generate, 'GENERATE'); + }); +}); diff --git a/interfaces/tests/enum-root-state.test.mjs b/interfaces/tests/enum-root-state.test.mjs new file mode 100644 index 0000000000..7f98410d55 --- /dev/null +++ b/interfaces/tests/enum-root-state.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { RootState } from '../dist/type/root-state.type.js'; + +describe('RootState numeric enum', () => { + it('uses 0 for CREATED and 1 for CONFIRMED', () => { + assert.equal(RootState.CREATED, 0); + assert.equal(RootState.CONFIRMED, 1); + }); + it('supports reverse-lookup', () => { + assert.equal(RootState[0], 'CREATED'); + assert.equal(RootState[1], 'CONFIRMED'); + }); +}); diff --git a/interfaces/tests/enum-schema-category.test.mjs b/interfaces/tests/enum-schema-category.test.mjs new file mode 100644 index 0000000000..e781a7f634 --- /dev/null +++ b/interfaces/tests/enum-schema-category.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { SchemaCategory } from '../dist/type/schema-category.type.js'; + +describe('SchemaCategory enum', () => { + it('exposes POLICY/MODULE/SYSTEM/TAG/TOOL/STATISTIC/LABEL', () => { + for (const k of ['POLICY', 'MODULE', 'SYSTEM', 'TAG', 'TOOL', 'STATISTIC', 'LABEL']) { + assert.equal(SchemaCategory[k], k); + } + assert.equal(Object.keys(SchemaCategory).length, 7); + }); +}); diff --git a/interfaces/tests/enum-schema-entity.test.mjs b/interfaces/tests/enum-schema-entity.test.mjs new file mode 100644 index 0000000000..01becb1162 --- /dev/null +++ b/interfaces/tests/enum-schema-entity.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; + +describe('SchemaEntity enum', () => { + it('covers core schema entity types', () => { + const values = Object.values(SchemaEntity); + for (const expected of ['NONE', 'VC', 'EVC', 'STANDARD_REGISTRY', 'USER', 'POLICY']) { + assert.ok(values.includes(expected), `missing ${expected}`); + } + }); +}); diff --git a/interfaces/tests/enum-schema-status.test.mjs b/interfaces/tests/enum-schema-status.test.mjs new file mode 100644 index 0000000000..b9ac6b2425 --- /dev/null +++ b/interfaces/tests/enum-schema-status.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { SchemaStatus } from '../dist/type/schema-status.type.js'; + +describe('SchemaStatus enum', () => { + it('exposes the six schema lifecycle states', () => { + for (const k of ['DRAFT', 'PUBLISHED', 'UNPUBLISHED', 'ERROR', 'DEMO', 'VIEW']) { + assert.equal(SchemaStatus[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-script-language.test.mjs b/interfaces/tests/enum-script-language.test.mjs new file mode 100644 index 0000000000..9c4eb3169b --- /dev/null +++ b/interfaces/tests/enum-script-language.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { ScriptLanguageOption } from '../dist/type/script-language-option.type.js'; + +describe('ScriptLanguageOption enum', () => { + it('exposes JAVASCRIPT and PYTHON', () => { + assert.equal(ScriptLanguageOption.JAVASCRIPT, 'JAVASCRIPT'); + assert.equal(ScriptLanguageOption.PYTHON, 'PYTHON'); + assert.equal(Object.keys(ScriptLanguageOption).length, 2); + }); +}); diff --git a/interfaces/tests/enum-signature.test.mjs b/interfaces/tests/enum-signature.test.mjs new file mode 100644 index 0000000000..14238b6b5f --- /dev/null +++ b/interfaces/tests/enum-signature.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { SignatureType } from '../dist/type/signature.type.js'; + +describe('SignatureType enum', () => { + it('exposes Ed25519Signature2018 and BbsBlsSignature2020 W3C suite ids', () => { + assert.equal(SignatureType.Ed25519Signature2018, 'Ed25519Signature2018'); + assert.equal(SignatureType.BbsBlsSignature2020, 'BbsBlsSignature2020'); + }); +}); diff --git a/interfaces/tests/enum-tag.test.mjs b/interfaces/tests/enum-tag.test.mjs new file mode 100644 index 0000000000..64ce958950 --- /dev/null +++ b/interfaces/tests/enum-tag.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { TagType } from '../dist/type/tag.type.js'; + +describe('TagType enum (interfaces)', () => { + it('matches PascalCase resource names', () => { + for (const k of ['Schema', 'Policy', 'Token', 'Module', 'PolicyDocument', 'Contract', 'Tool', 'PolicyBlock']) { + assert.equal(TagType[k], k); + } + }); +}); diff --git a/interfaces/tests/enum-token-type.test.mjs b/interfaces/tests/enum-token-type.test.mjs new file mode 100644 index 0000000000..191261a1a6 --- /dev/null +++ b/interfaces/tests/enum-token-type.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { TokenType } from '../dist/type/token.type.js'; + +describe('interfaces TokenType enum', () => { + it('uses kebab-case non-fungible/fungible (NOT the Hedera enum names)', () => { + assert.equal(TokenType.NON_FUNGIBLE, 'non-fungible'); + assert.equal(TokenType.FUNGIBLE, 'fungible'); + }); +}); diff --git a/interfaces/tests/enum-topic-type.test.mjs b/interfaces/tests/enum-topic-type.test.mjs new file mode 100644 index 0000000000..dca56c6d83 --- /dev/null +++ b/interfaces/tests/enum-topic-type.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { TopicType } from '../dist/type/topic.type.js'; + +describe('interfaces TopicType enum', () => { + it('exposes the full topic taxonomy (18 entries)', () => { + const expected = ['UserTopic', 'PolicyTopic', 'InstancePolicyTopic', 'DynamicTopic', 'SchemaTopic', + 'SynchronizationTopic', 'RetireTopic', 'TokenTopic', 'ModuleTopic', 'ContractTopic', + 'ToolTopic', 'TagsTopic', 'StatisticTopic', 'LabelTopic', 'RestoreTopic', + 'ActionsTopic', 'RecordsTopic', 'CommentsTopic']; + for (const k of expected) { + assert.ok(typeof TopicType[k] === 'string', `missing ${k}`); + } + assert.equal(Object.keys(TopicType).length, expected.length); + }); +}); diff --git a/interfaces/tests/enum-unit-system.test.mjs b/interfaces/tests/enum-unit-system.test.mjs new file mode 100644 index 0000000000..ce25ea8fee --- /dev/null +++ b/interfaces/tests/enum-unit-system.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { UnitSystem } from '../dist/type/unit-system.type.js'; + +describe('UnitSystem enum', () => { + it('exposes Prefix / Postfix / None', () => { + const values = Object.values(UnitSystem); + assert.ok(values.length >= 2); + }); +}); diff --git a/interfaces/tests/enum-user-group.test.mjs b/interfaces/tests/enum-user-group.test.mjs new file mode 100644 index 0000000000..2027af42ef --- /dev/null +++ b/interfaces/tests/enum-user-group.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { GroupRelationshipType, GroupAccessType } from '../dist/type/user-group.type.js'; + +describe('GroupRelationshipType enum', () => { + it('exposes Single and Multiple', () => { + assert.equal(GroupRelationshipType.Single, 'Single'); + assert.equal(GroupRelationshipType.Multiple, 'Multiple'); + }); + + it('has exactly two entries', () => { + assert.equal(Object.keys(GroupRelationshipType).length, 2); + }); +}); + +describe('GroupAccessType enum', () => { + it('exposes Global and Private', () => { + assert.equal(GroupAccessType.Global, 'Global'); + assert.equal(GroupAccessType.Private, 'Private'); + }); + + it('has exactly two entries', () => { + assert.equal(Object.keys(GroupAccessType).length, 2); + }); +}); diff --git a/interfaces/tests/enum-user-option.test.mjs b/interfaces/tests/enum-user-option.test.mjs new file mode 100644 index 0000000000..30ea38f109 --- /dev/null +++ b/interfaces/tests/enum-user-option.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { UserOption } from '../dist/type/user-option.type.js'; + +describe('UserOption enum', () => { + it('exposes the documented selection modes', () => { + assert.equal(UserOption.ALL, 'ALL'); + assert.equal(UserOption.CURRENT, 'CURRENT'); + assert.equal(UserOption.ROLE, 'ROLE'); + }); + + it('maps POLICY_OWNER to "OWNER" (legacy compatibility, NOT "POLICY_OWNER")', () => { + assert.equal(UserOption.POLICY_OWNER, 'OWNER'); + }); + + it('exposes document-scoped owner/issuer options', () => { + assert.equal(UserOption.DOCUMENT_OWNER, 'DOCUMENT_OWNER'); + assert.equal(UserOption.DOCUMENT_ISSUER, 'DOCUMENT_ISSUER'); + }); + + it('exposes GROUP_OWNER', () => { + assert.equal(UserOption.GROUP_OWNER, 'GROUP_OWNER'); + }); + + it('has exactly seven entries', () => { + assert.equal(Object.keys(UserOption).length, 7); + }); +}); diff --git a/interfaces/tests/enum-user-type.test.mjs b/interfaces/tests/enum-user-type.test.mjs new file mode 100644 index 0000000000..a3831e0bb9 --- /dev/null +++ b/interfaces/tests/enum-user-type.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { UserType } from '../dist/type/user.type.js'; + +describe('UserType enum', () => { + it('exposes OWNER and CURRENT', () => { + assert.equal(UserType.OWNER, 'owner'); + assert.equal(UserType.CURRENT, 'current'); + }); + + it('uses lower-case string values (not the SCREAMING_SNAKE_CASE keys)', () => { + for (const v of Object.values(UserType)) { + assert.equal(v, v.toLowerCase()); + } + }); + + it('has exactly two entries', () => { + assert.equal(Object.keys(UserType).length, 2); + }); +}); diff --git a/interfaces/tests/enum-w3s-events.test.mjs b/interfaces/tests/enum-w3s-events.test.mjs new file mode 100644 index 0000000000..c4a100140f --- /dev/null +++ b/interfaces/tests/enum-w3s-events.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { W3SEvents } from '../dist/type/w3s-events.js'; + +describe('W3SEvents enum', () => { + it('exposes the UPLOAD_FILE event with the canonical NATS subject', () => { + assert.equal(W3SEvents.UPLOAD_FILE, 'w3s-upload-file'); + }); + + it('has exactly one entry', () => { + assert.equal(Object.keys(W3SEvents).length, 1); + }); +}); diff --git a/interfaces/tests/field-types-dictionary-extra.test.mjs b/interfaces/tests/field-types-dictionary-extra.test.mjs new file mode 100644 index 0000000000..42dcec6333 --- /dev/null +++ b/interfaces/tests/field-types-dictionary-extra.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { + FieldTypesDictionary, + DefaultFieldDictionary, +} from '../dist/helpers/field-types-dictionary.js'; + +const byName = (name) => FieldTypesDictionary.FieldTypes.find((t) => t.name === name); + +describe('FieldTypesDictionary.FieldTypes — string-formatted entries', () => { + const cases = [ + ['Time', 'time'], + ['DateTime', 'date-time'], + ['Duration', 'duration'], + ['URL', 'url'], + ['URI', 'uri'], + ['Email', 'email'], + ]; + for (const [name, format] of cases) { + it(`${name} is a string with format ${format}`, () => { + const entry = byName(name); + assert.equal(entry.type, 'string'); + assert.equal(entry.format, format); + assert.equal(entry.isRef, false); + }); + } + + it('Image uses an ipfs pattern and no format', () => { + const entry = byName('Image'); + assert.equal(entry.type, 'string'); + assert.equal(entry.format, undefined); + assert.equal(entry.pattern, '^ipfs://.+'); + }); + + it('File carries customType=file with an ipfs pattern', () => { + const entry = byName('File'); + assert.equal(entry.customType, 'file'); + assert.equal(entry.pattern, '^ipfs://.+'); + }); + + it('Enum carries customType=enum', () => { + assert.equal(byName('Enum').customType, 'enum'); + }); + + it('Help Text is of type null', () => { + assert.equal(byName('Help Text').type, 'null'); + }); + + it('Table carries customType=table', () => { + assert.equal(byName('Table').customType, 'table'); + }); + + it('GeoJSON ref uses #GeoJSON type and customType=geo', () => { + const entry = byName('GeoJSON'); + assert.equal(entry.type, '#GeoJSON'); + assert.equal(entry.customType, 'geo'); + assert.equal(entry.isRef, true); + }); + + it('SentinelHUB ref uses #SentinelHUB type and customType=sentinel', () => { + const entry = byName('SentinelHUB'); + assert.equal(entry.type, '#SentinelHUB'); + assert.equal(entry.customType, 'sentinel'); + assert.equal(entry.isRef, true); + }); + + it('Number / Integer map to numeric JSON types', () => { + assert.equal(byName('Number').type, 'number'); + assert.equal(byName('Integer').type, 'integer'); + }); +}); + +describe('FieldTypesDictionary.CustomFieldTypes', () => { + it('contains a hederaAccount entry with the dotted pattern', () => { + const acc = FieldTypesDictionary.CustomFieldTypes.find((t) => t.name === 'hederaAccount'); + assert.equal(acc.type, 'string'); + assert.equal(acc.customType, 'hederaAccount'); + assert.equal(acc.pattern, '^\\d+\\.\\d+\\.\\d+$'); + }); + + it('contains two unit-system entries typed as number', () => { + const units = FieldTypesDictionary.CustomFieldTypes.filter((t) => t.unitSystem); + assert.equal(units.length, 2); + for (const u of units) { + assert.equal(u.type, 'number'); + assert.equal(u.unit, ''); + } + }); +}); + +describe('FieldTypesDictionary.SystemFieldTypes / MeasureFieldTypes', () => { + it('SystemFieldTypes are GeoJSON and SentinelHUB references', () => { + const names = FieldTypesDictionary.SystemFieldTypes.map((t) => t.name); + assert.deepEqual(names, ['GeoJSON', 'SentinelHUB']); + for (const t of FieldTypesDictionary.SystemFieldTypes) { + assert.equal(t.isRef, true); + assert.equal(t.customType, undefined); + } + }); + + it('MeasureFieldTypes is empty', () => { + assert.deepEqual(FieldTypesDictionary.MeasureFieldTypes, []); + }); +}); + +describe('FieldTypesDictionary.equal — more edges', () => { + it('uses loose comparison so undefined matches null', () => { + const field = { type: 'string', format: null, pattern: null, isRef: false, customType: null }; + const type = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), true); + }); + + it('returns false when type differs', () => { + const field = { type: 'number', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + const type = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), false); + }); + + it('returns false when pattern differs', () => { + const field = { type: 'string', format: undefined, pattern: '^a', isRef: false, customType: undefined }; + const type = { type: 'string', format: undefined, pattern: '^b', isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), false); + }); + + it('returns false when customType differs', () => { + const field = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: 'file' }; + const type = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: 'enum' }; + assert.equal(FieldTypesDictionary.equal(field, type), false); + }); + + it('matches a real catalogue Date entry against an equivalent field', () => { + const date = byName('Date'); + const field = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, date), true); + }); +}); + +describe('DefaultFieldDictionary.vcDefaultFields', () => { + it('lists policyId, ref, guardianVersion in order', () => { + const names = DefaultFieldDictionary.vcDefaultFields.map((f) => f.name); + assert.deepEqual(names, ['policyId', 'ref', 'guardianVersion']); + }); + + it('marks every default field readOnly and string-typed', () => { + for (const f of DefaultFieldDictionary.vcDefaultFields) { + assert.equal(f.readOnly, true); + assert.equal(f.type, 'string'); + assert.equal(f.isRef, false); + } + }); + + it('only policyId is required', () => { + const required = DefaultFieldDictionary.vcDefaultFields.filter((f) => f.required).map((f) => f.name); + assert.deepEqual(required, ['policyId']); + }); +}); diff --git a/interfaces/tests/field-types-dictionary.test.mjs b/interfaces/tests/field-types-dictionary.test.mjs new file mode 100644 index 0000000000..1a8d072f3e --- /dev/null +++ b/interfaces/tests/field-types-dictionary.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { + FieldTypesDictionary, + DefaultFieldDictionary, +} from '../dist/helpers/field-types-dictionary.js'; + +describe('FieldTypesDictionary.FieldTypes (catalogue)', () => { + it('contains the basic primitive types', () => { + const names = FieldTypesDictionary.FieldTypes.map((t) => t.name); + for (const required of ['Number', 'Integer', 'String', 'Boolean']) { + assert.ok(names.includes(required), `${required} missing from FieldTypes`); + } + }); + + it('marks scalar primitives (Number/Integer/String/Boolean) as isRef=false', () => { + const scalars = ['Number', 'Integer', 'String', 'Boolean']; + for (const t of FieldTypesDictionary.FieldTypes) { + if (scalars.includes(t.name)) { + assert.equal(t.isRef, false, `${t.name} should not be a reference`); + } + } + }); + + it('marks complex reference types (GeoJSON, SentinelHUB) as isRef=true', () => { + const refs = ['GeoJSON', 'SentinelHUB']; + for (const t of FieldTypesDictionary.FieldTypes) { + if (refs.includes(t.name)) { + assert.equal(t.isRef, true, `${t.name} should be a reference`); + } + } + }); + + it('uses string+date format for the Date entry', () => { + const date = FieldTypesDictionary.FieldTypes.find((t) => t.name === 'Date'); + assert.equal(date.type, 'string'); + assert.equal(date.format, 'date'); + }); +}); + +describe('FieldTypesDictionary.equal', () => { + it('returns true when type/format/pattern/isRef/customType all match', () => { + const field = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + const type = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), true); + }); + + it('returns false when format differs', () => { + const field = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + const type = { type: 'string', format: 'time', pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), false); + }); + + it('returns false when isRef differs', () => { + const field = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + const type = { type: 'string', format: undefined, pattern: undefined, isRef: true, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(field, type), false); + }); +}); + +describe('DefaultFieldDictionary.getDefaultFields', () => { + it('returns vcDefaultFields for VC and EVC entities', () => { + const vc = DefaultFieldDictionary.getDefaultFields('VC'); + const evc = DefaultFieldDictionary.getDefaultFields('EVC'); + assert.equal(vc.length, DefaultFieldDictionary.vcDefaultFields.length); + assert.equal(evc.length, DefaultFieldDictionary.vcDefaultFields.length); + }); + + it('returns a deep copy (mutation does not affect catalogue)', () => { + const a = DefaultFieldDictionary.getDefaultFields('VC'); + a[0].name = 'mutated'; + const b = DefaultFieldDictionary.getDefaultFields('VC'); + assert.equal(b[0].name, 'policyId'); + }); + + it('returns [] for non-VC entities', () => { + assert.deepEqual(DefaultFieldDictionary.getDefaultFields('NONE'), []); + assert.deepEqual(DefaultFieldDictionary.getDefaultFields('USER'), []); + }); +}); diff --git a/interfaces/tests/formula-engine.test.mjs b/interfaces/tests/formula-engine.test.mjs new file mode 100644 index 0000000000..99f9be15cb --- /dev/null +++ b/interfaces/tests/formula-engine.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +// Minimal stub so we don't pull in mathjs just to test the wrapper logic. +function makeStubEngine() { + return { + callsTo: { evaluate: [] }, + evaluate(expr, scope) { + this.callsTo.evaluate.push({ expr, scope }); + // toy evaluator: only handles `a + b` style numerics from scope + const m = /^([a-z]+)\s*\+\s*([a-z]+)$/i.exec(expr); + if (m) return scope[m[1]] + scope[m[2]]; + if (expr === 'BOOM') throw new Error('engine boom'); + // numeric literal passthrough + if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr); + return expr; + }, + }; +} + +describe('FormulaEngine.setMathEngine / evaluate', () => { + it('throws when no math engine is set', () => { + FormulaEngine.setMathEngine(undefined); + assert.throws(() => FormulaEngine.evaluate('1+1', {}), /Math engine is not defined/); + }); + + it('forwards trimmed expression to the underlying engine', () => { + const stub = makeStubEngine(); + FormulaEngine.setMathEngine(stub); + const result = FormulaEngine.evaluate(' a + b ', { a: 2, b: 3 }); + assert.equal(result, 5); + assert.equal(stub.callsTo.evaluate[0].expr, 'a + b'); + assert.deepEqual(stub.callsTo.evaluate[0].scope, { a: 2, b: 3 }); + }); + + it('strips a leading "=" before forwarding (spreadsheet-style formulas)', () => { + const stub = makeStubEngine(); + FormulaEngine.setMathEngine(stub); + const result = FormulaEngine.evaluate('= 4 + 6', { /* unused */ }); + // The leading "=" must be stripped — toy engine returns the literal expression text otherwise + // Our stub matches `a + b` form via regex and returns NaN; numeric literal "4" alone won't match, + // so we just check the expr passed in had no leading "=". + assert.equal(stub.callsTo.evaluate[0].expr.startsWith('='), false); + }); + + it('returns "Incorrect formula" sentinel when the engine throws', () => { + const stub = makeStubEngine(); + FormulaEngine.setMathEngine(stub); + const out = FormulaEngine.evaluate('BOOM', {}); + assert.equal(out, 'Incorrect formula'); + }); + + it('passes the scope reference through unchanged', () => { + const stub = makeStubEngine(); + FormulaEngine.setMathEngine(stub); + const scope = { a: 1, b: 2 }; + FormulaEngine.evaluate('a + b', scope); + assert.strictEqual(stub.callsTo.evaluate[0].scope, scope); + }); + + it('replacing the math engine takes effect immediately', () => { + const stub1 = makeStubEngine(); + const stub2 = makeStubEngine(); + FormulaEngine.setMathEngine(stub1); + FormulaEngine.evaluate('a + b', { a: 1, b: 1 }); + assert.equal(stub1.callsTo.evaluate.length, 1); + FormulaEngine.setMathEngine(stub2); + FormulaEngine.evaluate('a + b', { a: 5, b: 5 }); + assert.equal(stub1.callsTo.evaluate.length, 1); + assert.equal(stub2.callsTo.evaluate.length, 1); + }); +}); diff --git a/interfaces/tests/generate-document-field-types-edge.test.mjs b/interfaces/tests/generate-document-field-types-edge.test.mjs new file mode 100644 index 0000000000..48688bc5b2 --- /dev/null +++ b/interfaces/tests/generate-document-field-types-edge.test.mjs @@ -0,0 +1,542 @@ +import assert from 'node:assert/strict'; +import { DocumentGenerator } from '../dist/helpers/generate-document.js'; +import { + FieldTypesDictionary, + DefaultFieldDictionary, +} from '../dist/helpers/field-types-dictionary.js'; + +const field = (overrides) => ({ + name: 'f', + type: null, + format: null, + pattern: null, + isRef: false, + isArray: false, + examples: null, + default: null, + customType: null, + ...overrides, +}); + +describe('@unit DocumentGenerator.generateExample — boundary inputs', () => { + it('returns undefined when type is null', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: null })), undefined); + }); + + it('returns undefined when type is undefined', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: undefined })), undefined); + }); + + it('returns undefined for an empty-string type', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: '' })), undefined); + }); + + it('returns "example" for a number-format string (format ignored on number)', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'number', format: 'date' })), 1); + }); + + it('default is preferred over inferred number value', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'number', default: 7 })), 7); + }); + + it('a falsy zero default is NOT used (truthiness check) and number falls back to 1', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'number', default: 0 })), 1); + }); + + it('a falsy empty-string default is NOT used and string falls back to "example"', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'string', default: '' })), 'example'); + }); + + it('a falsy false default is NOT used and boolean falls back to true', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'boolean', default: false })), true); + }); + + it('examples[0] truthy short-circuits over default and inferred', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'number', examples: [99], default: 5 })), + 99, + ); + }); + + it('examples[0] falsy 0 is skipped, then default used', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'number', examples: [0], default: 5 })), + 5, + ); + }); + + it('examples=[] (empty array) falls through to inferred value', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'integer', examples: [] })), 1); + }); + + it('a unicode default string is returned verbatim', () => { + const u = 'héllo-世界-😀'; + assert.equal(DocumentGenerator.generateExample(field({ type: 'string', default: u })), u); + }); + + it('a very large default string is returned verbatim', () => { + const big = 'x'.repeat(10000); + assert.equal(DocumentGenerator.generateExample(field({ type: 'string', default: big })), big); + }); +}); + +describe('@unit DocumentGenerator.generateExample — customType edges', () => { + it('enum with null enum returns undefined', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', customType: 'enum', enum: null })), + undefined, + ); + }); + + it('enum with empty array reads enum[0] as undefined', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', customType: 'enum', enum: [] })), + undefined, + ); + }); + + it('enum returns a falsy first value (empty string) when present', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', customType: 'enum', enum: [''] })), + '', + ); + }); + + it('file customType is not special-cased and falls to ipfs pattern handling', () => { + const out = DocumentGenerator.generateExample(field({ type: 'string', customType: 'file', pattern: '^ipfs://.+' })); + assert.ok(out.startsWith('ipfs://')); + }); + + it('unknown customType on string falls through to format/pattern/default chain', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'string', customType: 'mystery' })), 'example'); + }); + + it('table customType wins even when a format is also set', () => { + const out = DocumentGenerator.generateExample(field({ type: 'string', customType: 'table', format: 'date' })); + assert.match(out, /^\{"type":"table"/); + }); + + it('hederaAccount customType wins over pattern', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', customType: 'hederaAccount', pattern: '^ipfs://.+' })), + '0.0.1', + ); + }); +}); + +describe('@unit DocumentGenerator.generateExample — string format/pattern interplay', () => { + it('format takes precedence over pattern when both set', () => { + assert.equal( + DocumentGenerator.generateExample(field({ type: 'string', format: 'date', pattern: '^ipfs://.+' })), + '2000-01-01', + ); + }); + + it('unknown format with a pattern falls through to the pattern branch', () => { + const out = DocumentGenerator.generateExample(field({ type: 'string', format: 'bogus', pattern: '^ipfs://.+' })); + assert.ok(out.startsWith('ipfs://')); + }); + + it('empty-string pattern is falsy so returns the plain "example" default', () => { + assert.equal(DocumentGenerator.generateExample(field({ type: 'string', pattern: '' })), 'example'); + }); + + it('a non-ipfs pattern yields a generated id that is not "example"', () => { + const out = DocumentGenerator.generateExample(field({ type: 'string', pattern: '^[A-Z]+$' })); + assert.notEqual(out, 'example'); + assert.equal(typeof out, 'string'); + assert.ok(out.length > 0); + }); + + it('ipfs pattern only matches the exact literal "^ipfs://.+"', () => { + const exact = DocumentGenerator.generateExample(field({ type: 'string', pattern: '^ipfs://.+' })); + const variant = DocumentGenerator.generateExample(field({ type: 'string', pattern: '^ipfs://[a-z]+' })); + assert.ok(exact.startsWith('ipfs://')); + assert.ok(!variant.startsWith('ipfs://')); + }); +}); + +describe('@unit DocumentGenerator.generateExample — passes null context/option', () => { + it('null type returns undefined even with null context/option', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'null' }), undefined); + }); + + it('handles a field that omits all optional keys', () => { + assert.equal(DocumentGenerator.generateExample({ type: 'string' }), 'example'); + }); +}); + +describe('@unit DocumentGenerator.generateField — rowPresets edges', () => { + const f = (extra) => ({ name: 'f', type: 'string', ...extra }); + + it('undefined rowPresets object does not throw', () => { + assert.equal(DocumentGenerator.generateField(f(), ['c'], null, undefined), 'example'); + }); + + it('null rowPresets object does not throw', () => { + assert.equal(DocumentGenerator.generateField(f(), ['c'], null, null), 'example'); + }); + + it('preset of explicit undefined is treated as absent', () => { + assert.equal(DocumentGenerator.generateField(f(), ['c'], null, { f: undefined }), 'example'); + }); + + it('preset of null is honoured (defined, not undefined)', () => { + assert.equal(DocumentGenerator.generateField(f({ type: 'number' }), ['c'], null, { f: null }), null); + }); + + it('preset of empty string is honoured', () => { + assert.equal(DocumentGenerator.generateField(f(), ['c'], null, { f: '' }), ''); + }); + + it('preset of false is honoured', () => { + assert.equal(DocumentGenerator.generateField(f({ type: 'boolean' }), ['c'], null, { f: false }), false); + }); + + it('preset wins over examples and default', () => { + assert.equal( + DocumentGenerator.generateField(f({ examples: ['ex'], default: 'def' }), ['c'], null, { f: 'preset' }), + 'preset', + ); + }); + + it('null-typed field with a defined preset returns the preset wrapped per isArray', () => { + assert.equal(DocumentGenerator.generateField(f({ type: 'null' }), ['c'], null, { f: 'kept' }), 'kept'); + }); +}); + +describe('@unit DocumentGenerator.generateField — array wrapping boundaries', () => { + it('isArray with undefined value returns undefined, not [undefined]', () => { + assert.equal(DocumentGenerator.generateField({ name: 'f', type: 'null', isArray: true }, ['c'], null, {}), undefined); + }); + + it('isArray with a null preset wraps null into [null]', () => { + assert.deepEqual( + DocumentGenerator.generateField({ name: 'f', type: 'number', isArray: true }, ['c'], null, { f: null }), + [null], + ); + }); + + it('isArray with empty-string preset wraps into [""]', () => { + assert.deepEqual( + DocumentGenerator.generateField({ name: 'f', type: 'string', isArray: true }, ['c'], null, { f: '' }), + [''], + ); + }); + + it('isArray=false leaves an array value as-is', () => { + assert.deepEqual( + DocumentGenerator.generateField({ name: 'f', type: 'string', isArray: false }, ['c'], null, { f: ['a'] }), + ['a'], + ); + }); + + it('isArray with an already-array preset is not double-wrapped', () => { + assert.deepEqual( + DocumentGenerator.generateField({ name: 'f', type: 'string', isArray: true }, ['c'], null, { f: ['a', 'b'] }), + ['a', 'b'], + ); + }); +}); + +describe('@unit DocumentGenerator ref fields — examples short-circuit', () => { + it('ref field with a truthy examples[0] returns it verbatim (no generation)', () => { + const out = DocumentGenerator.generateField( + { name: 'geo', type: '#GeoJSON', isRef: true, examples: [{ ok: 1 }] }, + ['c'], null, {}, + ); + assert.deepEqual(out, { ok: 1 }); + }); + + it('ref field with falsy examples[0] still generates the GeoJSON default', () => { + const out = DocumentGenerator.generateField( + { name: 'geo', type: '#GeoJSON', isRef: true, examples: [null] }, + ['c'], null, {}, + ); + assert.equal(out.type, 'FeatureCollection'); + }); + + it('ref field with empty examples array generates the default', () => { + const out = DocumentGenerator.generateField( + { name: 'sh', type: '#SentinelHUB', isRef: true, examples: [] }, + ['c'], null, {}, + ); + assert.equal(out.layers, 'NATURAL-COLOR'); + }); +}); + +describe('@unit DocumentGenerator GeoJSON — preset and option edges', () => { + const geo = (extra) => ({ name: 'geo', type: '#GeoJSON', isRef: true, ...extra }); + + it('an array preset (non-plain-object) is ignored and defaults generate', () => { + const out = DocumentGenerator.generateField(geo(), ['c'], null, { geo: { geo: [1, 2] } }); + assert.equal(out.type, 'FeatureCollection'); + }); + + it('a null nested preset is ignored and defaults generate', () => { + const out = DocumentGenerator.generateField(geo(), ['c'], null, { geo: { geo: null } }); + assert.equal(out.type, 'FeatureCollection'); + }); + + it('availableOptions empty array falls back to Point', () => { + const out = DocumentGenerator.generateField(geo({ availableOptions: [] }), ['c'], null, {}); + assert.equal(out.features[0].geometry.type, 'Point'); + }); + + it('Point geometry uses the [0,0] default coordinates', () => { + const out = DocumentGenerator.generateField(geo({ availableOptions: ['Point'] }), ['c'], null, {}); + assert.deepEqual(out.features[0].geometry.coordinates, [0, 0]); + }); + + it('a plain-object preset is returned by identity, ignoring availableOptions', () => { + const preset = { type: 'FeatureCollection', features: [] }; + const out = DocumentGenerator.generateField(geo({ availableOptions: ['Polygon'] }), ['c'], null, { geo: { geo: preset } }); + assert.equal(out, preset); + }); + + it('always emits empty properties object on the feature', () => { + const out = DocumentGenerator.generateField(geo(), ['c'], null, {}); + assert.deepEqual(out.features[0].properties, {}); + }); +}); + +describe('@unit DocumentGenerator SentinelHUB — context wiring', () => { + const sh = () => ({ name: 'sh', type: '#SentinelHUB', isRef: true }); + + it('embeds the passed context array by reference into @context', () => { + const ctx = ['#root']; + const out = DocumentGenerator.generateField(sh(), ctx, null, {}); + assert.deepEqual(out['@context'], ['#root']); + }); + + it('numeric-string preset is not a plain object so defaults generate', () => { + const out = DocumentGenerator.generateField(sh(), ['c'], null, { sh: { sh: '123' } }); + assert.equal(out.maxcc, 10); + }); +}); + +describe('@unit DocumentGenerator sub-documents — structure edges', () => { + it('sub-document with empty fields array still gets type and @context', () => { + const out = DocumentGenerator.generateField( + { name: 'sub', type: '#Empty&1.0.0', isRef: true, fields: [] }, + ['c'], null, {}, + ); + assert.equal(out.type, 'Empty&1.0.0'); + assert.deepEqual(out['@context'], ['c']); + }); + + it('omits child fields whose generated value is undefined', () => { + const out = DocumentGenerator.generateField( + { name: 'sub', type: '#S', isRef: true, fields: [{ name: 'gone', type: 'null' }, { name: 'kept', type: 'integer' }] }, + ['c'], null, {}, + ); + assert.equal('gone' in out, false); + assert.equal(out.kept, 1); + }); + + it('a child field literally named "type" is overwritten by the schema type', () => { + const out = DocumentGenerator.generateField( + { name: 'sub', type: '#S', isRef: true, fields: [{ name: 'type', type: 'string' }] }, + ['c'], null, {}, + ); + assert.equal(out.type, 'S'); + }); + + it('parseRef strips the leading hash from the sub-document type', () => { + const out = DocumentGenerator.generateField( + { name: 'sub', type: '#Nested&2.1.0', isRef: true, fields: [] }, + ['c'], null, {}, + ); + assert.equal(out.type, 'Nested&2.1.0'); + }); +}); + +describe('@unit DocumentGenerator.generateDocument — schema-level edges', () => { + it('schema with no fields still yields id, type and @context', () => { + const doc = DocumentGenerator.generateDocument({ iri: '#R', type: 'R', fields: [] }); + assert.ok(doc.id); + assert.equal(doc.type, 'R'); + assert.deepEqual(doc['@context'], ['#R']); + }); + + it('undefined schema.iri produces a [undefined] @context', () => { + const doc = DocumentGenerator.generateDocument({ type: 'R', fields: [] }); + assert.deepEqual(doc['@context'], [undefined]); + }); + + it('a field named "id" overrides the generated uuid (id set before the field loop)', () => { + const doc = DocumentGenerator.generateDocument({ + iri: '#R', type: 'R', fields: [field({ name: 'id', type: 'string' })], + }); + assert.equal(doc.id, 'example'); + }); + + it('a field named "type" is overwritten by the schema type', () => { + const doc = DocumentGenerator.generateDocument({ + iri: '#R', type: 'RealType', fields: [field({ name: 'type', type: 'string' })], + }); + assert.equal(doc.type, 'RealType'); + }); + + it('skips fields that generate undefined (null type, empty enum)', () => { + const doc = DocumentGenerator.generateDocument({ + iri: '#R', type: 'R', fields: [ + field({ name: 'a', type: 'null' }), + field({ name: 'b', type: 'string', customType: 'enum' }), + field({ name: 'c', type: 'integer' }), + ], + }); + assert.equal('a' in doc, false); + assert.equal('b' in doc, false); + assert.equal(doc.c, 1); + }); + + it('rowPresets apply across multiple top-level fields', () => { + const doc = DocumentGenerator.generateDocument( + { iri: '#R', type: 'R', fields: [field({ name: 'a', type: 'string' }), field({ name: 'b', type: 'number' })] }, + undefined, + { a: 'AA', b: 42 }, + ); + assert.equal(doc.a, 'AA'); + assert.equal(doc.b, 42); + }); + + it('explicit option object is used when provided', () => { + const doc = DocumentGenerator.generateDocument( + { iri: '#R', type: 'R', fields: [field({ name: 'a', type: 'string' })] }, + { enableHiddenFields: true }, + ); + assert.equal(doc.a, 'example'); + }); + + it('two documents from the same schema differ only by id', () => { + const s = { iri: '#R', type: 'R', fields: [field({ name: 'a', type: 'string' })] }; + const a = DocumentGenerator.generateDocument(s); + const b = DocumentGenerator.generateDocument(s); + assert.notEqual(a.id, b.id); + assert.equal(a.a, b.a); + assert.equal(a.type, b.type); + }); + + it('a unicode field name is preserved as a document key', () => { + const doc = DocumentGenerator.generateDocument({ + iri: '#R', type: 'R', fields: [field({ name: '区域', type: 'string' })], + }); + assert.equal(doc['区域'], 'example'); + }); +}); + +describe('@unit FieldTypesDictionary.equal — error/boundary inputs', () => { + it('matches catalogue String entry against an equivalent field', () => { + const str = FieldTypesDictionary.FieldTypes.find((t) => t.name === 'String'); + const fld = { type: 'string', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(fld, str), true); + }); + + it('null format on field loosely equals undefined format on type', () => { + const fld = { type: 'number', format: null, pattern: null, isRef: false, customType: null }; + const typ = { type: 'number', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(fld, typ), true); + }); + + it('a string-vs-number type mismatch is not loosely equal', () => { + const fld = { type: '1', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + const typ = { type: 1, format: undefined, pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(fld, typ), true); + }); + + it('boolean false isRef loosely equals 0', () => { + const fld = { type: 's', format: undefined, pattern: undefined, isRef: false, customType: undefined }; + const typ = { type: 's', format: undefined, pattern: undefined, isRef: 0, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(fld, typ), true); + }); + + it('is symmetric for a matching pair', () => { + const a = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + const b = { type: 'string', format: 'date', pattern: undefined, isRef: false, customType: undefined }; + assert.equal(FieldTypesDictionary.equal(a, b), FieldTypesDictionary.equal(b, a)); + }); + + it('throws when the field argument is null', () => { + assert.throws(() => FieldTypesDictionary.equal(null, { type: 's' })); + }); + + it('throws when the type argument is null', () => { + assert.throws(() => FieldTypesDictionary.equal({ type: 's' }, null)); + }); +}); + +describe('@unit FieldTypesDictionary — catalogue integrity', () => { + it('every FieldTypes entry has a name and a type', () => { + for (const t of FieldTypesDictionary.FieldTypes) { + assert.ok(t.name); + assert.ok(t.type); + } + }); + + it('FieldTypes names are unique', () => { + const names = FieldTypesDictionary.FieldTypes.map((t) => t.name); + assert.equal(new Set(names).size, names.length); + }); + + it('exactly two ref entries (GeoJSON, SentinelHUB) in FieldTypes', () => { + const refs = FieldTypesDictionary.FieldTypes.filter((t) => t.isRef).map((t) => t.name); + assert.deepEqual(refs.sort(), ['GeoJSON', 'SentinelHUB']); + }); + + it('Image and File share the same ipfs pattern', () => { + const img = FieldTypesDictionary.FieldTypes.find((t) => t.name === 'Image'); + const file = FieldTypesDictionary.FieldTypes.find((t) => t.name === 'File'); + assert.equal(img.pattern, file.pattern); + }); + + it('CustomFieldTypes unit entries differ only by unitSystem', () => { + const units = FieldTypesDictionary.CustomFieldTypes.filter((t) => t.unitSystem); + assert.equal(units.length, 2); + assert.notEqual(units[0].unitSystem, units[1].unitSystem); + }); + + it('the same catalogue reference is returned on repeated reads (no clone)', () => { + assert.equal(FieldTypesDictionary.FieldTypes, FieldTypesDictionary.FieldTypes); + }); +}); + +describe('@unit DefaultFieldDictionary.getDefaultFields — boundaries', () => { + it('returns [] for null entity', () => { + assert.deepEqual(DefaultFieldDictionary.getDefaultFields(null), []); + }); + + it('returns [] for undefined entity', () => { + assert.deepEqual(DefaultFieldDictionary.getDefaultFields(undefined), []); + }); + + it('returns [] for an empty-string entity', () => { + assert.deepEqual(DefaultFieldDictionary.getDefaultFields(''), []); + }); + + it('VC and EVC return structurally identical copies', () => { + assert.deepEqual( + DefaultFieldDictionary.getDefaultFields('VC'), + DefaultFieldDictionary.getDefaultFields('EVC'), + ); + }); + + it('successive VC calls return distinct array instances', () => { + const a = DefaultFieldDictionary.getDefaultFields('VC'); + const b = DefaultFieldDictionary.getDefaultFields('VC'); + assert.notEqual(a, b); + assert.deepEqual(a, b); + }); + + it('mutating a nested returned field does not corrupt the catalogue', () => { + const a = DefaultFieldDictionary.getDefaultFields('VC'); + a[1].title = 'changed'; + assert.equal(DefaultFieldDictionary.vcDefaultFields[1].title, 'Relationships'); + }); + + it('a non-VC each call returns a fresh empty array literal', () => { + const a = DefaultFieldDictionary.getDefaultFields('USER'); + const b = DefaultFieldDictionary.getDefaultFields('USER'); + assert.notEqual(a, b); + }); +}); diff --git a/interfaces/tests/generate-uuid-v4.test.mjs b/interfaces/tests/generate-uuid-v4.test.mjs new file mode 100644 index 0000000000..0b6144ae36 --- /dev/null +++ b/interfaces/tests/generate-uuid-v4.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { GenerateUUIDv4, GenerateID } from '../dist/helpers/generate-uuid-v4.js'; + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('GenerateUUIDv4', () => { + it('produces a valid v4 UUID', () => { + const id = GenerateUUIDv4(); + assert.match(id, UUID_V4_RE); + }); + + it('produces a different value across calls (statistical test)', () => { + const a = GenerateUUIDv4(); + const b = GenerateUUIDv4(); + assert.notEqual(a, b); + }); +}); + +describe('GenerateID', () => { + it('returns a 32-character hex string', () => { + const id = GenerateID(); + assert.match(id, /^[0-9a-f]{32}$/); + }); + + it('produces a different value across calls', () => { + assert.notEqual(GenerateID(), GenerateID()); + }); +}); diff --git a/interfaces/tests/geojson-context.test.mjs b/interfaces/tests/geojson-context.test.mjs new file mode 100644 index 0000000000..8581440b7e --- /dev/null +++ b/interfaces/tests/geojson-context.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import GeoJsonContext from '../dist/helpers/geojson-schema/geo-json-context.js'; + +describe('GeoJSON JSON-LD @context', () => { + it('declares JSON-LD version 1.1', () => { + assert.equal(GeoJsonContext['@context']['@version'], 1.1); + }); + + it('maps the geojson vocab prefix to the canonical URL', () => { + assert.equal(GeoJsonContext['@context'].geojson, 'https://purl.org/geojson/vocab#'); + }); + + it('aliases every geometry/feature type under the geojson vocab', () => { + const ctx = GeoJsonContext['@context']; + for (const t of [ + 'Feature', + 'FeatureCollection', + 'GeometryCollection', + 'LineString', + 'MultiLineString', + 'MultiPoint', + 'MultiPolygon', + 'Point', + 'Polygon', + ]) { + assert.equal(ctx[t], `geojson:${t}`, `${t} should map to geojson:${t}`); + } + }); + + it('bbox and coordinates are ordered lists', () => { + const ctx = GeoJsonContext['@context']; + assert.equal(ctx.bbox['@container'], '@list'); + assert.equal(ctx.coordinates['@container'], '@list'); + }); + + it('features is an unordered set', () => { + assert.equal(GeoJsonContext['@context'].features['@container'], '@set'); + }); + + it('id and type map to JSON-LD reserved keywords', () => { + const ctx = GeoJsonContext['@context']; + assert.equal(ctx.id, '@id'); + assert.equal(ctx.type, '@type'); + }); + + it('properties maps to geojson:properties (not the JSON-LD reserved word)', () => { + assert.equal(GeoJsonContext['@context'].properties, 'geojson:properties'); + }); +}); diff --git a/interfaces/tests/geojson-feature-collection.test.mjs b/interfaces/tests/geojson-feature-collection.test.mjs new file mode 100644 index 0000000000..7282be7376 --- /dev/null +++ b/interfaces/tests/geojson-feature-collection.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import FeatureCollection from '../dist/helpers/geojson-schema/feature-collection.js'; + +describe('GeoJSON FeatureCollection schema', () => { + it('declares the correct title and type', () => { + assert.equal(FeatureCollection.title, 'GeoJSON FeatureCollection'); + assert.equal(FeatureCollection.type, 'object'); + }); + + it('requires type and features', () => { + assert.deepEqual(FeatureCollection.required, ['type', 'features']); + }); + + it('pins the type enum to ["FeatureCollection"]', () => { + assert.deepEqual(FeatureCollection.properties.type.enum, ['FeatureCollection']); + }); + + it('features is an array of Feature sub-schemas', () => { + assert.equal(FeatureCollection.properties.features.type, 'array'); + assert.equal(FeatureCollection.properties.features.items.title, 'GeoJSON Feature'); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(FeatureCollection.properties.bbox.type, 'array'); + assert.equal(FeatureCollection.properties.bbox.minItems, 4); + assert.equal(FeatureCollection.properties.bbox.items.type, 'number'); + }); +}); diff --git a/interfaces/tests/geojson-feature.test.mjs b/interfaces/tests/geojson-feature.test.mjs new file mode 100644 index 0000000000..b7cd388b95 --- /dev/null +++ b/interfaces/tests/geojson-feature.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import Feature from '../dist/helpers/geojson-schema/feature.js'; + +describe('GeoJSON Feature schema', () => { + it('declares the correct title and type', () => { + assert.equal(Feature.title, 'GeoJSON Feature'); + assert.equal(Feature.type, 'object'); + }); + + it('requires only type and geometry', () => { + assert.deepEqual(Feature.required, ['type', 'geometry']); + }); + + it('pins the type enum to ["Feature"]', () => { + assert.deepEqual(Feature.properties.type.enum, ['Feature']); + }); + + it('id accepts either number or string', () => { + const ids = Feature.properties.id.oneOf.map((s) => s.type); + assert.ok(ids.includes('number')); + assert.ok(ids.includes('string')); + }); + + it('properties accepts null or object', () => { + const types = Feature.properties.properties.oneOf.map((s) => s.type); + assert.ok(types.includes('null')); + assert.ok(types.includes('object')); + }); + + it('geometry accepts null plus all primary geometry types and GeometryCollection', () => { + const titles = Feature.properties.geometry.oneOf + .filter((s) => s.title) + .map((s) => s.title); + for (const expected of [ + 'GeoJSON Point', + 'GeoJSON LineString', + 'GeoJSON Polygon', + 'GeoJSON MultiPoint', + 'GeoJSON MultiLineString', + 'GeoJSON MultiPolygon', + 'GeoJSON GeometryCollection', + ]) { + assert.ok(titles.includes(expected), `missing ${expected}`); + } + const nullEntry = Feature.properties.geometry.oneOf.find((s) => s.type === 'null'); + assert.ok(nullEntry, 'geometry should accept null'); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(Feature.properties.bbox.type, 'array'); + assert.equal(Feature.properties.bbox.minItems, 4); + assert.equal(Feature.properties.bbox.items.type, 'number'); + }); +}); diff --git a/interfaces/tests/geojson-geometry-collection.test.mjs b/interfaces/tests/geojson-geometry-collection.test.mjs new file mode 100644 index 0000000000..d1bf93ad9b --- /dev/null +++ b/interfaces/tests/geojson-geometry-collection.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import GeometryCollection from '../dist/helpers/geojson-schema/geometry-collection.js'; + +describe('GeoJSON GeometryCollection schema', () => { + it('declares the correct title and type', () => { + assert.equal(GeometryCollection.title, 'GeoJSON GeometryCollection'); + assert.equal(GeometryCollection.type, 'object'); + }); + + it('requires type and geometries', () => { + assert.deepEqual(GeometryCollection.required, ['type', 'geometries']); + }); + + it('pins the type enum to ["GeometryCollection"]', () => { + assert.deepEqual(GeometryCollection.properties.type.enum, ['GeometryCollection']); + }); + + it('geometries is an array whose items oneOf the six primary geometry types', () => { + assert.equal(GeometryCollection.properties.geometries.type, 'array'); + const titles = GeometryCollection.properties.geometries.items.oneOf.map((s) => s.title); + assert.deepEqual(titles.sort(), [ + 'GeoJSON LineString', + 'GeoJSON MultiLineString', + 'GeoJSON MultiPoint', + 'GeoJSON MultiPolygon', + 'GeoJSON Point', + 'GeoJSON Polygon', + ]); + }); + + it('does NOT permit nested GeometryCollections (RFC-7946 §3.1.8)', () => { + const titles = GeometryCollection.properties.geometries.items.oneOf.map((s) => s.title); + assert.ok(!titles.includes('GeoJSON GeometryCollection')); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(GeometryCollection.properties.bbox.type, 'array'); + assert.equal(GeometryCollection.properties.bbox.minItems, 4); + assert.equal(GeometryCollection.properties.bbox.items.type, 'number'); + }); +}); diff --git a/interfaces/tests/geojson-geometry.test.mjs b/interfaces/tests/geojson-geometry.test.mjs new file mode 100644 index 0000000000..17182328e5 --- /dev/null +++ b/interfaces/tests/geojson-geometry.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import Geometry from '../dist/helpers/geojson-schema/geometry.js'; + +describe('GeoJSON Geometry oneOf union', () => { + it('declares the correct title', () => { + assert.equal(Geometry.title, 'GeoJSON Geometry'); + }); + + it('lists all six primary geometry types in oneOf', () => { + const titles = Geometry.oneOf.map((s) => s.title); + assert.deepEqual(titles.sort(), [ + 'GeoJSON LineString', + 'GeoJSON MultiLineString', + 'GeoJSON MultiPoint', + 'GeoJSON MultiPolygon', + 'GeoJSON Point', + 'GeoJSON Polygon', + ]); + }); + + it('does NOT include Feature, FeatureCollection or GeometryCollection (those live in geo-json.js)', () => { + const titles = Geometry.oneOf.map((s) => s.title); + assert.ok(!titles.includes('GeoJSON Feature')); + assert.ok(!titles.includes('GeoJSON FeatureCollection')); + assert.ok(!titles.includes('GeoJSON GeometryCollection')); + }); +}); diff --git a/interfaces/tests/geojson-line-string.test.mjs b/interfaces/tests/geojson-line-string.test.mjs new file mode 100644 index 0000000000..d722028337 --- /dev/null +++ b/interfaces/tests/geojson-line-string.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import LineString from '../dist/helpers/geojson-schema/line-string.js'; + +describe('GeoJSON LineString schema', () => { + it('declares the correct title and type', () => { + assert.equal(LineString.title, 'GeoJSON LineString'); + assert.equal(LineString.type, 'object'); + }); + + it('requires type and coordinates', () => { + assert.deepEqual(LineString.required, ['type', 'coordinates']); + }); + + it('pins the type enum to ["LineString"]', () => { + assert.deepEqual(LineString.properties.type.enum, ['LineString']); + }); + + it('coordinates is an array of ≥2 position arrays (per RFC-7946 §3.1.4)', () => { + const coords = LineString.properties.coordinates; + assert.equal(coords.type, 'array'); + assert.equal(coords.minItems, 2); + assert.equal(coords.items.type, 'array'); + assert.equal(coords.items.minItems, 2); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(LineString.properties.bbox.type, 'array'); + assert.equal(LineString.properties.bbox.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-multi-line-string.test.mjs b/interfaces/tests/geojson-multi-line-string.test.mjs new file mode 100644 index 0000000000..258bcd0f32 --- /dev/null +++ b/interfaces/tests/geojson-multi-line-string.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import MultiLineString from '../dist/helpers/geojson-schema/multi-line-string.js'; + +describe('GeoJSON MultiLineString schema', () => { + it('declares the correct title and type', () => { + assert.equal(MultiLineString.title, 'GeoJSON MultiLineString'); + assert.equal(MultiLineString.type, 'object'); + }); + + it('requires type and coordinates', () => { + assert.deepEqual(MultiLineString.required, ['type', 'coordinates']); + }); + + it('pins the type enum to ["MultiLineString"]', () => { + assert.deepEqual(MultiLineString.properties.type.enum, ['MultiLineString']); + }); + + it('coordinates is an array of line-string-coordinate arrays (each ≥2 positions)', () => { + const coords = MultiLineString.properties.coordinates; + assert.equal(coords.type, 'array'); + assert.equal(coords.items.type, 'array'); + assert.equal(coords.items.minItems, 2); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(MultiLineString.properties.bbox.type, 'array'); + assert.equal(MultiLineString.properties.bbox.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-multi-point.test.mjs b/interfaces/tests/geojson-multi-point.test.mjs new file mode 100644 index 0000000000..b201db1315 --- /dev/null +++ b/interfaces/tests/geojson-multi-point.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import MultiPoint from '../dist/helpers/geojson-schema/multi-point.js'; + +describe('GeoJSON MultiPoint schema', () => { + it('declares the correct title and type', () => { + assert.equal(MultiPoint.title, 'GeoJSON MultiPoint'); + assert.equal(MultiPoint.type, 'object'); + }); + + it('requires type and coordinates', () => { + assert.deepEqual(MultiPoint.required, ['type', 'coordinates']); + }); + + it('pins the type enum to ["MultiPoint"]', () => { + assert.deepEqual(MultiPoint.properties.type.enum, ['MultiPoint']); + }); + + it('coordinates is an array whose items are point coordinates (≥2 numbers)', () => { + const coords = MultiPoint.properties.coordinates; + assert.equal(coords.type, 'array'); + assert.equal(coords.items.type, 'array'); + assert.equal(coords.items.minItems, 2); + assert.equal(coords.items.items.type, 'number'); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(MultiPoint.properties.bbox.type, 'array'); + assert.equal(MultiPoint.properties.bbox.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-multi-polygon.test.mjs b/interfaces/tests/geojson-multi-polygon.test.mjs new file mode 100644 index 0000000000..b8c13deaaf --- /dev/null +++ b/interfaces/tests/geojson-multi-polygon.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import MultiPolygon from '../dist/helpers/geojson-schema/multi-polygon.js'; + +describe('GeoJSON MultiPolygon schema', () => { + it('declares the correct title and type', () => { + assert.equal(MultiPolygon.title, 'GeoJSON MultiPolygon'); + assert.equal(MultiPolygon.type, 'object'); + }); + + it('requires type and coordinates', () => { + assert.deepEqual(MultiPolygon.required, ['type', 'coordinates']); + }); + + it('pins the type enum to ["MultiPolygon"]', () => { + assert.deepEqual(MultiPolygon.properties.type.enum, ['MultiPolygon']); + }); + + it('coordinates is an array of polygon-coordinate arrays', () => { + const coords = MultiPolygon.properties.coordinates; + assert.equal(coords.type, 'array'); + assert.equal(coords.items.type, 'array'); + assert.equal(coords.items.items.type, 'array'); + assert.equal(coords.items.items.minItems, 4); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(MultiPolygon.properties.bbox.type, 'array'); + assert.equal(MultiPolygon.properties.bbox.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-polygon.test.mjs b/interfaces/tests/geojson-polygon.test.mjs new file mode 100644 index 0000000000..8c3c326b8b --- /dev/null +++ b/interfaces/tests/geojson-polygon.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import Polygon from '../dist/helpers/geojson-schema/polygon.js'; + +describe('GeoJSON Polygon schema', () => { + it('declares the correct title and type', () => { + assert.equal(Polygon.title, 'GeoJSON Polygon'); + assert.equal(Polygon.type, 'object'); + }); + + it('requires type and coordinates', () => { + assert.deepEqual(Polygon.required, ['type', 'coordinates']); + }); + + it('pins the type enum to ["Polygon"]', () => { + assert.deepEqual(Polygon.properties.type.enum, ['Polygon']); + }); + + it('coordinates is an array of linear-ring arrays (each ≥4 positions)', () => { + const coords = Polygon.properties.coordinates; + assert.equal(coords.type, 'array'); + assert.equal(coords.items.type, 'array'); + assert.equal(coords.items.minItems, 4); + }); + + it('exposes a bbox sub-schema (array of numbers, ≥4 items)', () => { + assert.equal(Polygon.properties.bbox.type, 'array'); + assert.equal(Polygon.properties.bbox.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-ref-bounding-box.test.mjs b/interfaces/tests/geojson-ref-bounding-box.test.mjs new file mode 100644 index 0000000000..ab60a63861 --- /dev/null +++ b/interfaces/tests/geojson-ref-bounding-box.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import BoundingBox from '../dist/helpers/geojson-schema/ref/bounding-box.js'; + +describe('GeoJSON BoundingBox ref schema', () => { + it('is an array', () => { + assert.equal(BoundingBox.type, 'array'); + }); + + it('requires at least 4 numeric items (2D bbox = [minX, minY, maxX, maxY])', () => { + assert.equal(BoundingBox.minItems, 4); + assert.equal(BoundingBox.items.type, 'number'); + }); + + it('does not pin maxItems (allows 3D bbox of 6 items per RFC-7946 §5)', () => { + assert.equal(BoundingBox.maxItems, undefined); + }); +}); diff --git a/interfaces/tests/geojson-ref-line-string-coordinates.test.mjs b/interfaces/tests/geojson-ref-line-string-coordinates.test.mjs new file mode 100644 index 0000000000..08ab8eea9b --- /dev/null +++ b/interfaces/tests/geojson-ref-line-string-coordinates.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import LineStringCoordinates from '../dist/helpers/geojson-schema/ref/line-string-coordinates.js'; + +describe('GeoJSON LineStringCoordinates ref schema', () => { + it('is an array whose items are point-coordinates (positions)', () => { + assert.equal(LineStringCoordinates.type, 'array'); + assert.equal(LineStringCoordinates.items.type, 'array'); + assert.equal(LineStringCoordinates.items.items.type, 'number'); + assert.equal(LineStringCoordinates.items.minItems, 2); + }); + + it('requires at least 2 positions (RFC-7946 §3.1.4)', () => { + assert.equal(LineStringCoordinates.minItems, 2); + }); +}); diff --git a/interfaces/tests/geojson-ref-linear-ring-coordinates.test.mjs b/interfaces/tests/geojson-ref-linear-ring-coordinates.test.mjs new file mode 100644 index 0000000000..f0699afe2f --- /dev/null +++ b/interfaces/tests/geojson-ref-linear-ring-coordinates.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import LinearRingCoordinates from '../dist/helpers/geojson-schema/ref/linear-ring-coordinates.js'; + +describe('GeoJSON LinearRingCoordinates ref schema', () => { + it('is an array whose items are point-coordinates (positions)', () => { + assert.equal(LinearRingCoordinates.type, 'array'); + assert.equal(LinearRingCoordinates.items.type, 'array'); + assert.equal(LinearRingCoordinates.items.items.type, 'number'); + assert.equal(LinearRingCoordinates.items.minItems, 2); + }); + + it('requires at least 4 positions (closed ring, RFC-7946 §3.1.6)', () => { + assert.equal(LinearRingCoordinates.minItems, 4); + }); +}); diff --git a/interfaces/tests/geojson-ref-point-coordinates.test.mjs b/interfaces/tests/geojson-ref-point-coordinates.test.mjs new file mode 100644 index 0000000000..d0212f4e29 --- /dev/null +++ b/interfaces/tests/geojson-ref-point-coordinates.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import PointCoordinates from '../dist/helpers/geojson-schema/ref/point-coordinates.js'; + +describe('GeoJSON PointCoordinates ref schema', () => { + it('is an array of numbers', () => { + assert.equal(PointCoordinates.type, 'array'); + assert.equal(PointCoordinates.items.type, 'number'); + }); + + it('requires at least 2 items (longitude, latitude — altitude optional per RFC-7946 §3.1.1)', () => { + assert.equal(PointCoordinates.minItems, 2); + }); + + it('does not pin maxItems (allows the optional altitude component)', () => { + assert.equal(PointCoordinates.maxItems, undefined); + }); +}); diff --git a/interfaces/tests/geojson-ref-polygon-coordinates.test.mjs b/interfaces/tests/geojson-ref-polygon-coordinates.test.mjs new file mode 100644 index 0000000000..1c3fbc86d0 --- /dev/null +++ b/interfaces/tests/geojson-ref-polygon-coordinates.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import PolygonCoordinates from '../dist/helpers/geojson-schema/ref/polygon-coordinates.js'; + +describe('GeoJSON PolygonCoordinates ref schema', () => { + it('is an array of linear-ring arrays', () => { + assert.equal(PolygonCoordinates.type, 'array'); + assert.equal(PolygonCoordinates.items.type, 'array'); + assert.equal(PolygonCoordinates.items.minItems, 4); + }); + + it('inner ring items are position arrays (≥2 numbers)', () => { + const ring = PolygonCoordinates.items; + assert.equal(ring.items.type, 'array'); + assert.equal(ring.items.minItems, 2); + assert.equal(ring.items.items.type, 'number'); + }); +}); diff --git a/interfaces/tests/geojson-sentinel.test.mjs b/interfaces/tests/geojson-sentinel.test.mjs new file mode 100644 index 0000000000..acc5c70a7f --- /dev/null +++ b/interfaces/tests/geojson-sentinel.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import GeoJsonSchema from '../dist/helpers/geojson-schema/geo-json.js'; +import SentinelHubSchema from '../dist/helpers/sentinel-hub/sentinel-hub-schema.js'; +import Point from '../dist/helpers/geojson-schema/point.js'; + +describe('GeoJSON master schema', () => { + it('exposes the canonical $id and title', () => { + assert.equal(GeoJsonSchema.$id, '#GeoJSON'); + assert.equal(GeoJsonSchema.title, 'GeoJSON'); + }); + + it('lists all primary geometry types in oneOf', () => { + const titles = GeoJsonSchema.oneOf.map((s) => s.title); + for (const expected of [ + 'GeoJSON Point', + 'GeoJSON LineString', + 'GeoJSON Polygon', + 'GeoJSON MultiPoint', + 'GeoJSON MultiLineString', + 'GeoJSON MultiPolygon', + ]) { + assert.ok(titles.includes(expected), `missing ${expected}`); + } + }); + + it('Point sub-schema requires type + coordinates and pins type enum to ["Point"]', () => { + assert.deepEqual(Point.required, ['type', 'coordinates']); + assert.deepEqual(Point.properties.type.enum, ['Point']); + }); +}); + +describe('SentinelHUB schema', () => { + it('exposes the canonical $id and title', () => { + assert.equal(SentinelHubSchema.$id, '#SentinelHUB'); + assert.equal(SentinelHubSchema.title, '#SentinelHUB'); + }); + + it("constrains 'layers' to NATURAL-COLOR and 'format' to image/jpeg", () => { + assert.deepEqual(SentinelHubSchema.properties.layers.enum, ['NATURAL-COLOR']); + assert.deepEqual(SentinelHubSchema.properties.format.enum, ['image/jpeg']); + }); + + it('declares numeric properties for maxcc / width / height', () => { + assert.equal(SentinelHubSchema.properties.maxcc.type, 'number'); + assert.equal(SentinelHubSchema.properties.width.type, 'number'); + assert.equal(SentinelHubSchema.properties.height.type, 'number'); + }); + + it('declares string properties for time / bbox', () => { + assert.equal(SentinelHubSchema.properties.time.type, 'string'); + assert.equal(SentinelHubSchema.properties.bbox.type, 'string'); + }); +}); diff --git a/interfaces/tests/json-to-schema-conditions.test.mjs b/interfaces/tests/json-to-schema-conditions.test.mjs new file mode 100644 index 0000000000..693fcb4edd --- /dev/null +++ b/interfaces/tests/json-to-schema-conditions.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; + +const ctx = () => new ErrorContext().setPath(['schema', 'conditions']); +const fields = () => [{ name: 'a' }, { name: 'b' }]; +const fieldJson = (key) => ({ key, type: 'String', required: 'None', availableOptions: [] }); + +describe('JsonToSchema.resolveFieldByName', () => { + it('returns the matching field object', () => { + const list = fields(); + assert.equal(JsonToSchema.resolveFieldByName('b', list, ctx()), list[1]); + }); + + it('throws for an unknown field name', () => { + assert.throws(() => JsonToSchema.resolveFieldByName('zz', fields(), ctx()), /reference to an existing field/); + }); +}); + +describe('JsonToSchema.fromCondIf', () => { + it('resolves a plain field predicate', () => { + const list = fields(); + const result = JsonToSchema.fromCondIf({ if: { field: 'a', fieldValue: 'x' } }, list, ctx()); + assert.equal(result.field, list[0]); + assert.equal(result.fieldValue, 'x'); + }); + + it('throws when the plain field name does not exist', () => { + assert.throws(() => JsonToSchema.fromCondIf({ if: { field: 'zz', fieldValue: 1 } }, fields(), ctx())); + }); + + it('throws for an empty AND array', () => { + assert.throws(() => JsonToSchema.fromCondIf({ if: { AND: [] } }, fields(), ctx()), /type array/); + }); + + it('flattens a single-element AND to a plain predicate', () => { + const list = fields(); + const result = JsonToSchema.fromCondIf({ if: { AND: [{ field: 'a', fieldValue: 1 }] } }, list, ctx()); + assert.equal(result.field, list[0]); + assert.equal(result.fieldValue, 1); + assert.equal('AND' in result, false); + }); + + it('resolves every AND clause to its field object', () => { + const list = fields(); + const result = JsonToSchema.fromCondIf( + { if: { AND: [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }] } }, + list, + ctx(), + ); + assert.equal(result.AND.length, 2); + assert.equal(result.AND[0].field, list[0]); + assert.equal(result.AND[1].field, list[1]); + assert.equal(result.AND[1].fieldValue, 2); + }); + + it('throws when an AND clause references an unknown field', () => { + assert.throws(() => JsonToSchema.fromCondIf( + { if: { AND: [{ field: 'a', fieldValue: 1 }, { field: 'zz', fieldValue: 2 }] } }, + fields(), + ctx(), + )); + }); + + it('throws for an empty OR array', () => { + assert.throws(() => JsonToSchema.fromCondIf({ if: { OR: [] } }, fields(), ctx()), /type array/); + }); + + it('flattens a single-element OR to a plain predicate', () => { + const list = fields(); + const result = JsonToSchema.fromCondIf({ if: { OR: [{ field: 'b', fieldValue: 3 }] } }, list, ctx()); + assert.equal(result.field, list[1]); + assert.equal(result.fieldValue, 3); + }); + + it('resolves every OR clause to its field object', () => { + const list = fields(); + const result = JsonToSchema.fromCondIf( + { if: { OR: [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }] } }, + list, + ctx(), + ); + assert.equal(result.OR.length, 2); + assert.equal(result.OR[0].field, list[0]); + }); + + it('throws when the if node is missing entirely', () => { + assert.throws(() => JsonToSchema.fromCondIf({}, fields(), ctx())); + }); +}); + +describe('JsonToSchema.fromCondFields', () => { + it('parses then and else field arrays', () => { + const result = JsonToSchema.fromCondFields( + { then: [fieldJson('t1')], else: [fieldJson('e1')] }, + [], 'NONE', new Set(), ctx(), + ); + assert.equal(result.then.length, 1); + assert.equal(result.then[0].name, 't1'); + assert.equal(result.else[0].name, 'e1'); + }); + + it('defaults a missing else to an empty list', () => { + const result = JsonToSchema.fromCondFields({ then: [fieldJson('t1')] }, [], 'NONE', new Set(), ctx()); + assert.deepEqual(result.else, []); + }); + + it('defaults a missing then to an empty list', () => { + const result = JsonToSchema.fromCondFields({ else: [fieldJson('e1')] }, [], 'NONE', new Set(), ctx()); + assert.deepEqual(result.then, []); + }); + + it('throws when then is not an array', () => { + assert.throws(() => JsonToSchema.fromCondFields({ then: 'x' }, [], 'NONE', new Set(), ctx()), /type array/); + }); + + it('throws when else is not an array', () => { + assert.throws(() => JsonToSchema.fromCondFields({ then: [fieldJson('t1')], else: 'x' }, [], 'NONE', new Set(), ctx()), /type array/); + }); + + it('throws when both branches end up empty', () => { + assert.throws(() => JsonToSchema.fromCondFields({}, [], 'NONE', new Set(), ctx()), /at least one value/); + }); +}); + +describe('JsonToSchema.fromCondition / fromConditions', () => { + it('combines the if predicate with parsed branch fields', () => { + const list = fields(); + const condition = JsonToSchema.fromCondition( + { if: { field: 'a', fieldValue: 'x' }, then: [fieldJson('t1')] }, + 0, list, [], 'NONE', ctx(), + ); + assert.equal(condition.ifCondition.field, list[0]); + assert.equal(condition.thenFields[0].name, 't1'); + assert.deepEqual(condition.elseFields, []); + }); + + it('fromConditions returns [] for a missing value', () => { + assert.deepEqual(JsonToSchema.fromConditions(undefined, fields(), [], 'NONE', ctx()), []); + }); + + it('fromConditions throws for a non-array value', () => { + assert.throws(() => JsonToSchema.fromConditions({}, fields(), [], 'NONE', ctx()), /type array/); + }); + + it('fromConditions maps each entry through fromCondition', () => { + const list = fields(); + const conditions = JsonToSchema.fromConditions( + [ + { if: { field: 'a', fieldValue: 1 }, then: [fieldJson('t1')] }, + { if: { field: 'b', fieldValue: 2 }, else: [fieldJson('e1')] }, + ], + list, [], 'NONE', ctx(), + ); + assert.equal(conditions.length, 2); + assert.equal(conditions[0].ifCondition.field, list[0]); + assert.equal(conditions[1].elseFields[0].name, 'e1'); + }); + + it('fromJson wires conditions through to the schema object', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [fieldJson('a')], + conditions: [{ if: { field: 'a', fieldValue: 'yes' }, then: [fieldJson('extra')] }], + }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.conditions.length, 1); + assert.equal(result.conditions[0].ifCondition.field.name, 'a'); + assert.equal(result.conditions[0].thenFields[0].name, 'extra'); + }); +}); + +describe('JsonToSchema.fromFields uniqueness', () => { + it('throws when two fields share the same key', () => { + assert.throws( + () => JsonToSchema.fromFields([fieldJson('dup'), fieldJson('dup')], [], 'NONE', new Set(), ctx()), + /must be unique/, + ); + }); + + it('tracks already-used names through the shared set', () => { + const used = new Set(['taken']); + assert.throws(() => JsonToSchema.fromFields([fieldJson('taken')], [], 'NONE', used, ctx())); + }); +}); diff --git a/interfaces/tests/json-to-schema-converters-deep.test.mjs b/interfaces/tests/json-to-schema-converters-deep.test.mjs new file mode 100644 index 0000000000..d9b1662c5a --- /dev/null +++ b/interfaces/tests/json-to-schema-converters-deep.test.mjs @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; + +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; + +function ctx() { + return new ErrorContext().setPath(['schema', 'field']); +} + +describe('JsonToSchema.equalString', () => { + it('matches identical strings', () => { + assert.equal(JsonToSchema.equalString('A', 'A'), true); + }); + + it('matches case-insensitively', () => { + assert.equal(JsonToSchema.equalString('Abc', 'aBC'), true); + }); + + it('rejects different strings', () => { + assert.equal(JsonToSchema.equalString('a', 'b'), false); + }); + + it('matches identical non-strings by strict equality', () => { + assert.equal(JsonToSchema.equalString(1, 1), true); + }); + + it('rejects different non-strings', () => { + assert.equal(JsonToSchema.equalString(1, 2), false); + }); + + it('rejects a string compared with a non-string', () => { + assert.equal(JsonToSchema.equalString('1', 1), false); + }); +}); + +describe('JsonToSchema.fromString', () => { + it('returns a string value unchanged', () => { + assert.equal(JsonToSchema.fromString('x', ctx()), 'x'); + }); + + it('returns undefined for undefined', () => { + assert.equal(JsonToSchema.fromString(undefined, ctx()), undefined); + }); + + it('throws for a number', () => { + assert.throws(() => JsonToSchema.fromString(5, ctx()), /Invalid format/); + }); + + it('throws for an object', () => { + assert.throws(() => JsonToSchema.fromString({}, ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromRequiredString', () => { + it('returns a non-empty string', () => { + assert.equal(JsonToSchema.fromRequiredString('hi', ctx()), 'hi'); + }); + + it('throws for an empty string', () => { + assert.throws(() => JsonToSchema.fromRequiredString('', ctx()), /Invalid format/); + }); + + it('throws for undefined', () => { + assert.throws(() => JsonToSchema.fromRequiredString(undefined, ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromBoolean', () => { + it('accepts true and "true"', () => { + assert.equal(JsonToSchema.fromBoolean(true, ctx()), true); + assert.equal(JsonToSchema.fromBoolean('true', ctx()), true); + }); + + it('accepts false and "false"', () => { + assert.equal(JsonToSchema.fromBoolean(false, ctx()), false); + assert.equal(JsonToSchema.fromBoolean('false', ctx()), false); + }); + + it('returns undefined for undefined', () => { + assert.equal(JsonToSchema.fromBoolean(undefined, ctx()), undefined); + }); + + it('throws for a non-boolean value', () => { + assert.throws(() => JsonToSchema.fromBoolean('yes', ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromEntity', () => { + it('accepts NONE', () => { + assert.equal(JsonToSchema.fromEntity(SchemaEntity.NONE, ctx()), SchemaEntity.NONE); + }); + + it('accepts VC', () => { + assert.equal(JsonToSchema.fromEntity(SchemaEntity.VC, ctx()), SchemaEntity.VC); + }); + + it('accepts EVC', () => { + assert.equal(JsonToSchema.fromEntity(SchemaEntity.EVC, ctx()), SchemaEntity.EVC); + }); + + it('throws for an unknown entity', () => { + assert.throws(() => JsonToSchema.fromEntity('OTHER', ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromTextSize', () => { + it('accepts a numeric size within range', () => { + assert.equal(JsonToSchema.fromTextSize(20, ctx()), '20'); + }); + + it('parses a px string', () => { + assert.equal(JsonToSchema.fromTextSize('30px', ctx()), '30'); + }); + + it('returns undefined for undefined', () => { + assert.equal(JsonToSchema.fromTextSize(undefined, ctx()), undefined); + }); + + it('throws for an out-of-range number', () => { + assert.throws(() => JsonToSchema.fromTextSize(100, ctx()), /Invalid format/); + }); + + it('throws for a non-numeric string', () => { + assert.throws(() => JsonToSchema.fromTextSize('abc', ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromTextColor', () => { + it('accepts a 6-digit hex color', () => { + assert.equal(JsonToSchema.fromTextColor('#aabbcc', ctx()), '#aabbcc'); + }); + + it('accepts a 3-digit hex color', () => { + assert.equal(JsonToSchema.fromTextColor('#abc', ctx()), '#abc'); + }); + + it('returns undefined for undefined', () => { + assert.equal(JsonToSchema.fromTextColor(undefined, ctx()), undefined); + }); + + it('throws for an invalid color', () => { + assert.throws(() => JsonToSchema.fromTextColor('red', ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromArray and fromNotArray', () => { + it('fromArray returns an array unchanged', () => { + assert.deepEqual(JsonToSchema.fromArray([1, 2], ctx()), [1, 2]); + }); + + it('fromArray throws for a non-array', () => { + assert.throws(() => JsonToSchema.fromArray('x', ctx()), /Invalid format/); + }); + + it('fromNotArray returns a primitive unchanged', () => { + assert.equal(JsonToSchema.fromNotArray('x', ctx()), 'x'); + assert.equal(JsonToSchema.fromNotArray(5, ctx()), 5); + }); + + it('fromNotArray throws for an array', () => { + assert.throws(() => JsonToSchema.fromNotArray([1], ctx()), /Invalid format/); + }); + + it('fromNotArray throws for an object', () => { + assert.throws(() => JsonToSchema.fromNotArray({}, ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.fromRequired', () => { + function valueWith(required) { + return { required }; + } + + it('maps boolean true to required', () => { + const r = JsonToSchema.fromRequired(valueWith(true), ctx()); + assert.deepEqual(r, { required: true, hidden: false, autocalculate: false }); + }); + + it('maps "Required" to required', () => { + assert.equal(JsonToSchema.fromRequired(valueWith('Required'), ctx()).required, true); + }); + + it('maps "Hidden" to hidden', () => { + assert.equal(JsonToSchema.fromRequired(valueWith('Hidden'), ctx()).hidden, true); + }); + + it('maps "Auto Calculate" to autocalculate', () => { + assert.equal(JsonToSchema.fromRequired(valueWith('Auto Calculate'), ctx()).autocalculate, true); + }); + + it('maps "None" to all-false', () => { + assert.deepEqual(JsonToSchema.fromRequired(valueWith('None'), ctx()), { required: false, hidden: false, autocalculate: false }); + }); + + it('defaults to all-false for undefined', () => { + assert.deepEqual(JsonToSchema.fromRequired(valueWith(undefined), ctx()), { required: false, hidden: false, autocalculate: false }); + }); + + it('throws for an unknown required value', () => { + assert.throws(() => JsonToSchema.fromRequired(valueWith('Maybe'), ctx()), /Invalid format/); + }); +}); + +describe('JsonToSchema.getStringValue', () => { + it('serializes a short object', () => { + assert.equal(JsonToSchema.getStringValue({ a: 1 }), '{"a":1}'); + }); + + it('truncates a long string with ellipsis', () => { + const out = JsonToSchema.getStringValue('abcdefghijklmnopqrstuvwxyz'); + assert.ok(out.endsWith('...')); + assert.ok(out.length <= 23); + }); + + it('handles a number', () => { + assert.equal(JsonToSchema.getStringValue(5), '5'); + }); +}); + +describe('JsonToSchema.fromEnum', () => { + it('returns an enum array for an Enum field', () => { + const r = JsonToSchema.fromEnum({ type: 'Enum', enum: ['a', 'b'] }, ctx()); + assert.deepEqual(r.enum, ['a', 'b']); + assert.equal(r.link, undefined); + }); + + it('returns a link for a string enum value', () => { + const r = JsonToSchema.fromEnum({ type: 'Enum', enum: 'http://link' }, ctx()); + assert.equal(r.enum, undefined); + assert.equal(r.link, 'http://link'); + }); + + it('returns empties for a non-Enum field without enum', () => { + const r = JsonToSchema.fromEnum({ type: 'String' }, ctx()); + assert.deepEqual(r, { enum: undefined, link: undefined }); + }); + + it('throws when enum is present on a non-Enum field', () => { + assert.throws(() => JsonToSchema.fromEnum({ type: 'String', enum: ['x'] }, ctx()), /Invalid property type/); + }); + + it('throws for an Enum field with an invalid enum shape', () => { + assert.throws(() => JsonToSchema.fromEnum({ type: 'Enum', enum: 5 }, ctx()), /Invalid format/); + }); +}); diff --git a/interfaces/tests/json-to-schema-enum-expression.test.mjs b/interfaces/tests/json-to-schema-enum-expression.test.mjs new file mode 100644 index 0000000000..63870d3b20 --- /dev/null +++ b/interfaces/tests/json-to-schema-enum-expression.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; + +const ctx = () => new ErrorContext().setPath(['schema', 'fields']); + +describe('JsonToSchema.fromEnum', () => { + it('reads an inline enum array for Enum fields', () => { + assert.deepEqual(JsonToSchema.fromEnum({ type: 'Enum', enum: ['A', 'B'] }, ctx()), { enum: ['A', 'B'], link: undefined }); + }); + + it('treats a string enum as a remote link', () => { + assert.deepEqual(JsonToSchema.fromEnum({ type: 'Enum', enum: 'ipfs://cid' }, ctx()), { enum: undefined, link: 'ipfs://cid' }); + }); + + it('rejects empty enum entries', () => { + assert.throws(() => JsonToSchema.fromEnum({ type: 'Enum', enum: ['A', ''] }, ctx()), /Value of type string is required/); + }); + + it('rejects a missing or non-enum value on Enum fields', () => { + assert.throws(() => JsonToSchema.fromEnum({ type: 'Enum' }, ctx()), /Value of type enum or a reference to enum is required/); + assert.throws(() => JsonToSchema.fromEnum({ type: 'Enum', enum: 5 }, ctx())); + }); + + it('rejects enum values on non-Enum fields', () => { + assert.throws(() => JsonToSchema.fromEnum({ type: 'String', enum: ['A'] }, ctx()), /Invalid property type/); + }); + + it('returns empty enum and link for plain fields', () => { + assert.deepEqual(JsonToSchema.fromEnum({ type: 'String' }, ctx()), { enum: undefined, link: undefined }); + }); +}); + +describe('JsonToSchema.fromAvailableOptions', () => { + it('copies a valid options array', () => { + assert.deepEqual(JsonToSchema.fromAvailableOptions({ availableOptions: ['Point', 'Polygon'] }, ctx()), { availableOptions: ['Point', 'Polygon'] }); + }); + + it('rejects empty option strings', () => { + assert.throws(() => JsonToSchema.fromAvailableOptions({ availableOptions: [''] }, ctx())); + }); + + it('returns undefined when no options are provided', () => { + assert.equal(JsonToSchema.fromAvailableOptions({}, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromExpression', () => { + it('requires an expression for Auto Calculate fields', () => { + assert.equal(JsonToSchema.fromExpression({ required: 'Auto Calculate', expression: 'a+b' }, ctx()), 'a+b'); + assert.throws(() => JsonToSchema.fromExpression({ required: 'Auto Calculate' }, ctx())); + }); + + it('rejects expressions on non-calculated fields', () => { + assert.throws(() => JsonToSchema.fromExpression({ required: 'Required', expression: 'a+b' }, ctx()), /Invalid property type/); + }); + + it('returns undefined when no expression is supplied', () => { + assert.equal(JsonToSchema.fromExpression({ required: 'Required' }, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromExamples', () => { + it('wraps a scalar example in a single-element array', () => { + assert.deepEqual(JsonToSchema.fromExamples({ example: 'e' }, ctx()), { examples: ['e'], suggest: undefined, default: undefined }); + }); + + it('keeps an array example as a nested array for isArray fields', () => { + assert.deepEqual(JsonToSchema.fromExamples({ example: ['e1', 'e2'], isArray: true }, ctx()), { examples: [['e1', 'e2']], suggest: undefined, default: undefined }); + }); + + it('rejects an array example on a non-array field', () => { + assert.throws(() => JsonToSchema.fromExamples({ example: ['e'] }, ctx()), /non-array/); + }); + + it('rejects a scalar example on an array field', () => { + assert.throws(() => JsonToSchema.fromExamples({ example: 'e', isArray: true }, ctx()), /Value of type array is required/); + }); + + it('reads suggest and default with the same array rules', () => { + assert.deepEqual(JsonToSchema.fromExamples({ suggest: 's', default: 'd' }, ctx()), { examples: undefined, suggest: 's', default: 'd' }); + assert.deepEqual(JsonToSchema.fromExamples({ suggest: ['s'], default: ['d'], isArray: true }, ctx()), { examples: undefined, suggest: ['s'], default: ['d'] }); + }); + + it('returns all-undefined when nothing is provided', () => { + assert.deepEqual(JsonToSchema.fromExamples({}, ctx()), { examples: undefined, suggest: undefined, default: undefined }); + }); +}); + +describe('JsonToSchema.createError message substitution', () => { + it('substitutes property and entity into the template', () => { + const context = new ErrorContext().setPath(['schema', 'fields', '[0]', 'key']); + try { + JsonToSchema.fromRequiredString(undefined, context); + assert.fail('expected to throw'); + } catch (error) { + assert.ok(error.message.includes('schema.fields[0]')); + assert.ok(error.message.includes('"key"')); + assert.ok(error.message.includes('Value of type string is required.')); + } + }); + + it('embeds a truncated JSON value into the message', () => { + try { + JsonToSchema.fromString({ very: 'long value that exceeds twenty chars' }, new ErrorContext().setPath(['schema', 'name'])); + assert.fail('expected to throw'); + } catch (error) { + assert.ok(error.message.includes('...')); + } + }); +}); diff --git a/interfaces/tests/json-to-schema-scalars.test.mjs b/interfaces/tests/json-to-schema-scalars.test.mjs new file mode 100644 index 0000000000..9e7b901549 --- /dev/null +++ b/interfaces/tests/json-to-schema-scalars.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; + +const ctx = () => new ErrorContext().setPath(['schema']); + +describe('JsonToSchema.equalString', () => { + it('returns true for identical values', () => { + assert.equal(JsonToSchema.equalString('abc', 'abc'), true); + assert.equal(JsonToSchema.equalString(5, 5), true); + }); + + it('compares strings case-insensitively', () => { + assert.equal(JsonToSchema.equalString('Prefix', 'prefix'), true); + assert.equal(JsonToSchema.equalString('STRING', 'string'), true); + }); + + it('returns false for different strings', () => { + assert.equal(JsonToSchema.equalString('abc', 'abd'), false); + }); + + it('returns false when either side is not a string', () => { + assert.equal(JsonToSchema.equalString(5, '5'), false); + assert.equal(JsonToSchema.equalString(undefined, 'x'), false); + }); +}); + +describe('JsonToSchema.fromString', () => { + it('passes strings through and keeps undefined', () => { + assert.equal(JsonToSchema.fromString('hello', ctx()), 'hello'); + assert.equal(JsonToSchema.fromString('', ctx()), ''); + assert.equal(JsonToSchema.fromString(undefined, ctx()), undefined); + }); + + it('throws on non-string values', () => { + assert.throws(() => JsonToSchema.fromString(5, ctx()), /Value of type string is required/); + assert.throws(() => JsonToSchema.fromString(null, ctx()), /Value of type string is required/); + }); +}); + +describe('JsonToSchema.fromRequiredString', () => { + it('accepts a non-empty string', () => { + assert.equal(JsonToSchema.fromRequiredString('x', ctx()), 'x'); + }); + + it('rejects empty strings and undefined', () => { + assert.throws(() => JsonToSchema.fromRequiredString('', ctx())); + assert.throws(() => JsonToSchema.fromRequiredString(undefined, ctx())); + }); +}); + +describe('JsonToSchema.fromBoolean', () => { + it('accepts boolean literals', () => { + assert.equal(JsonToSchema.fromBoolean(true, ctx()), true); + assert.equal(JsonToSchema.fromBoolean(false, ctx()), false); + }); + + it('accepts the strings "true" and "false"', () => { + assert.equal(JsonToSchema.fromBoolean('true', ctx()), true); + assert.equal(JsonToSchema.fromBoolean('false', ctx()), false); + }); + + it('returns undefined for undefined', () => { + assert.equal(JsonToSchema.fromBoolean(undefined, ctx()), undefined); + }); + + it('throws for any other value', () => { + assert.throws(() => JsonToSchema.fromBoolean(1, ctx()), /Value of type boolean is required/); + assert.throws(() => JsonToSchema.fromBoolean('yes', ctx()), /Value of type boolean is required/); + }); +}); + +describe('JsonToSchema.fromEntity', () => { + it('accepts NONE, VC and EVC', () => { + assert.equal(JsonToSchema.fromEntity(SchemaEntity.NONE, ctx()), SchemaEntity.NONE); + assert.equal(JsonToSchema.fromEntity(SchemaEntity.VC, ctx()), SchemaEntity.VC); + assert.equal(JsonToSchema.fromEntity(SchemaEntity.EVC, ctx()), SchemaEntity.EVC); + }); + + it('rejects other entities', () => { + assert.throws(() => JsonToSchema.fromEntity('USER', ctx()), /must be one of \[NONE, VC, EVC\]/); + assert.throws(() => JsonToSchema.fromEntity(undefined, ctx())); + }); +}); + +describe('JsonToSchema.fromArray / fromNotArray', () => { + it('fromArray passes arrays through', () => { + assert.deepEqual(JsonToSchema.fromArray([1, 2], ctx()), [1, 2]); + assert.deepEqual(JsonToSchema.fromArray([], ctx()), []); + }); + + it('fromArray rejects non-arrays', () => { + assert.throws(() => JsonToSchema.fromArray('x', ctx()), /Value of type array is required/); + assert.throws(() => JsonToSchema.fromArray({}, ctx())); + }); + + it('fromNotArray passes scalars through', () => { + assert.equal(JsonToSchema.fromNotArray('x', ctx()), 'x'); + assert.equal(JsonToSchema.fromNotArray(5, ctx()), 5); + assert.equal(JsonToSchema.fromNotArray(true, ctx()), true); + }); + + it('fromNotArray rejects arrays with a dedicated message', () => { + assert.throws(() => JsonToSchema.fromNotArray([1], ctx()), /Value of type non-array is required/); + }); + + it('fromNotArray rejects plain objects with a dedicated message', () => { + assert.throws(() => JsonToSchema.fromNotArray({ a: 1 }, ctx()), /Value of type non-object is required/); + }); +}); + +describe('JsonToSchema.getStringValue', () => { + it('stringifies short values verbatim', () => { + assert.equal(JsonToSchema.getStringValue(5), '5'); + assert.equal(JsonToSchema.getStringValue('ab'), '"ab"'); + }); + + it('truncates long values with an ellipsis', () => { + const long = JsonToSchema.getStringValue('a'.repeat(50)); + assert.equal(long.length, 23); + assert.ok(long.endsWith('...')); + }); +}); diff --git a/interfaces/tests/json-to-schema-style-statics.test.mjs b/interfaces/tests/json-to-schema-style-statics.test.mjs new file mode 100644 index 0000000000..4cbc11c17d --- /dev/null +++ b/interfaces/tests/json-to-schema-style-statics.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; + +const ctx = () => new ErrorContext().setPath(['schema', 'fields']); + +describe('JsonToSchema.fromTextSize', () => { + it('accepts numbers in the open interval (0, 70)', () => { + assert.equal(JsonToSchema.fromTextSize(18, ctx()), '18'); + assert.equal(JsonToSchema.fromTextSize(69, ctx()), '69'); + }); + + it('accepts numeric strings, optionally with a px suffix', () => { + assert.equal(JsonToSchema.fromTextSize('25', ctx()), '25'); + assert.equal(JsonToSchema.fromTextSize('25px', ctx()), '25'); + }); + + it('rejects out-of-range sizes', () => { + assert.throws(() => JsonToSchema.fromTextSize(70, ctx()), /between 0 and 70/); + assert.throws(() => JsonToSchema.fromTextSize(-5, ctx())); + assert.throws(() => JsonToSchema.fromTextSize('99px', ctx())); + }); + + it('returns undefined for falsy input', () => { + assert.equal(JsonToSchema.fromTextSize(undefined, ctx()), undefined); + assert.equal(JsonToSchema.fromTextSize(0, ctx()), undefined); + assert.equal(JsonToSchema.fromTextSize('', ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromTextColor', () => { + it('accepts 3- and 6-digit hex colors', () => { + assert.equal(JsonToSchema.fromTextColor('#fff', ctx()), '#fff'); + assert.equal(JsonToSchema.fromTextColor('#FF00aa', ctx()), '#FF00aa'); + }); + + it('rejects malformed colors', () => { + assert.throws(() => JsonToSchema.fromTextColor('red', ctx()), /Rgb color definition/); + assert.throws(() => JsonToSchema.fromTextColor('#12345', ctx())); + assert.throws(() => JsonToSchema.fromTextColor(255, ctx())); + }); + + it('returns undefined for falsy input', () => { + assert.equal(JsonToSchema.fromTextColor(undefined, ctx()), undefined); + assert.equal(JsonToSchema.fromTextColor('', ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromFont', () => { + it('builds a full font object for Help Text fields', () => { + const font = JsonToSchema.fromFont({ type: 'Help Text', textSize: 20, textColor: '#ff0000', textBold: true }, ctx()); + assert.deepEqual(font, { size: '20', color: '#ff0000', bold: true }); + }); + + it('applies defaults for missing Help Text font properties', () => { + const font = JsonToSchema.fromFont({ type: 'Help Text' }, ctx()); + assert.deepEqual(font, { size: '18', color: '#000000', bold: false }); + }); + + it('throws when font properties are set on a non Help Text field', () => { + assert.throws(() => JsonToSchema.fromFont({ type: 'String', textSize: 20 }, ctx()), /Invalid property type/); + assert.throws(() => JsonToSchema.fromFont({ type: 'String', textColor: '#fff' }, ctx())); + assert.throws(() => JsonToSchema.fromFont({ type: 'String', textBold: true }, ctx())); + }); + + it('returns an empty font object for plain fields', () => { + assert.deepEqual(JsonToSchema.fromFont({ type: 'String' }, ctx()), { size: undefined, color: undefined, bold: undefined }); + }); +}); + +describe('JsonToSchema.fromRequired', () => { + it('maps boolean and boolean-string values', () => { + assert.deepEqual(JsonToSchema.fromRequired({ required: true }, ctx()), { required: true, hidden: false, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: 'true' }, ctx()), { required: true, hidden: false, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: false }, ctx()), { required: false, hidden: false, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: 'false' }, ctx()), { required: false, hidden: false, autocalculate: false }); + }); + + it('maps the documented enum values case-insensitively', () => { + assert.deepEqual(JsonToSchema.fromRequired({ required: 'none' }, ctx()), { required: false, hidden: false, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: 'REQUIRED' }, ctx()), { required: true, hidden: false, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: 'hidden' }, ctx()), { required: false, hidden: true, autocalculate: false }); + assert.deepEqual(JsonToSchema.fromRequired({ required: 'auto calculate' }, ctx()), { required: false, hidden: false, autocalculate: true }); + }); + + it('throws for unrecognised non-empty values', () => { + assert.throws(() => JsonToSchema.fromRequired({ required: 'maybe' }, ctx()), /must be one of \[None, Required, Hidden, Auto Calculate\]/); + }); + + it('defaults to all-false when required is missing', () => { + assert.deepEqual(JsonToSchema.fromRequired({}, ctx()), { required: false, hidden: false, autocalculate: false }); + }); +}); + +describe('JsonToSchema.fromIsPrivate', () => { + it('reads the private flag for EVC entities', () => { + assert.equal(JsonToSchema.fromIsPrivate({ private: true }, SchemaEntity.EVC, ctx()), true); + assert.equal(JsonToSchema.fromIsPrivate({ private: 'false' }, SchemaEntity.EVC, ctx()), false); + assert.equal(JsonToSchema.fromIsPrivate({}, SchemaEntity.EVC, ctx()), undefined); + }); + + it('rejects the private flag for non-EVC entities', () => { + assert.throws(() => JsonToSchema.fromIsPrivate({ private: true }, SchemaEntity.VC, ctx()), /Invalid property type/); + assert.throws(() => JsonToSchema.fromIsPrivate({ private: false }, SchemaEntity.NONE, ctx())); + }); + + it('returns undefined when the flag is absent for non-EVC entities', () => { + assert.equal(JsonToSchema.fromIsPrivate({}, SchemaEntity.VC, ctx()), undefined); + }); +}); diff --git a/interfaces/tests/json-to-schema-type-statics.test.mjs b/interfaces/tests/json-to-schema-type-statics.test.mjs new file mode 100644 index 0000000000..be0120db45 --- /dev/null +++ b/interfaces/tests/json-to-schema-type-statics.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; +import { UnitSystem } from '../dist/type/unit-system.type.js'; + +const ctx = () => new ErrorContext().setPath(['schema', 'fields']); + +describe('JsonToSchema.fromType', () => { + it('maps primitive dictionary names to json-schema types', () => { + assert.equal(JsonToSchema.fromType({ type: 'Number' }, [], ctx()), 'number'); + assert.equal(JsonToSchema.fromType({ type: 'Integer' }, [], ctx()), 'integer'); + assert.equal(JsonToSchema.fromType({ type: 'Boolean' }, [], ctx()), 'boolean'); + assert.equal(JsonToSchema.fromType({ type: 'String' }, [], ctx()), 'string'); + }); + + it('matches names case-insensitively', () => { + assert.equal(JsonToSchema.fromType({ type: 'number' }, [], ctx()), 'number'); + assert.equal(JsonToSchema.fromType({ type: 'DATE' }, [], ctx()), 'string'); + }); + + it('maps custom field types', () => { + assert.equal(JsonToSchema.fromType({ type: 'Prefix' }, [], ctx()), 'number'); + assert.equal(JsonToSchema.fromType({ type: 'Postfix' }, [], ctx()), 'number'); + assert.equal(JsonToSchema.fromType({ type: 'hederaAccount' }, [], ctx()), 'string'); + }); + + it('maps system field types to their ref types', () => { + assert.equal(JsonToSchema.fromType({ type: 'GeoJSON' }, [], ctx()), '#GeoJSON'); + assert.equal(JsonToSchema.fromType({ type: 'SentinelHUB' }, [], ctx()), '#SentinelHUB'); + }); + + it('resolves a sub-schema iri from the provided list', () => { + const all = [{ iri: '#Sub&1.0.0' }]; + assert.equal(JsonToSchema.fromType({ type: '#Sub&1.0.0' }, all, ctx()), '#Sub&1.0.0'); + }); + + it('throws for an unknown type', () => { + assert.throws(() => JsonToSchema.fromType({ type: '#Missing' }, [], ctx()), /primitive type or a sub-schema reference/); + }); +}); + +describe('JsonToSchema.fromFormat', () => { + it('returns the dictionary format for formatted string types', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'Date' }, ctx()), 'date'); + assert.equal(JsonToSchema.fromFormat({ type: 'Time' }, ctx()), 'time'); + assert.equal(JsonToSchema.fromFormat({ type: 'DateTime' }, ctx()), 'date-time'); + assert.equal(JsonToSchema.fromFormat({ type: 'Duration' }, ctx()), 'duration'); + assert.equal(JsonToSchema.fromFormat({ type: 'URL' }, ctx()), 'url'); + assert.equal(JsonToSchema.fromFormat({ type: 'URI' }, ctx()), 'uri'); + assert.equal(JsonToSchema.fromFormat({ type: 'Email' }, ctx()), 'email'); + }); + + it('returns undefined for format-less types', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'Number' }, ctx()), undefined); + assert.equal(JsonToSchema.fromFormat({ type: 'Unknown' }, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromPattern', () => { + it('uses the provided pattern for String fields', () => { + assert.equal(JsonToSchema.fromPattern({ type: 'String', pattern: '^a$' }, ctx()), '^a$'); + assert.equal(JsonToSchema.fromPattern({ type: 'String' }, ctx()), undefined); + }); + + it('uses the dictionary pattern for Image and hederaAccount', () => { + assert.equal(JsonToSchema.fromPattern({ type: 'Image' }, ctx()), '^ipfs:\/\/.+'); + assert.equal(JsonToSchema.fromPattern({ type: 'hederaAccount' }, ctx()), '^\\d+\\.\\d+\\.\\d+$'); + }); + + it('ignores a pattern on a dictionary type that has none', () => { + assert.equal(JsonToSchema.fromPattern({ type: 'Number', pattern: '^1$' }, ctx()), undefined); + }); + + it('throws when a pattern is supplied for an unknown type', () => { + assert.throws(() => JsonToSchema.fromPattern({ type: '#Sub', pattern: '^1$' }, ctx()), /Invalid property type/); + }); + + it('returns undefined when an unknown type has no pattern', () => { + assert.equal(JsonToSchema.fromPattern({ type: '#Sub' }, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromIsRef', () => { + it('is false for dictionary and custom types', () => { + assert.equal(JsonToSchema.fromIsRef({ type: 'Number' }, [], ctx()), false); + assert.equal(JsonToSchema.fromIsRef({ type: 'Prefix' }, [], ctx()), false); + assert.equal(JsonToSchema.fromIsRef({ type: 'String' }, [], ctx()), false); + }); + + it('is true for sub-schema iris', () => { + assert.equal(JsonToSchema.fromIsRef({ type: '#Sub' }, [{ iri: '#Sub' }], ctx()), true); + }); + + it('treats GeoJSON as a form field type rather than a system ref', () => { + assert.equal(JsonToSchema.fromIsRef({ type: 'GeoJSON' }, [], ctx()), false); + }); + + it('is false for an unresolvable type', () => { + assert.equal(JsonToSchema.fromIsRef({ type: '#Sub' }, [], ctx()), false); + }); +}); + +describe('JsonToSchema.fromUnit / fromUnitType', () => { + it('returns the unit string for Prefix and Postfix types', () => { + assert.equal(JsonToSchema.fromUnit({ type: 'Prefix', unit: '$' }, ctx()), '$'); + assert.equal(JsonToSchema.fromUnit({ type: 'Postfix', unit: 'kg' }, ctx()), 'kg'); + }); + + it('returns undefined unit for other types', () => { + assert.equal(JsonToSchema.fromUnit({ type: 'Number', unit: '$' }, ctx()), undefined); + }); + + it('derives the unit system from the type name', () => { + assert.equal(JsonToSchema.fromUnitType({ type: 'Prefix' }, ctx()), UnitSystem.Prefix); + assert.equal(JsonToSchema.fromUnitType({ type: 'Postfix' }, ctx()), UnitSystem.Postfix); + assert.equal(JsonToSchema.fromUnitType({ type: 'Number' }, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromCustomType', () => { + it('returns the dictionary customType when defined', () => { + assert.equal(JsonToSchema.fromCustomType({ type: 'Enum' }, ctx()), 'enum'); + assert.equal(JsonToSchema.fromCustomType({ type: 'File' }, ctx()), 'file'); + assert.equal(JsonToSchema.fromCustomType({ type: 'Table' }, ctx()), 'table'); + assert.equal(JsonToSchema.fromCustomType({ type: 'hederaAccount' }, ctx()), 'hederaAccount'); + }); + + it('returns undefined for plain types', () => { + assert.equal(JsonToSchema.fromCustomType({ type: 'Number' }, ctx()), undefined); + assert.equal(JsonToSchema.fromCustomType({ type: '#Sub' }, ctx()), undefined); + }); +}); + +describe('JsonToSchema.fromSubFields', () => { + it('returns [] for dictionary and custom types', () => { + assert.deepEqual(JsonToSchema.fromSubFields({ type: 'Number' }, [], ctx()), []); + assert.deepEqual(JsonToSchema.fromSubFields({ type: 'Prefix' }, [], ctx()), []); + }); + + it('deep-copies fields from a matching sub-schema', () => { + const all = [{ iri: '#Sub', fields: [{ name: 'a', fields: [] }] }]; + const result = JsonToSchema.fromSubFields({ type: '#Sub' }, all, ctx()); + assert.deepEqual(result, [{ name: 'a', fields: [] }]); + assert.notEqual(result, all[0].fields); + assert.notEqual(result[0], all[0].fields[0]); + }); + + it('returns [] for an unknown type', () => { + assert.deepEqual(JsonToSchema.fromSubFields({ type: '#Missing' }, [], ctx()), []); + }); +}); diff --git a/interfaces/tests/json-to-schema.test.mjs b/interfaces/tests/json-to-schema.test.mjs new file mode 100644 index 0000000000..8761999dd8 --- /dev/null +++ b/interfaces/tests/json-to-schema.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { JsonToSchema } from '../dist/helpers/schema-json.js'; + +describe('JsonToSchema.fromJson — happy path', () => { + it('builds a schema with name/description/entity from JSON', () => { + const json = { + name: 'Hello', + description: 'desc', + entity: 'NONE', + fields: [], + conditions: [], + }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.name, 'Hello'); + assert.equal(result.description, 'desc'); + assert.equal(result.entity, 'NONE'); + }); + + it('parses a single String field', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [ + { key: 'note', title: 'Note', description: 'A note', required: 'None', type: 'String', availableOptions: [] }, + ], + conditions: [], + }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.fields.length, 1); + const field = result.fields[0]; + assert.equal(field.name, 'note'); + assert.equal(field.type, 'string'); + assert.equal(field.required, false); + assert.equal(field.hidden, false); + }); + + it('parses required="Required" as required=true', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [{ key: 'x', required: 'Required', type: 'String', availableOptions: [] }], + conditions: [], + }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.fields[0].required, true); + assert.equal(result.fields[0].hidden, false); + assert.equal(result.fields[0].autocalculate, false); + }); + + it('parses required="Hidden" as hidden=true', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [{ key: 'x', required: 'Hidden', type: 'String', availableOptions: [] }], + conditions: [], + }; + assert.equal(JsonToSchema.fromJson(json, []).fields[0].hidden, true); + }); + + it('parses required="Auto Calculate" as autocalculate=true with expression', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [ + { key: 'x', required: 'Auto Calculate', type: 'String', expression: 'a+b', availableOptions: [] }, + ], + conditions: [], + }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.fields[0].autocalculate, true); + assert.equal(result.fields[0].expression, 'a+b'); + }); + + it('parses Number / Integer / Boolean primitives', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [ + { key: 'a', type: 'Number', availableOptions: [] }, + { key: 'b', type: 'Integer', availableOptions: [] }, + { key: 'c', type: 'Boolean', availableOptions: [] }, + ], + conditions: [], + }; + const result = JsonToSchema.fromJson(json, []); + const types = result.fields.map((f) => f.type); + assert.deepEqual(types, ['number', 'integer', 'boolean']); + }); + + it("emits VC's default fields when entity=VC", () => { + const json = { name: 'S', entity: 'VC', fields: [], conditions: [] }; + const result = JsonToSchema.fromJson(json, []); + const names = result.fields.map((f) => f.name); + assert.ok(names.includes('policyId')); + assert.ok(names.includes('ref')); + assert.ok(names.includes('guardianVersion')); + }); + + it('omits default fields for entity=NONE', () => { + const json = { name: 'S', entity: 'NONE', fields: [], conditions: [] }; + const result = JsonToSchema.fromJson(json, []); + assert.equal(result.fields.length, 0); + }); +}); + +describe('JsonToSchema.fromJson — error paths', () => { + it('throws when name is missing', () => { + assert.throws( + () => JsonToSchema.fromJson({ entity: 'NONE', fields: [], conditions: [] }, []), + /Invalid format/, + ); + }); + + it('throws when fields is not an array', () => { + assert.throws( + () => JsonToSchema.fromJson({ name: 'S', entity: 'NONE', fields: 'oops', conditions: [] }, []), + /Invalid format/, + ); + }); + + it('throws on unknown entity value', () => { + assert.throws( + () => JsonToSchema.fromJson({ name: 'S', entity: 'MAYBE', fields: [], conditions: [] }, []), + /Invalid format/, + ); + }); + + it('throws on unknown field type', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [{ key: 'x', type: 'NotAType' }], + conditions: [], + }; + assert.throws(() => JsonToSchema.fromJson(json, []), /Invalid format/); + }); + + it('throws when private flag is supplied for non-EVC entities', () => { + const json = { + name: 'S', + entity: 'NONE', + fields: [{ key: 'x', type: 'String', private: true }], + conditions: [], + }; + assert.throws(() => JsonToSchema.fromJson(json, []), /property type/); + }); +}); diff --git a/interfaces/tests/label-item-steps-extra.test.mjs b/interfaces/tests/label-item-steps-extra.test.mjs new file mode 100644 index 0000000000..5e95faef38 --- /dev/null +++ b/interfaces/tests/label-item-steps-extra.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { RuleItemValidator } from '../dist/validators/label-validator/item-rule-validator.js'; +import { StatisticItemValidator } from '../dist/validators/label-validator/item-statistic-validator.js'; +import { GroupItemValidator } from '../dist/validators/label-validator/item-group-validator.js'; +import { LabelItemValidator } from '../dist/validators/label-validator/item-label-validator.js'; +import { ValidateNamespace } from '../dist/validators/label-validator/namespace.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +const engine = (map) => + FormulaEngine.setMathEngine({ evaluate: (expr) => (expr in map ? map[expr] : expr) }); + +const ns = (amount) => + new ValidateNamespace('root', [{ schema: 's#1', document: { credentialSubject: { amount } } }]); + +const config = () => ({ + variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }], + scores: [{ id: 'sc1', type: 't', description: 'd', relationships: ['v1'], options: [{ description: 'Low', value: 1 }] }], + formulas: [{ id: 'f1', type: 'number', formula: 'fx', rule: { type: 'formula', formula: 'COND' } }], +}); + +const makeRule = () => + new RuleItemValidator({ id: 'r1', name: 'Rule', title: 'Rule', tag: 'R1', schemaId: 's#1', config: config() }); + +const makeStat = () => + new StatisticItemValidator({ id: 's1', name: 'Stat', title: 'Stat', tag: 'S1', schemaId: 's#1', config: config() }); + +describe('RuleItemValidator step updates', () => { + it('updateScores writes current variable values into the scope', () => { + engine({}); + const v = makeRule(); + v.setData(ns(5)); + v.variables[0].setValue(7); + v.updateScores(); + assert.equal(v.getScope().getScore().v1, 7); + }); + + it('updateFormulas computes formula values and stores them', () => { + engine({ fx: 3 }); + const v = makeRule(); + v.setData(ns(5)); + v.updateFormulas(); + assert.equal(v.formulas[0].getValue(), 3); + assert.equal(v.getScope().getScore().f1, 3); + }); + + it('updateFormulas exposes score values to the scope', () => { + engine({ fx: 3 }); + const v = makeRule(); + v.setData(ns(5)); + v.scores[0].setValue('Low'); + v.updateFormulas(); + assert.equal(v.getScope().getScore().sc1, 1); + }); + + it('getNamespace returns the bound namespace', () => { + engine({}); + const v = makeRule(); + const namespace = ns(5); + v.setData(namespace); + assert.equal(v.getNamespace(), namespace); + }); +}); + +describe('RuleItemValidator.validateScores', () => { + it('passes when every score holds a valid option value', () => { + engine({}); + const v = makeRule(); + v.setData(ns(5)); + v.scores[0].setValue('Low'); + assert.equal(v.validateScores().valid, true); + }); + + it('fails with "Invalid scores" when a score value is unset', () => { + engine({}); + const v = makeRule(); + v.setData(ns(5)); + const r = v.validateScores(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid scores'); + }); + + it('short-circuits when an Invalid document status is present', () => { + engine({}); + const v = makeRule(); + v.setData(ns(5)); + v.setResult(null); + assert.equal(v.validateScores().error, 'Invalid document'); + }); +}); + +describe('RuleItemValidator.validateFormulas', () => { + it('passes when stored formula values match recomputed ones', () => { + engine({ fx: 3 }); + const v = makeRule(); + v.setData(ns(5)); + v.updateFormulas(); + assert.equal(v.validateFormulas().valid, true); + }); + + it('fails with "Invalid formula" when stored values are stale', () => { + engine({ fx: 3 }); + const v = makeRule(); + v.setData(ns(5)); + const r = v.validateFormulas(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid formula'); + }); + + it('short-circuits when an Invalid document status is present', () => { + engine({ fx: 3 }); + const v = makeRule(); + v.setData(ns(5)); + v.setResult(null); + assert.equal(v.validateFormulas().error, 'Invalid document'); + }); +}); + +describe('StatisticItemValidator step updates', () => { + it('updateScores writes current variable values into the scope', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + v.variables[0].setValue(8); + v.updateScores(); + assert.equal(v.getScope().getScore().v1, 8); + }); + + it('updateFormulas computes and stores formula values', () => { + engine({ fx: 4 }); + const v = makeStat(); + v.setData(ns(5)); + v.updateFormulas(); + assert.equal(v.formulas[0].getValue(), 4); + assert.equal(v.getScope().getScore().f1, 4); + }); + + it('getNamespace returns the bound namespace', () => { + engine({}); + const v = makeStat(); + const namespace = ns(5); + v.setData(namespace); + assert.equal(v.getNamespace(), namespace); + }); +}); + +describe('StatisticItemValidator validations', () => { + it('validateScores passes for valid option values', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + v.scores[0].setValue('Low'); + assert.equal(v.validateScores().valid, true); + }); + + it('validateScores fails with "Invalid scores" otherwise', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + const r = v.validateScores(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid scores'); + }); + + it('validateFormulas passes after updateFormulas', () => { + engine({ fx: 4 }); + const v = makeStat(); + v.setData(ns(5)); + v.updateFormulas(); + assert.equal(v.validateFormulas().valid, true); + }); + + it('validateFormulas fails with "Invalid formula" on stale values', () => { + engine({ fx: 4 }); + const v = makeStat(); + v.setData(ns(5)); + const r = v.validateFormulas(); + assert.equal(r.error, 'Invalid formula'); + }); + + it('validateVariables returns a prior failed result unchanged', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + const failed = v.validateScores(); + const r = v.validateVariables(); + assert.equal(r, failed); + assert.equal(r.error, 'Invalid scores'); + }); + + it('validateScores returns a prior failed result unchanged', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + const failed = v.validateVariables(); + assert.equal(failed.error, 'Invalid variable'); + assert.equal(v.validateScores(), failed); + }); + + it('validateFormulas returns a prior failed result unchanged', () => { + engine({}); + const v = makeStat(); + v.setData(ns(5)); + const failed = v.validateVariables(); + assert.equal(v.validateFormulas(), failed); + }); +}); + +describe('Group and Label namespace accessors', () => { + it('GroupItemValidator.getNamespace returns the bound namespace', () => { + const g = new GroupItemValidator({ id: 'g1', children: [] }); + const namespace = ns(1); + g.setData(namespace); + assert.equal(g.getNamespace(), namespace); + assert.ok(g.getScope()); + }); + + it('LabelItemValidator.getNamespace returns the bound namespace', () => { + const l = new LabelItemValidator({ id: 'l1', name: 'L', config: { children: [] } }); + const namespace = ns(1); + l.setData(namespace); + assert.equal(l.getNamespace(), namespace); + assert.ok(l.getScope()); + }); +}); diff --git a/interfaces/tests/label-item-validators-suite.test.mjs b/interfaces/tests/label-item-validators-suite.test.mjs new file mode 100644 index 0000000000..a979aefc46 --- /dev/null +++ b/interfaces/tests/label-item-validators-suite.test.mjs @@ -0,0 +1,332 @@ +import assert from 'node:assert/strict'; +import { NodeItemValidator } from '../dist/validators/label-validator/item-node-validator.js'; +import { GroupItemValidator } from '../dist/validators/label-validator/item-group-validator.js'; +import { LabelItemValidator } from '../dist/validators/label-validator/item-label-validator.js'; +import { RuleItemValidator } from '../dist/validators/label-validator/item-rule-validator.js'; +import { StatisticItemValidator } from '../dist/validators/label-validator/item-statistic-validator.js'; +import { ValidateNamespace } from '../dist/validators/label-validator/namespace.js'; +import { ValidateScore } from '../dist/validators/label-validator/score.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +const ns = () => new ValidateNamespace('root', []); + +describe('NodeItemValidator', () => { + const item = { id: 'n1', name: 'Node', title: 'Title', tag: 'TAG' }; + + it('maps id/name/title/tag and exposes default flags', () => { + const v = new NodeItemValidator(item); + assert.equal(v.id, 'n1'); + assert.equal(v.name, 'Node'); + assert.equal(v.title, 'Title'); + assert.equal(v.tag, 'TAG'); + assert.equal(v.type, null); + assert.equal(v.steps, 0); + assert.equal(v.isRoot, false); + }); + + it('status is undefined before validation', () => { + assert.equal(new NodeItemValidator(item).status, undefined); + }); + + it('validate marks the node as unidentified and invalid', () => { + const v = new NodeItemValidator(item); + const result = v.validate(); + assert.equal(result.valid, false); + assert.equal(result.error, 'Unidentified item'); + assert.equal(result.id, 'n1'); + assert.equal(v.status, false); + }); + + it('getStatus returns the last validation result', () => { + const v = new NodeItemValidator(item); + v.validate(); + assert.deepEqual(v.getStatus(), { id: 'n1', valid: false, error: 'Unidentified item' }); + }); + + it('clear resets the validation status', () => { + const v = new NodeItemValidator(item); + v.validate(); + v.clear(); + assert.equal(v.status, undefined); + }); + + it('getSteps yields a single auto validate step bound to the item', () => { + const v = new NodeItemValidator(item); + const steps = v.getSteps(); + assert.equal(steps.length, 1); + assert.equal(steps[0].item, v); + assert.equal(steps[0].type, 'validate'); + assert.equal(steps[0].auto, true); + assert.equal(typeof steps[0].validate, 'function'); + }); + + it('result/VC accessors are inert on the base node', () => { + const v = new NodeItemValidator(item); + assert.equal(v.getResult(), null); + assert.equal(v.setResult({}), undefined); + assert.equal(v.getVC(), null); + assert.equal(v.setVC({}), false); + }); + + it('setData registers a score in the namespace', () => { + const v = new NodeItemValidator(item); + v.setData(ns()); + assert.ok(v.getScope() instanceof ValidateScore); + assert.ok(v.getNamespace() instanceof ValidateNamespace); + }); + + describe('static calculateFormula', () => { + it('coerces a truthy result to String for string type', () => { + FormulaEngine.setMathEngine({ evaluate: () => 5 }); + assert.strictEqual(NodeItemValidator.calculateFormula({ formula: 'x', type: 'string' }, {}), '5'); + }); + + it('coerces a truthy result to Number for non-string type', () => { + FormulaEngine.setMathEngine({ evaluate: () => '7' }); + assert.strictEqual(NodeItemValidator.calculateFormula({ formula: 'x', type: 'number' }, {}), 7); + }); + + it('returns a falsy result unchanged without coercion', () => { + FormulaEngine.setMathEngine({ evaluate: () => 0 }); + assert.strictEqual(NodeItemValidator.calculateFormula({ formula: 'x', type: 'number' }, {}), 0); + }); + }); + + describe('static from dispatch', () => { + it('creates a GroupItemValidator for type group', () => { + assert.ok(NodeItemValidator.from({ id: 'g', type: 'group' }) instanceof GroupItemValidator); + }); + it('creates a LabelItemValidator for type label', () => { + assert.ok(NodeItemValidator.from({ id: 'l', type: 'label', config: {} }) instanceof LabelItemValidator); + }); + it('creates a RuleItemValidator for type rules', () => { + assert.ok(NodeItemValidator.from({ id: 'r', type: 'rules' }) instanceof RuleItemValidator); + }); + it('creates a StatisticItemValidator for type statistic', () => { + assert.ok(NodeItemValidator.from({ id: 's', type: 'statistic' }) instanceof StatisticItemValidator); + }); + it('falls back to NodeItemValidator for an unknown type', () => { + const v = NodeItemValidator.from({ id: 'x', type: 'mystery' }); + assert.ok(v instanceof NodeItemValidator); + assert.equal(v.constructor.name, 'NodeItemValidator'); + }); + }); + + describe('static fromArray', () => { + it('maps each config to a validator', () => { + const arr = NodeItemValidator.fromArray([{ id: 'g', type: 'group' }, { id: 'x', type: '?' }]); + assert.equal(arr.length, 2); + assert.ok(arr[0] instanceof GroupItemValidator); + }); + it('returns [] for a non-array', () => { + assert.deepEqual(NodeItemValidator.fromArray(undefined), []); + assert.deepEqual(NodeItemValidator.fromArray(null), []); + }); + }); +}); + +describe('GroupItemValidator', () => { + it('applies defaults for name/title/tag/schema and rule Every', () => { + const g = new GroupItemValidator({ id: 'g1' }); + assert.equal(g.name, ''); + assert.equal(g.title, ''); + assert.equal(g.schema, ''); + assert.equal(g.rule, 'every'); + assert.equal(g.type, 'group'); + assert.deepEqual(g.children, []); + }); + + it('builds child validators from config children', () => { + const g = new GroupItemValidator({ id: 'g1', children: [{ id: 'c', type: 'group' }] }); + assert.equal(g.children.length, 1); + assert.ok(g.children[0] instanceof GroupItemValidator); + }); + + it('an empty group validates to true', () => { + const g = new GroupItemValidator({ id: 'g1' }); + const r = g.validate(); + assert.equal(r.valid, true); + assert.deepEqual(r.children, []); + }); + + it('rule Every requires every child to be valid', () => { + const g = new GroupItemValidator({ id: 'g1', rule: 'every' }); + g.children.push({ validate: () => ({ id: 'a', valid: true }) }); + g.children.push({ validate: () => ({ id: 'b', valid: false }) }); + assert.equal(g.validate().valid, false); + }); + + it('rule Every passes when all children are valid', () => { + const g = new GroupItemValidator({ id: 'g1', rule: 'every' }); + g.children.push({ validate: () => ({ id: 'a', valid: true }) }); + g.children.push({ validate: () => ({ id: 'b', valid: true }) }); + assert.equal(g.validate().valid, true); + }); + + it('rule One passes when at least one child is valid', () => { + const g = new GroupItemValidator({ id: 'g1', rule: 'one' }); + g.children.push({ validate: () => ({ id: 'a', valid: false }) }); + g.children.push({ validate: () => ({ id: 'b', valid: true }) }); + assert.equal(g.validate().valid, true); + }); + + it('rule One fails when no child is valid', () => { + const g = new GroupItemValidator({ id: 'g1', rule: 'one' }); + g.children.push({ validate: () => ({ id: 'a', valid: false }) }); + assert.equal(g.validate().valid, false); + }); + + it('collects child results under children', () => { + const g = new GroupItemValidator({ id: 'g1', rule: 'one' }); + g.children.push({ validate: () => ({ id: 'a', valid: true }) }); + assert.equal(g.validate().children.length, 1); + }); + + it('getResult exposes the current status', () => { + const g = new GroupItemValidator({ id: 'g1' }); + g.validate(); + assert.deepEqual(g.getResult(), { status: true }); + }); + + it('setResult(null) marks invalid document', () => { + const g = new GroupItemValidator({ id: 'g1' }); + g.setResult(null); + assert.equal(g.status, false); + assert.equal(g.getStatus().error, 'Invalid document'); + }); + + it('setResult derives validity from document.status', () => { + const g = new GroupItemValidator({ id: 'g1' }); + g.setResult({ status: true }); + assert.equal(g.status, true); + g.setResult({ status: false }); + assert.equal(g.status, false); + }); + + it('getVC wraps id, schema and result', () => { + const g = new GroupItemValidator({ id: 'g1', schemaId: 's#1' }); + g.validate(); + assert.deepEqual(g.getVC(), { id: 'g1', schema: 's#1', document: { status: true } }); + }); + + it('setVC applies the document and returns true', () => { + const g = new GroupItemValidator({ id: 'g1' }); + assert.equal(g.setVC({ status: true }), true); + assert.equal(g.status, true); + }); + + it('clear resets status', () => { + const g = new GroupItemValidator({ id: 'g1' }); + g.validate(); + g.clear(); + assert.equal(g.status, undefined); + }); + + it('setData propagates a namespace to children', () => { + const g = new GroupItemValidator({ id: 'g1', children: [{ id: 'c', type: 'group' }] }); + g.setData(ns()); + assert.ok(g.getScope() instanceof ValidateScore); + assert.ok(g.children[0].getScope() instanceof ValidateScore); + }); +}); + +describe('LabelItemValidator', () => { + const cfg = (over = {}) => ({ id: 'l1', name: 'Lbl', title: 'T', tag: 'LT', schemaId: 's#1', config: { children: [] }, ...over }); + + it('maps fields and builds a root group flagged isRoot', () => { + const v = new LabelItemValidator(cfg()); + assert.equal(v.id, 'l1'); + assert.equal(v.type, 'label'); + assert.equal(v.schema, 's#1'); + assert.ok(v.root instanceof GroupItemValidator); + assert.equal(v.root.isRoot, true); + }); + + it('resolves schema from config.schemaId when item.schemaId is absent', () => { + const v = new LabelItemValidator({ id: 'l1', config: { schemaId: 's#cfg', children: [] } }); + assert.equal(v.schema, 's#cfg'); + }); + + it('validate delegates to the root group (empty → valid)', () => { + const v = new LabelItemValidator(cfg()); + assert.equal(v.validate().valid, true); + assert.equal(v.status, true); + }); + + it('getResult returns the status object', () => { + const v = new LabelItemValidator(cfg()); + v.validate(); + assert.deepEqual(v.getResult(), { status: true }); + }); + + it('setResult(null) marks invalid document', () => { + const v = new LabelItemValidator(cfg()); + v.setResult(null); + assert.equal(v.status, false); + assert.equal(v.getStatus().error, 'Invalid document'); + }); + + it('setResult delegates to the root group', () => { + const v = new LabelItemValidator(cfg()); + v.setResult({ status: true }); + assert.equal(v.status, true); + }); + + it('getVC wraps id, schema and result', () => { + const v = new LabelItemValidator(cfg()); + v.validate(); + assert.deepEqual(v.getVC(), { id: 'l1', schema: 's#1', document: { status: true } }); + }); + + it('setVC applies a document and returns true', () => { + const v = new LabelItemValidator(cfg()); + assert.equal(v.setVC({ status: true }), true); + }); + + it('clear resets status', () => { + const v = new LabelItemValidator(cfg()); + v.validate(); + v.clear(); + assert.equal(v.status, undefined); + }); + + it('setData wires the namespace into the root group', () => { + const v = new LabelItemValidator(cfg()); + v.setData(ns()); + assert.ok(v.getScope() instanceof ValidateScore); + }); +}); + +describe('RuleItemValidator / StatisticItemValidator constructors', () => { + for (const [Cls, type, steps] of [[RuleItemValidator, 'rules', 3], [StatisticItemValidator, 'statistic', 3]]) { + it(`${Cls.name} maps fields and declares ${steps} steps`, () => { + const v = new Cls({ id: 'x1', name: 'N', title: 'T', tag: 'G', schemaId: 's#1' }); + assert.equal(v.id, 'x1'); + assert.equal(v.name, 'N'); + assert.equal(v.tag, 'G'); + assert.equal(v.schema, 's#1'); + assert.equal(v.type, type); + assert.equal(v.steps, steps); + assert.equal(v.isRoot, false); + }); + + it(`${Cls.name} applies empty-string defaults for optional labels`, () => { + const v = new Cls({ id: 'x1' }); + assert.equal(v.name, ''); + assert.equal(v.title, ''); + assert.equal(v.tag, ''); + assert.equal(v.schema, ''); + }); + + it(`${Cls.name} tolerates a missing config and starts with undefined status`, () => { + const v = new Cls({ id: 'x1' }); + assert.equal(v.status, undefined); + }); + + it(`${Cls.name} setData seeds a score in the namespace`, () => { + const v = new Cls({ id: 'x1', tag: 'G' }); + v.setData(ns()); + assert.ok(v.getScope() instanceof ValidateScore); + }); + } +}); diff --git a/interfaces/tests/label-validator-suite.test.mjs b/interfaces/tests/label-validator-suite.test.mjs new file mode 100644 index 0000000000..0711c96b7f --- /dev/null +++ b/interfaces/tests/label-validator-suite.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { ValidateScore } from '../dist/validators/label-validator/score.js'; +import { ValidateNamespace } from '../dist/validators/label-validator/namespace.js'; +import { FormulaValidator } from '../dist/validators/label-validator/variable-validator.js'; +import { FieldRuleResult } from '../dist/validators/rule-validator/interfaces/status.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +describe('ValidateScore', () => { + it('exposes id and name from the constructor', () => { + const s = new ValidateScore('id1', 'nameA'); + assert.equal(s.id, 'id1'); + assert.equal(s.name, 'nameA'); + }); + + it('starts with an empty score object and name list', () => { + const s = new ValidateScore('id1', 'nameA'); + assert.deepEqual(s.getScore(), {}); + assert.deepEqual(s.getName(), []); + }); + + it('setVariable accumulates keyed values', () => { + const s = new ValidateScore('id1', 'nameA'); + s.setVariable('x', 1); + s.setVariable('y', 2); + assert.deepEqual(s.getScore(), { x: 1, y: 2 }); + }); + + it('setVariable overwrites an existing key', () => { + const s = new ValidateScore('id1', 'nameA'); + s.setVariable('x', 1); + s.setVariable('x', 9); + assert.deepEqual(s.getScore(), { x: 9 }); + }); + + it('setName appends to the names list preserving order', () => { + const s = new ValidateScore('id1', 'nameA'); + s.setName('a'); + s.setName('b'); + assert.deepEqual(s.getName(), ['a', 'b']); + }); +}); + +describe('ValidateNamespace', () => { + it('createNamespaces returns a child namespace sharing documents', () => { + const root = new ValidateNamespace('root', [{ schema: 's1' }]); + const child = root.createNamespaces('child'); + assert.ok(child instanceof ValidateNamespace); + assert.equal(child.name, 'child'); + }); + + it('createScore returns a ValidateScore', () => { + const ns = new ValidateNamespace('root', []); + const score = ns.createScore('sc1', 'metric'); + assert.ok(score instanceof ValidateScore); + assert.equal(score.id, 'sc1'); + }); + + it('getNamespace aggregates all score values keyed by score name', () => { + const ns = new ValidateNamespace('root', []); + const a = ns.createScore('1', 'a'); + a.setVariable('x', 1); + const b = ns.createScore('2', 'b'); + b.setVariable('y', 2); + assert.deepEqual(ns.getNamespace(), { a: { x: 1 }, b: { y: 2 } }); + }); + + it('getNamespace(id) stops accumulating once it reaches the matching score', () => { + const ns = new ValidateNamespace('root', []); + const a = ns.createScore('1', 'a'); + a.setVariable('x', 1); + const b = ns.createScore('2', 'b'); + b.setVariable('y', 2); + assert.deepEqual(ns.getNamespace('2'), { a: { x: 1 } }); + }); + + it('getNames returns dotted "scoreName.key" entries from registered names', () => { + const ns = new ValidateNamespace('root', []); + const a = ns.createScore('1', 'a'); + a.setName('x'); + a.setName('z'); + assert.deepEqual(ns.getNames().sort(), ['a.x', 'a.z']); + }); + + it('getNames(id) stops at the matching score', () => { + const ns = new ValidateNamespace('root', []); + const a = ns.createScore('1', 'a'); + a.setName('x'); + const b = ns.createScore('2', 'b'); + b.setName('y'); + assert.deepEqual(ns.getNames('2'), ['a.x']); + }); + + it('getField resolves a dotted path inside the matching document subject', () => { + const docs = [{ schema: 's1', document: { credentialSubject: { a: { b: 5 } } } }]; + const ns = new ValidateNamespace('root', docs); + assert.equal(ns.getField('s1', 'a.b'), 5); + }); + + it('getField unwraps an array credential subject', () => { + const docs = [{ schema: 's1', document: { credentialSubject: [{ a: 7 }] } }]; + const ns = new ValidateNamespace('root', docs); + assert.equal(ns.getField('s1', 'a'), 7); + }); + + it('getField returns undefined when the schema is not present', () => { + const ns = new ValidateNamespace('root', [{ schema: 's1', document: {} }]); + assert.equal(ns.getField('other', 'a'), undefined); + }); + + it('getField returns undefined when the path cannot be resolved', () => { + const docs = [{ schema: 's1', document: { credentialSubject: { a: 1 } } }]; + const ns = new ValidateNamespace('root', docs); + assert.equal(ns.getField('s1', 'a.b.c'), undefined); + }); +}); + +describe('FormulaValidator', () => { + it('wraps a formula rule and validates via the rule engine', () => { + FormulaEngine.setMathEngine({ evaluate: () => 1 }); + const fv = new FormulaValidator({ id: 'f1', rule: { type: 'formula', formula: 'x' } }); + assert.equal(fv.id, 'f1'); + assert.equal(fv.validate({ x: 1 }), FieldRuleResult.Success); + }); + + it('validates to None when the formula has no rule', () => { + FormulaEngine.setMathEngine({ evaluate: () => 1 }); + const fv = new FormulaValidator({ id: 'f1', rule: null }); + assert.equal(fv.validate({}), FieldRuleResult.None); + }); +}); diff --git a/interfaces/tests/label-validators-nav-tree.test.mjs b/interfaces/tests/label-validators-nav-tree.test.mjs new file mode 100644 index 0000000000..910992091c --- /dev/null +++ b/interfaces/tests/label-validators-nav-tree.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { LabelValidators } from '../dist/validators/label-validator/label-validator.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +FormulaEngine.setMathEngine({ evaluate: (e) => e }); + +const twoRules = () => ({ + name: 'Nav Label', + config: { + children: [ + { id: 'r1', type: 'rules', tag: 'R1', title: 'Rule 1', schemaId: 's#1', config: { variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }] } }, + { id: 'r2', type: 'rules', tag: 'R2', title: 'Rule 2', schemaId: 's#1', config: { variables: [{ id: 'v2', schemaId: 's#1', path: 'amount' }] } }, + ], + }, +}); + +const grouped = () => ({ + name: 'Group Label', + config: { + children: [ + { + id: 'g1', type: 'group', tag: 'G1', title: 'Group 1', + children: [ + { id: 'r1', type: 'rules', tag: 'R1', title: 'Rule 1', schemaId: 's#1', config: { variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }] } }, + ], + }, + ], + }, +}); + +const docs = () => [{ schema: 's#1', document: { credentialSubject: { amount: 5 } } }]; + +describe('LabelValidators.prev', () => { + it('returns the previous non-auto step and runs its update', () => { + const lv = new LabelValidators(twoRules()); + lv.setData(docs()); + const first = lv.start(); + const second = lv.next(); + assert.notEqual(first, second); + const back = lv.prev(); + assert.equal(back, first); + assert.equal(lv.current(), first); + }); + + it('skips auto steps while walking backwards', () => { + const lv = new LabelValidators(twoRules()); + lv.setData(docs()); + lv.start(); + const second = lv.next(); + assert.equal(second.item.id, 'r2'); + const back = lv.prev(); + assert.equal(back.item.id, 'r1'); + }); + + it('returns null when stepping back before the first step', () => { + const lv = new LabelValidators(twoRules()); + lv.setData(docs()); + lv.start(); + assert.equal(lv.prev(), null); + }); + + it('isPrev is true after advancing past the first step', () => { + const lv = new LabelValidators(twoRules()); + lv.setData(docs()); + lv.start(); + assert.equal(lv.isPrev(), false); + lv.next(); + assert.equal(lv.isPrev(), true); + }); +}); + +describe('LabelValidators.getStatus', () => { + it('is undefined before any validation ran', () => { + const lv = new LabelValidators(twoRules()); + assert.equal(lv.getStatus(), undefined); + }); + + it('reflects the root result after walking through all steps', () => { + const lv = new LabelValidators(twoRules()); + lv.setData(docs()); + let step = lv.start(); + while (step) { + step.validate(); + step = lv.next(); + } + const status = lv.getStatus(); + assert.equal(typeof status.valid, 'boolean'); + assert.equal(status.id, 'root'); + }); +}); + +describe('LabelValidators tree with nested groups', () => { + it('builds group children into the tree', () => { + const tree = new LabelValidators(grouped()).getTree(); + assert.equal(tree.children.length, 1); + assert.equal(tree.children[0].type, 'group'); + assert.equal(tree.children[0].children.length, 1); + }); + + it('prefixes nested nodes with hierarchical ordinals', () => { + const tree = new LabelValidators(grouped()).getTree(); + assert.equal(tree.children[0].name, '1. Group 1'); + assert.equal(tree.children[0].children[0].name, '1.1. Rule 1'); + }); + + it('groups are not selectable while nested rules are', () => { + const tree = new LabelValidators(grouped()).getTree(); + assert.equal(tree.children[0].selectable, false); + assert.equal(tree.children[0].children[0].selectable, true); + }); + + it('includes the nested rule in the flat validator list', () => { + const lv = new LabelValidators(grouped()); + assert.ok(lv.getValidator('g1')); + assert.ok(lv.getValidator('r1')); + }); +}); diff --git a/interfaces/tests/label-validators-orchestrator-suite.test.mjs b/interfaces/tests/label-validators-orchestrator-suite.test.mjs new file mode 100644 index 0000000000..bd1c6f1678 --- /dev/null +++ b/interfaces/tests/label-validators-orchestrator-suite.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { LabelValidators } from '../dist/validators/label-validator/label-validator.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +FormulaEngine.setMathEngine({ evaluate: (e) => e }); + +function makeLabel() { + return { + name: 'My Label', + config: { + children: [ + { id: 'r1', type: 'rules', tag: 'R1', name: 'Rule1', title: 'Rule 1', schemaId: 's#1', config: { variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }] } }, + { id: 's1', type: 'statistic', tag: 'S1', name: 'Stat1', title: 'Stat 1', config: {} } + ] + } + }; +} + +const docs = () => [{ schema: 's#1', document: { credentialSubject: { amount: 5 } } }]; + +describe('LabelValidators — structure', () => { + it('exposes an undefined status before validation', () => { + const lv = new LabelValidators(makeLabel()); + assert.equal(lv.status, undefined); + }); + + it('builds a tree rooted at the label name', () => { + const lv = new LabelValidators(makeLabel()); + const tree = lv.getTree(); + assert.equal(tree.name, 'My Label'); + assert.equal(tree.children.length, 2); + }); + + it('prefixes child tree node names with their ordinal', () => { + const tree = new LabelValidators(makeLabel()).getTree(); + assert.equal(tree.children[0].name, '1. Rule 1'); + assert.equal(tree.children[1].name, '2. Stat 1'); + }); + + it('marks rules and statistic nodes as selectable', () => { + const tree = new LabelValidators(makeLabel()).getTree(); + assert.equal(tree.children[0].selectable, true); + assert.equal(tree.children[1].selectable, true); + assert.equal(tree.selectable, false); + }); + + it('getValidator resolves nodes by id and returns undefined for unknown', () => { + const lv = new LabelValidators(makeLabel()); + assert.equal(lv.getValidator('r1').id, 'r1'); + assert.equal(lv.getValidator('s1').id, 's1'); + assert.equal(lv.getValidator('nope'), undefined); + }); + + it('flattens a step list covering each item plus the root', () => { + const steps = new LabelValidators(makeLabel()).getSteps(); + assert.equal(steps.length, 5); + assert.ok(steps.some((s) => s.type === 'variables')); + }); + + it('builds a document step list excluding root items', () => { + const docSteps = new LabelValidators(makeLabel()).getDocument(); + assert.equal(docSteps.length >= 2, true); + }); +}); + +describe('LabelValidators — results and VCs', () => { + it('getResult returns one document per list node', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + const result = lv.getResult(); + assert.equal(result.length, 4); + }); + + it('setResult / getResult round-trips through the node list', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + const result = lv.getResult(); + lv.setResult(result); + assert.equal(lv.getResult().length, 4); + }); + + it('getVCs collects a VC per node', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + const vcs = lv.getVCs(); + assert.equal(vcs.length, 4); + assert.ok(vcs.every((vc) => typeof vc.id === 'string')); + }); + + it('setVp distributes verifiable credentials across nodes', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + lv.setVp({ document: { verifiableCredential: [ + { credentialSubject: [{ status: true }] }, + { credentialSubject: { status: true } }, + { credentialSubject: { status: true } }, + { credentialSubject: { status: true } } + ] } }); + assert.equal(typeof lv.status, 'boolean'); + }); +}); + +describe('LabelValidators — navigation', () => { + it('start returns the first non-auto step', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + const step = lv.start(); + assert.ok(step); + assert.equal(step.type, 'variables'); + assert.equal(step.auto, false); + }); + + it('current reflects the active step after start', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + lv.start(); + assert.equal(lv.current().type, 'variables'); + }); + + it('isPrev is false at the first step and isNext is true', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + lv.start(); + assert.equal(lv.isPrev(), false); + assert.equal(lv.isNext(), true); + }); + + it('advancing past all steps returns null', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + lv.start(); + assert.equal(lv.next(), null); + }); +}); + +describe('LabelValidators — validate and clear', () => { + it('validate runs every step and reports the root status', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + const status = lv.validate(); + assert.equal(typeof status.valid, 'boolean'); + }); + + it('clear resets the status to undefined', () => { + const lv = new LabelValidators(makeLabel()); + lv.setData(docs()); + lv.validate(); + lv.clear(); + assert.equal(lv.status, undefined); + }); +}); diff --git a/interfaces/tests/model-helper-edge.test.mjs b/interfaces/tests/model-helper-edge.test.mjs new file mode 100644 index 0000000000..de14b2cfab --- /dev/null +++ b/interfaces/tests/model-helper-edge.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { ModelHelper } from '../dist/helpers/model-helper.js'; + +describe('ModelHelper.checkVersionFormat — edge & quirks', () => { + it('accepts leading zeros in any segment', () => { + assert.equal(ModelHelper.checkVersionFormat('01.0.0'), true); + assert.equal(ModelHelper.checkVersionFormat('1.00.007'), true); + }); + + it('accepts a backslash as a segment separator (char-class quirk)', () => { + assert.equal(ModelHelper.checkVersionFormat('1' + String.fromCharCode(92) + '0'), true); + assert.equal(ModelHelper.checkVersionFormat('1' + String.fromCharCode(92) + '0' + String.fromCharCode(92) + '0'), true); + }); + + it('rejects a trailing newline (anchored, non-multiline)', () => { + assert.equal(ModelHelper.checkVersionFormat('1' + String.fromCharCode(10)), false); + assert.equal(ModelHelper.checkVersionFormat('1.0.0' + String.fromCharCode(10)), false); + }); + + it('rejects embedded tabs and surrounding spaces', () => { + assert.equal(ModelHelper.checkVersionFormat('1' + String.fromCharCode(9) + '0'), false); + assert.equal(ModelHelper.checkVersionFormat(' 1.0.0'), false); + assert.equal(ModelHelper.checkVersionFormat('1.0.0 '), false); + }); + + it('rejects more than three segments', () => { + assert.equal(ModelHelper.checkVersionFormat('1.2.3.4'), false); + assert.equal(ModelHelper.checkVersionFormat('1.2.3.4.5'), false); + }); + + it('rejects a sign prefix and trailing dot', () => { + assert.equal(ModelHelper.checkVersionFormat('+1.0.0'), false); + assert.equal(ModelHelper.checkVersionFormat('1.0.'), false); + assert.equal(ModelHelper.checkVersionFormat('.1.0'), false); + }); +}); + +describe('ModelHelper.versionCompare — edge & quirks', () => { + it('treats an empty v1 as older than any real version', () => { + assert.equal(ModelHelper.versionCompare('', '1.0.0'), -1); + }); + + it('treats non-numeric segments (NaN) as lower precedence', () => { + assert.equal(ModelHelper.versionCompare('a.b.c', '1.0.0'), -1); + assert.equal(ModelHelper.versionCompare('1.x.0', '1.0.0'), -1); + }); + + it('tolerates leading/trailing whitespace inside numeric segments', () => { + assert.equal(ModelHelper.versionCompare(' 1.0.0', '1.0.0'), 0); + assert.equal(ModelHelper.versionCompare('1.0.0 ', '1.0.0'), 0); + }); + + it('orders equal-prefix versions by length in both directions', () => { + assert.equal(ModelHelper.versionCompare('1.0', '1.0.0'), -1); + assert.equal(ModelHelper.versionCompare('1.0.0', '1.0'), 1); + }); + + it('compares multi-digit segments numerically, not lexically', () => { + assert.equal(ModelHelper.versionCompare('1.100.0', '1.99.0'), 1); + assert.equal(ModelHelper.versionCompare('1.9.0', '1.10.0'), -1); + }); + + it('handles very large numeric segments', () => { + assert.equal(ModelHelper.versionCompare('999999999.0.0', '1.0.0'), 1); + assert.equal(ModelHelper.versionCompare('1.0.0', '999999999.0.0'), -1); + }); +}); diff --git a/interfaces/tests/model-helper.test.mjs b/interfaces/tests/model-helper.test.mjs new file mode 100644 index 0000000000..7ae79b27b0 --- /dev/null +++ b/interfaces/tests/model-helper.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { ModelHelper } from '../dist/helpers/model-helper.js'; + +describe('ModelHelper.checkVersionFormat', () => { + it('accepts canonical X.Y.Z formats', () => { + assert.equal(ModelHelper.checkVersionFormat('1'), true); + assert.equal(ModelHelper.checkVersionFormat('1.0'), true); + assert.equal(ModelHelper.checkVersionFormat('1.2.3'), true); + assert.equal(ModelHelper.checkVersionFormat('10.20.30'), true); + }); + + it('rejects pre-release / non-numeric segments', () => { + assert.equal(ModelHelper.checkVersionFormat('1.0.0-alpha'), false); + assert.equal(ModelHelper.checkVersionFormat('v1.0.0'), false); + assert.equal(ModelHelper.checkVersionFormat('1.0.0.0'), false); // 4 segments not allowed + }); + + it('rejects empty / whitespace input', () => { + assert.equal(ModelHelper.checkVersionFormat(''), false); + assert.equal(ModelHelper.checkVersionFormat(' '), false); + }); +}); + +describe('ModelHelper.versionCompare', () => { + it('treats missing v2 as v1 being newer', () => { + assert.equal(ModelHelper.versionCompare('1.0.0', null), 1); + assert.equal(ModelHelper.versionCompare('1.0.0', undefined), 1); + assert.equal(ModelHelper.versionCompare('1.0.0', ''), 1); + }); + + it('returns 0 for identical versions', () => { + assert.equal(ModelHelper.versionCompare('2.3.4', '2.3.4'), 0); + }); + + it('returns 1 / -1 by descending precedence', () => { + assert.equal(ModelHelper.versionCompare('2.0.0', '1.9.9'), 1); + assert.equal(ModelHelper.versionCompare('1.0.0', '2.0.0'), -1); + assert.equal(ModelHelper.versionCompare('1.10.0', '1.9.9'), 1); + assert.equal(ModelHelper.versionCompare('1.9.9', '1.10.0'), -1); + }); + + it('returns 1 when v1 has more components than v2', () => { + assert.equal(ModelHelper.versionCompare('1.0.1', '1.0'), 1); + }); + + it('returns -1 when v1 is shorter than v2', () => { + assert.equal(ModelHelper.versionCompare('1.0', '1.0.1'), -1); + }); +}); diff --git a/interfaces/tests/permissions-getter-matrix.test.mjs b/interfaces/tests/permissions-getter-matrix.test.mjs new file mode 100644 index 0000000000..0a10cf2424 --- /dev/null +++ b/interfaces/tests/permissions-getter-matrix.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { UserPermissions } from '../dist/helpers/permissions-helper.js'; +import { Permissions } from '../dist/type/index.js'; + +const getters = [ + 'ARTIFACTS_FILE_DELETE', + 'CONTRACTS_CONTRACT_EXECUTE', + 'CONTRACTS_CONTRACT_MANAGE', + 'CONTRACTS_CONTRACT_CREATE', + 'CONTRACTS_CONTRACT_DELETE', + 'CONTRACTS_WIPE_REQUEST_READ', + 'CONTRACTS_WIPE_REQUEST_UPDATE', + 'CONTRACTS_WIPE_REQUEST_REVIEW', + 'CONTRACTS_WIPE_REQUEST_DELETE', + 'CONTRACTS_WIPE_ADMIN_CREATE', + 'CONTRACTS_WIPE_ADMIN_DELETE', + 'CONTRACTS_WIPE_MANAGER_CREATE', + 'CONTRACTS_WIPE_MANAGER_DELETE', + 'CONTRACTS_WIPER_CREATE', + 'CONTRACTS_WIPER_DELETE', + 'CONTRACTS_POOL_READ', + 'CONTRACTS_POOL_UPDATE', + 'CONTRACTS_POOL_DELETE', + 'CONTRACTS_RETIRE_REQUEST_READ', + 'CONTRACTS_RETIRE_REQUEST_CREATE', + 'CONTRACTS_RETIRE_REQUEST_DELETE', + 'CONTRACTS_RETIRE_REQUEST_REVIEW', + 'CONTRACTS_RETIRE_ADMIN_CREATE', + 'CONTRACTS_RETIRE_ADMIN_DELETE', + 'CONTRACTS_PERMISSIONS_READ', + 'CONTRACTS_DOCUMENT_READ', + 'IPFS_FILE_CREATE', + 'MODULES_MODULE_CREATE', + 'MODULES_MODULE_UPDATE', + 'MODULES_MODULE_DELETE', + 'MODULES_MODULE_REVIEW', + 'POLICIES_POLICY_UPDATE', + 'POLICIES_POLICY_DELETE', + 'POLICIES_POLICY_REVIEW', + 'POLICIES_POLICY_EXECUTE', + 'POLICIES_MIGRATION_CREATE', + 'POLICIES_RECORD_ALL', + 'POLICIES_POLICY_MANAGE', + 'POLICIES_POLICY_AUDIT', + 'POLICIES_POLICY_TAG', + 'SCHEMAS_SCHEMA_CREATE', + 'SCHEMAS_SCHEMA_UPDATE', + 'SCHEMAS_SCHEMA_DELETE', + 'SCHEMAS_SCHEMA_REVIEW', + 'SCHEMAS_SYSTEM_SCHEMA_CREATE', + 'SCHEMAS_SYSTEM_SCHEMA_UPDATE', + 'SCHEMAS_SYSTEM_SCHEMA_DELETE', + 'SCHEMAS_SYSTEM_SCHEMA_REVIEW', + 'TOOLS_TOOL_CREATE', + 'TOOLS_TOOL_UPDATE', + 'TOOLS_TOOL_DELETE', + 'TOOLS_TOOL_REVIEW', + 'TOOL_MIGRATION_CREATE', + 'TOKENS_TOKEN_UPDATE', + 'TOKENS_TOKEN_DELETE', + 'TOKENS_TOKEN_EXECUTE', + 'TOKENS_TOKEN_MANAGE', + 'TAGS_TAG_CREATE', + 'PROFILES_USER_UPDATE', + 'PROFILES_BALANCE_READ', + 'PROFILES_RESTORE_ALL', + 'SUGGESTIONS_SUGGESTIONS_UPDATE', + 'SETTINGS_SETTINGS_UPDATE', + 'SETTINGS_THEME_CREATE', + 'SETTINGS_THEME_UPDATE', + 'SETTINGS_THEME_DELETE', + 'PERMISSIONS_ROLE_CREATE', + 'PERMISSIONS_ROLE_UPDATE', + 'PERMISSIONS_ROLE_DELETE', + 'PERMISSIONS_ROLE_MANAGE', + 'STATISTICS_STATISTIC_CREATE', + 'SCHEMAS_RULE_CREATE', + 'SCHEMAS_RULE_EXECUTE', + 'STATISTICS_LABEL_CREATE', + 'FORMULAS_FORMULA_CREATE', + 'POLICIES_EXTERNAL_POLICY_CREATE', + 'POLICIES_EXTERNAL_POLICY_DELETE', + 'POLICIES_EXTERNAL_POLICY_UPDATE', + 'WORKER_TASKS_EXECUTE', + 'WORKER_TASKS_DELETE', +]; + +describe('UserPermissions getter matrix', () => { + it('every getter under test maps to a declared permission constant', () => { + for (const name of getters) { + assert.equal(typeof Permissions[name], 'string', name); + } + }); + + for (const name of getters) { + it(`${name} reflects the granted permission`, () => { + const granted = new UserPermissions({ role: 'USER', permissions: [Permissions[name]] }); + const denied = new UserPermissions({ role: 'USER', permissions: [] }); + assert.equal(granted[name], true); + assert.equal(denied[name], false); + }); + } + + it('an unrelated permission does not satisfy a getter', () => { + const user = new UserPermissions({ role: 'USER', permissions: [Permissions.TAGS_TAG_CREATE] }); + assert.equal(user.TOOLS_TOOL_DELETE, false); + assert.equal(user.TAGS_TAG_CREATE, true); + }); +}); diff --git a/interfaces/tests/permissions-helper-branches.test.mjs b/interfaces/tests/permissions-helper-branches.test.mjs new file mode 100644 index 0000000000..be2618f73d --- /dev/null +++ b/interfaces/tests/permissions-helper-branches.test.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { UserPermissions } from '../dist/helpers/permissions-helper.js'; +import { Permissions, LocationType, UserRole } from '../dist/type/index.js'; + +const make = (permissions, role = UserRole.USER) => new UserPermissions({ role, permissions }); + +describe('ACCESS_POLICY_ASSIGNED_AND_PUBLISHED getter', () => { + it('is true only when the dedicated combined permission is held', () => { + assert.equal(make([Permissions.ACCESS_POLICY_ASSIGNED_AND_PUBLISHED]).ACCESS_POLICY_ASSIGNED_AND_PUBLISHED, true); + }); + + it('is false when only ASSIGNED is held', () => { + assert.equal(make([Permissions.ACCESS_POLICY_ASSIGNED]).ACCESS_POLICY_ASSIGNED_AND_PUBLISHED, false); + }); + + it('is false when only PUBLISHED is held', () => { + assert.equal(make([Permissions.ACCESS_POLICY_PUBLISHED]).ACCESS_POLICY_ASSIGNED_AND_PUBLISHED, false); + }); + + it('is false when both ASSIGNED and PUBLISHED are held but not the combined permission', () => { + assert.equal( + make([Permissions.ACCESS_POLICY_ASSIGNED, Permissions.ACCESS_POLICY_PUBLISHED]).ACCESS_POLICY_ASSIGNED_AND_PUBLISHED, + false, + ); + }); + + it('is false with no permissions', () => { + assert.equal(make([]).ACCESS_POLICY_ASSIGNED_AND_PUBLISHED, false); + }); +}); + +describe('ACCESS_POLICY_ASSIGNED_OR_PUBLISHED getter (requires BOTH despite the name)', () => { + const both = [Permissions.ACCESS_POLICY_ASSIGNED, Permissions.ACCESS_POLICY_PUBLISHED]; + + it('is true when both ASSIGNED and PUBLISHED are held', () => { + assert.equal(make(both).ACCESS_POLICY_ASSIGNED_OR_PUBLISHED, true); + }); + + it('is false when only ASSIGNED is held', () => { + assert.equal(make([Permissions.ACCESS_POLICY_ASSIGNED]).ACCESS_POLICY_ASSIGNED_OR_PUBLISHED, false); + }); + + it('is false when only PUBLISHED is held', () => { + assert.equal(make([Permissions.ACCESS_POLICY_PUBLISHED]).ACCESS_POLICY_ASSIGNED_OR_PUBLISHED, false); + }); + + it('is false when neither is held', () => { + assert.equal(make([]).ACCESS_POLICY_ASSIGNED_OR_PUBLISHED, false); + }); + + it('is false when the combined ALL permission is held but not the two parts', () => { + assert.equal(make([Permissions.ACCESS_POLICY_ALL]).ACCESS_POLICY_ASSIGNED_OR_PUBLISHED, false); + }); +}); + +describe('UserPermissions.has — array argument branches', () => { + const user = { permissions: ['A', 'B', 'C'] }; + + it('returns true when the first listed permission matches', () => { + assert.equal(UserPermissions.has(user, ['A', 'X']), true); + }); + + it('returns true when a later listed permission matches', () => { + assert.equal(UserPermissions.has(user, ['X', 'Y', 'C']), true); + }); + + it('returns false for an empty required array', () => { + assert.equal(UserPermissions.has(user, []), false); + }); + + it('returns false when none of the listed permissions match', () => { + assert.equal(UserPermissions.has(user, ['X', 'Y', 'Z']), false); + }); + + it('returns true when the required list contains a duplicate that matches', () => { + assert.equal(UserPermissions.has(user, ['A', 'A']), true); + }); + + it('returns false for an array argument when user is null', () => { + assert.equal(UserPermissions.has(null, ['A']), false); + }); + + it('returns false for an array argument when user has no permissions field', () => { + assert.equal(UserPermissions.has({}, ['A']), false); + }); + + it('returns false for an array argument when permissions is undefined', () => { + assert.equal(UserPermissions.has({ permissions: undefined }, ['A']), false); + }); +}); + +describe('UserPermissions.has — single argument branches', () => { + const user = { permissions: ['READ', 'WRITE'] }; + + it('returns true when the single permission is held', () => { + assert.equal(UserPermissions.has(user, 'READ'), true); + }); + + it('returns false when the single permission is not held', () => { + assert.equal(UserPermissions.has(user, 'DELETE'), false); + }); + + it('returns false for undefined user', () => { + assert.equal(UserPermissions.has(undefined, 'READ'), false); + }); + + it('returns false when permissions array is empty', () => { + assert.equal(UserPermissions.has({ permissions: [] }, 'READ'), false); + }); + + it('returns false for falsy permission strings against an empty store', () => { + assert.equal(UserPermissions.has({ permissions: [] }, ''), false); + }); +}); + +describe('UserPermissions.isPolicyAdmin — full single-permission matrix', () => { + const adminPerms = [ + Permissions.POLICIES_MIGRATION_CREATE, + Permissions.POLICIES_POLICY_CREATE, + Permissions.POLICIES_POLICY_UPDATE, + Permissions.POLICIES_POLICY_DELETE, + Permissions.POLICIES_POLICY_REVIEW, + ]; + + for (const perm of adminPerms) { + it(`returns true when only ${perm} is held`, () => { + assert.equal(UserPermissions.isPolicyAdmin({ permissions: [perm] }), true); + }); + } + + const nonAdminPerms = [ + Permissions.POLICIES_POLICY_READ, + Permissions.POLICIES_POLICY_EXECUTE, + Permissions.POLICIES_RECORD_ALL, + Permissions.SCHEMAS_SCHEMA_CREATE, + Permissions.TOKENS_TOKEN_CREATE, + ]; + + for (const perm of nonAdminPerms) { + it(`returns false when only ${perm} is held`, () => { + assert.equal(UserPermissions.isPolicyAdmin({ permissions: [perm] }), false); + }); + } + + it('returns true when an admin permission is mixed with non-admin permissions', () => { + assert.equal( + UserPermissions.isPolicyAdmin({ + permissions: [Permissions.POLICIES_POLICY_READ, Permissions.POLICIES_POLICY_CREATE], + }), + true, + ); + }); + + it('returns false for empty permissions / missing field / null user', () => { + assert.equal(UserPermissions.isPolicyAdmin({ permissions: [] }), false); + assert.equal(UserPermissions.isPolicyAdmin({}), false); + assert.equal(UserPermissions.isPolicyAdmin(null), false); + assert.equal(UserPermissions.isPolicyAdmin(undefined), false); + }); +}); + +describe('UserPermissions constructor edge cases', () => { + it('falls back to an empty permissions array when user.permissions is falsy', () => { + assert.deepEqual(new UserPermissions({ role: UserRole.USER }).permissions, []); + assert.deepEqual(new UserPermissions({ role: UserRole.USER, permissions: null }).permissions, []); + assert.deepEqual(new UserPermissions({ role: UserRole.USER, permissions: 0 }).permissions, []); + }); + + it('preserves a provided non-empty permissions array by reference content', () => { + const up = new UserPermissions({ role: UserRole.USER, permissions: ['X', 'Y'] }); + assert.deepEqual(up.permissions, ['X', 'Y']); + }); + + it('copies username/did/parent/role/permissionsGroup/location verbatim', () => { + const up = new UserPermissions({ + username: 'bob', + did: 'did:b', + parent: 'did:a', + role: UserRole.STANDARD_REGISTRY, + permissions: ['P'], + permissionsGroup: ['g1', 'g2'], + location: LocationType.REMOTE, + }); + assert.equal(up.username, 'bob'); + assert.equal(up.did, 'did:b'); + assert.equal(up.parent, 'did:a'); + assert.equal(up.role, UserRole.STANDARD_REGISTRY); + assert.deepEqual(up.permissionsGroup, ['g1', 'g2']); + assert.equal(up.location, LocationType.REMOTE); + }); + + it('defaults to LOCAL location and empty permissions for no-arg construction', () => { + const up = new UserPermissions(); + assert.equal(up.location, LocationType.LOCAL); + assert.deepEqual(up.permissions, []); + assert.equal(up.username, undefined); + assert.equal(up.role, undefined); + }); + + it('getters operate against the constructed permissions array', () => { + const up = new UserPermissions({ role: UserRole.USER, permissions: [Permissions.POLICIES_POLICY_READ] }); + assert.equal(up.POLICIES_POLICY_READ, true); + assert.equal(up.POLICIES_POLICY_CREATE, false); + }); +}); diff --git a/interfaces/tests/policy-editable-field.test.mjs b/interfaces/tests/policy-editable-field.test.mjs new file mode 100644 index 0000000000..8e658ab409 --- /dev/null +++ b/interfaces/tests/policy-editable-field.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { PolicyEditableField, PolicyEditableFieldDTO } from '../dist/helpers/policy-editable-field.js'; + +describe('PolicyEditableField defaults', () => { + it('initialises scalar fields to empty/false', () => { + const field = new PolicyEditableField(); + assert.equal(field.blockType, ''); + assert.equal(field.blockTag, ''); + assert.equal(field.propertyPath, ''); + assert.equal(field.label, ''); + assert.equal(field.shortDescription, ''); + assert.equal(field.required, false); + }); + + it('initialises collection fields to independent empty arrays', () => { + const a = new PolicyEditableField(); + const b = new PolicyEditableField(); + assert.deepEqual(a.visible, []); + assert.deepEqual(a.applyTo, []); + assert.deepEqual(a.blocks, []); + assert.deepEqual(a.properties, []); + assert.deepEqual(a.roles, []); + assert.deepEqual(a.targets, []); + a.visible.push('x'); + assert.deepEqual(b.visible, []); + }); +}); + +describe('PolicyEditableField.fromDTO', () => { + it('copies every DTO property onto a new instance', () => { + const dto = new PolicyEditableFieldDTO(); + dto.blockType = 'requestVcDocumentBlock'; + dto.blockTag = 'tag-1'; + dto.propertyPath = 'options.title'; + dto.visible = ['OWNER']; + dto.applyTo = ['All']; + dto.label = 'Title'; + dto.required = true; + dto.shortDescription = 'desc'; + + const field = PolicyEditableField.fromDTO(dto); + assert.ok(field instanceof PolicyEditableField); + assert.equal(field.blockType, 'requestVcDocumentBlock'); + assert.equal(field.blockTag, 'tag-1'); + assert.equal(field.propertyPath, 'options.title'); + assert.deepEqual(field.visible, ['OWNER']); + assert.deepEqual(field.applyTo, ['All']); + assert.equal(field.label, 'Title'); + assert.equal(field.required, true); + assert.equal(field.shortDescription, 'desc'); + }); + + it('keeps instance-only defaults for properties absent from the DTO', () => { + const field = PolicyEditableField.fromDTO(new PolicyEditableFieldDTO()); + assert.deepEqual(field.blocks, []); + assert.deepEqual(field.roles, []); + assert.deepEqual(field.targets, []); + assert.deepEqual(field.properties, []); + }); +}); + +describe('PolicyEditableField.toDTO', () => { + it('projects the editable subset into a DTO', () => { + const field = new PolicyEditableField(); + field.blockType = 'bt'; + field.blockTag = 'tg'; + field.propertyPath = 'p'; + field.visible = ['A']; + field.applyTo = ['B']; + field.label = 'L'; + field.required = true; + field.shortDescription = 'sd'; + + const dto = field.toDTO(); + assert.ok(dto instanceof PolicyEditableFieldDTO); + assert.equal(dto.blockType, 'bt'); + assert.equal(dto.blockTag, 'tg'); + assert.equal(dto.propertyPath, 'p'); + assert.deepEqual(dto.visible, ['A']); + assert.deepEqual(dto.applyTo, ['B']); + assert.equal(dto.label, 'L'); + assert.equal(dto.required, true); + assert.equal(dto.shortDescription, 'sd'); + }); + + it('does not carry view-only collections onto the DTO', () => { + const field = new PolicyEditableField(); + field.blocks = [{ tag: 'x' }]; + field.roles = ['OWNER']; + const dto = field.toDTO(); + assert.equal(dto.blocks, undefined); + assert.equal(dto.roles, undefined); + }); + + it('round-trips DTO -> field -> DTO preserving editable values', () => { + const src = new PolicyEditableFieldDTO(); + src.blockType = 'bt'; + src.blockTag = 'tg'; + src.propertyPath = 'pp'; + src.visible = ['OWNER', 'USER']; + src.applyTo = ['All']; + src.label = 'lbl'; + src.required = false; + src.shortDescription = 'sd'; + + const out = PolicyEditableField.fromDTO(src).toDTO(); + assert.deepEqual(out, src); + }); +}); diff --git a/interfaces/tests/policy-helper.test.mjs b/interfaces/tests/policy-helper.test.mjs new file mode 100644 index 0000000000..24f813d0b7 --- /dev/null +++ b/interfaces/tests/policy-helper.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { PolicyHelper } from '../dist/helpers/policy-helper.js'; + +const Status = { + DRY_RUN: 'DRY-RUN', + DRAFT: 'DRAFT', + PUBLISH_ERROR: 'PUBLISH_ERROR', + PUBLISH: 'PUBLISH', + DISCONTINUED: 'DISCONTINUED', + DEMO: 'DEMO', + VIEW: 'VIEW', +}; + +describe('PolicyHelper.isRun', () => { + it('returns true for DRY_RUN/DEMO/VIEW/PUBLISH/DISCONTINUED', () => { + for (const s of [Status.DRY_RUN, Status.DEMO, Status.VIEW, Status.PUBLISH, Status.DISCONTINUED]) { + assert.equal(PolicyHelper.isRun({ status: s }), true, `status=${s}`); + } + }); + + it('returns false for DRAFT and PUBLISH_ERROR', () => { + assert.equal(PolicyHelper.isRun({ status: Status.DRAFT }), false); + assert.equal(PolicyHelper.isRun({ status: Status.PUBLISH_ERROR }), false); + }); + + it('returns false for null/undefined input', () => { + assert.equal(PolicyHelper.isRun(null), false); + assert.equal(PolicyHelper.isRun(undefined), false); + assert.equal(PolicyHelper.isRun({}), false); + }); +}); + +describe('PolicyHelper.isDryRunMode', () => { + it('returns true only for DRY_RUN and DEMO', () => { + assert.equal(PolicyHelper.isDryRunMode({ status: Status.DRY_RUN }), true); + assert.equal(PolicyHelper.isDryRunMode({ status: Status.DEMO }), true); + assert.equal(PolicyHelper.isDryRunMode({ status: Status.PUBLISH }), false); + assert.equal(PolicyHelper.isDryRunMode({ status: Status.VIEW }), false); + }); + + it('returns false for null input', () => { + assert.equal(PolicyHelper.isDryRunMode(null), false); + }); +}); + +describe('PolicyHelper.isPublishMode', () => { + it('returns true for PUBLISH and DISCONTINUED', () => { + assert.equal(PolicyHelper.isPublishMode({ status: Status.PUBLISH }), true); + assert.equal(PolicyHelper.isPublishMode({ status: Status.DISCONTINUED }), true); + }); + + it('returns false for non-publish statuses', () => { + assert.equal(PolicyHelper.isPublishMode({ status: Status.DRAFT }), false); + assert.equal(PolicyHelper.isPublishMode({ status: Status.DRY_RUN }), false); + assert.equal(PolicyHelper.isPublishMode({ status: Status.VIEW }), false); + }); +}); + +describe('PolicyHelper.isEditMode', () => { + it('returns true only for DRAFT and PUBLISH_ERROR', () => { + assert.equal(PolicyHelper.isEditMode({ status: Status.DRAFT }), true); + assert.equal(PolicyHelper.isEditMode({ status: Status.PUBLISH_ERROR }), true); + }); + + it('returns false for running and view statuses', () => { + for (const s of [Status.PUBLISH, Status.DRY_RUN, Status.DEMO, Status.VIEW, Status.DISCONTINUED]) { + assert.equal(PolicyHelper.isEditMode({ status: s }), false, `status=${s}`); + } + }); + + it('returns false for null input', () => { + assert.equal(PolicyHelper.isEditMode(null), false); + }); +}); diff --git a/interfaces/tests/policy-messages-provider.test.mjs b/interfaces/tests/policy-messages-provider.test.mjs new file mode 100644 index 0000000000..b3b62decd5 --- /dev/null +++ b/interfaces/tests/policy-messages-provider.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { + applyIgnoreRules, + getPolicyMessagesForBlock, + buildMessagesForValidator, +} from '../dist/validators/policy-messages/provider.js'; +import { + MSG_DEPRECATION_BLOCK, + MSG_REACH_NO_IN, + MSG_REACH_NO_OUT, + MSG_REACH_ISOLATED, +} from '../dist/validators/policy-messages/types.js'; +import { collapseReachabilityMessages } from '../dist/validators/policy-messages/reachability.js'; + +const make = (overrides) => ({ + severity: 'warning', + code: MSG_DEPRECATION_BLOCK, + text: 'msg', + ...overrides, +}); + +describe('applyIgnoreRules', () => { + it('returns a copy when no rules supplied', () => { + const msgs = [make()]; + const result = applyIgnoreRules(msgs); + assert.equal(result.length, 1); + assert.notEqual(result, msgs); + }); + + it('drops messages matching by code', () => { + const msgs = [ + make({ code: 'DEPRECATION_BLOCK' }), + make({ code: 'DEPRECATION_PROP' }), + ]; + const result = applyIgnoreRules(msgs, [{ code: 'DEPRECATION_BLOCK' }]); + assert.equal(result.length, 1); + assert.equal(result[0].code, 'DEPRECATION_PROP'); + }); + + it('drops messages matching by blockType', () => { + const msgs = [ + make({ blockType: 'foo' }), + make({ blockType: 'bar' }), + ]; + const result = applyIgnoreRules(msgs, [{ blockType: 'foo' }]); + assert.equal(result.length, 1); + assert.equal(result[0].blockType, 'bar'); + }); + + it('matches by contains substring on text', () => { + const msgs = [make({ text: 'hello world' }), make({ text: 'goodbye' })]; + const result = applyIgnoreRules(msgs, [{ contains: 'hello' }]); + assert.equal(result.length, 1); + assert.equal(result[0].text, 'goodbye'); + }); + + it('matches by severity filter', () => { + const msgs = [make({ severity: 'warning' }), make({ severity: 'info' })]; + const result = applyIgnoreRules(msgs, [{ severity: 'warning' }]); + assert.equal(result.length, 1); + assert.equal(result[0].severity, 'info'); + }); + + it('all rule fields must match for a message to be dropped (AND semantics within a rule)', () => { + const msgs = [ + make({ code: 'A', blockType: 'b1' }), + make({ code: 'A', blockType: 'b2' }), + ]; + const result = applyIgnoreRules(msgs, [{ code: 'A', blockType: 'b1' }]); + // Only b1 matches both → dropped; b2 survives. + assert.equal(result.length, 1); + assert.equal(result[0].blockType, 'b2'); + }); + + it('any matching rule drops the message (OR semantics across rules)', () => { + const msgs = [make({ blockType: 'a' }), make({ blockType: 'b' })]; + const result = applyIgnoreRules(msgs, [ + { blockType: 'a' }, + { blockType: 'b' }, + ]); + assert.equal(result.length, 0); + }); +}); + +describe('getPolicyMessagesForBlock', () => { + it('returns [] for an unknown block (no deprecations registered)', () => { + const result = getPolicyMessagesForBlock('unknown-block', {}); + assert.deepEqual(result, []); + }); + + it('appends reachability messages from the supplied per-block map', () => { + const reachByBlock = new Map([ + ['b1', [{ severity: 'warning', code: MSG_REACH_NO_IN, text: 'no in' }]], + ]); + const result = getPolicyMessagesForBlock('any', {}, 'b1', reachByBlock); + assert.equal(result.length, 1); + assert.equal(result[0].code, MSG_REACH_NO_IN); + }); + + it('deduplicates messages by code+block+prop+text', () => { + const dup = [ + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'same' }, + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'same' }, + ]; + const reachByBlock = new Map([['b1', dup]]); + const result = getPolicyMessagesForBlock('any', {}, 'b1', reachByBlock); + assert.equal(result.length, 1); + }); +}); + +describe('collapseReachabilityMessages', () => { + it('keeps NO_IN/NO_OUT when no ISOLATED is present', () => { + const msgs = [ + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'no in' }, + { severity: 'warning', code: MSG_REACH_NO_OUT, text: 'no out' }, + ]; + const result = collapseReachabilityMessages(msgs); + assert.equal(result.length, 2); + }); + + it('drops NO_IN/NO_OUT when ISOLATED is present (collapsed view)', () => { + const msgs = [ + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'no in' }, + { severity: 'warning', code: MSG_REACH_NO_OUT, text: 'no out' }, + { severity: 'warning', code: MSG_REACH_ISOLATED, text: 'isolated' }, + ]; + const result = collapseReachabilityMessages(msgs); + assert.equal(result.length, 1); + assert.equal(result[0].code, MSG_REACH_ISOLATED); + }); +}); + +describe('buildMessagesForValidator', () => { + it('returns warningsText / infosText split by severity', () => { + const reachByBlock = new Map([ + ['b1', [ + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'warn-1' }, + { severity: 'info', code: MSG_REACH_NO_OUT, text: 'info-1' }, + ]], + ]); + const result = buildMessagesForValidator('any', {}, undefined, reachByBlock, 'b1'); + assert.deepEqual(result.warningsText, ['warn-1']); + assert.deepEqual(result.infosText, ['info-1']); + }); + + it('applies ignore rules before splitting', () => { + const reachByBlock = new Map([ + ['b1', [ + { severity: 'warning', code: MSG_REACH_NO_IN, text: 'kept' }, + { severity: 'warning', code: MSG_REACH_NO_OUT, text: 'dropped' }, + ]], + ]); + const result = buildMessagesForValidator( + 'any', {}, + [{ code: MSG_REACH_NO_OUT }], + reachByBlock, + 'b1', + ); + assert.equal(result.warningsText.length, 1); + assert.equal(result.warningsText[0], 'kept'); + }); +}); diff --git a/interfaces/tests/policy-messages-types.test.mjs b/interfaces/tests/policy-messages-types.test.mjs new file mode 100644 index 0000000000..96fc8e2f67 --- /dev/null +++ b/interfaces/tests/policy-messages-types.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { + MSG_DEPRECATION_BLOCK, + MSG_DEPRECATION_PROP, + MSG_REACH_NO_IN, + MSG_REACH_NO_OUT, + MSG_REACH_ISOLATED, +} from '../dist/validators/policy-messages/types.js'; + +describe('PolicyMessage code constants', () => { + it('exposes the canonical deprecation codes', () => { + assert.equal(MSG_DEPRECATION_BLOCK, 'DEPRECATION_BLOCK'); + assert.equal(MSG_DEPRECATION_PROP, 'DEPRECATION_PROP'); + }); + + it('exposes the canonical reachability codes', () => { + assert.equal(MSG_REACH_NO_IN, 'REACHABILITY_NO_IN'); + assert.equal(MSG_REACH_NO_OUT, 'REACHABILITY_NO_OUT'); + assert.equal(MSG_REACH_ISOLATED, 'REACHABILITY_ISOLATED'); + }); + + it('codes are distinct', () => { + const codes = new Set([ + MSG_DEPRECATION_BLOCK, + MSG_DEPRECATION_PROP, + MSG_REACH_NO_IN, + MSG_REACH_NO_OUT, + MSG_REACH_ISOLATED, + ]); + assert.equal(codes.size, 5); + }); +}); diff --git a/interfaces/tests/pure-helpers-suite.test.mjs b/interfaces/tests/pure-helpers-suite.test.mjs new file mode 100644 index 0000000000..125ec8aa54 --- /dev/null +++ b/interfaces/tests/pure-helpers-suite.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { sortObjectsArray } from '../dist/helpers/sort-objects-array.js'; +import { removeObjectProperties } from '../dist/helpers/remove-object-properties.js'; +import { GenerateUUIDv4, GenerateID } from '../dist/helpers/generate-uuid-v4.js'; +import { OrderDirection } from '../dist/type/index.js'; + +describe('sortObjectsArray', () => { + const mk = () => [{ n: 3 }, { n: 1 }, { n: 2 }]; + + it('sorts ascending by default', () => { + assert.deepEqual(sortObjectsArray(mk(), 'n').map((x) => x.n), [1, 2, 3]); + }); + + it('sorts ascending when ASC is explicit', () => { + assert.deepEqual(sortObjectsArray(mk(), 'n', OrderDirection.ASC).map((x) => x.n), [1, 2, 3]); + }); + + it('sorts descending with DESC', () => { + assert.deepEqual(sortObjectsArray(mk(), 'n', OrderDirection.DESC).map((x) => x.n), [3, 2, 1]); + }); + + it('handles negative numbers', () => { + const r = sortObjectsArray([{ n: -1 }, { n: -5 }, { n: 2 }], 'n'); + assert.deepEqual(r.map((x) => x.n), [-5, -1, 2]); + }); + + it('preserves floating point ordering', () => { + const r = sortObjectsArray([{ n: 0.3 }, { n: 0.1 }, { n: 0.2 }], 'n'); + assert.deepEqual(r.map((x) => x.n), [0.1, 0.2, 0.3]); + }); + + it('returns an empty array unchanged', () => { + assert.deepEqual(sortObjectsArray([], 'n'), []); + }); + + it('only reorders by the named field, leaving objects intact', () => { + const r = sortObjectsArray([{ n: 2, tag: 'b' }, { n: 1, tag: 'a' }], 'n'); + assert.deepEqual(r, [{ n: 1, tag: 'a' }, { n: 2, tag: 'b' }]); + }); + + it('sorts in place and returns the same array reference', () => { + const arr = mk(); + assert.equal(sortObjectsArray(arr, 'n'), arr); + }); + + it('keeps equal-keyed elements together', () => { + const r = sortObjectsArray([{ n: 1, k: 'a' }, { n: 1, k: 'b' }, { n: 0, k: 'c' }], 'n'); + assert.equal(r[0].n, 0); + assert.equal(r[1].n, 1); + assert.equal(r[2].n, 1); + }); +}); + +describe('removeObjectProperties', () => { + it('removes a top-level property', () => { + assert.deepEqual(removeObjectProperties(['a'], { a: 1, b: 2 }), { b: 2 }); + }); + + it('removes multiple named properties', () => { + assert.deepEqual(removeObjectProperties(['a', 'c'], { a: 1, b: 2, c: 3 }), { b: 2 }); + }); + + it('returns a null object untouched', () => { + assert.equal(removeObjectProperties(['a'], null), null); + }); + + it('returns the object untouched when names is not an array', () => { + const obj = { a: 1 }; + assert.equal(removeObjectProperties('a', obj), obj); + assert.deepEqual(obj, { a: 1 }); + }); + + it('an empty names array leaves the object unchanged', () => { + assert.deepEqual(removeObjectProperties([], { a: 1 }), { a: 1 }); + }); + + it('removes nested properties recursively', () => { + const obj = { a: 1, child: { a: 2, keep: 5 } }; + assert.deepEqual(removeObjectProperties(['a'], obj), { child: { keep: 5 } }); + }); + + it('removes the property from every element of an array value', () => { + const obj = { list: [{ a: 1, k: 1 }, { a: 2, k: 2 }] }; + assert.deepEqual(removeObjectProperties(['a'], obj), { list: [{ k: 1 }, { k: 2 }] }); + }); + + it('removes from a deeply nested structure', () => { + const obj = { x: { y: { z: { secret: 1, keep: 2 } } } }; + assert.deepEqual(removeObjectProperties(['secret'], obj), { x: { y: { z: { keep: 2 } } } }); + }); + + it('mutates and returns the same reference', () => { + const obj = { a: 1, b: 2 }; + assert.equal(removeObjectProperties(['a'], obj), obj); + }); +}); + +describe('GenerateUUIDv4', () => { + const RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + it('produces an RFC 4122 v4 formatted string', () => { + assert.match(GenerateUUIDv4(), RE); + }); + + it('the version nibble is 4 and the variant nibble is one of 8/9/a/b', () => { + const parts = GenerateUUIDv4().split('-'); + assert.equal(parts[2][0], '4'); + assert.ok(['8', '9', 'a', 'b'].includes(parts[3][0].toLowerCase())); + }); + + it('produces unique values across many calls', () => { + const set = new Set(); + for (let i = 0; i < 500; i++) { + set.add(GenerateUUIDv4()); + } + assert.equal(set.size, 500); + }); +}); + +describe('GenerateID', () => { + it('produces a 32-character lowercase hex string', () => { + const id = GenerateID(); + assert.equal(id.length, 32); + assert.match(id, /^[0-9a-f]{32}$/); + }); + + it('produces unique values across many calls', () => { + const set = new Set(); + for (let i = 0; i < 500; i++) { + set.add(GenerateID()); + } + assert.equal(set.size, 500); + }); +}); diff --git a/interfaces/tests/reachability-edge.test.mjs b/interfaces/tests/reachability-edge.test.mjs new file mode 100644 index 0000000000..6f3024c041 --- /dev/null +++ b/interfaces/tests/reachability-edge.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { + computeReachability, +} from '../dist/validators/policy-messages/reachability.js'; +import { + MSG_REACH_NO_IN, + MSG_REACH_NO_OUT, + MSG_REACH_ISOLATED, +} from '../dist/validators/policy-messages/types.js'; + +function source(id, opts = {}) { + return { + getId: () => id, + getTag: () => opts.tag, + getBlockType: () => opts.blockType ?? 'block', + getParentId: () => opts.parentId, + getRawConfig: () => ('raw' in opts ? opts.raw : {}), + }; +} + +describe('computeReachability edge branches', () => { + it('resolves a target given as a raw id (not a tag)', () => { + const result = computeReachability({ + sources: [ + source('a', { raw: { events: [{ target: 'b' }] } }), + source('b', {}), + ], + }); + assert.ok(result.get('a').map((m) => m.code).includes(MSG_REACH_NO_IN)); + assert.ok(result.get('b').map((m) => m.code).includes(MSG_REACH_NO_OUT)); + }); + + it('leaves an unresolvable target reference unconnected', () => { + const result = computeReachability({ + sources: [source('a', { raw: { events: [{ target: 'nope' }] } })], + }); + const codes = result.get('a').map((m) => m.code).sort(); + assert.deepEqual(codes, [MSG_REACH_ISOLATED, MSG_REACH_NO_IN, MSG_REACH_NO_OUT].sort()); + }); + + it('ignores a non-string / blank target reference', () => { + const result = computeReachability({ + sources: [ + source('a', { raw: { events: [{ target: 42 }, { to: ' ' }] } }), + ], + }); + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + }); + + it('skips explicit-connection processing when the raw config is not an object', () => { + const result = computeReachability({ + sources: [source('a', { raw: null })], + }); + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + }); + + it('reads edges from options.events as well as events', () => { + const result = computeReachability({ + sources: [ + source('a', { tag: 'a-tag', raw: { options: { events: [{ target: 'b-tag' }] } } }), + source('b', { tag: 'b-tag' }), + ], + }); + assert.ok(result.get('a').map((m) => m.code).includes(MSG_REACH_NO_IN)); + assert.ok(result.get('b').map((m) => m.code).includes(MSG_REACH_NO_OUT)); + }); + + it('does not self-connect when a node targets itself', () => { + const result = computeReachability({ + sources: [source('a', { tag: 'a-tag', raw: { events: [{ target: 'a-tag' }] } })], + }); + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + }); + + it('falls back to getBlockType when raw config has no blockType for implicit edges', () => { + const result = computeReachability({ + sources: [ + source('a', { parentId: 'p', blockType: 'auto', raw: {} }), + source('b', { parentId: 'p', blockType: 'auto', raw: {} }), + ], + blockAboutRegistry: { auto: { defaultEvent: true } }, + }); + assert.ok(result.get('a').map((m) => m.code).includes(MSG_REACH_NO_IN)); + assert.ok(result.get('b').map((m) => m.code).includes(MSG_REACH_NO_OUT)); + }); + + it('treats a blank current block type as having no implicit default event', () => { + const result = computeReachability({ + sources: [ + source('a', { parentId: 'p', blockType: '', raw: {} }), + source('b', { parentId: 'p', blockType: '', raw: {} }), + ], + blockAboutRegistry: { auto: { defaultEvent: true } }, + }); + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + assert.ok(result.get('b').some((m) => m.code === MSG_REACH_ISOLATED)); + }); +}); diff --git a/interfaces/tests/reachability-project-raw-node.test.mjs b/interfaces/tests/reachability-project-raw-node.test.mjs new file mode 100644 index 0000000000..d45fbc1772 --- /dev/null +++ b/interfaces/tests/reachability-project-raw-node.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; + +function assertIncludesMembers(arr, members) { + for (const m of members) assert.ok(arr.includes(m), `expected ${JSON.stringify(arr)} to include ${m}`); +} +import { + projectRawNode, + computeReachability, +} from '../dist/validators/policy-messages/reachability.js'; +import { + MSG_REACH_NO_IN, + MSG_REACH_NO_OUT, + MSG_REACH_ISOLATED, +} from '../dist/validators/policy-messages/types.js'; + +describe('projectRawNode', () => { + it('copies the documented set of fields', () => { + const source = { + id: 'b1', + tag: 't1', + blockType: 'foo', + properties: { stopPropagation: true }, + events: [{ a: 1 }], + options: { events: [{ b: 2 }] }, + uiMetaData: { x: 'y' }, + stopPropagation: true, + extra: 'should-not-appear', + }; + const view = projectRawNode(source); + assert.equal(view.id, 'b1'); + assert.equal(view.tag, 't1'); + assert.equal(view.blockType, 'foo'); + assert.deepEqual(view.properties, { stopPropagation: true }); + assert.equal(view.stopPropagation, true); + assert.equal('extra' in view, false); + }); + + it('coerces stopPropagation to boolean', () => { + const view = projectRawNode({ stopPropagation: 'truthy' }); + assert.equal(view.stopPropagation, true); + + const view2 = projectRawNode({}); + assert.equal(view2.stopPropagation, false); + }); + + it('handles null/undefined source', () => { + const view = projectRawNode(null); + assert.equal(view.id, undefined); + assert.equal(view.stopPropagation, false); + }); +}); + +describe('computeReachability', () => { + function source(id, opts = {}) { + return { + getId: () => id, + getTag: () => opts.tag, + getBlockType: () => opts.blockType ?? 'block', + getParentId: () => opts.parentId, + getRawConfig: () => opts.raw ?? {}, + }; + } + + it('returns an empty Map when context is missing or has no sources', () => { + assert.equal(computeReachability(undefined).size, 0); + assert.equal(computeReachability({ sources: [] }).size, 0); + }); + + it('flags an isolated block (no inbound, no outbound)', () => { + const result = computeReachability({ sources: [source('b1')] }); + const msgs = result.get('b1'); + const codes = msgs.map((m) => m.code).sort(); + assert.deepEqual(codes, [MSG_REACH_ISOLATED, MSG_REACH_NO_IN, MSG_REACH_NO_OUT].sort()); + }); + + it('records explicit edges via events[].target (by tag)', () => { + const result = computeReachability({ + sources: [ + source('a', { tag: 'a-tag', raw: { events: [{ target: 'b-tag' }] } }), + source('b', { tag: 'b-tag' }), + ], + }); + const aMsgs = result.get('a').map((m) => m.code); + const bMsgs = result.get('b').map((m) => m.code); + // a has outbound but no inbound → only NO_IN. + assertIncludesMembers(aMsgs, [MSG_REACH_NO_IN]); + // b has inbound but no outbound → only NO_OUT. + assertIncludesMembers(bMsgs, [MSG_REACH_NO_OUT]); + }); + + it('records implicit defaultEvent → next sibling edges via the blockAbout registry', () => { + const result = computeReachability({ + sources: [ + source('a', { parentId: 'p', blockType: 'auto', raw: { blockType: 'auto' } }), + source('b', { parentId: 'p', blockType: 'auto', raw: { blockType: 'auto' } }), + ], + blockAboutRegistry: { auto: { defaultEvent: true } }, + }); + const aMsgs = result.get('a').map((m) => m.code); + const bMsgs = result.get('b').map((m) => m.code); + // a has outbound (default → b) but no inbound → NO_IN only. + assertIncludesMembers(aMsgs, [MSG_REACH_NO_IN]); + // b has inbound (from a) but no outbound → NO_OUT only. + assertIncludesMembers(bMsgs, [MSG_REACH_NO_OUT]); + }); + + it('honours stopPropagation=true to disable implicit edges', () => { + const result = computeReachability({ + sources: [ + source('a', { parentId: 'p', blockType: 'auto', raw: { blockType: 'auto', properties: { stopPropagation: true } } }), + source('b', { parentId: 'p', blockType: 'auto', raw: { blockType: 'auto' } }), + ], + blockAboutRegistry: { auto: { defaultEvent: true } }, + }); + // Both isolated. + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + assert.ok(result.get('b').some((m) => m.code === MSG_REACH_ISOLATED)); + }); + + it('skips disabled events', () => { + const result = computeReachability({ + sources: [ + source('a', { tag: 'a-tag', raw: { events: [{ target: 'b-tag', disabled: true }] } }), + source('b', { tag: 'b-tag' }), + ], + }); + // Both isolated since the only edge was disabled. + assert.ok(result.get('a').some((m) => m.code === MSG_REACH_ISOLATED)); + assert.ok(result.get('b').some((m) => m.code === MSG_REACH_ISOLATED)); + }); +}); diff --git a/interfaces/tests/remove-object-properties-edge.test.mjs b/interfaces/tests/remove-object-properties-edge.test.mjs new file mode 100644 index 0000000000..c263187db7 --- /dev/null +++ b/interfaces/tests/remove-object-properties-edge.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { removeObjectProperties } from '../dist/helpers/remove-object-properties.js'; + +describe('removeObjectProperties — edge & quirks', () => { + it('returns the same object reference (mutates in place)', () => { + const obj = { drop: 1, keep: 2 }; + assert.equal(removeObjectProperties(['drop'], obj), obj); + }); + + it('leaves the object unchanged for an empty properties list', () => { + const obj = { a: 1, b: { c: 2 } }; + const result = removeObjectProperties([], obj); + assert.equal(result, obj); + assert.deepEqual(obj, { a: 1, b: { c: 2 } }); + }); + + it('is a no-op when the property does not exist', () => { + const obj = { a: 1 }; + removeObjectProperties(['missing'], obj); + assert.deepEqual(obj, { a: 1 }); + }); + + it('walks arrays nested inside objects', () => { + const obj = { list: [{ drop: 1, k: 2 }, { drop: 3, k: 4 }] }; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, { list: [{ k: 2 }, { k: 4 }] }); + }); + + it('descends multiple levels deep', () => { + const obj = { a: { b: { c: { drop: 1, keep: 2 } } } }; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, { a: { b: { c: { keep: 2 } } } }); + }); + + it('does not throw on null-valued nested fields', () => { + const obj = { keep: 1, nested: null, drop: 2 }; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, { keep: 1, nested: null }); + }); + + it('does not corrupt Date instances while recursing through them', () => { + const when = new Date(0); + const obj = { when, drop: 1 }; + removeObjectProperties(['drop'], obj); + assert.ok(obj.when instanceof Date); + assert.equal(obj.when.getTime(), 0); + }); + + it('removes properties keyed by Unicode names', () => { + const obj = { 'café': 1, drop: 2 }; + removeObjectProperties(['café'], obj); + assert.deepEqual(obj, { drop: 2 }); + }); + + it('returns primitive arguments unchanged', () => { + assert.equal(removeObjectProperties(['x'], 5), 5); + assert.equal(removeObjectProperties(['x'], 'hi'), 'hi'); + assert.equal(removeObjectProperties(['x'], true), true); + }); + + it('overflows the stack on a circular reference (no cycle guard)', () => { + const obj = { a: 1 }; + obj.self = obj; + assert.throws(() => removeObjectProperties(['a'], obj), RangeError); + }); +}); diff --git a/interfaces/tests/remove-object-properties.test.mjs b/interfaces/tests/remove-object-properties.test.mjs new file mode 100644 index 0000000000..410d792b2b --- /dev/null +++ b/interfaces/tests/remove-object-properties.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { removeObjectProperties } from '../dist/helpers/remove-object-properties.js'; + +describe('removeObjectProperties', () => { + it('removes top-level properties', () => { + const obj = { keep: 1, drop: 2 }; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, { keep: 1 }); + }); + + it('removes properties recursively from nested objects', () => { + const obj = { keep: 1, child: { keep: 'a', drop: 'b' } }; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, { keep: 1, child: { keep: 'a' } }); + }); + + it('walks arrays of objects', () => { + const obj = [{ drop: 'x', keep: 1 }, { drop: 'y', keep: 2 }]; + removeObjectProperties(['drop'], obj); + assert.deepEqual(obj, [{ keep: 1 }, { keep: 2 }]); + }); + + it('returns the input untouched when properties is not an array', () => { + const obj = { drop: 1 }; + const result = removeObjectProperties('drop', obj); + assert.deepEqual(result, { drop: 1 }); + }); + + it('returns the input when obj is null/undefined', () => { + assert.equal(removeObjectProperties(['x'], null), null); + assert.equal(removeObjectProperties(['x'], undefined), undefined); + }); + + it('removes multiple properties in one pass', () => { + const obj = { a: 1, b: 2, c: 3 }; + removeObjectProperties(['a', 'c'], obj); + assert.deepEqual(obj, { b: 2 }); + }); +}); diff --git a/interfaces/tests/rule-document-validator-edge.test.mjs b/interfaces/tests/rule-document-validator-edge.test.mjs new file mode 100644 index 0000000000..3be2a00c9c --- /dev/null +++ b/interfaces/tests/rule-document-validator-edge.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { DocumentValidator } from '../dist/validators/rule-validator/document-validator.js'; + +describe('DocumentValidator constructor fallbacks', () => { + it('uses an empty rules object when data.rules is missing', () => { + const v = new DocumentValidator({}); + assert.equal(v.name, undefined); + assert.equal(v.description, undefined); + assert.equal(v.schemas.size, 0); + assert.equal(v.relationships.size, 0); + assert.equal(v.validators.variables.length, 0); + }); + + it('uses an empty config when rules has no config', () => { + const v = new DocumentValidator({ rules: { name: 'R', description: 'd' } }); + assert.equal(v.name, 'R'); + assert.equal(v.description, 'd'); + assert.equal(v.validators.variables.length, 0); + assert.equal(v.schemas.size, 0); + }); + + it('uses an empty relationships list when data.relationships is missing', () => { + const v = new DocumentValidator({ rules: { config: { fields: [] } } }); + assert.equal(v.relationships.size, 0); + }); + + it('treats null data fields the same as missing ones', () => { + const v = new DocumentValidator({ rules: null, relationships: null }); + assert.equal(v.relationships.size, 0); + assert.equal(v.schemas.size, 0); + }); +}); diff --git a/interfaces/tests/rule-document-validators-extra.test.mjs b/interfaces/tests/rule-document-validators-extra.test.mjs new file mode 100644 index 0000000000..2cd73aaadf --- /dev/null +++ b/interfaces/tests/rule-document-validators-extra.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { RuleValidator } from '../dist/validators/rule-validator/rule-validator.js'; +import { DocumentValidator } from '../dist/validators/rule-validator/document-validator.js'; +import { DocumentValidators } from '../dist/validators/rule-validator/document-validators.js'; +import { FieldRuleResult } from '../dist/validators/rule-validator/interfaces/status.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +const engine = (fn) => FormulaEngine.setMathEngine({ evaluate: fn }); + +after(() => { + FormulaEngine.setMathEngine({ evaluate: (e) => e }); +}); + +describe('RuleValidator.parseCondition branches', () => { + const v = new RuleValidator('id', null); + + it('returns an empty string for a falsy condition', () => { + assert.equal(v.parseCondition(null), ''); + assert.equal(v.parseCondition(undefined), ''); + }); + + it('returns the raw formula for a formula condition', () => { + assert.equal(v.parseCondition({ type: 'formula', formula: 'a + b' }), 'a + b'); + }); + + it('builds a range expression', () => { + assert.equal(v.parseCondition({ type: 'range', min: 1, variable: 'x', max: 9 }), '1 <= x <= 9'); + }); + + it('builds a text equality expression', () => { + assert.equal(v.parseCondition({ type: 'text', variable: 'x', value: 'a' }), "x == 'a'"); + }); + + it('joins enum values with or', () => { + assert.equal( + v.parseCondition({ type: 'enum', variable: 'x', value: ['a', 'b'] }), + "x == 'a' or x == 'b'", + ); + }); + + it('returns an empty string for an unknown condition type', () => { + assert.equal(v.parseCondition({ type: 'mystery' }), ''); + }); +}); + +describe('RuleValidator.calculate exception handling', () => { + it('maps an evaluation throw to Error', () => { + FormulaEngine.setMathEngine(null); + const v = new RuleValidator('id', { type: 'formula', formula: 'x' }); + assert.equal(v.validate({}), FieldRuleResult.Error); + engine((e) => e); + }); +}); + +describe('DocumentValidator.validate value resolution', () => { + function makeValidator(relationships) { + return new DocumentValidator({ + rules: { + name: 'R', + description: 'd', + config: { + fields: [{ id: 'f1', rule: { type: 'formula', formula: 'F1' }, path: 'amount', schemaId: 'schema#1' }], + }, + }, + relationships: relationships || [], + }); + } + + it('falls back to relationship documents when the list misses a path', () => { + let seen; + engine((expr, scope) => { seen = scope; return 1; }); + const v = makeValidator([{ schema: 'schema#1', document: { credentialSubject: { amount: 9 } } }]); + const result = v.validate('schema#1', new Map()); + assert.equal(seen.f1, 9); + assert.equal(result['schema#1/amount'], FieldRuleResult.Success); + }); + + it('scores null when neither the list nor relationships hold the path', () => { + let seen; + engine((expr, scope) => { seen = scope; return 1; }); + const v = makeValidator([]); + v.validate('schema#1', new Map()); + assert.equal(seen.f1, null); + }); + + it('prefers the supplied list over relationships', () => { + let seen; + engine((expr, scope) => { seen = scope; return 1; }); + const v = makeValidator([{ schema: 'schema#1', document: { credentialSubject: { amount: 9 } } }]); + v.validate('schema#1', new Map([['schema#1/amount', 4]])); + assert.equal(seen.f1, 4); + }); +}); + +describe('DocumentValidators.validateForm / validateVC guards', () => { + const data = (formula) => [{ + rules: { + name: 'R', + description: 'd', + config: { fields: [{ id: 'f1', rule: { type: 'formula', formula }, path: 'amount', schemaId: 'schema#1' }] }, + }, + relationships: [], + }]; + + it('validateForm returns null when no validators are configured', () => { + const d = new DocumentValidators([]); + assert.equal(d.validateForm('schema#1', { amount: 1 }), null); + }); + + it('validateForm returns null for an unknown or missing iri', () => { + const d = new DocumentValidators(data('F')); + assert.equal(d.validateForm('other#1', { amount: 1 }), null); + assert.equal(d.validateForm(undefined, { amount: 1 }), null); + }); + + it('validateForm produces a status per validated path', () => { + engine(() => 1); + const d = new DocumentValidators(data('F')); + const result = d.validateForm('schema#1', { amount: 1 }); + assert.equal(result['schema#1/amount'].status, FieldRuleResult.Success); + assert.equal(result['schema#1/amount'].rules.length, 1); + assert.equal(result['schema#1/amount'].rules[0].name, 'R'); + }); + + it('validateVC unwraps the credential subject before validating', () => { + engine(() => 1); + const d = new DocumentValidators(data('F')); + const result = d.validateVC('schema#1', { credentialSubject: [{ amount: 1 }] }); + assert.equal(result['schema#1/amount'].status, FieldRuleResult.Success); + }); + + it('validateVC returns null when no validators are configured', () => { + const d = new DocumentValidators([]); + assert.equal(d.validateVC('schema#1', { credentialSubject: { amount: 1 } }), null); + }); +}); + +describe('DocumentValidators.validate merging', () => { + const twoValidators = (f1, f2) => new DocumentValidators([ + { + rules: { name: 'A', description: 'a', config: { fields: [{ id: 'x', rule: { type: 'formula', formula: f1 }, path: 'amount', schemaId: 'schema#1' }] } }, + relationships: [], + }, + { + rules: { name: 'B', description: 'b', config: { fields: [{ id: 'y', rule: { type: 'formula', formula: f2 }, path: 'amount', schemaId: 'schema#1' }] } }, + relationships: [], + }, + ]); + + it('appends each validator rule for a shared path', () => { + engine(() => 1); + const d = twoValidators('F1', 'F2'); + const result = d.validateForm('schema#1', { amount: 1 }); + const entry = result['schema#1/amount']; + assert.equal(entry.rules.length, 2); + assert.deepEqual(entry.rules.map((r) => r.name), ['A', 'B']); + }); + + it('a later Failure overrides an earlier Success status', () => { + engine((expr) => (expr === 'F1' ? 1 : 0)); + const d = twoValidators('F1', 'F2'); + const entry = d.validateForm('schema#1', { amount: 1 })['schema#1/amount']; + assert.equal(entry.status, FieldRuleResult.Failure); + assert.equal(entry.rules[0].status, FieldRuleResult.Success); + assert.equal(entry.rules[1].status, FieldRuleResult.Failure); + }); + + it('a later Success keeps the earlier status', () => { + engine((expr) => (expr === 'F1' ? 0 : 1)); + const d = twoValidators('F1', 'F2'); + const entry = d.validateForm('schema#1', { amount: 1 })['schema#1/amount']; + assert.equal(entry.status, FieldRuleResult.Failure); + }); + + it('a later Error overrides an earlier Success status', () => { + engine((expr) => (expr === 'F1' ? 1 : '')); + const d = twoValidators('F1', 'F2'); + const entry = d.validateForm('schema#1', { amount: 1 })['schema#1/amount']; + assert.equal(entry.status, FieldRuleResult.Error); + }); + + it('collects schema iris from every validator', () => { + const d = twoValidators('F1', 'F2'); + assert.ok(d.schemas.has('schema#1')); + assert.equal(d.validators.length, 2); + }); +}); diff --git a/interfaces/tests/rule-item-validator-deep-suite.test.mjs b/interfaces/tests/rule-item-validator-deep-suite.test.mjs new file mode 100644 index 0000000000..9ab95c0d0d --- /dev/null +++ b/interfaces/tests/rule-item-validator-deep-suite.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import { RuleItemValidator } from '../dist/validators/label-validator/item-rule-validator.js'; +import { ValidateNamespace } from '../dist/validators/label-validator/namespace.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +function engine(map) { + FormulaEngine.setMathEngine({ evaluate: (expr) => (expr in map ? map[expr] : expr) }); +} + +const docNamespace = (amount) => + new ValidateNamespace('root', [{ schema: 's#1', document: { credentialSubject: { amount } } }]); + +function makeValidator(over = {}) { + return new RuleItemValidator({ + id: 'r1', name: 'Rule', title: 'Rule title', tag: 'R1', schemaId: 's#1', + config: { + variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }], + scores: [{ id: 'sc1', type: 't', description: 'd', relationships: ['v1'], options: [{ description: 'Low', value: 1 }] }], + formulas: [{ id: 'f1', type: 'number', formula: 'fx', rule: { type: 'formula', formula: 'COND' } }], + ...over + } + }); +} + +describe('RuleItemValidator — data flow', () => { + it('updateVariables pulls field values from the namespace into the scope', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.updateVariables(); + assert.equal(v.getScope().getScore().v1, 5); + }); + + it('validate passes when no formula condition fails', () => { + engine({ COND: 1 }); + const v = makeValidator(); + v.setData(docNamespace(5)); + const r = v.validate(); + assert.equal(r.valid, true); + assert.equal(v.status, true); + }); + + it('validate fails with "Invalid condition" when a formula evaluates to Failure', () => { + engine({ COND: 0 }); + const v = makeValidator(); + v.setData(docNamespace(5)); + const r = v.validate(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid condition'); + }); + + it('validate fails with "Invalid condition" when a formula evaluates to Error', () => { + engine({ COND: '' }); + const v = makeValidator(); + v.setData(docNamespace(5)); + assert.equal(v.validate().valid, false); + }); + + it('validate short-circuits when a prior Invalid document status is set', () => { + engine({ COND: 1 }); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.setResult(null); + const r = v.validate(); + assert.equal(r.error, 'Invalid document'); + }); +}); + +describe('RuleItemValidator — validateVariables', () => { + it('passes after updateVariables aligns stored values with the document', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.updateVariables(); + assert.equal(v.validateVariables().valid, true); + }); + + it('fails with "Invalid variable" when stored value differs from the field', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + const r = v.validateVariables(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid variable'); + }); +}); + +describe('RuleItemValidator — getResult / setResult round-trip', () => { + it('setResult(null) marks the item invalid', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.setResult(null); + assert.equal(v.status, false); + assert.equal(v.getStatus().error, 'Invalid document'); + }); + + it('setResult loads variable/score/formula values and derives status', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.setResult({ status: true, v1: 9, f1: 42 }); + const result = v.getResult(); + assert.equal(result.status, true); + assert.equal(result.v1, 9); + assert.equal(result.f1, 42); + }); + + it('getResult omits values that are undefined', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.setResult({ status: false }); + const result = v.getResult(); + assert.equal(result.status, false); + assert.equal('v1' in result, false); + }); + + it('getVC wraps id, schema and the result document', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.validate(); + const vc = v.getVC(); + assert.equal(vc.id, 'r1'); + assert.equal(vc.schema, 's#1'); + assert.equal(typeof vc.document, 'object'); + }); + + it('setVC delegates to setResult and returns true', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + assert.equal(v.setVC({ status: true, v1: 3 }), true); + assert.equal(v.status, true); + }); + + it('clear resets the status', () => { + engine({ COND: 1 }); + const v = makeValidator(); + v.setData(docNamespace(5)); + v.validate(); + v.clear(); + assert.equal(v.status, undefined); + }); +}); + +describe('RuleItemValidator — getSteps', () => { + it('emits Overview/Scores/Statistics substeps plus a final validate step', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + const steps = v.getSteps(); + assert.equal(steps.length, 4); + assert.deepEqual(steps.map((s) => s.type), ['variables', 'scores', 'formulas', 'validate']); + assert.equal(steps[3].auto, true); + }); + + it('the final validate step is the only one when there is no config', () => { + engine({}); + const v = new RuleItemValidator({ id: 'r1', tag: 'R1' }); + v.setData(docNamespace(5)); + const steps = v.getSteps(); + assert.equal(steps.length, 1); + assert.equal(steps[0].type, 'validate'); + }); + + it('each substep marks its own subIndex as selected', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(5)); + const steps = v.getSteps(); + const overview = steps.find((s) => s.type === 'variables'); + const selected = overview.subIndexes.find((i) => i.selected); + assert.equal(selected.name, 'Overview'); + }); +}); diff --git a/interfaces/tests/rule-validator-suite.test.mjs b/interfaces/tests/rule-validator-suite.test.mjs new file mode 100644 index 0000000000..eea418de1e --- /dev/null +++ b/interfaces/tests/rule-validator-suite.test.mjs @@ -0,0 +1,482 @@ +import assert from 'node:assert/strict'; +import { RuleValidator } from '../dist/validators/rule-validator/rule-validator.js'; +import { FieldValidator } from '../dist/validators/rule-validator/field-validator.js'; +import { DocumentFieldVariable } from '../dist/validators/rule-validator/document-field-validator.js'; +import { DocumentFieldValidators } from '../dist/validators/rule-validator/document-field-validators.js'; +import { DocumentValidator } from '../dist/validators/rule-validator/document-validator.js'; +import { DocumentValidators } from '../dist/validators/rule-validator/document-validators.js'; +import { FieldRuleResult } from '../dist/validators/rule-validator/interfaces/status.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +function stubEngine(handler) { + const calls = []; + const engine = { + calls, + evaluate(expr, scope) { + calls.push({ expr, scope }); + return handler ? handler(expr, scope) : expr; + } + }; + return engine; +} + +describe('RuleValidator', () => { + it('a null rule produces no formula type and validates to None', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const v = new RuleValidator('id1', null); + assert.equal(v.validate({}), FieldRuleResult.None); + }); + + it('an unknown rule type validates to None', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const v = new RuleValidator('id1', { type: 'mystery' }); + assert.equal(v.validate({}), FieldRuleResult.None); + }); + + it('exposes id and rule as readonly fields', () => { + const rule = { type: 'formula', formula: 'a' }; + const v = new RuleValidator('the-id', rule); + assert.equal(v.id, 'the-id'); + assert.equal(v.rule, rule); + }); + + describe('formula type → result mapping', () => { + const cases = [ + ['truthy number', 5, FieldRuleResult.Success], + ['truthy string', 'ok', FieldRuleResult.Success], + ['truthy object', { a: 1 }, FieldRuleResult.Success], + ['number 0', 0, FieldRuleResult.Failure], + ['boolean false', false, FieldRuleResult.Failure], + ['string "0"', '0', FieldRuleResult.Failure], + ['string "false"', 'false', FieldRuleResult.Failure], + ['empty string', '', FieldRuleResult.Error], + ['"Incorrect formula"', 'Incorrect formula', FieldRuleResult.Error], + ['null result', null, FieldRuleResult.Error], + ['undefined result', undefined, FieldRuleResult.Error], + ]; + for (const [label, ret, expected] of cases) { + it(`maps ${label} → ${expected}`, () => { + FormulaEngine.setMathEngine(stubEngine(() => ret)); + const v = new RuleValidator('x', { type: 'formula', formula: 'expr' }); + assert.equal(v.validate({ a: 1 }), expected); + }); + } + + it('maps an engine exception → Error', () => { + FormulaEngine.setMathEngine(stubEngine(() => { throw new Error('boom'); })); + const v = new RuleValidator('x', { type: 'formula', formula: 'expr' }); + assert.equal(v.validate({}), FieldRuleResult.Error); + }); + + it('an empty formula short-circuits to None without calling the engine', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { type: 'formula', formula: '' }); + assert.equal(v.validate({}), FieldRuleResult.None); + assert.equal(eng.calls.length, 0); + }); + + it('forwards the configured formula and scope to the engine', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { type: 'formula', formula: 'a + b' }); + v.validate({ a: 2, b: 3 }); + assert.equal(eng.calls[0].expr, 'a + b'); + assert.deepEqual(eng.calls[0].scope, { a: 2, b: 3 }); + }); + }); + + describe('range type', () => { + it('builds a "min <= id <= max" expression using the validator id', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('temperature', { type: 'range', min: 1, max: 10 }); + v.validate({ temperature: 5 }); + assert.equal(eng.calls[0].expr, '1 <= temperature <= 10'); + }); + + it('maps a truthy range evaluation to Success', () => { + FormulaEngine.setMathEngine(stubEngine(() => true)); + const v = new RuleValidator('t', { type: 'range', min: 0, max: 100 }); + assert.equal(v.validate({ t: 50 }), FieldRuleResult.Success); + }); + + it('maps a false range evaluation to Failure', () => { + FormulaEngine.setMathEngine(stubEngine(() => false)); + const v = new RuleValidator('t', { type: 'range', min: 0, max: 100 }); + assert.equal(v.validate({ t: 500 }), FieldRuleResult.Failure); + }); + }); + + describe('condition type', () => { + it('an empty conditions list validates to None', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const v = new RuleValidator('x', { type: 'condition', conditions: [] }); + assert.equal(v.validate({}), FieldRuleResult.None); + }); + + it('runs the "then" branch when the "if" condition succeeds', () => { + const eng = stubEngine((expr) => (expr === 'IF' ? 1 : 1)); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'formula', formula: 'IF' }, + formula: { type: 'formula', formula: 'THEN' } + }] + }); + assert.equal(v.validate({}), FieldRuleResult.Success); + assert.deepEqual(eng.calls.map((c) => c.expr), ['IF', 'THEN']); + }); + + it('returns Error immediately when the "if" condition errors', () => { + const eng = stubEngine((expr) => (expr === 'IF' ? '' : 1)); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'formula', formula: 'IF' }, + formula: { type: 'formula', formula: 'THEN' } + }] + }); + assert.equal(v.validate({}), FieldRuleResult.Error); + assert.equal(eng.calls.length, 1); + }); + + it('falls through a failed "if" to the next matching branch', () => { + const eng = stubEngine((expr) => { + if (expr === 'IF1') return false; + if (expr === 'IF2') return 1; + if (expr === 'THEN2') return 7; + return 1; + }); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [ + { type: 'if', condition: { type: 'formula', formula: 'IF1' }, formula: { type: 'formula', formula: 'THEN1' } }, + { type: 'if', condition: { type: 'formula', formula: 'IF2' }, formula: { type: 'formula', formula: 'THEN2' } } + ] + }); + assert.equal(v.validate({}), FieldRuleResult.Success); + }); + + it('an "else" branch is treated as an unconditional success path', () => { + const eng = stubEngine((expr) => { + if (expr === 'IF1') return false; + if (expr === 'ELSE') return 1; + return 1; + }); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [ + { type: 'if', condition: { type: 'formula', formula: 'IF1' }, formula: { type: 'formula', formula: 'THEN1' } }, + { type: 'else', formula: { type: 'formula', formula: 'ELSE' } } + ] + }); + assert.equal(v.validate({}), FieldRuleResult.Success); + }); + + it('builds a text condition as "variable == \'value\'"', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'text', variable: 'status', value: 'OPEN' }, + formula: { type: 'formula', formula: 'T' } + }] + }); + v.validate({}); + assert.equal(eng.calls[0].expr, "status == 'OPEN'"); + }); + + it('builds a range condition as "min <= variable <= max"', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'range', variable: 'n', min: 2, max: 8 }, + formula: { type: 'formula', formula: 'T' } + }] + }); + v.validate({}); + assert.equal(eng.calls[0].expr, '2 <= n <= 8'); + }); + + it('builds an enum condition as OR-joined equality checks', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'enum', variable: 'c', value: ['A', 'B', 'C'] }, + formula: { type: 'formula', formula: 'T' } + }] + }); + v.validate({}); + assert.equal(eng.calls[0].expr, "c == 'A' or c == 'B' or c == 'C'"); + }); + + it('an enum condition with a non-array value yields an empty expression', () => { + const eng = stubEngine(() => 1); + FormulaEngine.setMathEngine(eng); + const v = new RuleValidator('x', { + type: 'condition', + conditions: [{ + type: 'if', + condition: { type: 'enum', variable: 'c', value: 'A' }, + formula: { type: 'formula', formula: 'T' } + }] + }); + assert.equal(v.validate({}), FieldRuleResult.None); + }); + }); +}); + +describe('FieldValidator', () => { + it('captures path and schemaId from the rule data', () => { + const fv = new FieldValidator({ id: 'r1', rule: null, path: 'a.b', schemaId: 'schema#1' }); + assert.equal(fv.path, 'a.b'); + assert.equal(fv.schemaId, 'schema#1'); + }); + + it('checkField matches by path only when no schema is provided', () => { + const fv = new FieldValidator({ id: 'r1', rule: null, path: 'a.b', schemaId: 's1' }); + assert.equal(fv.checkField('a.b'), true); + assert.equal(fv.checkField('a.c'), false); + }); + + it('checkField requires both path and schema when schema is provided', () => { + const fv = new FieldValidator({ id: 'r1', rule: null, path: 'a.b', schemaId: 's1' }); + assert.equal(fv.checkField('a.b', 's1'), true); + assert.equal(fv.checkField('a.b', 's2'), false); + assert.equal(fv.checkField('a.c', 's1'), false); + }); + + it('inherits RuleValidator validation behaviour', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const fv = new FieldValidator({ id: 'r1', rule: { type: 'formula', formula: 'a' }, path: 'p', schemaId: 's' }); + assert.equal(fv.validate({ a: 1 }), FieldRuleResult.Success); + }); +}); + +describe('DocumentFieldVariable', () => { + it('maps all fields and composes fullPah from schemaId and path', () => { + const v = new DocumentFieldVariable({ + id: 'v1', schemaId: 'schema#1', path: 'a.b', + fieldRef: true, fieldArray: false, + fieldDescription: 'desc', schemaName: 'My Schema' + }); + assert.equal(v.id, 'v1'); + assert.equal(v.schemaId, 'schema#1'); + assert.equal(v.path, 'a.b'); + assert.equal(v.fullPah, 'schema#1/a.b'); + assert.equal(v.fieldRef, true); + assert.equal(v.fieldArray, false); + assert.equal(v.fieldDescription, 'desc'); + assert.equal(v.schemaName, 'My Schema'); + }); +}); + +describe('DocumentFieldValidators', () => { + const rules = [ + { id: 'a', rule: { type: 'formula', formula: 'A' }, path: 'pa', schemaId: 's1' }, + { id: 'b', rule: { type: 'formula', formula: 'B' }, path: 'pb', schemaId: 's1' }, + ]; + + it('an empty/omitted rule list yields empty collections', () => { + const d = new DocumentFieldValidators(); + assert.deepEqual(d.rules, []); + assert.deepEqual(d.variables, []); + assert.equal(d.idToPath.size, 0); + }); + + it('builds a FieldValidator and DocumentFieldVariable per rule', () => { + const d = new DocumentFieldValidators(rules); + assert.equal(d.rules.length, 2); + assert.equal(d.variables.length, 2); + assert.ok(d.rules[0] instanceof FieldValidator); + }); + + it('builds bidirectional id↔fullPath maps', () => { + const d = new DocumentFieldValidators(rules); + assert.equal(d.idToPath.get('a'), 's1/pa'); + assert.equal(d.pathToId.get('s1/pa'), 'a'); + assert.equal(d.idToPath.get('b'), 's1/pb'); + }); + + it('validate keys results by rule id', () => { + FormulaEngine.setMathEngine(stubEngine((expr) => (expr === 'A' ? 1 : 0))); + const d = new DocumentFieldValidators(rules); + const result = d.validate({}); + assert.equal(result.a, FieldRuleResult.Success); + assert.equal(result.b, FieldRuleResult.Failure); + }); + + it('validateWithFullPath keys results by full path', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const d = new DocumentFieldValidators(rules); + const result = d.validateWithFullPath({}); + assert.equal(result['s1/pa'], FieldRuleResult.Success); + assert.equal(result['s1/pb'], FieldRuleResult.Success); + }); +}); + +describe('DocumentValidator static helpers', () => { + it('getCredentialSubject returns the first element of an array subject', () => { + assert.deepEqual( + DocumentValidator.getCredentialSubject({ credentialSubject: [{ a: 1 }, { a: 2 }] }), + { a: 1 } + ); + }); + + it('getCredentialSubject returns a non-array subject as-is', () => { + assert.deepEqual(DocumentValidator.getCredentialSubject({ credentialSubject: { a: 1 } }), { a: 1 }); + }); + + it('getCredentialSubject of undefined document is undefined', () => { + assert.equal(DocumentValidator.getCredentialSubject(undefined), undefined); + }); + + it('convertDocument flattens primitives into dotted paths', () => { + const map = DocumentValidator.convertDocument({ a: 1, b: 'x' }, 's/', new Map()); + assert.equal(map.get('s/a'), 1); + assert.equal(map.get('s/b'), 'x'); + }); + + it('convertDocument recurses into nested objects', () => { + const map = DocumentValidator.convertDocument({ a: { b: { c: 5 } } }, 's/', new Map()); + assert.equal(map.get('s/a.b.c'), 5); + assert.deepEqual(map.get('s/a'), { b: { c: 5 } }); + }); + + it('convertDocument stores arrays but does not recurse into them', () => { + const map = DocumentValidator.convertDocument({ a: [1, 2, 3] }, 's/', new Map()); + assert.deepEqual(map.get('s/a'), [1, 2, 3]); + assert.equal(map.get('s/a.0'), undefined); + }); + + it('convertDocument skips function-valued properties', () => { + const map = DocumentValidator.convertDocument({ a: 1, fn: () => 0 }, 's/', new Map()); + assert.equal(map.has('s/fn'), false); + assert.equal(map.get('s/a'), 1); + }); + + it('convertDocument of a falsy document returns the list unchanged', () => { + const list = new Map([['x', 1]]); + assert.equal(DocumentValidator.convertDocument(null, 's/', list), list); + assert.equal(list.size, 1); + }); +}); + +describe('DocumentValidator instance', () => { + function makeValidator() { + return new DocumentValidator({ + rules: { + name: 'Rule A', + description: 'a rule', + config: { + fields: [ + { id: 'f1', rule: { type: 'formula', formula: 'F1' }, path: 'amount', schemaId: 'schema#1' } + ] + } + }, + relationships: [] + }); + } + + it('collects schema ids from its variables', () => { + const v = makeValidator(); + assert.ok(v.schemas.has('schema#1')); + assert.equal(v.name, 'Rule A'); + assert.equal(v.description, 'a rule'); + }); + + it('validate returns null for an iri not among its schemas', () => { + const v = makeValidator(); + assert.equal(v.validate('other#1', new Map()), null); + }); + + it('validate returns null for an undefined iri', () => { + const v = makeValidator(); + assert.equal(v.validate(undefined, new Map()), null); + }); + + it('validate scores variables from the supplied value map', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const v = makeValidator(); + const list = new Map([['schema#1/amount', 42]]); + const result = v.validate('schema#1', list); + assert.equal(result['schema#1/amount'], FieldRuleResult.Success); + }); + + it('relationships from constructor data are flattened into the lookup map', () => { + const v = new DocumentValidator({ + rules: { config: { fields: [{ id: 'f1', rule: null, path: 'amount', schemaId: 'schema#1' }] } }, + relationships: [{ schema: 'schema#2', document: { credentialSubject: { ref: 9 } } }] + }); + assert.equal(v.relationships.get('schema#2/ref'), 9); + }); +}); + +describe('DocumentValidators', () => { + function build() { + return new DocumentValidators([{ + rules: { + name: 'R', description: 'd', + config: { fields: [{ id: 'f1', rule: { type: 'formula', formula: 'F' }, path: 'amount', schemaId: 'schema#1' }] } + }, + relationships: [] + }]); + } + + it('aggregates schemas across all child validators', () => { + const dv = build(); + assert.ok(dv.schemas.has('schema#1')); + assert.equal(dv.validators.length, 1); + }); + + it('a null config yields zero validators and null validation', () => { + const dv = new DocumentValidators(null); + assert.equal(dv.validators.length, 0); + assert.equal(dv.validateVC('schema#1', {}), null); + }); + + it('validateVC returns null for an iri not in the schema set', () => { + const dv = build(); + assert.equal(dv.validateVC('unknown#1', { credentialSubject: {} }), null); + }); + + it('validateVC flattens the credential subject and produces a status map', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const dv = build(); + const statuses = dv.validateVC('schema#1', { credentialSubject: { amount: 10 } }); + assert.equal(statuses['schema#1/amount'].status, FieldRuleResult.Success); + assert.equal(statuses['schema#1/amount'].rules[0].name, 'R'); + }); + + it('validateForm flattens raw form data and produces a status map', () => { + FormulaEngine.setMathEngine(stubEngine(() => 0)); + const dv = build(); + const statuses = dv.validateForm('schema#1', { amount: 10 }); + assert.equal(statuses['schema#1/amount'].status, FieldRuleResult.Failure); + }); + + it('omits fields whose status is None from the status map', () => { + FormulaEngine.setMathEngine(stubEngine(() => 1)); + const dv = new DocumentValidators([{ + rules: { name: 'R', description: 'd', config: { fields: [{ id: 'f1', rule: null, path: 'amount', schemaId: 'schema#1' }] } }, + relationships: [] + }]); + const statuses = dv.validateForm('schema#1', { amount: 1 }); + assert.deepEqual(statuses, {}); + }); +}); diff --git a/interfaces/tests/schema-engine-model-pipeline.test.mjs b/interfaces/tests/schema-engine-model-pipeline.test.mjs new file mode 100644 index 0000000000..acc42fe06f --- /dev/null +++ b/interfaces/tests/schema-engine-model-pipeline.test.mjs @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +const doc = (over = {}) => ({ + $id: '#u-1&1.0.0', + $comment: '{ "@id": "ctx:#u-1&1.0.0", "term": "u-1&1.0.0" }', + title: 'My Schema', + description: 'A description', + type: 'object', + properties: { + amount: { + type: 'number', + title: 'Amount', + description: 'Amount', + $comment: JSON.stringify({ term: 'amount' }), + }, + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string' }, + $comment: JSON.stringify({ term: 'tags' }), + }, + }, + required: ['amount'], + ...over, +}); + +const schema = (over = {}) => new Schema({ + uuid: 'u-1', + version: '1.0.0', + contextURL: 'ctx:', + iri: '#u-1&1.0.0', + document: doc(), + ...over, +}); + +describe('Schema model — constructor document parsing', () => { + it('parses fields from the document', () => { + const s = schema(); + const names = s.fields.map((f) => f.name).sort(); + assert.deepEqual(names, ['amount', 'tags']); + }); + + it('derives the type from uuid and version', () => { + assert.equal(schema().type, 'u-1&1.0.0'); + }); + + it('reads previousVersion from the $comment', () => { + const s = schema({ document: doc({ $comment: '{ "term": "x", "previousVersion": "0.9.0" }' }) }); + assert.equal(s.previousVersion, '0.9.0'); + }); + + it('leaves previousVersion undefined when not present', () => { + assert.equal(schema().previousVersion, undefined); + }); + + it('marks a required scalar field as required', () => { + const amount = schema().fields.find((f) => f.name === 'amount'); + assert.equal(amount.required, true); + }); + + it('marks a non-required field as not required', () => { + const tags = schema().fields.find((f) => f.name === 'tags'); + assert.equal(tags.required, false); + }); + + it('detects array fields', () => { + const tags = schema().fields.find((f) => f.name === 'tags'); + assert.equal(tags.isArray, true); + }); + + it('defaults entity to NONE and status to DRAFT', () => { + const s = schema(); + assert.equal(s.entity, 'NONE'); + assert.equal(s.status, 'DRAFT'); + }); + + it('generates a uuid when none is supplied', () => { + const s = new Schema(); + assert.ok(typeof s.uuid === 'string' && s.uuid.length > 0); + }); + + it('produces a schema: contextURL for the empty constructor', () => { + const s = new Schema(); + assert.ok(s.contextURL.startsWith('schema:')); + }); +}); + +describe('Schema model — setPaths', () => { + it('sets a flat path equal to the field name', () => { + const amount = schema().fields.find((f) => f.name === 'amount'); + assert.equal(amount.path, 'amount'); + }); + + it('sets fullPath using the iri prefix', () => { + const amount = schema().fields.find((f) => f.name === 'amount'); + assert.equal(amount.fullPath, '#u-1&1.0.0/amount'); + }); + + it('sets dotted child paths for nested refs', () => { + const refDoc = doc({ + properties: { + child: { $ref: '#sub', $comment: JSON.stringify({ term: 'child' }) }, + }, + required: [], + $defs: { '#sub': { properties: { leaf: { type: 'string', $comment: JSON.stringify({ term: 'leaf' }) } } } }, + }); + const s = schema({ document: refDoc }); + const child = s.fields.find((f) => f.name === 'child'); + assert.equal(child.fields[0].path, 'child.leaf'); + }); +}); + +describe('Schema model — setTypes (arrayLvl / fullType)', () => { + it('gives a scalar field arrayLvl 0', () => { + const amount = schema().fields.find((f) => f.name === 'amount'); + assert.equal(amount.arrayLvl, 0); + assert.equal(amount.fullType, 'number'); + }); + + it('gives an array field arrayLvl 1 and a []-suffixed fullType', () => { + const tags = schema().fields.find((f) => f.name === 'tags'); + assert.equal(tags.arrayLvl, 1); + assert.equal(tags.fullType, 'string[]'); + }); + + it('uses object as the fullType base for refs', () => { + const refDoc = doc({ + properties: { child: { $ref: '#sub', $comment: JSON.stringify({ term: 'child' }) } }, + required: [], + $defs: { '#sub': { properties: {} } }, + }); + const s = schema({ document: refDoc }); + const child = s.fields.find((f) => f.name === 'child'); + assert.equal(child.fullType, 'object'); + }); + + it('accumulates arrayLvl across nested array refs', () => { + const refDoc = doc({ + properties: { + rows: { + type: 'array', + items: { $ref: '#sub' }, + $comment: JSON.stringify({ term: 'rows' }), + }, + }, + required: [], + $defs: { '#sub': { properties: { cells: { type: 'array', items: { type: 'string' }, $comment: JSON.stringify({ term: 'cells' }) } } } }, + }); + const s = schema({ document: refDoc }); + const rows = s.fields.find((f) => f.name === 'rows'); + const cells = rows.fields.find((f) => f.name === 'cells'); + assert.equal(rows.arrayLvl, 1); + assert.equal(cells.arrayLvl, 2); + assert.equal(cells.fullType, 'string[][]'); + }); +}); + +describe('Schema model — getDeepFields', () => { + const nestedSchema = () => { + const refDoc = doc({ + properties: { child: { $ref: '#sub', $comment: JSON.stringify({ term: 'child' }) } }, + required: [], + $defs: { '#sub': { properties: { leaf: { type: 'string', $comment: JSON.stringify({ term: 'leaf' }) } } } }, + }); + return schema({ document: refDoc }); + }; + + it('returns one node per top-level field', () => { + const nodes = nestedSchema().getDeepFields(); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].path, 'child'); + }); + + it('nests deep fields under their parent', () => { + const nodes = nestedSchema().getDeepFields(); + assert.equal(nodes[0].fields[0].path, 'child.leaf'); + }); + + it('reports object type for ref nodes', () => { + const nodes = nestedSchema().getDeepFields(); + assert.equal(nodes[0].type, 'object'); + }); + + it('attaches the underlying field to each node', () => { + const nodes = nestedSchema().getDeepFields(); + assert.equal(nodes[0].field.name, 'child'); + }); +}); + +describe('Schema model — getField / getFields / searchFields', () => { + const nestedSchema = () => { + const refDoc = doc({ + properties: { child: { $ref: '#sub', $comment: JSON.stringify({ term: 'child' }) } }, + required: [], + $defs: { '#sub': { properties: { leaf: { type: 'string', $comment: JSON.stringify({ term: 'leaf' }) } } } }, + }); + return schema({ document: refDoc }); + }; + + it('getFields flattens nested fields', () => { + const names = nestedSchema().getFields().map((f) => f.name).sort(); + assert.deepEqual(names, ['child', 'leaf']); + }); + + it('getField resolves a dotted nested path', () => { + const f = nestedSchema().getField('child.leaf'); + assert.equal(f.name, 'leaf'); + }); + + it('getField returns null for an unknown path', () => { + assert.equal(nestedSchema().getField('nope'), null); + }); + + it('searchFields returns fields passing the predicate', () => { + const result = nestedSchema().searchFields((f) => f.name === 'leaf'); + assert.equal(result.length, 1); + assert.equal(result[0].name, 'leaf'); + }); + + it('searchFields returns [] when nothing matches', () => { + assert.deepEqual(nestedSchema().searchFields(() => false), []); + }); +}); + +describe('Schema model — clone & static factories', () => { + it('clone copies identity fields', () => { + const c = schema().clone(); + assert.equal(c.uuid, 'u-1'); + assert.equal(c.version, '1.0.0'); + assert.equal(c.type, 'u-1&1.0.0'); + }); + + it('clone shares the same fields reference', () => { + const s = schema(); + assert.equal(s.clone().fields, s.fields); + }); + + it('Schema.from returns a Schema for valid input', () => { + const s = Schema.from({ uuid: 'x', version: '1.0.0', contextURL: 'ctx:', document: doc() }); + assert.ok(s instanceof Schema); + }); + + it('Schema.fromDocument builds from a bare document', () => { + const s = Schema.fromDocument(doc()); + assert.ok(s instanceof Schema); + assert.ok(s.fields.length > 0); + }); +}); + +describe('Schema model — updateDocument round-trip', () => { + it('rebuilds a document from parsed fields', () => { + const s = schema(); + s.updateDocument(); + assert.ok(s.document.properties.amount); + assert.ok(s.document.properties.tags); + }); + + it('keeps required projection after updateDocument', () => { + const s = schema(); + s.updateDocument(); + assert.ok(s.document.required.includes('amount')); + }); + + it('re-parses to the same field names after updateDocument', () => { + const s = schema(); + s.updateDocument(); + const reparsed = SchemaHelper.parseFields(s.document, 'ctx:', new Map(), null); + const names = reparsed.map((f) => f.name).sort(); + assert.deepEqual(names, ['amount', 'tags']); + }); +}); + +describe('Schema model — setVersion guards', () => { + it('accepts a greater version and records the previous one', () => { + const s = schema(); + s.version = '1.0.0'; + s.setVersion('1.1.0'); + assert.equal(s.version, '1.1.0'); + assert.equal(s.previousVersion, '1.0.0'); + }); + + it('throws on an invalid version format', () => { + assert.throws(() => schema().setVersion('not-a-version'), /Invalid version format/); + }); + + it('throws when the new version is not greater', () => { + const s = schema(); + s.version = '2.0.0'; + assert.throws(() => s.setVersion('1.0.0'), /Version must be greater/); + }); +}); diff --git a/interfaces/tests/schema-engine-roundtrip.test.mjs b/interfaces/tests/schema-engine-roundtrip.test.mjs new file mode 100644 index 0000000000..0620b679a3 --- /dev/null +++ b/interfaces/tests/schema-engine-roundtrip.test.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +const field = (name, over = {}) => ({ + name, + title: name, + description: name, + type: 'string', + required: false, + isArray: false, + isRef: false, + readOnly: false, + ...over, +}); + +const baseSchema = (over = {}) => ({ + uuid: 'u-1', + version: '1.0.0', + name: 'N', + description: 'D', + contextURL: 'ctx:', + ...over, +}); + +const buildOne = (f) => SchemaHelper.buildDocument(baseSchema(), [f], []); +const parseBack = (doc) => SchemaHelper.parseFields(doc, 'ctx:', new Map(), null); + +describe('SchemaHelper.buildField — type matrix', () => { + const types = ['string', 'number', 'integer', 'boolean']; + for (const t of types) { + it(`emits a scalar ${t} field with the type preserved`, () => { + const prop = SchemaHelper.buildField(field('f', { type: t }), 'f', 'ctx:'); + assert.equal(prop.type, t); + assert.equal(prop.readOnly, false); + }); + it(`emits an array of ${t} with items.type`, () => { + const prop = SchemaHelper.buildField(field('f', { type: t, isArray: true }), 'f', 'ctx:'); + assert.equal(prop.type, 'array'); + assert.equal(prop.items.type, t); + }); + } + + it('uses the field name when no title is provided', () => { + const prop = SchemaHelper.buildField(field('myField', { title: null }), 'myField', 'ctx:'); + assert.equal(prop.title, 'myField'); + }); + + it('uses the field name when no description is provided', () => { + const prop = SchemaHelper.buildField(field('myField', { description: null }), 'myField', 'ctx:'); + assert.equal(prop.description, 'myField'); + }); + + it('marks read-only fields', () => { + const prop = SchemaHelper.buildField(field('f', { readOnly: true }), 'f', 'ctx:'); + assert.equal(prop.readOnly, true); + }); + + it('carries examples through when present', () => { + const prop = SchemaHelper.buildField(field('f', { examples: ['a', 'b'] }), 'f', 'ctx:'); + assert.deepEqual(prop.examples, ['a', 'b']); + }); + + it('omits examples when absent', () => { + const prop = SchemaHelper.buildField(field('f'), 'f', 'ctx:'); + assert.equal('examples' in prop, false); + }); + + it('carries a default value through', () => { + const prop = SchemaHelper.buildField(field('f', { default: 7 }), 'f', 'ctx:'); + assert.equal(prop.default, 7); + }); + + it('omits a falsy default', () => { + const prop = SchemaHelper.buildField(field('f', { default: 0 }), 'f', 'ctx:'); + assert.equal('default' in prop, false); + }); + + it('writes a $ref for ref fields', () => { + const prop = SchemaHelper.buildField(field('f', { isRef: true, type: '#child' }), 'f', 'ctx:'); + assert.equal(prop.$ref, '#child'); + assert.equal('type' in prop, false); + }); + + it('writes items.$ref for array ref fields', () => { + const prop = SchemaHelper.buildField(field('f', { isRef: true, isArray: true, type: '#child' }), 'f', 'ctx:'); + assert.equal(prop.type, 'array'); + assert.equal(prop.items.$ref, '#child'); + }); + + it('writes enum / format / pattern for scalar fields', () => { + const prop = SchemaHelper.buildField(field('f', { + enum: ['x', 'y'], + format: 'date', + pattern: '^a', + }), 'f', 'ctx:'); + assert.deepEqual(prop.enum, ['x', 'y']); + assert.equal(prop.format, 'date'); + assert.equal(prop.pattern, '^a'); + }); + + it('writes a remoteLink ref alongside scalar type', () => { + const prop = SchemaHelper.buildField(field('f', { remoteLink: '#remote' }), 'f', 'ctx:'); + assert.equal(prop.$ref, '#remote'); + assert.equal(prop.type, 'string'); + }); + + it('always attaches a $comment', () => { + const prop = SchemaHelper.buildField(field('f'), 'f', 'ctx:'); + assert.ok(typeof prop.$comment === 'string'); + assert.ok(prop.$comment.includes('"term":"f"')); + }); +}); + +describe('SchemaHelper round-trip — scalar field invariants', () => { + const cases = [ + ['string', {}], + ['number', {}], + ['integer', {}], + ['boolean', {}], + ['string', { format: 'date' }], + ['string', { format: 'date-time' }], + ['string', { format: 'time' }], + ['string', { pattern: '^[a-z]+$' }], + ['string', { unit: 'kg' }], + ['string', { unit: 'm', unitSystem: 'postfix' }], + ['string', { customType: 'geo' }], + ]; + for (const [type, over] of cases) { + const label = `${type} ${JSON.stringify(over)}`; + it(`preserves type across build->parse for ${label}`, () => { + const f = field('amount', { type, ...over, required: true }); + const doc = buildOne(f); + const [parsed] = parseBack(doc); + assert.equal(parsed.type, type); + assert.equal(parsed.name, 'amount'); + assert.equal(parsed.required, true); + }); + } + + it('preserves unit metadata across round-trip', () => { + const doc = buildOne(field('w', { unit: 'kg', unitSystem: 'postfix' })); + const [parsed] = parseBack(doc); + assert.equal(parsed.unit, 'kg'); + assert.equal(parsed.unitSystem, 'postfix'); + }); + + it('preserves customType across round-trip', () => { + const doc = buildOne(field('g', { customType: 'geo' })); + const [parsed] = parseBack(doc); + assert.equal(parsed.customType, 'geo'); + }); + + it('preserves pattern across round-trip', () => { + const doc = buildOne(field('p', { pattern: '^x' })); + const [parsed] = parseBack(doc); + assert.equal(parsed.pattern, '^x'); + }); + + it('preserves format across round-trip', () => { + const doc = buildOne(field('d', { format: 'date' })); + const [parsed] = parseBack(doc); + assert.equal(parsed.format, 'date'); + }); + + it('preserves enum across round-trip', () => { + const doc = buildOne(field('e', { enum: ['a', 'b', 'c'] })); + const [parsed] = parseBack(doc); + assert.deepEqual(parsed.enum, ['a', 'b', 'c']); + }); + + it('marks isArray across round-trip', () => { + const doc = buildOne(field('arr', { isArray: true })); + const [parsed] = parseBack(doc); + assert.equal(parsed.isArray, true); + }); +}); + +describe('SchemaHelper round-trip — required projection', () => { + it('lists required fields in document.required', () => { + const doc = SchemaHelper.buildDocument(baseSchema(), [ + field('a', { required: true }), + field('b', { required: false }), + field('c', { required: true }), + ], []); + assert.ok(doc.required.includes('a')); + assert.ok(doc.required.includes('c')); + assert.equal(doc.required.includes('b'), false); + }); + + it('always includes the system @context and type as required', () => { + const doc = SchemaHelper.buildDocument(baseSchema(), [field('a')], []); + assert.ok(doc.required.includes('@context')); + assert.ok(doc.required.includes('type')); + }); + + it('parses back a required field as required:true', () => { + const doc = buildOne(field('x', { required: true })); + const [parsed] = parseBack(doc); + assert.equal(parsed.required, true); + }); + + it('parses back a non-required field as required:false', () => { + const doc = buildOne(field('x', { required: false })); + const [parsed] = parseBack(doc); + assert.equal(parsed.required, false); + }); +}); + +describe('SchemaHelper round-trip — system field injection & skipping', () => { + const doc = () => SchemaHelper.buildDocument(baseSchema(), [field('user')], []); + + it('injects @context, type and id system properties', () => { + const d = doc(); + assert.ok(d.properties['@context']); + assert.ok(d.properties['type']); + assert.ok(d.properties['id']); + }); + + it('marks the id system property read-only', () => { + assert.equal(doc().properties.id.readOnly, true); + }); + + it('marks @context and type read-only', () => { + const d = doc(); + assert.equal(d.properties['@context'].readOnly, true); + assert.equal(d.properties['type'].readOnly, true); + }); + + it('skips read-only system fields when includeSystemProperties is false', () => { + const fields = SchemaHelper.parseFields(doc(), 'ctx:', new Map(), null, false); + const names = fields.map((f) => f.name); + assert.equal(names.includes('id'), false); + assert.equal(names.includes('@context'), false); + assert.equal(names.includes('type'), false); + assert.ok(names.includes('user')); + }); + + it('includes read-only system fields when includeSystemProperties is true', () => { + const fields = SchemaHelper.parseFields(doc(), 'ctx:', new Map(), null, true); + const names = fields.map((f) => f.name); + assert.ok(names.includes('id')); + assert.ok(names.includes('user')); + }); + + it('sets additionalProperties:false on built documents', () => { + assert.equal(doc().additionalProperties, false); + }); + + it('builds an $id and $comment header', () => { + const d = doc(); + assert.equal(d.$id, '#u-1&1.0.0'); + assert.ok(d.$comment.includes('"term": "u-1&1.0.0"')); + }); + + it('uses schema name/description as document title/description', () => { + const d = SchemaHelper.buildDocument(baseSchema({ name: 'Title!', description: 'Desc!' }), [field('a')], []); + assert.equal(d.title, 'Title!'); + assert.equal(d.description, 'Desc!'); + }); +}); + +describe('SchemaHelper.getFieldsFromObject — via buildDocument', () => { + it('throws when a field name contains spaces', () => { + assert.throws( + () => SchemaHelper.buildDocument(baseSchema(), [field('bad name')], []), + /must not contain spaces/, + ); + }); + + it('keeps the first occurrence on duplicate field names', () => { + const d = SchemaHelper.buildDocument(baseSchema(), [ + field('dup', { title: 'first' }), + field('dup', { title: 'second' }), + ], []); + assert.ok(d.properties.dup.$comment.includes('"term":"dup"')); + }); + + it('does not overwrite the system id property with a user field', () => { + const d = SchemaHelper.buildDocument(baseSchema(), [field('id', { type: 'number' })], []); + assert.equal(d.properties.id.readOnly, true); + }); +}); + +describe('SchemaHelper.parseFields — ordering by orderPosition', () => { + const docWithOrders = () => ({ + properties: { + a: { type: 'string', title: 'a', $comment: JSON.stringify({ term: 'a', orderPosition: 3 }) }, + b: { type: 'string', title: 'b', $comment: JSON.stringify({ term: 'b', orderPosition: 1 }) }, + c: { type: 'string', title: 'c', $comment: JSON.stringify({ term: 'c', orderPosition: 2 }) }, + z: { type: 'string', title: 'z' }, + }, + required: [], + }); + + it('places unordered fields before ordered ones', () => { + const fields = parseBack(docWithOrders()); + assert.equal(fields[0].name, 'z'); + }); + + it('sorts ordered fields by orderPosition ascending', () => { + const fields = parseBack(docWithOrders()); + const ordered = fields.filter((f) => f.order >= 1).map((f) => f.name); + assert.deepEqual(ordered, ['b', 'c', 'a']); + }); + + it('assigns order -1 to fields without an orderPosition', () => { + const fields = parseBack(docWithOrders()); + const z = fields.find((f) => f.name === 'z'); + assert.equal(z.order, -1); + }); + + it('returns [] for a document without properties', () => { + assert.deepEqual(SchemaHelper.parseFields({}, 'ctx:', new Map(), null), []); + }); + + it('returns [] for a null document', () => { + assert.deepEqual(SchemaHelper.parseFields(null, 'ctx:', new Map(), null), []); + }); +}); + +describe('SchemaHelper.parseFields — nested $ref chains', () => { + const docChain = () => ({ + properties: { root: { $ref: '#L1' } }, + required: ['root'], + $defs: { + '#L1': { properties: { mid: { $ref: '#L2' } }, required: [] }, + '#L2': { properties: { leaf: { type: 'string' } }, required: ['leaf'] }, + }, + }); + + it('resolves a two-level ref chain', () => { + const fields = parseBack(docChain()); + assert.equal(fields[0].name, 'root'); + assert.equal(fields[0].isRef, true); + assert.equal(fields[0].fields[0].name, 'mid'); + assert.equal(fields[0].fields[0].fields[0].name, 'leaf'); + }); + + it('marks the deepest required field', () => { + const fields = parseBack(docChain()); + assert.equal(fields[0].fields[0].fields[0].required, true); + }); + + it('caches each ref level by type', () => { + const cache = new Map(); + SchemaHelper.parseFields(docChain(), 'ctx:', cache, null); + assert.ok(cache.has('#L1')); + assert.ok(cache.has('#L2')); + }); + + it('reuses cached parse for repeated refs in the same document', () => { + const doc = { + properties: { a: { $ref: '#S' }, b: { $ref: '#S' } }, + required: [], + $defs: { '#S': { properties: { v: { type: 'string' } }, required: [] } }, + }; + const cache = new Map(); + const fields = SchemaHelper.parseFields(doc, 'ctx:', cache, null); + assert.equal(fields[0].fields[0].name, 'v'); + assert.equal(fields[1].fields[0].name, 'v'); + assert.equal(cache.size, 1); + }); + + it('detaches cloned sub-fields between sibling refs', () => { + const doc = { + properties: { a: { $ref: '#S' }, b: { $ref: '#S' } }, + required: [], + $defs: { '#S': { properties: { v: { type: 'string' } }, required: [] } }, + }; + const fields = SchemaHelper.parseFields(doc, 'ctx:', new Map(), null); + fields[0].fields[0].name = 'mutated'; + assert.equal(fields[1].fields[0].name, 'v'); + }); +}); + +describe('SchemaHelper.findRefs / uniqueRefs — via class shapes', () => { + const fakeSchema = (iri, doc, fields) => ({ iri, document: doc, fields }); + + it('returns built-in GeoJSON ref when a field references #GeoJSON', () => { + const target = fakeSchema('#T', {}, [{ isRef: true, type: '#GeoJSON' }]); + const refs = SchemaHelper.findRefs(target, []); + assert.ok(refs['#GeoJSON']); + }); + + it('returns built-in SentinelHUB ref when referenced', () => { + const target = fakeSchema('#T', {}, [{ isRef: true, type: '#SentinelHUB' }]); + const refs = SchemaHelper.findRefs(target, []); + assert.ok(refs['#SentinelHUB']); + }); + + it('resolves a ref to another schema in the list', () => { + const child = fakeSchema('#child', { properties: { x: { type: 'string' } } }, []); + const target = fakeSchema('#T', {}, [{ isRef: true, type: '#child' }]); + const refs = SchemaHelper.findRefs(target, [child]); + assert.ok(refs['#child']); + assert.deepEqual(refs['#child'].properties, { x: { type: 'string' } }); + }); + + it('ignores non-ref fields', () => { + const target = fakeSchema('#T', {}, [{ isRef: false, type: 'string' }]); + const refs = SchemaHelper.findRefs(target, []); + assert.deepEqual(refs, {}); + }); + + it('ignores ref fields whose type is unknown', () => { + const target = fakeSchema('#T', {}, [{ isRef: true, type: '#missing' }]); + const refs = SchemaHelper.findRefs(target, []); + assert.deepEqual(refs, {}); + }); + + it('flattens nested $defs and strips the $defs key', () => { + const child = fakeSchema('#child', { + properties: { x: { type: 'string' } }, + $defs: { '#grand': { properties: { y: { type: 'string' } } } }, + }, []); + const target = fakeSchema('#T', {}, [{ isRef: true, type: '#child' }]); + const refs = SchemaHelper.findRefs(target, [child]); + assert.ok(refs['#child']); + assert.ok(refs['#grand']); + assert.equal('$defs' in refs['#child'], false); + }); +}); diff --git a/interfaces/tests/schema-helper-build.test.mjs b/interfaces/tests/schema-helper-build.test.mjs new file mode 100644 index 0000000000..e9a01f69b3 --- /dev/null +++ b/interfaces/tests/schema-helper-build.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.buildType / buildRef / buildUrl', () => { + it('buildType joins uuid and version with &', () => { + assert.equal(SchemaHelper.buildType('uuid-1', '1.0.0'), 'uuid-1&1.0.0'); + }); + + it('buildType returns just uuid when no version', () => { + assert.equal(SchemaHelper.buildType('uuid-1'), 'uuid-1'); + }); + + it('buildRef prepends #', () => { + assert.equal(SchemaHelper.buildRef('uuid-1&1.0.0'), '#uuid-1&1.0.0'); + }); + + it('buildUrl concatenates contextURL and ref, accepting empty parts', () => { + assert.equal(SchemaHelper.buildUrl('https://x', '#abc'), 'https://x#abc'); + assert.equal(SchemaHelper.buildUrl('', '#abc'), '#abc'); + assert.equal(SchemaHelper.buildUrl('https://x', ''), 'https://x'); + assert.equal(SchemaHelper.buildUrl(undefined, undefined), ''); + }); +}); + +describe('SchemaHelper.parseRef', () => { + it('parses iri / type / uuid / version from a string ref', () => { + const result = SchemaHelper.parseRef('https://x#uuid-1&1.0.0'); + assert.equal(result.iri, 'https://x#uuid-1&1.0.0'); + assert.equal(result.type, 'uuid-1&1.0.0'); + assert.equal(result.uuid, 'uuid-1'); + assert.equal(result.version, '1.0.0'); + }); + + it('treats missing version segment as null', () => { + const result = SchemaHelper.parseRef('https://x#uuid-only'); + assert.equal(result.uuid, 'uuid-only'); + assert.equal(result.version, null); + }); + + it('returns all-null on empty string', () => { + const result = SchemaHelper.parseRef(''); + assert.equal(result.iri, null); + assert.equal(result.uuid, null); + assert.equal(result.version, null); + }); + + it('parses an ISchema-like object via its document.$id', () => { + const result = SchemaHelper.parseRef({ + document: { $id: 'https://x#uuid-1&2.0.0' }, + }); + assert.equal(result.uuid, 'uuid-1'); + assert.equal(result.version, '2.0.0'); + }); + + it('parses a JSON-string document', () => { + const result = SchemaHelper.parseRef({ + document: JSON.stringify({ $id: 'https://x#uuid-1&3.0.0' }), + }); + assert.equal(result.version, '3.0.0'); + }); + + it('returns all-null on malformed input rather than throwing', () => { + const result = SchemaHelper.parseRef({ document: 'not-json' }); + assert.equal(result.iri, null); + }); +}); + +describe('SchemaHelper.incrementVersion', () => { + it("returns '1.0.0' when there is no prior version and no others (the implementation defaults previousVersion to '1.0.0' but ALSO pushes the empty original onto versions, so map['1.0'] tracks the 0 from the default and the next bump still lands on 1.0.0)", () => { + // versions=[''] is filtered out (continue on falsy), then the function + // sets previousVersion='1.0.0' AFTER the loop. map['1.0'] is undefined + // → next = '1.0.' + ((-1)+1) = '1.0.0'. + assert.equal(SchemaHelper.incrementVersion('', []), '1.0.0'); + }); + + it('increments past the largest known patch in the same major.minor', () => { + const next = SchemaHelper.incrementVersion('1.0.0', ['1.0.1', '1.0.5', '2.0.0']); + assert.equal(next, '1.0.6'); + }); + + it('starts a fresh minor at .0 when no other versions share the prefix', () => { + const next = SchemaHelper.incrementVersion('1.5.0', []); + assert.equal(next, '1.5.1'); + }); + + it('skips empty entries in versions list', () => { + const next = SchemaHelper.incrementVersion('1.0.0', ['', null, undefined, '1.0.3']); + assert.equal(next, '1.0.4'); + }); +}); diff --git a/interfaces/tests/schema-helper-comment.test.mjs b/interfaces/tests/schema-helper-comment.test.mjs new file mode 100644 index 0000000000..6643430876 --- /dev/null +++ b/interfaces/tests/schema-helper-comment.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.buildSchemaComment / parseSchemaComment', () => { + it('builds canonical { @id, term } JSON without version', () => { + const comment = SchemaHelper.buildSchemaComment('uuid-1', 'https://x#uuid-1'); + const parsed = JSON.parse(comment); + assert.equal(parsed['@id'], 'https://x#uuid-1'); + assert.equal(parsed.term, 'uuid-1'); + assert.equal(parsed.previousVersion, undefined); + }); + + it('embeds previousVersion when supplied', () => { + const comment = SchemaHelper.buildSchemaComment('uuid-1', 'https://x#uuid-1', '1.0.0'); + const parsed = JSON.parse(comment); + assert.equal(parsed.previousVersion, '1.0.0'); + }); + + it('parseSchemaComment round-trips a built comment', () => { + const built = SchemaHelper.buildSchemaComment('uuid-1', 'https://x#uuid-1', '1.0.0'); + const parsed = SchemaHelper.parseSchemaComment(built); + assert.equal(parsed.previousVersion, '1.0.0'); + }); + + it('parseSchemaComment returns {} for malformed input', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment(''), {}); + assert.deepEqual(SchemaHelper.parseSchemaComment('not-json'), {}); + assert.deepEqual(SchemaHelper.parseSchemaComment(null), {}); + }); +}); + +describe('SchemaHelper.parseFieldComment', () => { + it('parses a JSON object string', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('{"unit":"kg"}'), { unit: 'kg' }); + }); + + it('returns {} for malformed JSON', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('not-json'), {}); + assert.deepEqual(SchemaHelper.parseFieldComment(''), {}); + assert.deepEqual(SchemaHelper.parseFieldComment(null), {}); + }); +}); + +describe('SchemaHelper.buildFieldComment', () => { + it('embeds term, schema.org @id for non-ref fields, and skips falsy options', () => { + const json = SchemaHelper.buildFieldComment( + { isRef: false }, 'name', 'https://x', + ); + const parsed = JSON.parse(json); + assert.equal(parsed.term, 'name'); + assert.equal(parsed['@id'], 'https://www.schema.org/text'); + // No optional fields → only term + @id. + assert.deepEqual(Object.keys(parsed).sort(), ['@id', 'term']); + }); + + it('embeds the constructed URL @id for $ref fields', () => { + const json = SchemaHelper.buildFieldComment( + { isRef: true, type: '#child' }, 'rel', 'https://x', + ); + const parsed = JSON.parse(json); + assert.equal(parsed['@id'], 'https://x#child'); + }); + + it('passes through unit, customType, hidden, expression, etc.', () => { + const json = SchemaHelper.buildFieldComment( + { + isRef: false, + unit: 'kg', + unitSystem: 'metric', + customType: 'enum', + hidden: true, + expression: 'a+b', + isUpdatable: true, + availableOptions: ['A', 'B'], + }, + 'qty', 'https://x', 3, + ); + const parsed = JSON.parse(json); + assert.equal(parsed.unit, 'kg'); + assert.equal(parsed.unitSystem, 'metric'); + assert.equal(parsed.customType, 'enum'); + assert.equal(parsed.hidden, true); + assert.equal(parsed.expression, 'a+b'); + assert.equal(parsed.isUpdatable, true); + assert.deepEqual(parsed.availableOptions, ['A', 'B']); + assert.equal(parsed.orderPosition, 3); + }); + + it('omits orderPosition when not an integer or negative', () => { + let parsed = JSON.parse(SchemaHelper.buildFieldComment({ isRef: false }, 'a', 'u')); + assert.equal(parsed.orderPosition, undefined); + + parsed = JSON.parse(SchemaHelper.buildFieldComment({ isRef: false }, 'a', 'u', -1)); + assert.equal(parsed.orderPosition, undefined); + }); + + it('embeds isPrivate=false (explicit) but not when null/undefined', () => { + let parsed = JSON.parse( + SchemaHelper.buildFieldComment({ isRef: false, isPrivate: false }, 'a', 'u'), + ); + assert.equal(parsed.isPrivate, false); + + parsed = JSON.parse(SchemaHelper.buildFieldComment({ isRef: false }, 'a', 'u')); + assert.equal(parsed.isPrivate, undefined); + }); +}); + +describe('SchemaHelper.checkSchemaKey', () => { + it('returns true when no property keys contain whitespace', () => { + const ok = SchemaHelper.checkSchemaKey({ + document: { properties: { foo: {}, bar_baz: {} } }, + }); + assert.equal(ok, true); + }); + + it('throws when any property key contains whitespace', () => { + assert.throws(() => SchemaHelper.checkSchemaKey({ + document: { properties: { 'has space': {} } }, + }), /must not contain spaces/); + }); + + it('returns true (no-op) when the schema lacks document.properties', () => { + assert.equal(SchemaHelper.checkSchemaKey({}), true); + assert.equal(SchemaHelper.checkSchemaKey({ document: {} }), true); + }); +}); diff --git a/interfaces/tests/schema-helper-conditions-serialize.test.mjs b/interfaces/tests/schema-helper-conditions-serialize.test.mjs new file mode 100644 index 0000000000..34179d5435 --- /dev/null +++ b/interfaces/tests/schema-helper-conditions-serialize.test.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +const field = (name, over = {}) => ({ + name, + title: name, + description: name, + type: 'string', + required: false, + isArray: false, + isRef: false, + readOnly: false, + ...over, +}); + +const baseSchema = () => ({ uuid: 'u-1', version: '1.0.0', name: 'N', description: 'D', contextURL: 'ctx:' }); + +describe('SchemaHelper.parseConditions — if shapes', () => { + const fields = [field('a'), field('b'), field('c')]; + const run = (nodeIf) => SchemaHelper.parseConditions( + { allOf: [{ if: nodeIf, then: { properties: { c: { type: 'string' } } } }] }, + 'ctx:', + fields, + new Map(), + ); + + it('maps a plain properties block with one const to a single predicate', () => { + const [cond] = run({ properties: { a: { const: 1 } } }); + assert.equal(cond.ifCondition.field.name, 'a'); + assert.equal(cond.ifCondition.fieldValue, 1); + }); + + it('maps a properties block with several consts to AND', () => { + const [cond] = run({ properties: { a: { const: 1 }, b: { const: 2 } } }); + assert.equal(cond.ifCondition.AND.length, 2); + assert.deepEqual(cond.ifCondition.AND.map((p) => p.field.name), ['a', 'b']); + }); + + it('yields a null ifCondition when no property carries a const', () => { + const [cond] = run({ properties: { a: { type: 'string' } } }); + assert.equal(cond.ifCondition, null); + }); + + it('maps anyOf branches to OR', () => { + const [cond] = run({ anyOf: [{ properties: { a: { const: 1 } } }, { properties: { b: { const: 2 } } }] }); + assert.equal(cond.ifCondition.OR.length, 2); + assert.equal(cond.ifCondition.OR[1].fieldValue, 2); + }); + + it('flattens a single-predicate anyOf to a plain predicate', () => { + const [cond] = run({ anyOf: [{ properties: { a: { const: 1 } } }] }); + assert.equal(cond.ifCondition.field.name, 'a'); + }); + + it('maps allOf branches to AND', () => { + const [cond] = run({ allOf: [{ properties: { a: { const: 1 } } }, { properties: { b: { const: 2 } } }] }); + assert.equal(cond.ifCondition.AND.length, 2); + }); + + it('flattens a single-predicate allOf to a plain predicate', () => { + const [cond] = run({ allOf: [{ properties: { b: { const: 5 } } }] }); + assert.equal(cond.ifCondition.field.name, 'b'); + assert.equal(cond.ifCondition.fieldValue, 5); + }); + + it('yields a null ifCondition for a non-object if node', () => { + const [cond] = run('not-an-object'); + assert.equal(cond.ifCondition, null); + }); + + it('ignores predicates that reference unknown fields', () => { + const [cond] = run({ properties: { zz: { const: 1 }, a: { const: 2 } } }); + assert.equal(cond.ifCondition.field.name, 'a'); + }); + + it('skips allOf entries without an if node', () => { + const out = SchemaHelper.parseConditions({ allOf: [{ then: {} }] }, 'ctx:', fields, new Map()); + assert.deepEqual(out, []); + }); + + it('also reads conditions from a top-level anyOf array', () => { + const out = SchemaHelper.parseConditions( + { anyOf: [{ if: { properties: { a: { const: 1 } } }, then: { properties: { c: { type: 'string' } } } }] }, + 'ctx:', + fields, + new Map(), + ); + assert.equal(out.length, 1); + assert.equal(out[0].thenFields.length, 1); + assert.equal(out[0].thenFields[0].name, 'c'); + }); + + it('returns [] for a missing document', () => { + assert.deepEqual(SchemaHelper.parseConditions(null, 'ctx:', fields, new Map()), []); + }); +}); + +describe('SchemaHelper.buildDocument — condition serialization', () => { + const build = (conditions) => SchemaHelper.buildDocument(baseSchema(), [field('a'), field('b')], conditions); + + it('serialises a single predicate into if.properties with const', () => { + const doc = build([{ ifCondition: { field: { name: 'a' }, fieldValue: 'x' }, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf.length, 1); + assert.deepEqual(doc.allOf[0].if.properties, { a: { const: 'x' } }); + assert.ok(doc.allOf[0].then.properties.t1); + assert.equal(doc.allOf[0].else, undefined); + }); + + it('serialises a multi-predicate AND into if.allOf', () => { + const ifCondition = { AND: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }; + const doc = build([{ ifCondition, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf[0].if.allOf.length, 2); + assert.deepEqual(doc.allOf[0].if.allOf[1].properties, { b: { const: 2 } }); + }); + + it('flattens a single-element AND to plain properties', () => { + const ifCondition = { AND: [{ field: { name: 'a' }, fieldValue: 1 }] }; + const doc = build([{ ifCondition, thenFields: [field('t1')], elseFields: [] }]); + assert.deepEqual(doc.allOf[0].if.properties, { a: { const: 1 } }); + }); + + it('serialises a multi-predicate OR into if.anyOf', () => { + const ifCondition = { OR: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }; + const doc = build([{ ifCondition, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf[0].if.anyOf.length, 2); + }); + + it('flattens a single-element OR to plain properties', () => { + const ifCondition = { OR: [{ field: { name: 'b' }, fieldValue: 7 }] }; + const doc = build([{ ifCondition, thenFields: [field('t1')], elseFields: [] }]); + assert.deepEqual(doc.allOf[0].if.properties, { b: { const: 7 } }); + }); + + it('drops conditions whose AND list is empty', () => { + const doc = build([{ ifCondition: { AND: [] }, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf, undefined); + }); + + it('drops conditions whose OR list is empty', () => { + const doc = build([{ ifCondition: { OR: [] }, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf, undefined); + }); + + it('drops conditions without an ifCondition', () => { + const doc = build([{ ifCondition: null, thenFields: [field('t1')], elseFields: [] }]); + assert.equal(doc.allOf, undefined); + }); + + it('emits else when only elseFields are present', () => { + const doc = build([{ ifCondition: { field: { name: 'a' }, fieldValue: 1 }, thenFields: [], elseFields: [field('e1', { required: true })] }]); + assert.equal(doc.allOf[0].then, undefined); + assert.ok(doc.allOf[0].else.properties.e1); + assert.deepEqual(doc.allOf[0].else.required, ['e1']); + }); + + it('omits allOf entirely when no conditions are given', () => { + const doc = build(undefined); + assert.equal('allOf' in doc, false); + }); +}); + +describe('SchemaHelper.parseFields — sub-schema refs', () => { + const doc = () => ({ + properties: { + ref1: { $ref: '#sub' }, + ref2: { $ref: '#sub' }, + }, + required: [], + $defs: { + '#sub': { properties: { x: { type: 'string' } }, required: ['x'] }, + }, + }); + + it('expands referenced sub-schemas into nested fields', () => { + const fields = SchemaHelper.parseFields(doc(), 'ctx:', new Map(), null); + assert.equal(fields.length, 2); + assert.equal(fields[0].isRef, true); + assert.equal(fields[0].fields.length, 1); + assert.equal(fields[0].fields[0].name, 'x'); + assert.equal(fields[0].fields[0].required, true); + }); + + it('caches the parsed sub-schema by type', () => { + const cache = new Map(); + SchemaHelper.parseFields(doc(), 'ctx:', cache, null); + assert.ok(cache.has('#sub')); + assert.equal(cache.get('#sub').fields.length, 1); + }); + + it('clones cached sub-fields per referencing field', () => { + const fields = SchemaHelper.parseFields(doc(), 'ctx:', new Map(), null); + assert.notEqual(fields[0].fields, fields[1].fields); + assert.notEqual(fields[0].fields[0], fields[1].fields[0]); + }); + + it('uses the defs argument when the document has no $defs', () => { + const { $defs, ...noDefs } = doc(); + const fields = SchemaHelper.parseFields(noDefs, 'ctx:', new Map(), $defs); + assert.equal(fields[0].fields[0].name, 'x'); + }); + + it('attaches a context with the ref type to ref fields', () => { + const fields = SchemaHelper.parseFields(doc(), 'ctx:', new Map(), null); + assert.equal(fields[0].context.type, 'sub'); + assert.deepEqual(fields[0].context.context, ['ctx:']); + }); +}); diff --git a/interfaces/tests/schema-helper-context-misc.test.mjs b/interfaces/tests/schema-helper-context-misc.test.mjs new file mode 100644 index 0000000000..117d749a8a --- /dev/null +++ b/interfaces/tests/schema-helper-context-misc.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.parseProperty — oneOf', () => { + it('reads type information from the first oneOf entry', () => { + const field = SchemaHelper.parseProperty('f', { oneOf: [{ type: 'string' }] }); + assert.equal(field.type, 'string'); + assert.equal(field.isArray, false); + }); + + it('keeps the outer title and description over the oneOf entry', () => { + const field = SchemaHelper.parseProperty('f', { title: 'Outer', oneOf: [{ type: 'string', title: 'Inner' }] }); + assert.equal(field.title, 'Outer'); + }); + + it('falls back to the oneOf entry title when the outer one is missing', () => { + const field = SchemaHelper.parseProperty('f', { oneOf: [{ type: 'string', title: 'Inner' }] }); + assert.equal(field.title, 'Inner'); + }); +}); + +describe('SchemaHelper.parseField — font fragments', () => { + const prop = (comment) => ({ title: 'T', type: 'string', $comment: JSON.stringify(comment) }); + + it('builds font from textSize alone', () => { + const field = SchemaHelper.parseField('f', prop({ term: 'f', textSize: '14px' }), false, 'url'); + assert.deepEqual(field.font, { size: '14px' }); + }); + + it('builds font from textBold alone', () => { + const field = SchemaHelper.parseField('f', prop({ term: 'f', textBold: true }), false, 'url'); + assert.deepEqual(field.font, { bold: true }); + }); + + it('builds font from textColor alone', () => { + const field = SchemaHelper.parseField('f', prop({ term: 'f', textColor: '#fff' }), false, 'url'); + assert.deepEqual(field.font, { color: '#fff' }); + }); + + it('merges all three font fragments', () => { + const field = SchemaHelper.parseField('f', prop({ term: 'f', textColor: '#fff', textSize: '14px', textBold: true }), false, 'url'); + assert.deepEqual(field.font, { color: '#fff', size: '14px', bold: true }); + }); +}); + +describe('SchemaHelper.buildFieldComment — optional keys', () => { + const base = { isRef: false }; + + it('serialises property, customType and availableOptions when present', () => { + const json = JSON.parse(SchemaHelper.buildFieldComment({ ...base, property: 'p', customType: 'ct', availableOptions: ['a'] }, 'f', 'url')); + assert.equal(json.property, 'p'); + assert.equal(json.customType, 'ct'); + assert.deepEqual(json.availableOptions, ['a']); + }); + + it('serialises text styling keys when present', () => { + const json = JSON.parse(SchemaHelper.buildFieldComment({ ...base, textColor: '#fff', textSize: '12px', textBold: true }, 'f', 'url')); + assert.equal(json.textColor, '#fff'); + assert.equal(json.textSize, '12px'); + assert.equal(json.textBold, true); + }); + + it('serialises suggest and autocalculate when present', () => { + const json = JSON.parse(SchemaHelper.buildFieldComment({ ...base, suggest: 'sg', autocalculate: true, expression: 'a+b' }, 'f', 'url')); + assert.equal(json.suggest, 'sg'); + assert.equal(json.autocalculate, true); + assert.equal(json.expression, 'a+b'); + }); + + it('records orderPosition only for non-negative integers', () => { + const withOrder = JSON.parse(SchemaHelper.buildFieldComment(base, 'f', 'url', 2)); + const withoutOrder = JSON.parse(SchemaHelper.buildFieldComment(base, 'f', 'url', -1)); + assert.equal(withOrder.orderPosition, 2); + assert.equal('orderPosition' in withoutOrder, false); + }); + + it('omits all optional keys for a bare field', () => { + const json = JSON.parse(SchemaHelper.buildFieldComment(base, 'f', 'url')); + assert.deepEqual(Object.keys(json), ['term', '@id']); + }); +}); + +describe('SchemaHelper.updateVersion — string document', () => { + it('parses a stringified document before re-versioning', () => { + const comment = JSON.stringify({ term: 'u&1.0.0', '@id': 'ctx:#u&1.0.0', previousVersion: '1.0.0' }); + const data = { + document: JSON.stringify({ $id: '#u&1.0.0', $comment: comment }), + uuid: 'u', + contextURL: 'ctx:', + }; + const result = SchemaHelper.updateVersion(data, '2.0.0'); + assert.equal(result.version, '2.0.0'); + assert.equal(typeof result.document, 'object'); + assert.equal(result.document.$id, '#u&2.0.0'); + }); +}); + +describe('SchemaHelper.getContext — error path', () => { + it('returns null when the item cannot be read', () => { + assert.equal(SchemaHelper.getContext(null), null); + }); +}); + +describe('SchemaHelper.updateObjectContext', () => { + const schema = () => ({ + type: 'Main', + contextURL: 'ctx:', + fields: [ + { name: 'sub', isRef: true, context: { type: 'Sub', context: ['ctx:'] }, fields: [] }, + { name: 'geo', isRef: true, context: { type: 'GeoJSON', context: ['geo-ctx'] }, fields: [] }, + { name: 'obj', isRef: false }, + ], + }); + + it('stamps the root type and context', () => { + const json = SchemaHelper.updateObjectContext(schema(), { sub: null, plain: 1 }); + assert.equal(json.type, 'Main'); + assert.deepEqual(json['@context'], ['ctx:']); + }); + + it('rewrites ref sub-objects with their schema type and context', () => { + const json = SchemaHelper.updateObjectContext(schema(), { sub: { type: 'Old', '@context': 'old' } }); + assert.equal(json.sub.type, 'Sub'); + assert.deepEqual(json.sub['@context'], ['ctx:']); + }); + + it('keeps the geometry type for GeoJSON refs and only replaces the context', () => { + const json = SchemaHelper.updateObjectContext(schema(), { geo: { type: 'Point', '@context': 'x' } }); + assert.equal(json.geo.type, 'Point'); + assert.deepEqual(json.geo['@context'], ['geo-ctx']); + }); + + it('strips type and context from plain nested objects', () => { + const json = SchemaHelper.updateObjectContext(schema(), { obj: { type: 'T', '@context': 'y', keep: 1 } }); + assert.equal('type' in json.obj, false); + assert.equal('@context' in json.obj, false); + assert.equal(json.obj.keep, 1); + }); + + it('rewrites each element of an array-valued ref field', () => { + const json = SchemaHelper.updateObjectContext(schema(), { sub: [{ a: 1 }, { a: 2 }] }); + assert.equal(json.sub[0].type, 'Sub'); + assert.equal(json.sub[1].type, 'Sub'); + }); + + it('leaves primitive values untouched', () => { + const json = SchemaHelper.updateObjectContext(schema(), { sub: 'just-a-string', plain: 5 }); + assert.equal(json.sub, 'just-a-string'); + assert.equal(json.plain, 5); + }); +}); + +describe('SchemaHelper.checkErrors', () => { + it('wraps schema-level errors with a schema target', () => { + const errors = SchemaHelper.checkErrors({ errors: [{ code: 'E1' }] }); + assert.deepEqual(errors, [{ target: { type: 'schema' }, code: 'E1' }]); + }); + + it('wraps field errors with the field name', () => { + const errors = SchemaHelper.checkErrors({ fields: [{ name: 'a', errors: [{ code: 'E2' }] }] }); + assert.deepEqual(errors[0].target, { type: 'field', field: 'a' }); + }); + + it('normalises a single-field ifCondition to mode IF', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ errors: [{ code: 'E3' }], ifCondition: { field: { name: 'a' }, fieldValue: 1 } }], + }); + assert.equal(errors[0].target.mode, 'IF'); + assert.equal(errors[0].target.field, 'a'); + assert.equal(errors[0].target.fieldValue, 1); + assert.equal(errors[0].target.index, 0); + }); + + it('normalises AND conditions with predicates', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ + errors: [{ code: 'E4' }], + ifCondition: { AND: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }, + }], + }); + assert.equal(errors[0].target.mode, 'AND'); + assert.deepEqual(errors[0].target.predicates, [ + { field: 'a', fieldValue: 1 }, + { field: 'b', fieldValue: 2 }, + ]); + }); + + it('normalises OR conditions and filters nameless predicates', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ + errors: [{ code: 'E5' }], + ifCondition: { OR: [{ field: { name: 'a' }, fieldValue: 1 }, { field: {}, fieldValue: 2 }] }, + }], + }); + assert.equal(errors[0].target.mode, 'OR'); + assert.deepEqual(errors[0].target.predicates, [{ field: 'a', fieldValue: 1 }]); + }); + + it('maps a predicates list with op ANY_OF to OR', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ + errors: [{ code: 'E6' }], + ifCondition: { op: 'ANY_OF', predicates: [{ field: { name: 'a' }, value: 1 }, { field: { name: 'b' }, value: 2 }] }, + }], + }); + assert.equal(errors[0].target.mode, 'OR'); + assert.deepEqual(errors[0].target.predicates, [{ field: 'a', value: 1 }, { field: 'b', value: 2 }]); + }); + + it('maps a predicates list without op to AND', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ + errors: [{ code: 'E7' }], + ifCondition: { predicates: [{ field: { name: 'a' }, value: 1 }, { field: { name: 'b' }, value: 2 }] }, + }], + }); + assert.equal(errors[0].target.mode, 'AND'); + }); + + it('collapses a single-entry predicates list to mode IF', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ + errors: [{ code: 'E8' }], + ifCondition: { predicates: [{ field: { name: 'a' }, value: 9 }] }, + }], + }); + assert.equal(errors[0].target.mode, 'IF'); + assert.equal(errors[0].target.field, 'a'); + assert.equal(errors[0].target.fieldValue, 9); + }); + + it('falls back to mode IF with raw fields for an unrecognised ifCondition', () => { + const errors = SchemaHelper.checkErrors({ + conditions: [{ errors: [{ code: 'E9' }], ifCondition: {} }], + }); + assert.equal(errors[0].target.mode, 'IF'); + assert.equal(errors[0].target.field, undefined); + }); + + it('returns [] when nothing carries errors', () => { + assert.deepEqual(SchemaHelper.checkErrors({ fields: [{ name: 'a' }], conditions: [{}] }), []); + }); +}); diff --git a/interfaces/tests/schema-helper-context.test.mjs b/interfaces/tests/schema-helper-context.test.mjs new file mode 100644 index 0000000000..d25444a06c --- /dev/null +++ b/interfaces/tests/schema-helper-context.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.getContext', () => { + it('builds { type, @context } from item.iri and item.contextURL', () => { + const ctx = SchemaHelper.getContext({ + iri: 'https://example.com#uuid-1&1.0.0', + contextURL: 'https://example.com/ctx.jsonld', + }); + assert.equal(ctx.type, 'uuid-1&1.0.0'); + assert.deepEqual(ctx['@context'], ['https://example.com/ctx.jsonld']); + }); + + it('returns null when iri is null', () => { + const ctx = SchemaHelper.getContext({ iri: null, contextURL: 'x' }); + assert.equal(ctx.type, null); + assert.deepEqual(ctx['@context'], ['x']); + }); + + it('emits an empty type for an empty iri', () => { + const ctx = SchemaHelper.getContext({ iri: '', contextURL: 'ctx' }); + assert.equal(ctx.type, null); + }); +}); diff --git a/interfaces/tests/schema-helper-deep.test.mjs b/interfaces/tests/schema-helper-deep.test.mjs new file mode 100644 index 0000000000..00ddc59279 --- /dev/null +++ b/interfaces/tests/schema-helper-deep.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.cloneFields', () => { + it('returns a new array of shallow-cloned field objects', () => { + const fields = [{ name: 'a', type: 'string' }, { name: 'b', type: 'number' }]; + const clone = SchemaHelper.cloneFields(fields); + assert.notEqual(clone, fields); + assert.notEqual(clone[0], fields[0]); + assert.deepEqual(clone, fields); + }); + it('recursively clones nested fields', () => { + const fields = [{ name: 'parent', fields: [{ name: 'child', fields: [{ name: 'grandchild' }] }] }]; + const clone = SchemaHelper.cloneFields(fields); + assert.notEqual(clone[0].fields, fields[0].fields); + assert.notEqual(clone[0].fields[0].fields, fields[0].fields[0].fields); + assert.deepEqual(clone, fields); + }); + it('does not mutate the source when the clone is edited', () => { + const fields = [{ name: 'x', fields: [{ name: 'y' }] }]; + const clone = SchemaHelper.cloneFields(fields); + clone[0].name = 'changed'; + clone[0].fields[0].name = 'changed-child'; + assert.equal(fields[0].name, 'x'); + assert.equal(fields[0].fields[0].name, 'y'); + }); + it('handles an empty array', () => { + assert.deepEqual(SchemaHelper.cloneFields([]), []); + }); + it('leaves non-array fields property untouched', () => { + const fields = [{ name: 'a', fields: undefined }]; + const clone = SchemaHelper.cloneFields(fields); + assert.equal(clone[0].fields, undefined); + }); +}); + +describe('SchemaHelper.uniqueRefs', () => { + it('copies schemas and strips $defs', () => { + const map = { '#A': { title: 'A', $defs: { '#B': { title: 'B' } } } }; + const result = SchemaHelper.uniqueRefs(map, {}); + assert.equal(result['#A'].title, 'A'); + assert.equal(result['#A'].$defs, undefined); + }); + it('flattens nested $defs into the result map', () => { + const map = { '#A': { title: 'A', $defs: { '#B': { title: 'B' } } } }; + const result = SchemaHelper.uniqueRefs(map, {}); + assert.equal(result['#B'].title, 'B'); + assert.equal(result['#B'].$defs, undefined); + }); + it('does not overwrite an already present iri', () => { + const existing = { '#A': { title: 'existing' } }; + const map = { '#A': { title: 'new' } }; + const result = SchemaHelper.uniqueRefs(map, existing); + assert.equal(result['#A'].title, 'existing'); + }); + it('returns the passed-in accumulator object', () => { + const acc = {}; + const result = SchemaHelper.uniqueRefs({}, acc); + assert.equal(result, acc); + }); + it('recurses through multiple levels of $defs', () => { + const map = { + '#A': { title: 'A', $defs: { '#B': { title: 'B', $defs: { '#C': { title: 'C' } } } } }, + }; + const result = SchemaHelper.uniqueRefs(map, {}); + assert.equal(result['#C'].title, 'C'); + }); +}); + +describe('SchemaHelper.findRefs', () => { + it('returns refs only for fields that reference known schemas', () => { + const target = { + fields: [ + { isRef: true, type: '#Known' }, + { isRef: false, type: '#Ignored' }, + { isRef: true, type: '#Missing' }, + ], + }; + const schemas = [{ iri: '#Known', document: { title: 'Known' } }]; + const refs = SchemaHelper.findRefs(target, schemas); + assert.equal(refs['#Known'].title, 'Known'); + assert.equal(refs['#Ignored'], undefined); + assert.equal(refs['#Missing'], undefined); + }); + it('resolves the built-in GeoJSON ref', () => { + const target = { fields: [{ isRef: true, type: '#GeoJSON' }] }; + const refs = SchemaHelper.findRefs(target, []); + assert.ok(refs['#GeoJSON']); + }); + it('resolves the built-in SentinelHUB ref', () => { + const target = { fields: [{ isRef: true, type: '#SentinelHUB' }] }; + const refs = SchemaHelper.findRefs(target, []); + assert.ok(refs['#SentinelHUB']); + }); + it('returns an empty map when no fields are refs', () => { + const target = { fields: [{ isRef: false, type: 'string' }] }; + assert.deepEqual(SchemaHelper.findRefs(target, []), {}); + }); +}); + +describe('SchemaHelper.getFieldsFromObject', () => { + it('builds properties and collects required field names', () => { + const required = []; + const properties = {}; + const fields = [ + { name: 'a', type: 'string', required: true, title: 'A', description: 'A' }, + { name: 'b', type: 'string', required: false, title: 'B', description: 'B' }, + ]; + SchemaHelper.getFieldsFromObject(fields, required, properties, 'ctx'); + assert.ok(properties.a); + assert.ok(properties.b); + assert.deepEqual(required, ['a']); + }); + it('throws when a field name contains whitespace', () => { + assert.throws( + () => SchemaHelper.getFieldsFromObject( + [{ name: 'bad name', type: 'string', required: false }], [], {}, 'ctx'), + /must not contain spaces/, + ); + }); + it('skips a field whose name already exists in properties', () => { + const properties = { dup: { existing: true } }; + const required = []; + SchemaHelper.getFieldsFromObject( + [{ name: 'dup', type: 'string', required: true }], required, properties, 'ctx'); + assert.equal(properties.dup.existing, true); + assert.deepEqual(required, []); + }); + it('does not add optional fields to the required list', () => { + const required = []; + SchemaHelper.getFieldsFromObject( + [{ name: 'opt', type: 'string', required: false }], required, {}, 'ctx'); + assert.deepEqual(required, []); + }); +}); diff --git a/interfaces/tests/schema-helper-edge.test.mjs b/interfaces/tests/schema-helper-edge.test.mjs new file mode 100644 index 0000000000..baa5539449 --- /dev/null +++ b/interfaces/tests/schema-helper-edge.test.mjs @@ -0,0 +1,562 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('@unit SchemaHelper.parseRef — boundary/error', () => { + it('returns all-null for an empty string ref', () => { + assert.deepEqual(SchemaHelper.parseRef(''), { iri: null, type: null, uuid: null, version: null }); + }); + + it('returns all-null for a null ref', () => { + assert.deepEqual(SchemaHelper.parseRef(null), { iri: null, type: null, uuid: null, version: null }); + }); + + it('parses a ref that has no leading hash', () => { + const r = SchemaHelper.parseRef('uuid-1&1.0.0'); + assert.equal(r.iri, 'uuid-1&1.0.0'); + assert.equal(r.type, 'uuid-1&1.0.0'); + assert.equal(r.uuid, 'uuid-1'); + assert.equal(r.version, '1.0.0'); + }); + + it('uses the trailing segment when multiple hashes are present', () => { + const r = SchemaHelper.parseRef('a#b#uuid&2.0.0'); + assert.equal(r.type, 'uuid&2.0.0'); + assert.equal(r.uuid, 'uuid'); + assert.equal(r.version, '2.0.0'); + }); + + it('treats a bare "#" as an empty type with null uuid/version', () => { + const r = SchemaHelper.parseRef('#'); + assert.equal(r.type, ''); + assert.equal(r.uuid, null); + assert.equal(r.version, null); + }); + + it('keeps only the first two ampersand keys as uuid/version', () => { + const r = SchemaHelper.parseRef('#uuid&1.0.0&extra'); + assert.equal(r.uuid, 'uuid'); + assert.equal(r.version, '1.0.0'); + }); + + it('parses a ref from a JSON-string document', () => { + const r = SchemaHelper.parseRef({ document: JSON.stringify({ $id: '#u&3.0.0' }) }); + assert.equal(r.uuid, 'u'); + assert.equal(r.version, '3.0.0'); + }); + + it('returns all-null when the document JSON is malformed', () => { + assert.deepEqual(SchemaHelper.parseRef({ document: '{bad' }), { iri: null, type: null, uuid: null, version: null }); + }); + + it('returns all-null when the document object has no $id', () => { + assert.deepEqual(SchemaHelper.parseRef({ document: {} }), { iri: null, type: null, uuid: null, version: null }); + }); + + it('parses a uuid-only ref (no version key) with null version', () => { + const r = SchemaHelper.parseRef('#uuid-only'); + assert.equal(r.uuid, 'uuid-only'); + assert.equal(r.version, null); + }); +}); + +describe('@unit SchemaHelper.incrementVersion — boundary', () => { + it('seeds 1.0.0 when previousVersion is empty and no versions exist', () => { + assert.equal(SchemaHelper.incrementVersion('', []), '1.0.0'); + }); + + it('seeds 1.0.0 when previousVersion is null', () => { + assert.equal(SchemaHelper.incrementVersion(null, []), '1.0.0'); + }); + + it('increments the patch from previousVersion when no other versions exist', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', []), '1.0.1'); + }); + + it('takes the max minor in the same major.minor group and adds one', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', ['1.0.0', '1.0.5']), '1.0.6'); + }); + + it('ignores empty/null/undefined entries in the versions list', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', ['', null, undefined, '1.0.3']), '1.0.4'); + }); + + it('treats a two-part previousVersion by its last dot segment', () => { + assert.equal(SchemaHelper.incrementVersion('1.0', []), '1.1'); + }); + + it('produces a leading-dot result for a dotless previousVersion (latent quirk)', () => { + assert.equal(SchemaHelper.incrementVersion('2', []), '.3'); + }); + + it('is deterministic across repeated calls with the same inputs', () => { + const a = SchemaHelper.incrementVersion('3.2.0', ['3.2.1', '3.2.4']); + const b = SchemaHelper.incrementVersion('3.2.0', ['3.2.1', '3.2.4']); + assert.equal(a, b); + assert.equal(a, '3.2.5'); + }); +}); + +describe('@unit SchemaHelper.buildType / buildRef / buildUrl — boundary', () => { + it('omits version when version is undefined', () => { + assert.equal(SchemaHelper.buildType('uuid'), 'uuid'); + }); + + it('omits version when version is an empty string (falsy)', () => { + assert.equal(SchemaHelper.buildType('uuid', ''), 'uuid'); + }); + + it('appends a 0.0.0 version (truthy non-empty string)', () => { + assert.equal(SchemaHelper.buildType('uuid', '0.0.0'), 'uuid&0.0.0'); + }); + + it('prefixes the type with a hash', () => { + assert.equal(SchemaHelper.buildRef('uuid&1.0.0'), '#uuid&1.0.0'); + }); + + it('returns "" when both contextURL and ref are null', () => { + assert.equal(SchemaHelper.buildUrl(null, null), ''); + }); + + it('returns the context alone when ref is null', () => { + assert.equal(SchemaHelper.buildUrl('https://x', null), 'https://x'); + }); + + it('returns the ref alone when context is null', () => { + assert.equal(SchemaHelper.buildUrl(null, '#r'), '#r'); + }); +}); + +describe('@unit SchemaHelper.buildSchemaComment / parseSchemaComment — boundary', () => { + it('omits previousVersion when version is undefined', () => { + assert.equal(SchemaHelper.buildSchemaComment('t', 'u'), '{ "@id": "u", "term": "t" }'); + }); + + it('omits previousVersion when version is an empty string', () => { + assert.equal(SchemaHelper.buildSchemaComment('t', 'u', ''), '{ "@id": "u", "term": "t" }'); + }); + + it('includes previousVersion when supplied', () => { + assert.ok(SchemaHelper.buildSchemaComment('t', 'u', '1.0.0').includes('"previousVersion": "1.0.0"')); + }); + + it('round-trips a built comment back through parseSchemaComment', () => { + const c = SchemaHelper.buildSchemaComment('t', 'u', '1.0.0'); + assert.equal(SchemaHelper.parseSchemaComment(c).previousVersion, '1.0.0'); + }); + + it('returns {} for malformed comment JSON', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment('{bad'), {}); + }); + + it('returns {} for the literal "null" comment', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment('null'), {}); + }); + + it('returns {} for an undefined comment', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment(undefined), {}); + }); +}); + +describe('@unit SchemaHelper.parseFieldComment — boundary', () => { + it('returns {} for the literal "null"', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('null'), {}); + }); + + it('returns {} for an empty string', () => { + assert.deepEqual(SchemaHelper.parseFieldComment(''), {}); + }); + + it('returns {} for malformed JSON', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('{not-json'), {}); + }); + + it('parses a valid object comment', () => { + assert.equal(SchemaHelper.parseFieldComment('{"unit":"kg"}').unit, 'kg'); + }); +}); + +describe('@unit SchemaHelper.validate — error paths', () => { + it('returns false when name is missing', () => { + assert.equal(SchemaHelper.validate({ uuid: 'u', document: { $id: '#x' } }), false); + }); + + it('returns false when uuid is missing', () => { + assert.equal(SchemaHelper.validate({ name: 'n', document: { $id: '#x' } }), false); + }); + + it('returns false when document is missing', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u' }), false); + }); + + it('returns false when the string document is malformed JSON', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: '{bad' }), false); + }); + + it('returns false when $id is missing from the document', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: {} }), false); + }); + + it('returns true for a valid JSON-string document', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: JSON.stringify({ $id: '#x' }) }), true); + }); +}); + +describe('@unit SchemaHelper.checkSchemaKey — error paths & unicode', () => { + it('returns true for a unicode key without whitespace', () => { + assert.equal(SchemaHelper.checkSchemaKey({ document: { properties: { 'naïve_café': {} } } }), true); + }); + + it('throws for a key containing a space', () => { + assert.throws( + () => SchemaHelper.checkSchemaKey({ document: { properties: { 'with space': {} } } }), + /Field key 'with space' must not contain spaces/, + ); + }); + + it('throws for a key containing a tab', () => { + assert.throws( + () => SchemaHelper.checkSchemaKey({ document: { properties: { 'a\tb': {} } } }), + /must not contain spaces/, + ); + }); + + it('returns true when there are no properties', () => { + assert.equal(SchemaHelper.checkSchemaKey({ document: {} }), true); + }); + + it('returns true for a null schema', () => { + assert.equal(SchemaHelper.checkSchemaKey(null), true); + }); +}); + +describe('@unit SchemaHelper.parseProperty — edge', () => { + it('prefers the outer title over the oneOf branch title', () => { + assert.equal(SchemaHelper.parseProperty('f', { oneOf: [{ type: 'string', title: 'OT' }], title: 'PT' }).title, 'PT'); + }); + + it('is a ref when $ref present and type absent', () => { + assert.equal(SchemaHelper.parseProperty('f', { $ref: '#X' }).isRef, true); + }); + + it('is not a ref when both $ref and type are present', () => { + assert.equal(SchemaHelper.parseProperty('f', { $ref: '#X', type: 'string' }).isRef, false); + }); + + it('descends into items for array properties', () => { + const r = SchemaHelper.parseProperty('f', { type: 'array', items: { type: 'string', enum: ['a'] } }); + assert.equal(r.isArray, true); + assert.equal(r.type, 'string'); + assert.deepEqual(r.enum, ['a']); + }); + + it('nulls examples when examples is not an array', () => { + assert.equal(SchemaHelper.parseProperty('f', { type: 'string', examples: 'x' }).examples, null); + }); + + it('preserves a unicode field name', () => { + assert.equal(SchemaHelper.parseProperty('café_naïve', { type: 'string' }).name, 'café_naïve'); + }); + + it('falls back to the name for title and description', () => { + const r = SchemaHelper.parseProperty('fld', { type: 'string' }); + assert.equal(r.title, 'fld'); + assert.equal(r.description, 'fld'); + }); +}); + +describe('@unit SchemaHelper.parseField — edge', () => { + it('defaults order to -1 when no orderPosition comment is present', () => { + assert.equal(SchemaHelper.parseField('f', { type: 'string' }, false, 'u').order, -1); + }); + + it('records the required flag passed in', () => { + assert.equal(SchemaHelper.parseField('f', { type: 'string' }, true, 'u').required, true); + }); + + it('round-trips unit/isPrivate/order through buildField then parseField', () => { + const built = SchemaHelper.buildField( + { title: 'T', description: 'D', type: 'string', isArray: false, isRef: false, unit: 'kg', isPrivate: true }, + 'name', 'ctx', 2, + ); + const parsed = SchemaHelper.parseField('name', built, true, 'ctx'); + assert.equal(parsed.unit, 'kg'); + assert.equal(parsed.isPrivate, true); + assert.equal(parsed.order, 2); + }); +}); + +describe('@unit SchemaHelper.setVersion — boundary', () => { + it('builds a versionless $id and comment when version is undefined', () => { + const r = SchemaHelper.setVersion({ uuid: 'u', contextURL: 'c', document: { $id: 'old' } }, undefined, undefined); + assert.equal(r.document.$id, '#u'); + assert.ok(!r.document.$comment.includes('previousVersion')); + }); + + it('is idempotent when applied twice with the same arguments', () => { + const s = { uuid: 'u', contextURL: 'c', document: { $id: 'old', $comment: '{}' } }; + const a = SchemaHelper.setVersion(s, '2.0.0', '1.0.0'); + const aId = a.document.$id; + const aComment = a.document.$comment; + const b = SchemaHelper.setVersion(a, '2.0.0', '1.0.0'); + assert.equal(b.document.$id, aId); + assert.equal(b.document.$comment, aComment); + }); + + it('parses a JSON-string document and reassigns it as an object', () => { + const r = SchemaHelper.setVersion({ uuid: 'u', contextURL: '', document: JSON.stringify({ $id: 'x' }) }, '1.1.0', '1.0.0'); + assert.equal(typeof r.document, 'object'); + assert.equal(r.document.$id, '#u&1.1.0'); + }); +}); + +describe('@unit SchemaHelper.updateVersion — comparison boundaries', () => { + const mk = (prev) => ({ + uuid: 'u', + contextURL: 'c', + creator: 'cr', + document: { $id: '#u&' + prev, $comment: JSON.stringify({ previousVersion: prev }) }, + }); + + it('rejects a four-part version as invalid format', () => { + assert.throws(() => SchemaHelper.updateVersion(mk('1.0.0'), '1.0.0.0'), /Invalid version format/); + }); + + it('rejects an empty version string', () => { + assert.throws(() => SchemaHelper.updateVersion(mk('1.0.0'), ''), /Invalid version format/); + }); + + it('rejects a non-numeric version', () => { + assert.throws(() => SchemaHelper.updateVersion(mk('1.0.0'), 'v2'), /Invalid version format/); + }); + + it('accepts 1.0.1 over a two-part 1.0 previousVersion', () => { + assert.equal(SchemaHelper.updateVersion(mk('1.0'), '1.0.1').version, '1.0.1'); + }); + + it('accepts a very large version', () => { + assert.equal(SchemaHelper.updateVersion(mk('1.0.0'), '999.999.999').version, '999.999.999'); + }); + + it('accepts any version when previousVersion is absent (empty comment)', () => { + const data = { uuid: 'u', contextURL: 'c', creator: 'cr', document: { $id: '#u&1.0.0', $comment: '{}' } }; + assert.equal(SchemaHelper.updateVersion(data, '0.0.1').version, '0.0.1'); + }); + + it('rejects a version equal to previousVersion', () => { + assert.throws(() => SchemaHelper.updateVersion(mk('1.0.0'), '1.0.0'), /Version must be greater than 1\.0\.0/); + }); + + it('falls back to uuid parsed from $id when data.uuid is absent', () => { + const data = { contextURL: 'c', creator: 'cr', document: { $id: '#parsed-uuid&1.0.0', $comment: JSON.stringify({ previousVersion: '1.0.0' }) } }; + const r = SchemaHelper.updateVersion(data, '1.1.0'); + assert.equal(r.uuid, 'parsed-uuid'); + assert.equal(r.document.$id, '#parsed-uuid&1.1.0'); + }); +}); + +describe('@unit SchemaHelper.updateOwner — edge', () => { + it('uses newOwner.username as the fallback for owner and creator', () => { + const data = { uuid: 'u', version: '1.0.0', contextURL: 'c', document: { $id: '#u&1.0.0', $comment: '{}' } }; + const r = SchemaHelper.updateOwner(data, { username: 'alice' }); + assert.equal(r.owner, 'alice'); + assert.equal(r.creator, 'alice'); + }); + + it('prefers explicit owner/creator over username', () => { + const data = { uuid: 'u', version: '1.0.0', contextURL: 'c', document: { $id: '#u&1.0.0', $comment: '{}' } }; + const r = SchemaHelper.updateOwner(data, { owner: 'o', creator: 'c2', username: 'alice' }); + assert.equal(r.owner, 'o'); + assert.equal(r.creator, 'c2'); + }); + + it('recovers version and uuid from $id when data fields are absent', () => { + const data = { contextURL: 'c', document: { $id: '#fromid&7.0.0', $comment: '{}' } }; + const r = SchemaHelper.updateOwner(data, { username: 'x' }); + assert.equal(r.uuid, 'fromid'); + assert.equal(r.version, '7.0.0'); + }); +}); + +describe('@unit SchemaHelper.updatePermission — edge', () => { + it('flags isOwner/isCreator only on exact matches', () => { + const arr = [{ owner: 'a', creator: 'a' }, { owner: 'b', creator: 'c' }]; + SchemaHelper.updatePermission(arr, { owner: 'a', creator: 'a' }); + assert.equal(arr[0].isOwner, true); + assert.equal(arr[0].isCreator, true); + assert.equal(arr[1].isOwner, false); + assert.equal(arr[1].isCreator, false); + }); + + it('leaves isOwner/isCreator falsy when owner/creator are absent', () => { + const arr = [{}]; + SchemaHelper.updatePermission(arr, { owner: 'a', creator: 'a' }); + assert.ok(!arr[0].isOwner); + assert.ok(!arr[0].isCreator); + }); +}); + +describe('@unit SchemaHelper.getContext — edge', () => { + it('returns the parsed type and the contextURL wrapped in an array', () => { + assert.deepEqual(SchemaHelper.getContext({ iri: '#u&1.0.0', contextURL: 'c' }), { type: 'u&1.0.0', '@context': ['c'] }); + }); + + it('returns a null type when iri is absent', () => { + assert.deepEqual(SchemaHelper.getContext({ contextURL: 'c' }), { type: null, '@context': ['c'] }); + }); +}); + +describe('@unit SchemaHelper.updateFields — idempotency & null safety', () => { + it('returns null unchanged for a null document', () => { + assert.equal(SchemaHelper.updateFields(null, () => ({})), null); + }); + + it('returns the same object when there are no properties', () => { + const noProps = { foo: 'bar' }; + assert.equal(SchemaHelper.updateFields(noProps, () => ({})), noProps); + }); + + it('is idempotent for an identity transform applied twice', () => { + const doc = { properties: { a: { type: 'string' } } }; + const snapshot = JSON.stringify(doc); + const once = SchemaHelper.updateFields(doc, (n, prop) => prop); + const twice = SchemaHelper.updateFields(once, (n, prop) => prop); + assert.equal(JSON.stringify(twice), snapshot); + }); +}); + +describe('@unit SchemaHelper.updateIRI — error paths', () => { + it('reads iri from an existing document.$id', () => { + assert.equal(SchemaHelper.updateIRI({ document: { $id: '#existing' } }).iri, '#existing'); + }); + + it('sets iri to null when document is present but $id is missing', () => { + assert.equal(SchemaHelper.updateIRI({ document: {} }).iri, null); + }); + + it('builds iri from uuid+version when no document is present', () => { + assert.equal(SchemaHelper.updateIRI({ uuid: 'u', version: '1.0.0' }).iri, '#u&1.0.0'); + }); + + it('builds a versionless iri when version is absent and no document is present', () => { + assert.equal(SchemaHelper.updateIRI({ uuid: 'u' }).iri, '#u'); + }); + + it('returns iri=null on a malformed JSON document', () => { + assert.equal(SchemaHelper.updateIRI({ document: 'not-json' }).iri, null); + }); +}); + +describe('@unit SchemaHelper.buildDocument — condition serialization edges', () => { + const schema = () => ({ uuid: 'u', version: '1.0.0', contextURL: 'c', name: 'N', description: 'DD' }); + + it('omits allOf entirely when there are no conditions', () => { + const doc = SchemaHelper.buildDocument(schema(), [], []); + assert.equal('allOf' in doc, false); + assert.equal(doc.$id, '#u&1.0.0'); + assert.deepEqual(doc.required, ['@context', 'type']); + }); + + it('drops a condition whose ifCondition is null', () => { + const doc = SchemaHelper.buildDocument(schema(), [], [{ ifCondition: null, thenFields: [], elseFields: [] }]); + assert.equal('allOf' in doc, false); + }); + + it('serializes a single-predicate ifCondition into a const property', () => { + const cond = { + ifCondition: { field: { name: 'sel' }, fieldValue: 'yes' }, + thenFields: [{ name: 'extra', type: 'string', title: 'E', description: 'E', required: true }], + elseFields: [], + }; + const doc = SchemaHelper.buildDocument(schema(), [], [cond]); + assert.equal(doc.allOf.length, 1); + assert.deepEqual(doc.allOf[0].if.properties.sel, { const: 'yes' }); + assert.ok(doc.allOf[0].then.properties.extra); + }); + + it('drops an AND ifCondition with an empty predicate array', () => { + const doc = SchemaHelper.buildDocument(schema(), [], [{ ifCondition: { AND: [] }, thenFields: [], elseFields: [] }]); + assert.equal('allOf' in doc, false); + }); + + it('collapses a single-element AND into a single const property', () => { + const cond = { + ifCondition: { AND: [{ field: { name: 'sel' }, fieldValue: 'v' }] }, + thenFields: [{ name: 'x', type: 'string', title: 'X', description: 'X', required: false }], + elseFields: [], + }; + const doc = SchemaHelper.buildDocument(schema(), [], [cond]); + assert.deepEqual(doc.allOf[0].if.properties.sel, { const: 'v' }); + assert.equal(doc.allOf[0].if.allOf, undefined); + }); + + it('emits anyOf for a multi-element OR ifCondition', () => { + const cond = { + ifCondition: { OR: [{ field: { name: 'a' }, fieldValue: '1' }, { field: { name: 'b' }, fieldValue: '2' }] }, + thenFields: [{ name: 'x', type: 'string', title: 'X', description: 'X', required: false }], + elseFields: [], + }; + const doc = SchemaHelper.buildDocument(schema(), [], [cond]); + assert.equal(doc.allOf[0].if.anyOf.length, 2); + }); +}); + +describe('@unit SchemaHelper.checkErrors — condition normalization edges', () => { + it('returns [] for an empty schema object', () => { + assert.deepEqual(SchemaHelper.checkErrors({}), []); + }); + + it('normalizes an IF-mode condition error target', () => { + const schema = { + conditions: [{ ifCondition: { field: { name: 'sel' }, fieldValue: 'yes' }, errors: [{ message: 'bad' }] }], + }; + const r = SchemaHelper.checkErrors(schema); + assert.equal(r.length, 1); + assert.equal(r[0].target.type, 'condition'); + assert.equal(r[0].target.mode, 'IF'); + assert.equal(r[0].target.field, 'sel'); + assert.equal(r[0].target.fieldValue, 'yes'); + }); + + it('normalizes an AND-mode condition error into predicates', () => { + const schema = { + conditions: [{ + ifCondition: { AND: [{ field: { name: 'a' }, fieldValue: '1' }, { field: { name: 'b' }, fieldValue: '2' }] }, + errors: [{ message: 'bad' }], + }], + }; + const r = SchemaHelper.checkErrors(schema); + assert.equal(r[0].target.mode, 'AND'); + assert.equal(r[0].target.predicates.length, 2); + }); + + it('keeps the condition index in the target', () => { + const schema = { + conditions: [ + { ifCondition: null, errors: [] }, + { ifCondition: { field: { name: 'x' }, fieldValue: 'y' }, errors: [{ message: 'e' }] }, + ], + }; + const r = SchemaHelper.checkErrors(schema); + assert.equal(r[0].target.index, 1); + }); + + it('tags field-level errors with the field name', () => { + const r = SchemaHelper.checkErrors({ fields: [{ name: 'fld', errors: [{ message: 'm' }] }] }); + assert.equal(r[0].target.type, 'field'); + assert.equal(r[0].target.field, 'fld'); + }); +}); + +describe('@unit SchemaHelper.getSchemaName — boundary', () => { + it('returns "" when nothing is supplied', () => { + assert.equal(SchemaHelper.getSchemaName(), ''); + }); + + it('coerces a missing name to an empty prefix but keeps version', () => { + assert.equal(SchemaHelper.getSchemaName(undefined, '1.0.0'), ' (1.0.0)'); + }); + + it('omits empty-string version and status', () => { + assert.equal(SchemaHelper.getSchemaName('N', '', ''), 'N'); + }); +}); diff --git a/interfaces/tests/schema-helper-extra.test.mjs b/interfaces/tests/schema-helper-extra.test.mjs new file mode 100644 index 0000000000..829f66010a --- /dev/null +++ b/interfaces/tests/schema-helper-extra.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.buildField — scalars', () => { + it('uses title/description and emits a $comment', () => { + const r = SchemaHelper.buildField( + { title: 'T', description: 'D', type: 'string', isArray: false, isRef: false }, + 'x', 'ctx', 0, + ); + assert.equal(r.title, 'T'); + assert.equal(r.description, 'D'); + assert.equal(r.type, 'string'); + assert.equal(r.readOnly, false); + assert.ok(r.$comment.includes('"term":"x"')); + }); + + it('falls back to the field name for title/description', () => { + const r = SchemaHelper.buildField({ type: 'string', isArray: false, isRef: false }, 'fallback', 'ctx'); + assert.equal(r.title, 'fallback'); + assert.equal(r.description, 'fallback'); + }); + + it('marks readOnly fields', () => { + const r = SchemaHelper.buildField({ type: 'string', isArray: false, isRef: false, readOnly: true }, 'x', 'ctx'); + assert.equal(r.readOnly, true); + }); + + it('passes through examples and default', () => { + const r = SchemaHelper.buildField( + { type: 'string', isArray: false, isRef: false, examples: ['e1'], default: 'dd' }, + 'x', 'ctx', + ); + assert.deepEqual(r.examples, ['e1']); + assert.equal(r.default, 'dd'); + }); + + it('emits enum / format / pattern when present', () => { + const r = SchemaHelper.buildField( + { type: 'string', isArray: false, isRef: false, enum: ['a', 'b'], format: 'email', pattern: '^x' }, + 'x', 'ctx', + ); + assert.deepEqual(r.enum, ['a', 'b']); + assert.equal(r.format, 'email'); + assert.equal(r.pattern, '^x'); + }); + + it('orderPosition is recorded in the comment', () => { + const r = SchemaHelper.buildField({ type: 'string', isArray: false, isRef: false }, 'x', 'ctx', 7); + assert.ok(r.$comment.includes('"orderPosition":7')); + }); +}); + +describe('SchemaHelper.buildField — arrays', () => { + it('wraps scalar in items for array fields', () => { + const r = SchemaHelper.buildField( + { type: 'string', isArray: true, isRef: false, enum: ['a'] }, 'y', 'ctx', + ); + assert.equal(r.type, 'array'); + assert.equal(r.items.type, 'string'); + assert.deepEqual(r.items.enum, ['a']); + assert.equal(r.items.format, undefined); + }); +}); + +describe('SchemaHelper.buildField — references', () => { + it('emits $ref at the property level for non-array refs', () => { + const r = SchemaHelper.buildField({ type: '#Foo', isArray: false, isRef: true }, 'z', 'ctx'); + assert.equal(r.$ref, '#Foo'); + assert.equal(r.type, undefined); + }); + + it('emits $ref inside items for array refs', () => { + const r = SchemaHelper.buildField({ type: '#Foo', isArray: true, isRef: true }, 'z', 'ctx'); + assert.equal(r.type, 'array'); + assert.equal(r.items.$ref, '#Foo'); + }); + + it('remoteLink becomes $ref for non-ref fields', () => { + const r = SchemaHelper.buildField( + { type: 'string', isArray: false, isRef: false, remoteLink: '#remote' }, 'z', 'ctx', + ); + assert.equal(r.$ref, '#remote'); + assert.equal(r.type, 'string'); + }); +}); + +describe('SchemaHelper.parseConditions', () => { + const fields = () => [{ name: 'sel', type: 'string' }, { name: 'extra', type: 'string' }]; + const doc = () => ({ + allOf: [{ + if: { properties: { sel: { const: 'yes' } } }, + then: { properties: { extra: { type: 'string', $comment: JSON.stringify({ term: 'extra', '@id': 'ctx' }) } } }, + else: { properties: {} }, + }], + }); + + it('returns [] for a null document', () => { + assert.deepEqual(SchemaHelper.parseConditions(null, 'ctx', [], new Map()), []); + }); + + it('returns [] when there is no allOf/anyOf', () => { + assert.deepEqual(SchemaHelper.parseConditions({ properties: {} }, 'ctx', [], new Map()), []); + }); + + it('parses a single-property if into an ifCondition predicate', () => { + const r = SchemaHelper.parseConditions(doc(), 'ctx', fields(), new Map()); + assert.equal(r.length, 1); + assert.equal(r[0].ifCondition.field.name, 'sel'); + assert.equal(r[0].ifCondition.fieldValue, 'yes'); + }); + + it('collects the then-branch fields', () => { + const r = SchemaHelper.parseConditions(doc(), 'ctx', fields(), new Map()); + assert.deepEqual(r[0].thenFields.map((f) => f.name), ['extra']); + }); + + it('skips nodes without an if clause', () => { + const d = { allOf: [{ then: { properties: {} } }] }; + assert.deepEqual(SchemaHelper.parseConditions(d, 'ctx', fields(), new Map()), []); + }); + + it('reads conditions from anyOf as well as allOf', () => { + const d = { + anyOf: [{ + if: { properties: { sel: { const: 'maybe' } } }, + then: { properties: {} }, + else: { properties: {} }, + }], + }; + const r = SchemaHelper.parseConditions(d, 'ctx', fields(), new Map()); + assert.equal(r.length, 1); + assert.equal(r[0].ifCondition.fieldValue, 'maybe'); + }); +}); diff --git a/interfaces/tests/schema-helper-getversion.test.mjs b/interfaces/tests/schema-helper-getversion.test.mjs new file mode 100644 index 0000000000..ccac965ac4 --- /dev/null +++ b/interfaces/tests/schema-helper-getversion.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.getVersion', () => { + it('extracts version and previousVersion from a populated document', () => { + const result = SchemaHelper.getVersion({ + document: { + $id: 'https://x#uuid-1&2.0.0', + $comment: '{"previousVersion":"1.5.0"}', + }, + }); + assert.equal(result.version, '2.0.0'); + assert.equal(result.previousVersion, '1.5.0'); + }); + + it('parses JSON-string documents', () => { + const result = SchemaHelper.getVersion({ + document: JSON.stringify({ + $id: 'https://x#uuid-1&3.0.0', + $comment: '{"previousVersion":"2.0.0"}', + }), + }); + assert.equal(result.version, '3.0.0'); + assert.equal(result.previousVersion, '2.0.0'); + }); + + it('returns null/null on parse failure (malformed input)', () => { + const result = SchemaHelper.getVersion({ document: 'not-json' }); + assert.equal(result.version, null); + assert.equal(result.previousVersion, null); + }); + + it('returns null/null when the document is missing', () => { + const result = SchemaHelper.getVersion({}); + assert.equal(result.version, null); + assert.equal(result.previousVersion, null); + }); +}); + +describe('SchemaHelper.updateObjectContext', () => { + it('stamps schema.type and @context onto the json', () => { + const json = SchemaHelper.updateObjectContext( + { type: 'uuid-1&1.0.0', contextURL: 'https://x', fields: [] }, + { existing: 'value' }, + ); + assert.equal(json.type, 'uuid-1&1.0.0'); + assert.deepEqual(json['@context'], ['https://x']); + assert.equal(json.existing, 'value'); + }); +}); + +describe('SchemaHelper.map', () => { + it('returns [] for null/undefined input', () => { + assert.deepEqual(SchemaHelper.map(null), []); + assert.deepEqual(SchemaHelper.map(undefined), []); + }); + + it('maps each ISchema to a Schema instance', () => { + const result = SchemaHelper.map([ + { + uuid: 'uuid-1', + document: { $id: 'https://x#uuid-1&1.0.0', $comment: '{}' }, + }, + ]); + assert.equal(result.length, 1); + assert.equal(typeof result[0], 'object'); + }); +}); diff --git a/interfaces/tests/schema-helper-misc.test.mjs b/interfaces/tests/schema-helper-misc.test.mjs new file mode 100644 index 0000000000..cd01eb3ead --- /dev/null +++ b/interfaces/tests/schema-helper-misc.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.getSchemaName', () => { + it('returns "" when nothing is supplied', () => { + assert.equal(SchemaHelper.getSchemaName(), ''); + }); + + it('returns just the name when only name is supplied', () => { + assert.equal(SchemaHelper.getSchemaName('Schema-1'), 'Schema-1'); + }); + + it('appends a parenthesised version when supplied', () => { + assert.equal( + SchemaHelper.getSchemaName('Schema-1', '1.0.0'), + 'Schema-1 (1.0.0)', + ); + }); + + it('appends both version and status, in that order', () => { + assert.equal( + SchemaHelper.getSchemaName('Schema-1', '1.0.0', 'PUBLISH'), + 'Schema-1 (1.0.0) (PUBLISH)', + ); + }); + + it('appends just status when version is missing', () => { + assert.equal( + SchemaHelper.getSchemaName('Schema-1', undefined, 'DRAFT'), + 'Schema-1 (DRAFT)', + ); + }); +}); + +describe('SchemaHelper.checkErrors', () => { + it('returns [] when there are no schema- or field-level errors', () => { + assert.deepEqual(SchemaHelper.checkErrors({}), []); + assert.deepEqual(SchemaHelper.checkErrors({ errors: [], fields: [] }), []); + }); + + it('marks schema-level errors with target.type="schema"', () => { + const schema = { errors: [{ message: 'oops' }], fields: [] }; + const result = SchemaHelper.checkErrors(schema); + assert.equal(result.length, 1); + assert.equal(result[0].target.type, 'schema'); + assert.equal(result[0].message, 'oops'); + }); + + it('flattens errors from each field array', () => { + const schema = { + errors: [], + fields: [ + { errors: [{ message: 'f1.bad' }] }, + { errors: [{ message: 'f2.bad' }] }, + ], + }; + const result = SchemaHelper.checkErrors(schema); + assert.equal(result.length, 2); + const messages = result.map((e) => e.message); + assert.ok(messages.includes('f1.bad')); + assert.ok(messages.includes('f2.bad')); + }); +}); diff --git a/interfaces/tests/schema-helper-parse-build-deep.test.mjs b/interfaces/tests/schema-helper-parse-build-deep.test.mjs new file mode 100644 index 0000000000..a3506a8bd4 --- /dev/null +++ b/interfaces/tests/schema-helper-parse-build-deep.test.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; + +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.parseProperty scalar shapes', () => { + it('parses a plain string property', () => { + const f = SchemaHelper.parseProperty('name', { type: 'string' }); + assert.equal(f.name, 'name'); + assert.equal(f.type, 'string'); + assert.equal(f.isArray, false); + assert.equal(f.isRef, false); + }); + + it('defaults title and description to the field name', () => { + const f = SchemaHelper.parseProperty('age', { type: 'number' }); + assert.equal(f.title, 'age'); + assert.equal(f.description, 'age'); + }); + + it('keeps explicit title and description', () => { + const f = SchemaHelper.parseProperty('age', { type: 'number', title: 'Age', description: 'Your age' }); + assert.equal(f.title, 'Age'); + assert.equal(f.description, 'Your age'); + }); + + it('captures format, pattern and enum', () => { + const f = SchemaHelper.parseProperty('d', { type: 'string', format: 'date', pattern: '^x', enum: ['a', 'b'] }); + assert.equal(f.format, 'date'); + assert.equal(f.pattern, '^x'); + assert.deepEqual(f.enum, ['a', 'b']); + }); + + it('captures the $comment', () => { + const f = SchemaHelper.parseProperty('c', { type: 'string', $comment: '{"unit":"kg"}' }); + assert.equal(f.comment, '{"unit":"kg"}'); + }); + + it('captures examples when present and array', () => { + const f = SchemaHelper.parseProperty('e', { type: 'string', examples: ['x', 'y'] }); + assert.deepEqual(f.examples, ['x', 'y']); + }); + + it('sets examples to null when not an array', () => { + const f = SchemaHelper.parseProperty('e', { type: 'string', examples: 'nope' }); + assert.equal(f.examples, null); + }); + + it('captures a default value', () => { + const f = SchemaHelper.parseProperty('e', { type: 'string', default: 'dv' }); + assert.equal(f.default, 'dv'); + }); + + it('marks readOnly from the property', () => { + const f = SchemaHelper.parseProperty('r', { type: 'string', readOnly: true }); + assert.equal(f.readOnly, true); + }); + + it('captures remoteLink ($ref alongside type)', () => { + const f = SchemaHelper.parseProperty('rl', { type: 'string', $ref: '#remote' }); + assert.equal(f.remoteLink, '#remote'); + assert.equal(f.isRef, false); + }); +}); + +describe('SchemaHelper.parseProperty array shapes', () => { + it('marks an array property and unwraps items', () => { + const f = SchemaHelper.parseProperty('list', { type: 'array', items: { type: 'string' } }); + assert.equal(f.isArray, true); + assert.equal(f.type, 'string'); + assert.equal(f.isRef, false); + }); + + it('handles an array of refs', () => { + const f = SchemaHelper.parseProperty('refs', { type: 'array', items: { $ref: '#Sub' } }); + assert.equal(f.isArray, true); + assert.equal(f.isRef, true); + assert.equal(f.type, '#Sub'); + }); +}); + +describe('SchemaHelper.parseProperty ref shapes', () => { + it('marks a ref-only property', () => { + const f = SchemaHelper.parseProperty('sub', { $ref: '#Sub' }); + assert.equal(f.isRef, true); + assert.equal(f.type, '#Sub'); + }); + + it('a ref with a type is not treated as a ref', () => { + const f = SchemaHelper.parseProperty('sub', { $ref: '#Sub', type: 'object' }); + assert.equal(f.isRef, false); + }); + + it('unwraps oneOf into the first entry', () => { + const f = SchemaHelper.parseProperty('o', { oneOf: [{ type: 'string', format: 'date' }] }); + assert.equal(f.type, 'string'); + assert.equal(f.format, 'date'); + }); + + it('propagates outer readOnly when oneOf is used', () => { + const f = SchemaHelper.parseProperty('o', { readOnly: true, oneOf: [{ type: 'string' }] }); + assert.equal(f.readOnly, true); + }); +}); + +describe('SchemaHelper.buildField scalar', () => { + function field(extra) { + return { title: 'T', description: 'D', type: 'string', ...extra }; + } + + it('builds a scalar property with title/description/type', () => { + const p = SchemaHelper.buildField(field(), 'n', 'http://c', 0); + assert.equal(p.title, 'T'); + assert.equal(p.description, 'D'); + assert.equal(p.type, 'string'); + assert.equal(p.readOnly, false); + }); + + it('defaults title and description to the name', () => { + const p = SchemaHelper.buildField({ type: 'string' }, 'theName', 'http://c', 0); + assert.equal(p.title, 'theName'); + assert.equal(p.description, 'theName'); + }); + + it('carries examples when present', () => { + const p = SchemaHelper.buildField(field({ examples: ['a'] }), 'n', 'http://c', 0); + assert.deepEqual(p.examples, ['a']); + }); + + it('omits examples when absent', () => { + const p = SchemaHelper.buildField(field(), 'n', 'http://c', 0); + assert.equal('examples' in p, false); + }); + + it('carries a truthy default', () => { + const p = SchemaHelper.buildField(field({ default: 'dv' }), 'n', 'http://c', 0); + assert.equal(p.default, 'dv'); + }); + + it('omits a falsy default', () => { + const p = SchemaHelper.buildField(field({ default: '' }), 'n', 'http://c', 0); + assert.equal('default' in p, false); + }); + + it('marks readOnly when flagged', () => { + const p = SchemaHelper.buildField(field({ readOnly: true }), 'n', 'http://c', 0); + assert.equal(p.readOnly, true); + }); + + it('writes enum, format and pattern', () => { + const p = SchemaHelper.buildField(field({ enum: ['a'], format: 'date', pattern: '^x' }), 'n', 'http://c', 0); + assert.deepEqual(p.enum, ['a']); + assert.equal(p.format, 'date'); + assert.equal(p.pattern, '^x'); + }); + + it('writes a remoteLink $ref alongside the type', () => { + const p = SchemaHelper.buildField(field({ remoteLink: '#remote' }), 'n', 'http://c', 0); + assert.equal(p.$ref, '#remote'); + assert.equal(p.type, 'string'); + }); + + it('always attaches a $comment', () => { + const p = SchemaHelper.buildField(field(), 'n', 'http://c', 0); + assert.ok(p.$comment); + }); +}); + +describe('SchemaHelper.buildField array and ref', () => { + it('builds an array property whose items carry the type', () => { + const p = SchemaHelper.buildField({ isArray: true, type: 'string', title: 'T' }, 'n', 'http://c', 0); + assert.equal(p.type, 'array'); + assert.equal(p.items.type, 'string'); + }); + + it('builds an array of refs', () => { + const p = SchemaHelper.buildField({ isArray: true, isRef: true, type: '#Sub' }, 'n', 'http://c', 0); + assert.equal(p.type, 'array'); + assert.equal(p.items.$ref, '#Sub'); + }); + + it('builds a scalar ref', () => { + const p = SchemaHelper.buildField({ isRef: true, type: '#Sub' }, 'n', 'http://c', 0); + assert.equal(p.$ref, '#Sub'); + assert.equal(p.type, undefined); + }); +}); + +describe('SchemaHelper parseProperty -> buildField round-trip', () => { + const scalarShapes = [ + { type: 'string' }, + { type: 'number' }, + { type: 'integer' }, + { type: 'boolean' }, + ]; + for (const shape of scalarShapes) { + it(`preserves type ${shape.type} across parse->build`, () => { + const parsed = SchemaHelper.parseProperty('f', shape); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.equal(built.type, shape.type); + }); + } + + it('preserves format across parse->build', () => { + const parsed = SchemaHelper.parseProperty('f', { type: 'string', format: 'date-time' }); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.equal(built.format, 'date-time'); + }); + + it('preserves enum across parse->build', () => { + const parsed = SchemaHelper.parseProperty('f', { type: 'string', enum: ['x', 'y'] }); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.deepEqual(built.enum, ['x', 'y']); + }); + + it('preserves pattern across parse->build', () => { + const parsed = SchemaHelper.parseProperty('f', { type: 'string', pattern: '^[a-z]+$' }); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.equal(built.pattern, '^[a-z]+$'); + }); + + it('preserves isArray across parse->build', () => { + const parsed = SchemaHelper.parseProperty('f', { type: 'array', items: { type: 'string' } }); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.equal(built.type, 'array'); + assert.equal(built.items.type, 'string'); + }); + + it('preserves a ref across parse->build', () => { + const parsed = SchemaHelper.parseProperty('f', { $ref: '#Sub' }); + const built = SchemaHelper.buildField(parsed, 'f', 'http://c', 0); + assert.equal(built.$ref, '#Sub'); + }); +}); + +describe('SchemaHelper.cloneFields', () => { + it('returns a deep clone array of flat fields', () => { + const fields = [{ name: 'a' }, { name: 'b' }]; + const cloned = SchemaHelper.cloneFields(fields); + assert.notEqual(cloned, fields); + assert.notEqual(cloned[0], fields[0]); + assert.deepEqual(cloned, fields); + }); + + it('recursively clones nested fields', () => { + const fields = [{ name: 'a', fields: [{ name: 'a1' }] }]; + const cloned = SchemaHelper.cloneFields(fields); + assert.notEqual(cloned[0].fields, fields[0].fields); + assert.notEqual(cloned[0].fields[0], fields[0].fields[0]); + cloned[0].fields[0].name = 'changed'; + assert.equal(fields[0].fields[0].name, 'a1'); + }); + + it('returns an empty array for empty input', () => { + assert.deepEqual(SchemaHelper.cloneFields([]), []); + }); +}); + +describe('SchemaHelper.getFieldsFromObject', () => { + it('builds properties and collects required keys', () => { + const required = []; + const properties = {}; + const fields = [ + { name: 'a', type: 'string', required: true }, + { name: 'b', type: 'number', required: false }, + ]; + SchemaHelper.getFieldsFromObject(fields, required, properties, 'http://c'); + assert.ok(properties.a); + assert.ok(properties.b); + assert.deepEqual(required, ['a']); + }); + + it('throws when a field name contains a space', () => { + assert.throws( + () => SchemaHelper.getFieldsFromObject([{ name: 'bad name', type: 'string' }], [], {}, 'http://c'), + /must not contain spaces/ + ); + }); + + it('does not overwrite an existing property of the same name', () => { + const properties = { a: { sentinel: true } }; + SchemaHelper.getFieldsFromObject([{ name: 'a', type: 'string', required: true }], [], properties, 'http://c'); + assert.equal(properties.a.sentinel, true); + }); +}); diff --git a/interfaces/tests/schema-helper-parse-field.test.mjs b/interfaces/tests/schema-helper-parse-field.test.mjs new file mode 100644 index 0000000000..26c2835112 --- /dev/null +++ b/interfaces/tests/schema-helper-parse-field.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +const commentJson = (overrides) => JSON.stringify(overrides); + +describe('SchemaHelper.parseField (full meta extraction)', () => { + it('hydrates a non-ref field with unit/customType/font fields from $comment', () => { + const prop = { + type: 'number', + $comment: commentJson({ + unit: 'kg', + unitSystem: 'metric', + customType: 'enum', + textColor: '#fff', + textSize: 14, + textBold: true, + hidden: true, + expression: 'a+b', + orderPosition: 3, + availableOptions: ['A', 'B'], + isPrivate: true, + suggest: 'hint', + autocalculate: true, + isUpdatable: true, + property: 'foo.bar', + }), + }; + const field = SchemaHelper.parseField('weight', prop, true, 'https://x'); + assert.equal(field.name, 'weight'); + assert.equal(field.required, true); + assert.equal(field.unit, 'kg'); + assert.equal(field.unitSystem, 'metric'); + assert.equal(field.customType, 'enum'); + assert.equal(field.textColor, '#fff'); + assert.equal(field.textSize, 14); + assert.equal(field.textBold, true); + assert.equal(field.hidden, true); + assert.equal(field.expression, 'a+b'); + assert.equal(field.order, 3); + assert.deepEqual(field.availableOptions, ['A', 'B']); + assert.equal(field.isPrivate, true); + assert.equal(field.suggest, 'hint'); + assert.equal(field.autocalculate, true); + assert.equal(field.isUpdatable, true); + assert.equal(field.property, 'foo.bar'); + assert.deepEqual(field.font, { color: '#fff', size: 14, bold: true }); + }); + + it('skips font building when no text* attrs are present', () => { + const prop = { type: 'string', $comment: commentJson({}) }; + const field = SchemaHelper.parseField('name', prop, false, 'https://x'); + assert.equal(field.font, undefined); + }); + + it('records context for ref fields and skips unit/font assignments', () => { + const prop = { $ref: '#child', $comment: commentJson({}) }; + const field = SchemaHelper.parseField('child', prop, false, 'https://ctx'); + assert.equal(field.isRef, true); + // parseRef strips the leading '#' off the type segment when reading from a string. + assert.deepEqual(field.context, { type: 'child', context: ['https://ctx'] }); + // The non-ref branch sets `unit` to null when missing; for ref fields + // the unit assignment is skipped — but `parseProperty` initialises unit + // to null, so the residual value is null. + assert.equal(field.unit, null); + assert.equal(field.font, undefined); + }); + + it('coerces order to -1 when orderPosition is missing or zero/falsy', () => { + const prop = { type: 'string', $comment: commentJson({}) }; + const field = SchemaHelper.parseField('a', prop, false, ''); + assert.equal(field.order, -1); + + const prop2 = { type: 'string', $comment: commentJson({ orderPosition: 0 }) }; + const f2 = SchemaHelper.parseField('a', prop2, false, ''); + assert.equal(f2.order, -1); + }); + + it('coerces hidden / autocalculate to boolean using !!', () => { + const prop = { type: 'string', $comment: commentJson({ hidden: 'truthy', autocalculate: 1 }) }; + const field = SchemaHelper.parseField('a', prop, false, ''); + assert.equal(field.hidden, true); + assert.equal(field.autocalculate, true); + }); + + it('returns property/customType as null when missing in $comment', () => { + const prop = { type: 'string', $comment: commentJson({}) }; + const field = SchemaHelper.parseField('a', prop, false, ''); + assert.equal(field.property, null); + assert.equal(field.customType, null); + }); +}); diff --git a/interfaces/tests/schema-helper-parse.test.mjs b/interfaces/tests/schema-helper-parse.test.mjs new file mode 100644 index 0000000000..73fe6e7b71 --- /dev/null +++ b/interfaces/tests/schema-helper-parse.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.parseProperty', () => { + it('parses a basic string property', () => { + const field = SchemaHelper.parseProperty('username', { + type: 'string', + title: 'User Name', + description: 'The user', + }); + assert.equal(field.name, 'username'); + assert.equal(field.type, 'string'); + assert.equal(field.title, 'User Name'); + assert.equal(field.description, 'The user'); + assert.equal(field.isArray, false); + assert.equal(field.isRef, false); + assert.equal(field.readOnly, false); + }); + + it('falls back to name for missing title/description', () => { + const field = SchemaHelper.parseProperty('age', { type: 'integer' }); + assert.equal(field.title, 'age'); + assert.equal(field.description, 'age'); + }); + + it('detects array type and unwraps to items', () => { + const field = SchemaHelper.parseProperty('tags', { + type: 'array', + items: { type: 'string' }, + }); + assert.equal(field.isArray, true); + assert.equal(field.type, 'string'); + }); + + it('detects $ref-only properties as references', () => { + const field = SchemaHelper.parseProperty('child', { $ref: '#/$defs/Child' }); + assert.equal(field.isRef, true); + assert.equal(field.type, '#/$defs/Child'); + }); + + it('captures format and pattern when present', () => { + const field = SchemaHelper.parseProperty('birth', { + type: 'string', + format: 'date', + pattern: '\\d{4}-\\d{2}-\\d{2}', + }); + assert.equal(field.format, 'date'); + assert.equal(field.pattern, '\\d{4}-\\d{2}-\\d{2}'); + }); + + it('captures readOnly flag', () => { + const field = SchemaHelper.parseProperty('id', { type: 'string', readOnly: true }); + assert.equal(field.readOnly, true); + }); + + it('captures examples when an array is provided', () => { + const field = SchemaHelper.parseProperty('greet', { + type: 'string', + examples: ['hi', 'hello'], + }); + assert.deepEqual(field.examples, ['hi', 'hello']); + }); + + it('ignores non-array examples', () => { + const field = SchemaHelper.parseProperty('greet', { + type: 'string', + examples: 'hi', + }); + assert.equal(field.examples, null); + }); + + it('captures $comment as comment', () => { + const field = SchemaHelper.parseProperty('notes', { + type: 'string', + $comment: 'internal use', + }); + assert.equal(field.comment, 'internal use'); + }); + + it('captures default value', () => { + const field = SchemaHelper.parseProperty('count', { type: 'integer', default: 42 }); + assert.equal(field.default, 42); + }); +}); diff --git a/interfaces/tests/schema-helper-pure-helpers-deep.test.mjs b/interfaces/tests/schema-helper-pure-helpers-deep.test.mjs new file mode 100644 index 0000000000..f92f239846 --- /dev/null +++ b/interfaces/tests/schema-helper-pure-helpers-deep.test.mjs @@ -0,0 +1,366 @@ +import assert from 'node:assert/strict'; + +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; +import { Schema } from '../dist/models/schema.js'; + +describe('SchemaHelper.buildType', () => { + it('returns uuid alone when version is falsy', () => { + assert.equal(SchemaHelper.buildType('uuid-1', null), 'uuid-1'); + assert.equal(SchemaHelper.buildType('uuid-1', undefined), 'uuid-1'); + assert.equal(SchemaHelper.buildType('uuid-1', ''), 'uuid-1'); + assert.equal(SchemaHelper.buildType('uuid-1', 0), 'uuid-1'); + }); + + it('joins uuid and version with an ampersand', () => { + assert.equal(SchemaHelper.buildType('uuid-1', '1.0.0'), 'uuid-1&1.0.0'); + assert.equal(SchemaHelper.buildType('abc', '2.3.4'), 'abc&2.3.4'); + }); +}); + +describe('SchemaHelper.buildRef', () => { + it('prefixes the type with a hash', () => { + assert.equal(SchemaHelper.buildRef('uuid-1&1.0.0'), '#uuid-1&1.0.0'); + assert.equal(SchemaHelper.buildRef('x'), '#x'); + assert.equal(SchemaHelper.buildRef(''), '#'); + }); +}); + +describe('SchemaHelper.buildUrl', () => { + it('concatenates context url and ref', () => { + assert.equal(SchemaHelper.buildUrl('http://c/', '#x'), 'http://c/#x'); + }); + + it('treats falsy context url as empty', () => { + assert.equal(SchemaHelper.buildUrl(null, '#x'), '#x'); + assert.equal(SchemaHelper.buildUrl(undefined, '#x'), '#x'); + }); + + it('treats falsy ref as empty', () => { + assert.equal(SchemaHelper.buildUrl('http://c', null), 'http://c'); + assert.equal(SchemaHelper.buildUrl('http://c', undefined), 'http://c'); + }); + + it('returns empty when both are falsy', () => { + assert.equal(SchemaHelper.buildUrl(null, null), ''); + assert.equal(SchemaHelper.buildUrl('', ''), ''); + }); +}); + +describe('SchemaHelper.getSchemaName', () => { + it('returns just the name when no version or status', () => { + assert.equal(SchemaHelper.getSchemaName('My Schema'), 'My Schema'); + }); + + it('returns empty string for falsy name', () => { + assert.equal(SchemaHelper.getSchemaName(null), ''); + assert.equal(SchemaHelper.getSchemaName(undefined), ''); + assert.equal(SchemaHelper.getSchemaName(''), ''); + }); + + it('appends version in parentheses', () => { + assert.equal(SchemaHelper.getSchemaName('S', '1.0.0'), 'S (1.0.0)'); + }); + + it('appends status in parentheses', () => { + assert.equal(SchemaHelper.getSchemaName('S', null, 'DRAFT'), 'S (DRAFT)'); + }); + + it('appends both version and status in order', () => { + assert.equal(SchemaHelper.getSchemaName('S', '1.0.0', 'PUBLISHED'), 'S (1.0.0) (PUBLISHED)'); + }); +}); + +describe('SchemaHelper.buildSchemaComment', () => { + it('omits previousVersion when version is falsy', () => { + const out = SchemaHelper.buildSchemaComment('t', 'u', null); + assert.deepEqual(JSON.parse(out), { '@id': 'u', term: 't' }); + }); + + it('includes previousVersion when provided', () => { + const out = SchemaHelper.buildSchemaComment('t', 'u', '1.0.0'); + assert.deepEqual(JSON.parse(out), { '@id': 'u', term: 't', previousVersion: '1.0.0' }); + }); +}); + +describe('SchemaHelper.parseSchemaComment', () => { + it('parses a valid JSON comment', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment('{"a":1}'), { a: 1 }); + }); + + it('returns {} for invalid JSON', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment('{bad'), {}); + }); + + it('returns {} for null/undefined', () => { + assert.deepEqual(SchemaHelper.parseSchemaComment(null), {}); + assert.deepEqual(SchemaHelper.parseSchemaComment(undefined), {}); + }); + + it('round-trips a built comment', () => { + const built = SchemaHelper.buildSchemaComment('term-1', 'http://u', '0.9.0'); + const parsed = SchemaHelper.parseSchemaComment(built); + assert.equal(parsed.term, 'term-1'); + assert.equal(parsed['@id'], 'http://u'); + assert.equal(parsed.previousVersion, '0.9.0'); + }); +}); + +describe('SchemaHelper.parseFieldComment', () => { + it('parses a valid JSON field comment', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('{"unit":"kg"}'), { unit: 'kg' }); + }); + + it('returns {} for invalid JSON', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('not json'), {}); + }); + + it('returns {} for falsy input', () => { + assert.deepEqual(SchemaHelper.parseFieldComment(null), {}); + assert.deepEqual(SchemaHelper.parseFieldComment(''), {}); + }); + + it('returns {} when JSON parses to null literal', () => { + assert.deepEqual(SchemaHelper.parseFieldComment('null'), {}); + }); +}); + +describe('SchemaHelper.checkSchemaKey', () => { + it('returns true when there are no properties', () => { + assert.equal(SchemaHelper.checkSchemaKey({}), true); + assert.equal(SchemaHelper.checkSchemaKey({ document: {} }), true); + assert.equal(SchemaHelper.checkSchemaKey(null), true); + }); + + it('returns true for clean property keys', () => { + const schema = { document: { properties: { field1: {}, field_2: {} } } }; + assert.equal(SchemaHelper.checkSchemaKey(schema), true); + }); + + it('throws when a property key contains a space', () => { + const schema = { document: { properties: { 'bad key': {} } } }; + assert.throws(() => SchemaHelper.checkSchemaKey(schema), /must not contain spaces/); + }); + + it('throws when a property key contains a tab', () => { + const schema = { document: { properties: { 'bad\tkey': {} } } }; + assert.throws(() => SchemaHelper.checkSchemaKey(schema), /must not contain spaces/); + }); +}); + +describe('SchemaHelper.parseRef', () => { + it('parses a full iri string with version', () => { + const r = SchemaHelper.parseRef('#uuidA&1.2.3'); + assert.equal(r.iri, '#uuidA&1.2.3'); + assert.equal(r.type, 'uuidA&1.2.3'); + assert.equal(r.uuid, 'uuidA'); + assert.equal(r.version, '1.2.3'); + }); + + it('parses an iri string without a version', () => { + const r = SchemaHelper.parseRef('#uuidA'); + assert.equal(r.uuid, 'uuidA'); + assert.equal(r.version, null); + }); + + it('returns nulls for null input', () => { + assert.deepEqual(SchemaHelper.parseRef(null), { iri: null, type: null, uuid: null, version: null }); + }); + + it('returns nulls for an empty string', () => { + assert.deepEqual(SchemaHelper.parseRef(''), { iri: null, type: null, uuid: null, version: null }); + }); + + it('parses an object with an object document', () => { + const r = SchemaHelper.parseRef({ document: { $id: '#u2&2.0.0' } }); + assert.equal(r.uuid, 'u2'); + assert.equal(r.version, '2.0.0'); + }); + + it('parses an object with a string document', () => { + const r = SchemaHelper.parseRef({ document: JSON.stringify({ $id: '#u3&3.0.0' }) }); + assert.equal(r.uuid, 'u3'); + assert.equal(r.version, '3.0.0'); + }); + + it('returns nulls when the object document has no $id', () => { + const r = SchemaHelper.parseRef({ document: {} }); + assert.deepEqual(r, { iri: null, type: null, uuid: null, version: null }); + }); + + it('returns nulls when document string is unparsable', () => { + const r = SchemaHelper.parseRef({ document: '{not json' }); + assert.deepEqual(r, { iri: null, type: null, uuid: null, version: null }); + }); +}); + +describe('SchemaHelper.getContext', () => { + it('builds a context object from an item iri', () => { + const ctx = SchemaHelper.getContext({ iri: '#abc&1.0.0', contextURL: 'http://x' }); + assert.equal(ctx.type, 'abc&1.0.0'); + assert.deepEqual(ctx['@context'], ['http://x']); + }); + + it('returns null when item is null', () => { + assert.equal(SchemaHelper.getContext(null), null); + }); + + it('returns a context even when contextURL is undefined', () => { + const ctx = SchemaHelper.getContext({ iri: '#abc&1.0.0' }); + assert.deepEqual(ctx['@context'], [undefined]); + }); +}); + +describe('SchemaHelper.incrementVersion', () => { + it('returns 1.0.0 when there is no previous version and no versions', () => { + assert.equal(SchemaHelper.incrementVersion(null, []), '1.0.0'); + }); + + it('bumps the patch of the previous version when alone', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', []), '1.0.1'); + assert.equal(SchemaHelper.incrementVersion('2.3.4', []), '2.3.5'); + }); + + it('takes the max patch within the same major.minor group', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', ['1.0.2', '1.0.9']), '1.0.10'); + }); + + it('ignores falsy entries in the versions list', () => { + assert.equal(SchemaHelper.incrementVersion('1.0.0', [null, '', '1.0.5']), '1.0.6'); + }); + + it('isolates groups by major.minor prefix', () => { + assert.equal(SchemaHelper.incrementVersion('2.0.0', ['1.0.99']), '2.0.1'); + }); +}); + +describe('SchemaHelper.validate', () => { + it('returns true for a complete schema with object document', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: { $id: '#x' } }), true); + }); + + it('returns true for a complete schema with string document', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: JSON.stringify({ $id: '#x' }) }), true); + }); + + it('returns false when name is missing', () => { + assert.equal(SchemaHelper.validate({ uuid: 'u', document: { $id: '#x' } }), false); + }); + + it('returns false when uuid is missing', () => { + assert.equal(SchemaHelper.validate({ name: 'n', document: { $id: '#x' } }), false); + }); + + it('returns false when document is missing', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u' }), false); + }); + + it('returns false when document has no $id', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: {} }), false); + }); + + it('returns false when document string is unparsable', () => { + assert.equal(SchemaHelper.validate({ name: 'n', uuid: 'u', document: '{bad' }), false); + }); +}); + +describe('SchemaHelper.map', () => { + it('returns [] for falsy input', () => { + assert.deepEqual(SchemaHelper.map(null), []); + assert.deepEqual(SchemaHelper.map(undefined), []); + }); + + it('returns [] for an empty array', () => { + assert.deepEqual(SchemaHelper.map([]), []); + }); + + it('wraps each element into a Schema instance', () => { + const result = SchemaHelper.map([{ name: 'a' }, { name: 'b' }]); + assert.equal(result.length, 2); + assert.ok(result[0] instanceof Schema); + assert.ok(result[1] instanceof Schema); + }); +}); + +describe('SchemaHelper.uniqueRefs', () => { + it('copies entries and strips $defs at the top level', () => { + const map = { '#A': { title: 'A', $defs: { '#B': { title: 'B' } } } }; + const out = SchemaHelper.uniqueRefs(map, {}); + assert.equal(out['#A'].title, 'A'); + assert.equal(out['#A'].$defs, undefined); + }); + + it('recursively flattens nested $defs into the result', () => { + const map = { '#A': { title: 'A', $defs: { '#B': { title: 'B' } } } }; + const out = SchemaHelper.uniqueRefs(map, {}); + assert.equal(out['#B'].title, 'B'); + }); + + it('does not overwrite existing keys in newMap', () => { + const newMap = { '#A': { title: 'existing' } }; + const out = SchemaHelper.uniqueRefs({ '#A': { title: 'new' } }, newMap); + assert.equal(out['#A'].title, 'existing'); + }); + + it('handles entries without $defs', () => { + const out = SchemaHelper.uniqueRefs({ '#A': { title: 'A' } }, {}); + assert.deepEqual(out, { '#A': { title: 'A' } }); + }); +}); + +describe('SchemaHelper.findRefs', () => { + it('returns built-in GeoJSON when a field references it', () => { + const target = { fields: [{ isRef: true, type: '#GeoJSON' }] }; + const out = SchemaHelper.findRefs(target, []); + assert.ok(out['#GeoJSON']); + }); + + it('returns built-in SentinelHUB when referenced', () => { + const target = { fields: [{ isRef: true, type: '#SentinelHUB' }] }; + const out = SchemaHelper.findRefs(target, []); + assert.ok(out['#SentinelHUB']); + }); + + it('resolves a ref to a schema in the provided list', () => { + const target = { fields: [{ isRef: true, type: '#mySchema' }] }; + const out = SchemaHelper.findRefs(target, [{ iri: '#mySchema', document: { title: 'mine' } }]); + assert.equal(out['#mySchema'].title, 'mine'); + }); + + it('ignores non-ref fields', () => { + const target = { fields: [{ isRef: false, type: 'string' }] }; + const out = SchemaHelper.findRefs(target, []); + assert.deepEqual(out, {}); + }); + + it('ignores ref fields whose type is unknown', () => { + const target = { fields: [{ isRef: true, type: '#unknown' }] }; + const out = SchemaHelper.findRefs(target, []); + assert.deepEqual(out, {}); + }); +}); + +describe('SchemaHelper.getVersion', () => { + const doc = { $id: '#uuidA&1.0.0', $comment: '{ "previousVersion": "0.9.0" }' }; + + it('extracts version and previousVersion from an object document', () => { + const v = SchemaHelper.getVersion({ document: doc }); + assert.equal(v.version, '1.0.0'); + assert.equal(v.previousVersion, '0.9.0'); + }); + + it('extracts from a string document', () => { + const v = SchemaHelper.getVersion({ document: JSON.stringify(doc) }); + assert.equal(v.version, '1.0.0'); + assert.equal(v.previousVersion, '0.9.0'); + }); + + it('returns nulls for an unparsable document', () => { + assert.deepEqual(SchemaHelper.getVersion({ document: '{bad' }), { version: null, previousVersion: null }); + }); + + it('returns null previousVersion when comment lacks it', () => { + const v = SchemaHelper.getVersion({ document: { $id: '#u&2.0.0' } }); + assert.equal(v.version, '2.0.0'); + assert.equal(v.previousVersion, undefined); + }); +}); diff --git a/interfaces/tests/schema-helper-set-version-iri.test.mjs b/interfaces/tests/schema-helper-set-version-iri.test.mjs new file mode 100644 index 0000000000..d7b8a35639 --- /dev/null +++ b/interfaces/tests/schema-helper-set-version-iri.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.setVersion', () => { + it('rebuilds $id, $comment and stamps version', () => { + const data = { + uuid: 'uuid-1', + contextURL: 'https://x', + document: { $id: 'old-id', $comment: '{}' }, + }; + const result = SchemaHelper.setVersion(data, '2.1.0', '2.0.0'); + assert.equal(result.version, '2.1.0'); + assert.equal(result.document.$id, '#uuid-1&2.1.0'); + const comment = JSON.parse(result.document.$comment); + assert.equal(comment.previousVersion, '2.0.0'); + }); + + it('parses a JSON-string document and reassigns it as an object', () => { + const data = { + uuid: 'uuid-1', + contextURL: '', + document: JSON.stringify({ $id: 'x' }), + }; + const result = SchemaHelper.setVersion(data, '1.1.0', '1.0.0'); + assert.equal(typeof result.document, 'object'); + }); +}); + +describe('SchemaHelper.updateIRI', () => { + it('reads iri from existing document.$id', () => { + const result = SchemaHelper.updateIRI({ + document: { $id: '#existing-id' }, + }); + assert.equal(result.iri, '#existing-id'); + }); + + it('parses JSON-string document', () => { + const result = SchemaHelper.updateIRI({ + document: JSON.stringify({ $id: '#parsed' }), + }); + assert.equal(result.iri, '#parsed'); + }); + + it('sets iri to null when document is present but $id is missing', () => { + const result = SchemaHelper.updateIRI({ document: {} }); + assert.equal(result.iri, null); + }); + + it('builds iri from uuid+version when no document is present', () => { + const result = SchemaHelper.updateIRI({ uuid: 'u', version: '1.0.0' }); + assert.equal(result.iri, '#u&1.0.0'); + }); + + it('returns iri=null on a parse error (malformed JSON document)', () => { + const result = SchemaHelper.updateIRI({ document: 'not-json' }); + assert.equal(result.iri, null); + }); +}); diff --git a/interfaces/tests/schema-helper-update-fields.test.mjs b/interfaces/tests/schema-helper-update-fields.test.mjs new file mode 100644 index 0000000000..37a0ffbb95 --- /dev/null +++ b/interfaces/tests/schema-helper-update-fields.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.updateFields', () => { + it('returns the input unchanged when document has no properties', () => { + const result = SchemaHelper.updateFields(null, () => ({})); + assert.equal(result, null); + + const noProps = { foo: 'bar' }; + assert.equal(SchemaHelper.updateFields(noProps, () => ({})), noProps); + }); + + it('applies the transform to each property by name', () => { + const doc = { + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + }; + const seen = []; + const result = SchemaHelper.updateFields(doc, (name, prop) => { + seen.push(name); + return { ...prop, touched: true }; + }); + assert.deepEqual(seen.sort(), ['a', 'b']); + assert.equal(result.properties.a.touched, true); + assert.equal(result.properties.b.touched, true); + }); + + it('mutates the same document object (returns identity)', () => { + const doc = { properties: { a: { type: 'string' } } }; + const result = SchemaHelper.updateFields(doc, (_, p) => ({ ...p, marker: 1 })); + assert.equal(result, doc); + assert.equal(doc.properties.a.marker, 1); + }); +}); diff --git a/interfaces/tests/schema-helper-update-version.test.mjs b/interfaces/tests/schema-helper-update-version.test.mjs new file mode 100644 index 0000000000..d738723e54 --- /dev/null +++ b/interfaces/tests/schema-helper-update-version.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +const baseSchema = () => ({ + uuid: 'uuid-1', + version: '1.0.0', + contextURL: 'https://x', + creator: 'did:creator', + owner: 'did:owner', + document: { + $id: 'https://x#uuid-1&1.0.0', + $comment: '{"previousVersion":"1.0.0"}', + }, +}); + +describe('SchemaHelper.updateVersion', () => { + it('rejects an invalid version string', () => { + const data = baseSchema(); + assert.throws( + () => SchemaHelper.updateVersion(data, 'v2.0'), + /Invalid version format/, + ); + }); + + it('rejects when new version is not greater than previousVersion', () => { + const data = baseSchema(); + assert.throws( + () => SchemaHelper.updateVersion(data, '1.0.0'), + /Version must be greater than 1\.0\.0/, + ); + assert.throws( + () => SchemaHelper.updateVersion(data, '0.9.0'), + /Version must be greater than 1\.0\.0/, + ); + }); + + it('accepts a strictly newer version and rebuilds $id', () => { + const data = baseSchema(); + const result = SchemaHelper.updateVersion(data, '1.1.0'); + assert.equal(result.version, '1.1.0'); + assert.equal(result.document.$id, '#uuid-1&1.1.0'); + }); + + it('uses creator (or owner fallback) for owner/creator after update', () => { + const data = baseSchema(); + delete data.creator; + const result = SchemaHelper.updateVersion(data, '1.1.0'); + assert.equal(result.creator, 'did:owner'); + assert.equal(result.owner, 'did:owner'); + }); +}); diff --git a/interfaces/tests/schema-helper-validate.test.mjs b/interfaces/tests/schema-helper-validate.test.mjs new file mode 100644 index 0000000000..9f3f53b517 --- /dev/null +++ b/interfaces/tests/schema-helper-validate.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.validate', () => { + const valid = { + name: 'foo', + uuid: 'uuid-1', + document: { $id: 'https://x#uuid-1' }, + }; + + it('passes a fully populated object schema', () => { + assert.equal(SchemaHelper.validate(valid), true); + }); + + it('passes when document is a JSON string', () => { + const schema = { ...valid, document: JSON.stringify(valid.document) }; + assert.equal(SchemaHelper.validate(schema), true); + }); + + it('fails when name is missing', () => { + assert.equal(SchemaHelper.validate({ ...valid, name: '' }), false); + }); + + it('fails when uuid is missing', () => { + assert.equal(SchemaHelper.validate({ ...valid, uuid: '' }), false); + }); + + it('fails when document is missing', () => { + assert.equal(SchemaHelper.validate({ ...valid, document: null }), false); + }); + + it('fails when document.$id is missing', () => { + assert.equal( + SchemaHelper.validate({ ...valid, document: { foo: 'bar' } }), + false, + ); + }); + + it('returns false for malformed JSON document', () => { + assert.equal( + SchemaHelper.validate({ ...valid, document: 'not-json' }), + false, + ); + }); +}); + +describe('SchemaHelper.updatePermission', () => { + it('marks isOwner / isCreator based on the supplied owner identity', () => { + const data = [ + { owner: 'alice', creator: 'alice' }, + { owner: 'bob', creator: 'alice' }, + { owner: null, creator: null }, + ]; + SchemaHelper.updatePermission(data, { owner: 'alice', creator: 'alice' }); + assert.equal(data[0].isOwner, true); + assert.equal(data[0].isCreator, true); + assert.equal(data[1].isOwner, false); + assert.equal(data[1].isCreator, true); + // For falsy owner/creator the implementation short-circuits and + // assigns the falsy value itself (null), not literally false. + assert.ok(!data[2].isOwner); + assert.ok(!data[2].isCreator); + }); +}); + +describe('SchemaHelper.updateOwner', () => { + it('rebuilds $id, $comment, and stamps owner/creator from the owner record', () => { + const data = { + uuid: 'uuid-1', + version: '1.0.0', + contextURL: 'https://x', + document: { + $id: 'https://x#uuid-1&1.0.0', + $comment: '{"previousVersion":"0.9.0"}', + }, + }; + const result = SchemaHelper.updateOwner(data, { + owner: 'did:owner', creator: 'did:creator', username: 'fallback', + }); + assert.equal(result.owner, 'did:owner'); + assert.equal(result.creator, 'did:creator'); + // $id rebuilt from uuid + version. + assert.equal(result.document.$id, '#uuid-1&1.0.0'); + }); + + it('falls back to username when owner/creator are missing', () => { + const data = { + uuid: 'uuid-1', + version: '1.0.0', + contextURL: '', + document: { $id: 'https://x#uuid-1&1.0.0' }, + }; + const result = SchemaHelper.updateOwner(data, { username: 'alice' }); + assert.equal(result.owner, 'alice'); + assert.equal(result.creator, 'alice'); + }); + + it('parses a JSON-string document and reassigns it as an object', () => { + const data = { + uuid: 'uuid-1', + version: '1.0.0', + contextURL: '', + document: JSON.stringify({ $id: 'https://x#uuid-1&1.0.0' }), + }; + const result = SchemaHelper.updateOwner(data, { username: 'alice' }); + assert.equal(typeof result.document, 'object'); + assert.equal(result.document.$id, '#uuid-1&1.0.0'); + }); +}); diff --git a/interfaces/tests/schema-helper-version-ops-deep.test.mjs b/interfaces/tests/schema-helper-version-ops-deep.test.mjs new file mode 100644 index 0000000000..aa4e41a8d6 --- /dev/null +++ b/interfaces/tests/schema-helper-version-ops-deep.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; + +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +describe('SchemaHelper.setVersion', () => { + it('rewrites $id and $comment for an object document', () => { + const data = { uuid: 'uuidA', contextURL: 'http://c', document: {} }; + const out = SchemaHelper.setVersion(data, '2.0.0', '1.0.0'); + assert.equal(out.document.$id, '#uuidA&2.0.0'); + assert.equal(out.version, '2.0.0'); + const comment = JSON.parse(out.document.$comment); + assert.equal(comment.term, 'uuidA&2.0.0'); + assert.equal(comment.previousVersion, '1.0.0'); + assert.equal(comment['@id'], 'http://c#uuidA&2.0.0'); + }); + + it('parses a string document before rewriting', () => { + const data = { uuid: 'uuidB', contextURL: '', document: JSON.stringify({ existing: true }) }; + const out = SchemaHelper.setVersion(data, '1.5.0'); + assert.equal(out.document.$id, '#uuidB&1.5.0'); + assert.equal(out.document.existing, true); + }); + + it('omits previousVersion in the comment when not provided', () => { + const out = SchemaHelper.setVersion({ uuid: 'u', document: {} }, '1.0.0'); + const comment = JSON.parse(out.document.$comment); + assert.equal(comment.previousVersion, undefined); + }); +}); + +describe('SchemaHelper.updateVersion', () => { + function makeData(prev, contextURL) { + return { + uuid: 'uuidA', + owner: 'o1', + creator: 'c1', + contextURL: contextURL ?? 'http://c', + document: { $id: '#uuidA&1.0.0', $comment: JSON.stringify({ previousVersion: prev }) }, + }; + } + + it('accepts a greater version and rewrites identity', () => { + const out = SchemaHelper.updateVersion(makeData('1.0.0'), '2.0.0'); + assert.equal(out.version, '2.0.0'); + assert.equal(out.document.$id, '#uuidA&2.0.0'); + assert.equal(out.uuid, 'uuidA'); + }); + + it('carries the previousVersion into the new comment', () => { + const out = SchemaHelper.updateVersion(makeData('1.0.0'), '2.0.0'); + const comment = JSON.parse(out.document.$comment); + assert.equal(comment.previousVersion, '1.0.0'); + }); + + it('throws on an invalid version format', () => { + assert.throws(() => SchemaHelper.updateVersion(makeData('1.0.0'), 'not-a-version'), /Invalid version format/); + }); + + it('throws when the new version is not greater than previous', () => { + assert.throws(() => SchemaHelper.updateVersion(makeData('3.0.0'), '2.0.0'), /Version must be greater than 3.0.0/); + }); + + it('throws when the new version equals the previous', () => { + assert.throws(() => SchemaHelper.updateVersion(makeData('2.0.0'), '2.0.0'), /Version must be greater than/); + }); + + it('prefers creator over owner for the resulting owner field', () => { + const out = SchemaHelper.updateVersion(makeData('1.0.0'), '2.0.0'); + assert.equal(out.owner, 'c1'); + assert.equal(out.creator, 'c1'); + }); + + it('falls back to document uuid when data.uuid is absent', () => { + const data = { + owner: 'o1', + contextURL: 'http://c', + document: { $id: '#docUuid&1.0.0', $comment: JSON.stringify({ previousVersion: '1.0.0' }) }, + }; + const out = SchemaHelper.updateVersion(data, '2.0.0'); + assert.equal(out.uuid, 'docUuid'); + }); + + it('parses a string document', () => { + const data = { + uuid: 'uuidA', + owner: 'o1', + contextURL: 'http://c', + document: JSON.stringify({ $id: '#uuidA&1.0.0', $comment: JSON.stringify({ previousVersion: '1.0.0' }) }), + }; + const out = SchemaHelper.updateVersion(data, '2.0.0'); + assert.equal(out.document.$id, '#uuidA&2.0.0'); + }); +}); + +describe('SchemaHelper.updateOwner', () => { + function makeData() { + return { document: { $id: '#uuidA&1.0.0', $comment: JSON.stringify({ previousVersion: '0.9.0' }) } }; + } + + it('sets owner and creator from explicit fields', () => { + const out = SchemaHelper.updateOwner(makeData(), { owner: 'newOwner', creator: 'newCreator' }); + assert.equal(out.owner, 'newOwner'); + assert.equal(out.creator, 'newCreator'); + }); + + it('falls back to username for owner and creator', () => { + const out = SchemaHelper.updateOwner(makeData(), { username: 'bob' }); + assert.equal(out.owner, 'bob'); + assert.equal(out.creator, 'bob'); + }); + + it('derives version and uuid from the document when missing', () => { + const out = SchemaHelper.updateOwner(makeData(), { username: 'bob' }); + assert.equal(out.version, '1.0.0'); + assert.equal(out.uuid, 'uuidA'); + assert.equal(out.document.$id, '#uuidA&1.0.0'); + }); + + it('preserves an existing version and uuid on the data', () => { + const data = { version: '5.0.0', uuid: 'fixed', document: { $id: '#uuidA&1.0.0' } }; + const out = SchemaHelper.updateOwner(data, { username: 'bob' }); + assert.equal(out.version, '5.0.0'); + assert.equal(out.uuid, 'fixed'); + assert.equal(out.document.$id, '#fixed&5.0.0'); + }); + + it('parses a string document', () => { + const data = { document: JSON.stringify({ $id: '#uuidA&1.0.0' }) }; + const out = SchemaHelper.updateOwner(data, { owner: 'o', creator: 'c' }); + assert.equal(out.document.$id, '#uuidA&1.0.0'); + }); +}); + +describe('SchemaHelper.updatePermission', () => { + it('flags isOwner and isCreator by exact match', () => { + const data = [ + { owner: 'a', creator: 'a' }, + { owner: 'b', creator: 'c' }, + ]; + SchemaHelper.updatePermission(data, { owner: 'a', creator: 'a' }); + assert.equal(data[0].isOwner, true); + assert.equal(data[0].isCreator, true); + assert.equal(data[1].isOwner, false); + assert.equal(data[1].isCreator, false); + }); + + it('isOwner is falsy when element.owner is missing', () => { + const data = [{ creator: 'a' }]; + SchemaHelper.updatePermission(data, { owner: 'a', creator: 'a' }); + assert.ok(!data[0].isOwner); + assert.equal(data[0].isCreator, true); + }); + + it('handles an empty list without error', () => { + const data = []; + SchemaHelper.updatePermission(data, { owner: 'a', creator: 'a' }); + assert.deepEqual(data, []); + }); + + it('isCreator is falsy when creators differ', () => { + const data = [{ owner: 'a', creator: 'x' }]; + SchemaHelper.updatePermission(data, { owner: 'a', creator: 'a' }); + assert.equal(data[0].isOwner, true); + assert.equal(data[0].isCreator, false); + }); +}); + +describe('SchemaHelper.updateIRI', () => { + it('reads iri from an object document $id', () => { + const out = SchemaHelper.updateIRI({ document: { $id: '#zzz&1.0.0' } }); + assert.equal(out.iri, '#zzz&1.0.0'); + }); + + it('reads iri from a string document', () => { + const out = SchemaHelper.updateIRI({ document: JSON.stringify({ $id: '#str&2.0.0' }) }); + assert.equal(out.iri, '#str&2.0.0'); + }); + + it('sets iri to null when document has no $id', () => { + const out = SchemaHelper.updateIRI({ document: { properties: {} } }); + assert.equal(out.iri, null); + }); + + it('builds iri from uuid and version when no document', () => { + const out = SchemaHelper.updateIRI({ uuid: 'qq', version: '1.0.0' }); + assert.equal(out.iri, '#qq&1.0.0'); + }); + + it('builds iri from uuid alone when version is absent', () => { + const out = SchemaHelper.updateIRI({ uuid: 'qq' }); + assert.equal(out.iri, '#qq'); + }); + + it('sets iri to null when document string is unparsable', () => { + const out = SchemaHelper.updateIRI({ document: '{bad' }); + assert.equal(out.iri, null); + }); +}); + +describe('SchemaHelper.updateObjectContext', () => { + it('sets schema type and contextURL on the object', () => { + const schema = { type: '#myType', contextURL: 'http://ctx', fields: [] }; + const out = SchemaHelper.updateObjectContext(schema, { name: 'x' }); + assert.equal(out.type, '#myType'); + assert.deepEqual(out['@context'], ['http://ctx']); + assert.equal(out.name, 'x'); + }); + + it('strips type and @context from non-ref nested objects', () => { + const schema = { + type: '#root', + contextURL: 'http://ctx', + fields: [{ name: 'nested', isRef: false }], + }; + const json = { nested: { type: 'old', '@context': ['old'], value: 1 } }; + const out = SchemaHelper.updateObjectContext(schema, json); + assert.equal(out.nested.type, undefined); + assert.equal(out.nested['@context'], undefined); + assert.equal(out.nested.value, 1); + }); + + it('applies child context to ref fields', () => { + const schema = { + type: '#root', + contextURL: 'http://ctx', + fields: [{ + name: 'child', isRef: true, + context: { type: 'ChildType', context: ['http://child'] }, + fields: [], + }], + }; + const json = { child: { value: 1 } }; + const out = SchemaHelper.updateObjectContext(schema, json); + assert.equal(out.child.type, 'ChildType'); + assert.deepEqual(out.child['@context'], ['http://child']); + }); +}); diff --git a/interfaces/tests/schema-json-edge.test.mjs b/interfaces/tests/schema-json-edge.test.mjs new file mode 100644 index 0000000000..b92fa87d3e --- /dev/null +++ b/interfaces/tests/schema-json-edge.test.mjs @@ -0,0 +1,632 @@ +import assert from 'node:assert/strict'; +import { SchemaToJson, JsonToSchema, ErrorContext, JsonError, JsonErrorMessage } from '../dist/helpers/schema-json.js'; + +const field = (o = {}) => ({ + key: 'a', + title: 't', + description: '', + type: 'String', + required: 'None', + isArray: false, + availableOptions: [], + ...o +}); + +const schemaJson = (fields, conditions = [], extra = {}) => ({ + name: 'S', + description: '', + entity: 'NONE', + fields, + conditions, + ...extra +}); + +const ctx = () => new ErrorContext().setPath(['schema']); + +const fromJson = (json, all = []) => JsonToSchema.fromJson(json, all); + +describe('@unit schema-json edge — ErrorContext.setPath boundaries', () => { + it('produces the literal string "undefined" entity for an empty path array', () => { + const c = new ErrorContext().setPath([]); + assert.equal(c.entity, 'undefined'); + assert.equal(c.property, undefined); + }); + + it('composes a self-prefixed property for a single bracketed segment', () => { + const c = new ErrorContext().setPath(['[0]']); + assert.equal(c.entity, '[0]'); + assert.equal(c.property, 'undefined[0]'); + }); + + it('keeps the prior path when add() extends a multi-segment path', () => { + const c = new ErrorContext().setPath(['schema', 'fields']).add('x'); + assert.equal(c.entity, 'schema.fields'); + assert.equal(c.property, 'x'); + }); + + it('joins three plain segments with dots and keeps the last as property', () => { + const c = new ErrorContext().setPath(['a', 'b', 'c']); + assert.equal(c.entity, 'a.b'); + assert.equal(c.property, 'c'); + }); + + it('treats a null path argument as a no-op leaving empty strings', () => { + const c = new ErrorContext().setPath(null); + assert.equal(c.entity, ''); + assert.equal(c.property, ''); + }); + + it('resets entity/property to empty on a second setPath call', () => { + const c = new ErrorContext().setPath(['a', 'b']); + c.setPath(['x']); + assert.equal(c.entity, 'x'); + assert.equal(c.property, 'x'); + }); + + it('add() on a fresh context starts from an empty base path', () => { + const c = new ErrorContext().add('only'); + assert.equal(c.entity, 'only'); + assert.equal(c.property, 'only'); + }); + + it('add() does not mutate the source context', () => { + const a = new ErrorContext().setPath(['root']); + const b = a.add('child'); + assert.equal(a.property, 'root'); + assert.equal(b.property, 'child'); + assert.notEqual(a, b); + }); +}); + +describe('@unit schema-json edge — SchemaToJson.fieldToJson type resolution', () => { + it('maps a bare string field to the literal "String" type name', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string' }, 0).type, 'String'); + }); + + it('maps number to "Number"', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'number', isRef: false }, 0).type, 'Number'); + }); + + it('maps Prefix unit system to "Prefix" regardless of base type', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', unitSystem: 'prefix' }, 0).type, 'Prefix'); + }); + + it('maps Postfix unit system to "Postfix"', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'number', unitSystem: 'postfix' }, 0).type, 'Postfix'); + }); + + it('maps the hederaAccount custom type to "HederaAccount"', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', customType: 'hederaAccount' }, 0).type, 'HederaAccount'); + }); + + it('returns an empty type string for an unrecognised field shape', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'weird' }, 0).type, ''); + }); + + it('falls back to a blank key when name is missing', () => { + assert.equal(SchemaToJson.fieldToJson({ type: 'string' }, 0).key, ''); + }); +}); + +describe('@unit schema-json edge — SchemaToJson.getRequired precedence', () => { + it('prefers Auto Calculate over hidden and required', () => { + const f = SchemaToJson.fieldToJson({ name: 'a', type: 'string', autocalculate: true, hidden: true, required: true }, 0); + assert.equal(f.required, 'Auto Calculate'); + }); + + it('prefers Hidden over required', () => { + const f = SchemaToJson.fieldToJson({ name: 'a', type: 'string', hidden: true, required: true }, 0); + assert.equal(f.required, 'Hidden'); + }); + + it('emits Required when only required is set', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', required: true }, 0).required, 'Required'); + }); + + it('emits None when no flag is set', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string' }, 0).required, 'None'); + }); +}); + +describe('@unit schema-json edge — SchemaToJson optional outputs', () => { + it('only emits unit when a unitSystem is present', () => { + assert.equal('unit' in SchemaToJson.fieldToJson({ name: 'a', type: 'string', unit: 'kg' }, 0), false); + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', unitSystem: 'postfix', unit: 'kg' }, 0).unit, 'kg'); + }); + + it('extracts the first examples bucket into example', () => { + const f = SchemaToJson.fieldToJson({ name: 'a', type: 'string', examples: [['x']] }, 0); + assert.deepEqual(f.example, ['x']); + }); + + it('omits example when examples[0] is falsy', () => { + assert.equal('example' in SchemaToJson.fieldToJson({ name: 'a', type: 'string', examples: [null] }, 0), false); + }); + + it('emits font triple when only textColor is set, filling defaults', () => { + const f = SchemaToJson.fieldToJson({ name: 'a', type: 'string', textColor: '#abcdef' }, 0); + assert.equal(f.textSize, '18'); + assert.equal(f.textColor, '#abcdef'); + assert.equal(f.textBold, false); + }); + + it('prefers enum over remoteLink for the enum output', () => { + assert.deepEqual(SchemaToJson.fieldToJson({ name: 'a', type: 'string', enum: ['x'], remoteLink: 'http://r' }, 0).enum, ['x']); + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', remoteLink: 'http://r' }, 0).enum, 'http://r'); + }); + + it('emits expression (possibly empty) only for autocalculate fields', () => { + assert.equal(SchemaToJson.fieldToJson({ name: 'a', type: 'string', autocalculate: true }, 0).expression, ''); + assert.equal('expression' in SchemaToJson.fieldToJson({ name: 'a', type: 'string', expression: 'x' }, 0), false); + }); +}); + +describe('@unit schema-json edge — SchemaToJson.schemaToJson', () => { + it('skips readOnly fields', () => { + const j = SchemaToJson.schemaToJson({ name: 'S', fields: [{ name: 'a', type: 'string', readOnly: true }, { name: 'b', type: 'string' }], conditions: [] }); + assert.deepEqual(j.fields.map((f) => f.key), ['b']); + }); + + it('defaults name/entity/fields/conditions for an empty schema object', () => { + const j = SchemaToJson.schemaToJson({}); + assert.equal(j.name, ''); + assert.equal(j.entity, 'NONE'); + assert.deepEqual(j.fields, []); + assert.deepEqual(j.conditions, []); + }); + + it('serialises conditions through conditionToJson', () => { + const fa = { name: 'a', title: 'A', description: 'd', type: 'string' }; + const j = SchemaToJson.schemaToJson({ name: 'S', fields: [fa], conditions: [{ ifCondition: { field: fa, fieldValue: 'x' }, thenFields: [], elseFields: [] }] }); + assert.equal(j.conditions[0].if.field, 'a'); + assert.equal(j.conditions[0].if.fieldValue, 'x'); + }); +}); + +describe('@unit schema-json edge — SchemaToJson.conditionToJson branches', () => { + it('produces OR for an ANY_OF predicate list', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { op: 'ANY_OF', predicates: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }, thenFields: [], elseFields: [] }); + assert.deepEqual(j.if.OR, [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }]); + }); + + it('defaults a multi-predicate list to AND', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { predicates: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }, thenFields: [], elseFields: [] }); + assert.ok(j.if.AND); + assert.equal(j.if.AND.length, 2); + }); + + it('LATENT: multi-AND entries use key "value" not "fieldValue"', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { AND: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }, thenFields: [], elseFields: [] }); + assert.deepEqual(j.if.AND, [{ field: 'a', value: 1 }, { field: 'b', value: 2 }]); + }); + + it('multi-OR entries use key "fieldValue"', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { OR: [{ field: { name: 'a' }, fieldValue: 1 }, { field: { name: 'b' }, fieldValue: 2 }] }, thenFields: [], elseFields: [] }); + assert.deepEqual(j.if.OR, [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }]); + }); + + it('collapses a one-element AND to a plain if', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { AND: [{ field: { name: 'a' }, fieldValue: 7 }] }, thenFields: [], elseFields: [] }); + assert.equal(j.if.field, 'a'); + assert.equal(j.if.fieldValue, 7); + }); + + it('returns an empty if for an empty ifCondition object', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: {}, thenFields: [], elseFields: [] }); + assert.deepEqual(j.if, {}); + }); + + it('returns an empty if when ifCondition is undefined', () => { + const j = SchemaToJson.conditionToJson({ thenFields: [], elseFields: [] }); + assert.deepEqual(j.if, {}); + }); + + it('serialises thenFields and elseFields', () => { + const fa = { name: 'a', title: 'A', description: 'd', type: 'string' }; + const j = SchemaToJson.conditionToJson({ ifCondition: { field: fa, fieldValue: 1 }, thenFields: [fa], elseFields: [fa] }); + assert.equal(j.then.length, 1); + assert.equal(j.else.length, 1); + assert.equal(j.then[0].key, 'a'); + }); + + it('drops AND predicates with an undefined field name', () => { + const j = SchemaToJson.conditionToJson({ ifCondition: { AND: [{ field: { name: 'a' }, fieldValue: 1 }, { fieldValue: 2 }] }, thenFields: [], elseFields: [] }); + assert.equal(j.if.AND.length, 1); + assert.equal(j.if.AND[0].field, 'a'); + }); +}); + +describe('@unit schema-json edge — JsonToSchema.fromJson happy paths', () => { + it('round-trips a minimal NONE schema with one string field', () => { + const r = fromJson(schemaJson([field({ key: 'a' })])); + assert.equal(r.name, 'S'); + assert.equal(r.entity, 'NONE'); + assert.equal(r.fields.length, 1); + assert.equal(r.fields[0].name, 'a'); + assert.equal(r.fields[0].type, 'string'); + }); + + it('appends VC default read-only fields after user fields', () => { + const r = fromJson(schemaJson([field({ key: 'a' })], [], { entity: 'VC' })); + const names = r.fields.map((f) => f.name); + assert.ok(names.includes('policyId')); + assert.ok(names.includes('ref')); + assert.ok(names.includes('guardianVersion')); + }); + + it('adds no default fields for a NONE schema', () => { + const r = fromJson(schemaJson([field({ key: 'a' })])); + assert.equal(r.fields.length, 1); + }); + + it('coerces a string "true" isArray into a boolean', () => { + const r = fromJson(schemaJson([field({ isArray: 'true' })])); + assert.equal(r.fields[0].isArray, true); + }); + + it('maps required string "Required" to required=true', () => { + const r = fromJson(schemaJson([field({ required: 'Required' })])); + assert.equal(r.fields[0].required, true); + assert.equal(r.fields[0].hidden, false); + assert.equal(r.fields[0].autocalculate, false); + }); + + it('maps required string "Hidden" to hidden=true', () => { + const r = fromJson(schemaJson([field({ required: 'Hidden' })])); + assert.equal(r.fields[0].hidden, true); + }); + + it('preserves field order via the order property', () => { + const r = fromJson(schemaJson([field({ key: 'a' }), field({ key: 'b' })])); + assert.equal(r.fields[0].order, 0); + assert.equal(r.fields[1].order, 1); + }); +}); + +describe('@unit schema-json edge — JsonToSchema.fromJson error paths', () => { + it('LATENT: a field without an availableOptions array crashes fromJson', () => { + assert.throws( + () => fromJson(schemaJson([{ key: 'a', title: 't', description: '', type: 'String', required: 'None', isArray: false }])), + /Cannot read properties of undefined/ + ); + }); + + it('rejects a missing field key as a required string', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: undefined })])), + /Invalid format for variable .*"key".* schema\.fields\[0\]/ + ); + }); + + it('rejects an empty-string field key', () => { + assert.throws(() => fromJson(schemaJson([field({ key: '' })])), /"key"/); + }); + + it('rejects duplicate field keys as non-unique', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'dup' }), field({ key: 'dup' })])), + /must be unique/ + ); + }); + + it('rejects an unknown entity value', () => { + assert.throws( + () => fromJson(schemaJson([field()], [], { entity: 'XX' })), + /Value must be one of \[NONE, VC, EVC\]/ + ); + }); + + it('rejects an unknown field type', () => { + assert.throws( + () => fromJson(schemaJson([field({ type: 'Nope' })])), + /Value of a primitive type or a sub-schema reference is required/ + ); + }); + + it('rejects an empty schema name', () => { + assert.throws(() => fromJson(schemaJson([field()], [], { name: '' })), /"name"/); + }); + + it('rejects a non-string description', () => { + assert.throws(() => fromJson(schemaJson([field()], [], { description: 123 })), /"description"/); + }); + + it('rejects a non-string field title', () => { + assert.throws(() => fromJson(schemaJson([field({ title: 123 })])), /"title": 123/); + }); + + it('rejects a non-array fields value', () => { + assert.throws( + () => fromJson({ name: 'S', description: '', entity: 'NONE', fields: 'x', conditions: [] }), + /"fields": "x".*Value of type array is required/ + ); + }); + + it('rejects a non-array conditions value', () => { + assert.throws( + () => fromJson(schemaJson([field()], 'x')), + /"conditions": "x".*Value of type array is required/ + ); + }); + + it('rejects a non-boolean isArray value', () => { + assert.throws(() => fromJson(schemaJson([field({ isArray: 'maybe' })])), /boolean is required/); + }); + + it('truncates long offending values in the error message', () => { + assert.throws( + () => fromJson(schemaJson([field({ type: 'ThisIsAVeryLongInvalidTypeNameThatExceeds' })])), + /"type": "ThisIsAVeryLongInva\.\.\./ + ); + }); +}); + +describe('@unit schema-json edge — privacy / EVC handling', () => { + it('rejects a private flag on a non-EVC schema', () => { + assert.throws( + () => fromJson(schemaJson([field({ private: true })])), + /Invalid property type for variable "private"/ + ); + }); + + it('accepts a private flag on an EVC schema', () => { + const r = fromJson(schemaJson([field({ private: true })], [], { entity: 'EVC' })); + assert.equal(r.fields[0].isPrivate, true); + }); + + it('coerces private "false" string on an EVC schema', () => { + const r = fromJson(schemaJson([field({ private: 'false' })], [], { entity: 'EVC' })); + assert.equal(r.fields[0].isPrivate, false); + }); + + it('rejects a non-boolean private value on an EVC schema', () => { + assert.throws(() => fromJson(schemaJson([field({ private: 'maybe' })], [], { entity: 'EVC' })), /boolean is required/); + }); +}); + +describe('@unit schema-json edge — font (Help Text) handling', () => { + it('fills font defaults for a Help Text field with no overrides', () => { + const r = fromJson(schemaJson([field({ type: 'Help Text' })])); + assert.equal(r.fields[0].textSize, '18'); + assert.equal(r.fields[0].textColor, '#000000'); + assert.equal(r.fields[0].textBold, false); + assert.equal(r.fields[0].type, 'null'); + }); + + it('parses a px-suffixed text size for a Help Text field', () => { + const r = fromJson(schemaJson([field({ type: 'Help Text', textSize: '24px' })])); + assert.equal(r.fields[0].textSize, '24'); + }); + + it('rejects an out-of-range Help Text size', () => { + assert.throws( + () => fromJson(schemaJson([field({ type: 'Help Text', textSize: '999' })])), + /between 0 and 70/ + ); + }); + + it('rejects an invalid Help Text colour', () => { + assert.throws( + () => fromJson(schemaJson([field({ type: 'Help Text', textColor: 'red' })])), + /Rgb color definition in format #xxxxxx/ + ); + }); + + it('accepts a 3-digit hex colour for Help Text', () => { + const r = fromJson(schemaJson([field({ type: 'Help Text', textColor: '#abc' })])); + assert.equal(r.fields[0].textColor, '#abc'); + }); + + it('rejects textSize on a non-Help-Text field', () => { + assert.throws( + () => fromJson(schemaJson([field({ textSize: '20' })])), + /Invalid property type for variable "textSize"/ + ); + }); + + it('rejects textColor on a non-Help-Text field', () => { + assert.throws( + () => fromJson(schemaJson([field({ textColor: '#abcdef' })])), + /Invalid property type for variable "textColor"/ + ); + }); +}); + +describe('@unit schema-json edge — enum / expression / examples', () => { + it('parses an enum array for an Enum-typed field', () => { + const r = fromJson(schemaJson([field({ type: 'Enum', enum: ['x', 'y'] })])); + assert.deepEqual(r.fields[0].enum, ['x', 'y']); + assert.equal(r.fields[0].remoteLink, undefined); + }); + + it('treats a string enum as a remote link', () => { + const r = fromJson(schemaJson([field({ type: 'Enum', enum: 'http://list' })])); + assert.equal(r.fields[0].enum, undefined); + assert.equal(r.fields[0].remoteLink, 'http://list'); + }); + + it('rejects an enum on a non-Enum field type', () => { + assert.throws( + () => fromJson(schemaJson([field({ enum: ['x'] })])), + /Invalid property type for variable "enum"/ + ); + }); + + it('rejects a non-string enum array entry', () => { + assert.throws(() => fromJson(schemaJson([field({ type: 'Enum', enum: [1] })])), /string is required/); + }); + + it('requires an expression for an Auto Calculate field', () => { + assert.throws( + () => fromJson(schemaJson([field({ required: 'Auto Calculate' })])), + /"expression".*string is required/ + ); + }); + + it('keeps the expression for an Auto Calculate field', () => { + const r = fromJson(schemaJson([field({ required: 'Auto Calculate', expression: '1+1' })])); + assert.equal(r.fields[0].expression, '1+1'); + assert.equal(r.fields[0].autocalculate, true); + }); + + it('rejects an expression on a non-autocalculate field', () => { + assert.throws( + () => fromJson(schemaJson([field({ expression: '1+1' })])), + /Invalid property type for variable "expression"/ + ); + }); + + it('rejects a non-array example on an isArray field', () => { + assert.throws( + () => fromJson(schemaJson([field({ isArray: true, example: 'notarr' })])), + /Value of type array is required/ + ); + }); + + it('rejects an array example on a non-array field with the NOT_ARRAY message', () => { + assert.throws( + () => fromJson(schemaJson([field({ isArray: false, example: [1] })])), + /Value of type non-array is required/ + ); + }); + + it('rejects an object example on a non-array field with the NOT_OBJECT message', () => { + assert.throws( + () => fromJson(schemaJson([field({ isArray: false, example: { a: 1 } })])), + /Value of type non-object is required/ + ); + }); + + it('accepts a scalar example on a non-array field', () => { + const r = fromJson(schemaJson([field({ isArray: false, example: 'v' })])); + assert.deepEqual(r.fields[0].examples, ['v']); + }); +}); + +describe('@unit schema-json edge — sub-schema reference resolution', () => { + const sub = { iri: '#SubA', fields: [{ name: 'x', type: 'string' }] }; + + it('resolves a sub-schema iri as the field type', () => { + assert.equal(JsonToSchema.fromType({ type: '#SubA' }, [sub], ctx()), '#SubA'); + }); + + it('marks a sub-schema iri field as a reference', () => { + assert.equal(JsonToSchema.fromIsRef({ type: '#SubA' }, [sub], ctx()), true); + }); + + it('deep-copies sub-schema fields into the resolved field', () => { + const r = fromJson(schemaJson([field({ key: 'a', type: '#SubA' })]), [sub]); + assert.deepEqual(r.fields[0].fields.map((f) => f.name), ['x']); + assert.equal(r.fields[0].isRef, true); + assert.equal(r.fields[0].type, '#SubA'); + }); + + it('does not share the sub-schema field array (copy, not alias)', () => { + const r = fromJson(schemaJson([field({ key: 'a', type: '#SubA' })]), [sub]); + assert.notEqual(r.fields[0].fields, sub.fields); + }); + + it('rejects a sub-schema iri that is absent from the all[] list', () => { + assert.throws(() => fromJson(schemaJson([field({ key: 'a', type: '#Missing' })]), [sub]), /sub-schema reference/); + }); +}); + +describe('@unit schema-json edge — static type helpers', () => { + it('matches type names case-insensitively in fromType', () => { + assert.equal(JsonToSchema.fromType({ type: 'string' }, [], ctx()), 'string'); + assert.equal(JsonToSchema.fromType({ type: 'STRING' }, [], ctx()), 'string'); + assert.equal(JsonToSchema.fromType({ type: 'number' }, [], ctx()), 'number'); + }); + + it('LATENT: fromIsRef returns false for GeoJSON (FieldTypes wins over SystemFieldTypes)', () => { + assert.equal(JsonToSchema.fromIsRef({ type: 'GeoJSON' }, [], ctx()), false); + }); + + it('fromFormat returns the format for a Date field and undefined for plain types', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'Date' }, ctx()), 'date'); + assert.equal(JsonToSchema.fromFormat({ type: 'String' }, ctx()), undefined); + }); + + it('fromFormat returns undefined for the hederaAccount custom type', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'hederaAccount' }, ctx()), undefined); + }); +}); + +describe('@unit schema-json edge — conditions', () => { + it('resolves a plain if condition against an existing field', () => { + const r = fromJson(schemaJson([field({ key: 'a' })], [{ if: { field: 'a', fieldValue: 'x' }, then: [field({ key: 'b' })], else: [] }])); + assert.equal(r.conditions.length, 1); + assert.equal(r.conditions[0].ifCondition.field.name, 'a'); + assert.deepEqual(r.conditions[0].thenFields.map((f) => f.name), ['b']); + }); + + it('rejects an if reference to a missing field', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'a' })], [{ if: { field: 'zzz', fieldValue: 'x' }, then: [field({ key: 'b' })], else: [] }])), + /Value must be a reference to an existing field/ + ); + }); + + it('rejects a condition with empty then and else branches', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'a' })], [{ if: { field: 'a', fieldValue: 'x' }, then: [], else: [] }])), + /Empty "then" or "else" branches/ + ); + }); + + it('rejects an empty AND array in a condition if', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'a' })], [{ if: { AND: [] }, then: [field({ key: 'b' })], else: [] }])), + /Value of type array is required/ + ); + }); + + it('rejects an empty OR array in a condition if', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'a' })], [{ if: { OR: [] }, then: [field({ key: 'b' })], else: [] }])), + /Value of type array is required/ + ); + }); + + it('rejects a non-array then branch', () => { + assert.throws( + () => fromJson(schemaJson([field({ key: 'a' })], [{ if: { field: 'a' }, then: 'nope', else: [] }])), + /"then": "nope".*Value of type array is required/ + ); + }); + + it('resolves a multi-predicate AND condition into AND entries', () => { + const r = fromJson(schemaJson([field({ key: 'a' }), field({ key: 'b' })], [{ if: { AND: [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }] }, then: [field({ key: 'c' })], else: [] }])); + assert.deepEqual(r.conditions[0].ifCondition.AND.map((p) => p.field.name), ['a', 'b']); + }); + + it('resolves a multi-predicate OR condition into OR entries', () => { + const r = fromJson(schemaJson([field({ key: 'a' }), field({ key: 'b' })], [{ if: { OR: [{ field: 'a', fieldValue: 1 }, { field: 'b', fieldValue: 2 }] }, then: [field({ key: 'c' })], else: [] }])); + assert.deepEqual(r.conditions[0].ifCondition.OR.map((p) => p.field.name), ['a', 'b']); + }); + + it('collapses a single-element AND condition to a plain field if', () => { + const r = fromJson(schemaJson([field({ key: 'a' })], [{ if: { AND: [{ field: 'a', fieldValue: 9 }] }, then: [field({ key: 'b' })], else: [] }])); + assert.equal(r.conditions[0].ifCondition.field.name, 'a'); + assert.equal(r.conditions[0].ifCondition.fieldValue, 9); + }); + + it('uses a fresh uniqueness set per condition so branch keys can reuse top-level names', () => { + const r = fromJson(schemaJson([field({ key: 'a' })], [{ if: { field: 'a' }, then: [field({ key: 'a' })], else: [] }])); + assert.equal(r.conditions[0].thenFields[0].name, 'a'); + }); +}); + +describe('@unit schema-json edge — enums and messages', () => { + it('exposes documented JsonError templates', () => { + assert.match(JsonError.INVALID_FORMAT, /\$\{prop\}/); + assert.match(JsonError.UNIQUE, /must be unique/); + assert.match(JsonError.THEN_ELSE, /then.*else/); + }); + + it('exposes documented JsonErrorMessage strings', () => { + assert.equal(JsonErrorMessage.STRING, 'Value of type string is required.'); + assert.equal(JsonErrorMessage.ARRAY, 'Value of type array is required.'); + assert.match(JsonErrorMessage.REQUIRED_ENTITY, /NONE, VC, EVC/); + }); +}); diff --git a/interfaces/tests/schema-json-error-context-deep.test.mjs b/interfaces/tests/schema-json-error-context-deep.test.mjs new file mode 100644 index 0000000000..b4e6808fc0 --- /dev/null +++ b/interfaces/tests/schema-json-error-context-deep.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; + +import { ErrorContext } from '../dist/helpers/schema-json.js'; + +describe('ErrorContext.setPath entity/property derivation', () => { + it('starts empty by default', () => { + const c = new ErrorContext(); + assert.equal(c.entity, ''); + assert.equal(c.property, ''); + }); + + it('uses the single segment as both entity and property', () => { + const c = new ErrorContext().setPath(['schema']); + assert.equal(c.entity, 'schema'); + assert.equal(c.property, 'schema'); + }); + + it('joins nested segments with dots into entity', () => { + const c = new ErrorContext().setPath(['schema', 'fields', 'key']); + assert.equal(c.entity, 'schema.fields'); + assert.equal(c.property, 'key'); + }); + + it('attaches bracket segments without a dot', () => { + const c = new ErrorContext().setPath(['schema', 'fields', '[0]', 'key']); + assert.equal(c.entity, 'schema.fields[0]'); + assert.equal(c.property, 'key'); + }); + + it('builds property as previous + bracket when last is a bracket', () => { + const c = new ErrorContext().setPath(['schema', 'f', '[2]']); + assert.equal(c.entity, 'schema.f'); + assert.equal(c.property, 'f[2]'); + }); + + it('handles an empty path array', () => { + const c = new ErrorContext().setPath([]); + assert.equal(c.entity, 'undefined'); + assert.equal(c.property, undefined); + }); + + it('resets entity/property on a fresh setPath call', () => { + const c = new ErrorContext().setPath(['schema', 'a', 'b']); + c.setPath(['other']); + assert.equal(c.entity, 'other'); + assert.equal(c.property, 'other'); + }); +}); + +describe('ErrorContext.add', () => { + it('returns a new context with the field appended to the path', () => { + const base = new ErrorContext().setPath(['schema']); + const child = base.add('name'); + assert.notEqual(child, base); + assert.equal(child.property, 'name'); + }); + + it('appends to an existing multi-segment path', () => { + const base = new ErrorContext().setPath(['schema', 'fields']); + const child = base.add('key'); + assert.equal(child.entity, 'schema.fields'); + assert.equal(child.property, 'key'); + }); + + it('appends a bracket segment correctly', () => { + const base = new ErrorContext().setPath(['schema', 'fields']); + const child = base.add('[3]'); + assert.equal(child.entity, 'schema.fields'); + assert.equal(child.property, 'fields[3]'); + }); + + it('add starting from no path produces a single-segment context', () => { + const base = new ErrorContext(); + const child = base.add('root'); + assert.equal(child.property, 'root'); + assert.equal(child.entity, 'root'); + }); + + it('chains add calls building deeper paths', () => { + const c = new ErrorContext().setPath(['schema']).add('fields').add('[0]').add('title'); + assert.equal(c.entity, 'schema.fields[0]'); + assert.equal(c.property, 'title'); + }); +}); + +describe('ErrorContext.setMessage', () => { + it('records the error and message', () => { + const c = new ErrorContext().setMessage('ERR', 'MSG'); + assert.equal(c.error, 'ERR'); + assert.equal(c.message, 'MSG'); + }); + + it('defaults message to empty string', () => { + const c = new ErrorContext().setMessage('ERR'); + assert.equal(c.message, ''); + }); + + it('returns itself for chaining', () => { + const c = new ErrorContext(); + assert.equal(c.setMessage('E', 'M'), c); + }); +}); + +describe('ErrorContext.setData', () => { + it('stores arbitrary data', () => { + const c = new ErrorContext(); + const data = { a: 1 }; + c.setData(data); + assert.equal(c.data, data); + }); +}); diff --git a/interfaces/tests/schema-json-error-context.test.mjs b/interfaces/tests/schema-json-error-context.test.mjs new file mode 100644 index 0000000000..b013818170 --- /dev/null +++ b/interfaces/tests/schema-json-error-context.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { ErrorContext, JsonError, JsonErrorMessage } from '../dist/helpers/schema-json.js'; + +describe('ErrorContext', () => { + it('initializes with empty entity / property / error / message', () => { + const ctx = new ErrorContext(); + assert.equal(ctx.entity, ''); + assert.equal(ctx.property, ''); + assert.equal(ctx.error, ''); + assert.equal(ctx.message, ''); + }); + + describe('setPath', () => { + it('builds entity from a single root segment, property = root', () => { + const ctx = new ErrorContext().setPath(['schema']); + assert.equal(ctx.entity, 'schema'); + assert.equal(ctx.property, 'schema'); + }); + + it("joins intermediate segments with '.' and treats the last as the property", () => { + const ctx = new ErrorContext().setPath(['schema', 'fields', 'name']); + assert.equal(ctx.entity, 'schema.fields'); + assert.equal(ctx.property, 'name'); + }); + + it("appends bracketed segments without a separator (e.g. fields[0])", () => { + const ctx = new ErrorContext().setPath(['schema', '[0]', 'name']); + assert.equal(ctx.entity, 'schema[0]'); + assert.equal(ctx.property, 'name'); + }); + + it("composes property correctly when last segment is bracketed", () => { + const ctx = new ErrorContext().setPath(['schema', 'fields', '[2]']); + assert.equal(ctx.property, 'fields[2]'); + }); + }); + + describe('add', () => { + it('returns a new ErrorContext with extended path', () => { + const a = new ErrorContext().setPath(['schema']); + const b = a.add('fields'); + assert.notEqual(a, b); + assert.equal(b.entity, 'schema'); + assert.equal(b.property, 'fields'); + }); + + it('starts from an empty path when add() is called on a freshly constructed ctx', () => { + const ctx = new ErrorContext().add('first'); + assert.equal(ctx.entity, 'first'); + assert.equal(ctx.property, 'first'); + }); + }); + + describe('setMessage', () => { + it('records error template and message and returns this for chaining', () => { + const ctx = new ErrorContext().setMessage(JsonError.INVALID_FORMAT, JsonErrorMessage.STRING); + assert.equal(ctx.error, JsonError.INVALID_FORMAT); + assert.equal(ctx.message, JsonErrorMessage.STRING); + }); + + it("falls back to '' when message is omitted", () => { + const ctx = new ErrorContext().setMessage(JsonError.NOT_AVAILABLE); + assert.equal(ctx.message, ''); + }); + }); + + it('setData stores the raw payload for later inspection', () => { + const ctx = new ErrorContext(); + ctx.setData({ payload: 'x' }); + assert.deepEqual(ctx.data, { payload: 'x' }); + }); +}); + +describe('JsonError / JsonErrorMessage enums', () => { + it('JsonError exposes canonical templates', () => { + assert.match(JsonError.INVALID_FORMAT, /\$\{prop\}.*\$\{entity\}.*\$\{message\}/); + assert.match(JsonError.NOT_AVAILABLE, /property type/); + assert.match(JsonError.UNIQUE, /must be unique/); + }); + + it('JsonErrorMessage exposes the documented validation strings', () => { + assert.equal(JsonErrorMessage.STRING, 'Value of type string is required.'); + assert.equal(JsonErrorMessage.BOOLEAN, 'Value of type boolean is required.'); + assert.match(JsonErrorMessage.SIZE, /between 0 and 70/); + }); +}); diff --git a/interfaces/tests/schema-json-field-branches.test.mjs b/interfaces/tests/schema-json-field-branches.test.mjs new file mode 100644 index 0000000000..28b076de28 --- /dev/null +++ b/interfaces/tests/schema-json-field-branches.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { SchemaToJson, JsonToSchema, ErrorContext } from '../dist/helpers/schema-json.js'; + +const ctx = () => new ErrorContext().setPath(['schema']); + +describe('SchemaToJson.fieldToJson — optional outputs', () => { + const base = { name: 'f', title: 'F', description: 'd', type: 'string' }; + + it('emits private only when isPrivate is a boolean', () => { + assert.equal(SchemaToJson.fieldToJson({ ...base, isPrivate: true }, 0).private, true); + assert.equal(SchemaToJson.fieldToJson({ ...base, isPrivate: false }, 0).private, false); + assert.equal('private' in SchemaToJson.fieldToJson(base, 0), false); + }); + + it('emits default when a default value is set', () => { + assert.equal(SchemaToJson.fieldToJson({ ...base, default: 'dv' }, 0).default, 'dv'); + assert.equal('default' in SchemaToJson.fieldToJson(base, 0), false); + }); + + it('emits suggest when a suggestion is set', () => { + assert.equal(SchemaToJson.fieldToJson({ ...base, suggest: 'sv' }, 0).suggest, 'sv'); + assert.equal('suggest' in SchemaToJson.fieldToJson(base, 0), false); + }); +}); + +describe('SchemaToJson.schemaToJson — conditions', () => { + it('serialises each condition via conditionToJson', () => { + const fieldA = { name: 'a', title: 'A', description: 'd', type: 'string' }; + const json = SchemaToJson.schemaToJson({ + name: 'S', + fields: [fieldA], + conditions: [{ ifCondition: { field: fieldA, fieldValue: 'x' }, thenFields: [], elseFields: [] }], + }); + assert.equal(json.conditions.length, 1); + assert.equal(json.conditions[0].if.field, 'a'); + assert.equal(json.conditions[0].if.fieldValue, 'x'); + }); +}); + +describe('SchemaToJson.conditionToJson — single predicates entry', () => { + it('collapses a one-element predicates list to a plain if', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { predicates: [{ field: { name: 'a' }, fieldValue: 5 }] }, + thenFields: [], + elseFields: [], + }); + assert.equal(json.if.field, 'a'); + assert.equal(json.if.fieldValue, 5); + }); +}); + +describe('JsonToSchema.fromType — literal and system names', () => { + it("maps the literal 'String' name case-insensitively", () => { + assert.equal(JsonToSchema.fromType({ type: 'string' }, [], ctx()), 'string'); + assert.equal(JsonToSchema.fromType({ type: 'STRING' }, [], ctx()), 'string'); + }); +}); + +describe('JsonToSchema.fromFormat — custom types', () => { + it('returns undefined for the hederaAccount custom type', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'hederaAccount' }, ctx()), undefined); + }); + + it('returns the format for formatted dictionary types', () => { + assert.equal(JsonToSchema.fromFormat({ type: 'Date' }, ctx()), 'date'); + }); +}); + +describe('JsonToSchema.fromIsRef — literal String', () => { + it("is false for the literal 'String' regardless of case", () => { + assert.equal(JsonToSchema.fromIsRef({ type: 'string' }, [], ctx()), false); + }); + + it('is false for the hederaAccount custom type', () => { + assert.equal(JsonToSchema.fromIsRef({ type: 'hederaAccount' }, [], ctx()), false); + }); +}); diff --git a/interfaces/tests/schema-model-constructor-statics.test.mjs b/interfaces/tests/schema-model-constructor-statics.test.mjs new file mode 100644 index 0000000000..61a0ee1d59 --- /dev/null +++ b/interfaces/tests/schema-model-constructor-statics.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; +import { SchemaCategory } from '../dist/type/schema-category.type.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; +import { SchemaStatus } from '../dist/type/schema-status.type.js'; + +const minimalDocument = () => ({ + $id: '#Doc&1.0.0', + title: 'Doc title', + description: 'Doc description', + properties: {}, + required: [], +}); + +describe('Schema constructor source parsing', () => { + it('parses a JSON-string document', () => { + const s = new Schema({ document: JSON.stringify(minimalDocument()) }); + assert.equal(typeof s.document, 'object'); + assert.equal(s.document.$id, '#Doc&1.0.0'); + assert.deepEqual(s.fields, []); + assert.deepEqual(s.conditions, []); + }); + + it('keeps an object document by reference', () => { + const doc = minimalDocument(); + const s = new Schema({ document: doc }); + assert.equal(s.document, doc); + }); + + it('parses a JSON-string context and keeps an object context', () => { + const context = { '@context': {} }; + assert.deepEqual(new Schema({ context: JSON.stringify(context) }).context, context); + assert.equal(new Schema({ context }).context, context); + }); + + it('derives category SYSTEM for system schemas and POLICY otherwise', () => { + assert.equal(new Schema({ system: true }).category, SchemaCategory.SYSTEM); + assert.equal(new Schema({}).category, SchemaCategory.POLICY); + assert.equal(new Schema({ category: SchemaCategory.TOOL }).category, SchemaCategory.TOOL); + }); + + it('reads component from component or __component', () => { + assert.equal(new Schema({ component: 'a' }).component, 'a'); + assert.equal(new Schema({ __component: 'b' }).component, 'b'); + assert.equal(new Schema({ component: 'a', __component: 'b' }).component, 'a'); + }); + + it('defaults entity, status, and document for an empty source', () => { + const s = new Schema({}); + assert.equal(s.entity, SchemaEntity.NONE); + assert.equal(s.status, SchemaStatus.DRAFT); + assert.equal(s.document, null); + assert.equal(s.context, null); + }); +}); + +describe('Schema.from', () => { + it('wraps a response object into a Schema', () => { + const s = Schema.from({ name: 'n', uuid: '0000', iri: '#n' }); + assert.ok(s instanceof Schema); + assert.equal(s.name, 'n'); + assert.equal(s.iri, '#n'); + }); + + it('returns null when construction fails', () => { + const original = console.error; + console.error = () => undefined; + try { + assert.equal(Schema.from({ document: '{invalid json' }), null); + } finally { + console.error = original; + } + }); +}); + +describe('Schema.fromDocument', () => { + it('builds a schema whose fields come from the document', () => { + const document = { + ...minimalDocument(), + properties: { + amount: { title: 'Amount', description: 'Amount', type: 'number', readOnly: false }, + }, + }; + const s = Schema.fromDocument(document); + assert.equal(s.document.$id, '#Doc&1.0.0'); + assert.equal(s.fields.length, 1); + assert.equal(s.fields[0].name, 'amount'); + assert.equal(s.fields[0].type, 'number'); + }); + + it('returns null for an unparsable document', () => { + const original = console.error; + console.error = () => undefined; + try { + assert.equal(Schema.fromDocument('{nope'), null); + } finally { + console.error = original; + } + }); +}); + +describe('Schema.fromVc', () => { + it('returns null when the document has no $defs', () => { + assert.equal(Schema.fromVc({}), null); + }); + + it('builds a schema from the first nested $defs entry', () => { + const vc = { + $defs: { + '#Nested&1.0.0': { + ...minimalDocument(), + $id: '#Nested&1.0.0', + title: 'Nested', + }, + }, + }; + const s = Schema.fromVc(vc); + assert.ok(s instanceof Schema); + assert.equal(s.document.$id, '#Nested&1.0.0'); + assert.equal(s.document.title, 'Nested'); + }); + + it('returns null when $defs is empty', () => { + assert.equal(Schema.fromVc({ $defs: {} }), null); + }); +}); + +describe('Schema.updateRefs', () => { + it('fills document.$defs with referenced sub-schema documents', () => { + const child = new Schema(); + child.iri = '#Child&1.0.0'; + child.document = { $id: '#Child&1.0.0', properties: {} }; + const parent = new Schema(); + parent.setFields([ + { name: 'c', type: '#Child&1.0.0', isRef: true, fields: [] }, + ], [], true); + parent.updateDocument(); + parent.updateRefs([child]); + assert.deepEqual(Object.keys(parent.document.$defs), ['#Child&1.0.0']); + assert.deepEqual(parent.document.$defs['#Child&1.0.0'], child.document); + }); +}); diff --git a/interfaces/tests/schema-model-deep-extra.test.mjs b/interfaces/tests/schema-model-deep-extra.test.mjs new file mode 100644 index 0000000000..03f95baa39 --- /dev/null +++ b/interfaces/tests/schema-model-deep-extra.test.mjs @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; + +import { Schema } from '../dist/models/schema.js'; +import { SchemaHelper } from '../dist/helpers/schema-helper.js'; + +function buildSchema() { + const fields = [ + { name: 'a', title: 'A', description: 'A', type: 'string', required: true, isArray: false, isRef: false, readOnly: false, order: 0 }, + { + name: 'b', title: 'B', description: 'B', type: 'number', required: false, + isArray: true, isRef: false, readOnly: false, order: 1, + }, + ]; + const schema = new Schema(); + schema.uuid = 'uuidModel'; + schema.version = '1.0.0'; + schema.contextURL = 'schema:uuidModel'; + schema.iri = '#uuidModel&1.0.0'; + schema.name = 'My Schema'; + schema.description = 'A schema'; + schema.entity = 'VC'; + schema.update(fields, []); + return schema; +} + +describe('Schema.clone fidelity', () => { + it('copies every scalar identity field', () => { + const s = buildSchema(); + s.owner = 'o'; + s.creator = 'c'; + s.topicId = '0.0.1'; + s.messageId = 'm'; + const c = s.clone(); + for (const key of ['uuid', 'name', 'description', 'entity', 'status', 'version', 'creator', 'owner', 'topicId', 'messageId', 'iri', 'contextURL']) { + assert.equal(c[key], s[key], `mismatch on ${key}`); + } + }); + + it('shares the fields and conditions references', () => { + const s = buildSchema(); + const c = s.clone(); + assert.equal(c.fields, s.fields); + assert.equal(c.conditions, s.conditions); + }); + + it('produces an independent Schema instance', () => { + const s = buildSchema(); + const c = s.clone(); + assert.notEqual(c, s); + assert.ok(c instanceof Schema); + }); +}); + +describe('Schema.getFields and getField', () => { + it('flattens top-level fields', () => { + const s = buildSchema(); + const names = s.getFields().map((f) => f.name); + assert.ok(names.includes('a')); + assert.ok(names.includes('b')); + }); + + it('resolves a top-level field by path', () => { + const s = buildSchema(); + s.searchFields(() => true); + assert.equal(s.getField('a').name, 'a'); + }); + + it('returns null for an unknown path', () => { + const s = buildSchema(); + s.searchFields(() => true); + assert.equal(s.getField('nope'), null); + }); +}); + +describe('Schema.searchFields', () => { + it('returns fields matching the predicate and assigns paths', () => { + const s = buildSchema(); + const arrays = s.searchFields((f) => f.isArray); + assert.equal(arrays.length, 1); + assert.equal(arrays[0].name, 'b'); + assert.equal(arrays[0].path, 'b'); + }); + + it('returns [] when nothing matches', () => { + const s = buildSchema(); + assert.deepEqual(s.searchFields(() => false), []); + }); + + it('returns all when the predicate is always true', () => { + const s = buildSchema(); + const all = s.searchFields(() => true); + assert.equal(all.length, 2); + }); +}); + +describe('Schema.getDeepFields', () => { + it('returns one node per top-level field', () => { + const s = buildSchema(); + const nodes = s.getDeepFields(); + assert.equal(nodes.length, 2); + }); + + it('reports arrayLvl 0 for a scalar and 1 for an array', () => { + const s = buildSchema(); + const nodes = s.getDeepFields(); + const a = nodes.find((n) => n.field.name === 'a'); + const b = nodes.find((n) => n.field.name === 'b'); + assert.equal(a.arrayLvl, 0); + assert.equal(b.arrayLvl, 1); + }); + + it('suffixes fullType with [] per array level', () => { + const s = buildSchema(); + const b = s.getDeepFields().find((n) => n.field.name === 'b'); + assert.ok(b.type.endsWith('[]')); + }); + + it('attaches the underlying field to each node', () => { + const s = buildSchema(); + const nodes = s.getDeepFields(); + assert.equal(nodes[0].field, s.fields[0]); + }); + + it('returns [] when there are no fields', () => { + const empty = new Schema(); + assert.deepEqual(empty.getDeepFields(), []); + }); +}); + +describe('Schema.setExample', () => { + it('writes examples into matching document properties', () => { + const s = buildSchema(); + s.setExample({ a: 'hello' }); + assert.deepEqual(s.document.properties.a.examples, ['hello']); + }); + + it('leaves properties without data untouched', () => { + const s = buildSchema(); + s.setExample({ a: 'hello' }); + assert.equal(s.document.properties.b.examples, undefined); + }); + + it('is a no-op for falsy data', () => { + const s = buildSchema(); + const before = JSON.stringify(s.document); + s.setExample(null); + assert.equal(JSON.stringify(s.document), before); + }); +}); + +describe('Schema.updateRefs', () => { + it('writes $defs from referenced schemas', () => { + const s = buildSchema(); + s.fields[0].isRef = true; + s.fields[0].type = '#Sub'; + s.updateRefs([{ iri: '#Sub', document: { title: 'sub' } }]); + assert.ok(s.document.$defs['#Sub']); + assert.equal(s.document.$defs['#Sub'].title, 'sub'); + }); + + it('produces an empty $defs when there are no refs', () => { + const s = buildSchema(); + s.updateRefs([]); + assert.deepEqual(s.document.$defs, {}); + }); +}); + +describe('Schema.update and updateDocument', () => { + it('update returns null when there are no fields', () => { + const s = new Schema(); + assert.equal(s.update(undefined, undefined), null); + }); + + it('updateDocument rebuilds a JSON-schema document', () => { + const s = buildSchema(); + s.updateDocument(); + assert.equal(s.document.$id, '#uuidModel&1.0.0'); + assert.ok(s.document.properties.a); + }); + + it('a rebuilt document re-parses to the same field names', () => { + const s = buildSchema(); + s.updateDocument(); + const reparsed = new Schema({ uuid: s.uuid, version: s.version, contextURL: s.contextURL, iri: s.iri, document: s.document }); + const names = reparsed.fields.map((f) => f.name); + assert.ok(names.includes('a')); + assert.ok(names.includes('b')); + }); +}); + +describe('Schema.setDocument', () => { + it('re-derives name and description from the document', () => { + const s = buildSchema(); + s.updateDocument(); + const doc = { ...s.document, title: 'New Title', description: 'New Desc' }; + const s2 = new Schema({ uuid: s.uuid, version: s.version, contextURL: s.contextURL, iri: s.iri }); + s2.setDocument(doc); + assert.equal(s2.name, 'New Title'); + assert.equal(s2.description, 'New Desc'); + }); +}); + +describe('Schema.setFields force semantics', () => { + it('without force, only accepts arrays', () => { + const s = buildSchema(); + const originalFields = s.fields; + s.setFields(undefined, undefined, false); + assert.equal(s.fields, originalFields); + }); + + it('with force, coerces missing values to empty arrays', () => { + const s = buildSchema(); + s.setFields(undefined, undefined, true); + assert.deepEqual(s.fields, []); + assert.deepEqual(s.conditions, []); + }); + + it('without force, replaces arrays when provided', () => { + const s = buildSchema(); + const newFields = [{ name: 'z', type: 'string' }]; + s.setFields(newFields, [], false); + assert.equal(s.fields, newFields); + }); +}); + +describe('Schema.isOwner / isCreator getters', () => { + it('isOwner reflects owner === userDID', () => { + const s = buildSchema(); + s.owner = 'alice'; + assert.ok(!s.isOwner); + s.setUser('alice'); + assert.equal(s.isOwner, true); + }); + + it('isCreator reflects creator === userDID', () => { + const s = buildSchema(); + s.creator = 'bob'; + s.setUser('bob'); + assert.equal(s.isCreator, true); + }); + + it('isOwner is falsy without a user', () => { + const s = buildSchema(); + s.owner = 'alice'; + assert.ok(!s.isOwner); + }); +}); + +describe('Schema.setVersion', () => { + it('accepts a greater version and records the previous one', () => { + const s = buildSchema(); + s.setVersion('2.0.0'); + assert.equal(s.version, '2.0.0'); + assert.equal(s.previousVersion, '1.0.0'); + }); + + it('throws on an invalid version format', () => { + const s = buildSchema(); + assert.throws(() => s.setVersion('xx'), /Invalid version format/); + }); + + it('throws when the new version is not greater', () => { + const s = buildSchema(); + assert.throws(() => s.setVersion('0.5.0'), /Version must be greater than/); + }); +}); + +describe('Schema.from / fromDocument', () => { + it('from wraps a response into a Schema', () => { + const s = Schema.from({ name: 'x', uuid: 'u' }); + assert.ok(s instanceof Schema); + assert.equal(s.name, 'x'); + }); + + it('SchemaHelper.map and Schema.from agree on instance type', () => { + const list = SchemaHelper.map([{ name: 'a' }]); + assert.ok(list[0] instanceof Schema); + }); +}); diff --git a/interfaces/tests/schema-model-extra.test.mjs b/interfaces/tests/schema-model-extra.test.mjs new file mode 100644 index 0000000000..159375cb7a --- /dev/null +++ b/interfaces/tests/schema-model-extra.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; + +const baseSchema = () => { + const s = new Schema({ uuid: 'u', iri: '#x', version: '1.0.0', name: 'S', description: 'd' }); + s.setFields([ + { name: 'a', description: 'A', type: 'string', required: false, isArray: false, isRef: false, readOnly: false, order: 0 }, + { name: 'b', description: 'B', type: 'number', required: false, isArray: true, isRef: false, readOnly: false, order: 1 }, + ], [], true); + return s; +}; + +describe('Schema model — updateDocument / round-trip parseDocument', () => { + it('updateDocument builds a JSON-schema document', () => { + const s = baseSchema(); + s.updateDocument(); + assert.equal(s.document.title, 'S'); + assert.ok(s.document.properties.a); + assert.ok(s.document.properties.b); + }); + + it('a schema reconstructed from a document re-parses its fields', () => { + const s = baseSchema(); + s.updateDocument(); + const s2 = new Schema({ document: s.document, iri: '#x', uuid: 'u', version: '1.0.0' }); + assert.deepEqual(s2.fields.map((f) => f.name), ['a', 'b']); + }); + + it('reconstructed array field keeps isArray', () => { + const s = baseSchema(); + s.updateDocument(); + const s2 = new Schema({ document: s.document, iri: '#x', uuid: 'u', version: '1.0.0' }); + assert.equal(s2.fields.find((f) => f.name === 'b').isArray, true); + }); + + it('setDocument re-derives name and description from the document', () => { + const s = baseSchema(); + s.updateDocument(); + const doc = s.document; + const s2 = new Schema({ uuid: 'u', iri: '#x', version: '1.0.0' }); + s2.setDocument(doc); + assert.equal(s2.name, 'S'); + assert.equal(s2.description, 'd'); + assert.equal(s2.fields.length, 2); + }); +}); + +describe('Schema model — getDeepFields', () => { + it('returns one node per top-level field', () => { + const s = baseSchema(); + const nodes = s.getDeepFields(); + assert.equal(nodes.length, 2); + assert.deepEqual(nodes.map((n) => n.path), ['a', 'b']); + }); + + it('computes fullType with array suffix from arrayLvl', () => { + const s = baseSchema(); + const nodes = s.getDeepFields(); + const b = nodes.find((n) => n.path === 'b'); + assert.equal(b.arrayLvl, 1); + assert.equal(b.type, 'number[]'); + }); + + it('non-array primitive has arrayLvl 0', () => { + const s = baseSchema(); + const a = s.getDeepFields().find((n) => n.path === 'a'); + assert.equal(a.arrayLvl, 0); + assert.equal(a.type, 'string'); + }); + + it('each node carries a reference to its underlying field', () => { + const s = baseSchema(); + const nodes = s.getDeepFields(); + assert.equal(nodes[0].field, s.fields[0]); + }); + + it('returns an empty array when there are no fields', () => { + const s = new Schema({ uuid: 'u', iri: '#x', version: '1.0.0' }); + s.setFields([], [], true); + assert.deepEqual(s.getDeepFields(), []); + }); +}); + +describe('Schema model — setExample', () => { + it('writes examples into matching document properties', () => { + const s = baseSchema(); + s.updateDocument(); + s.setExample({ a: 'hello' }); + assert.deepEqual(s.document.properties.a.examples, ['hello']); + }); + + it('leaves unrelated properties untouched', () => { + const s = baseSchema(); + s.updateDocument(); + s.setExample({ a: 'hello' }); + assert.equal(s.document.properties.b.examples, undefined); + }); + + it('is a no-op when given falsy data', () => { + const s = baseSchema(); + s.updateDocument(); + const before = JSON.stringify(s.document); + s.setExample(null); + assert.equal(JSON.stringify(s.document), before); + }); +}); + +describe('Schema model — update', () => { + it('update replaces fields and rebuilds the document', () => { + const s = baseSchema(); + s.update([ + { name: 'z', description: 'Z', type: 'string', required: false, isArray: false, isRef: false, readOnly: false, order: 0 }, + ], []); + assert.deepEqual(s.fields.map((f) => f.name), ['z']); + assert.ok(s.document.properties.z); + }); +}); diff --git a/interfaces/tests/schema-model-paths-fields.test.mjs b/interfaces/tests/schema-model-paths-fields.test.mjs new file mode 100644 index 0000000000..45333ec5d6 --- /dev/null +++ b/interfaces/tests/schema-model-paths-fields.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; + +const nestedDocument = () => ({ + $id: '#u-1&1.0.0', + $comment: JSON.stringify({ term: 'u-1&1.0.0', '@id': 'ctx:#u-1&1.0.0' }), + title: 'Nested', + description: 'd', + type: 'object', + properties: { + ref1: { $ref: '#sub' }, + list: { type: 'array', items: { type: 'string' } }, + }, + required: [], + $defs: { + '#sub': { + properties: { + x: { type: 'string' }, + deep: { $ref: '#sub2' }, + }, + required: [], + $defs: { '#sub2': { properties: { y: { type: 'number' } }, required: [] } }, + }, + '#sub2': { properties: { y: { type: 'number' } }, required: [] }, + }, +}); + +describe('Schema model — nested path and type derivation', () => { + it('assigns dotted paths to nested fields', () => { + const s = new Schema({ iri: '#u-1&1.0.0', document: nestedDocument() }); + const ref = s.fields.find((f) => f.name === 'ref1'); + assert.equal(ref.path, 'ref1'); + assert.equal(ref.fields.find((f) => f.name === 'x').path, 'ref1.x'); + }); + + it('builds fullPath from the schema iri', () => { + const s = new Schema({ iri: '#u-1&1.0.0', document: nestedDocument() }); + const ref = s.fields.find((f) => f.name === 'ref1'); + assert.equal(ref.fullPath, '#u-1&1.0.0/ref1'); + assert.equal(ref.fields.find((f) => f.name === 'x').fullPath, '#u-1&1.0.0/ref1.x'); + }); + + it('marks ref fields as object and nests their scalar types', () => { + const s = new Schema({ iri: '#u-1&1.0.0', document: nestedDocument() }); + const ref = s.fields.find((f) => f.name === 'ref1'); + assert.equal(ref.fullType, 'object'); + assert.equal(ref.fields.find((f) => f.name === 'x').fullType, 'string'); + }); + + it('appends [] per array level to fullType', () => { + const s = new Schema({ iri: '#u-1&1.0.0', document: nestedDocument() }); + const list = s.fields.find((f) => f.name === 'list'); + assert.equal(list.isArray, true); + assert.equal(list.fullType, 'string[]'); + }); +}); + +describe('Schema model — setFields and update', () => { + it('setFields without force only accepts arrays', () => { + const s = new Schema(); + s.fields = [{ name: 'keep' }]; + s.conditions = [{ id: 'keep' }]; + s.setFields(undefined, undefined); + assert.deepEqual(s.fields, [{ name: 'keep' }]); + assert.deepEqual(s.conditions, [{ id: 'keep' }]); + }); + + it('setFields without force replaces arrays', () => { + const s = new Schema(); + s.setFields([{ name: 'a' }], [{ id: 'c' }]); + assert.deepEqual(s.fields, [{ name: 'a' }]); + assert.deepEqual(s.conditions, [{ id: 'c' }]); + }); + + it('setFields with force coerces missing values to empty arrays', () => { + const s = new Schema(); + s.fields = [{ name: 'old' }]; + s.setFields(undefined, undefined, true); + assert.deepEqual(s.fields, []); + assert.deepEqual(s.conditions, []); + }); + + it('update returns null when no fields are available', () => { + const s = new Schema(); + s.fields = null; + assert.equal(s.update(undefined, undefined), null); + }); + + it('update builds a fresh document from the given fields', () => { + const s = new Schema(); + s.update([{ name: 'a', title: 'A', description: 'A', type: 'string', required: true, isArray: false, isRef: false, readOnly: false }], []); + assert.ok(s.document.properties.a); + assert.ok(s.document.required.includes('a')); + }); +}); + +describe('Schema.fromVc', () => { + it('returns a Schema built from the first $defs entry', () => { + const sub = { + $id: '#s-1&1.0.0', + $comment: JSON.stringify({ term: 's-1&1.0.0', '@id': 'ctx:#s-1&1.0.0' }), + title: 'Sub', + properties: { x: { type: 'string' } }, + required: [], + }; + const result = Schema.fromVc({ $defs: { '#s-1&1.0.0': sub } }); + assert.ok(result instanceof Schema); + assert.equal(result.document.$id, '#s-1&1.0.0'); + }); + + it('returns null when the document has no $defs', () => { + assert.equal(Schema.fromVc({}), null); + }); + + it('returns null for an empty $defs object', () => { + assert.equal(Schema.fromVc({ $defs: {} }), null); + }); + + it('returns null when reading the document throws', () => { + const original = console.error; + console.error = () => { }; + try { + const trap = {}; + Object.defineProperty(trap, '$defs', { get() { throw new Error('boom'); } }); + assert.equal(Schema.fromVc(trap), null); + } finally { + console.error = original; + } + }); +}); diff --git a/interfaces/tests/schema-model-suite.test.mjs b/interfaces/tests/schema-model-suite.test.mjs new file mode 100644 index 0000000000..48dc4584db --- /dev/null +++ b/interfaces/tests/schema-model-suite.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; +import { SchemaStatus } from '../dist/type/schema-status.type.js'; + +describe('Schema model — defaults', () => { + it('a no-arg schema gets sensible defaults', () => { + const s = new Schema(); + assert.equal(s.entity, SchemaEntity.NONE); + assert.equal(s.status, SchemaStatus.DRAFT); + assert.equal(s.document, null); + assert.equal(s.version, ''); + assert.equal(s.readonly, false); + assert.equal(s.system, false); + }); + + it('generates a uuid and a schema: context URL', () => { + const s = new Schema(); + assert.match(s.uuid, /^[0-9a-f-]{36}$/i); + assert.equal(s.contextURL, `schema:${s.uuid}`); + }); +}); + +describe('Schema model — mapping from ISchema', () => { + const raw = (over = {}) => ({ + id: 'sid', uuid: 'u-1', name: 'My Schema', description: 'desc', + entity: SchemaEntity.VC, version: '1.2.3', creator: 'did:c', owner: 'did:o', + topicId: '0.0.1', messageId: 'm-1', iri: '#sid', ...over + }); + + it('copies the descriptive fields', () => { + const s = new Schema(raw()); + assert.equal(s.name, 'My Schema'); + assert.equal(s.description, 'desc'); + assert.equal(s.entity, SchemaEntity.VC); + assert.equal(s.version, '1.2.3'); + assert.equal(s.creator, 'did:c'); + assert.equal(s.owner, 'did:o'); + assert.equal(s.topicId, '0.0.1'); + assert.equal(s.iri, '#sid'); + }); + + it('sets userDID to owner when isOwner is flagged', () => { + const s = new Schema(raw({ isOwner: true })); + assert.equal(s.isOwner, true); + assert.equal(s.isCreator, false); + }); + + it('sets userDID to creator when isCreator is flagged', () => { + const s = new Schema(raw({ isCreator: true })); + assert.equal(s.isCreator, true); + }); + + it('isOwner is falsy with no user set', () => { + const s = new Schema(raw()); + assert.ok(!s.isOwner); + }); +}); + +describe('Schema model — setUser / setVersion', () => { + it('setUser drives the isOwner getter', () => { + const s = new Schema({ owner: 'did:o' }); + assert.ok(!s.isOwner); + s.setUser('did:o'); + assert.equal(s.isOwner, true); + }); + + it('setVersion throws on an invalid format', () => { + const s = new Schema(); + assert.throws(() => s.setVersion('not-a-version'), /Invalid version format/); + }); + + it('setVersion bumps the version and records the previous one', () => { + const s = new Schema({ version: '1.0.0' }); + s.setVersion('1.1.0'); + assert.equal(s.version, '1.1.0'); + assert.equal(s.previousVersion, '1.0.0'); + }); + + it('setVersion rejects a non-greater version', () => { + const s = new Schema({ version: '2.0.0' }); + assert.throws(() => s.setVersion('1.0.0'), /Version must be greater/); + }); +}); + +describe('Schema model — fields access', () => { + const withFields = () => { + const s = new Schema(); + s.setFields([ + { name: 'a', path: 'a', fields: [{ name: 'b', path: 'a.b' }] }, + { name: 'c', path: 'c' } + ], [], true); + return s; + }; + + it('setFields(force) replaces fields and conditions', () => { + const s = withFields(); + assert.equal(s.fields.length, 2); + assert.deepEqual(s.conditions, []); + }); + + it('getFields flattens nested fields depth-first', () => { + const names = withFields().getFields().map((f) => f.name); + assert.deepEqual(names, ['a', 'b', 'c']); + }); + + it('getField resolves a nested path', () => { + assert.equal(withFields().getField('a.b').name, 'b'); + }); + + it('getField returns null for an unknown path', () => { + assert.equal(withFields().getField('zzz'), null); + }); + + it('searchFields returns fields matching the predicate', () => { + const result = withFields().searchFields((f) => f.name === 'b'); + assert.equal(result.length, 1); + assert.equal(result[0].name, 'b'); + }); +}); + +describe('Schema model — clone / from', () => { + it('clone copies identity and fields', () => { + const s = new Schema({ name: 'Original', version: '1.0.0', owner: 'did:o' }); + s.setFields([{ name: 'a', path: 'a' }], [], true); + const c = s.clone(); + assert.equal(c.name, 'Original'); + assert.equal(c.uuid, s.uuid); + assert.equal(c.fields, s.fields); + }); + + it('static from builds a Schema instance', () => { + const s = Schema.from({ name: 'X', uuid: 'u' }); + assert.ok(s instanceof Schema); + assert.equal(s.name, 'X'); + }); +}); diff --git a/interfaces/tests/schema-to-json-extra.test.mjs b/interfaces/tests/schema-to-json-extra.test.mjs new file mode 100644 index 0000000000..7c24da9b56 --- /dev/null +++ b/interfaces/tests/schema-to-json-extra.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { SchemaToJson } from '../dist/helpers/schema-json.js'; + +const baseField = (overrides = {}) => ({ + name: 'f', + type: null, + format: null, + pattern: null, + isRef: false, + isArray: false, + isUpdatable: false, + customType: null, + unitSystem: null, + examples: null, + default: null, + ...overrides, +}); + +describe('SchemaToJson.fieldToJson — type mapping', () => { + const cases = [ + ['Date', { type: 'string', format: 'date' }], + ['DateTime', { type: 'string', format: 'date-time' }], + ['Time', { type: 'string', format: 'time' }], + ['Email', { type: 'string', format: 'email' }], + ['URL', { type: 'string', format: 'url' }], + ['Boolean', { type: 'boolean' }], + ['Integer', { type: 'integer' }], + ['Enum', { type: 'string', customType: 'enum' }], + ['GeoJSON', { type: '#GeoJSON', isRef: true, customType: 'geo' }], + ]; + for (const [expected, props] of cases) { + it(`maps ${JSON.stringify(props)} to type=${expected}`, () => { + assert.equal(SchemaToJson.fieldToJson(baseField(props), 0).type, expected); + }); + } + + it('propagates isArray, property, pattern and isUpdatable', () => { + const json = SchemaToJson.fieldToJson( + baseField({ type: 'string', isArray: true, property: 'p1', pattern: '^z', isUpdatable: true }), + 0, + ); + assert.equal(json.isArray, true); + assert.equal(json.property, 'p1'); + assert.equal(json.pattern, '^z'); + assert.equal(json.isUpdatable, true); + }); +}); + +describe('SchemaToJson.conditionToJson', () => { + const f = (n) => baseField({ name: n, type: 'string' }); + + it('serialises a single field predicate plus then/else fields', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { field: { name: 'sel' }, fieldValue: 'yes' }, + thenFields: [f('a')], + elseFields: [f('b')], + }); + assert.equal(json.if.field, 'sel'); + assert.equal(json.if.fieldValue, 'yes'); + assert.deepEqual(json.then.map((x) => x.key), ['a']); + assert.deepEqual(json.else.map((x) => x.key), ['b']); + }); + + it('serialises a multi-clause AND condition', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { AND: [{ field: { name: 'x' }, fieldValue: 1 }, { field: { name: 'y' }, fieldValue: 2 }] }, + thenFields: [], + elseFields: [], + }); + assert.deepEqual(json.if.AND, [{ field: 'x', value: 1 }, { field: 'y', value: 2 }]); + }); + + it('serialises a multi-clause OR condition', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { OR: [{ field: { name: 'x' }, fieldValue: 1 }, { field: { name: 'y' }, fieldValue: 2 }] }, + thenFields: [], + elseFields: [], + }); + assert.deepEqual(json.if.OR, [{ field: 'x', fieldValue: 1 }, { field: 'y', fieldValue: 2 }]); + }); + + it('flattens a single-element AND to a plain field predicate', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { AND: [{ field: { name: 'x' }, fieldValue: 1 }] }, + thenFields: [], + elseFields: [], + }); + assert.equal(json.if.field, 'x'); + assert.equal(json.if.fieldValue, 1); + assert.equal(json.if.AND, undefined); + }); + + it('flattens a single-element OR to a plain field predicate', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { OR: [{ field: { name: 'z' }, fieldValue: 9 }] }, + thenFields: [], + elseFields: [], + }); + assert.equal(json.if.field, 'z'); + assert.equal(json.if.fieldValue, 9); + }); + + it('serialises a predicates array with ANY_OF as OR', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { + op: 'ANY_OF', + predicates: [{ field: { name: 'x' }, fieldValue: 1 }, { field: { name: 'y' }, fieldValue: 2 }], + }, + thenFields: [], + elseFields: [], + }); + assert.deepEqual(json.if.OR, [{ field: 'x', fieldValue: 1 }, { field: 'y', fieldValue: 2 }]); + }); + + it('serialises a predicates array without op as AND', () => { + const json = SchemaToJson.conditionToJson({ + ifCondition: { + predicates: [{ field: { name: 'x' }, fieldValue: 1 }, { field: { name: 'y' }, fieldValue: 2 }], + }, + thenFields: [], + elseFields: [], + }); + assert.deepEqual(json.if.AND, [{ field: 'x', fieldValue: 1 }, { field: 'y', fieldValue: 2 }]); + }); + + it('returns an empty if object for an empty condition', () => { + const json = SchemaToJson.conditionToJson({ ifCondition: {}, thenFields: [], elseFields: [] }); + assert.deepEqual(json.if, {}); + assert.deepEqual(json.then, []); + assert.deepEqual(json.else, []); + }); + + it('tolerates missing then/else arrays', () => { + const json = SchemaToJson.conditionToJson({ ifCondition: { field: { name: 'x' }, fieldValue: 1 } }); + assert.deepEqual(json.then, []); + assert.deepEqual(json.else, []); + }); +}); diff --git a/interfaces/tests/schema-to-json-getters.test.mjs b/interfaces/tests/schema-to-json-getters.test.mjs new file mode 100644 index 0000000000..64f57daf28 --- /dev/null +++ b/interfaces/tests/schema-to-json-getters.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { SchemaToJson } from '../dist/helpers/schema-json.js'; +import { UnitSystem } from '../dist/type/unit-system.type.js'; + +describe('SchemaToJson.getType', () => { + it('maps a ref field with a system type to its dictionary name', () => { + assert.equal(SchemaToJson.getType({ isRef: true, type: '#GeoJSON' }), 'GeoJSON'); + assert.equal(SchemaToJson.getType({ isRef: true, type: '#SentinelHUB' }), 'SentinelHUB'); + }); + + it('returns the raw type for a ref field outside the system dictionary', () => { + assert.equal(SchemaToJson.getType({ isRef: true, type: '#Custom&1.0.0' }), '#Custom&1.0.0'); + }); + + it('maps unitSystem prefix/postfix before dictionary lookup', () => { + assert.equal(SchemaToJson.getType({ unitSystem: UnitSystem.Prefix, type: 'number' }), 'Prefix'); + assert.equal(SchemaToJson.getType({ unitSystem: UnitSystem.Postfix, type: 'number' }), 'Postfix'); + }); + + it('maps customType hederaAccount to HederaAccount', () => { + assert.equal(SchemaToJson.getType({ customType: 'hederaAccount', type: 'string' }), 'HederaAccount'); + }); + + it('maps primitive dictionary entries by structural equality', () => { + assert.equal(SchemaToJson.getType({ type: 'number', isRef: false }), 'Number'); + assert.equal(SchemaToJson.getType({ type: 'integer', isRef: false }), 'Integer'); + assert.equal(SchemaToJson.getType({ type: 'boolean', isRef: false }), 'Boolean'); + }); + + it('maps string formats to their dictionary names', () => { + assert.equal(SchemaToJson.getType({ type: 'string', format: 'date', isRef: false }), 'Date'); + assert.equal(SchemaToJson.getType({ type: 'string', format: 'time', isRef: false }), 'Time'); + assert.equal(SchemaToJson.getType({ type: 'string', format: 'date-time', isRef: false }), 'DateTime'); + assert.equal(SchemaToJson.getType({ type: 'string', format: 'duration', isRef: false }), 'Duration'); + }); + + it('maps the ipfs pattern to Image', () => { + assert.equal(SchemaToJson.getType({ type: 'string', pattern: '^ipfs:\/\/.+', isRef: false }), 'Image'); + }); + + it('falls back to String for an unmatched string field', () => { + assert.equal(SchemaToJson.getType({ type: 'string', pattern: '^abc$', isRef: false }), 'String'); + }); + + it('returns an empty string for an unknown non-string type', () => { + assert.equal(SchemaToJson.getType({ type: 'mystery', isRef: false }), ''); + }); +}); + +describe('SchemaToJson.getPattern', () => { + it('returns the dictionary pattern for Image-shaped fields', () => { + assert.equal(SchemaToJson.getPattern({ type: 'string', pattern: '^ipfs:\/\/.+', isRef: false }), '^ipfs:\/\/.+'); + }); + + it('returns the custom pattern for a plain string field', () => { + assert.equal(SchemaToJson.getPattern({ type: 'string', pattern: '^x$' }), '^x$'); + }); + + it('returns undefined for non-string types', () => { + assert.equal(SchemaToJson.getPattern({ type: 'mystery' }), undefined); + }); +}); + +describe('SchemaToJson.getRequired', () => { + it('prioritises Auto Calculate over Hidden and Required', () => { + assert.equal(SchemaToJson.getRequired({ autocalculate: true, hidden: true, required: true }), 'Auto Calculate'); + }); + + it('prioritises Hidden over Required', () => { + assert.equal(SchemaToJson.getRequired({ hidden: true, required: true }), 'Hidden'); + }); + + it('returns Required and None for the remaining cases', () => { + assert.equal(SchemaToJson.getRequired({ required: true }), 'Required'); + assert.equal(SchemaToJson.getRequired({}), 'None'); + }); +}); + +describe('SchemaToJson small getters', () => { + it('getPrivate returns the boolean or null', () => { + assert.equal(SchemaToJson.getPrivate({ isPrivate: true }), true); + assert.equal(SchemaToJson.getPrivate({ isPrivate: false }), false); + assert.equal(SchemaToJson.getPrivate({ isPrivate: 'yes' }), null); + assert.equal(SchemaToJson.getPrivate({}), null); + }); + + it('getEnum prefers enum, falls back to remoteLink, then null', () => { + assert.deepEqual(SchemaToJson.getEnum({ enum: ['a'], remoteLink: 'ipfs://x' }), ['a']); + assert.equal(SchemaToJson.getEnum({ remoteLink: 'ipfs://x' }), 'ipfs://x'); + assert.equal(SchemaToJson.getEnum({}), null); + }); + + it('getAvailableOptions returns options or null', () => { + assert.deepEqual(SchemaToJson.getAvailableOptions({ availableOptions: ['Point'] }), ['Point']); + assert.equal(SchemaToJson.getAvailableOptions({}), null); + }); + + it('getFront builds a font object with defaults', () => { + assert.deepEqual(SchemaToJson.getFront({ textBold: true }), { size: '18', color: '#000000', bold: true }); + assert.deepEqual(SchemaToJson.getFront({ textSize: '25', textColor: '#ff0000' }), { size: '25', color: '#ff0000', bold: false }); + assert.equal(SchemaToJson.getFront({}), null); + }); + + it('getExpression returns the expression only for autocalculated fields', () => { + assert.equal(SchemaToJson.getExpression({ autocalculate: true, expression: 'a+b' }), 'a+b'); + assert.equal(SchemaToJson.getExpression({ autocalculate: true }), ''); + assert.equal(SchemaToJson.getExpression({ expression: 'a+b' }), null); + }); + + it('getUnit returns the unit only when a unitSystem is set', () => { + assert.equal(SchemaToJson.getUnit({ unitSystem: UnitSystem.Prefix, unit: '$' }), '$'); + assert.equal(SchemaToJson.getUnit({ unit: '$' }), null); + }); + + it('getExample returns the first example or null', () => { + assert.equal(SchemaToJson.getExample({ examples: ['e1', 'e2'] }), 'e1'); + assert.equal(SchemaToJson.getExample({ examples: [] }), null); + assert.equal(SchemaToJson.getExample({}), null); + }); + + it('getDefault and getSuggest pass values through or return null', () => { + assert.equal(SchemaToJson.getDefault({ default: 'd' }), 'd'); + assert.equal(SchemaToJson.getDefault({}), null); + assert.equal(SchemaToJson.getSuggest({ suggest: 's' }), 's'); + assert.equal(SchemaToJson.getSuggest({}), null); + }); +}); diff --git a/interfaces/tests/schema-to-json.test.mjs b/interfaces/tests/schema-to-json.test.mjs new file mode 100644 index 0000000000..742f1a8bec --- /dev/null +++ b/interfaces/tests/schema-to-json.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { SchemaToJson } from '../dist/helpers/schema-json.js'; + +const baseField = (overrides = {}) => ({ + name: 'f', + type: null, + format: null, + pattern: null, + isRef: false, + isArray: false, + isUpdatable: false, + customType: null, + unitSystem: null, + examples: null, + default: null, + ...overrides, +}); + +describe('SchemaToJson.fieldToJson', () => { + it('serialises a basic Number field with required="None" by default', () => { + const json = SchemaToJson.fieldToJson(baseField({ type: 'number', name: 'qty', title: 'Qty' }), 0); + assert.equal(json.key, 'qty'); + assert.equal(json.title, 'Qty'); + assert.equal(json.type, 'Number'); + assert.equal(json.required, 'None'); + assert.equal(json.isArray, false); + }); + + it('marks required="Required" for a required field', () => { + const json = SchemaToJson.fieldToJson(baseField({ type: 'string', required: true }), 0); + assert.equal(json.required, 'Required'); + }); + + it('marks required="Hidden" when the field is hidden', () => { + const json = SchemaToJson.fieldToJson(baseField({ type: 'string', hidden: true }), 0); + assert.equal(json.required, 'Hidden'); + }); + + it('marks required="Auto Calculate" when autocalculate=true', () => { + const json = SchemaToJson.fieldToJson( + baseField({ type: 'string', autocalculate: true, expression: 'a+b' }), + 0, + ); + assert.equal(json.required, 'Auto Calculate'); + assert.equal(json.expression, 'a+b'); + }); + + it("falls back to type='String' for an unrecognised string field", () => { + const json = SchemaToJson.fieldToJson(baseField({ type: 'string' }), 0); + assert.equal(json.type, 'String'); + }); + + it("emits type='HederaAccount' when customType is hederaAccount", () => { + const json = SchemaToJson.fieldToJson(baseField({ type: 'string', customType: 'hederaAccount' }), 0); + assert.equal(json.type, 'HederaAccount'); + }); + + it("emits type='Prefix' / 'Postfix' for unitSystem fields", () => { + const a = SchemaToJson.fieldToJson(baseField({ type: 'number', unitSystem: 'prefix' }), 0); + const b = SchemaToJson.fieldToJson(baseField({ type: 'number', unitSystem: 'postfix' }), 0); + assert.equal(a.type, 'Prefix'); + assert.equal(b.type, 'Postfix'); + }); + + it('emits enum array when field.enum is set', () => { + const json = SchemaToJson.fieldToJson( + baseField({ type: 'string', enum: ['A', 'B'] }), + 0, + ); + assert.deepEqual(json.enum, ['A', 'B']); + }); + + it('emits availableOptions when present', () => { + const json = SchemaToJson.fieldToJson( + baseField({ type: 'string', availableOptions: ['X', 'Y'] }), + 0, + ); + assert.deepEqual(json.availableOptions, ['X', 'Y']); + }); + + it('emits font defaults when textColor/textSize/textBold are present', () => { + const json = SchemaToJson.fieldToJson( + baseField({ type: 'string', textColor: '#fff' }), + 0, + ); + // size defaults to '18' when only color set; bold defaults to false. + assert.equal(json.textColor, '#fff'); + assert.equal(json.textSize, '18'); + assert.equal(json.textBold, false); + }); + + it('emits unit only when unitSystem is set', () => { + const a = SchemaToJson.fieldToJson( + baseField({ type: 'number', unitSystem: 'prefix', unit: 'm' }), + 0, + ); + assert.equal(a.unit, 'm'); + + const b = SchemaToJson.fieldToJson(baseField({ type: 'number', unit: 'm' }), 0); + assert.equal(b.unit, undefined); + }); + + it('includes example only when examples[0] is non-empty', () => { + const a = SchemaToJson.fieldToJson(baseField({ type: 'string', examples: ['hello'] }), 0); + assert.equal(a.example, 'hello'); + + const b = SchemaToJson.fieldToJson(baseField({ type: 'string', examples: [] }), 0); + assert.equal(b.example, undefined); + }); +}); + +describe('SchemaToJson.schemaToJson', () => { + it('serialises name / description / entity', () => { + const json = SchemaToJson.schemaToJson({ + name: 'My', + description: 'desc', + entity: 'VC', + fields: [], + conditions: [], + }); + assert.equal(json.name, 'My'); + assert.equal(json.description, 'desc'); + assert.equal(json.entity, 'VC'); + }); + + it("falls back to entity='NONE' when not supplied", () => { + const json = SchemaToJson.schemaToJson({ + fields: [], conditions: [], + }); + assert.equal(json.entity, 'NONE'); + }); + + it('skips readOnly fields', () => { + const json = SchemaToJson.schemaToJson({ + name: 'My', entity: 'NONE', + fields: [ + baseField({ name: 'a', type: 'string' }), + baseField({ name: 'b', type: 'string', readOnly: true }), + ], + conditions: [], + }); + assert.equal(json.fields.length, 1); + assert.equal(json.fields[0].key, 'a'); + }); +}); diff --git a/interfaces/tests/schema-token-model-edge.test.mjs b/interfaces/tests/schema-token-model-edge.test.mjs new file mode 100644 index 0000000000..bbd713e7ec --- /dev/null +++ b/interfaces/tests/schema-token-model-edge.test.mjs @@ -0,0 +1,499 @@ +import assert from 'node:assert/strict'; +import { Schema } from '../dist/models/schema.js'; +import { Token } from '../dist/models/token.js'; +import { SchemaEntity } from '../dist/type/schema-entity.type.js'; +import { SchemaStatus } from '../dist/type/schema-status.type.js'; +import { SchemaCategory } from '../dist/type/schema-category.type.js'; + +const silenceError = (fn) => { + const original = console.error; + console.error = () => undefined; + try { + return fn(); + } finally { + console.error = original; + } +}; + +describe('@unit Schema model — edge construction', () => { + it('no-arg schema generates a fresh uuid each time', () => { + assert.notEqual(new Schema().uuid, new Schema().uuid); + }); + + it('no-arg schema leaves category undefined', () => { + assert.equal(new Schema().category, undefined); + }); + + it('no-arg schema seeds errors with an empty array', () => { + assert.deepEqual(new Schema().errors, []); + }); + + it('empty-object source still generates a uuid', () => { + assert.match(new Schema({}).uuid, /^[0-9a-f-]{36}$/i); + }); + + it('empty-object source derives contextURL from the generated uuid', () => { + const s = new Schema({}); + assert.equal(s.contextURL, ''); + }); + + it('no-arg source derives a schema: contextURL', () => { + const s = new Schema(); + assert.equal(s.contextURL, `schema:${s.uuid}`); + }); + + it('keeps a provided uuid instead of generating one', () => { + assert.equal(new Schema({ uuid: 'given-uuid' }).uuid, 'given-uuid'); + }); + + it('falls back to default entity and status for a partial source', () => { + const s = new Schema({ name: 'only-name' }); + assert.equal(s.entity, SchemaEntity.NONE); + assert.equal(s.status, SchemaStatus.DRAFT); + }); + + it('coerces blank string fields to empty-string defaults', () => { + const s = new Schema({ name: '', description: '', hash: '' }); + assert.equal(s.name, ''); + assert.equal(s.description, ''); + assert.equal(s.hash, ''); + }); + + it('category POLICY for a non-system empty source', () => { + assert.equal(new Schema({}).category, SchemaCategory.POLICY); + }); + + it('category SYSTEM when system flag is set', () => { + assert.equal(new Schema({ system: true }).category, SchemaCategory.SYSTEM); + }); + + it('an explicit category overrides the system-derived default', () => { + assert.equal(new Schema({ system: true, category: SchemaCategory.TOOL }).category, SchemaCategory.TOOL); + }); + + it('readonly/active/system default to false on a partial source', () => { + const s = new Schema({ name: 'x' }); + assert.equal(s.readonly, false); + assert.equal(s.active, false); + assert.equal(s.system, false); + }); + + it('document defaults to null when absent', () => { + assert.equal(new Schema({ name: 'x' }).document, null); + }); + + it('context defaults to null when absent', () => { + assert.equal(new Schema({ name: 'x' }).context, null); + }); +}); + +describe('@unit Schema model — document/context parsing edges', () => { + it('parses a JSON-string context into an object', () => { + const s = new Schema({ context: '{"@context":{}}' }); + assert.deepEqual(s.context, { '@context': {} }); + }); + + it('throws synchronously on a malformed JSON-string document', () => { + assert.throws(() => new Schema({ document: '{not json' })); + }); + + it('Schema.from swallows malformed-document errors and returns null', () => { + silenceError(() => assert.equal(Schema.from({ document: '{not json' }), null)); + }); + + it('a numeric JSON-string document is stored as a number', () => { + const s = new Schema({ document: '123' }); + assert.equal(s.document, 123); + assert.equal(typeof s.document, 'number'); + }); + + it('a numeric document yields empty fields and conditions', () => { + const s = new Schema({ document: '123' }); + assert.deepEqual(s.fields, []); + assert.deepEqual(s.conditions, []); + }); + + it('an empty-object document parses to empty fields', () => { + const s = new Schema({ document: {} }); + assert.deepEqual(s.fields, []); + }); + + it('type is the bare uuid when version is empty', () => { + const s = new Schema({ uuid: 'abc', document: {} }); + assert.equal(s.type, 'abc'); + }); + + it('type joins uuid and version with an ampersand', () => { + const s = new Schema({ uuid: 'abc', version: '1.0.0', document: {} }); + assert.equal(s.type, 'abc&1.0.0'); + }); +}); + +describe('@unit Schema model — setVersion boundaries', () => { + it('rejects an empty-string version as an invalid format', () => { + assert.throws(() => new Schema().setVersion(''), /Invalid version format/); + }); + + it('rejects a non-numeric version', () => { + assert.throws(() => new Schema().setVersion('v1'), /Invalid version format/); + }); + + it('accepts a two-part version and bumps from an empty current', () => { + const s = new Schema(); + s.setVersion('1.0'); + assert.equal(s.version, '1.0'); + assert.equal(s.previousVersion, ''); + }); + + it('records the prior version as previousVersion on a bump', () => { + const s = new Schema({ version: '1.0.0' }); + s.setVersion('2.0.0'); + assert.equal(s.previousVersion, '1.0.0'); + }); + + it('rejects an equal version as not greater', () => { + const s = new Schema({ version: '1.0.0' }); + assert.throws(() => s.setVersion('1.0.0'), /Version must be greater than 1.0.0/); + }); + + it('rejects a strictly lower version', () => { + const s = new Schema({ version: '2.5.0' }); + assert.throws(() => s.setVersion('2.4.9'), /Version must be greater/); + }); + + it('leaves version untouched after a failed bump', () => { + const s = new Schema({ version: '2.0.0' }); + try { s.setVersion('1.0.0'); } catch { /* expected */ } + assert.equal(s.version, '2.0.0'); + }); +}); + +describe('@unit Schema model — fields manipulation edges', () => { + it('getFields returns an empty array when fields are undefined', () => { + assert.deepEqual(new Schema().getFields(), []); + }); + + it('getField returns null when fields are undefined', () => { + assert.equal(new Schema().getField('any'), null); + }); + + it('searchFields returns an empty array when fields are undefined', () => { + assert.deepEqual(new Schema().searchFields(() => true), []); + }); + + it('getDeepFields returns an empty array when fields are undefined', () => { + assert.deepEqual(new Schema().getDeepFields(), []); + }); + + it('getField returns the first match on duplicate paths', () => { + const s = new Schema(); + s.setFields([{ name: 'first', path: 'p' }, { name: 'second', path: 'p' }], [], true); + assert.equal(s.getField('p').name, 'first'); + }); + + it('getField resolves unicode field paths', () => { + const s = new Schema(); + s.setFields([{ name: 'naïve🚀', path: 'naïve🚀' }], [], true); + assert.equal(s.getField('naïve🚀').name, 'naïve🚀'); + }); + + it('getFields preserves duplicate-named entries', () => { + const s = new Schema(); + s.setFields([{ name: 'dup', path: 'a' }, { name: 'dup', path: 'b' }], [], true); + assert.equal(s.getFields().filter((f) => f.name === 'dup').length, 2); + }); + + it('setFields(force) coerces undefined fields and conditions to empty arrays', () => { + const s = new Schema(); + s.fields = [{ name: 'old' }]; + s.conditions = [{ id: 'old' }]; + s.setFields(undefined, undefined, true); + assert.deepEqual(s.fields, []); + assert.deepEqual(s.conditions, []); + }); + + it('setFields without force ignores non-array arguments', () => { + const s = new Schema(); + s.fields = [{ name: 'keep' }]; + s.setFields('not-an-array', 42); + assert.deepEqual(s.fields, [{ name: 'keep' }]); + }); + + it('setFields without force accepts an empty fields array', () => { + const s = new Schema(); + s.fields = [{ name: 'old' }]; + s.setFields([], undefined); + assert.deepEqual(s.fields, []); + }); + + it('searchFields walks nested fields and assigns paths', () => { + const s = new Schema(); + s.setFields([{ name: 'a', path: 'a', fields: [{ name: 'b', path: '' }] }], [], true); + const found = s.searchFields((f) => f.name === 'b'); + assert.equal(found.length, 1); + assert.equal(found[0].path, 'a.b'); + }); + + it('searchFields with an always-false predicate returns nothing', () => { + const s = new Schema(); + s.setFields([{ name: 'a', path: 'a' }], [], true); + assert.deepEqual(s.searchFields(() => false), []); + }); +}); + +describe('@unit Schema model — clone semantics', () => { + it('clone drops category because it rebuilds from a no-arg Schema', () => { + const s = new Schema({ system: true, category: SchemaCategory.TOOL }); + assert.equal(s.clone().category, undefined); + }); + + it('clone resets errors to the no-arg default', () => { + const s = new Schema({}); + s.errors = [{ code: 1 }]; + assert.deepEqual(s.clone().errors, []); + }); + + it('clone shares the fields array by reference', () => { + const s = new Schema(); + s.setFields([{ name: 'a', path: 'a' }], [], true); + assert.equal(s.clone().fields, s.fields); + }); + + it('clone copies the uuid and identity fields', () => { + const s = new Schema({ uuid: 'u9', owner: 'did:o', topicId: '0.0.5' }); + const c = s.clone(); + assert.equal(c.uuid, 'u9'); + assert.equal(c.owner, 'did:o'); + assert.equal(c.topicId, '0.0.5'); + }); + + it('clone preserves topicCount but not codeVersion', () => { + const s = new Schema({ topicCount: 7, codeVersion: 'cv9' }); + const c = s.clone(); + assert.equal(c.topicCount, 7); + assert.equal(c.codeVersion, ''); + }); +}); + +describe('@unit Schema model — owner/creator getters', () => { + it('isOwner is falsy when owner is empty even if userDID is empty', () => { + const s = new Schema({}); + assert.ok(!s.isOwner); + }); + + it('isOwner becomes true once setUser matches the owner', () => { + const s = new Schema({ owner: 'did:o' }); + s.setUser('did:o'); + assert.equal(s.isOwner, true); + }); + + it('isCreator is independent of isOwner', () => { + const s = new Schema({ owner: 'did:o', creator: 'did:c' }); + s.setUser('did:c'); + assert.equal(s.isCreator, true); + assert.equal(s.isOwner, false); + }); + + it('isOwner stays falsy after setUser to a non-matching DID', () => { + const s = new Schema({ owner: 'did:o' }); + s.setUser('did:x'); + assert.ok(!s.isOwner); + }); +}); + +describe('@unit Schema model — update/setExample edges', () => { + it('update returns null when fields stay null', () => { + const s = new Schema(); + s.fields = null; + assert.equal(s.update(undefined, undefined), null); + }); + + it('updateDocument throws when fields are undefined', () => { + const s = new Schema(); + s.fields = undefined; + assert.throws(() => s.updateDocument(), TypeError); + }); + + it('setExample is a no-op when the document is null', () => { + const s = new Schema(); + s.document = null; + s.setExample({ a: 1 }); + assert.equal(s.document, null); + }); + + it('setExample with falsy data leaves the document untouched', () => { + const s = new Schema({ document: { properties: {}, required: [] } }); + const before = JSON.stringify(s.document); + s.setExample(undefined); + assert.equal(JSON.stringify(s.document), before); + }); +}); + +describe('@unit Schema model — static factories edges', () => { + it('fromDocument returns null for an unparsable string', () => { + silenceError(() => assert.equal(Schema.fromDocument('{bad'), null)); + }); + + it('fromVc returns null for a missing $defs', () => { + assert.equal(Schema.fromVc({}), null); + }); + + it('fromVc returns null for an empty $defs object', () => { + assert.equal(Schema.fromVc({ $defs: {} }), null); + }); + + it('fromVc swallows a throwing $defs accessor', () => { + const trap = {}; + Object.defineProperty(trap, '$defs', { get() { throw new Error('boom'); } }); + silenceError(() => assert.equal(Schema.fromVc(trap), null)); + }); +}); + +describe('@unit Token model — constructor error edges', () => { + it('throws a TypeError when constructed with null', () => { + assert.throws(() => new Token(null), TypeError); + }); + + it('throws a TypeError when constructed with undefined', () => { + assert.throws(() => new Token(undefined), TypeError); + }); + + it('tolerates an empty object and base64-encodes the literal "undefined"', () => { + const t = new Token({}); + assert.equal(t.url, btoa('undefined')); + }); + + it('base64-encodes the literal "null" for a null tokenId', () => { + const t = new Token({ tokenId: null }); + assert.equal(t.url, btoa('null')); + }); + + it('base64-encodes an empty string tokenId to an empty string', () => { + const t = new Token({ tokenId: '' }); + assert.equal(t.url, ''); + }); +}); + +describe('@unit Token model — default and passthrough fields', () => { + it('defaults policies to an empty array when absent', () => { + assert.deepEqual(new Token({ tokenId: 'x' }).policies, []); + }); + + it('keeps a non-array falsy policies value via the || fallback', () => { + assert.deepEqual(new Token({ tokenId: 'x', policies: 0 }).policies, []); + }); + + it('leaves decimals and initialSupply undefined when absent', () => { + const t = new Token({ tokenId: 'x' }); + assert.equal(t.decimals, undefined); + assert.equal(t.initialSupply, undefined); + }); + + it('leaves tokenType undefined when absent', () => { + assert.equal(new Token({ tokenId: 'x' }).tokenType, undefined); + }); + + it('passes through draftToken/canDelete/wipeContractId unchanged', () => { + const t = new Token({ tokenId: 'x' }); + assert.equal(t.draftToken, undefined); + assert.equal(t.canDelete, undefined); + assert.equal(t.wipeContractId, undefined); + }); +}); + +describe('@unit Token model — decimals/supply boundaries', () => { + it('preserves zero decimals and zero supply without coercion', () => { + const t = new Token({ tokenId: 'x', decimals: 0, initialSupply: 0 }); + assert.equal(t.decimals, 0); + assert.equal(t.initialSupply, 0); + }); + + it('preserves negative decimals verbatim', () => { + assert.equal(new Token({ tokenId: 'x', decimals: -3 }).decimals, -3); + }); + + it('preserves a max-safe-integer supply', () => { + assert.equal(new Token({ tokenId: 'x', initialSupply: Number.MAX_SAFE_INTEGER }).initialSupply, Number.MAX_SAFE_INTEGER); + }); + + it('preserves string-typed decimals and supply', () => { + const t = new Token({ tokenId: 'x', decimals: '8', initialSupply: '1000000' }); + assert.equal(t.decimals, '8'); + assert.equal(t.initialSupply, '1000000'); + }); +}); + +describe('@unit Token model — enable flag passthrough', () => { + it('stores each enable flag without boolean coercion', () => { + const t = new Token({ tokenId: 'x', enableAdmin: 'yes', enableFreeze: 1, enableKYC: 0, enableWipe: null }); + assert.equal(t.enableAdmin, 'yes'); + assert.equal(t.enableFreeze, 1); + assert.equal(t.enableKYC, 0); + assert.equal(t.enableWipe, null); + }); + + it('leaves all enable flags undefined when omitted', () => { + const t = new Token({ tokenId: 'x' }); + assert.equal(t.enableAdmin, undefined); + assert.equal(t.enableFreeze, undefined); + assert.equal(t.enableKYC, undefined); + assert.equal(t.enableWipe, undefined); + }); + + it('carries an all-true flag combination', () => { + const t = new Token({ tokenId: 'x', enableAdmin: true, enableFreeze: true, enableKYC: true, enableWipe: true }); + assert.deepEqual( + [t.enableAdmin, t.enableFreeze, t.enableKYC, t.enableWipe], + [true, true, true, true] + ); + }); + + it('carries an all-false flag combination', () => { + const t = new Token({ tokenId: 'x', enableAdmin: false, enableFreeze: false, enableKYC: false, enableWipe: false }); + assert.deepEqual( + [t.enableAdmin, t.enableFreeze, t.enableKYC, t.enableWipe], + [false, false, false, false] + ); + }); +}); + +describe('@unit Token model — association and balance edges', () => { + it('treats numeric-zero associated as not associated', () => { + const t = new Token({ tokenId: 'x', associated: 0 }); + assert.equal(t.associated, 'No'); + assert.equal(t.frozen, 'n/a'); + assert.equal(t.kyc, 'n/a'); + }); + + it('treats an empty-string associated as not associated', () => { + assert.equal(new Token({ tokenId: 'x', associated: '' }).associated, 'No'); + }); + + it('treats a non-empty string associated as associated', () => { + assert.equal(new Token({ tokenId: 'x', associated: 'false' }).associated, 'Yes'); + }); + + it('drops a numeric-zero balance to n/a via the || fallback', () => { + assert.equal(new Token({ tokenId: 'x', associated: true, balance: 0 }).tokenBalance, 'n/a'); + }); + + it('keeps a string-zero balance', () => { + assert.equal(new Token({ tokenId: 'x', associated: true, balance: '0' }).tokenBalance, '0'); + }); + + it('drops a numeric-zero hBarBalance to n/a via the || fallback', () => { + assert.equal(new Token({ tokenId: 'x', associated: true, hBarBalance: 0 }).hBarBalance, 'n/a'); + }); + + it('derives frozen/kyc only when associated', () => { + const t = new Token({ tokenId: 'x', associated: true, frozen: true, kyc: false }); + assert.equal(t.frozen, 'Yes'); + assert.equal(t.kyc, 'No'); + }); + + it('reports frozen/kyc n/a when associated is falsy despite frozen/kyc flags', () => { + const t = new Token({ tokenId: 'x', associated: false, frozen: true, kyc: true }); + assert.equal(t.frozen, 'n/a'); + assert.equal(t.kyc, 'n/a'); + }); +}); diff --git a/interfaces/tests/sentinel-hub-context.test.mjs b/interfaces/tests/sentinel-hub-context.test.mjs new file mode 100644 index 0000000000..db81114f75 --- /dev/null +++ b/interfaces/tests/sentinel-hub-context.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import SentinelHubContext from '../dist/helpers/sentinel-hub/sentinel-hub-context.js'; + +describe('SentinelHUB JSON-LD @context', () => { + it('declares JSON-LD version 1.1', () => { + assert.equal(SentinelHubContext['@context']['@version'], 1.1); + }); + + it('falls back to the traceability undefinedTerm vocabulary', () => { + assert.equal( + SentinelHubContext['@context']['@vocab'], + 'https://w3id.org/traceability/#undefinedTerm', + ); + }); + + it('id and type map to JSON-LD reserved keywords', () => { + assert.equal(SentinelHubContext['@context'].id, '@id'); + assert.equal(SentinelHubContext['@context'].type, '@type'); + }); + + it('SentinelHUB term points at the #SentinelHUB schema fragment', () => { + assert.equal(SentinelHubContext['@context'].SentinelHUB['@id'], '#SentinelHUB'); + }); + + it('every nested SentinelHUB field is typed as schema.org/text', () => { + const inner = SentinelHubContext['@context'].SentinelHUB['@context']; + const expectedType = 'https://www.schema.org/text'; + for (const key of ['layers', 'format', 'maxcc', 'width', 'height', 'time', 'bbox']) { + assert.equal(inner[key]['@type'], expectedType, `${key} should map to schema.org/text`); + } + }); +}); diff --git a/interfaces/tests/sort-objects-array-edge.test.mjs b/interfaces/tests/sort-objects-array-edge.test.mjs new file mode 100644 index 0000000000..0d62a55ed1 --- /dev/null +++ b/interfaces/tests/sort-objects-array-edge.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { sortObjectsArray } from '../dist/helpers/sort-objects-array.js'; + +describe('sortObjectsArray — edge & quirks', () => { + it('only treats the exact string "ASC" as ascending; any other value descends', () => { + const items = [{ n: 3 }, { n: 1 }, { n: 2 }]; + const result = sortObjectsArray(items.map((o) => ({ ...o })), 'n', 'asc'); + assert.deepEqual(result.map((i) => i.n), [3, 2, 1]); + }); + + it('orders negative and floating-point values ascending', () => { + const items = [{ n: 1.5 }, { n: -2 }, { n: 0 }, { n: -2.5 }]; + const result = sortObjectsArray(items, 'n'); + assert.deepEqual(result.map((i) => i.n), [-2.5, -2, 0, 1.5]); + }); + + it('orders descending including negatives', () => { + const items = [{ n: -1 }, { n: 5 }, { n: -10 }]; + const result = sortObjectsArray(items, 'n', 'DESC'); + assert.deepEqual(result.map((i) => i.n), [5, -1, -10]); + }); + + it('does not throw and preserves every element when the field is missing on some items', () => { + const items = [{ n: 2 }, { other: 9 }, { n: 1 }]; + const result = sortObjectsArray(items, 'n'); + assert.equal(result.length, 3); + assert.equal(result.filter((i) => i.n === 2).length, 1); + assert.equal(result.filter((i) => i.n === 1).length, 1); + assert.equal(result.filter((i) => i.other === 9).length, 1); + }); + + it('does not throw and preserves elements for non-numeric field values', () => { + const items = [{ n: 'banana' }, { n: 'apple' }, { n: 'cherry' }]; + const result = sortObjectsArray(items, 'n'); + assert.equal(result.length, 3); + assert.deepEqual([...result].map((i) => i.n).sort(), ['apple', 'banana', 'cherry']); + }); + + it('throws a TypeError on a null/undefined array', () => { + assert.throws(() => sortObjectsArray(null, 'n'), TypeError); + assert.throws(() => sortObjectsArray(undefined, 'n'), TypeError); + }); +}); diff --git a/interfaces/tests/sort-objects-array.test.mjs b/interfaces/tests/sort-objects-array.test.mjs new file mode 100644 index 0000000000..87edecad19 --- /dev/null +++ b/interfaces/tests/sort-objects-array.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { sortObjectsArray } from '../dist/helpers/sort-objects-array.js'; + +describe('sortObjectsArray', () => { + it('sorts ascending by default', () => { + const items = [{ n: 3 }, { n: 1 }, { n: 2 }]; + const result = sortObjectsArray(items, 'n'); + assert.deepEqual(result.map((i) => i.n), [1, 2, 3]); + }); + + it('sorts descending when direction=DESC', () => { + const items = [{ n: 3 }, { n: 1 }, { n: 2 }]; + const result = sortObjectsArray(items, 'n', 'DESC'); + assert.deepEqual(result.map((i) => i.n), [3, 2, 1]); + }); + + it('mutates the input array (in-place sort)', () => { + const items = [{ n: 2 }, { n: 1 }]; + const result = sortObjectsArray(items, 'n'); + assert.equal(result, items); + }); + + it('returns an empty array when input is empty', () => { + assert.deepEqual(sortObjectsArray([], 'n'), []); + }); + + it('handles single-element arrays as a no-op', () => { + const items = [{ n: 42 }]; + assert.deepEqual(sortObjectsArray(items, 'n'), [{ n: 42 }]); + }); +}); diff --git a/interfaces/tests/statistic-item-validator-deep-suite.test.mjs b/interfaces/tests/statistic-item-validator-deep-suite.test.mjs new file mode 100644 index 0000000000..63f87e4d72 --- /dev/null +++ b/interfaces/tests/statistic-item-validator-deep-suite.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { StatisticItemValidator } from '../dist/validators/label-validator/item-statistic-validator.js'; +import { ValidateNamespace } from '../dist/validators/label-validator/namespace.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +function engine(map) { + FormulaEngine.setMathEngine({ evaluate: (expr) => (expr in map ? map[expr] : expr) }); +} + +const docNamespace = (amount) => + new ValidateNamespace('root', [{ schema: 's#1', document: { credentialSubject: { amount } } }]); + +function makeValidator(over = {}) { + return new StatisticItemValidator({ + id: 's1', name: 'Stat', title: 'Stat title', tag: 'S1', schemaId: 's#1', + config: { + variables: [{ id: 'v1', schemaId: 's#1', path: 'amount' }], + scores: [{ id: 'sc1', type: 't', description: 'd', relationships: ['v1'], options: [] }], + formulas: [{ id: 'f1', type: 'number', formula: 'fx' }], + ...over + } + }); +} + +describe('StatisticItemValidator — data flow', () => { + it('updateVariables pulls field values into the scope', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.updateVariables(); + assert.equal(v.getScope().getScore().v1, 8); + }); + + it('validate defaults to valid:true', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + assert.equal(v.validate().valid, true); + }); + + it('validate preserves an existing invalid status', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.setResult(null); + assert.equal(v.validate().valid, false); + }); +}); + +describe('StatisticItemValidator — validation steps', () => { + it('validateVariables passes after updateVariables', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.updateVariables(); + assert.equal(v.validateVariables().valid, true); + }); + + it('validateVariables fails with "Invalid variable" when stale', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + const r = v.validateVariables(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid variable'); + }); + + it('validateFormulas fails with "Invalid formula" when the stored value is stale', () => { + engine({ fx: 5 }); + const v = makeValidator(); + v.setData(docNamespace(8)); + const r = v.validateFormulas(); + assert.equal(r.valid, false); + assert.equal(r.error, 'Invalid formula'); + }); + + it('validateFormulas passes after updateFormulas computes the same value', () => { + engine({ fx: 5 }); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.updateFormulas(); + assert.equal(v.validateFormulas().valid, true); + }); +}); + +describe('StatisticItemValidator — result round-trip', () => { + it('getResult has no status field and omits undefined values', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + const result = v.getResult(); + assert.equal('status' in result, false); + assert.equal('v1' in result, false); + }); + + it('setResult(null) marks the item invalid', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.setResult(null); + assert.equal(v.status, false); + assert.equal(v.getStatus().error, 'Invalid document'); + }); + + it('setResult loads values and always marks valid:true', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.setResult({ status: false, v1: 9, f1: 3 }); + assert.equal(v.status, true); + const result = v.getResult(); + assert.equal(result.v1, 9); + assert.equal(result.f1, 3); + }); + + it('getVC wraps id, schema and the result', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + const vc = v.getVC(); + assert.equal(vc.id, 's1'); + assert.equal(vc.schema, 's#1'); + }); + + it('setVC delegates to setResult and returns true', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + assert.equal(v.setVC({ v1: 1 }), true); + assert.equal(v.status, true); + }); + + it('clear resets the status', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + v.validate(); + v.clear(); + assert.equal(v.status, undefined); + }); +}); + +describe('StatisticItemValidator — getSteps', () => { + it('emits the three substeps plus a final validate step', () => { + engine({}); + const v = makeValidator(); + v.setData(docNamespace(8)); + assert.deepEqual(v.getSteps().map((s) => s.type), ['variables', 'scores', 'formulas', 'validate']); + }); + + it('emits only the validate step when there is no config', () => { + engine({}); + const v = new StatisticItemValidator({ id: 's1', tag: 'S1' }); + v.setData(docNamespace(8)); + assert.equal(v.getSteps().length, 1); + }); +}); diff --git a/interfaces/tests/statistic-score-data.test.mjs b/interfaces/tests/statistic-score-data.test.mjs new file mode 100644 index 0000000000..902e3ab717 --- /dev/null +++ b/interfaces/tests/statistic-score-data.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { ScoreData } from '../dist/validators/statistic-validator/score.js'; +import { VariableData } from '../dist/validators/statistic-validator/variables.js'; + +const options = [ + { description: 'Low', value: 1 }, + { description: 'High', value: 2 }, + { description: 'Zero', value: 0 }, +]; + +describe('ScoreData constructor', () => { + it('copies id, type and description', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd' }); + assert.equal(s.id, 's1'); + assert.equal(s.type, 't'); + assert.equal(s.description, 'd'); + }); + + it('defaults relationships and options to empty arrays', () => { + const s = new ScoreData({ id: 's1' }); + assert.deepEqual(s.relationships, []); + assert.deepEqual(s.options, []); + }); +}); + +describe('ScoreData.setRelationships', () => { + it('maps relationship ids to variable instances and drops unknown ids', () => { + const v1 = new VariableData({ id: 'v1' }); + const v2 = new VariableData({ id: 'v2' }); + const s = new ScoreData({ id: 's1', relationships: ['v2', 'missing'] }); + s.setRelationships([v1, v2]); + assert.deepEqual(s._relationships, [v2]); + }); + + it('sets _relationships to [] when variables is not an array', () => { + const s = new ScoreData({ id: 's1', relationships: ['v1'] }); + s.setRelationships(null); + assert.deepEqual(s._relationships, []); + }); + + it('builds _options entries with generated ids', () => { + const s = new ScoreData({ id: 's1', options }); + s.setRelationships([]); + assert.equal(s._options.length, 3); + assert.equal(s._options[0].description, 'Low'); + assert.equal(s._options[0].value, 1); + assert.match(s._options[0].id, /^[0-9a-f-]{36}$/i); + }); + + it('generates a distinct id per option', () => { + const s = new ScoreData({ id: 's1', options }); + s.setRelationships([]); + const ids = new Set(s._options.map((o) => o.id)); + assert.equal(ids.size, 3); + }); + + it('sets _options to [] when options is not an array', () => { + const s = new ScoreData({ id: 's1', options: { not: 'array' } }); + s.setRelationships([]); + assert.deepEqual(s._options, []); + }); +}); + +describe('ScoreData value mapping', () => { + it('setValue maps an option description to its value', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('High'); + assert.equal(s.value, 2); + }); + + it('setValue keeps the raw value when no option matches', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('Unknown'); + assert.equal(s.value, 'Unknown'); + }); + + it('setValue falls back to the raw input when the matched option value is falsy', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('Zero'); + assert.equal(s.value, 'Zero'); + }); + + it('getValue maps the stored value back to its description', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('Low'); + assert.equal(s.getValue(), 'Low'); + }); + + it('getValue stringifies a value without a matching option', () => { + const s = new ScoreData({ id: 's1', options }); + s.value = 42; + assert.equal(s.getValue(), '42'); + }); +}); + +describe('ScoreData.validate', () => { + it('is true when the stored value equals the argument and matches an option', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('Low'); + assert.equal(s.validate(1), true); + }); + + it('is false when the stored value differs from the argument', () => { + const s = new ScoreData({ id: 's1', options }); + s.setValue('Low'); + assert.equal(s.validate(2), false); + }); + + it('is false when the value matches but is not a known option', () => { + const s = new ScoreData({ id: 's1', options }); + s.value = 99; + assert.equal(s.validate(99), false); + }); +}); + +describe('ScoreData.from', () => { + it('maps an array of configs to instances', () => { + const list = ScoreData.from([{ id: 'a' }, { id: 'b' }]); + assert.equal(list.length, 2); + assert.ok(list[0] instanceof ScoreData); + assert.equal(list[1].id, 'b'); + }); + + it('returns [] for a non-array', () => { + assert.deepEqual(ScoreData.from(undefined), []); + assert.deepEqual(ScoreData.from({}), []); + }); +}); diff --git a/interfaces/tests/statistic-validator-suite.test.mjs b/interfaces/tests/statistic-validator-suite.test.mjs new file mode 100644 index 0000000000..2f17603e4c --- /dev/null +++ b/interfaces/tests/statistic-validator-suite.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { FormulaData } from '../dist/validators/statistic-validator/formula.js'; +import { VariableData } from '../dist/validators/statistic-validator/variables.js'; +import { ScoreData } from '../dist/validators/statistic-validator/score.js'; + +describe('FormulaData', () => { + const item = { id: 'f1', type: 'string', description: 'desc', formula: 'a+b', rule: { type: 'formula' } }; + + it('copies declared fields from the source item', () => { + const f = new FormulaData(item); + assert.equal(f.id, 'f1'); + assert.equal(f.type, 'string'); + assert.equal(f.description, 'desc'); + assert.equal(f.formula, 'a+b'); + assert.deepEqual(f.rule, { type: 'formula' }); + }); + + it('value is undefined until set', () => { + const f = new FormulaData(item); + assert.equal(f.getValue(), undefined); + }); + + it('setValue / getValue round-trip', () => { + const f = new FormulaData(item); + f.setValue(123); + assert.equal(f.getValue(), 123); + }); + + it('validate compares against the stored value', () => { + const f = new FormulaData(item); + f.setValue('x'); + assert.equal(f.validate('x'), true); + assert.equal(f.validate('y'), false); + }); + + it('static from maps an array of items into instances', () => { + const arr = FormulaData.from([item, { ...item, id: 'f2' }]); + assert.equal(arr.length, 2); + assert.ok(arr[0] instanceof FormulaData); + assert.equal(arr[1].id, 'f2'); + }); + + it('static from returns [] for non-array input', () => { + assert.deepEqual(FormulaData.from(undefined), []); + assert.deepEqual(FormulaData.from(null), []); + assert.deepEqual(FormulaData.from({}), []); + }); +}); + +describe('VariableData', () => { + const item = { + id: 'v1', schemaId: 's#1', path: 'a.b', schemaName: 'S', schemaPath: 'sp', + fieldType: 'number', fieldRef: false, fieldArray: true, + fieldDescription: 'd', fieldProperty: 'p', fieldPropertyName: 'pn' + }; + + it('copies declared fields from the source item', () => { + const v = new VariableData(item); + assert.equal(v.id, 'v1'); + assert.equal(v.schemaId, 's#1'); + assert.equal(v.path, 'a.b'); + assert.equal(v.schemaName, 'S'); + assert.equal(v.fieldType, 'number'); + assert.equal(v.fieldRef, false); + assert.equal(v.fieldArray, true); + assert.equal(v.fieldPropertyName, 'pn'); + }); + + it('setValue records the value and an isArray flag for scalars', () => { + const v = new VariableData(item); + v.setValue(5); + assert.equal(v.getValue(), 5); + assert.equal(v.isArray, false); + }); + + it('setValue flags array values as arrays', () => { + const v = new VariableData(item); + v.setValue([1, 2]); + assert.deepEqual(v.getValue(), [1, 2]); + assert.equal(v.isArray, true); + }); + + it('validate compares against the stored value', () => { + const v = new VariableData(item); + v.setValue('hello'); + assert.equal(v.validate('hello'), true); + assert.equal(v.validate('world'), false); + }); + + it('static from maps arrays and returns [] for non-arrays', () => { + assert.equal(VariableData.from([item]).length, 1); + assert.ok(VariableData.from([item])[0] instanceof VariableData); + assert.deepEqual(VariableData.from(undefined), []); + }); +}); + +describe('ScoreData', () => { + const options = [ + { description: 'Low', value: 1 }, + { description: 'High', value: 10 } + ]; + + it('defaults relationships and options to empty arrays when omitted', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd' }); + assert.deepEqual(s.relationships, []); + assert.deepEqual(s.options, []); + }); + + it('copies provided relationships and options', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: ['v1'], options }); + assert.deepEqual(s.relationships, ['v1']); + assert.deepEqual(s.options, options); + }); + + it('setRelationships resolves relationship ids against the variable list', () => { + const v1 = new VariableData({ id: 'v1' }); + const v2 = new VariableData({ id: 'v2' }); + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: ['v2'], options: [] }); + s.setRelationships([v1, v2]); + assert.equal(s._relationships.length, 1); + assert.equal(s._relationships[0].id, 'v2'); + }); + + it('setRelationships drops ids that are not present in the variable list', () => { + const v1 = new VariableData({ id: 'v1' }); + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: ['nope'], options: [] }); + s.setRelationships([v1]); + assert.deepEqual(s._relationships, []); + }); + + it('setRelationships builds option copies with generated ids', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options }); + s.setRelationships([]); + assert.equal(s._options.length, 2); + assert.equal(s._options[0].description, 'Low'); + assert.equal(s._options[0].value, 1); + assert.ok(typeof s._options[0].id === 'string' && s._options[0].id.length > 0); + assert.notEqual(s._options[0].id, s._options[1].id); + }); + + it('setRelationships with a non-array yields empty relationships', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options }); + s.setRelationships(null); + assert.deepEqual(s._relationships, []); + }); + + it('setValue maps an option description to its value', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options }); + s.setValue('High'); + assert.equal(s.value, 10); + }); + + it('setValue stores the raw value when no option description matches', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options }); + s.setValue('Unknown'); + assert.equal(s.value, 'Unknown'); + }); + + it('getValue maps a stored value back to its option description', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options }); + s.setValue('Low'); + assert.equal(s.getValue(), 'Low'); + }); + + it('getValue stringifies the value when no option value matches', () => { + const s = new ScoreData({ id: 's1', type: 't', description: 'd', relationships: [], options: [] }); + s.setValue(42); + assert.equal(s.getValue(), '42'); + }); +}); diff --git a/interfaces/tests/timeout-error-formula.test.mjs b/interfaces/tests/timeout-error-formula.test.mjs new file mode 100644 index 0000000000..285fcc5f86 --- /dev/null +++ b/interfaces/tests/timeout-error-formula.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { TimeoutError } from '../dist/errors/timeout.error.js'; +import { FormulaEngine } from '../dist/validators/utils/formula.js'; + +describe('TimeoutError', () => { + it('extends Error', () => { + const err = new TimeoutError('boom'); + assert.ok(err instanceof Error); + assert.equal(err.message, 'boom'); + }); + + it('exposes isTimeoutError=true marker', () => { + const err = new TimeoutError('x'); + assert.equal(err.isTimeoutError, true); + }); + + it("can be distinguished from a plain Error via the marker", () => { + const t = new TimeoutError('a'); + const e = new Error('b'); + assert.equal(t.isTimeoutError, true); + assert.equal(e.isTimeoutError, undefined); + }); +}); + +describe('interfaces FormulaEngine', () => { + afterEach(() => FormulaEngine.setMathEngine(null)); + + it('throws when no math engine has been registered', () => { + FormulaEngine.setMathEngine(null); + assert.throws(() => FormulaEngine.evaluate('1+1', {}), /Math engine is not defined/); + }); + + it("returns 'Incorrect formula' when the engine throws", () => { + FormulaEngine.setMathEngine({ evaluate: () => { throw new Error('bad'); } }); + assert.equal(FormulaEngine.evaluate('oops', {}), 'Incorrect formula'); + }); + + it('strips leading "=" before evaluating', () => { + let captured = ''; + FormulaEngine.setMathEngine({ + evaluate(formula) { captured = formula; return 7; }, + }); + assert.equal(FormulaEngine.evaluate('= 2 + 5', {}), 7); + // After two .trim() and stripping leading '=', a single leading space remains. + assert.equal(captured.trim(), '2 + 5'); + }); + + it('passes the supplied scope to the math engine', () => { + let capturedScope; + FormulaEngine.setMathEngine({ + evaluate(_formula, scope) { capturedScope = scope; return 0; }, + }); + FormulaEngine.evaluate('a', { a: 9 }); + assert.deepEqual(capturedScope, { a: 9 }); + }); +}); diff --git a/interfaces/tests/token-model-suite.test.mjs b/interfaces/tests/token-model-suite.test.mjs new file mode 100644 index 0000000000..b1a86218e3 --- /dev/null +++ b/interfaces/tests/token-model-suite.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { Token } from '../dist/models/token.js'; + +describe('Token model', () => { + const base = { + id: 'id1', tokenId: '0.0.123', tokenName: 'My Token', tokenSymbol: 'MTK', + tokenType: 'fungible', decimals: 2, initialSupply: 1000, + enableAdmin: true, enableFreeze: false, enableKYC: true, enableWipe: false, + draftToken: false, canDelete: true, wipeContractId: '0.0.999' + }; + + it('maps the core descriptive fields', () => { + const t = new Token({ ...base }); + assert.equal(t.id, 'id1'); + assert.equal(t.tokenId, '0.0.123'); + assert.equal(t.tokenName, 'My Token'); + assert.equal(t.tokenSymbol, 'MTK'); + assert.equal(t.tokenType, 'fungible'); + assert.equal(t.decimals, 2); + assert.equal(t.initialSupply, 1000); + }); + + it('maps the capability flags', () => { + const t = new Token({ ...base }); + assert.equal(t.enableAdmin, true); + assert.equal(t.enableFreeze, false); + assert.equal(t.enableKYC, true); + assert.equal(t.enableWipe, false); + }); + + it('defaults policies to an empty array when absent', () => { + const t = new Token({ ...base }); + assert.deepEqual(t.policies, []); + }); + + it('keeps a provided policies array', () => { + const t = new Token({ ...base, policies: ['p1', 'p2'] }); + assert.deepEqual(t.policies, ['p1', 'p2']); + }); + + it('base64-encodes the tokenId into url', () => { + const t = new Token({ ...base }); + assert.equal(t.url, btoa('0.0.123')); + }); + + it('carries the wipe contract id and delete/draft flags', () => { + const t = new Token({ ...base }); + assert.equal(t.wipeContractId, '0.0.999'); + assert.equal(t.canDelete, true); + assert.equal(t.draftToken, false); + }); + + describe('when not associated (IToken / plain token)', () => { + it('marks associated as No and balances n/a', () => { + const t = new Token({ ...base }); + assert.equal(t.associated, 'No'); + assert.equal(t.tokenBalance, 'n/a'); + assert.equal(t.hBarBalance, 'n/a'); + }); + + it('reports frozen and kyc as n/a', () => { + const t = new Token({ ...base }); + assert.equal(t.frozen, 'n/a'); + assert.equal(t.kyc, 'n/a'); + }); + }); + + describe('when associated (ITokenInfo)', () => { + it('marks associated as Yes and reads balances', () => { + const t = new Token({ ...base, associated: true, balance: '50', hBarBalance: '1.5' }); + assert.equal(t.associated, 'Yes'); + assert.equal(t.tokenBalance, '50'); + assert.equal(t.hBarBalance, '1.5'); + }); + + it('derives frozen / kyc Yes/No from the info flags', () => { + const t = new Token({ ...base, associated: true, frozen: true, kyc: false }); + assert.equal(t.frozen, 'Yes'); + assert.equal(t.kyc, 'No'); + }); + + it('falls back to n/a balance when balance missing despite association', () => { + const t = new Token({ ...base, associated: true }); + assert.equal(t.tokenBalance, 'n/a'); + assert.equal(t.hBarBalance, 'n/a'); + }); + }); +}); diff --git a/logger-service/package.json b/logger-service/package.json index 5ac235904b..c48568dfad 100644 --- a/logger-service/package.json +++ b/logger-service/package.json @@ -37,6 +37,7 @@ "dev:docker": "nodemon .", "lint": "tslint --config ../tslint.json --project .", "start": "node dist/index.js", + "test": "mocha tests/**/*.test.mjs --reporter mocha-junit-reporter --reporter-options mochaFile=../test_results/logger-service.xml --exit", "watch": "nodemon src/index.ts" }, "type": "module", diff --git a/logger-service/tests/constants.test.mjs b/logger-service/tests/constants.test.mjs new file mode 100644 index 0000000000..b4d26cf3a3 --- /dev/null +++ b/logger-service/tests/constants.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { DEFAULT as DEFAULT_MONGO } from '../dist/constants/mongo.js'; +import { DEFAULT_MONGO as DEFAULT_MONGO_BARREL } from '../dist/constants/index.js'; + +describe('constants barrel (logger-service)', () => { + it('re-exports DEFAULT_MONGO from the mongo module', () => { + assert.equal(DEFAULT_MONGO_BARREL, DEFAULT_MONGO); + }); +}); + +describe('DEFAULT_MONGO constants (logger-service)', () => { + it('exposes the expected mongo pool defaults', () => { + assert.deepEqual(DEFAULT_MONGO, { + MIN_POOL_SIZE: '1', + MAX_POOL_SIZE: '5', + MAX_IDLE_TIME_MS: '30000', + }); + }); + + it('all values are strings (env-style)', () => { + for (const [k, v] of Object.entries(DEFAULT_MONGO)) { + assert.equal(typeof v, 'string', `${k} should be a string`); + } + }); + + it('min pool size is <= max pool size and both are positive integers', () => { + const min = Number(DEFAULT_MONGO.MIN_POOL_SIZE); + const max = Number(DEFAULT_MONGO.MAX_POOL_SIZE); + assert.ok(Number.isInteger(min) && min > 0); + assert.ok(Number.isInteger(max) && max > 0); + assert.ok(min <= max); + }); + + it('max idle time parses to a positive number', () => { + const idle = Number(DEFAULT_MONGO.MAX_IDLE_TIME_MS); + assert.ok(Number.isFinite(idle) && idle > 0); + }); +}); diff --git a/logger-service/tests/logger-service-guards.test.mjs b/logger-service/tests/logger-service-guards.test.mjs new file mode 100644 index 0000000000..0109bc8420 --- /dev/null +++ b/logger-service/tests/logger-service-guards.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { LoggerService, LoggerModule } from '../dist/api/logger.service.js'; + +const service = new LoggerService(); + +describe('LoggerService.writeLog input guard', () => { + it('rejects a null message with a MessageError', async () => { + const result = await service.writeLog(null, {}); + assert.equal(result.code, 500); + assert.equal(result.error, 'Log message is empty'); + }); + + it('rejects an undefined message', async () => { + const result = await service.writeLog(undefined, {}); + assert.equal(result.error, 'Log message is empty'); + }); + + it('rejects an empty-string message', async () => { + const result = await service.writeLog('', {}); + assert.equal(result.error, 'Log message is empty'); + }); + + it('rejects a zero message', async () => { + const result = await service.writeLog(0, {}); + assert.equal(result.error, 'Log message is empty'); + }); + + it('error responses carry a null body', async () => { + const result = await service.writeLog(null, {}); + assert.equal(result.body, null); + }); +}); + +describe('LoggerService surface', () => { + it('exposes the three message handlers', () => { + assert.equal(typeof service.writeLog, 'function'); + assert.equal(typeof service.getLogs, 'function'); + assert.equal(typeof service.getAttributes, 'function'); + }); + + it('exports the LoggerModule class', () => { + assert.equal(typeof LoggerModule, 'function'); + }); +}); diff --git a/logger-service/tests/logger-service-handlers.test.mjs b/logger-service/tests/logger-service-handlers.test.mjs new file mode 100644 index 0000000000..e8ad14e246 --- /dev/null +++ b/logger-service/tests/logger-service-handlers.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { LoggerService } from '../dist/api/logger.service.js'; + +const service = new LoggerService(); + +describe('LoggerService.writeLog with a valid message', () => { + it('returns a MessageError when the database layer is not initialized', async () => { + const result = await service.writeLog({ message: 'hi', type: 'INFO' }, {}); + assert.equal(result.code, 500); + assert.ok(typeof result.error === 'string'); + }); + + it('attempts persistence (surfacing the ORM-not-initialized error)', async () => { + const result = await service.writeLog({ message: 'persist-me' }, {}); + assert.match(String(result.error), /ORM is not initialized/); + }); +}); + +describe('LoggerService.getLogs', () => { + it('returns a MessageError when the query layer is not initialized', async () => { + const result = await service.getLogs({ filters: {}, pageParameters: {} }, {}); + assert.equal(result.code, 500); + assert.match(String(result.error), /ORM is not initialized/); + }); + + it('handles a missing message object', async () => { + const result = await service.getLogs(undefined, {}); + assert.equal(result.code, 500); + }); + + it('handles an explicit sortDirection', async () => { + const result = await service.getLogs({ filters: {}, sortDirection: 'asc' }, {}); + assert.equal(result.code, 500); + }); +}); + +describe('LoggerService.getAttributes', () => { + it('returns a MessageError when aggregation cannot run', async () => { + const result = await service.getAttributes({ name: 'x' }, {}); + assert.equal(result.code, 500); + assert.ok(typeof result.error === 'string'); + }); + + it('handles a missing name (default empty filter)', async () => { + const result = await service.getAttributes({ existingAttributes: ['a'] }, {}); + assert.equal(result.code, 500); + }); + + it('handles provided filters', async () => { + const result = await service.getAttributes({ name: 'y', filters: { datetime: { $gte: 1 } } }, {}); + assert.equal(result.code, 500); + }); +}); diff --git a/logger-service/tests/mongo-constants.test.mjs b/logger-service/tests/mongo-constants.test.mjs new file mode 100644 index 0000000000..6d06851e3e --- /dev/null +++ b/logger-service/tests/mongo-constants.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { DEFAULT } from '../dist/constants/mongo.js'; + +describe('logger-service mongo defaults', () => { + it('exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); +}); diff --git a/notification-service/package.json b/notification-service/package.json index 5f95ab0784..87b80af644 100644 --- a/notification-service/package.json +++ b/notification-service/package.json @@ -38,6 +38,7 @@ "dev:docker": "nodemon .", "lint": "tslint --config ../tslint.json --project .", "start": "node dist/index.js", + "test": "mocha tests/**/*.test.mjs --reporter mocha-junit-reporter --reporter-options mochaFile=../test_results/notification-service.xml --exit", "watch": "nodemon src/index.ts" }, "type": "module", diff --git a/notification-service/tests/config.test.mjs b/notification-service/tests/config.test.mjs new file mode 100644 index 0000000000..7e4b572435 --- /dev/null +++ b/notification-service/tests/config.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; + +describe('notification-service config module', () => { + it('imports without throwing and runs dotenv.config()', async () => { + const mod = await import('../dist/config.js'); + assert.ok(mod); + }); +}); diff --git a/notification-service/tests/constants.test.mjs b/notification-service/tests/constants.test.mjs new file mode 100644 index 0000000000..67a6c5fc26 --- /dev/null +++ b/notification-service/tests/constants.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { DEFAULT as DEFAULT_MONGO } from '../dist/constants/mongo.js'; +import { DEFAULT_MONGO as DEFAULT_MONGO_BARREL } from '../dist/constants/index.js'; + +describe('constants barrel (notification-service)', () => { + it('re-exports DEFAULT_MONGO from the mongo module', () => { + assert.equal(DEFAULT_MONGO_BARREL, DEFAULT_MONGO); + }); +}); + +describe('DEFAULT_MONGO constants (notification-service)', () => { + it('exposes the expected mongo pool defaults', () => { + assert.deepEqual(DEFAULT_MONGO, { + MIN_POOL_SIZE: '1', + MAX_POOL_SIZE: '5', + MAX_IDLE_TIME_MS: '30000', + }); + }); + + it('values are strings (env-style)', () => { + for (const key of Object.keys(DEFAULT_MONGO)) { + assert.equal(typeof DEFAULT_MONGO[key], 'string', `${key} should be a string`); + } + }); + + it('min pool size <= max pool size', () => { + assert.ok( + Number(DEFAULT_MONGO.MIN_POOL_SIZE) <= Number(DEFAULT_MONGO.MAX_POOL_SIZE) + ); + }); + + it('max idle time is positive', () => { + assert.ok(Number(DEFAULT_MONGO.MAX_IDLE_TIME_MS) > 0); + }); +}); diff --git a/notification-service/tests/environment.test.mjs b/notification-service/tests/environment.test.mjs new file mode 100644 index 0000000000..58a3b480f1 --- /dev/null +++ b/notification-service/tests/environment.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { ApplicationEnvironment } from '../dist/environment.js'; + +describe('ApplicationEnvironment', () => { + it('is exported as an object', () => { + assert.equal(typeof ApplicationEnvironment, 'object'); + assert.notEqual(ApplicationEnvironment, null); + }); + + it('exposes a boolean demoMode flag', () => { + assert.equal(typeof ApplicationEnvironment.demoMode, 'boolean'); + }); + + it('defaults demoMode to true', () => { + assert.equal(ApplicationEnvironment.demoMode, true); + }); +}); diff --git a/notification-service/tests/notification-entity.test.mjs b/notification-service/tests/notification-entity.test.mjs new file mode 100644 index 0000000000..27ed2382de --- /dev/null +++ b/notification-service/tests/notification-entity.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { Notification } from '../dist/entity/notification.entity.js'; + +describe('Notification entity', () => { + it('exposes a constructible class extending the Common BaseEntity', () => { + const n = new Notification(); + assert.equal(typeof n, 'object'); + assert.ok(n instanceof Notification); + }); + + it('allows assignment of userId/title/type/action/read/old fields', () => { + const n = new Notification(); + n.userId = 'u1'; + n.title = 'New invite'; + n.type = 'INFO'; + n.action = 'POLICIES_PAGE'; + n.read = false; + n.old = false; + assert.equal(n.userId, 'u1'); + assert.equal(n.title, 'New invite'); + assert.equal(n.read, false); + assert.equal(n.old, false); + }); +}); diff --git a/notification-service/tests/notification-service-guards.test.mjs b/notification-service/tests/notification-service-guards.test.mjs new file mode 100644 index 0000000000..b09f423ce4 --- /dev/null +++ b/notification-service/tests/notification-service-guards.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { NotificationService, NotificationModule } from '../dist/api/notification.service.js'; + +const service = new NotificationService(); + +describe('NotificationService construction', () => { + it('constructs repeatedly without error thanks to the static interval guard', () => { + const again = new NotificationService(); + assert.ok(again instanceof NotificationService); + }); + + it('exports the NotificationModule class', () => { + assert.equal(typeof NotificationModule, 'function'); + }); +}); + +describe('NotificationService input guards', () => { + it('create rejects a null payload', async () => { + const result = await service.create(null); + assert.equal(result.code, 500); + assert.equal(result.error, 'Invalid notification create message'); + }); + + it('create rejects an undefined payload', async () => { + const result = await service.create(undefined); + assert.equal(result.error, 'Invalid notification create message'); + }); + + it('update rejects a null payload', async () => { + const result = await service.update(null); + assert.equal(result.error, 'Invalid notification update message'); + }); + + it('createProgress rejects a null payload', async () => { + const result = await service.createProgress(null); + assert.equal(result.error, 'Invalid progress create message'); + }); + + it('updateProgress rejects a null payload', async () => { + const result = await service.updateProgress(null); + assert.equal(result.error, 'Invalid progress update message'); + }); + + it('deleteProgress rejects an empty id', async () => { + const result = await service.deleteProgress(''); + assert.equal(result.error, 'Invalid notification id'); + }); + + it('deleteProgress rejects a null id', async () => { + const result = await service.deleteProgress(null); + assert.equal(result.error, 'Invalid notification id'); + }); + + it('guard failures respond with a null body and code 500', async () => { + const result = await service.deleteProgress(null); + assert.equal(result.body, null); + assert.equal(result.code, 500); + }); +}); diff --git a/notification-service/tests/notification-service-handlers.test.mjs b/notification-service/tests/notification-service-handlers.test.mjs new file mode 100644 index 0000000000..97010f66c8 --- /dev/null +++ b/notification-service/tests/notification-service-handlers.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { NotificationService } from '../dist/api/notification.service.js'; + +const service = new NotificationService(); + +const isOrmError = (result) => { + assert.equal(result.code, 500); + assert.match(String(result.error), /ORM is not initialized/); +}; + +describe('NotificationService.getNotifications', () => { + it('queries new notifications and surfaces the ORM error', async () => { + isOrmError(await service.getNotifications('user-1')); + }); +}); + +describe('NotificationService.getAll', () => { + it('uses the paged options branch when pageIndex/pageSize are numbers', async () => { + isOrmError(await service.getAll({ userId: 'u', pageIndex: 2, pageSize: 5 })); + }); + + it('uses the unpaged options branch when paging is absent', async () => { + isOrmError(await service.getAll({ userId: 'u' })); + }); + + it('treats a zero page index/size as the paged branch', async () => { + isOrmError(await service.getAll({ userId: 'u', pageIndex: 0, pageSize: 0 })); + }); +}); + +describe('NotificationService.deleteUpToThis', () => { + it('looks up the boundary notification and surfaces the ORM error', async () => { + isOrmError(await service.deleteUpToThis({ id: 'n-1', userId: 'u' })); + }); +}); + +describe('NotificationService.read / readAll', () => { + it('read surfaces the ORM error', async () => { + isOrmError(await service.read('n-1')); + }); + + it('readAll surfaces the ORM error', async () => { + isOrmError(await service.readAll('u-1')); + }); +}); + +describe('NotificationService.getProgresses', () => { + it('queries progresses and surfaces the ORM error', async () => { + isOrmError(await service.getProgresses('u-1')); + }); +}); + +describe('NotificationService.create / update with valid payloads', () => { + it('create attempts to save and surfaces the ORM error', async () => { + isOrmError(await service.create({ title: 't', message: 'm' })); + }); + + it('update attempts to update and surfaces the ORM error', async () => { + isOrmError(await service.update({ id: 'n-1', title: 't' })); + }); +}); + +describe('NotificationService.createProgress / updateProgress with valid payloads', () => { + it('createProgress attempts to save and surfaces the ORM error', async () => { + isOrmError(await service.createProgress({ action: 'a', userId: 'u' })); + }); + + it('updateProgress floors the progress and surfaces the ORM error', async () => { + isOrmError(await service.updateProgress({ id: 'n-1', progress: 42.9 })); + }); +}); + +describe('NotificationService.deleteProgress with a valid id', () => { + it('looks up the progress and surfaces the ORM error', async () => { + isOrmError(await service.deleteProgress('p-1')); + }); +}); + +describe('NotificationService websocket relays (no NATS client)', () => { + it('updateNotificationWS swallows the send failure', async () => { + assert.equal(await service.updateNotificationWS({ id: '1', userId: 'u' }), undefined); + }); + + it('deleteNotificationWS swallows the send failure', async () => { + assert.equal(await service.deleteNotificationWS({ id: '1', userId: 'u' }), undefined); + }); + + it('updateProgressWS swallows the send failure', async () => { + assert.equal(await service.updateProgressWS({ id: '1', userId: 'u' }), undefined); + }); + + it('createProgressWS swallows the send failure', async () => { + assert.equal(await service.createProgressWS({ id: '1', userId: 'u' }), undefined); + }); + + it('deleteProgressWS swallows the send failure', async () => { + assert.equal(await service.deleteProgressWS({ id: '1', userId: 'u' }), undefined); + }); + + it('sendMessage rethrows when the NATS client is unavailable', async () => { + await assert.rejects(() => service.sendMessage('ANY_SUBJECT', { x: 1 })); + }); +}); diff --git a/notification-service/tests/progress-entity.test.mjs b/notification-service/tests/progress-entity.test.mjs new file mode 100644 index 0000000000..f067aa3824 --- /dev/null +++ b/notification-service/tests/progress-entity.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { Progress } from '../dist/entity/progress.entity.js'; + +describe('Progress.onCreate', () => { + it('forces progress to 0 regardless of any prior value', () => { + const p = new Progress(); + p.progress = 42; + p.onCreate(); + assert.equal(p.progress, 0); + }); + + it('initialises progress to 0 when previously undefined', () => { + const p = new Progress(); + p.onCreate(); + assert.equal(p.progress, 0); + }); +}); + +describe('Progress.onUpdate', () => { + it('floors fractional progress to an integer', () => { + const p = new Progress(); + p.progress = 37.9; + p.onUpdate(); + assert.equal(p.progress, 37); + }); + + it('clamps negative values to 0', () => { + const p = new Progress(); + p.progress = -5; + p.onUpdate(); + assert.equal(p.progress, 0); + }); + + it('clamps values above 100 to 100', () => { + const p = new Progress(); + p.progress = 150.7; + p.onUpdate(); + assert.equal(p.progress, 100); + }); + + it('passes integer progress in range through unchanged', () => { + const p = new Progress(); + p.progress = 50; + p.onUpdate(); + assert.equal(p.progress, 50); + }); + + it('treats exactly 100 as in-range (not clamped down)', () => { + const p = new Progress(); + p.progress = 100; + p.onUpdate(); + assert.equal(p.progress, 100); + }); + + it('treats exactly 0 as in-range', () => { + const p = new Progress(); + p.progress = 0; + p.onUpdate(); + assert.equal(p.progress, 0); + }); + + it('coerces -0.4 to 0 (floor first, then clamp)', () => { + const p = new Progress(); + p.progress = -0.4; + p.onUpdate(); + // Math.floor(-0.4) === -1, then clamped to 0 + assert.equal(p.progress, 0); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/block-validator-class.test.mjs b/policy-service/tests/unit-tests/block-validators/block-validator-class.test.mjs new file mode 100644 index 0000000000..b45fdddee9 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/block-validator-class.test.mjs @@ -0,0 +1,480 @@ +import { assert } from 'chai'; +import { BlockValidator } from '../../../dist/policy-engine/block-validators/block-validator.js'; + +function makeFakeValidator(overrides = {}) { + return Object.assign({ + isDryRun: false, + tagCount: () => 0, + permissionsNotExist: () => null, + getTag: () => null, + getSchema: () => null, + getPermission: () => null, + schemaExistByEntity: () => false, + schemaExist: () => false, + unsupportedSchema: () => false, + getTokenTemplate: () => null, + getToken: async () => null, + getTopicTemplate: () => null, + getGroup: () => null, + getArtifact: async () => null, + }, overrides); +} + +function makeConfig(over = {}) { + return Object.assign({ + id: 'block-uuid-1', + blockType: 'someBlockType', + tag: 'TagA', + permissions: ['OWNER'], + }, over); +} + +describe('@unit BlockValidator constructor', () => { + it('stores id, blockType, tag, permissions', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.equal(v.getId(), 'block-uuid-1'); + assert.equal(v.getBlockType(), 'someBlockType'); + assert.equal(v.getTag(), 'TagA'); + assert.deepEqual(v.permissions, ['OWNER']); + }); + + it('initializes empty errors and children', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.deepEqual(v.errors, []); + assert.deepEqual(v.children, []); + }); + + it('strips blockType and children from options', () => { + const v = new BlockValidator(makeConfig({ foo: 'bar', children: [{ id: 'c' }] }), makeFakeValidator()); + const opts = v.getOptions(); + assert.equal(opts.foo, 'bar'); + assert.isUndefined(opts.blockType); + }); + + it('flattens nested options object into options', () => { + const v = new BlockValidator(makeConfig({ options: { nested: 1, deep: 'x' } }), makeFakeValidator()); + const opts = v.getOptions(); + assert.equal(opts.nested, 1); + assert.equal(opts.deep, 'x'); + }); + + it('warningMessagesText and infoMessagesText start empty', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.deepEqual(v.warningMessagesText, []); + assert.deepEqual(v.infoMessagesText, []); + }); +}); + +describe('@unit BlockValidator.isDryRun', () => { + it('is false when validator is not a PolicyValidator instance', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ isDryRun: true })); + assert.isFalse(v.isDryRun); + }); +}); + +describe('@unit BlockValidator children/refs', () => { + it('addChild appends to children', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + const child = new BlockValidator(makeConfig({ id: 'c1', tag: 'c' }), makeFakeValidator()); + v.addChild(child); + assert.equal(v.children.length, 1); + assert.deepEqual(v.getChildrenIds(), ['c1']); + }); + + it('_getRef returns empty children array', () => { + const v = new BlockValidator(makeConfig({ blockType: 'bt' }), makeFakeValidator()); + const ref = v._getRef(); + assert.equal(ref.blockType, 'bt'); + assert.deepEqual(ref.children, []); + }); + + it('getRef includes mapped child refs', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + const c1 = new BlockValidator(makeConfig({ id: 'c1', blockType: 'x' }), makeFakeValidator()); + v.addChild(c1); + const ref = v.getRef(); + assert.equal(ref.children.length, 1); + assert.equal(ref.children[0].blockType, 'x'); + }); + + it('getRef options equals getOptions', () => { + const v = new BlockValidator(makeConfig({ k: 'v' }), makeFakeValidator()); + assert.equal(v.getRef().options.k, 'v'); + }); +}); + +describe('@unit BlockValidator errors', () => { + it('addError pushes to errors', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addError('boom'); + assert.deepEqual(v.errors, ['boom']); + }); + + it('clear empties errors', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addError('a'); + v.addError('b'); + v.clear(); + assert.equal(v.errors.length, 0); + }); + + it('checkBlockError adds error only when truthy', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.checkBlockError(null); + v.checkBlockError(undefined); + v.checkBlockError(''); + assert.equal(v.errors.length, 0); + v.checkBlockError('real error'); + assert.deepEqual(v.errors, ['real error']); + }); +}); + +describe('@unit BlockValidator parent id', () => { + it('getParentId is undefined before set', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.isUndefined(v.getParentId()); + }); + + it('setParentId then getParentId roundtrips', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.setParentId('parent-99'); + assert.equal(v.getParentId(), 'parent-99'); + }); +}); + +describe('@unit BlockValidator.addPrecomputedMessagesAsText', () => { + it('warning severity routes to warningMessagesText', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addPrecomputedMessagesAsText(['w1', 'w2'], 'warning'); + assert.deepEqual(v.warningMessagesText, ['w1', 'w2']); + assert.deepEqual(v.infoMessagesText, []); + }); + + it('non-warning severity routes to infoMessagesText', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addPrecomputedMessagesAsText(['i1'], 'info'); + assert.deepEqual(v.infoMessagesText, ['i1']); + assert.deepEqual(v.warningMessagesText, []); + }); + + it('accumulates across calls', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addPrecomputedMessagesAsText(['a'], 'warning'); + v.addPrecomputedMessagesAsText(['b'], 'warning'); + assert.deepEqual(v.warningMessagesText, ['a', 'b']); + }); +}); + +describe('@unit BlockValidator.getSerializedErrors', () => { + it('isValid true with no errors', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + const s = v.getSerializedErrors(); + assert.isTrue(s.isValid); + assert.deepEqual(s.errors, []); + assert.equal(s.id, 'block-uuid-1'); + assert.equal(s.name, 'someBlockType'); + }); + + it('isValid false when there are errors', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addError('x'); + const s = v.getSerializedErrors(); + assert.isFalse(s.isValid); + assert.deepEqual(s.errors, ['x']); + }); + + it('includes warnings and infos copies', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addPrecomputedMessagesAsText(['w'], 'warning'); + v.addPrecomputedMessagesAsText(['i'], 'info'); + const s = v.getSerializedErrors(); + assert.deepEqual(s.warnings, ['w']); + assert.deepEqual(s.infos, ['i']); + }); + + it('errors array is a copy (slice), not the same ref', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + v.addError('e1'); + const s = v.getSerializedErrors(); + s.errors.push('mutated'); + assert.equal(v.errors.length, 1); + }); +}); + +describe('@unit BlockValidator validator delegation', () => { + it('tagNotExist returns true when validator.getTag falsy', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getTag: () => null })); + assert.isTrue(v.tagNotExist('t')); + }); + + it('tagNotExist returns false when validator.getTag truthy', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getTag: () => ({}) })); + assert.isFalse(v.tagNotExist('t')); + }); + + it('getSchema delegates', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getSchema: (i) => ({ iri: i }) })); + assert.deepEqual(v.getSchema('#abc'), { iri: '#abc' }); + }); + + it('permissionNotExist negates getPermission', () => { + assert.isTrue(new BlockValidator(makeConfig(), makeFakeValidator({ getPermission: () => null })).permissionNotExist('p')); + assert.isFalse(new BlockValidator(makeConfig(), makeFakeValidator({ getPermission: () => 'p' })).permissionNotExist('p')); + }); + + it('schemaNotExistByEntity negates schemaExistByEntity', () => { + assert.isTrue(new BlockValidator(makeConfig(), makeFakeValidator({ schemaExistByEntity: () => false })).schemaNotExistByEntity('e')); + assert.isFalse(new BlockValidator(makeConfig(), makeFakeValidator({ schemaExistByEntity: () => true })).schemaNotExistByEntity('e')); + }); + + it('schemaNotExistByEntity returns true when method absent (optional chaining)', () => { + const fv = makeFakeValidator(); + delete fv.schemaExistByEntity; + assert.isTrue(new BlockValidator(makeConfig(), fv).schemaNotExistByEntity('e')); + }); + + it('schemaNotExist / schemaExist mirror each other', () => { + const t = new BlockValidator(makeConfig(), makeFakeValidator({ schemaExist: () => true })); + assert.isFalse(t.schemaNotExist('#x')); + assert.isTrue(t.schemaExist('#x')); + }); + + it('tokenTemplateNotExist negates getTokenTemplate', () => { + assert.isTrue(new BlockValidator(makeConfig(), makeFakeValidator({ getTokenTemplate: () => null })).tokenTemplateNotExist('n')); + assert.isFalse(new BlockValidator(makeConfig(), makeFakeValidator({ getTokenTemplate: () => ({}) })).tokenTemplateNotExist('n')); + }); + + it('getTokenTemplate delegates', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getTokenTemplate: (n) => ({ n }) })); + assert.deepEqual(v.getTokenTemplate('T'), { n: 'T' }); + }); + + it('topicTemplateNotExist negates getTopicTemplate', () => { + assert.isTrue(new BlockValidator(makeConfig(), makeFakeValidator({ getTopicTemplate: () => null })).topicTemplateNotExist('t')); + assert.isFalse(new BlockValidator(makeConfig(), makeFakeValidator({ getTopicTemplate: () => ({}) })).topicTemplateNotExist('t')); + }); + + it('groupNotExist negates getGroup', () => { + assert.isTrue(new BlockValidator(makeConfig(), makeFakeValidator({ getGroup: () => null })).groupNotExist('g')); + assert.isFalse(new BlockValidator(makeConfig(), makeFakeValidator({ getGroup: () => ({}) })).groupNotExist('g')); + }); + + it('tokenNotExist resolves true when getToken null', async () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getToken: async () => null })); + assert.isTrue(await v.tokenNotExist('0.0.1')); + }); + + it('tokenNotExist resolves false when getToken returns token', async () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getToken: async () => ({ id: '0.0.1' }) })); + assert.isFalse(await v.tokenNotExist('0.0.1')); + }); + + it('getArtifact delegates and awaits', async () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getArtifact: async (u) => ({ u }) })); + assert.deepEqual(await v.getArtifact('a1'), { u: 'a1' }); + }); +}); + +describe('@unit BlockValidator.validateSchema', () => { + it('returns non-existing-schema error when unsupportedSchema true', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ unsupportedSchema: () => true })); + assert.match(v.validateSchema('#x'), /refers to non-existing schema/); + }); + + it('returns null when schema exists and supported', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ unsupportedSchema: () => false, schemaExist: () => true })); + assert.isNull(v.validateSchema('#x')); + }); + + it('returns does-not-exist error when schema missing', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ unsupportedSchema: () => false, schemaExist: () => false })); + assert.match(v.validateSchema('#x'), /does not exist/); + }); +}); + +describe('@unit BlockValidator.validateSchemaVariable', () => { + it('returns null for empty optional value', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.isNull(v.validateSchemaVariable('s', '', false)); + }); + + it('returns "is not set" for empty required value', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.equal(v.validateSchemaVariable('s', '', true), 'Option "s" is not set'); + }); + + it('returns "must be a string" when value not a string', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.equal(v.validateSchemaVariable('s', 123, true), 'Option "s" must be a string'); + }); + + it('delegates to validateSchema for valid string value', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ schemaExist: () => true })); + assert.isNull(v.validateSchemaVariable('s', '#abc', true)); + }); + + it('returns error from validateSchema when schema missing', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ schemaExist: () => false })); + assert.match(v.validateSchemaVariable('s', '#abc', true), /does not exist/); + }); +}); + +describe('@unit BlockValidator.getErrorMessage', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + it('returns string as-is', () => { + assert.equal(v().getErrorMessage('plain'), 'plain'); + }); + it('returns .message', () => { + assert.equal(v().getErrorMessage({ message: 'm' }), 'm'); + }); + it('returns .error when no message', () => { + assert.equal(v().getErrorMessage({ error: 'e' }), 'e'); + }); + it('returns .name when no message/error', () => { + assert.equal(v().getErrorMessage({ name: 'n' }), 'n'); + }); + it('returns Unidentified error otherwise', () => { + assert.equal(v().getErrorMessage({}), 'Unidentified error'); + }); + it('prefers message over error and name', () => { + assert.equal(v().getErrorMessage({ message: 'm', error: 'e', name: 'n' }), 'm'); + }); +}); + +describe('@unit BlockValidator.validateFormula', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + it('true for valid formula', () => { + assert.isTrue(v().validateFormula('a + b * 2')); + }); + it('false for invalid formula', () => { + assert.isFalse(v().validateFormula('a +')); + }); + it('true for numeric literal', () => { + assert.isTrue(v().validateFormula('42')); + }); +}); + +describe('@unit BlockValidator.parsFormulaVariables', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + it('extracts variable symbols', () => { + const vars = v().parsFormulaVariables('a + b'); + assert.include(vars, 'a'); + assert.include(vars, 'b'); + }); + it('excludes known mathjs symbols like pi', () => { + const vars = v().parsFormulaVariables('pi + x'); + assert.notInclude(vars, 'pi'); + assert.include(vars, 'x'); + }); + it('returns empty array on parse error', () => { + assert.deepEqual(v().parsFormulaVariables('a +'), []); + }); + it('returns empty for pure numeric', () => { + assert.deepEqual(v().parsFormulaVariables('1 + 2'), []); + }); +}); + +describe('@unit BlockValidator.compareFields', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + const base = { name: 'a', title: 't', description: 'd', required: true, isArray: false, isRef: false, type: 'string', format: '', pattern: '', unit: '', unitSystem: '', customType: '' }; + it('equal non-ref fields compare true', () => { + assert.isTrue(v().compareFields({ ...base }, { ...base })); + }); + it('different name compares false', () => { + assert.isFalse(v().compareFields({ ...base }, { ...base, name: 'b' })); + }); + it('different type compares false (non-ref)', () => { + assert.isFalse(v().compareFields({ ...base }, { ...base, type: 'number' })); + }); + it('isRef true short-circuits to true when core props match', () => { + const ref = { ...base, isRef: true }; + assert.isTrue(v().compareFields({ ...ref }, { ...ref, type: 'whatever-different' })); + }); + it('different required compares false', () => { + assert.isFalse(v().compareFields({ ...base }, { ...base, required: false })); + }); + it('different isArray compares false', () => { + assert.isFalse(v().compareFields({ ...base }, { ...base, isArray: true })); + }); +}); + +describe('@unit BlockValidator.ifExtendFields', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + const f = (name) => ({ name, title: name, description: '', required: false, isArray: false, isRef: false, type: 'string', format: '', pattern: '', unit: '', unitSystem: '', customType: '' }); + it('returns false when extension or base falsy', () => { + assert.isFalse(v().ifExtendFields(null, [f('a')])); + assert.isFalse(v().ifExtendFields([f('a')], null)); + }); + it('returns true when extension contains all base fields equally', () => { + assert.isTrue(v().ifExtendFields([f('a'), f('b')], [f('a')])); + }); + it('returns false when base field missing in extension', () => { + assert.isFalse(v().ifExtendFields([f('a')], [f('z')])); + }); + it('returns false when matched field differs', () => { + assert.isFalse(v().ifExtendFields([{ ...f('a'), type: 'number' }], [f('a')])); + }); +}); + +describe('@unit BlockValidator.getSchemaFields', () => { + const v = () => new BlockValidator(makeConfig(), makeFakeValidator()); + it('returns null for malformed JSON string', () => { + assert.isNull(v().getSchemaFields('{not json')); + }); + it('returns an array for a minimal valid schema document', () => { + const doc = { $id: '#a', type: 'object', properties: {}, required: [] }; + const res = v().getSchemaFields(doc); + assert.isArray(res); + }); +}); + +describe('@unit BlockValidator.validateBaseSchema', () => { + it('returns null when baseSchema falsy', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator()); + assert.isNull(v.validateBaseSchema(null, {})); + }); + it('returns does-not-exist when base string resolves to nothing', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ getSchema: () => null })); + assert.match(v.validateBaseSchema('#base', '#schema'), /does not exist/); + }); + it('returns schema-does-not-exist when only schema missing', () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ + getSchema: (i) => i === '#base' ? { document: {} } : null, + })); + assert.match(v.validateBaseSchema('#base', '#schema'), /"#schema" does not exist/); + }); +}); + +describe('@unit BlockValidator.validate (integration of pure branches)', () => { + it('adds error when tag count > 1', async () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ tagCount: () => 2 })); + await v.validate(); + assert.isTrue(v.errors.some(e => /already exist/.test(e))); + }); + + it('adds error when permission not exist', async () => { + const v = new BlockValidator(makeConfig(), makeFakeValidator({ permissionsNotExist: () => 'BADPERM' })); + await v.validate(); + assert.isTrue(v.errors.some(e => /Permission BADPERM not exist/.test(e))); + }); + + it('no errors for unknown blockType with clean validator', async () => { + const v = new BlockValidator(makeConfig({ blockType: 'no-such-block-type' }), makeFakeValidator()); + await v.validate(); + assert.equal(v.errors.length, 0); + }); + + it('captures thrown error message', async () => { + const fv = makeFakeValidator({ tagCount: () => { throw new Error('explode'); } }); + const v = new BlockValidator(makeConfig(), fv); + await v.validate(); + assert.deepEqual(v.errors, ['explode']); + }); + + it('captures thrown string error', async () => { + const fv = makeFakeValidator({ tagCount: () => { throw 'strErr'; } }); + const v = new BlockValidator(makeConfig(), fv); + await v.validate(); + assert.deepEqual(v.errors, ['strErr']); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/events-misc-validators-branches.test.mjs b/policy-service/tests/unit-tests/block-validators/events-misc-validators-branches.test.mjs new file mode 100644 index 0000000000..0faba7d62e --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/events-misc-validators-branches.test.mjs @@ -0,0 +1,302 @@ +import { assert } from 'chai'; +import { NotificationType, UserOption } from '@guardian/interfaces'; +import { validators as allValidators } from '../../../dist/policy-engine/block-validators/block-validator.js'; +import { TokenOperationAddon } from '../../../dist/policy-engine/block-validators/blocks/impact-addon.js'; +import { GlobalEventsReaderBlock } from '../../../dist/policy-engine/block-validators/blocks/global-events-reader-block.js'; +import { GlobalEventsWriterBlock } from '../../../dist/policy-engine/block-validators/blocks/global-events-writer-block.js'; +import { NotificationBlock } from '../../../dist/policy-engine/block-validators/blocks/notification.block.js'; +import { MultiSignBlock } from '../../../dist/policy-engine/block-validators/blocks/multi-sign-block.js'; +import { SplitBlock } from '../../../dist/policy-engine/block-validators/blocks/split-block.js'; +import { ButtonBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/button-block-addon.js'; +import { DropdownBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/dropdown-block-addon.js'; +import { CommonBlock } from '../../../dist/policy-engine/block-validators/blocks/common.js'; +import { PropertyValidator } from '../../../dist/policy-engine/block-validators/property-validator.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._artifactMissing = !!opts.artifactMissing; + this._schemaError = opts.schemaError ?? null; + } + addError(msg) { this.errors.push(msg); } + async getArtifact() { return this._artifactMissing ? null : {}; } + getErrorMessage(err) { return err?.message ?? String(err); } + checkBlockError(err) { if (err) { this.errors.push(err); } } + validateSchemaVariable(name, value, required) { + if (this._schemaError) { return this._schemaError; } + if (!value && required) { return `Option "${name}" is not set`; } + return null; + } +} + +const ref = (options = {}, children = []) => ({ options, children }); +const has = (v, sub) => v.errors.some(e => typeof e === 'string' && e.includes(sub)); + +describe('GlobalEventsReaderBlock.validate branches', () => { + it('blockType is globalEventsReaderBlock', () => { + assert.equal(GlobalEventsReaderBlock.blockType, 'globalEventsReaderBlock'); + }); + it('non-array eventTopics adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ eventTopics: 'x' })); + assert.isTrue(has(v, 'Option "eventTopics" must be an array')); + }); + it('eventTopics missing topicId adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ eventTopics: [{ topicId: '' }] })); + assert.isTrue(has(v, 'topicId" is not set')); + }); + it('non-array branches adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ branches: 'x' })); + assert.isTrue(has(v, 'Option "branches" must be an array')); + }); + it('branch missing branchEvent adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ branches: [{ documentType: 'vc' }] })); + assert.isTrue(has(v, 'branchEvent" is not set')); + }); + it('branch invalid documentType adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ branches: [{ branchEvent: 'e', documentType: 'bad' }] })); + assert.isTrue(has(v, 'documentType" must be one of')); + }); + it('branch schema error propagated', async () => { + const v = new FakeValidator({ schemaError: 'bad schema' }); + await GlobalEventsReaderBlock.validate(v, ref({ branches: [{ branchEvent: 'e', documentType: 'vc', schema: 'iri' }] })); + assert.isTrue(has(v, 'bad schema')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await GlobalEventsReaderBlock.validate(v, ref({ eventTopics: [{ topicId: '0.0.1' }], branches: [{ branchEvent: 'e', documentType: 'vc' }] })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('GlobalEventsWriterBlock.validate branches', () => { + it('blockType is globalEventsWriterBlock', () => { + assert.equal(GlobalEventsWriterBlock.blockType, 'globalEventsWriterBlock'); + }); + it('non-array topicIds adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsWriterBlock.validate(v, ref({ topicIds: 'x' })); + assert.isTrue(has(v, 'Option "topicIds" must be an array')); + }); + it('item missing topicId adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsWriterBlock.validate(v, ref({ topicIds: [{ documentType: 'vc' }] })); + assert.isTrue(has(v, 'Option "topicId" is not set')); + }); + it('item missing documentType adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsWriterBlock.validate(v, ref({ topicIds: [{ topicId: '0.0.1' }] })); + assert.isTrue(has(v, 'Option "documentType" is not set')); + }); + it('item invalid documentType adds error', async () => { + const v = new FakeValidator(); + await GlobalEventsWriterBlock.validate(v, ref({ topicIds: [{ topicId: '0.0.1', documentType: 'bad' }] })); + assert.isTrue(has(v, 'Option "documentType" must be one of')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await GlobalEventsWriterBlock.validate(v, ref({ topicIds: [{ topicId: '0.0.1', documentType: 'vc' }] })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('NotificationBlock.validate branches', () => { + it('blockType is notificationBlock', () => { + assert.equal(NotificationBlock.blockType, 'notificationBlock'); + }); + it('missing title adds error', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ message: 'm', type: NotificationType.INFO, user: UserOption.ALL })); + assert.isTrue(has(v, 'Option "title" is empty')); + }); + it('missing message adds error', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ title: 't', type: NotificationType.INFO, user: UserOption.ALL })); + assert.isTrue(has(v, 'Option "message" is empty')); + }); + it('invalid type adds error', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ title: 't', message: 'm', type: 'BOGUS', user: UserOption.ALL })); + assert.isTrue(has(v, 'Option "type" has incorrect value')); + }); + it('invalid user adds error', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ title: 't', message: 'm', type: NotificationType.INFO, user: 'BOGUS' })); + assert.isTrue(has(v, 'Option "user" has incorrect value')); + }); + it('ROLE user without role adds error', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ title: 't', message: 'm', type: NotificationType.INFO, user: UserOption.ROLE })); + assert.isTrue(has(v, 'Option "role" is empty')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await NotificationBlock.validate(v, ref({ title: 't', message: 'm', type: NotificationType.INFO, user: UserOption.ALL })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('MultiSignBlock.validate branches', () => { + it('blockType is multiSignBlock', () => { + assert.equal(MultiSignBlock.blockType, 'multiSignBlock'); + }); + it('missing threshold adds error', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "threshold" is not set')); + }); + it('threshold above 100 adds error', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '150' })); + assert.isTrue(has(v, 'value must be between 0 and 100')); + }); + it('valid threshold yields no errors', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '50' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('SplitBlock.validate branches', () => { + it('blockType is splitBlock', () => { + assert.equal(SplitBlock.blockType, 'splitBlock'); + }); + it('missing threshold adds error', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "threshold" is not set')); + }); + it('valid threshold yields no errors', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({ threshold: '5' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('ButtonBlockAddon.validate branches', () => { + it('blockType is buttonBlockAddon', () => { + assert.equal(ButtonBlockAddon.blockType, 'buttonBlockAddon'); + }); + it('missing name adds error', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, ref({})); + assert.isTrue(has(v, 'Button name is empty')); + }); + it('dialog missing title adds error', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, ref({ name: 'n', dialog: true, dialogOptions: { dialogResultFieldPath: 'p' } })); + assert.isTrue(has(v, 'Dialog title is empty')); + }); + it('dialog missing result field path adds error', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, ref({ name: 'n', dialog: true, dialogOptions: { dialogTitle: 't' } })); + assert.isTrue(has(v, 'Dialog result field path is empty')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, ref({ name: 'n' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('DropdownBlockAddon.validate branches', () => { + it('blockType is dropdownBlockAddon', () => { + assert.equal(DropdownBlockAddon.blockType, 'dropdownBlockAddon'); + }); + it('missing optionName adds error', async () => { + const v = new FakeValidator(); + await DropdownBlockAddon.validate(v, ref({ optionValue: 'v', field: 'f' })); + assert.isTrue(has(v, 'Option name is empty')); + }); + it('missing optionValue adds error', async () => { + const v = new FakeValidator(); + await DropdownBlockAddon.validate(v, ref({ optionName: 'n', field: 'f' })); + assert.isTrue(has(v, 'Option value is empty')); + }); + it('missing field adds error', async () => { + const v = new FakeValidator(); + await DropdownBlockAddon.validate(v, ref({ optionName: 'n', optionValue: 'v' })); + assert.isTrue(has(v, 'Field is empty')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await DropdownBlockAddon.validate(v, ref({ optionName: 'n', optionValue: 'v', field: 'f' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('CommonBlock.validate (artifacts) branches', () => { + it('null artifact entry adds error and returns false', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: [null] })); + assert.isFalse(result); + assert.isTrue(has(v, 'Artifact does not exist')); + }); + it('missing artifact file adds error and returns false', async () => { + const v = new FakeValidator({ artifactMissing: true }); + const result = await CommonBlock.validate(v, ref({ artifacts: [{ uuid: 'u1' }] })); + assert.isFalse(result); + assert.isTrue(has(v, 'Artifact with id "u1" does not exist')); + }); + it('present artifact returns true with no errors', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: [{ uuid: 'u1' }] })); + assert.isTrue(result); + assert.deepEqual(v.errors, []); + }); + it('no artifacts returns true', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({})); + assert.isTrue(result); + }); +}); + +describe('PropertyValidator pure helpers', () => { + it('selectValidator returns null for valid value', () => { + assert.isNull(PropertyValidator.selectValidator('x', 'a', ['a', 'b'])); + }); + it('selectValidator returns message for invalid value', () => { + const m = PropertyValidator.selectValidator('x', 'z', ['a', 'b']); + assert.include(m, 'Option "x" must be one of'); + }); + it('inputValidator returns not-set for empty value', () => { + assert.include(PropertyValidator.inputValidator('x', '', 'string'), 'is not set'); + }); + it('inputValidator returns type message for wrong type', () => { + assert.include(PropertyValidator.inputValidator('x', 5, 'string'), 'must be a string'); + }); + it('inputValidator returns null for valid string', () => { + assert.isNull(PropertyValidator.inputValidator('x', 'ok', 'string')); + }); + it('inputValidator returns null when no type and value present', () => { + assert.isNull(PropertyValidator.inputValidator('x', 5)); + }); +}); + +describe('TokenOperationAddon (impact-addon).validate branches', () => { + it('barrel exposes the impactAddon validator', () => { + assert.equal(allValidators.find(b => b.blockType === 'impactAddon'), TokenOperationAddon); + }); + it('blockType is impactAddon', () => { + assert.equal(TokenOperationAddon.blockType, 'impactAddon'); + }); + it('missing amount adds error', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ impactType: 'Primary Impacts' })); + assert.isTrue(has(v, 'Option "amount" is not set')); + }); + it('invalid impactType adds error', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '1', impactType: 'bogus' })); + assert.isTrue(has(v, 'Option "impactType" must be one of')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '1', impactType: 'Primary Impacts' })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/module-tool-validator-pure.test.mjs b/policy-service/tests/unit-tests/block-validators/module-tool-validator-pure.test.mjs new file mode 100644 index 0000000000..813b575c47 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/module-tool-validator-pure.test.mjs @@ -0,0 +1,250 @@ +import { assert } from 'chai'; +import { ModuleValidator } from '../../../dist/policy-engine/block-validators/module-validator.js'; +import { ToolValidator } from '../../../dist/policy-engine/block-validators/tool-validator.js'; + +const cases = [ + { name: 'ModuleValidator', Cls: ModuleValidator, kind: 'module' }, + { name: 'ToolValidator', Cls: ToolValidator, kind: 'tool' }, +]; + +for (const { name, Cls, kind } of cases) { + describe(`@unit ${name} constructor`, () => { + it('stores uuid from config id', () => { + const v = new Cls({ id: 'X1' }); + assert.equal(v.uuid, 'X1'); + }); + it('base permissions present', () => { + assert.deepEqual(new Cls({ id: 'a' }).permissions, ['NO_ROLE', 'ANY_ROLE', 'OWNER']); + }); + it('collections start empty', () => { + const v = new Cls({ id: 'a' }); + assert.equal(v.blocks.size, 0); + assert.equal(v.tools.size, 0); + assert.equal(v.tags.size, 0); + assert.equal(v.schemas.size, 0); + assert.deepEqual(v.errors, []); + assert.deepEqual(v.tokens, []); + assert.deepEqual(v.topics, []); + assert.deepEqual(v.tokenTemplates, []); + assert.deepEqual(v.groups, []); + assert.deepEqual(v.variables, []); + }); + }); + + describe(`@unit ${name}.registerVariables`, () => { + it('Token variable goes to tokens', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Token', name: 'tk' }] }); + assert.include(v.tokens, 'tk'); + }); + it('Role variable goes to permissions', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Role', name: 'R' }] }); + assert.include(v.permissions, 'R'); + }); + it('Group variable goes to groups', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Group', name: 'G' }] }); + assert.include(v.groups, 'G'); + }); + it('TokenTemplate variable goes to tokenTemplates', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'TokenTemplate', name: 'TT' }] }); + assert.include(v.tokenTemplates, 'TT'); + }); + it('Topic variable goes to topics', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Topic', name: 'TP' }] }); + assert.include(v.topics, 'TP'); + }); + it('String variable is ignored without error', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'String', name: 'S' }] }); + assert.deepEqual(v.errors, []); + }); + it('unknown type yields an error', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Mystery', name: 'M' }] }); + assert.isTrue(v.errors.some(e => /Type 'Mystery' does not exist/.test(e))); + }); + it('all variables pushed into variables list', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ variables: [{ type: 'Token', name: 'a' }, { type: 'Role', name: 'b' }] }); + assert.equal(v.variables.length, 2); + }); + it('no variables array is a no-op', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({}); + assert.deepEqual(v.variables, []); + }); + it('duplicate input event name reports error', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ inputEvents: [{ name: 'E' }, { name: 'E' }] }); + assert.isTrue(v.errors.some(e => /Event 'E' already exist/.test(e))); + }); + it('unique events produce no error', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ inputEvents: [{ name: 'E1' }], outputEvents: [{ name: 'E2' }] }); + assert.deepEqual(v.errors, []); + }); + it('input/output same name collides', () => { + const v = new Cls({ id: 'a' }); + v.registerVariables({ inputEvents: [{ name: 'X' }], outputEvents: [{ name: 'X' }] }); + assert.isTrue(v.errors.some(e => /Event 'X' already exist/.test(e))); + }); + }); + + describe(`@unit ${name}.permissionsNotExist`, () => { + it('null when undefined', () => { + assert.isNull(new Cls({ id: 'a' }).permissionsNotExist(undefined)); + }); + it('null when known', () => { + assert.isNull(new Cls({ id: 'a' }).permissionsNotExist(['OWNER'])); + }); + it('returns unknown permission', () => { + assert.equal(new Cls({ id: 'a' }).permissionsNotExist(['ZZZ']), 'ZZZ'); + }); + }); + + describe(`@unit ${name}.tagCount / getTag`, () => { + it('0 for unknown', () => { + assert.equal(new Cls({ id: 'a' }).tagCount('t'), 0); + }); + it('reflects map value', () => { + const v = new Cls({ id: 'a' }); + v.tags.set('t', 2); + assert.equal(v.tagCount('t'), 2); + }); + it('getTag true/false', () => { + const v = new Cls({ id: 'a' }); + v.tags.set('t', 1); + assert.isTrue(v.getTag('t')); + assert.isFalse(v.getTag('x')); + }); + }); + + describe(`@unit ${name}.getPermission / getGroup`, () => { + it('getPermission returns when present, null otherwise', () => { + const v = new Cls({ id: 'a' }); + assert.equal(v.getPermission('OWNER'), 'OWNER'); + assert.isNull(v.getPermission('NOPE')); + }); + it('getGroup returns {} when present, null when not', () => { + const v = new Cls({ id: 'a' }); + v.groups.push('G'); + assert.deepEqual(v.getGroup('G'), {}); + assert.isNull(v.getGroup('H')); + }); + }); + + describe(`@unit ${name}.getTokenTemplate / getTopicTemplate / getToken`, () => { + it('getTokenTemplate {} when present else null', () => { + const v = new Cls({ id: 'a' }); + v.tokenTemplates.push('TT'); + assert.deepEqual(v.getTokenTemplate('TT'), {}); + assert.isNull(v.getTokenTemplate('XX')); + }); + it('getTopicTemplate {} when present else null', () => { + const v = new Cls({ id: 'a' }); + v.topics.push('TP'); + assert.deepEqual(v.getTopicTemplate('TP'), {}); + assert.isNull(v.getTopicTemplate('XX')); + }); + it('getToken resolves {} when present else null', async () => { + const v = new Cls({ id: 'a' }); + v.tokens.push('0.0.5'); + assert.deepEqual(await v.getToken('0.0.5'), {}); + assert.isNull(await v.getToken('0.0.9')); + }); + }); + + describe(`@unit ${name}.schema helpers`, () => { + it('schemaExist reflects validity', () => { + const v = new Cls({ id: 'a' }); + v.schemas.set('#s', { isValid: true }); + assert.isTrue(v.schemaExist('#s')); + v.schemas.set('#s', { isValid: false }); + assert.isFalse(v.schemaExist('#s')); + }); + it('schemaExist consults tools', () => { + const v = new Cls({ id: 'a' }); + v.tools.set('t', { schemaExist: (iri) => iri === '#fromtool' }); + assert.isTrue(v.schemaExist('#fromtool')); + assert.isFalse(v.schemaExist('#other')); + }); + it('getSchema returns/blocks based on validity', () => { + const v = new Cls({ id: 'a' }); + v.schemas.set('#s', { isValid: true, getSchema: () => ({ iri: '#s' }) }); + assert.deepEqual(v.getSchema('#s'), { iri: '#s' }); + v.schemas.set('#s', { isValid: false, getSchema: () => ({ iri: '#s' }) }); + assert.isNull(v.getSchema('#s')); + }); + it('getSchema falls back to tools', () => { + const v = new Cls({ id: 'a' }); + v.tools.set('t', { getSchema: (iri) => iri === '#x' ? { iri: '#x' } : null }); + assert.deepEqual(v.getSchema('#x'), { iri: '#x' }); + assert.isNull(v.getSchema('#missing')); + }); + it('unsupportedSchema reflects invalidity / tool fallback', () => { + const v = new Cls({ id: 'a' }); + v.schemas.set('#s', { isValid: false }); + assert.isTrue(v.unsupportedSchema('#s')); + v.schemas.set('#s', { isValid: true }); + assert.isFalse(v.unsupportedSchema('#s')); + v.tools.set('t', { unsupportedSchema: (iri) => iri === '#bad' }); + assert.isTrue(v.unsupportedSchema('#bad')); + }); + it('getAllSchemas merges own + tool schemas', () => { + const v = new Cls({ id: 'a' }); + v.schemas.set('#own', {}); + v.tools.set('t', { getAllSchemas: (m) => { m.set('#tool', {}); return m; } }); + const m = v.getAllSchemas(new Map()); + assert.isTrue(m.has('#own')); + assert.isTrue(m.has('#tool')); + }); + }); + + describe(`@unit ${name}.getSerializedErrors`, () => { + it('valid when no errors and empty', () => { + const out = new Cls({ id: 'a' }).getSerializedErrors(); + assert.isTrue(out.isValid); + assert.equal(out.id, 'a'); + }); + it('errors mark invalid and append a "is invalid" block entry', () => { + const v = new Cls({ id: 'a' }); + v.addError('broke'); + const out = v.getSerializedErrors(); + assert.isFalse(out.isValid); + assert.include(out.errors, 'broke'); + assert.isTrue(out.blocks.some(b => new RegExp(`${kind === 'module' ? 'Module' : 'Tool'} is invalid`).test(b.errors[0]))); + }); + it('schema errors fold into commonErrors', () => { + const v = new Cls({ id: 'a' }); + v.schemas.set('#s', { getSerializedErrors: () => ({ errors: ['schema err'], isValid: false }) }); + const out = v.getSerializedErrors(); + assert.include(out.errors, 'schema err'); + assert.isFalse(out.isValid); + }); + }); + + describe(`@unit ${name}.build error path`, () => { + it('returns false for null config', async () => { + assert.isFalse(await new Cls({ id: 'a' }).build(null)); + }); + it('records error for null/invalid', async () => { + const v = new Cls({ id: 'a' }); + await v.build(null); + assert.isTrue(v.errors.length > 0); + }); + }); + + describe(`@unit ${name}.clear`, () => { + it('clears block items without throwing', () => { + const v = new Cls({ id: 'a' }); + let cleared = false; + v.blocks.set('b', { clear: () => { cleared = true; } }); + v.clear(); + assert.isTrue(cleared); + }); + }); +} diff --git a/policy-service/tests/unit-tests/block-validators/schema-formula-validators-branches.test.mjs b/policy-service/tests/unit-tests/block-validators/schema-formula-validators-branches.test.mjs new file mode 100644 index 0000000000..d0dfcda4e6 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/schema-formula-validators-branches.test.mjs @@ -0,0 +1,235 @@ +import { assert } from 'chai'; +import { AggregateBlock } from '../../../dist/policy-engine/block-validators/blocks/aggregate-block.js'; +import { ExternalDataBlock } from '../../../dist/policy-engine/block-validators/blocks/external-data-block.js'; +import { ExternalTopicBlock } from '../../../dist/policy-engine/block-validators/blocks/external-topic-block.js'; +import { DocumentsSourceAddon } from '../../../dist/policy-engine/block-validators/blocks/documents-source-addon.js'; +import { RequestVcDocumentBlock } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block.js'; +import { RequestVcDocumentBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block-addon.js'; +import { CalculateMathAddon } from '../../../dist/policy-engine/block-validators/blocks/calculate-math-addon.js'; +import { CalculateMathVariables } from '../../../dist/policy-engine/block-validators/blocks/calculate-math-variables.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._schemaError = opts.schemaError ?? null; + this._formulaVars = opts.formulaVars ?? []; + this._validFormula = opts.validFormula ?? true; + } + addError(msg) { this.errors.push(msg); } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } + checkBlockError(err) { if (err) { this.errors.push(err); } } + validateSchemaVariable(name, value, required) { + if (this._schemaError) { return this._schemaError; } + if (!value && required) { return `Option "${name}" is not set`; } + return null; + } + parsFormulaVariables() { return this._formulaVars; } + validateFormula() { return this._validFormula; } +} + +const ref = (options = {}, children = []) => ({ options, children }); +const has = (v, sub) => v.errors.some(e => typeof e === 'string' && e.includes(sub)); + +describe('AggregateBlock.validate branches', () => { + it('blockType is aggregateDocumentBlock', () => { + assert.equal(AggregateBlock.blockType, 'aggregateDocumentBlock'); + }); + it('cumulative missing condition adds error', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'cumulative' })); + assert.isTrue(has(v, 'Option "condition" is not set')); + }); + it('cumulative non-string condition adds error', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'cumulative', condition: 5 })); + assert.isTrue(has(v, 'Option "condition" must be a string')); + }); + it('cumulative undefined variable in condition adds error', async () => { + const v = new FakeValidator({ formulaVars: ['x'] }); + await AggregateBlock.validate(v, ref({ aggregateType: 'cumulative', condition: 'x > 1' })); + assert.isTrue(has(v, "Variable 'x' not defined")); + }); + it('cumulative with defined variable passes condition', async () => { + const v = new FakeValidator({ formulaVars: ['x'] }); + await AggregateBlock.validate(v, ref({ aggregateType: 'cumulative', condition: 'x > 1', expressions: [{ name: 'x' }] })); + assert.isFalse(has(v, 'not defined')); + }); + it('period type passes aggregateType check', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'period' })); + assert.isFalse(has(v, 'aggregateType')); + }); + it('unknown aggregateType adds error', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'weird' })); + assert.isTrue(has(v, 'Option "aggregateType" must be one of period, cumulative')); + }); + it('groupByFields with empty fieldPath adds error', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'period', groupByFields: [{ fieldPath: '' }] })); + assert.isTrue(has(v, 'Field path in group fields can not be empty')); + }); + it('groupByFields with fieldPath passes', async () => { + const v = new FakeValidator(); + await AggregateBlock.validate(v, ref({ aggregateType: 'period', groupByFields: [{ fieldPath: 'a' }] })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('ExternalDataBlock.validate branches', () => { + it('blockType is externalDataBlock', () => { + assert.equal(ExternalDataBlock.blockType, 'externalDataBlock'); + }); + it('schema error is propagated', async () => { + const v = new FakeValidator({ schemaError: 'bad schema' }); + await ExternalDataBlock.validate(v, ref({ schema: 'iri' })); + assert.isTrue(has(v, 'bad schema')); + }); + it('valid optional schema yields no errors', async () => { + const v = new FakeValidator(); + await ExternalDataBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); +}); + +describe('ExternalTopicBlock.validate branches', () => { + it('blockType is externalTopicBlock', () => { + assert.equal(ExternalTopicBlock.blockType, 'externalTopicBlock'); + }); + it('schema error is propagated', async () => { + const v = new FakeValidator({ schemaError: 'bad schema' }); + await ExternalTopicBlock.validate(v, ref({ schema: 'iri' })); + assert.isTrue(has(v, 'bad schema')); + }); + it('valid optional schema yields no errors', async () => { + const v = new FakeValidator(); + await ExternalTopicBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); +}); + +describe('DocumentsSourceAddon.validate branches', () => { + it('blockType is documentsSourceAddon', () => { + assert.equal(DocumentsSourceAddon.blockType, 'documentsSourceAddon'); + }); + it('invalid dataType adds error', async () => { + const v = new FakeValidator(); + await DocumentsSourceAddon.validate(v, ref({ dataType: 'weird' })); + assert.isTrue(has(v, 'Option "dataType" must be one of')); + }); + for (const t of ['vc-documents', 'did-documents', 'vp-documents', 'root-authorities', 'standard-registries', 'approve', 'source']) { + it(`dataType ${t} passes type check`, async () => { + const v = new FakeValidator(); + await DocumentsSourceAddon.validate(v, ref({ dataType: t })); + assert.isFalse(has(v, 'Option "dataType" must be one of')); + }); + } + it('schema error propagated', async () => { + const v = new FakeValidator({ schemaError: 'bad schema' }); + await DocumentsSourceAddon.validate(v, ref({ dataType: 'vc-documents', schema: 'iri' })); + assert.isTrue(has(v, 'bad schema')); + }); +}); + +describe('RequestVcDocumentBlock.validate branches', () => { + it('blockType is requestVcDocumentBlock', () => { + assert.equal(RequestVcDocumentBlock.blockType, 'requestVcDocumentBlock'); + }); + it('missing required schema adds error', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "schema" is not set')); + }); + it('valid schema yields no errors', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, ref({ schema: 'iri' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('RequestVcDocumentBlockAddon.validate branches', () => { + it('blockType is requestVcDocumentBlockAddon', () => { + assert.equal(RequestVcDocumentBlockAddon.blockType, 'requestVcDocumentBlockAddon'); + }); + it('missing required schema adds error', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ buttonName: 'b', dialogTitle: 'd' })); + assert.isTrue(has(v, 'Option "schema" is not set')); + }); + it('missing buttonName adds error', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 'iri', dialogTitle: 'd' })); + assert.isTrue(has(v, 'Button name is empty')); + }); + it('missing dialogTitle adds error', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 'iri', buttonName: 'b' })); + assert.isTrue(has(v, 'Dialog title is empty')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 'iri', buttonName: 'b', dialogTitle: 'd' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('CalculateMathAddon.validate branches', () => { + it('blockType is calculateMathAddon', () => { + assert.equal(CalculateMathAddon.blockType, 'calculateMathAddon'); + }); + it('incorrect formula adds error', async () => { + const v = new FakeValidator({ validFormula: false }); + await CalculateMathAddon.validate(v, ref({ equations: [{ formula: 'x +' }] })); + assert.isTrue(has(v, 'Incorrect formula')); + }); + it('correct formula passes', async () => { + const v = new FakeValidator({ validFormula: true }); + await CalculateMathAddon.validate(v, ref({ equations: [{ formula: 'x + 1' }] })); + assert.deepEqual(v.errors, []); + }); + it('no equations passes', async () => { + const v = new FakeValidator(); + await CalculateMathAddon.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + it('getVariables returns equation names', () => { + const out = CalculateMathAddon.getVariables({ options: { equations: [{ variable: 'a' }, { variable: 'b' }] } }, {}); + assert.property(out, 'a'); + assert.property(out, 'b'); + }); +}); + +describe('CalculateMathVariables.validate branches', () => { + it('blockType is calculateMathVariables', () => { + assert.equal(CalculateMathVariables.blockType, 'calculateMathVariables'); + }); + it('selector missing sourceField adds error', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ selectors: [{ comparisonValue: 'x' }] })); + assert.isTrue(has(v, 'Incorrect Source Field')); + }); + it('selector missing comparisonValue adds error', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ selectors: [{ sourceField: 'f' }] })); + assert.isTrue(has(v, 'Incorrect filter')); + }); + it('variable missing variablePath adds error', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ variables: [{}] })); + assert.isTrue(has(v, 'Incorrect Variable Path')); + }); + it('schema error propagated', async () => { + const v = new FakeValidator({ schemaError: 'bad schema' }); + await CalculateMathVariables.validate(v, ref({ sourceSchema: 'iri' })); + assert.isTrue(has(v, 'bad schema')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ + selectors: [{ sourceField: 'f', comparisonValue: 'x' }], + variables: [{ variablePath: 'p' }] + })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/token-account-validators-branches.test.mjs b/policy-service/tests/unit-tests/block-validators/token-account-validators-branches.test.mjs new file mode 100644 index 0000000000..689539e937 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/token-account-validators-branches.test.mjs @@ -0,0 +1,232 @@ +import { assert } from 'chai'; +import { MintBlock } from '../../../dist/policy-engine/block-validators/blocks/mint-block.js'; +import { TokenActionBlock } from '../../../dist/policy-engine/block-validators/blocks/token-action-block.js'; +import { TokenConfirmationBlock } from '../../../dist/policy-engine/block-validators/blocks/token-confirmation-block.js'; +import { RetirementBlock } from '../../../dist/policy-engine/block-validators/blocks/retirement-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._tokenTemplateMissing = !!opts.tokenTemplateMissing; + this._tokenMissing = !!opts.tokenMissing; + } + addError(msg) { this.errors.push(msg); } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } + tokenTemplateNotExist() { return this._tokenTemplateMissing; } + async tokenNotExist() { return this._tokenMissing; } + checkBlockError(err) { if (err) { this.errors.push(err); } } + validateSchemaVariable() { return null; } +} + +const ref = (options = {}, children = []) => ({ options, children }); +const has = (v, sub) => v.errors.some(e => typeof e === 'string' && e.includes(sub)); + +describe('MintBlock.validate branches', () => { + it('blockType is mintDocumentBlock', () => { + assert.equal(MintBlock.blockType, 'mintDocumentBlock'); + }); + it('useTemplate without template adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ useTemplate: true, rule: 'r', accountType: 'default' })); + assert.isTrue(has(v, 'Option "template" is not set')); + }); + it('useTemplate with non-existent template token adds error', async () => { + const v = new FakeValidator({ tokenTemplateMissing: true }); + await MintBlock.validate(v, ref({ useTemplate: true, template: 'T', rule: 'r', accountType: 'default' })); + assert.isTrue(has(v, 'Token "T" does not exist')); + }); + it('non-template path: missing tokenId adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ rule: 'r', accountType: 'default' })); + assert.isTrue(has(v, 'Option "tokenId" is not set')); + }); + it('non-template path: non-string tokenId adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: 123, rule: 'r', accountType: 'default' })); + assert.isTrue(has(v, 'Option "tokenId" must be a string')); + }); + it('non-template path: missing token adds does-not-exist error', async () => { + const v = new FakeValidator({ tokenMissing: true }); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'default' })); + assert.isTrue(has(v, 'Token with id 0.0.1 does not exist')); + }); + it('missing rule adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', accountType: 'default' })); + assert.isTrue(has(v, 'Option "rule" is not set')); + }); + it('non-string rule adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 5, accountType: 'default' })); + assert.isTrue(has(v, 'Option "rule" must be a string')); + }); + it('invalid accountType adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'bogus' })); + assert.isTrue(has(v, 'Option "accountType" must be one of')); + }); + it('accountType custom without accountId adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'custom' })); + assert.isTrue(has(v, 'Option "accountId" is not set')); + }); + it('accountType custom-value with bad hedera value adds error', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'custom-value', accountIdValue: 'nope' })); + assert.isTrue(has(v, 'Option "accountIdValue" has invalid hedera account value')); + }); + it('accountType custom-value with valid hedera value passes that check', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'custom-value', accountIdValue: '0.0.99' })); + assert.isFalse(has(v, 'invalid hedera account value')); + }); + it('valid default config yields no errors', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, ref({ tokenId: '0.0.1', rule: 'r', accountType: 'default' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('TokenActionBlock.validate branches', () => { + it('blockType is tokenActionBlock', () => { + assert.equal(TokenActionBlock.blockType, 'tokenActionBlock'); + }); + it('invalid accountType adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'x', action: 'freeze', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "accountType" must be one of')); + }); + it('default accountType allows associate action', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'associate', tokenId: '0.0.1' })); + assert.isFalse(has(v, 'Option "action" must be one of')); + }); + it('custom accountType disallows associate action', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'custom', action: 'associate', tokenId: '0.0.1', accountId: 'a' })); + assert.isTrue(has(v, 'Option "action" must be one of')); + }); + it('invalid action adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'bogus', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "action" must be one of')); + }); + it('useTemplate missing template adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'freeze', useTemplate: true })); + assert.isTrue(has(v, 'Option "template" is not set')); + }); + it('non-template missing tokenId adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'freeze' })); + assert.isTrue(has(v, 'Option "tokenId" is not set')); + }); + it('non-string tokenId adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'freeze', tokenId: 1 })); + assert.isTrue(has(v, 'Option "tokenId" must be a string')); + }); + it('custom without accountId adds error', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'custom', action: 'freeze', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "accountId" is not set')); + }); + it('valid default config yields no errors', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, ref({ accountType: 'default', action: 'freeze', tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('TokenConfirmationBlock.validate branches', () => { + it('blockType is tokenConfirmationBlock', () => { + assert.equal(TokenConfirmationBlock.blockType, 'tokenConfirmationBlock'); + }); + it('invalid accountType adds error', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'x', action: 'associate', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "accountType" must be one of')); + }); + it('invalid action adds error', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'default', action: 'mint', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "action" must be one of')); + }); + it('useTemplate missing template adds error', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'default', action: 'associate', useTemplate: true })); + assert.isTrue(has(v, 'Option "template" is not set')); + }); + it('non-template missing tokenId adds error', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'default', action: 'associate' })); + assert.isTrue(has(v, 'Option "tokenId" is not set')); + }); + it('custom without accountId adds error', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'custom', action: 'associate', tokenId: '0.0.1' })); + assert.isTrue(has(v, 'Option "accountId" is not set')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, ref({ accountType: 'default', action: 'associate', tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('RetirementBlock.validate branches', () => { + it('blockType is retirementDocumentBlock', () => { + assert.equal(RetirementBlock.blockType, 'retirementDocumentBlock'); + }); + it('useTemplate missing template adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ useTemplate: true })); + assert.isTrue(has(v, 'Option "template" is not set')); + }); + it('non-template missing tokenId adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "tokenId" is not set')); + }); + it('non-string rule adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', rule: 5 })); + assert.isTrue(has(v, 'Option "rule" must be a string')); + }); + it('non-string serialNumbersExpression adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', serialNumbersExpression: 5 })); + assert.isTrue(has(v, 'Option "serial numbers" must be a string')); + }); + it('serial expression with illegal character adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', serialNumbersExpression: '1,@@' })); + assert.isTrue(has(v, 'is not allowed')); + }); + it('serial number less than 1 adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', serialNumbersExpression: '0' })); + assert.isTrue(has(v, 'must be greater than or equal to 1')); + }); + it('valid serial number range yields no serial error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', serialNumbersExpression: '1-3', accountType: 'default' })); + assert.isFalse(has(v, 'Invalid serial')); + }); + it('invalid accountType adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', accountType: 'bogus' })); + assert.isTrue(has(v, 'Option "accountType" must be one of')); + }); + it('custom accountType without accountId adds error', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', accountType: 'custom' })); + assert.isTrue(has(v, 'Option "accountId" is not set')); + }); + it('valid simple config yields no errors', async () => { + const v = new FakeValidator(); + await RetirementBlock.validate(v, ref({ tokenId: '0.0.1', accountType: 'default' })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/token-http-validators-branches.test.mjs b/policy-service/tests/unit-tests/block-validators/token-http-validators-branches.test.mjs new file mode 100644 index 0000000000..3aaea40985 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/token-http-validators-branches.test.mjs @@ -0,0 +1,170 @@ +import { assert } from 'chai'; +import { TokenType } from '@guardian/interfaces'; +import { CreateTokenBlock } from '../../../dist/policy-engine/block-validators/blocks/create-token-block.js'; +import { HttpRequestBlock } from '../../../dist/policy-engine/block-validators/blocks/http-request-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._tokenTemplate = ('tokenTemplate' in opts) ? opts.tokenTemplate : {}; + } + addError(msg) { this.errors.push(msg); } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } + getTokenTemplate() { return this._tokenTemplate; } +} + +const ref = (options = {}) => ({ options, children: [] }); +const has = (v, sub) => v.errors.some(e => typeof e === 'string' && e.includes(sub)); +const countOf = (v, sub) => v.errors.filter(e => typeof e === 'string' && e.includes(sub)).length; + +describe('CreateTokenBlock.validate branches', () => { + it('blockType is createTokenBlock', () => { + assert.equal(CreateTokenBlock.blockType, 'createTokenBlock'); + }); + it('_isEmpty returns true for null and undefined only', () => { + assert.isTrue(CreateTokenBlock._isEmpty(null)); + assert.isTrue(CreateTokenBlock._isEmpty(undefined)); + assert.isFalse(CreateTokenBlock._isEmpty('')); + assert.isFalse(CreateTokenBlock._isEmpty(0)); + assert.isFalse(CreateTokenBlock._isEmpty(false)); + }); + it('missing template adds error', async () => { + const v = new FakeValidator(); + await CreateTokenBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Template can not be empty')); + }); + it('autorun with defaultActive adds error', async () => { + const v = new FakeValidator(); + await CreateTokenBlock.validate(v, ref({ template: 't', autorun: true, defaultActive: true })); + assert.isTrue(has(v, `Autorun can't be use with default active`)); + }); + it('missing token template adds does-not-exist error', async () => { + const v = new FakeValidator({ tokenTemplate: null }); + await CreateTokenBlock.validate(v, ref({ template: 't' })); + assert.isTrue(has(v, 'Token "t" does not exist')); + }); + it('autorun with empty template fields accumulates errors', async () => { + const v = new FakeValidator({ tokenTemplate: {} }); + await CreateTokenBlock.validate(v, ref({ template: 't', autorun: true })); + assert.isTrue(countOf(v, 'Autorun requires all fields to be filled') >= 1); + }); + it('autorun fungible without decimals flags missing field', async () => { + const v = new FakeValidator({ tokenTemplate: { + tokenType: TokenType.FUNGIBLE, tokenName: 'n', tokenSymbol: 's', + enableAdmin: true, enableWipe: false, enableKYC: true, enableFreeze: true + } }); + await CreateTokenBlock.validate(v, ref({ template: 't', autorun: true })); + assert.isTrue(has(v, 'Autorun requires all fields to be filled')); + }); + it('autorun with fully-filled non-fungible template yields no errors', async () => { + const v = new FakeValidator({ tokenTemplate: { + tokenType: 'non-fungible', tokenName: 'n', tokenSymbol: 's', + enableAdmin: true, enableWipe: false, enableKYC: true, enableFreeze: true + } }); + await CreateTokenBlock.validate(v, ref({ template: 't', autorun: true })); + assert.deepEqual(v.errors, []); + }); + it('non-autorun with existing template yields no errors', async () => { + const v = new FakeValidator({ tokenTemplate: {} }); + await CreateTokenBlock.validate(v, ref({ template: 't' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('HttpRequestBlock static helpers', () => { + it('blockType is httpRequestBlock', () => { + assert.equal(HttpRequestBlock.blockType, 'httpRequestBlock'); + }); + it('isPrivateIP detects 10.x as private', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('10.0.0.5', 4)); + }); + it('isPrivateIP detects 192.168.x as private', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('192.168.1.1', 4)); + }); + it('isPrivateIP detects 172.16-31 as private', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('172.20.0.1', 4)); + }); + it('isPrivateIP detects loopback as private', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('127.0.0.1', 4)); + }); + it('isPrivateIP detects link-local as private', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('169.254.1.1', 4)); + }); + it('isPrivateIP returns false for public IPv4', () => { + assert.isFalse(HttpRequestBlock.isPrivateIP('8.8.8.8', 4)); + }); + it('isPrivateIP returns false for malformed IPv4', () => { + assert.isFalse(HttpRequestBlock.isPrivateIP('not.an.ip.addr', 4)); + }); + it('isPrivateIP detects IPv6 loopback', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('::1', 6)); + }); + it('isPrivateIP detects IPv6 unique-local fc/fd', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('fd00::1', 6)); + }); + it('isPrivateIP detects IPv6 link-local fe80', () => { + assert.isTrue(HttpRequestBlock.isPrivateIP('fe80::1', 6)); + }); + it('isPrivateIP returns false for public IPv6', () => { + assert.isFalse(HttpRequestBlock.isPrivateIP('2001:4860:4860::8888', 6)); + }); + it('isPrivateIP returns false for unknown family', () => { + assert.isFalse(HttpRequestBlock.isPrivateIP('1.2.3.4', 0)); + }); + + describe('validateProtocol', () => { + const prev = process.env.ALLOWED_PROTOCOLS; + afterEach(() => { process.env.ALLOWED_PROTOCOLS = prev; }); + it('throws when ALLOWED_PROTOCOLS not set', () => { + delete process.env.ALLOWED_PROTOCOLS; + assert.throws(() => HttpRequestBlock.validateProtocol('https://x.com'), /no allowed protocols/); + }); + it('throws when protocol not in allowed list', () => { + process.env.ALLOWED_PROTOCOLS = 'https'; + assert.throws(() => HttpRequestBlock.validateProtocol('http://x.com'), /is not allowed/); + }); + it('passes when protocol is allowed', () => { + process.env.ALLOWED_PROTOCOLS = 'https,http'; + assert.doesNotThrow(() => HttpRequestBlock.validateProtocol('https://x.com')); + }); + }); +}); + +describe('HttpRequestBlock.validate branches', () => { + const prev = process.env.ALLOWED_PROTOCOLS; + const prevBlock = process.env.BLOCK_PRIVATE_IP; + beforeEach(() => { process.env.ALLOWED_PROTOCOLS = 'https,http'; process.env.BLOCK_PRIVATE_IP = 'false'; }); + afterEach(() => { process.env.ALLOWED_PROTOCOLS = prev; process.env.BLOCK_PRIVATE_IP = prevBlock; }); + + it('missing url adds error', async () => { + const v = new FakeValidator(); + await HttpRequestBlock.validate(v, ref({ method: 'GET', url: 'https://x.com' })); + const v2 = new FakeValidator(); + await HttpRequestBlock.validate(v2, ref({ method: 'GET', url: ' ' })); + assert.isTrue(has(v2, 'Option "url" must be set')); + }); + it('invalid method adds error', async () => { + const v = new FakeValidator(); + await HttpRequestBlock.validate(v, ref({ method: 'FETCH', url: 'https://x.com' })); + assert.isTrue(has(v, 'Option "method" must be')); + }); + for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + it(`method ${m} passes method check`, async () => { + const v = new FakeValidator(); + await HttpRequestBlock.validate(v, ref({ method: m, url: 'https://x.com' })); + assert.isFalse(has(v, 'Option "method" must be')); + }); + } + it('disallowed protocol url adds error', async () => { + process.env.ALLOWED_PROTOCOLS = 'https'; + const v = new FakeValidator(); + await HttpRequestBlock.validate(v, ref({ method: 'GET', url: 'ftp://x.com' })); + assert.isTrue(has(v, 'is not allowed')); + }); + it('valid GET https config yields no errors', async () => { + const v = new FakeValidator(); + await HttpRequestBlock.validate(v, ref({ method: 'GET', url: 'https://x.com' })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/block-validators/ui-structural-validators-branches.test.mjs b/policy-service/tests/unit-tests/block-validators/ui-structural-validators-branches.test.mjs new file mode 100644 index 0000000000..f0478548a2 --- /dev/null +++ b/policy-service/tests/unit-tests/block-validators/ui-structural-validators-branches.test.mjs @@ -0,0 +1,277 @@ +import { assert } from 'chai'; +import { InterfaceDocumentActionBlock } from '../../../dist/policy-engine/block-validators/blocks/action-block.js'; +import { ButtonBlock } from '../../../dist/policy-engine/block-validators/blocks/button-block.js'; +import { FiltersAddonBlock } from '../../../dist/policy-engine/block-validators/blocks/filters-addon-block.js'; +import { DocumentValidatorBlock } from '../../../dist/policy-engine/block-validators/blocks/document-validator-block.js'; +import { RevokeBlock } from '../../../dist/policy-engine/block-validators/blocks/revoke-block.js'; +import { RevocationBlock } from '../../../dist/policy-engine/block-validators/blocks/revocation-block.js'; +import { SendToGuardianBlock } from '../../../dist/policy-engine/block-validators/blocks/send-to-guardian-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._topicMissing = !!opts.topicMissing; + } + addError(msg) { this.errors.push(msg); } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } + checkBlockError(err) { if (err) { this.errors.push(err); } } + validateSchemaVariable() { return null; } + topicTemplateNotExist() { return this._topicMissing; } +} + +const ref = (options = {}) => ({ options, children: [] }); +const has = (v, sub) => v.errors.some(e => typeof e === 'string' && e.includes(sub)); + +describe('InterfaceDocumentActionBlock.validate branches', () => { + it('blockType is interfaceActionBlock', () => { + assert.equal(InterfaceDocumentActionBlock.blockType, 'interfaceActionBlock'); + }); + it('missing type adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "type" is not set')); + }); + it('selector without uiMetaData adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'selector' })); + assert.isTrue(has(v, 'Option "uiMetaData" is not set')); + }); + it('selector missing field adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'selector', uiMetaData: { options: [] } })); + assert.isTrue(has(v, 'Option "field" is not set')); + }); + it('selector with non-array options adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'selector', field: 'f', uiMetaData: { options: 'x' } })); + assert.isTrue(has(v, 'Option "uiMetaData.options" must be an array')); + }); + it('selector option missing tag adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'selector', field: 'f', uiMetaData: { options: [{}] } })); + assert.isTrue(has(v, 'Option "tag" is not set')); + }); + it('selector duplicate option tag adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'selector', field: 'f', uiMetaData: { options: [{ tag: 'a' }, { tag: 'a' }] } })); + assert.isTrue(has(v, 'already exist')); + }); + it('download missing targetUrl adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'download' })); + assert.isTrue(has(v, 'Option "targetUrl" is not set')); + }); + it('dropdown missing name adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'dropdown' })); + assert.isTrue(has(v, 'Option "name" is not set')); + }); + it('dropdown missing value adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'dropdown', name: 'n' })); + assert.isTrue(has(v, 'Option "value" is not set')); + }); + it('transformation type passes', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'transformation' })); + assert.deepEqual(v.errors, []); + }); + it('unknown type adds error', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'weird' })); + assert.isTrue(has(v, 'Option "type" must be a "selector|download|dropdown"')); + }); +}); + +describe('ButtonBlock.validate branches', () => { + it('blockType is buttonBlock', () => { + assert.equal(ButtonBlock.blockType, 'buttonBlock'); + }); + it('missing uiMetaData adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "uiMetaData" is not set')); + }); + it('buttons not an array adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: 'x' } })); + assert.isTrue(has(v, 'Option "uiMetaData.buttons" must be an array')); + }); + it('button missing tag adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ type: 'selector', filters: [] }] } })); + assert.isTrue(has(v, 'Option "tag" is not set')); + }); + it('button filters not an array adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector', filters: 'x' }] } })); + assert.isTrue(has(v, 'Option "button.filters" must be an array')); + }); + it('filter missing type adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector', filters: [{ field: 'f' }] }] } })); + assert.isTrue(has(v, 'Option "type" is not set')); + }); + it('filter missing field adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector', filters: [{ type: 'eq' }] }] } })); + assert.isTrue(has(v, 'Option "field" is not set')); + }); + it('selector-dialog missing title adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector-dialog', filters: [] }] } })); + assert.isTrue(has(v, 'Option "title" is not set')); + }); + it('selector-dialog missing description adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector-dialog', title: 'T', filters: [] }] } })); + assert.isTrue(has(v, 'Option "description" is not set')); + }); + it('unknown button type adds error', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'weird', filters: [] }] } })); + assert.isTrue(has(v, 'Option "type" must be a "selector|selector-dialog"')); + }); + it('valid selector button yields no errors', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ref({ uiMetaData: { buttons: [{ tag: 't', type: 'selector', filters: [{ type: 'eq', field: 'f' }] }] } })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('FiltersAddonBlock.validate branches', () => { + it('blockType is filtersAddon', () => { + assert.equal(FiltersAddonBlock.blockType, 'filtersAddon'); + }); + it('missing type adds error', async () => { + const v = new FakeValidator(); + await FiltersAddonBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "type" is not set')); + }); + for (const t of ['dropdown', 'datepicker', 'input']) { + it(`type ${t} passes`, async () => { + const v = new FakeValidator(); + await FiltersAddonBlock.validate(v, ref({ type: t })); + assert.deepEqual(v.errors, []); + }); + } + it('unknown type adds error', async () => { + const v = new FakeValidator(); + await FiltersAddonBlock.validate(v, ref({ type: 'weird' })); + assert.isTrue(has(v, 'Option "type" must be a "dropdown"')); + }); +}); + +describe('DocumentValidatorBlock.validate branches', () => { + it('blockType is documentValidatorBlock', () => { + assert.equal(DocumentValidatorBlock.blockType, 'documentValidatorBlock'); + }); + it('invalid documentType adds error', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'bad' })); + assert.isTrue(has(v, 'Option "documentType" must be one of')); + }); + for (const t of ['vc-document', 'vp-document', 'related-vc-document', 'related-vp-document']) { + it(`documentType ${t} passes type check`, async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: t })); + assert.isFalse(has(v, 'Option "documentType" must be one of')); + }); + } + it('non-array conditions adds error', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document', conditions: {} })); + assert.isTrue(has(v, 'conditions option must be an array')); + }); + it('array conditions passes', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document', conditions: [] })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('RevokeBlock.validate branches', () => { + it('blockType is revokeBlock', () => { + assert.equal(RevokeBlock.blockType, 'revokeBlock'); + }); + it('missing uiMetaData adds error', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({})); + assert.isTrue(has(v, 'Option "uiMetaData" is not set')); + }); + it('updatePrevDoc without prevDocStatus adds error', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true } })); + assert.isTrue(has(v, 'Option "Status Value" is not set')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true, prevDocStatus: 'x' } })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('RevocationBlock.validate branches', () => { + it('blockType is revocationBlock', () => { + assert.equal(RevocationBlock.blockType, 'revocationBlock'); + }); + it('updatePrevDoc without prevDocStatus adds error', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({ updatePrevDoc: true })); + assert.isTrue(has(v, 'Option "Status Value" is not set')); + }); + it('valid config yields no errors', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({ updatePrevDoc: true, prevDocStatus: 'x' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('SendToGuardianBlock.validate branches', () => { + it('blockType is sendToGuardianBlock', () => { + assert.equal(SendToGuardianBlock.blockType, 'sendToGuardianBlock'); + }); + it('invalid dataType adds error', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'bad' })); + assert.isTrue(has(v, 'Option "dataType" must be one of')); + }); + for (const t of ['vc-documents', 'did-documents', 'approve', 'hedera']) { + it(`dataType ${t} passes`, async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: t })); + assert.deepEqual(v.errors, []); + }); + } + it('dataSource auto passes', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'auto' })); + assert.deepEqual(v.errors, []); + }); + it('dataSource database passes', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'database' })); + assert.deepEqual(v.errors, []); + }); + it('dataSource hedera with missing topic template adds error', async () => { + const v = new FakeValidator({ topicMissing: true }); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera', topic: 'myTopic' })); + assert.isTrue(has(v, 'Topic "myTopic" does not exist')); + }); + it('dataSource hedera with root topic passes', async () => { + const v = new FakeValidator({ topicMissing: true }); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera', topic: 'root' })); + assert.deepEqual(v.errors, []); + }); + it('no dataType and no dataSource passes', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + it('unknown dataSource adds error', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'weird' })); + assert.isTrue(has(v, 'Option "dataSource" must be one of auto|database|hedera')); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/_block-exec-harness.mjs b/policy-service/tests/unit-tests/blocks/_block-exec-harness.mjs new file mode 100644 index 0000000000..b495424e7a --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/_block-exec-harness.mjs @@ -0,0 +1,120 @@ +import { LocationType, PolicyStatus, PolicyAvailability } from '@guardian/interfaces'; +import { DatabaseServer } from '@guardian/common'; + +const _origStatics = {}; +let _staticsInstalled = false; + +/** Neutralize the static DatabaseServer calls getOptions()/blocks make against the real ORM. */ +export function installDbStatics(overrides = {}) { + if (_staticsInstalled) return; + const names = ['getPolicyById', 'getPolicyParameters', 'getPolicyCacheData', 'getVirtualUser', 'getResidue']; + for (const n of names) { + if (typeof DatabaseServer[n] === 'function') _origStatics[n] = DatabaseServer[n]; + } + DatabaseServer.getPolicyById = overrides.getPolicyById || (async () => ({ editableParametersSettings: null })); + DatabaseServer.getPolicyParameters = overrides.getPolicyParameters || (async () => null); + _staticsInstalled = true; +} + +export function restoreHarness() { + for (const [n, fn] of Object.entries(_origStatics)) DatabaseServer[n] = fn; + for (const k of Object.keys(_origStatics)) delete _origStatics[k]; + _staticsInstalled = false; +} + +export function makeUser(overrides = {}) { + return { + id: 'did:user', + did: 'did:user', + username: 'user', + role: 'USER', + location: LocationType.LOCAL, + virtual: false, + ...overrides, + }; +} + +/** Fake DatabaseServer — records calls; configurable returns. */ +export function makeDb(overrides = {}) { + const calls = []; + const rec = (name, ret) => async (...args) => { calls.push({ name, args }); return typeof ret === 'function' ? ret(...args) : ret; }; + const db = { + __calls: calls, + saveBlockState: rec('saveBlockState'), + getBlockState: rec('getBlockState', null), + getAllPolicyUsers: rec('getAllPolicyUsers', []), + saveDocument: rec('saveDocument', (d) => d), + getVcDocument: rec('getVcDocument', null), + getVcDocuments: rec('getVcDocuments', []), + getVpDocuments: rec('getVpDocuments', []), + getDidDocument: rec('getDidDocument', null), + getTokenById: rec('getTokenById', null), + getAggregateDocuments: rec('getAggregateDocuments', []), + getTags: rec('getTags', []), + find: rec('find', []), + findOne: rec('findOne', null), + ...overrides, + }; + return db; +} + +/** Fake ComponentsService — provides databaseServer + log sinks. */ +export function makeComponents(overrides = {}) { + const db = overrides.databaseServer || makeDb(); + const logs = []; + return { + databaseServer: db, + info: (m) => logs.push(['info', m]), + error: (m) => logs.push(['error', m]), + warn: (m) => logs.push(['warn', m]), + debug: (m) => logs.push(['debug', m]), + debugContext: async () => ({}), + debugError: () => {}, + __logs: logs, + ...overrides, + }; +} + +export function makePolicy(overrides = {}) { + return { + id: 'policy-1', + owner: 'did:owner', + ownerId: 'did:owner', + topicId: '0.0.1', + status: PolicyStatus.PUBLISH, + availability: PolicyAvailability.PRIVATE, + locationType: LocationType.LOCAL, + ...overrides, + }; +} + +/** + * Construct a real decorated execution block the way the engine does: + * new Block(uuid, defaultActive, tag, permissions, parent, options, components) + * .setPolicyInstance(policyId, policy) / setPolicyOwner / setTopicId + * (setTenantContext is intentionally skipped so databaseServer stays the injected fake). + */ +export function makeBlock(BlockClass, opts = {}) { + installDbStatics(opts.dbStatics || {}); + const components = opts.components || makeComponents(opts.componentsOverrides || {}); + const policy = opts.policy || makePolicy(opts.policyOverrides || {}); + const block = new BlockClass( + opts.uuid ?? 'uuid-1', + opts.defaultActive ?? true, + opts.tag ?? 'tag-1', + opts.permissions ?? [], + opts.parent ?? null, + opts.options ?? {}, + components, + ); + if (typeof block.setPolicyInstance === 'function') { + block.setPolicyInstance(opts.policyId ?? 'policy-1', policy); + } + if (typeof block.setPolicyOwner === 'function') { + block.setPolicyOwner(policy.owner); + } + if (typeof block.setTopicId === 'function') { + block.setTopicId(policy.topicId); + } + return { block, components, db: components.databaseServer, policy }; +} diff --git a/policy-service/tests/unit-tests/blocks/_block-exec-smoke.test.mjs b/policy-service/tests/unit-tests/blocks/_block-exec-smoke.test.mjs new file mode 100644 index 0000000000..33b38ec9cb --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/_block-exec-smoke.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { LocationType } from '@guardian/interfaces'; +import { makeBlock, makeUser, restoreHarness } from './_block-exec-harness.mjs'; +import { InformationBlock } from '../../../dist/policy-engine/blocks/information-block.js'; + +describe('@unit block-exec harness smoke', () => { + after(() => restoreHarness()); + + it('InformationBlock.getData returns the block envelope', async () => { + const { block } = makeBlock(InformationBlock, { + uuid: 'info-1', + options: { uiMetaData: { title: 'Hello' } }, + policyOverrides: { locationType: LocationType.LOCAL }, + }); + const data = await block.getData(makeUser()); + assert.equal(data.id, 'info-1'); + assert.equal(data.blockType, 'informationBlock'); + assert.deepEqual(data.uiMetaData, { title: 'Hello' }); + assert.equal(data.readonly, false); + }); + + it('InformationBlock is a LOCAL block, so getData stays non-readonly even for a remote user', async () => { + const { block } = makeBlock(InformationBlock, { options: {} }); + const data = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(data.actionType, LocationType.LOCAL); + assert.equal(data.readonly, false); + }); + + it('exposes uuid / options / tag from construction', () => { + const { block } = makeBlock(InformationBlock, { uuid: 'u9', tag: 't9', options: { a: 1 } }); + assert.equal(block.uuid, 'u9'); + assert.equal(block.tag, 't9'); + assert.deepEqual(block.options, { a: 1 }); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/action-block-extra.test.mjs b/policy-service/tests/unit-tests/blocks/action-block-extra.test.mjs new file mode 100644 index 0000000000..29d08c6aea --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/action-block-extra.test.mjs @@ -0,0 +1,101 @@ +import { assert } from 'chai'; +import { InterfaceDocumentActionBlock } from '../../../dist/policy-engine/block-validators/blocks/action-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this.checked = []; + this._schemaResult = opts.schemaResult ?? null; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + validateSchemaVariable(name, value, required) { + if (this._schemaResult !== null) { return this._schemaResult; } + if (required && !value) { return `${name} is required`; } + return null; + } + checkBlockError(error) { if (error) { this.checked.push(error); } } +} + +const ref = (options = {}) => ({ options, children: [] }); + +describe('@unit P0 InterfaceDocumentActionBlock extra', () => { + it('blockType is interfaceActionBlock', () => { + assert.equal(InterfaceDocumentActionBlock.blockType, 'interfaceActionBlock'); + }); + + it('selector with valid uiMetaData options and field passes', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'selector', field: 'f', uiMetaData: { options: [{ tag: 'a' }, { tag: 'b' }] }, + })); + assert.deepEqual(v.errors, []); + }); + + it('selector with missing uiMetaData.options reports not set', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'selector', field: 'f', uiMetaData: {}, + })); + assert.include(v.errors, 'Option "uiMetaData.options" is not set'); + }); + + it('selector flags an option missing its tag', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'selector', field: 'f', uiMetaData: { options: [{}] }, + })); + assert.include(v.errors, 'Option "tag" is not set'); + }); + + it('selector with an empty options array passes (field present)', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'selector', field: 'f', uiMetaData: { options: [] }, + })); + assert.deepEqual(v.errors, []); + }); + + it('download with targetUrl and valid schema passes', async () => { + const v = new FakeValidator({ schemaResult: null }); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'download', targetUrl: 'https://x', schema: '#A', + })); + assert.deepEqual(v.errors, []); + assert.deepEqual(v.checked, []); + }); + + it('download routes schema error via checkBlockError', async () => { + const v = new FakeValidator({ schemaResult: 'schema is required' }); + await InterfaceDocumentActionBlock.validate(v, ref({ + type: 'download', targetUrl: 'https://x', + })); + assert.include(v.checked, 'schema is required'); + }); + + it('dropdown with name and value passes', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'dropdown', name: 'n', value: 'v' })); + assert.deepEqual(v.errors, []); + }); + + it('dropdown missing value reports not set', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'dropdown', name: 'n' })); + assert.include(v.errors, 'Option "value" is not set'); + }); + + it('transformation type passes with no further checks', async () => { + const v = new FakeValidator(); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'transformation' })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await InterfaceDocumentActionBlock.validate(v, ref({ type: 'transformation', artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/calculate-container.test.mjs b/policy-service/tests/unit-tests/blocks/calculate-container.test.mjs new file mode 100644 index 0000000000..4ff11780e8 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/calculate-container.test.mjs @@ -0,0 +1,124 @@ +import { assert } from 'chai'; +import { CalculateContainerBlock } from '../../../dist/policy-engine/block-validators/blocks/calculate-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._schemaIssues = opts.schemaIssues || {}; + this._schemas = opts.schemas || {}; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + validateSchemaVariable(name, value, required) { + if (this._schemaIssues[name]) { return this._schemaIssues[name]; } + if (required && !value) { return `${name} required`; } + return null; + } + getSchema(id) { return this._schemas[id] ?? null; } +} + +const emptySchema = { document: { properties: {}, required: [] }, fields: [] }; + +const refWith = (options = {}, children = []) => ({ + options: { inputSchema: 'in', outputSchema: 'out', inputFields: [], outputFields: [], ...options }, + children, +}); + +describe('@unit P0 CalculateContainerBlock.validate', () => { + it('blockType is calculateContainerBlock', () => { + assert.equal(CalculateContainerBlock.blockType, 'calculateContainerBlock'); + }); + + it('errors and returns early on bad inputSchema', async () => { + const v = new FakeValidator({ schemaIssues: { inputSchema: 'bad input' } }); + await CalculateContainerBlock.validate(v, refWith()); + assert.deepEqual(v.errors, ['bad input']); + }); + + it('errors and returns early on bad outputSchema', async () => { + const v = new FakeValidator({ schemaIssues: { outputSchema: 'bad output' } }); + await CalculateContainerBlock.validate(v, refWith()); + assert.deepEqual(v.errors, ['bad output']); + }); + + it('passes with empty fields and an empty registered output schema', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + await CalculateContainerBlock.validate(v, refWith()); + assert.deepEqual(v.errors, []); + }); + + it('errors when output schema is not registered', async () => { + const v = new FakeValidator({ schemas: {} }); + await CalculateContainerBlock.validate(v, refWith()); + assert.include(v.errors, 'Schema with id "out" does not exist'); + }); + + it('errors when an outputField references an undefined variable', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + await CalculateContainerBlock.validate(v, refWith({ + outputFields: [{ value: 'ghost', name: 'g' }], + })); + assert.include(v.errors, 'Variable ghost not defined'); + }); + + it('accepts an outputField backed by an inputField variable', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + await CalculateContainerBlock.validate(v, refWith({ + inputFields: [{ value: 'x', name: 'X' }], + outputFields: [{ value: 'x', name: 'mapped' }], + })); + assert.deepEqual(v.errors, []); + }); + + it('skips outputFields with a falsy value', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + await CalculateContainerBlock.validate(v, refWith({ + outputFields: [{ value: '', name: 'ignored' }], + })); + assert.deepEqual(v.errors, []); + }); + + it('resolves a variable supplied by a calculateMathAddon child', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + const child = { blockType: 'calculateMathAddon', options: { equations: [{ variable: 'sum', formula: 'a+b' }] } }; + await CalculateContainerBlock.validate(v, refWith({ + outputFields: [{ value: 'sum', name: 'total' }], + }, [child])); + assert.deepEqual(v.errors, []); + }); + + it('resolves a variable supplied by a calculateMathVariables child', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + const child = { blockType: 'calculateMathVariables', options: { variables: [{ variableName: 'v', variablePath: 'a.b' }] } }; + await CalculateContainerBlock.validate(v, refWith({ + outputFields: [{ value: 'v', name: 'mapped' }], + }, [child])); + assert.deepEqual(v.errors, []); + }); + + it('ignores unrelated child block types when collecting variables', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + const child = { blockType: 'somethingElse', options: {} }; + await CalculateContainerBlock.validate(v, refWith({ + outputFields: [{ value: 'nope', name: 'n' }], + }, [child])); + assert.include(v.errors, 'Variable nope not defined'); + }); + + it('passes when a registered output schema has no required fields', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema } }); + await CalculateContainerBlock.validate(v, refWith({ + inputFields: [{ value: 'x', name: 'X' }], + outputFields: [{ value: 'x', name: 'mapped' }], + })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ schemas: { out: emptySchema }, throwGetArtifact: true }); + await CalculateContainerBlock.validate(v, refWith({ artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/calculate-family.test.mjs b/policy-service/tests/unit-tests/blocks/calculate-family.test.mjs new file mode 100644 index 0000000000..6ff9a01ef7 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/calculate-family.test.mjs @@ -0,0 +1,162 @@ +import { assert } from 'chai'; +import { CalculateMathAddon } from '../../../dist/policy-engine/block-validators/blocks/calculate-math-addon.js'; +import { CalculateMathVariables } from '../../../dist/policy-engine/block-validators/blocks/calculate-math-variables.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this.checked = []; + this._formulaError = !!opts.formulaError; + this._schemaResult = opts.schemaResult ?? null; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + validateFormula() { return !this._formulaError; } + validateSchemaVariable() { return this._schemaResult; } + checkBlockError(error) { if (error) { this.checked.push(error); } } +} + +const ref = (options) => ({ options, children: [] }); + +describe('@unit P0 CalculateMathAddon extra', () => { + it('blockType is calculateMathAddon', () => { + assert.equal(CalculateMathAddon.blockType, 'calculateMathAddon'); + }); + + it('passes for an empty equations array', async () => { + const v = new FakeValidator(); + await CalculateMathAddon.validate(v, ref({ equations: [] })); + assert.deepEqual(v.errors, []); + }); + + it('validates every formula when all are correct', async () => { + const v = new FakeValidator(); + await CalculateMathAddon.validate(v, ref({ + equations: [ + { variable: 'a', formula: 'x+1' }, + { variable: 'b', formula: 'y*2' }, + { variable: 'c', formula: 'z' }, + ], + })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception from artifact lookup', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await CalculateMathAddon.validate(v, { + options: { artifacts: [{ uuid: 'a' }], equations: [] }, + children: [], + }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); + + it('getVariables overwrites an existing key for a duplicate variable name', () => { + const out = CalculateMathAddon.getVariables( + { options: { equations: [{ variable: 'a', formula: 'first' }, { variable: 'a', formula: 'second' }] } }, + {}, + ); + assert.deepEqual(out, { a: 'second' }); + }); + + it('getVariables preserves unrelated keys already in the map', () => { + const out = CalculateMathAddon.getVariables( + { options: { equations: [{ variable: 'new', formula: 'f' }] } }, + { existing: 'keep' }, + ); + assert.deepEqual(out, { existing: 'keep', new: 'f' }); + }); + + it('getVariables returns the same object reference it was given', () => { + const seed = {}; + const out = CalculateMathAddon.getVariables({ options: {} }, seed); + assert.strictEqual(out, seed); + }); + + it('getVariables with empty equations leaves map untouched', () => { + const out = CalculateMathAddon.getVariables({ options: { equations: [] } }, { k: 'v' }); + assert.deepEqual(out, { k: 'v' }); + }); +}); + +describe('@unit P0 CalculateMathVariables extra', () => { + it('blockType is calculateMathVariables', () => { + assert.equal(CalculateMathVariables.blockType, 'calculateMathVariables'); + }); + + it('passes when neither selectors nor variables are present', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('routes sourceSchema result through checkBlockError', async () => { + const v = new FakeValidator({ schemaResult: 'bad source schema' }); + await CalculateMathVariables.validate(v, ref({})); + assert.include(v.checked, 'bad source schema'); + }); + + it('does not record a schema error when validateSchemaVariable returns null', async () => { + const v = new FakeValidator({ schemaResult: null }); + await CalculateMathVariables.validate(v, ref({})); + assert.deepEqual(v.checked, []); + }); + + it('selector with sourceField but no comparisonValue short-circuits before variables', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ + selectors: [{ sourceField: 'a' }], + variables: [{ variableName: 'x' }], + })); + assert.equal(v.errors.length, 1); + assert.match(v.errors[0], /Incorrect filter/); + }); + + it('valid selectors then bad variable path reports the variable error', async () => { + const v = new FakeValidator(); + await CalculateMathVariables.validate(v, ref({ + selectors: [{ sourceField: 'a', comparisonValue: '1' }], + variables: [{ variableName: 'x' }], + })); + assert.equal(v.errors.length, 1); + assert.match(v.errors[0], /Incorrect Variable Path/); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await CalculateMathVariables.validate(v, { + options: { artifacts: [{ uuid: 'a' }] }, + children: [], + }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); + + it('getVariables maps multiple variables into the map', () => { + const out = CalculateMathVariables.getVariables( + { options: { variables: [ + { variableName: 'a', variablePath: 'p.a' }, + { variableName: 'b', variablePath: 'p.b' }, + ] } }, + {}, + ); + assert.deepEqual(out, { a: 'p.a', b: 'p.b' }); + }); + + it('getVariables overwrites duplicate variable names with the last path', () => { + const out = CalculateMathVariables.getVariables( + { options: { variables: [ + { variableName: 'a', variablePath: 'first' }, + { variableName: 'a', variablePath: 'last' }, + ] } }, + {}, + ); + assert.deepEqual(out, { a: 'last' }); + }); + + it('getVariables returns the same map reference', () => { + const seed = { z: '1' }; + const out = CalculateMathVariables.getVariables({ options: {} }, seed); + assert.strictEqual(out, seed); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/common-block-extra.test.mjs b/policy-service/tests/unit-tests/blocks/common-block-extra.test.mjs new file mode 100644 index 0000000000..41a4535ff8 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/common-block-extra.test.mjs @@ -0,0 +1,102 @@ +import { assert } from 'chai'; +import { CommonBlock } from '../../../dist/policy-engine/block-validators/blocks/common.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._artifacts = opts.artifacts || {}; + this._throwOn = opts.throwOn || null; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact(uuid) { + if (this._throwOn === uuid) { throw new Error(`store-down:${uuid}`); } + return this._artifacts[uuid]; + } +} + +const ref = (options = {}) => ({ options, children: [] }); + +describe('@unit P0 CommonBlock.validate', () => { + it('returns true when no artifacts option present', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({})); + assert.isTrue(result); + assert.deepEqual(v.errors, []); + }); + + it('returns true when artifacts is not an array', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: 'not-array' })); + assert.isTrue(result); + assert.deepEqual(v.errors, []); + }); + + it('returns true for an empty artifacts array', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: [] })); + assert.isTrue(result); + assert.deepEqual(v.errors, []); + }); + + it('returns true when all artifacts resolve to files', async () => { + const v = new FakeValidator({ artifacts: { a1: { data: 1 }, a2: { data: 2 } } }); + const result = await CommonBlock.validate( + v, + ref({ artifacts: [{ uuid: 'a1' }, { uuid: 'a2' }] }), + ); + assert.isTrue(result); + assert.deepEqual(v.errors, []); + }); + + it('returns false and errors when an artifact entry is null', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: [null] })); + assert.isFalse(result); + assert.include(v.errors, 'Artifact does not exist'); + }); + + it('returns false and errors when an artifact entry is undefined', async () => { + const v = new FakeValidator(); + const result = await CommonBlock.validate(v, ref({ artifacts: [undefined] })); + assert.isFalse(result); + assert.include(v.errors, 'Artifact does not exist'); + }); + + it('returns false when artifact file is not found by uuid', async () => { + const v = new FakeValidator({ artifacts: {} }); + const result = await CommonBlock.validate(v, ref({ artifacts: [{ uuid: 'missing' }] })); + assert.isFalse(result); + assert.include(v.errors, 'Artifact with id "missing" does not exist'); + }); + + it('stops at the first missing artifact (short-circuits)', async () => { + const v = new FakeValidator({ artifacts: { ok: { data: 1 } } }); + const result = await CommonBlock.validate( + v, + ref({ artifacts: [{ uuid: 'bad' }, { uuid: 'ok' }] }), + ); + assert.isFalse(result); + assert.equal(v.errors.length, 1); + assert.include(v.errors[0], '"bad"'); + }); + + it('propagates errors thrown by getArtifact (no internal catch)', async () => { + const v = new FakeValidator({ throwOn: 'boom' }); + let caught = null; + try { + await CommonBlock.validate(v, ref({ artifacts: [{ uuid: 'boom' }] })); + } catch (e) { + caught = e; + } + assert.isNotNull(caught); + assert.match(caught.message, /store-down:boom/); + }); + + it('a falsy artifact reported before any lookup happens', async () => { + const v = new FakeValidator({ throwOn: 'x' }); + const result = await CommonBlock.validate(v, ref({ artifacts: [0] })); + assert.isFalse(result); + assert.include(v.errors, 'Artifact does not exist'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/create-token-extra.test.mjs b/policy-service/tests/unit-tests/blocks/create-token-extra.test.mjs new file mode 100644 index 0000000000..7afd98b71c --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/create-token-extra.test.mjs @@ -0,0 +1,158 @@ +import { assert } from 'chai'; +import { TokenType } from '@guardian/interfaces'; +import { CreateTokenBlock } from '../../../dist/policy-engine/block-validators/blocks/create-token-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._template = opts.template ?? undefined; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + getTokenTemplate() { return this._template; } +} + +const completeTemplate = (o = {}) => ({ + tokenType: TokenType.NON_FUNGIBLE, + tokenName: 'Name', + tokenSymbol: 'SYM', + enableAdmin: true, + enableWipe: false, + enableKYC: true, + enableFreeze: true, + ...o, +}); + +const ref = (o = {}) => ({ options: { template: 'tpl', ...o }, children: [] }); + +describe('@unit P0 CreateTokenBlock._isEmpty', () => { + it('true for null', () => { assert.isTrue(CreateTokenBlock._isEmpty(null)); }); + it('true for undefined', () => { assert.isTrue(CreateTokenBlock._isEmpty(undefined)); }); + it('false for 0', () => { assert.isFalse(CreateTokenBlock._isEmpty(0)); }); + it('false for empty string', () => { assert.isFalse(CreateTokenBlock._isEmpty('')); }); + it('false for false', () => { assert.isFalse(CreateTokenBlock._isEmpty(false)); }); + it('false for a string value', () => { assert.isFalse(CreateTokenBlock._isEmpty('x')); }); + it('false for an object', () => { assert.isFalse(CreateTokenBlock._isEmpty({})); }); +}); + +describe('@unit P0 CreateTokenBlock.validate', () => { + it('blockType is createTokenBlock', () => { + assert.equal(CreateTokenBlock.blockType, 'createTokenBlock'); + }); + + it('rejects empty template option', async () => { + const v = new FakeValidator({ template: completeTemplate() }); + await CreateTokenBlock.validate(v, ref({ template: '' })); + assert.include(v.errors, 'Template can not be empty'); + }); + + it('rejects autorun combined with defaultActive', async () => { + const v = new FakeValidator({ template: completeTemplate() }); + await CreateTokenBlock.validate(v, ref({ autorun: true, defaultActive: true })); + assert.include(v.errors, `Autorun can't be use with default active`); + }); + + it('reports missing token template and returns early', async () => { + const v = new FakeValidator({ template: undefined }); + await CreateTokenBlock.validate(v, ref({})); + assert.include(v.errors, 'Token "tpl" does not exist'); + }); + + it('passes for a present template without autorun', async () => { + const v = new FakeValidator({ template: completeTemplate() }); + await CreateTokenBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('passes autorun with a fully populated non-fungible template', async () => { + const v = new FakeValidator({ template: completeTemplate() }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.deepEqual(v.errors, []); + }); + + it('autorun flags missing tokenType', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenType: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun fungible flags missing decimals', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenType: TokenType.FUNGIBLE, decimals: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun fungible passes with decimals present', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenType: TokenType.FUNGIBLE, decimals: 2 }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.deepEqual(v.errors, []); + }); + + it('autorun flags missing tokenName', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenName: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun flags missing tokenSymbol', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenSymbol: undefined }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun flags missing enableAdmin', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableAdmin: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun flags missing enableWipe', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableWipe: undefined }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun flags missing enableKYC', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableKYC: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun flags missing enableFreeze', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableFreeze: undefined }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun with enableWipe true but null wipeContractId flags missing field', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableWipe: true, wipeContractId: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('autorun with enableWipe true and empty-string wipeContractId is allowed', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableWipe: true, wipeContractId: '' }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.deepEqual(v.errors, []); + }); + + it('autorun with enableWipe true and a real wipeContractId passes', async () => { + const v = new FakeValidator({ template: completeTemplate({ enableWipe: true, wipeContractId: '0.0.5' }) }); + await CreateTokenBlock.validate(v, ref({ autorun: true })); + assert.deepEqual(v.errors, []); + }); + + it('non-autorun ignores incomplete template fields', async () => { + const v = new FakeValidator({ template: completeTemplate({ tokenName: null, tokenSymbol: null }) }); + await CreateTokenBlock.validate(v, ref({ autorun: false })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ template: completeTemplate(), throwGetArtifact: true }); + await CreateTokenBlock.validate(v, ref({ artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/custom-logic-block.test.mjs b/policy-service/tests/unit-tests/blocks/custom-logic-block.test.mjs new file mode 100644 index 0000000000..51cf1adf21 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/custom-logic-block.test.mjs @@ -0,0 +1,59 @@ +import { assert } from 'chai'; +import { CustomLogicBlock } from '../../../dist/policy-engine/block-validators/blocks/custom-logic-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._artifacts = opts.artifacts || {}; + this._throwOnArtifact = !!opts.throwOnArtifact; + } + addError(msg) { this.errors.push(msg); } + async getArtifact(uuid) { + if (this._throwOnArtifact) { + throw new Error('artifact store unavailable'); + } + return this._artifacts[uuid] ?? null; + } + getErrorMessage(err) { return err?.message ?? String(err); } +} + +const refWith = (overrides = {}) => ({ options: { ...overrides }, children: [] }); + +describe('CustomLogicBlock.validate (block-validator)', () => { + it('exposes the customLogicBlock block type', () => { + assert.equal(CustomLogicBlock.blockType, 'customLogicBlock'); + }); + + it('produces no errors when options carry no artifacts', async () => { + const v = new FakeValidator(); + await CustomLogicBlock.validate(v, refWith()); + assert.deepEqual(v.errors, []); + }); + + it('produces no errors when every artifact resolves to a file', async () => { + const v = new FakeValidator({ artifacts: { 'art-1': 'code', 'art-2': '{}' } }); + await CustomLogicBlock.validate( + v, + refWith({ artifacts: [{ uuid: 'art-1' }, { uuid: 'art-2' }] }), + ); + assert.deepEqual(v.errors, []); + }); + + it('errors when an artifact entry is missing', async () => { + const v = new FakeValidator(); + await CustomLogicBlock.validate(v, refWith({ artifacts: [null] })); + assert.deepEqual(v.errors, ['Artifact does not exist']); + }); + + it('errors when a referenced artifact is not in the store', async () => { + const v = new FakeValidator({ artifacts: {} }); + await CustomLogicBlock.validate(v, refWith({ artifacts: [{ uuid: 'missing-uuid' }] })); + assert.deepEqual(v.errors, ['Artifact with id "missing-uuid" does not exist']); + }); + + it('wraps a thrown error as an "Unhandled exception" instead of propagating', async () => { + const v = new FakeValidator({ throwOnArtifact: true }); + await CustomLogicBlock.validate(v, refWith({ artifacts: [{ uuid: 'art-1' }] })); + assert.deepEqual(v.errors, ['Unhandled exception artifact store unavailable']); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/doc-request-validators.test.mjs b/policy-service/tests/unit-tests/blocks/doc-request-validators.test.mjs new file mode 100644 index 0000000000..fff523f381 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/doc-request-validators.test.mjs @@ -0,0 +1,191 @@ +import { assert } from 'chai'; +import { RequestVcDocumentBlock } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block.js'; +import { RequestVcDocumentBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block-addon.js'; +import { DocumentValidatorBlock } from '../../../dist/policy-engine/block-validators/blocks/document-validator-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this.checked = []; + this._schemaIssues = opts.schemaIssues || {}; + this._throwOnArtifact = !!opts.throwOnArtifact; + } + addError(msg) { this.errors.push(msg); } + checkBlockError(err) { if (err) { this.checked.push(err); this.errors.push(err); } } + validateSchemaVariable(name, value, required) { + if (this._schemaIssues[name]) { return this._schemaIssues[name]; } + if (required && !value) { return `${name} required`; } + return null; + } + async getArtifact() { + if (this._throwOnArtifact) { throw new Error('store down'); } + return {}; + } + getErrorMessage(err) { return err?.message ?? String(err); } +} + +const ref = (options = {}) => ({ options, children: [] }); + +describe('RequestVcDocumentBlock.validate', () => { + it('exposes the requestVcDocumentBlock block type', () => { + assert.equal(RequestVcDocumentBlock.blockType, 'requestVcDocumentBlock'); + }); + + it('passes when schema is present and presetSchema absent', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, ref({ schema: 's1' })); + assert.deepEqual(v.errors, []); + }); + + it('errors when required schema is missing', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, ref({})); + assert.include(v.errors, 'schema required'); + }); + + it('surfaces a schema validation issue', async () => { + const v = new FakeValidator({ schemaIssues: { schema: 'bad schema' } }); + await RequestVcDocumentBlock.validate(v, ref({ schema: 's1' })); + assert.include(v.errors, 'bad schema'); + }); + + it('surfaces a presetSchema validation issue', async () => { + const v = new FakeValidator({ schemaIssues: { presetSchema: 'bad preset' } }); + await RequestVcDocumentBlock.validate(v, ref({ schema: 's1', presetSchema: 'p1' })); + assert.include(v.errors, 'bad preset'); + }); + + it('does not require presetSchema', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, ref({ schema: 's1' })); + assert.notInclude(v.errors, 'presetSchema required'); + }); + + it('wraps a thrown error as Unhandled exception', async () => { + const v = new FakeValidator({ throwOnArtifact: true }); + await RequestVcDocumentBlock.validate(v, { options: { schema: 's1', artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.include(v.errors, 'Unhandled exception store down'); + }); +}); + +describe('RequestVcDocumentBlockAddon.validate', () => { + it('exposes the requestVcDocumentBlockAddon block type', () => { + assert.equal(RequestVcDocumentBlockAddon.blockType, 'requestVcDocumentBlockAddon'); + }); + + it('passes a complete config', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 's1', buttonName: 'Go', dialogTitle: 'Title' })); + assert.deepEqual(v.errors, []); + }); + + it('errors on missing required schema', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ buttonName: 'Go', dialogTitle: 'Title' })); + assert.include(v.errors, 'schema required'); + }); + + it('errors on missing buttonName', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 's1', dialogTitle: 'Title' })); + assert.include(v.errors, 'Button name is empty'); + }); + + it('errors on missing dialogTitle', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 's1', buttonName: 'Go' })); + assert.include(v.errors, 'Dialog title is empty'); + }); + + it('requires presetSchema when preset is truthy', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 's1', buttonName: 'Go', dialogTitle: 'Title', preset: true })); + assert.include(v.errors, 'presetSchema required'); + }); + + it('does not require presetSchema when preset is falsy', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({ schema: 's1', buttonName: 'Go', dialogTitle: 'Title', preset: false })); + assert.notInclude(v.errors, 'presetSchema required'); + }); + + it('accumulates multiple errors', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, ref({})); + assert.include(v.errors, 'schema required'); + assert.include(v.errors, 'Button name is empty'); + assert.include(v.errors, 'Dialog title is empty'); + }); +}); + +describe('DocumentValidatorBlock.validate', () => { + it('exposes the documentValidatorBlock block type', () => { + assert.equal(DocumentValidatorBlock.blockType, 'documentValidatorBlock'); + }); + + it('accepts documentType vc-document', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts documentType vp-document', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vp-document' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts documentType related-vc-document', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'related-vc-document' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts documentType related-vp-document', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'related-vp-document' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects an unknown documentType', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'mystery' })); + assert.include(v.errors, 'Option "documentType" must be one of vc-document,vp-document,related-vc-document,related-vp-document'); + }); + + it('rejects a missing documentType', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "documentType" must be one of vc-document,vp-document,related-vc-document,related-vp-document'); + }); + + it('surfaces a schema validation issue (schema optional)', async () => { + const v = new FakeValidator({ schemaIssues: { schema: 'bad schema' } }); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document', schema: 's1' })); + assert.include(v.errors, 'bad schema'); + }); + + it('does not require a schema', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document' })); + assert.notInclude(v.errors, 'schema required'); + }); + + it('accepts conditions as an array', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document', conditions: [] })); + assert.deepEqual(v.errors, []); + }); + + it('rejects non-array conditions', async () => { + const v = new FakeValidator(); + await DocumentValidatorBlock.validate(v, ref({ documentType: 'vc-document', conditions: { a: 1 } })); + assert.include(v.errors, 'conditions option must be an array'); + }); + + it('wraps a thrown error as Unhandled exception', async () => { + const v = new FakeValidator({ throwOnArtifact: true }); + await DocumentValidatorBlock.validate(v, { options: { documentType: 'vc-document', artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.include(v.errors, 'Unhandled exception store down'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/exec-document-blocks.test.mjs b/policy-service/tests/unit-tests/blocks/exec-document-blocks.test.mjs new file mode 100644 index 0000000000..6ecbd5227b --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/exec-document-blocks.test.mjs @@ -0,0 +1,1276 @@ +import assert from 'node:assert/strict'; +import { LocationType } from '@guardian/interfaces'; +import { makeBlock, makeUser, makeDb, restoreHarness } from './_block-exec-harness.mjs'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; +import { DocumentValidatorBlock } from '../../../dist/policy-engine/blocks/document-validator-block.js'; +import { ExtractDataBlock } from '../../../dist/policy-engine/blocks/extract-data-block.js'; +import { ReassigningBlock } from '../../../dist/policy-engine/blocks/reassigning.block.js'; +import { MultiSignBlock } from '../../../dist/policy-engine/blocks/multi-sign-block.js'; +import { AggregateBlock } from '../../../dist/policy-engine/blocks/aggregate-block.js'; +import { SplitBlock } from '../../../dist/policy-engine/blocks/split-block.js'; +import { SetRelationshipsBlock } from '../../../dist/policy-engine/blocks/set-relationships-block.js'; +import { FiltersAddonBlock } from '../../../dist/policy-engine/blocks/filters-addon-block.js'; + +const _origExternal = PolicyComponentsUtils.ExternalEventFn; +const _origBlockUpdate = PolicyComponentsUtils.BlockUpdateFn; +let externalEvents = []; +let blockUpdates = []; + +function instrument(block) { + const captured = []; + block.triggerEvents = async (...a) => { captured.push(a); }; + block.backup = () => {}; + return captured; +} + +function vcDoc(overrides = {}) { + return { + id: 'vc-1', + owner: 'did:owner', + document: { credentialSubject: [{ type: 'X', field0: 'a' }] }, + ...overrides, + }; +} + +before(() => { + PolicyComponentsUtils.ExternalEventFn = (e) => { externalEvents.push(e); }; + PolicyComponentsUtils.BlockUpdateFn = (b, u) => { blockUpdates.push([b, u]); }; +}); + +after(() => { + PolicyComponentsUtils.ExternalEventFn = _origExternal; + PolicyComponentsUtils.BlockUpdateFn = _origBlockUpdate; + restoreHarness(); +}); + +beforeEach(() => { + // Reinstall the singleton overrides before every test: other suites in the + // full run share PolicyComponentsUtils and restore these fns in their own + // after() hooks, which would otherwise clobber our capture mid-suite. + PolicyComponentsUtils.ExternalEventFn = (e) => { externalEvents.push(e); }; + PolicyComponentsUtils.BlockUpdateFn = (b, u) => { blockUpdates.push([b, u]); }; + externalEvents = []; + blockUpdates = []; +}); + +describe('@unit document-validator-block runtime', () => { + after(() => restoreHarness()); + + const ev = (data, user = makeUser()) => ({ user, data: { data } }); + + it('run returns Invalid document when event has no data', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + assert.equal(await block.run(ev(null)), 'Invalid document'); + }); + + it('run returns Invalid document when data is undefined', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + assert.equal(await block.run({ user: makeUser(), data: {} }), 'Invalid document'); + }); + + it('validateDocument returns Invalid document for null doc', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + const r = await block.validateDocument(block, ev(null), null); + assert.equal(r, 'Invalid document'); + }); + + it('vc-document passes a real VC', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + assert.equal(await block.run(ev(vcDoc())), null); + }); + + it('vc-document rejects a VP document type', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + const vp = { document: { verifiableCredential: [{ credentialSubject: [{}] }] } }; + assert.equal(await block.run(ev(vp)), 'Invalid document type'); + }); + + it('vp-document passes a VP document', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vp-document' } }); + const vp = { document: { verifiableCredential: [{ credentialSubject: [{}] }] } }; + assert.equal(await block.run(ev(vp)), null); + }); + + it('vp-document rejects a plain VC', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vp-document' } }); + assert.equal(await block.run(ev(vcDoc())), 'Invalid document type'); + }); + + it('document with no .document yields Document does not exist (getDocumentType null path)', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + const r = await block.run(ev({ id: 'x' })); + assert.equal(r, 'Invalid document type'); + }); + + it('checkOwnerDocument rejects mismatched owner', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkOwnerDocument: true } }); + const r = await block.run(ev(vcDoc({ owner: 'did:other' }), makeUser({ did: 'did:me' }))); + assert.equal(r, 'Invalid owner'); + }); + + it('checkOwnerDocument passes matching owner', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkOwnerDocument: true } }); + const r = await block.run(ev(vcDoc({ owner: 'did:me' }), makeUser({ did: 'did:me' }))); + assert.equal(r, null); + }); + + it('checkOwnerByGroupDocument rejects mismatched group', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkOwnerByGroupDocument: true } }); + const r = await block.run(ev(vcDoc({ group: 'g1' }), makeUser({ group: 'g2' }))); + assert.equal(r, 'Invalid group'); + }); + + it('checkOwnerByGroupDocument passes matching group', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkOwnerByGroupDocument: true } }); + const r = await block.run(ev(vcDoc({ group: 'g1' }), makeUser({ group: 'g1' }))); + assert.equal(r, null); + }); + + it('checkAssignDocument rejects mismatched assignee', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkAssignDocument: true } }); + const r = await block.run(ev(vcDoc({ assignedTo: 'did:a' }), makeUser({ did: 'did:b' }))); + assert.equal(r, 'Invalid assigned user'); + }); + + it('checkAssignDocument passes matching assignee', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkAssignDocument: true } }); + const r = await block.run(ev(vcDoc({ assignedTo: 'did:b' }), makeUser({ did: 'did:b' }))); + assert.equal(r, null); + }); + + it('checkAssignByGroupDocument rejects mismatched assigned group', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', checkAssignByGroupDocument: true } }); + const r = await block.run(ev(vcDoc({ assignedToGroup: 'g1' }), makeUser({ group: 'g2' }))); + assert.equal(r, 'Invalid assigned group'); + }); + + it('conditions equal filter passes', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', conditions: [{ type: 'equal', field: 'owner', value: 'did:owner' }] } }); + assert.equal(await block.run(ev(vcDoc())), null); + }); + + it('conditions equal filter fails', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', conditions: [{ type: 'equal', field: 'owner', value: 'nope' }] } }); + assert.equal(await block.run(ev(vcDoc())), 'Invalid document'); + }); + + it('conditions not_equal filter passes', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', conditions: [{ type: 'not_equal', field: 'owner', value: 'nope' }] } }); + assert.equal(await block.run(ev(vcDoc())), null); + }); + + it('multiple conditions short-circuit on first failure', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document', conditions: [{ type: 'equal', field: 'owner', value: 'did:owner' }, { type: 'equal', field: 'owner', value: 'x' }] } }); + assert.equal(await block.run(ev(vcDoc())), 'Invalid document'); + }); + + it('run over an array returns null when all pass', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + assert.equal(await block.run(ev([vcDoc(), vcDoc({ id: 'vc-2' })])), null); + }); + + it('run over an array returns the first error encountered', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + const bad = { document: { verifiableCredential: [{}] } }; + assert.equal(await block.run(ev([vcDoc(), bad])), 'Invalid document type'); + }); + + it('related-vc-document with no ref resolves to Document does not exist', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'related-vc-document' } }); + const r = await block.run(ev(vcDoc())); + assert.equal(r, 'Document does not exist'); + }); + + it('related-vc-document loads the referenced VC and validates type', async () => { + const related = vcDoc({ id: 'vc-rel' }); + let queried = 0; + const db = makeDb({ getVcDocument: async () => { queried++; return related; } }); + const { block } = makeBlock(DocumentValidatorBlock, { + options: { documentType: 'related-vc-document' }, + componentsOverrides: { databaseServer: db }, + }); + const doc = { document: { credentialSubject: [{ ref: 'ref-123' }] } }; + const r = await block.run(ev(doc)); + assert.equal(r, null); + assert.equal(queried, 1); + }); + + it('related-vc-document with missing referenced VC returns Document does not exist', async () => { + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(DocumentValidatorBlock, { + options: { documentType: 'related-vc-document' }, + componentsOverrides: { databaseServer: db }, + }); + const doc = { document: { credentialSubject: [{ ref: 'ref-123' }] } }; + assert.equal(await block.run(ev(doc)), 'Document does not exist'); + }); + + it('related-vp-document loads the referenced VP', async () => { + const relatedVp = { document: { verifiableCredential: [{ credentialSubject: [{}] }] } }; + let queried = 0; + const db = makeDb({ getVpDocument: async () => { queried++; return relatedVp; } }); + const { block } = makeBlock(DocumentValidatorBlock, { + options: { documentType: 'related-vp-document' }, + componentsOverrides: { databaseServer: db }, + }); + const doc = { document: { credentialSubject: [{ ref: 'ref-99' }] } }; + assert.equal(await block.run(ev(doc)), null); + assert.equal(queried, 1); + }); + + it('runAction triggers Run/Release/Refresh and returns event.data on success', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + const captured = instrument(block); + const event = ev(vcDoc()); + const out = await block.runAction(event); + assert.equal(out, event.data); + assert.equal(captured.length, 3); + assert.equal(captured[0][0], 'RunEvent'); + assert.equal(captured[1][0], 'ReleaseEvent'); + assert.equal(captured[2][0], 'RefreshEvent'); + }); + + it('runAction does not emit success events on validation failure', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vp-document' } }); + const captured = instrument(block); + await block.runAction(ev(vcDoc())).catch(() => {}); + assert.equal(captured.length, 0); + }); + + it('runAction emits a Run external event on success', async () => { + const { block } = makeBlock(DocumentValidatorBlock, { options: { documentType: 'vc-document' } }); + instrument(block); + await block.runAction(ev(vcDoc())); + assert.equal(externalEvents.length, 1); + }); +}); + +describe('@unit extract-data-block runtime', () => { + after(() => restoreHarness()); + + function withSchema(block, type, fieldPath) { + block._schema = { + type, + searchFields: (pred) => { + const f = { isRef: true, type, path: fieldPath }; + return pred(f) ? [f] : []; + }, + }; + block.getSchema = async () => ({ + searchFields: (pred) => { + const f = { isRef: true, type, path: fieldPath }; + return pred(f) ? [f] : []; + }, + }); + } + + it('compareSchema matches ref field type to iri with # normalization', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + assert.equal(block.compareSchema({ isRef: true, type: 'Foo' }, 'Foo'), true); + assert.equal(block.compareSchema({ isRef: true, type: '#Foo' }, 'Foo'), true); + assert.equal(block.compareSchema({ isRef: true, type: 'Foo' }, '#Foo'), true); + }); + + it('compareSchema returns false for non-ref fields', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + assert.equal(block.compareSchema({ isRef: false, type: 'Foo' }, 'Foo'), false); + }); + + it('compareSchema returns false when types differ', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + assert.equal(block.compareSchema({ isRef: true, type: 'Foo' }, 'Bar'), false); + }); + + it('getDocuments wraps a single document into an array', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + assert.deepEqual(block.getDocuments({ a: 1 }), [{ a: 1 }]); + }); + + it('getDocuments returns array unchanged', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + const arr = [{ a: 1 }]; + assert.equal(block.getDocuments(arr), arr); + }); + + it('getDocuments returns null for nullish input', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + assert.equal(block.getDocuments(null), null); + assert.equal(block.getDocuments(undefined), null); + }); + + it('searchFieldsPath collects nested object leaves from credentialSubject', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + const doc = { document: { credentialSubject: [{ nested: { leaf: { v: 7 } } }] } }; + const r = block.searchFieldsPath(doc, 'nested.leaf'); + assert.deepEqual(r, [{ v: 7 }]); + }); + + it('searchFieldsPath does not collect scalar leaves (object-only contract)', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + const doc = { document: { credentialSubject: [{ nested: { leaf: 7 } }] } }; + assert.deepEqual(block.searchFieldsPath(doc, 'nested.leaf'), []); + }); + + it('searchFieldsPath descends into arrays of subjects', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + const doc = { document: { credentialSubject: [{ items: [{ leaf: { v: 1 } }, { leaf: { v: 2 } }] }] } }; + const r = block.searchFieldsPath(doc, 'items.leaf'); + assert.deepEqual(r.map((x) => x.v).sort(), [1, 2]); + }); + + it('searchFieldsPath returns empty array for absent path', () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + const doc = { document: { credentialSubject: [{}] } }; + assert.deepEqual(block.searchFieldsPath(doc, 'missing'), []); + }); + + it('extract pushes matching subjects into result', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + withSchema(block, 'Sub', 'child'); + const doc = { schema: '#Root', document: { credentialSubject: [{ child: { x: 1 } }] } }; + const result = []; + await block.extract(doc, 'Sub', result); + assert.deepEqual(result, [{ x: 1 }]); + }); + + it('extract adds nothing when getSchema returns null', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: {} }); + block.getSchema = async () => null; + const result = []; + await block.extract({ schema: '#R', document: { credentialSubject: [{}] } }, 'Sub', result); + assert.deepEqual(result, []); + }); + + it('runAction get path builds unsigned VCs from extracted subjects', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'get' } }); + const captured = instrument(block); + withSchema(block, 'Sub', 'child'); + const doc = { schema: '#Root', document: { credentialSubject: [{ child: { x: 5 } }] } }; + const event = { user: makeUser(), data: { data: doc } }; + const out = await block.runAction(event); + assert.equal(out, event.data); + assert.equal(captured.length, 1); + assert.equal(captured[0][0], 'RunEvent'); + const state = captured[0][2]; + assert.equal(state.source, doc); + assert.equal(state.data.length, 1); + }); + + it('runAction get path over array extracts from each doc', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'get' } }); + const captured = instrument(block); + withSchema(block, 'Sub', 'child'); + const docs = [ + { schema: '#R', document: { credentialSubject: [{ child: { x: 1 } }] } }, + { schema: '#R', document: { credentialSubject: [{ child: { x: 2 } }] } }, + ]; + await block.runAction({ user: makeUser(), data: { data: docs } }); + assert.equal(captured[0][2].data.length, 2); + }); + + it('getAction throws on empty documents', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'get' } }); + block._schema = { type: 'Sub' }; + instrument(block); + await assert.rejects(() => block.getAction(block, { user: makeUser(), data: { data: null } }), /Invalid documents/); + }); + + it('runAction set path merges new subjects into sources', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'set' } }); + const captured = instrument(block); + withSchema(block, 'Sub', 'child'); + const source = { schema: '#R', document: { credentialSubject: [{ child: { keep: 1 } }] } }; + const newDoc = { document: { credentialSubject: [{ added: 2 }] } }; + const event = { user: makeUser(), data: { source, data: newDoc } }; + await block.runAction(event); + assert.equal(captured[0][0], 'RunEvent'); + assert.deepEqual(source.document.credentialSubject[0].child, { keep: 1, added: 2 }); + }); + + it('setAction throws on count mismatch', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'set' } }); + instrument(block); + withSchema(block, 'Sub', 'child'); + const source = { schema: '#R', document: { credentialSubject: [{ child: { a: 1 } }] } }; + const event = { user: makeUser(), data: { source, data: [{ document: { credentialSubject: [{}] } }, { document: { credentialSubject: [{}] } }] } }; + await assert.rejects(() => block.setAction(block, event), /Invalid documents count/); + }); + + it('setAction throws when sources missing', async () => { + const { block } = makeBlock(ExtractDataBlock, { options: { action: 'set' } }); + block._schema = { type: 'Sub' }; + instrument(block); + await assert.rejects(() => block.setAction(block, { user: makeUser(), data: { source: null, data: [{}] } }), /Invalid documents/); + }); +}); + +describe('@unit reassigning-block runtime', () => { + after(() => restoreHarness()); + + function stubReassign(block) { + block.documentReassigning = async (doc, user) => ({ + item: { ...doc, reassigned: true }, + actor: { ...user, did: 'did:actor' }, + }); + } + + it('runAction reassigns a single document and rewrites event.data.data', async () => { + const { block } = makeBlock(ReassigningBlock, { options: { issuer: '', actor: '' } }); + stubReassign(block); + const captured = instrument(block); + const doc = vcDoc(); + const event = { user: makeUser(), data: { data: doc } }; + const out = await block.runAction(event); + assert.equal(out.data.reassigned, true); + assert.equal(captured.length, 3); + assert.equal(captured[0][1].did, 'did:actor'); + }); + + it('runAction reassigns each document of an array', async () => { + const { block } = makeBlock(ReassigningBlock, { options: {} }); + stubReassign(block); + const captured = instrument(block); + const event = { user: makeUser(), data: { data: [vcDoc(), vcDoc({ id: 'b' })] } }; + const out = await block.runAction(event); + assert.equal(Array.isArray(out.data), true); + assert.equal(out.data.length, 2); + assert.equal(out.data[0].reassigned, true); + assert.equal(captured.length, 3); + }); + + it('runAction triggers Run/Release/Refresh with the resolved actor', async () => { + const { block } = makeBlock(ReassigningBlock, { options: {} }); + stubReassign(block); + const captured = instrument(block); + await block.runAction({ user: makeUser(), data: { data: vcDoc() } }); + assert.deepEqual(captured.map((c) => c[0]), ['RunEvent', 'ReleaseEvent', 'RefreshEvent']); + }); + + it('runAction emits a Run external event', async () => { + const { block } = makeBlock(ReassigningBlock, { options: {} }); + stubReassign(block); + instrument(block); + await block.runAction({ user: makeUser(), data: { data: vcDoc() } }); + assert.equal(externalEvents.length, 1); + }); +}); + +describe('@unit multi-sign-block runtime', () => { + after(() => restoreHarness()); + + const signRole = (status) => ({ status }); + + it('getData reports block identity and readonly false for local user', async () => { + const { block } = makeBlock(MultiSignBlock, { options: { type: 'multiSign', threshold: '50' } }); + const data = await block.getData(makeUser()); + assert.equal(data.id, 'uuid-1'); + assert.equal(data.blockType, 'multiSignBlock'); + assert.equal(data.readonly, false); + }); + + it('getData is readonly for a remote user on a remote block', async () => { + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: '50' }, + policyOverrides: { locationType: LocationType.REMOTE }, + }); + const data = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(data.actionType, LocationType.REMOTE); + assert.equal(data.readonly, true); + }); + + it('getDocumentStatus computes thresholds with 50% over 4 users', async () => { + const db = makeDb({ + getMultiSignStatus: async () => null, + getMultiSignDocuments: async () => [signRole('SIGNED'), signRole('SIGNED')], + getAllUsersByRole: async () => [{}, {}, {}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const r = await block.getDocumentStatus({ id: 'd1' }, makeUser({ id: 'u1', group: 'g' })); + assert.equal(r.total, 4); + assert.equal(r.signedCount, 2); + assert.equal(r.signedThreshold, 2); + assert.equal(r.declinedThreshold, 3); + assert.equal(r.signedPercent, 50); + }); + + it('getDocumentStatus reflects the current user document status', async () => { + const db = makeDb({ + getMultiSignStatus: async () => null, + getMultiSignDocuments: async () => [{ userId: 'u1', status: 'SIGNED' }, { userId: 'u2', status: 'DECLINED' }], + getAllUsersByRole: async () => [{}, {}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const r = await block.getDocumentStatus({ id: 'd1' }, makeUser({ id: 'u1', group: 'g' })); + assert.equal(r.documentStatus, 'SIGNED'); + assert.equal(r.signedCount, 1); + assert.equal(r.declinedCount, 1); + }); + + it('getDocumentStatus surfaces a non-NEW confirmation status', async () => { + const db = makeDb({ + getMultiSignStatus: async () => ({ status: 'SIGNED' }), + getMultiSignDocuments: async () => [], + getAllUsersByRole: async () => [{}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const r = await block.getDocumentStatus({ id: 'd1' }, makeUser({ id: 'u1', group: 'g' })); + assert.equal(r.confirmationStatus, 'SIGNED'); + }); + + it('getDocumentStatus keeps confirmationStatus null when status is NEW', async () => { + const db = makeDb({ + getMultiSignStatus: async () => ({ status: 'NEW' }), + getMultiSignDocuments: async () => [], + getAllUsersByRole: async () => [{}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const r = await block.getDocumentStatus({ id: 'd1' }, makeUser({ id: 'u1', group: 'g' })); + assert.equal(r.confirmationStatus, null); + }); + + it('getDocumentStatus threshold rounds up for non-divisible percentages', async () => { + const db = makeDb({ + getMultiSignStatus: async () => null, + getMultiSignDocuments: async () => [], + getAllUsersByRole: async () => [{}, {}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const r = await block.getDocumentStatus({ id: 'd1' }, makeUser({ id: 'u1', group: 'g' })); + assert.equal(r.signedThreshold, 2); + assert.equal(r.declinedThreshold, 2); + }); + + it('joinData stamps block status onto a single document', async () => { + const db = makeDb({ + getMultiSignStatus: async () => null, + getMultiSignDocuments: async () => [], + getAllUsersByRole: async () => [{}, {}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { type: 't', threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const doc = { id: 'd1' }; + const out = await block.joinData(doc, makeUser({ id: 'u1', group: 'g' }), null); + assert.ok(out.blocks['uuid-1']); + assert.equal(out.blocks['uuid-1'].status.total, 2); + }); + + it('joinData stamps block status onto each document of an array', async () => { + const db = makeDb({ + getMultiSignStatus: async () => null, + getMultiSignDocuments: async () => [], + getAllUsersByRole: async () => [{}], + }); + const { block } = makeBlock(MultiSignBlock, { + options: { type: 't', threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + const docs = [{ id: 'a' }, { id: 'b' }]; + const out = await block.joinData(docs, makeUser({ id: 'u1', group: 'g' }), null); + assert.ok(out[0].blocks['uuid-1']); + assert.ok(out[1].blocks['uuid-1']); + }); + + it('setData throws when the source document is missing', async () => { + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + await assert.rejects( + () => block.setData(makeUser({ id: 'u1', group: 'g' }), { status: 'SIGNED', document: { id: 'd1' } }, null, null), + /Invalid document/ + ); + }); + + it('setData throws when the document was already signed at group level', async () => { + const db = makeDb({ + getVcDocument: async () => ({ document: { credentialSubject: [{}] } }), + getMultiSignStatus: async () => ({ status: 'SIGNED' }), + }); + const { block } = makeBlock(MultiSignBlock, { + options: { threshold: 50 }, + componentsOverrides: { databaseServer: db }, + }); + await assert.rejects( + () => block.setData(makeUser({ id: 'u1', group: 'g' }), { status: 'SIGNED', document: { id: 'd1' } }, null, null), + /already been signed/ + ); + }); + + it('runAction is a no-op returning undefined', async () => { + const { block } = makeBlock(MultiSignBlock, { options: { threshold: 50 } }); + assert.equal(await block.runAction({ user: makeUser(), data: { data: vcDoc() } }), undefined); + }); +}); + +describe('@unit aggregate-block runtime', () => { + after(() => restoreHarness()); + + it('aggregateScope returns empty object for empty scopes', () => { + const { block } = makeBlock(AggregateBlock, { options: {} }); + assert.deepEqual(block.aggregateScope([]), {}); + assert.deepEqual(block.aggregateScope(null), {}); + }); + + it('aggregateScope collects each key into arrays', () => { + const { block } = makeBlock(AggregateBlock, { options: {} }); + const r = block.aggregateScope([{ a: 1, b: 2 }, { a: 3, b: 4 }]); + assert.deepEqual(r, { a: [1, 3], b: [2, 4] }); + }); + + it('expressions returns empty object when there are no expressions', () => { + const { block } = makeBlock(AggregateBlock, { options: {} }); + assert.deepEqual(block.expressions(block, [], { document: {} }), {}); + assert.deepEqual(block.expressions(block, null, { document: {} }), {}); + }); + + it('popDocuments removes the aggregate by hash', async () => { + const db = makeDb({ removeAggregateDocument: makeDb().saveBlockState }); + const calls = []; + db.removeAggregateDocument = async (...a) => { calls.push(a); }; + const { block } = makeBlock(AggregateBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.popDocuments(block, { hash: 'h1' }); + assert.deepEqual(calls[0], ['h1', 'uuid-1']); + }); + + it('onPopEvent removes each document hash in an array', async () => { + const calls = []; + const db = makeDb(); + db.removeAggregateDocument = async (...a) => { calls.push(a); }; + const { block } = makeBlock(AggregateBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + block.backup = () => {}; + await block.onPopEvent({ user: makeUser(), data: { data: [{ hash: 'h1' }, { hash: 'h2' }] } }); + assert.equal(calls.length, 2); + assert.deepEqual(calls.map((c) => c[0]), ['h1', 'h2']); + }); + + it('onPopEvent removes a single document hash', async () => { + const calls = []; + const db = makeDb(); + db.removeAggregateDocument = async (...a) => { calls.push(a); }; + const { block } = makeBlock(AggregateBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + block.backup = () => {}; + await block.onPopEvent({ user: makeUser(), data: { data: { hash: 'solo' } } }); + assert.deepEqual(calls[0][0], 'solo'); + }); + + it('tickCron is a no-op when aggregateType is not period', async () => { + const calls = []; + const db = makeDb(); + db.getAggregateDocuments = async (...a) => { calls.push(a); return []; }; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'cumulative' }, + componentsOverrides: { databaseServer: db }, + }); + block.backup = () => {}; + await block.tickCron({ user: makeUser(), data: ['id1'] }); + assert.equal(calls.length, 0); + }); + + it('tickCron groups documents by scope id and sends per-group', async () => { + const docs = [ + { owner: 'o1', document: {} }, + { owner: 'o1', document: {} }, + { owner: 'o2', document: {} }, + ]; + const db = makeDb({ getAggregateDocuments: async () => docs }); + db.removeAggregateDocuments = async () => {}; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period', disableUserGrouping: false, groupByFields: [] }, + componentsOverrides: { databaseServer: db }, + }); + block.backup = () => {}; + const sent = []; + block.sendCronDocuments = async (ref, userId, documents) => { sent.push({ userId, count: documents.length }); }; + await block.tickCron({ user: makeUser(), data: ['o1', 'o2'] }); + assert.equal(sent.length, 2); + const o1 = sent.find((s) => s.userId === 'o1'); + assert.equal(o1.count, 2); + }); + + it('tickCron drops documents whose scope id is not in the id list', async () => { + const docs = [{ owner: 'keep', document: {} }, { owner: 'drop', document: {} }]; + const db = makeDb({ getAggregateDocuments: async () => docs }); + const removed = []; + db.removeAggregateDocuments = async (d) => { removed.push(...d); }; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period', disableUserGrouping: false, groupByFields: [] }, + componentsOverrides: { databaseServer: db }, + }); + block.backup = () => {}; + const sent = []; + block.sendCronDocuments = async (ref, userId) => { sent.push(userId); }; + await block.tickCron({ user: makeUser(), data: ['keep'] }); + assert.equal(removed.length, 1); + assert.equal(removed[0].owner, 'drop'); + assert.deepEqual(sent, ['keep']); + }); + + it('tickCron with disableUserGrouping groups by document key only', async () => { + const docs = [ + { owner: 'o1', document: { f: 'A' } }, + { owner: 'o2', document: { f: 'A' } }, + { owner: 'o3', document: { f: 'B' } }, + ]; + const db = makeDb({ getAggregateDocuments: async () => docs }); + db.removeAggregateDocuments = async () => {}; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period', disableUserGrouping: true, groupByFields: [{ fieldPath: 'document.f' }] }, + componentsOverrides: { databaseServer: db }, + }); + block.backup = () => {}; + const sent = []; + block.sendCronDocuments = async (ref, userId, documents) => { sent.push(documents.length); }; + await block.tickCron({ user: makeUser(), data: [] }); + assert.equal(sent.length, 2); + assert.deepEqual(sent.sort(), [1, 2]); + }); + + it('removeDocuments restores source ids and returns the documents', async () => { + const db = makeDb(); + let removed = null; + db.removeAggregateDocuments = async (d) => { removed = d; }; + const { block } = makeBlock(AggregateBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const docs = [{ sourceDocumentId: { toString: () => 'src-1' } }]; + const r = await block.removeDocuments(block, docs); + assert.equal(removed.length, 1); + assert.equal(r[0].id, 'src-1'); + assert.equal(r[0].sourceDocumentId, undefined); + }); + + it('removeDocuments returns empty array untouched', async () => { + const db = makeDb(); + let called = false; + db.removeAggregateDocuments = async () => { called = true; }; + const { block } = makeBlock(AggregateBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const r = await block.removeDocuments(block, []); + assert.deepEqual(r, []); + assert.equal(called, false); + }); + + it('runAction saves documents and skips tickAggregate for non-cumulative type', async () => { + const saved = []; + const db = makeDb(); + db.createAggregateDocuments = async (item) => { saved.push(item); }; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period' }, + componentsOverrides: { databaseServer: db }, + }); + let tickCalled = false; + block.tickAggregate = async () => { tickCalled = true; }; + const event = { user: makeUser(), data: { data: [vcDoc(), vcDoc({ id: 'b' })] } }; + const out = await block.runAction(event); + assert.equal(out, event.data); + assert.equal(saved.length, 2); + assert.equal(tickCalled, false); + }); + + it('runAction invokes tickAggregate per doc for cumulative type', async () => { + const db = makeDb(); + db.createAggregateDocuments = async () => {}; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'cumulative' }, + componentsOverrides: { databaseServer: db }, + }); + let ticks = 0; + block.tickAggregate = async () => { ticks++; }; + await block.runAction({ user: makeUser(), data: { data: [vcDoc(), vcDoc({ id: 'b' })] } }); + assert.equal(ticks, 2); + }); + + it('runAction handles a single document', async () => { + const saved = []; + const db = makeDb(); + db.createAggregateDocuments = async (item) => { saved.push(item); }; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period' }, + componentsOverrides: { databaseServer: db }, + }); + block.tickAggregate = async () => {}; + await block.runAction({ user: makeUser(), data: { data: vcDoc() } }); + assert.equal(saved.length, 1); + }); + + it('runAction emits a Run external event', async () => { + const db = makeDb(); + db.createAggregateDocuments = async () => {}; + const { block } = makeBlock(AggregateBlock, { + options: { aggregateType: 'period' }, + componentsOverrides: { databaseServer: db }, + }); + block.tickAggregate = async () => {}; + await block.runAction({ user: makeUser(), data: { data: vcDoc() } }); + assert.equal(externalEvents.length, 1); + }); +}); + +describe('@unit split-block runtime', () => { + after(() => restoreHarness()); + + function setupSplit(block, db) { + let n = 0; + block.createNewDoc = async (ref, root, document, newValue) => ({ document: { id: `doc-${n++}`, value: newValue } }); + db.createResidue = (policyId, uuid, userId, value, newDoc) => ({ value, document: newDoc.document }); + } + + it('split with value below threshold accumulates residue without emitting a chunk', async () => { + const db = makeDb(); + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const result = []; + const doc = { document: { credentialSubject: [{ amount: 40 }] } }; + const residue = await block.split(block, {}, makeUser({ id: 'u1' }), result, [], doc, 'u1', null); + assert.equal(result.length, 0); + assert.equal(residue.length, 1); + assert.equal(residue[0].value, 40); + }); + + it('split fills a chunk exactly when residue plus value reaches threshold', async () => { + const db = makeDb(); + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const result = []; + const existing = [{ value: 60 }]; + const doc = { document: { credentialSubject: [{ amount: 40 }] } }; + const residue = await block.split(block, {}, makeUser({ id: 'u1' }), result, existing, doc, 'u1', null); + assert.equal(result.length, 1); + assert.equal(residue.length, 0); + assert.equal(result[0].reduce((s, r) => s + r.value, 0), 100); + }); + + it('split chunks a large value into multiple full chunks plus remainder', async () => { + const db = makeDb(); + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const result = []; + const doc = { document: { credentialSubject: [{ amount: 250 }] } }; + const residue = await block.split(block, {}, makeUser({ id: 'u1' }), result, [], doc, 'u1', null); + assert.equal(result.length, 2); + assert.equal(residue.length, 1); + assert.equal(residue[0].value, 50); + }); + + it('split resets a satisfied incoming residue before processing value', async () => { + const db = makeDb(); + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const result = []; + const existing = [{ value: 100 }]; + const doc = { document: { credentialSubject: [{ amount: 30 }] } }; + const residue = await block.split(block, {}, makeUser({ id: 'u1' }), result, existing, doc, 'u1', null); + assert.equal(result.length, 1); + assert.deepEqual(result[0], existing); + assert.equal(residue[0].value, 30); + }); + + it('calcDocValue parses the source field number', async () => { + const { block } = makeBlock(SplitBlock, { options: { sourceField: 'document.credentialSubject.0.amount' } }); + const v = await block.calcDocValue(block, { document: { credentialSubject: [{ amount: '12.5' }] } }, makeUser()); + assert.equal(v, 12.5); + }); + + it('calcDocValue returns NaN for a missing field', async () => { + const { block } = makeBlock(SplitBlock, { options: { sourceField: 'document.missing' } }); + const v = await block.calcDocValue(block, { document: {} }, makeUser()); + assert.ok(Number.isNaN(v)); + }); + + it('addDocs emits one chunk and a refresh for a single full document', async () => { + const db = makeDb({ getResidue: async () => [] }); + db.removeResidue = async () => {}; + db.setResidue = async () => {}; + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const orig = PolicyUtils.getUserCredentials; + PolicyUtils.getUserCredentials = async () => ({}); + try { + const captured = instrument(block); + const doc = { document: { credentialSubject: [{ amount: 100 }] } }; + await block.addDocs(block, makeUser({ id: 'u1' }), [doc], 'u1', null); + const runs = captured.filter((c) => c[0] === 'RunEvent'); + const refresh = captured.filter((c) => c[0] === 'RefreshEvent'); + assert.equal(runs.length, 1); + assert.equal(refresh.length, 1); + } finally { + PolicyUtils.getUserCredentials = orig; + } + }); + + it('addDocs only refreshes when nothing reaches the threshold', async () => { + const db = makeDb({ getResidue: async () => [] }); + db.removeResidue = async () => {}; + db.setResidue = async () => {}; + const { block } = makeBlock(SplitBlock, { + options: { threshold: '100', sourceField: 'document.credentialSubject.0.amount' }, + componentsOverrides: { databaseServer: db }, + }); + setupSplit(block, db); + const orig = PolicyUtils.getUserCredentials; + PolicyUtils.getUserCredentials = async () => ({}); + try { + const captured = instrument(block); + const doc = { document: { credentialSubject: [{ amount: 10 }] } }; + await block.addDocs(block, makeUser({ id: 'u1' }), [doc], 'u1', null); + assert.equal(captured.filter((c) => c[0] === 'RunEvent').length, 0); + assert.equal(captured.filter((c) => c[0] === 'RefreshEvent').length, 1); + } finally { + PolicyUtils.getUserCredentials = orig; + } + }); + + it('runAction wraps a single document and delegates to addDocs', async () => { + const { block } = makeBlock(SplitBlock, { options: { threshold: '100', sourceField: 'x' } }); + block.backup = () => {}; + let received = null; + block.addDocs = async (ref, user, documents) => { received = documents; }; + const event = { user: makeUser(), data: { data: vcDoc() } }; + const out = await block.runAction(event); + assert.equal(out, event.data); + assert.equal(received.length, 1); + }); + + it('runAction passes an array straight through to addDocs', async () => { + const { block } = makeBlock(SplitBlock, { options: { threshold: '100', sourceField: 'x' } }); + block.backup = () => {}; + let received = null; + block.addDocs = async (ref, user, documents) => { received = documents; }; + await block.runAction({ user: makeUser(), data: { data: [vcDoc(), vcDoc({ id: 'b' })] } }); + assert.equal(received.length, 2); + }); + + it('runAction emits a Run external event before splitting', async () => { + const { block } = makeBlock(SplitBlock, { options: { threshold: '100', sourceField: 'x' } }); + block.backup = () => {}; + block.addDocs = async () => {}; + await block.runAction({ user: makeUser(), data: { data: vcDoc() } }); + assert.equal(externalEvents.length, 1); + }); +}); + +describe('@unit set-relationships-block runtime', () => { + after(() => restoreHarness()); + + function withSources(block, sources) { + block.getSources = async () => sources; + } + + it('appends source relationships to a single target document', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + const captured = instrument(block); + withSources(block, [{ owner: 'o', messageId: 'm1' }, { messageId: 'm2' }]); + const target = { id: 't' }; + const event = { user: makeUser(), data: { data: target } }; + await block.runAction(event); + assert.deepEqual(target.relationships, ['m1', 'm2']); + assert.equal(captured.filter((c) => c[0] === 'RunEvent').length, 1); + }); + + it('dedupes repeated source messageIds', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ messageId: 'm1' }, { messageId: 'm1' }]); + const target = { id: 't' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.deepEqual(target.relationships, ['m1']); + }); + + it('concatenates onto existing relationships of an array target', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ messageId: 'm1' }]); + const docs = [{ relationships: ['pre'] }]; + await block.runAction({ user: makeUser(), data: { data: docs } }); + assert.deepEqual(docs[0].relationships, ['pre', 'm1']); + }); + + it('includeAccounts merges source accounts into the target', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: { includeAccounts: true } }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ accounts: { a: '1' } }, { accounts: { b: '2' } }]); + const target = { id: 't' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.deepEqual(target.accounts, { a: '1', b: '2' }); + }); + + it('includeTokens merges source tokens into the target', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: { includeTokens: true } }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ tokens: { t: '0.0.1' } }]); + const target = { id: 't' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.deepEqual(target.tokens, { t: '0.0.1' }); + }); + + it('changeOwner copies owner and group from first source', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: { changeOwner: true } }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ owner: 'src-owner', group: 'src-group' }]); + const target = { id: 't', owner: 'old', group: 'oldg' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.equal(target.owner, 'src-owner'); + assert.equal(target.group, 'src-group'); + }); + + it('changeOwner leaves owner untouched when first source has none', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: { changeOwner: true } }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ messageId: 'm1' }]); + const target = { id: 't', owner: 'old' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.equal(target.owner, 'old'); + }); + + it('does not mutate accounts/tokens when flags are off', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + instrument(block); + withSources(block, [{ accounts: { a: '1' }, tokens: { t: '1' } }]); + const target = { id: 't' }; + await block.runAction({ user: makeUser(), data: { data: target } }); + assert.equal(target.accounts, undefined); + assert.equal(target.tokens, undefined); + }); + + it('handles a missing documents payload without throwing', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + const captured = instrument(block); + withSources(block, [{ messageId: 'm1' }]); + const event = { user: makeUser(), data: {} }; + await block.runAction(event); + assert.equal(captured.filter((c) => c[0] === 'RunEvent').length, 1); + }); + + it('triggers Run then Release and emits an external event', async () => { + const { block } = makeBlock(SetRelationshipsBlock, { options: {} }); + block.backup = () => {}; + const captured = instrument(block); + withSources(block, []); + await block.runAction({ user: makeUser(), data: { data: { id: 't' } } }); + assert.deepEqual(captured.map((c) => c[0]), ['RunEvent', 'ReleaseEvent']); + assert.equal(externalEvents.length, 1); + }); +}); + +describe('@unit filters-addon-block runtime', () => { + after(() => restoreHarness()); + + const user = makeUser({ id: 'u1' }); + + it('addQuery builds an eq expression onto the filter', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', queryType: 'equal' } }); + const filter = {}; + await block.addQuery(filter, 'val', user); + assert.deepEqual(filter['document.f'], { $eq: 'val' }); + }); + + it('addQuery builds an in expression from a comma list', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', queryType: 'in' } }); + const filter = {}; + await block.addQuery(filter, 'a,b,c', user); + assert.deepEqual(filter['document.f'], { $in: ['a', 'b', 'c'] }); + }); + + it('addQuery builds a regex expression', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', queryType: 'regex' } }); + const filter = {}; + await block.addQuery(filter, 'abc', user); + assert.deepEqual(filter['document.f'], { $regex: '.*abc.*' }); + }); + + it('addQuery throws when the query yields no expression', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', queryType: 'equal' } }); + await assert.rejects(() => block.addQuery({}, null, user), /Unknown filter type/); + }); + + it('getData returns the block envelope with options carried through', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { type: 'input', queryType: 'equal', canBeEmpty: true } }); + block.getSources = async () => []; + const data = await block.getData(user); + assert.equal(data.id, 'uuid-1'); + assert.equal(data.blockType, 'filtersAddon'); + assert.equal(data.type, 'input'); + assert.equal(data.canBeEmpty, true); + assert.equal(data.queryType, 'equal'); + }); + + it('getData for dropdown builds deduped name/value pairs from sources', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { type: 'dropdown', optionName: 'name', optionValue: 'value' } }); + block.getSources = async () => [ + { name: 'A', value: '1' }, + { name: 'B', value: '2' }, + { name: 'A-dup', value: '1' }, + ]; + const data = await block.getData(user); + assert.equal(data.data.length, 2); + assert.deepEqual(data.data.map((d) => d.value).sort(), ['1', '2']); + assert.equal(data.optionName, 'name'); + assert.equal(data.optionValue, 'value'); + }); + + it('getData for dropdown caches lastData on block state', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { type: 'dropdown', optionName: 'name', optionValue: 'value' } }); + block.getSources = async () => [{ name: 'A', value: '1' }]; + await block.getData(user); + assert.equal(block.state['u1'].lastData.length, 1); + }); + + it('getData is readonly for a remote user on a remote block', async () => { + const { block } = makeBlock(FiltersAddonBlock, { + options: { type: 'input' }, + policyOverrides: { locationType: LocationType.REMOTE }, + }); + block.actionType = LocationType.REMOTE; + block.getSources = async () => []; + const data = await block.getData(makeUser({ id: 'u2', location: LocationType.REMOTE })); + assert.equal(data.readonly, true); + }); + + it('getFilters returns the existing user filter unchanged', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input', canBeEmpty: true } }); + block.filters['u1'] = { 'document.f': { $eq: 'cached' } }; + const r = await block.getFilters(user); + assert.deepEqual(r['document.f'], { $eq: 'cached' }); + }); + + it('getFilters with canBeEmpty leaves filters empty when none set', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input', canBeEmpty: true } }); + const r = await block.getFilters(user); + assert.deepEqual(r, {}); + }); + + it('getFilters for a dropdown resolves a default option value', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'dropdown', optionValue: 'value', queryType: 'equal' } }); + block.getSources = async () => [{ value: 'first' }]; + const r = await block.getFilters(user); + assert.deepEqual(r['document.f'], { $eq: 'first' }); + assert.equal(block.state['u1'].lastValue, 'first'); + }); + + it('setFiltersStrict throws when value is empty and canBeEmpty is false', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input', queryType: 'equal' } }); + await assert.rejects(() => block.setFiltersStrict(user, { filterValue: '' }), /filter value is unknown/); + }); + + it('setFiltersStrict throws when data is missing', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input' } }); + await assert.rejects(() => block.setFiltersStrict(user, null), /filter value is unknown/); + }); + + it('setFiltersStrict sets an input filter and records state', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input', queryType: 'equal' } }); + let setArgs = null; + block.setFilters = (filter, u) => { setArgs = [filter, u]; block.filters[u.id] = filter; }; + await block.setFiltersStrict(user, { filterValue: 'hello' }); + assert.deepEqual(setArgs[0]['document.f'], { $eq: 'hello' }); + assert.equal(block.state['u1'].lastValue, 'hello'); + }); + + it('setFilterState rejects a value absent from dropdown lastData', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'dropdown', optionValue: 'value', queryType: 'equal' } }); + block.getSources = async () => [{ value: 'known' }]; + block.setFilters = () => {}; + await assert.rejects(() => block.setFilterState(user, { filterValue: 'unknown' }), /filter value is unknown/); + }); + + it('setFilterState accepts a value present in pre-populated dropdown lastData', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'dropdown', optionName: 'name', optionValue: 'value', queryType: 'equal' } }); + block.state[user.id] = { lastData: [{ name: 'K', value: 'known' }] }; + let captured = null; + block.setFilters = (filter) => { captured = filter; }; + await block.setFilterState(user, { filterValue: 'known' }); + assert.deepEqual(captured['document.f'], { $eq: 'known' }); + }); + + it('setFilterState rejects dropdown values when lastData was only just loaded (stale local copy)', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'dropdown', optionName: 'name', optionValue: 'value', queryType: 'equal' } }); + block.getSources = async () => [{ name: 'K', value: 'known' }]; + block.setFilters = () => {}; + await assert.rejects(() => block.setFilterState(user, { filterValue: 'known' }), /filter value is unknown/); + }); + + it('setFilterState for input sets the filter directly', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { field: 'document.f', type: 'input', queryType: 'equal' } }); + let captured = null; + block.setFilters = (filter) => { captured = filter; }; + await block.setFilterState(user, { filterValue: 'xx' }); + assert.deepEqual(captured['document.f'], { $eq: 'xx' }); + }); + + it('checkValues returns false when lastData is not an array', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { queryType: 'equal' } }); + const r = await block.checkValues({ lastData: null }, 'v', user); + assert.equal(r, false); + }); + + it('checkValues matches a single scalar value against lastData', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { queryType: 'equal' } }); + const blockState = { lastData: [{ value: '1' }, { value: '2' }] }; + assert.equal(await block.checkValues(blockState, '2', user), true); + assert.equal(await block.checkValues(blockState, '9', user), false); + }); + + it('checkValues requires every element of an in-list to be present', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: { queryType: 'in' } }); + const blockState = { lastData: [{ value: 'a' }, { value: 'b' }, { value: 'c' }] }; + assert.equal(await block.checkValues(blockState, 'a,b', user), true); + assert.equal(await block.checkValues(blockState, 'a,z', user), false); + }); + + it('resetFilters restores the previous state and filters', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: {} }); + block.previousState['u1'] = { lastValue: 'prev' }; + block.previousFilters['u1'] = { 'document.f': { $eq: 'prev' } }; + block.filters['u1'] = { 'document.f': { $eq: 'now' } }; + await block.resetFilters(user); + assert.deepEqual(block.state['u1'], { lastValue: 'prev' }); + assert.deepEqual(block.filters['u1'], { 'document.f': { $eq: 'prev' } }); + }); + + it('resetFilters is a no-op when there is no previous state', async () => { + const { block } = makeBlock(FiltersAddonBlock, { options: {} }); + block.filters['u1'] = { keep: 1 }; + await block.resetFilters(user); + assert.deepEqual(block.filters['u1'], { keep: 1 }); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/exec-request-send-blocks.test.mjs b/policy-service/tests/unit-tests/blocks/exec-request-send-blocks.test.mjs new file mode 100644 index 0000000000..370fa42e6a --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/exec-request-send-blocks.test.mjs @@ -0,0 +1,1219 @@ +import assert from 'node:assert/strict'; +import { LocationType, DocumentStatus, DocumentSignature } from '@guardian/interfaces'; +import { MessageServer, VcDocumentDefinition as VcDocumentRef, VcHelper } from '@guardian/common'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; +import { PolicyActionsUtils } from '../../../dist/policy-engine/policy-actions/utils.js'; +import { DocumentType } from '../../../dist/policy-engine/interfaces/document.type.js'; +import { makeBlock, makeUser, makeDb, restoreHarness } from './_block-exec-harness.mjs'; +import { RequestVcDocumentBlock } from '../../../dist/policy-engine/blocks/request-vc-document-block.js'; +import { RequestVcDocumentBlockAddon } from '../../../dist/policy-engine/blocks/request-vc-document-block-addon.js'; +import { SendToGuardianBlock } from '../../../dist/policy-engine/blocks/send-to-guardian-block.js'; +import { RevokeBlock } from '../../../dist/policy-engine/blocks/revoke-block.js'; +import { RevocationBlock } from '../../../dist/policy-engine/blocks/revocation-block.js'; +import { UploadVcDocumentBlock } from '../../../dist/policy-engine/blocks/upload-vc-document-block.js'; + +const _origUtils = {}; +const _origActions = {}; +const _origMsg = {}; + +function patchUtils(map) { + for (const [k, v] of Object.entries(map)) { + if (!(k in _origUtils)) _origUtils[k] = PolicyUtils[k]; + PolicyUtils[k] = v; + } +} +function patchActions(map) { + for (const [k, v] of Object.entries(map)) { + if (!(k in _origActions)) _origActions[k] = PolicyActionsUtils[k]; + PolicyActionsUtils[k] = v; + } +} +function patchMsg(map) { + for (const [k, v] of Object.entries(map)) { + if (!(k in _origMsg)) _origMsg[k] = MessageServer[k]; + MessageServer[k] = v; + } +} +function restoreStatics() { + for (const [k, v] of Object.entries(_origUtils)) PolicyUtils[k] = v; + for (const [k, v] of Object.entries(_origActions)) PolicyActionsUtils[k] = v; + for (const [k, v] of Object.entries(_origMsg)) MessageServer[k] = v; + for (const k of Object.keys(_origUtils)) delete _origUtils[k]; + for (const k of Object.keys(_origActions)) delete _origActions[k]; + for (const k of Object.keys(_origMsg)) delete _origMsg[k]; +} + +function captureEvents(block) { + const events = []; + block.triggerEvents = async (...a) => { events.push(a); }; + return events; +} + +after(() => { restoreStatics(); restoreHarness(); }); + +describe('@unit exec RequestVcDocumentBlock.getData', () => { + afterEach(() => restoreStatics()); + + function mk(opts = {}, schema = { iri: '#Schema', fields: [{ name: 'a' }], conditions: [{ x: 1 }] }) { + const { block, db } = makeBlock(RequestVcDocumentBlock, { options: opts.options || {}, ...opts }); + block._schema = schema; + return { block, db }; + } + + it('returns the block envelope id/blockType/actionType', async () => { + const { block } = mk({ uuid: 'r1', tag: 'rt' }); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'r1'); + assert.equal(d.blockType, 'requestVcDocumentBlock'); + assert.equal(d.actionType, LocationType.REMOTE); + }); + + it('strips schema fields/conditions into an empty-fields envelope', async () => { + const { block } = mk({}, { iri: '#S', fields: [{ name: 'x' }], conditions: [{ c: 1 }] }); + const d = await block.getData(makeUser()); + assert.deepEqual(d.schema.fields, []); + assert.deepEqual(d.schema.conditions, []); + assert.equal(d.schema.iri, '#S'); + }); + + it('is readonly only when REMOTE block AND remote user', async () => { + const { block } = mk(); + assert.equal((await block.getData(makeUser({ location: LocationType.REMOTE }))).readonly, true); + }); + + it('is not readonly for a local user on a remote block', async () => { + const { block } = mk(); + assert.equal((await block.getData(makeUser({ location: LocationType.LOCAL }))).readonly, false); + }); + + it('passes through presetSchema / presetFields options', async () => { + const { block } = mk({ options: { presetSchema: '#Preset', presetFields: [{ name: 'f' }] } }); + const d = await block.getData(makeUser()); + assert.equal(d.presetSchema, '#Preset'); + assert.deepEqual(d.presetFields, [{ name: 'f' }]); + }); + + it('defaults editType to "new" when unset', async () => { + const d = await mk().block.getData(makeUser()); + assert.equal(d.editType, 'new'); + }); + + it('keeps an explicit editType', async () => { + const d = await mk({ options: { editType: 'edit' } }).block.getData(makeUser()); + assert.equal(d.editType, 'edit'); + }); + + it('coerces relayerAccount / enableAdditionalData option to boolean', async () => { + const d = await mk({ options: { relayerAccount: 'x', enableAdditionalData: 1 } }).block.getData(makeUser()); + assert.equal(d.relayerAccount, true); + assert.equal(d.enableAdditionalData, true); + }); + + it('relayerAccount/enableAdditionalData false when unset', async () => { + const d = await mk().block.getData(makeUser()); + assert.equal(d.relayerAccount, false); + assert.equal(d.enableAdditionalData, false); + }); + + it('defaults uiMetaData to {} and hideFields to []', async () => { + const d = await mk().block.getData(makeUser()); + assert.deepEqual(d.uiMetaData, {}); + assert.deepEqual(d.hideFields, []); + }); + + it('passes through uiMetaData and hideFields', async () => { + const d = await mk({ options: { uiMetaData: { title: 'T' }, hideFields: ['h'] } }).block.getData(makeUser()); + assert.deepEqual(d.uiMetaData, { title: 'T' }); + assert.deepEqual(d.hideFields, ['h']); + }); + + it('data is null when there are no source children', async () => { + const d = await mk().block.getData(makeUser()); + assert.equal(d.data, null); + }); + + it('data takes the first source when sources exist', async () => { + const { block } = mk(); + block.getSources = async () => [{ id: 'first' }, { id: 'second' }]; + const d = await block.getData(makeUser()); + assert.deepEqual(d.data, { id: 'first' }); + }); + + it('restoreData is undefined when no per-user state', async () => { + const d = await mk().block.getData(makeUser()); + assert.equal(d.restoreData, undefined); + }); + + it('restoreData reflects stored per-user restore payload', async () => { + const { block } = mk(); + const user = makeUser({ id: 'u-7' }); + block.state['u-7'] = { restoreData: { doc: 1 } }; + const d = await block.getData(user); + assert.deepEqual(d.restoreData, { doc: 1 }); + }); +}); + +describe('@unit exec RequestVcDocumentBlock.restoreAction', () => { + afterEach(() => restoreStatics()); + + function mk() { + const { block } = makeBlock(RequestVcDocumentBlock, { options: {} }); + block._schema = { iri: '#S' }; + return block; + } + + it('no-ops when event has no data', async () => { + const block = mk(); + await block.restoreAction({ user: makeUser() }); + assert.equal(block.state[makeUser().id], undefined); + }); + + it('no-ops when event has no user', async () => { + const block = mk(); + await block.restoreAction({ data: { data: { x: 1 } } }); + assert.deepEqual(Object.keys(block.state).filter((k) => k !== 'active'), []); + }); + + it('stores restoreData under a fresh per-user state', async () => { + const block = mk(); + const user = makeUser({ id: 'u-1' }); + await block.restoreAction({ user, data: { data: { vc: 1 } } }); + assert.deepEqual(block.state['u-1'].restoreData, { vc: 1 }); + }); + + it('merges restoreData into an existing per-user state', async () => { + const block = mk(); + const user = makeUser({ id: 'u-2' }); + block.state['u-2'] = { keep: true }; + await block.restoreAction({ user, data: { data: { vc: 2 } } }); + assert.equal(block.state['u-2'].keep, true); + assert.deepEqual(block.state['u-2'].restoreData, { vc: 2 }); + }); +}); + +describe('@unit exec RequestVcDocumentBlock.setData validation + preset', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}) { + const { block, db } = makeBlock(RequestVcDocumentBlock, { options }); + block._schema = { iri: '#S', fields: [], type: 'TestType', contextURL: 'ctx://x' }; + block.tenantContext = { tenantId: 'tenant-1' }; + const events = captureEvents(block); + return { block, db, events }; + } + + function stubHappyPath() { + patchUtils({ + getRelationships: async () => null, + getRelayerAccountAndOwner: async (_ref, user) => [null, user], + getSubjectId: () => 'subj-1', + getCredentialSubject: (d) => (d && d.document) || null, + setAutoCalculateFields: () => {}, + setGuardianVersion: () => {}, + getGroupContext: async () => null, + getBlockTags: async () => [], + setDocumentTags: () => {}, + getHederaAccounts: () => ({}), + createVC: (ref, owner, vc) => ({ owner: owner.did, document: vc, policyId: ref.policyId }), + setDocumentRef: (item) => item, + }); + patchActions({ + generateId: async () => null, + signVC: async ({ subject }) => ({ subject, toCredentialHash: () => 'h', toJsonTree: () => ({}) }), + }); + } + + it('throws when document is missing (prepareDocument)', async () => { + const { block } = mk(); + stubHappyPath(); + await assert.rejects(() => block.setData(makeUser(), { document: null }, null, null)); + }); + + it('throws when user has no did', async () => { + const { block } = mk(); + await assert.rejects( + () => block.setData(makeUser({ did: null }), { document: { a: 1 } }, null, null), + /did/ + ); + }); + + it('deletes restoreData for the user before processing', async () => { + const { block } = mk(); + const user = makeUser({ id: 'u-r' }); + block.state['u-r'] = { restoreData: { old: 1 } }; + stubHappyPath(); + block.getVcHelperOk = true; + await assert.rejects(() => block.setData(user, { document: null }, null, null)); + assert.equal(block.state['u-r'].restoreData, undefined); + }); + + it('rejects when readonly preset field was modified', async () => { + const { block } = mk({ presetSchema: '#P', presetFields: [{ name: 'a', value: 'a', readonly: true }] }); + patchUtils({ + getRelationships: async () => ({ messageId: 'm1', document: { credentialSubject: { a: 'orig' } } }), + getRelayerAccountAndOwner: async (_r, u) => [null, u], + getSubjectId: () => 'subj', + getCredentialSubject: (d) => d.document.credentialSubject, + setAutoCalculateFields: () => {}, + setGuardianVersion: () => {}, + }); + patchActions({ generateId: async () => null }); + await assert.rejects( + () => block.setData(makeUser(), { document: { a: 'changed' } }, null, null), + /readonly|Readonly/i + ); + }); + + it('throws when reference document has no subject id', async () => { + const { block } = mk(); + patchUtils({ + getRelationships: async () => ({ messageId: 'm1', document: {} }), + getRelayerAccountAndOwner: async (_r, u) => [null, u], + getSubjectId: () => null, + setAutoCalculateFields: () => {}, + setGuardianVersion: () => {}, + }); + patchActions({ generateId: async () => null }); + await assert.rejects( + () => block.setData(makeUser(), { document: { a: 1 }, ref: 'x' }, null, null), + /Reference document not found/ + ); + }); +}); + +describe('@unit exec RequestVcDocumentBlockAddon.getData', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}) { + const { block } = makeBlock(RequestVcDocumentBlockAddon, { options }); + block._schema = { iri: '#Addon', fields: [{ name: 'q' }], conditions: [{ c: 9 }] }; + return block; + } + + it('returns envelope with id/blockType', async () => { + const d = await mk().getData(makeUser()); + assert.equal(d.blockType, 'requestVcDocumentBlockAddon'); + assert.equal(d.id, 'uuid-1'); + }); + + it('spreads options into the envelope', async () => { + const d = await mk({ presetSchema: '#P', custom: 42 }).getData(makeUser()); + assert.equal(d.presetSchema, '#P'); + assert.equal(d.custom, 42); + }); + + it('strips schema fields/conditions', async () => { + const d = await mk().getData(makeUser()); + assert.deepEqual(d.schema.fields, []); + assert.deepEqual(d.schema.conditions, []); + }); + + it('readonly true for remote user', async () => { + assert.equal((await mk().getData(makeUser({ location: LocationType.REMOTE }))).readonly, true); + }); + + it('readonly false for local user', async () => { + assert.equal((await mk().getData(makeUser({ location: LocationType.LOCAL }))).readonly, false); + }); +}); + +describe('@unit exec RequestVcDocumentBlockAddon.checkPreset', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}) { + const { block } = makeBlock(RequestVcDocumentBlockAddon, { options }); + block._schema = { iri: '#S' }; + return block; + } + + it('valid when no presetFields configured', async () => { + const block = mk(); + const res = await block.checkPreset(block, { a: 1 }, null, makeUser()); + assert.equal(res.valid, true); + }); + + it('valid when presetFields present but no readonly+value entries', async () => { + const block = mk({ presetSchema: '#P', presetFields: [{ name: 'a', readonly: false }] }); + const res = await block.checkPreset(block, { a: 1 }, { document: { credentialSubject: { a: 1 } } }, makeUser()); + assert.equal(res.valid, true); + }); + + it('valid when documentRef is missing even with readonly fields', async () => { + const block = mk({ presetSchema: '#P', presetFields: [{ name: 'a', value: 'a', readonly: true }] }); + const res = await block.checkPreset(block, { a: 1 }, null, makeUser()); + assert.equal(res.valid, true); + }); + + it('invalid when preset document cannot be resolved', async () => { + const block = mk({ presetSchema: '#P', presetFields: [{ name: 'a', value: 'a', readonly: true }] }); + patchUtils({ getCredentialSubject: () => null }); + const res = await block.checkPreset(block, { a: 1 }, { document: {} }, makeUser()); + assert.equal(res.valid, false); + assert.match(res.error, /can not be verified/); + }); + + it('invalid when a readonly field was changed', async () => { + const block = mk({ presetSchema: '#P', presetFields: [{ name: 'a', value: 'a', readonly: true }] }); + patchUtils({ getCredentialSubject: () => ({ a: 'orig' }) }); + const res = await block.checkPreset(block, { a: 'changed' }, { document: { credentialSubject: { a: 'orig' } } }, makeUser()); + assert.equal(res.valid, false); + assert.match(res.error, /can not be modified/); + }); + + it('valid when readonly field is unchanged', async () => { + const block = mk({ presetSchema: '#P', presetFields: [{ name: 'a', value: 'a', readonly: true }] }); + patchUtils({ getCredentialSubject: () => ({ a: 'same' }) }); + const res = await block.checkPreset(block, { a: 'same' }, { document: { credentialSubject: { a: 'same' } } }, makeUser()); + assert.equal(res.valid, true); + }); +}); + +describe('@unit exec SendToGuardianBlock.mapDocument', () => { + afterEach(() => restoreStatics()); + const block = () => makeBlock(SendToGuardianBlock, { options: {} }).block; + + it('copies all non-id keys from new doc onto old', () => { + const out = block().mapDocument({ keep: 1 }, { foo: 'bar', baz: 2 }); + assert.equal(out.foo, 'bar'); + assert.equal(out.baz, 2); + assert.equal(out.keep, 1); + }); + + it('does not copy id / _id', () => { + const out = block().mapDocument({ id: 'OLD', _id: 'OLD2' }, { id: 'NEW', _id: 'NEW2', x: 1 }); + assert.equal(out.id, 'OLD'); + assert.equal(out._id, 'OLD2'); + assert.equal(out.x, 1); + }); + + it('skips function-valued properties', () => { + const out = block().mapDocument({}, { fn: () => 1, value: 5 }); + assert.equal(out.fn, undefined); + assert.equal(out.value, 5); + }); + + it('returns the same old reference', () => { + const old = {}; + assert.equal(block().mapDocument(old, { a: 1 }), old); + }); +}); + +describe('@unit exec SendToGuardianBlock.getTopicOwner', () => { + afterEach(() => restoreStatics()); + const block = () => makeBlock(SendToGuardianBlock, { options: {} }).block; + + it('defaults to document.owner', () => { + assert.equal(block().getTopicOwner({}, { owner: 'did:doc' }, undefined), 'did:doc'); + }); + + it('type "user" uses document.owner', () => { + assert.equal(block().getTopicOwner({}, { owner: 'did:u' }, 'user'), 'did:u'); + }); + + it('type "root" uses ref.policyOwner', () => { + assert.equal(block().getTopicOwner({ policyOwner: 'did:root' }, { owner: 'did:u' }, 'root'), 'did:root'); + }); + + it('type "issuer" uses document issuer', () => { + patchUtils({ getDocumentIssuer: () => 'did:issuer' }); + assert.equal(block().getTopicOwner({}, { owner: 'did:u', document: {} }, 'issuer'), 'did:issuer'); + }); + + it('throws when topic owner cannot be resolved', () => { + assert.throws(() => block().getTopicOwner({}, { owner: null }, 'user'), /Topic owner not found/); + }); +}); + +describe('@unit exec SendToGuardianBlock VC/DID/VP record lookup', () => { + afterEach(() => restoreStatics()); + + it('getVCRecord queries by draftId when draft', async () => { + const db = makeDb(); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.getVCRecord({ draft: true, draftId: 'd1' }, 'auto', block); + const call = db.__calls.find((c) => c.name === 'getVcDocument'); + assert.equal(call.args[0].draft.$eq, true); + assert.equal(call.args[0].id.$eq, 'd1'); + }); + + it('getVCRecord queries by hash (non-revoke) when hash present', async () => { + const db = makeDb(); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.getVCRecord({ hash: 'H' }, 'auto', block); + const call = db.__calls.find((c) => c.name === 'getVcDocument'); + assert.equal(call.args[0].hash.$eq, 'H'); + assert.deepEqual(call.args[0].hederaStatus, { $not: { $eq: DocumentStatus.REVOKE } }); + }); + + it('getVCRecord returns null without draft/hash', async () => { + const db = makeDb(); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + assert.equal(await block.getVCRecord({}, 'auto', block), null); + assert.equal(db.__calls.some((c) => c.name === 'getVcDocument'), false); + }); + + it('getDIDRecord queries by did', async () => { + const db = makeDb(); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.getDIDRecord({ did: 'did:x' }, 'auto', block); + const call = db.__calls.find((c) => c.name === 'getVcDocument'); + assert.equal(call.args[0].did.$eq, 'did:x'); + }); + + it('getDIDRecord null without did', async () => { + const db = makeDb(); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + assert.equal(await block.getDIDRecord({}, 'auto', block), null); + }); + + it('getVPRecord queries by hash', async () => { + const db = makeDb({ getVpDocument: async (q) => ({ q }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.getVPRecord({ hash: 'VPH' }, 'auto', block); + assert.equal(out.q.hash.$eq, 'VPH'); + }); + + it('getVPRecord null without hash', async () => { + const db = makeDb({ getVpDocument: async () => ({}) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + assert.equal(await block.getVPRecord({}, 'auto', block), null); + }); + + it('getApprovalRecord fetches by id', async () => { + const db = makeDb({ getApprovalDocument: async (id) => ({ id }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.getApprovalRecord({ id: 'A1' }, 'auto', block); + assert.equal(out.id, 'A1'); + }); + + it('getApprovalRecord undefined without id', async () => { + const db = makeDb({ getApprovalDocument: async () => ({}) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + assert.equal(await block.getApprovalRecord({}, 'auto', block), undefined); + }); +}); + +describe('@unit exec SendToGuardianBlock.updateApproval/Did/VP records', () => { + afterEach(() => restoreStatics()); + + it('updateApprovalRecord updates existing approval', async () => { + const db = makeDb({ + getApprovalDocument: async () => ({ id: 'EXIST', existing: true }), + updateApproval: async (d) => ({ updated: d }), + }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateApprovalRecord({ id: 'EXIST', newField: 1 }, 'auto', block); + assert.equal(out.updated.newField, 1); + assert.equal(out.updated.existing, true); + }); + + it('updateApprovalRecord saves new approval, stripping id/_id', async () => { + const db = makeDb({ + getApprovalDocument: async () => null, + saveApproval: async (d) => d, + }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateApprovalRecord({ id: 'X', _id: 'Y', field: 2 }, 'auto', block); + assert.equal(out.id, undefined); + assert.equal(out._id, undefined); + assert.equal(out.field, 2); + }); + + it('updateDIDRecord updates existing did', async () => { + const db = makeDb({ + getVcDocument: async () => ({ did: 'd', existing: true }), + updateDid: async (d) => ({ updated: d }), + }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateDIDRecord({ did: 'd', extra: 1 }, 'auto', block); + assert.equal(out.updated.extra, 1); + }); + + it('updateDIDRecord saves new did when none exists', async () => { + const db = makeDb({ getVcDocument: async () => null, saveDid: async (d) => d }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateDIDRecord({ id: 'I', _id: 'J', did: 'd' }, 'auto', block); + assert.equal(out.id, undefined); + assert.equal(out._id, undefined); + }); + + it('updateVPRecord updates existing vp', async () => { + const db = makeDb({ + getVpDocument: async () => ({ hash: 'h', existing: true }), + updateVP: async (d) => ({ updated: d }), + }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateVPRecord({ hash: 'h', x: 3 }, 'auto', block); + assert.equal(out.updated.x, 3); + }); + + it('updateVPRecord saves new vp when none exists', async () => { + const db = makeDb({ getVpDocument: async () => null, saveVP: async (d) => d }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateVPRecord({ id: 'I', _id: 'J', hash: 'h' }, 'auto', block); + assert.equal(out.id, undefined); + assert.equal(out._id, undefined); + }); +}); + +describe('@unit exec SendToGuardianBlock.updateVCRecord', () => { + afterEach(() => restoreStatics()); + + it('updates existing VC via PolicyUtils.updateVC and saves state', async () => { + const updated = []; + const states = []; + patchUtils({ + updateVC: async (_ref, doc) => { updated.push(doc); return doc; }, + saveDocumentState: async (_ref, doc) => { states.push(doc); }, + }); + const db = makeDb({ getVcDocument: async () => ({ hash: 'h', existing: true }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateVCRecord({ hash: 'h', field: 1 }, 'auto', block, 'uid'); + assert.equal(out.field, 1); + assert.equal(updated.length, 1); + assert.equal(states.length, 1); + }); + + it('saves new VC when none exists and strips id/_id', async () => { + const saved = []; + patchUtils({ + saveVC: async (_ref, doc) => { saved.push(doc); return doc; }, + saveDocumentState: async () => {}, + }); + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.updateVCRecord({ id: 'I', _id: 'J', hash: 'h' }, 'auto', block, 'uid'); + assert.equal(out.id, undefined); + assert.equal(out._id, undefined); + assert.equal(saved.length, 1); + }); + + it('skips saveDocumentState when option skipSaveState is set', async () => { + const states = []; + patchUtils({ + saveVC: async (_ref, doc) => doc, + saveDocumentState: async (_ref, doc) => { states.push(doc); }, + }); + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(SendToGuardianBlock, { options: { skipSaveState: true }, componentsOverrides: { databaseServer: db } }); + await block.updateVCRecord({ hash: 'h' }, 'auto', block, 'uid'); + assert.equal(states.length, 0); + }); + + it('drops draftId on existing record when not a draft', async () => { + let savedOld; + patchUtils({ + updateVC: async (_ref, doc) => { savedOld = doc; return doc; }, + saveDocumentState: async () => {}, + }); + const db = makeDb({ getVcDocument: async () => ({ hash: 'h', draftId: 'OLD-DRAFT' }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.updateVCRecord({ hash: 'h', draftId: 'NEW-DRAFT' }, 'auto', block, 'uid'); + assert.equal(savedOld.draftId, undefined); + }); +}); + +describe('@unit exec SendToGuardianBlock.updateMessage family', () => { + afterEach(() => restoreStatics()); + + function mk(dbOverrides = {}) { + const db = makeDb(dbOverrides); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + return { block, db }; + } + + it('updateMessage routes DID to updateDIDMessage (no old -> seeds messageIds)', async () => { + const { block } = mk({ getVcDocument: async () => null }); + const out = await block.updateMessage({ messageId: 'm1', did: 'd' }, DocumentType.DID, block, 'uid'); + assert.deepEqual(out.messageIds, ['m1']); + }); + + it('updateMessage routes VC to updateVCMessage and updates the found record', async () => { + let updated; + patchUtils({ updateVC: async (_ref, doc) => { updated = doc; } }); + const { block } = mk({ getVcDocument: async () => ({ hash: 'h', messageIds: ['old'] }) }); + const out = await block.updateMessage({ hash: 'h', messageId: 'm2', topicId: 't', messageHash: 'mh' }, DocumentType.VerifiableCredential, block, 'uid'); + assert.equal(updated.messageId, 'm2'); + assert.deepEqual(updated.messageIds, ['old', 'm2']); + assert.equal(out.messageId, 'm2'); + }); + + it('updateVCMessage seeds messageIds when no old record', async () => { + const { block } = mk({ getVcDocument: async () => null }); + const out = await block.updateMessage({ hash: 'h', messageId: 'm3' }, DocumentType.VerifiableCredential, block, 'uid'); + assert.deepEqual(out.messageIds, ['m3']); + }); + + it('updateMessage routes VP to updateVPMessage', async () => { + const { block } = mk({ getVpDocument: async () => ({ hash: 'h', messageIds: ['p0'] }), updateVP: async () => {} }); + const out = await block.updateMessage({ hash: 'h', messageId: 'p1' }, DocumentType.VerifiablePresentation, block, 'uid'); + assert.equal(out.messageId, 'p1'); + }); + + it('updateMessage returns undefined for an unknown type', async () => { + const { block } = mk(); + assert.equal(await block.updateMessage({}, 'mystery', block, 'uid'), undefined); + }); +}); + +describe('@unit exec SendToGuardianBlock.sendByType', () => { + afterEach(() => restoreStatics()); + + function mk(options) { + const db = makeDb({ getVcDocument: async () => null, saveVP: async (d) => d }); + const { block } = makeBlock(SendToGuardianBlock, { options, componentsOverrides: { databaseServer: db } }); + return { block, db }; + } + + it('vc-documents routes to updateVCRecord', async () => { + patchUtils({ saveVC: async (_r, d) => ({ via: 'vc', d }), saveDocumentState: async () => {} }); + const { block } = mk({ dataType: 'vc-documents' }); + const out = await block.sendByType({ hash: 'h' }, block, 'uid'); + assert.equal(out.via, 'vc'); + }); + + it('did-documents routes to updateDIDRecord', async () => { + const db = makeDb({ getVcDocument: async () => null, saveDid: async (d) => ({ via: 'did', d }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: { dataType: 'did-documents' }, componentsOverrides: { databaseServer: db } }); + const out = await block.sendByType({ did: 'd' }, block, 'uid'); + assert.equal(out.via, 'did'); + }); + + it('approve routes to updateApprovalRecord', async () => { + const db = makeDb({ getApprovalDocument: async () => null, saveApproval: async (d) => ({ via: 'approve', d }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: { dataType: 'approve' }, componentsOverrides: { databaseServer: db } }); + const out = await block.sendByType({ id: 'x' }, block, 'uid'); + assert.equal(out.via, 'approve'); + }); + + it('throws BlockActionError for an unknown dataType', async () => { + const { block } = mk({ dataType: 'whoknows' }); + await assert.rejects(() => block.sendByType({}, block, 'uid'), /unknown/); + }); + + it('stamps documentFields from the policy cache', async () => { + patchUtils({ saveVC: async (_r, d) => d, saveDocumentState: async () => {} }); + const { block } = mk({ dataType: 'vc-documents' }); + const out = await block.sendByType({ hash: 'h' }, block, 'uid'); + assert.ok(Array.isArray(out.documentFields)); + }); +}); + +describe('@unit exec SendToGuardianBlock.sendToDatabase', () => { + afterEach(() => restoreStatics()); + + it('sets edited=false and seeds startMessageId from messageId', async () => { + let saved; + patchUtils({ saveVC: async (_r, d) => { saved = d; return d; }, saveDocumentState: async () => {} }); + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.sendToDatabase({ hash: 'h', messageId: 'mm' }, DocumentType.VerifiableCredential, block, 'uid'); + assert.equal(saved.edited, false); + assert.equal(saved.startMessageId, 'mm'); + }); + + it('routes DID type to updateDIDRecord', async () => { + const db = makeDb({ getVcDocument: async () => null, saveDid: async (d) => ({ did: true, d }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.sendToDatabase({ did: 'd' }, DocumentType.DID, block, 'uid'); + assert.equal(out.did, true); + }); + + it('routes VP type to updateVPRecord', async () => { + const db = makeDb({ getVpDocument: async () => null, saveVP: async (d) => ({ vp: true, d }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.sendToDatabase({ hash: 'h' }, DocumentType.VerifiablePresentation, block, 'uid'); + assert.equal(out.vp, true); + }); +}); + +describe('@unit exec SendToGuardianBlock.updateVersion', () => { + afterEach(() => restoreStatics()); + + it('marks old VC edited and updates when it belongs to the policy', async () => { + let updated; + patchUtils({ updateVC: async (_ref, doc) => { updated = doc; } }); + const db = makeDb({ getVcDocument: async () => ({ policyId: 'policy-1' }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.updateVersion({ id: 'x' }, 'uid'); + assert.equal(updated.edited, true); + }); + + it('does nothing when old VC belongs to another policy', async () => { + let called = false; + patchUtils({ updateVC: async () => { called = true; } }); + const db = makeDb({ getVcDocument: async () => ({ policyId: 'other' }) }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.updateVersion({ id: 'x' }, 'uid'); + assert.equal(called, false); + }); + + it('does nothing when no old VC is found', async () => { + let called = false; + patchUtils({ updateVC: async () => { called = true; } }); + const db = makeDb({ getVcDocument: async () => null }); + const { block } = makeBlock(SendToGuardianBlock, { options: {}, componentsOverrides: { databaseServer: db } }); + await block.updateVersion({ id: 'x' }, 'uid'); + assert.equal(called, false); + }); +}); + +describe('@unit exec SendToGuardianBlock.runAction', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}) { + const { block } = makeBlock(SendToGuardianBlock, { options }); + const events = captureEvents(block); + const sent = []; + block.documentSender = async (doc) => { sent.push(doc); return { ...doc, processed: true }; }; + patchUtils({ getBlockTags: async () => [], setDocumentTags: () => {} }); + return { block, events, sent }; + } + + it('processes a single document and triggers Run/Refresh events', async () => { + const { block, events } = mk(); + const out = await block.runAction({ user: makeUser(), data: { data: { hash: 'h' } } }); + assert.equal(out.data.processed, true); + const types = events.map((e) => e[0]); + assert.ok(types.includes('RunEvent')); + assert.ok(types.includes('RefreshEvent')); + }); + + it('processes an array of documents', async () => { + const { block, sent } = mk(); + const out = await block.runAction({ user: makeUser(), data: { data: [{ hash: 'a' }, { hash: 'b' }] } }); + assert.equal(sent.length, 2); + assert.equal(out.data.length, 2); + assert.ok(out.data.every((d) => d.processed)); + }); + + it('updateVersion is invoked for a single old document', async () => { + const { block } = mk(); + const updated = []; + block.updateVersion = async (old) => updated.push(old); + await block.runAction({ user: makeUser(), data: { data: { hash: 'h' }, old: { id: 'o1' } } }); + assert.equal(updated.length, 1); + }); + + it('updateVersion is invoked for each old document in an array', async () => { + const { block } = mk(); + const updated = []; + block.updateVersion = async (old) => updated.push(old); + await block.runAction({ user: makeUser(), data: { data: { hash: 'h' }, old: [{ id: 'o1' }, { id: 'o2' }] } }); + assert.equal(updated.length, 2); + }); + + it('returns event.data', async () => { + const { block } = mk(); + const ev = { user: makeUser(), data: { data: { hash: 'h' } } }; + const out = await block.runAction(ev); + assert.equal(out, ev.data); + }); +}); + +describe('@unit exec RevokeBlock / RevocationBlock helpers', () => { + afterEach(() => restoreStatics()); + + for (const [name, Block] of [['RevokeBlock', RevokeBlock], ['RevocationBlock', RevocationBlock]]) { + describe(`${name}.findRelatedMessageIds`, () => { + const mk = () => makeBlock(Block, { options: {} }).block; + + it('throws when the seed topic message is empty', async () => { + await assert.rejects(() => mk().findRelatedMessageIds(null, []), /empty/); + }); + + it('returns a single entry with undefined parentIds for an isolated message', async () => { + const out = await mk().findRelatedMessageIds({ id: 'a' }, [{ id: 'a' }]); + assert.equal(out.length, 1); + assert.equal(out[0].id, 'a'); + assert.equal(out[0].parentIds, undefined); + }); + + it('walks relationships and records parentIds', async () => { + const msgs = [ + { id: 'a', relationships: [] }, + { id: 'b', relationships: ['a'] }, + { id: 'c', relationships: ['b'] }, + ]; + const out = await mk().findRelatedMessageIds(msgs[0], msgs); + const ids = out.map((x) => x.id).sort(); + assert.deepEqual(ids, ['a', 'b', 'c']); + const b = out.find((x) => x.id === 'b'); + assert.deepEqual(b.parentIds, ['a']); + }); + + it('merges multiple parents for a shared child', async () => { + const msgs = [ + { id: 'a', relationships: [] }, + { id: 'b', relationships: [] }, + { id: 'c', relationships: ['a', 'b'] }, + ]; + const seed = { id: 'root' }; + msgs.push({ id: 'root', relationships: [] }); + const all = [ + { id: 'root', relationships: [] }, + { id: 'a', relationships: ['root'] }, + { id: 'b', relationships: ['root'] }, + { id: 'c', relationships: ['a', 'b'] }, + ]; + const out = await mk().findRelatedMessageIds(all[0], all); + const c = out.find((x) => x.id === 'c'); + assert.deepEqual(c.parentIds.sort(), ['a', 'b']); + }); + }); + + describe(`${name}.findDocumentByMessageIds`, () => { + it('queries VC/VP/DID with an $in filter and concatenates results', async () => { + let vcArgs; + const db = makeDb({ + getVcDocuments: async (filters, other) => { vcArgs = [filters, other]; return [{ t: 'vc' }]; }, + getVpDocuments: async () => [{ t: 'vp' }], + getDidDocuments: async () => [{ t: 'did' }], + }); + const { block } = makeBlock(Block, { options: {}, componentsOverrides: { databaseServer: db } }); + const out = await block.findDocumentByMessageIds(['m1', 'm2']); + assert.deepEqual(out.map((d) => d.t), ['vc', 'vp', 'did']); + assert.deepEqual(vcArgs[0].messageId.$in, ['m1', 'm2']); + assert.deepEqual(vcArgs[1].orderBy, { messageId: 'ASC' }); + }); + + it('returns empty array when nothing matches', async () => { + const db = makeDb({ + getVcDocuments: async () => [], + getVpDocuments: async () => [], + getDidDocuments: async () => [], + }); + const { block } = makeBlock(Block, { options: {}, componentsOverrides: { databaseServer: db } }); + assert.deepEqual(await block.findDocumentByMessageIds(['x']), []); + }); + }); + } +}); + +describe('@unit exec RevokeBlock.runAction', () => { + afterEach(() => restoreStatics()); + + function fakeMessage(id, relationships, revoked = false) { + return { + id, + relationships, + _revoked: revoked, + _revokeArgs: null, + isRevoked() { return this._revoked; }, + revoke(comment, did, parentIds) { this._revoked = true; this._revokeArgs = { comment, did, parentIds }; }, + }; + } + + function setup(opts = {}) { + const messages = opts.messages || [fakeMessage('m1', [])]; + const docs = opts.docs || [{ messageId: 'm1', option: {} }]; + const db = makeDb({ + getTopics: async () => [{ topicId: '0.0.9' }], + getVcDocuments: async () => docs, + getVpDocuments: async () => [], + getDidDocuments: async () => [], + }); + const { block } = makeBlock(RevokeBlock, { options: opts.options || {}, componentsOverrides: { databaseServer: db } }); + const events = captureEvents(block); + const sent = []; + patchMsg({ getMessages: async () => messages }); + patchUtils({ + getDocumentRelayerAccount: async () => null, + updateVC: async () => {}, + saveDocumentState: async () => {}, + }); + patchActions({ sendMessages: async (o) => { sent.push(o); } }); + return { block, events, sent, db, messages }; + } + + it('revokes the target message and sends update messages', async () => { + const { block, sent, messages } = setup(); + await block.runAction({ + user: makeUser({ did: 'did:revoker' }), + data: { data: { messageId: 'm1', owner: 'did:o', option: { comment: ['c'] } } }, + }); + assert.equal(messages[0]._revoked, true); + assert.equal(messages[0]._revokeArgs.did, 'did:revoker'); + assert.equal(sent.length, 1); + assert.equal(sent[0].messages.length, 1); + }); + + it('takes the first element when data is an array', async () => { + const { block, messages } = setup(); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: [{ messageId: 'm1', owner: 'o', option: { comment: [] } }, { messageId: 'zzz' }] }, + }); + assert.equal(messages[0]._revoked, true); + }); + + it('triggers Run and Release events', async () => { + const { block, events } = setup(); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: [] } } }, + }); + const types = events.map((e) => e[0]); + assert.ok(types.includes('RunEvent')); + assert.ok(types.includes('ReleaseEvent')); + }); + + it('stamps Revoked status onto matched documents', async () => { + const docs = [{ messageId: 'm1', option: {} }]; + const { block } = setup({ docs }); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: ['note'] } } }, + }); + assert.equal(docs[0].option.status, 'Revoked'); + }); + + it('still re-revokes the seed message even if already revoked (seed is always added)', async () => { + const messages = [fakeMessage('m1', [], true)]; + const { block, sent } = setup({ messages }); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: [] } } }, + }); + assert.equal(sent[0].messages.length, 1); + }); + + it('updatePrevDoc updates a previous document status via uiMetaData', async () => { + const prev = { option: {} }; + const db = makeDb({ + getTopics: async () => [{ topicId: '0.0.9' }], + getVcDocuments: async (filters) => (filters.messageId.$in.includes('rel-1') ? [prev] : [{ messageId: 'm1', option: {} }]), + getVpDocuments: async () => [], + getDidDocuments: async () => [], + }); + const { block } = makeBlock(RevokeBlock, { + options: { uiMetaData: { updatePrevDoc: true, prevDocStatus: 'Archived' } }, + componentsOverrides: { databaseServer: db }, + }); + captureEvents(block); + patchMsg({ getMessages: async () => [fakeMessage('m1', [])] }); + const updatedPrev = []; + patchUtils({ + getDocumentRelayerAccount: async () => null, + updateVC: async (_r, d) => updatedPrev.push(d), + saveDocumentState: async () => {}, + }); + patchActions({ sendMessages: async () => {} }); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: [] }, relationships: ['rel-1'] } }, + }); + assert.equal(updatedPrev.length, 1); + assert.equal(prev.option.status, 'Archived'); + }); +}); + +describe('@unit exec RevocationBlock.runAction', () => { + afterEach(() => restoreStatics()); + + function fakeMessage(id, relationships, revoked = false) { + return { + id, + relationships, + _revoked: revoked, + isRevoked() { return this._revoked; }, + revoke() { this._revoked = true; }, + }; + } + + function setup(options = {}) { + const docs = [{ messageId: 'm1', option: {} }]; + const db = makeDb({ + getTopics: async () => [{ topicId: '0.0.9' }], + getVcDocuments: async () => docs, + getVpDocuments: async () => [], + getDidDocuments: async () => [], + }); + const { block } = makeBlock(RevocationBlock, { options, componentsOverrides: { databaseServer: db } }); + const events = captureEvents(block); + patchMsg({ getMessages: async () => [fakeMessage('m1', [])] }); + patchUtils({ + getDocumentRelayerAccount: async () => null, + updateVC: async () => {}, + saveDocumentState: async () => {}, + }); + const sent = []; + patchActions({ sendMessages: async (o) => sent.push(o) }); + return { block, events, sent, docs }; + } + + it('revokes and triggers Run/Release events', async () => { + const { block, events } = setup(); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: [] } } }, + }); + const types = events.map((e) => e[0]); + assert.ok(types.includes('RunEvent')); + assert.ok(types.includes('ReleaseEvent')); + }); + + it('uses block options.updatePrevDoc (not uiMetaData) for the prev-doc branch', async () => { + const prev = { option: {} }; + const db = makeDb({ + getTopics: async () => [{ topicId: '0.0.9' }], + getVcDocuments: async (filters) => (filters.messageId.$in.includes('rel-1') ? [prev] : [{ messageId: 'm1', option: {} }]), + getVpDocuments: async () => [], + getDidDocuments: async () => [], + }); + const { block } = makeBlock(RevocationBlock, { + options: { updatePrevDoc: true, prevDocStatus: 'Reset' }, + componentsOverrides: { databaseServer: db }, + }); + captureEvents(block); + patchMsg({ getMessages: async () => [fakeMessage('m1', [])] }); + const updated = []; + patchUtils({ + getDocumentRelayerAccount: async () => null, + updateVC: async (_r, d) => updated.push(d), + saveDocumentState: async () => {}, + }); + patchActions({ sendMessages: async () => {} }); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: [] }, relationships: ['rel-1'] } }, + }); + assert.equal(updated.length, 1); + assert.equal(prev.option.status, 'Reset'); + }); + + it('stamps Revoked status onto matched documents', async () => { + const { block, docs } = setup(); + await block.runAction({ + user: makeUser({ did: 'd' }), + data: { data: { messageId: 'm1', owner: 'o', option: { comment: ['n'] } } }, + }); + assert.equal(docs[0].option.status, 'Revoked'); + }); +}); + +describe('@unit exec UploadVcDocumentBlock.getData', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}, extra = {}) { + return makeBlock(UploadVcDocumentBlock, { options, ...extra }).block; + } + + it('returns id/blockType/actionType envelope', async () => { + const d = await mk({}, { uuid: 'up-1' }).getData(makeUser()); + assert.equal(d.id, 'up-1'); + assert.equal(d.blockType, 'uploadVcDocumentBlock'); + assert.equal(d.actionType, LocationType.REMOTE); + }); + + it('defaults uiMetaData to {}', async () => { + const d = await mk().getData(makeUser()); + assert.deepEqual(d.uiMetaData, {}); + }); + + it('passes through uiMetaData', async () => { + const d = await mk({ uiMetaData: { type: 'page' } }).getData(makeUser()); + assert.deepEqual(d.uiMetaData, { type: 'page' }); + }); + + it('readonly true for a remote user', async () => { + assert.equal((await mk().getData(makeUser({ location: LocationType.REMOTE }))).readonly, true); + }); + + it('readonly false for a local user', async () => { + assert.equal((await mk().getData(makeUser({ location: LocationType.LOCAL }))).readonly, false); + }); +}); + +describe('@unit exec UploadVcDocumentBlock.setData', () => { + afterEach(() => restoreStatics()); + + function mk(options = {}) { + const { block } = makeBlock(UploadVcDocumentBlock, { options }); + block.tenantContext = { tenantId: 'tenant-1' }; + const events = captureEvents(block); + return { block, events }; + } + + it('throws when user has no did', async () => { + const { block } = mk(); + await assert.rejects( + () => block.setData(makeUser({ did: null }), { documents: [] }, null, null), + /did/ + ); + }); + + it('verified documents land in retArray, invalid ones in badArray', async () => { + const { block } = mk({ entityType: 'E', schema: '#S' }); + patchUtils({ + getBlockTags: async () => [], + setDocumentTags: () => {}, + createVC: (_ref, user, vc) => ({ owner: user.did, document: vc }), + getErrorMessage: (e) => String(e), + }); + const goodDoc = { ok: true }; + const badDoc = { ok: false }; + const ret = await runWithStubbedVerification(block, [goodDoc, badDoc], (doc) => doc.ok); + assert.equal(ret.verified.length, 1); + assert.equal(ret.invalid.length, 1); + assert.equal(ret.invalid[0], badDoc); + }); + + it('marks verified docs with VERIFIED signature, schema and entityType', async () => { + const { block } = mk({ entityType: 'EType', schema: '#Sch' }); + patchUtils({ + getBlockTags: async () => [], + setDocumentTags: () => {}, + createVC: (_ref, user, vc) => ({ owner: user.did, document: vc }), + getErrorMessage: (e) => String(e), + }); + const ret = await runWithStubbedVerification(block, [{ ok: true }], () => true); + assert.equal(ret.verified[0].signature, DocumentSignature.VERIFIED); + assert.equal(ret.verified[0].schema, '#Sch'); + assert.equal(ret.verified[0].type, 'EType'); + }); + + it('triggers Run/Refresh/Release events', async () => { + const { block, events } = mk(); + patchUtils({ + getBlockTags: async () => [], + setDocumentTags: () => {}, + createVC: (_ref, user, vc) => ({ owner: user.did, document: vc }), + getErrorMessage: (e) => String(e), + }); + await runWithStubbedVerification(block, [{ ok: true }], () => true); + const types = events.map((e) => e[0]); + assert.ok(types.includes('RunEvent')); + assert.ok(types.includes('RefreshEvent')); + assert.ok(types.includes('ReleaseEvent')); + }); + + it('all-invalid input yields empty verified list', async () => { + const { block } = mk(); + patchUtils({ + getBlockTags: async () => [], + setDocumentTags: () => {}, + getErrorMessage: (e) => String(e), + }); + const ret = await runWithStubbedVerification(block, [{ ok: false }, { ok: false }], () => false); + assert.equal(ret.verified.length, 0); + assert.equal(ret.invalid.length, 2); + }); +}); + +async function runWithStubbedVerification(block, documents, verifyFn) { + const origFromJsonTree = VcDocumentRef.fromJsonTree; + VcDocumentRef.fromJsonTree = (d) => d; + const origProto = VcHelper.prototype; + const origVerifySchema = origProto.verifySchema; + const origVerifyVC = origProto.verifyVC; + origProto.verifySchema = async function (doc) { return { ok: verifyFn(doc) }; }; + origProto.verifyVC = async function (doc) { return verifyFn(doc); }; + try { + return await block.setData(makeUser(), { documents }, null, null); + } finally { + VcDocumentRef.fromJsonTree = origFromJsonTree; + origProto.verifySchema = origVerifySchema; + origProto.verifyVC = origVerifyVC; + } +} diff --git a/policy-service/tests/unit-tests/blocks/exec-ui-blocks.test.mjs b/policy-service/tests/unit-tests/blocks/exec-ui-blocks.test.mjs new file mode 100644 index 0000000000..8e1e07cc1d --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/exec-ui-blocks.test.mjs @@ -0,0 +1,1370 @@ +import assert from 'node:assert/strict'; +import { LocationType } from '@guardian/interfaces'; +import { + makeBlock, + makeUser, + makeDb, + makeComponents, + makePolicy, + restoreHarness, +} from './_block-exec-harness.mjs'; +import { InformationBlock } from '../../../dist/policy-engine/blocks/information-block.js'; +import { ButtonBlock } from '../../../dist/policy-engine/blocks/button-block.js'; +import { ButtonBlockAddon } from '../../../dist/policy-engine/blocks/button-block-addon.js'; +import { DropdownBlockAddon } from '../../../dist/policy-engine/blocks/dropdown-block-addon.js'; +import { PaginationAddon } from '../../../dist/policy-engine/blocks/pagination-addon.js'; +import { InterfaceDocumentsSource } from '../../../dist/policy-engine/blocks/documents-source.js'; +import { DocumentsSourceAddon } from '../../../dist/policy-engine/blocks/documents-source-addon.js'; +import { SelectiveAttributes } from '../../../dist/policy-engine/blocks/selective-attributes-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const _orig = {}; +function silenceSideEffects() { + _orig.ExternalEventFn = PolicyComponentsUtils.ExternalEventFn; + _orig.BlockUpdateFn = PolicyComponentsUtils.BlockUpdateFn; + PolicyComponentsUtils.ExternalEventFn = async () => {}; + PolicyComponentsUtils.BlockUpdateFn = () => {}; +} +function restoreSideEffects() { + if (_orig.ExternalEventFn) PolicyComponentsUtils.ExternalEventFn = _orig.ExternalEventFn; + if (_orig.BlockUpdateFn) PolicyComponentsUtils.BlockUpdateFn = _orig.BlockUpdateFn; +} + +function captureEvents(block) { + const captured = []; + block.triggerEvents = async (...a) => { captured.push(a); return []; }; + block.triggerEvent = (...a) => { captured.push(a); }; + return captured; +} + +function recDb(returns = {}) { + const calls = []; + const wrap = (name) => async (...args) => { + calls.push({ name, args }); + const r = returns[name]; + return typeof r === 'function' ? r(...args) : r; + }; + const names = [ + 'getVcDocuments', 'getDidDocuments', 'getVpDocuments', + 'getApprovalDocuments', 'getVPMintInformation', + ]; + const db = makeDb(); + db.__calls = calls; + for (const n of names) db[n] = wrap(n); + return db; +} + +function fakeParent(over = {}) { + return { uuid: 'parent-1', registerChild() {}, isChildActive() { return true; }, ...over }; +} + +describe('@unit exec-ui InformationBlock', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('getData returns the canonical envelope', async () => { + const { block } = makeBlock(InformationBlock, { + uuid: 'info-x', + options: { uiMetaData: { title: 'T', description: 'D' } }, + }); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'info-x'); + assert.equal(d.blockType, 'informationBlock'); + assert.equal(d.actionType, LocationType.LOCAL); + assert.deepEqual(d.uiMetaData, { title: 'T', description: 'D' }); + }); + + it('getData carries uiMetaData by reference from options', async () => { + const uiMetaData = { title: 'ref' }; + const { block } = makeBlock(InformationBlock, { options: { uiMetaData } }); + const d = await block.getData(makeUser()); + assert.equal(d.uiMetaData, uiMetaData); + }); + + it('getData uiMetaData is undefined when options has none', async () => { + const { block } = makeBlock(InformationBlock, { options: {} }); + const d = await block.getData(makeUser()); + assert.equal(d.uiMetaData, undefined); + }); + + it('LOCAL block stays non-readonly for a LOCAL user', async () => { + const { block } = makeBlock(InformationBlock, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.LOCAL })); + assert.equal(d.readonly, false); + }); + + it('LOCAL block stays non-readonly even for a REMOTE user', async () => { + const { block } = makeBlock(InformationBlock, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(d.readonly, false); + }); + + it('blockType static equals informationBlock', () => { + assert.equal(InformationBlock.blockType, 'informationBlock'); + }); + + it('about.control is UI and post is false', () => { + assert.equal(InformationBlock.about.control, 'UI'); + assert.equal(InformationBlock.about.post, false); + assert.equal(InformationBlock.about.get, true); + }); + + it('getOptions(null) returns the raw options', async () => { + const { block } = makeBlock(InformationBlock, { options: { a: 1, uiMetaData: {} } }); + const o = await block.getOptions(null); + assert.deepEqual(o, { a: 1, uiMetaData: {} }); + }); + + it('getOptions(user) returns options when no editable settings', async () => { + const { block } = makeBlock(InformationBlock, { options: { z: 9 } }); + const o = await block.getOptions(makeUser()); + assert.deepEqual(o, { z: 9 }); + }); + + it('exposes uuid/tag/options from construction', () => { + const { block } = makeBlock(InformationBlock, { uuid: 'u', tag: 't', options: { k: 'v' } }); + assert.equal(block.uuid, 'u'); + assert.equal(block.tag, 't'); + assert.deepEqual(block.options, { k: 'v' }); + }); + + it('getData id matches the constructed uuid', async () => { + const { block } = makeBlock(InformationBlock, { uuid: 'id-xyz', options: {} }); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'id-xyz'); + }); + + it('getData blockType is always informationBlock regardless of tag', async () => { + const { block } = makeBlock(InformationBlock, { tag: 'custom', options: {} }); + const d = await block.getData(makeUser()); + assert.equal(d.blockType, 'informationBlock'); + }); + + it('about exposes the RunEvent and RefreshEvent inputs', () => { + assert.ok(Array.isArray(InformationBlock.about.input)); + assert.equal(InformationBlock.about.input.length, 2); + }); +}); + +describe('@unit exec-ui ButtonBlock', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('is a REMOTE actionType block', () => { + assert.equal(ButtonBlock.blockType, 'buttonBlock'); + const { block } = makeBlock(ButtonBlock, { options: {} }); + assert.equal(block.actionType, LocationType.REMOTE); + }); + + it('getData copies type/uiMetaData/user from options', async () => { + const { block } = makeBlock(ButtonBlock, { + options: { type: 'selector', uiMetaData: { x: 1 }, user: 'owner' }, + }); + const d = await block.getData(makeUser()); + assert.equal(d.type, 'selector'); + assert.deepEqual(d.uiMetaData, { x: 1 }); + assert.equal(d.user, 'owner'); + }); + + it('getData echoes userId/userDid from the calling user', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const d = await block.getData(makeUser({ userId: 'uid-7', did: 'did:abc' })); + assert.equal(d.userId, 'uid-7'); + assert.equal(d.userDid, 'did:abc'); + }); + + it('getData readonly true when REMOTE block + REMOTE user', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(d.readonly, true); + }); + + it('getData readonly false when REMOTE block + LOCAL user', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.LOCAL })); + assert.equal(d.readonly, false); + }); + + it('getData id/blockType reflect construction', async () => { + const { block } = makeBlock(ButtonBlock, { uuid: 'btn-1', options: {} }); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'btn-1'); + assert.equal(d.blockType, 'buttonBlock'); + }); + + it('setData fires triggerEvents with the document state and tag', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const captured = captureEvents(block); + const user = makeUser(); + await block.setData(user, { document: { id: 'd1' }, tag: 'go' }, null, null); + assert.equal(captured.length, 1); + const [tag, evUser, state] = captured[0]; + assert.equal(tag, 'go'); + assert.equal(evUser, user); + assert.deepEqual(state, { data: { id: 'd1' } }); + }); + + it('setData wraps the document inside state.data', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const captured = captureEvents(block); + const doc = { foo: 'bar' }; + await block.setData(makeUser(), { document: doc, tag: 'submit' }, null, null); + assert.equal(captured[0][2].data, doc); + }); + + it('setData forwards actionStatus to triggerEvents', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const captured = captureEvents(block); + const status = { id: 'status-1' }; + await block.setData(makeUser(), { document: {}, tag: 'x' }, null, status); + assert.equal(captured[0][3], status); + }); + + it('setData with undefined document still triggers', async () => { + const { block } = makeBlock(ButtonBlock, { options: {} }); + const captured = captureEvents(block); + await block.setData(makeUser(), { document: undefined, tag: 't' }, null, null); + assert.equal(captured.length, 1); + assert.deepEqual(captured[0][2], { data: undefined }); + }); +}); + +describe('@unit exec-ui ButtonBlockAddon', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('getData spreads options into the envelope', async () => { + const { block } = makeBlock(ButtonBlockAddon, { + uuid: 'ba-1', + options: { name: 'Approve', uiClass: 'btn-primary', dialog: false }, + }); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'ba-1'); + assert.equal(d.blockType, 'buttonBlockAddon'); + assert.equal(d.name, 'Approve'); + assert.equal(d.uiClass, 'btn-primary'); + assert.equal(d.dialog, false); + }); + + it('getData readonly true for REMOTE block + REMOTE user', async () => { + const { block } = makeBlock(ButtonBlockAddon, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(d.readonly, true); + }); + + it('getData readonly false for LOCAL user', async () => { + const { block } = makeBlock(ButtonBlockAddon, { options: {} }); + const d = await block.getData(makeUser({ location: LocationType.LOCAL })); + assert.equal(d.readonly, false); + }); + + it('getData spread does not override id/blockType/actionType', async () => { + const { block } = makeBlock(ButtonBlockAddon, { + uuid: 'keep', + options: { id: 'should-be-overwritten-first', extra: 1 }, + }); + const d = await block.getData(makeUser()); + assert.equal(d.extra, 1); + assert.equal(d.id, 'should-be-overwritten-first'); + }); + + it('setData (no dialog) calls parent.onAddonEvent with identity handler result', async () => { + const calls = []; + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent(user, tag, documentId, handler) { + const doc = { id: documentId, payload: 1 }; + const res = await handler(doc); + calls.push({ user, tag, documentId, res }); + }, + }; + const { block } = makeBlock(ButtonBlockAddon, { + tag: 'addon-tag', + options: { dialog: false }, + parent, + }); + await block.setData(makeUser(), { documentId: 'doc-9', dialogResult: 'ignored' }, null, null); + assert.equal(calls.length, 1); + assert.equal(calls[0].tag, 'addon-tag'); + assert.equal(calls[0].documentId, 'doc-9'); + assert.deepEqual(calls[0].res, { data: { id: 'doc-9', payload: 1 } }); + }); + + it('setData (dialog) injects dialogResult at dialogResultFieldPath', async () => { + let result; + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent(user, tag, documentId, handler) { + result = await handler({ id: documentId, option: {} }); + }, + }; + const { block } = makeBlock(ButtonBlockAddon, { + options: { + dialog: true, + dialogOptions: { dialogResultFieldPath: 'option.comment' }, + }, + parent, + }); + await block.setData(makeUser(), { documentId: 'd', dialogResult: 'hello' }, null, null); + assert.equal(result.data.option.comment, 'hello'); + }); + + it('setData (dialog) creates nested path when absent', async () => { + let result; + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent(user, tag, documentId, handler) { + result = await handler({ id: documentId }); + }, + }; + const { block } = makeBlock(ButtonBlockAddon, { + options: { + dialog: true, + dialogOptions: { dialogResultFieldPath: 'a.b.c' }, + }, + parent, + }); + await block.setData(makeUser(), { documentId: 'd', dialogResult: 42 }, null, null); + assert.equal(result.data.a.b.c, 42); + }); + + it('setData propagates parent.onAddonEvent rejection', async () => { + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent() { throw new Error('addon boom'); }, + }; + const { block } = makeBlock(ButtonBlockAddon, { options: { dialog: false }, parent }); + await assert.rejects( + () => block.setData(makeUser(), { documentId: 'd' }, null, null), + /addon boom/ + ); + }); +}); + +describe('@unit exec-ui DropdownBlockAddon', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('getData maps sources to {name, optionValue, value}', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + uuid: 'dd-1', + options: { optionName: 'option.label', optionValue: 'option.code', field: 'document.choice' }, + }); + block.getSources = async () => ([ + { id: 'A', option: { label: 'Alpha', code: 'a' } }, + { id: 'B', option: { label: 'Beta', code: 'b' } }, + ]); + const d = await block.getData(makeUser()); + assert.equal(d.id, 'dd-1'); + assert.equal(d.blockType, 'dropdownBlockAddon'); + assert.deepEqual(d.documents, [ + { name: 'Alpha', optionValue: 'a', value: 'A' }, + { name: 'Beta', optionValue: 'b', value: 'B' }, + ]); + }); + + it('getData spreads options alongside documents', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'n', optionValue: 'v', field: 'f', uiClass: 'c' }, + }); + block.getSources = async () => []; + const d = await block.getData(makeUser()); + assert.equal(d.uiClass, 'c'); + assert.deepEqual(d.documents, []); + }); + + it('getData maps single-segment missing path to undefined name', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'missing', optionValue: 'x', field: 'f' }, + }); + block.getSources = async () => ([{ id: 'Z', option: {} }]); + const d = await block.getData(makeUser()); + assert.equal(d.documents[0].name, undefined); + assert.equal(d.documents[0].value, 'Z'); + }); + + it('getData throws when optionName path traverses through undefined (latent: findOptions has no guard)', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'missing.path', optionValue: 'x', field: 'f' }, + }); + block.getSources = async () => ([{ id: 'Z', option: {} }]); + await assert.rejects(() => block.getData(makeUser()), /Cannot read properties of undefined/); + }); + + it('getData readonly true REMOTE block + REMOTE user', async () => { + const { block } = makeBlock(DropdownBlockAddon, { options: { optionName: 'a', optionValue: 'b', field: 'c' } }); + block.getSources = async () => []; + const d = await block.getData(makeUser({ location: LocationType.REMOTE })); + assert.equal(d.readonly, true); + }); + + it('setData throws when dropdown document is not in sources', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'n', optionValue: 'v', field: 'f' }, + }); + block.getSources = async () => ([{ id: 'X' }]); + await assert.rejects( + () => block.setData(makeUser(), { documentId: 'd', dropdownDocumentId: 'NOPE' }, null, null), + /Document doesn't exist in dropdown options/ + ); + }); + + it('setData applies the selected optionValue onto field via parent', async () => { + let result; + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent(user, tag, documentId, handler) { + result = await handler({ id: documentId, document: {} }); + }, + }; + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'name', optionValue: 'option.code', field: 'document.selected' }, + parent, + }); + block.getSources = async () => ([ + { id: 'sel-1', option: { code: 'CODE-1' } }, + ]); + await block.setData(makeUser(), { documentId: 'target', dropdownDocumentId: 'sel-1' }, null, null); + assert.equal(result.data.document.selected, 'CODE-1'); + }); + + it('setData finds the matching dropdown doc by id', async () => { + let documentIdSeen; + const parent = { + registerChild() {}, + isChildActive() { return true; }, + async onAddonEvent(user, tag, documentId, handler) { + documentIdSeen = documentId; + await handler({ id: documentId, document: {} }); + }, + }; + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'n', optionValue: 'option.v', field: 'document.f' }, + parent, + }); + block.getSources = async () => ([ + { id: 'one', option: { v: '1' } }, + { id: 'two', option: { v: '2' } }, + ]); + await block.setData(makeUser(), { documentId: 'doc', dropdownDocumentId: 'two' }, null, null); + assert.equal(documentIdSeen, 'doc'); + }); +}); + +describe('@unit exec-ui PaginationAddon', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + function makePagination(count, over = {}) { + const holder = { count }; + const parent = { + registerChild() {}, + getGlobalSources: async () => holder.count, + }; + const { block } = makeBlock(PaginationAddon, { parent, ...over }); + return { block, holder }; + } + + it('getState seeds default state for a new user', async () => { + const { block } = makePagination(55, { uuid: 'pg-1', options: {} }); + const s = await block.getState(makeUser({ id: 'u1' })); + assert.equal(s.id, 'pg-1'); + assert.equal(s.blockType, 'paginationAddon'); + assert.equal(s.itemsPerPage, 10); + assert.equal(s.page, 0); + assert.equal(s.size, 55); + }); + + it('getState reuses totalCount from parent getGlobalSources', async () => { + const { block } = makePagination(7, { options: {} }); + const s = await block.getState(makeUser({ id: 'u2' })); + assert.equal(s.size, 7); + }); + + it('getState updates size when total changes', async () => { + const { block, holder } = makePagination(30, { options: {} }); + const user = makeUser({ id: 'u3' }); + await block.getState(user); + holder.count = 99; + const s = await block.getState(user); + assert.equal(s.size, 99); + }); + + it('setState records page/itemsPerPage and refreshes size', async () => { + const { block } = makePagination(40, { options: {} }); + const user = makeUser({ id: 'u4' }); + await block.setState(user, { size: 0, itemsPerPage: 25, page: 2 }); + const s = await block.getState(user); + assert.equal(s.itemsPerPage, 25); + assert.equal(s.page, 2); + assert.equal(s.size, 40); + }); + + it('resetPagination restores the previous state after setState', async () => { + const { block } = makePagination(12, { options: {} }); + const user = makeUser({ id: 'u5' }); + await block.getState(user); + await block.setState(user, { size: 0, itemsPerPage: 5, page: 1 }); + await block.resetPagination(user); + const s = await block.getState(user); + assert.equal(s.itemsPerPage, 10); + assert.equal(s.page, 0); + }); + + it('resetPagination is a no-op without a prior setState', async () => { + const { block } = makePagination(3, { options: {} }); + const user = makeUser({ id: 'u6' }); + await block.getState(user); + await block.resetPagination(user); + const s = await block.getState(user); + assert.equal(s.page, 0); + }); + + it('getData delegates to getState', async () => { + const { block } = makePagination(10, { uuid: 'pg-d', options: {} }); + const d = await block.getData(makeUser({ id: 'u7' })); + assert.equal(d.id, 'pg-d'); + assert.equal(d.itemsPerPage, 10); + }); + + it('setData stores raw data state per user', async () => { + const { block } = makePagination(8, { options: {} }); + const user = makeUser({ id: 'u8' }); + await block.setData(user, { itemsPerPage: 50, page: 3 }); + const s = await block.getState(user); + assert.equal(s.itemsPerPage, 50); + assert.equal(s.page, 3); + }); + + it('per-user state is isolated', async () => { + const { block } = makePagination(20, { options: {} }); + const a = makeUser({ id: 'a' }); + const b = makeUser({ id: 'b' }); + await block.setState(a, { size: 0, itemsPerPage: 5, page: 4 }); + const sb = await block.getState(b); + assert.equal(sb.page, 0); + assert.equal(sb.itemsPerPage, 10); + }); + + it('readonly is false for LOCAL pagination block', async () => { + const { block } = makePagination(1, { options: {} }); + const d = await block.getState(makeUser({ id: 'ro', location: LocationType.REMOTE })); + assert.equal(d.actionType, LocationType.LOCAL); + assert.equal(d.readonly, false); + }); +}); + +describe('@unit exec-ui DocumentsSourceAddon getFromSource', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('throws when filters option is not an array', async () => { + const db = makeDb(); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: 'nope' }, + componentsOverrides: { databaseServer: db }, + }); + await assert.rejects( + () => block.getFromSource(makeUser(), null, false, null), + /filters option must be an array/ + ); + }); + + it('vc-documents query injects policyId and reads getVcDocuments', async () => { + const db = recDb({ getVcDocuments: async () => [{ id: 'v1', option: {} }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.ok(call); + assert.equal(call.args[0].policyId, 'policy-1'); + assert.equal(call.args[0].initId.$exists, false); + assert.equal(out[0].id, 'v1'); + }); + + it('tags returned documents with __sourceTag__ from ref.tag', async () => { + const db = recDb({ getVcDocuments: async () => [{ id: 'v1', option: {} }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + tag: 'mysource', + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, false, null); + assert.equal(out[0].__sourceTag__, 'mysource'); + }); + + it('onlyOwnDocuments sets owner filter to user.did', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], onlyOwnDocuments: true }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser({ did: 'did:me' }), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].owner, 'did:me'); + }); + + it('onlyAssignDocuments sets assignedTo filter to user.did', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], onlyAssignDocuments: true }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser({ did: 'did:me' }), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].assignedTo, 'did:me'); + }); + + it('hidePreviousVersions sets edited $ne true', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], hidePreviousVersions: true }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[0].edited, { $ne: true }); + }); + + it('schema option becomes a filter', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], schema: '#Foo' }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].schema, '#Foo'); + }); + + it('equal filter builds a $eq expression on the field', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { + dataType: 'vc-documents', + filters: [{ type: 'equal', field: 'document.status', value: 'OK' }], + }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[0]['document.status'], { $eq: 'OK' }); + }); + + it('in filter splits comma values into $in array', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { + dataType: 'vc-documents', + filters: [{ type: 'in', field: 'tag', value: 'a,b,c' }], + }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[0].tag, { $in: ['a', 'b', 'c'] }); + }); + + it('unknown filter type throws BlockActionError', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { + dataType: 'vc-documents', + filters: [{ type: 'bogus', field: 'f', value: 'x' }], + }, + componentsOverrides: { databaseServer: db }, + }); + await assert.rejects( + () => block.getFromSource(makeUser(), null, false, null), + /Unknown filter type/ + ); + }); + + it('globalFilters are merged onto the query', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), { extra: 'yes' }, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].extra, 'yes'); + }); + + it('did-documents uses getDidDocuments without policyId', async () => { + const db = recDb({ getDidDocuments: async () => [{ id: 'did1', option: {} }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'did-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getDidDocuments'); + assert.ok(call); + assert.equal(call.args[0].policyId, undefined); + assert.equal(out[0].id, 'did1'); + }); + + it('approve dataType uses getApprovalDocuments with policyId', async () => { + const db = recDb({ getApprovalDocuments: async () => [{ id: 'ap1', option: {} }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'approve', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getApprovalDocuments'); + assert.equal(call.args[0].policyId, 'policy-1'); + assert.equal(out[0].id, 'ap1'); + }); + + it('source dataType (non-count) throws because data=0 is iterated (latent bug)', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'source', filters: [] }, + }); + await assert.rejects( + () => block.getFromSource(makeUser(), null, false, null), + /is not iterable/ + ); + }); + + it('source dataType for count returns [] (latent: swapped with non-count)', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'source', filters: [] }, + }); + const out = await block.getFromSource(makeUser(), null, true, null); + assert.deepEqual(out, []); + }); + + it('unknown dataType throws BlockActionError', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'mystery', filters: [] }, + }); + await assert.rejects( + () => block.getFromSource(makeUser(), null, false, null), + /dataType "mystery" is unknown/ + ); + }); + + it('countResult passes through to getVcDocuments as third arg', async () => { + const db = recDb({ getVcDocuments: async () => 42 }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, true, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[2], true); + assert.equal(out, 42); + }); + + it('orderDirection option sets otherOptions.orderBy.createDate', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], orderDirection: 'DESC' }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[1].orderBy, { createDate: 'DESC' }); + }); + + it('orderField + orderDirection set explicit orderBy field', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], orderField: 'createDate', orderDirection: 'ASC' }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[1].orderBy, { createDate: 'ASC' }); + }); + + it('applies selective attributes to each item.option', async () => { + const db = recDb({ getVcDocuments: async () => [{ id: 'v', option: { keep: 'k', drop: 'd' } }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + block.getSelectiveAttributes = () => ([ + { options: { attributes: [{ attributePath: 'keep' }] } }, + ]); + const out = await block.getFromSource(makeUser(), null, false, null); + assert.deepEqual(out[0].option, { keep: 'k' }); + }); + + it('without selective attributes the option is untouched', async () => { + const db = recDb({ getVcDocuments: async () => [{ id: 'v', option: { a: 1, b: 2 } }] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + block.getSelectiveAttributes = () => []; + const out = await block.getFromSource(makeUser(), null, false, null); + assert.deepEqual(out[0].option, { a: 1, b: 2 }); + }); + + it('count result skips selective-attribute post-processing', async () => { + const db = recDb({ getVcDocuments: async () => 5 }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + const out = await block.getFromSource(makeUser(), null, true, null); + assert.equal(out, 5); + }); +}); + +describe('@unit exec-ui DocumentsSourceAddon getFromSourceFilters', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('throws when filters is not an array', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: {} }, + }); + await assert.rejects( + () => block.getFromSourceFilters(makeUser(), null), + /filters option must be an array/ + ); + }); + + it('returns a $set block with __sourceTag__ cond using ref.tag', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + tag: 'srcTag', + options: { dataType: 'vc-documents', filters: [] }, + }); + block.getSelectiveAttributes = () => []; + const f = await block.getFromSourceFilters(makeUser(), null); + assert.ok(f.$set); + assert.equal(f.$set.id.$toString, '$_id'); + assert.equal(f.$set.__sourceTag__.$cond.then, 'srcTag'); + }); + + it('adds equal filter expression into the cond', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { + dataType: 'vc-documents', + filters: [{ type: 'equal', field: 'status', value: 'OK' }], + }, + }); + block.getSelectiveAttributes = () => []; + const f = await block.getFromSourceFilters(makeUser(), null); + const andClauses = JSON.stringify(f.$set.__sourceTag__.$cond.if.$and); + assert.ok(andClauses.includes('$status')); + }); + + it('selective attributes add newOption projection', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + }); + block.getSelectiveAttributes = () => ([ + { options: { attributes: [{ attributePath: 'foo' }] } }, + ]); + const f = await block.getFromSourceFilters(makeUser(), null); + assert.ok(f.$set.newOption); + assert.equal(f.$set.newOption.$cond.then.foo, '$option.foo'); + }); + + it('unknown filter type throws', async () => { + const { block } = makeBlock(DocumentsSourceAddon, { + options: { + dataType: 'vc-documents', + filters: [{ type: 'nope', field: 'f', value: 'v' }], + }, + }); + block.getSelectiveAttributes = () => []; + await assert.rejects( + () => block.getFromSourceFilters(makeUser(), null), + /Unknown filter type/ + ); + }); +}); + +describe('@unit exec-ui DocumentsSourceAddon setData', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('stores per-user state and calls BlockUpdateFn on parent', async () => { + let updated = null; + PolicyComponentsUtils.BlockUpdateFn = (block, user) => { updated = { block, user }; }; + const parent = fakeParent(); + const { block } = makeBlock(DocumentsSourceAddon, { options: {}, parent }); + const user = makeUser({ id: 'sd-1' }); + await block.setData(user, { orderDirection: 'ASC' }); + assert.equal(updated.block, parent); + assert.equal(updated.user, user); + PolicyComponentsUtils.BlockUpdateFn = () => {}; + }); +}); + +describe('@unit exec-ui SelectiveAttributes', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('blockType static is selectiveAttributes', () => { + assert.equal(SelectiveAttributes.blockType, 'selectiveAttributes'); + }); + + it('about post and get are both false', () => { + assert.equal(SelectiveAttributes.about.post, false); + assert.equal(SelectiveAttributes.about.get, false); + }); + + it('uuid/tag survive construction', () => { + const { block } = makeBlock(SelectiveAttributes, { uuid: 'sa-1', tag: 'sa-tag', options: {} }); + assert.equal(block.uuid, 'sa-1'); + assert.equal(block.tag, 'sa-tag'); + }); + + it('is a REMOTE actionType addon', () => { + const { block } = makeBlock(SelectiveAttributes, { options: { attributes: [] } }); + assert.equal(block.actionType, LocationType.REMOTE); + }); + + it('exposes the attributes option', () => { + const attributes = [{ attributePath: 'a.b' }]; + const { block } = makeBlock(SelectiveAttributes, { options: { attributes } }); + assert.deepEqual(block.options.attributes, attributes); + }); + + it('blockClassName is SourceAddon', () => { + const { block } = makeBlock(SelectiveAttributes, { options: {} }); + assert.equal(block.blockClassName, 'SourceAddon'); + }); + + it('about declares no children and Special control', () => { + assert.equal(SelectiveAttributes.about.children, 'None'); + assert.equal(SelectiveAttributes.about.control, 'Special'); + }); +}); + +describe('@unit exec-ui InterfaceDocumentsSource', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + function configureSource(block, docs, opts = {}) { + block.getCommonAddons = () => (opts.commonAddons || []); + block.getFiltersAddons = () => (opts.filterAddons || []); + block.getGlobalSources = async () => docs; + block.getDataByAggregationFilters = async () => docs; + block.getGlobalSourcesFilters = async () => ({ filters: [], dataType: 'vc-documents' }); + block.components = Object.assign(block.components, { + getPolicyCommentsCount: async () => 0, + }); + Object.defineProperty(block, 'children', { value: opts.children || [], configurable: true }); + } + + it('getData returns the base envelope with data + uiMetaData', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + uuid: 'ds-1', + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, [{ id: 'a', document: { id: 'a' } }]); + const d = await block.getData(makeUser(), 'ds-1', {}); + assert.equal(d.id, 'ds-1'); + assert.equal(d.blockType, 'interfaceDocumentsSourceBlock'); + assert.equal(d.actionType, LocationType.LOCAL); + assert.equal(d.data.length, 1); + assert.equal(d.viewHistory, false); + }); + + it('getData attaches commonAddons and filter blocks', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + const filterAddon = { uuid: 'flt', tag: 'flt', options: { uiMetaData: { x: 1 } }, blockType: 'filtersAddon' }; + const commonAddon = { uuid: 'cmn', options: { uiMetaData: {} }, blockType: 'documentsSourceAddon' }; + configureSource(block, [], { filterAddons: [filterAddon], commonAddons: [commonAddon] }); + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.blocks[0].id, 'flt'); + assert.equal(d.commonAddons[0].id, 'cmn'); + }); + + it('getData computes pagination flags from pagination addon state', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + const pagination = { + blockType: 'paginationAddon', + uuid: 'pag', + options: { uiMetaData: {} }, + async setState() {}, + async getState() { return { page: 1, itemsPerPage: 10, size: 35 }; }, + async resetPagination() {}, + }; + configureSource(block, [], { commonAddons: [pagination] }); + const d = await block.getData(makeUser(), 'x', { page: '1', itemsPerPage: '10' }); + assert.equal(d.page, 1); + assert.equal(d.pageSize, 10); + assert.equal(d.totalCount, 35); + assert.equal(d.hasPreviousPage, true); + assert.equal(d.hasNextPage, true); + }); + + it('getData hasNextPage false on the last page', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + const pagination = { + blockType: 'paginationAddon', + uuid: 'pag', + options: { uiMetaData: {} }, + async setState() {}, + async getState() { return { page: 3, itemsPerPage: 10, size: 35 }; }, + async resetPagination() {}, + }; + configureSource(block, [], { commonAddons: [pagination] }); + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.hasNextPage, false); + assert.equal(d.hasPreviousPage, true); + }); + + it('getData filterByUUID narrows to a single doc', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, [ + { id: '1', document: { id: 'u-1' } }, + { id: '2', document: { id: 'u-2' } }, + ]); + const d = await block.getData(makeUser(), 'x', { filterByUUID: 'u-2' }); + assert.equal(d.data.length, 1); + assert.equal(d.data[0].document.id, 'u-2'); + }); + + it('getData readonly false for LOCAL block', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, []); + const d = await block.getData(makeUser({ location: LocationType.REMOTE }), 'x', {}); + assert.equal(d.readonly, false); + }); + + it('getData treats null queryParams as empty', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, [{ id: 'a', document: { id: 'a' } }]); + const d = await block.getData(makeUser(), 'x', null); + assert.equal(d.data.length, 1); + }); + + it('getData sortDirection/sortField populate sortState', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, []); + const d = await block.getData(makeUser(), 'x', { sortDirection: 'asc', sortField: 'createDate' }); + assert.equal(d.orderDirection, 'asc'); + assert.equal(d.orderField, 'createDate'); + }); + + it('getData adds comments count to each document', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configureSource(block, [{ id: 'a', document: { id: 'a' } }]); + block.components.getPolicyCommentsCount = async () => 7; + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.data[0].comments, 7); + }); + + it('setData stores state and triggers no throw', async () => { + const parent = fakeParent(); + const { block } = makeBlock(InterfaceDocumentsSource, { options: {}, parent }); + await block.setData(makeUser({ id: 'sd' }), { some: 'state' }); + assert.ok(true); + }); + + it('onAddonEvent throws when document not found', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + block.getGlobalSources = async () => []; + const captured = captureEvents(block); + await assert.rejects( + () => block.onAddonEvent(makeUser({ did: 'd-1' }), 'tag', 'missing', async (x) => ({ data: x }), null), + /Document is not found/ + ); + assert.equal(captured.length, 0); + }); + + it('onAddonEvent runs handler and triggers events when document found', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + block.getGlobalSources = async () => ([{ id: 'doc-1', __sourceTag__: undefined }]); + const captured = captureEvents(block); + await block.onAddonEvent(makeUser({ did: 'd-2' }), 'evt', 'doc-1', async (doc) => ({ data: doc }), null); + assert.equal(captured.length, 1); + assert.equal(captured[0][0], 'evt'); + assert.equal(captured[0][2].data.id, 'doc-1'); + }); +}); + +describe('@unit exec-ui DocumentsSourceAddon filter operators', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + function runFilter(filter) { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [filter] }, + componentsOverrides: { databaseServer: db }, + }); + return block.getFromSource(makeUser(), null, false, null).then(() => { + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + return call.args[0][filter.field]; + }); + } + + it('not_equal builds $ne', async () => { + assert.deepEqual(await runFilter({ type: 'not_equal', field: 'f', value: 'X' }), { $ne: 'X' }); + }); + + it('not_in builds $nin array', async () => { + assert.deepEqual(await runFilter({ type: 'not_in', field: 'f', value: 'a,b' }), { $nin: ['a', 'b'] }); + }); + + it('gt builds $gt', async () => { + assert.deepEqual(await runFilter({ type: 'gt', field: 'f', value: '5' }), { $gt: '5' }); + }); + + it('gte builds $gte', async () => { + assert.deepEqual(await runFilter({ type: 'gte', field: 'f', value: '5' }), { $gte: '5' }); + }); + + it('lt builds $lt', async () => { + assert.deepEqual(await runFilter({ type: 'lt', field: 'f', value: '5' }), { $lt: '5' }); + }); + + it('lte builds $lte', async () => { + assert.deepEqual(await runFilter({ type: 'lte', field: 'f', value: '5' }), { $lte: '5' }); + }); + + it('onlyOwnByGroupDocuments sets group filter to user.group', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], onlyOwnByGroupDocuments: true }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser({ group: 'g1' }), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].group, 'g1'); + }); + + it('onlyAssignByGroupDocuments sets assignedToGroup', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], onlyAssignByGroupDocuments: true }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser({ group: 'g2' }), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].assignedToGroup, 'g2'); + }); + + it('dynamic getFilters values are merged into the query', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + block.getFilters = async () => ({ dyn: 'V' }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.equal(call.args[0].dyn, 'V'); + }); + + it('always sets initId $exists false', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [] }, + componentsOverrides: { databaseServer: db }, + }); + await block.getFromSource(makeUser(), null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[0].initId, { $exists: false }); + }); + + it('state orderDirection overrides options orderDirection', async () => { + const db = recDb({ getVcDocuments: async () => [] }); + const { block } = makeBlock(DocumentsSourceAddon, { + options: { dataType: 'vc-documents', filters: [], orderDirection: 'ASC' }, + componentsOverrides: { databaseServer: db }, + }); + const user = makeUser({ id: 'st-1' }); + await block.setData(user, { orderDirection: 'DESC', orderField: 'createDate' }); + await block.getFromSource(user, null, false, null); + const call = db.__calls.find((c) => c.name === 'getVcDocuments'); + assert.deepEqual(call.args[1].orderBy, { createDate: 'DESC' }); + }); +}); + +describe('@unit exec-ui DocumentsSourceAddon getFromSourceFilters branches', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + function makeAddon(options) { + const { block } = makeBlock(DocumentsSourceAddon, { tag: 'srcTag', options }); + block.getSelectiveAttributes = () => []; + return block; + } + + it('onlyOwnDocuments adds an $eq owner clause', async () => { + const block = makeAddon({ dataType: 'vc-documents', filters: [], onlyOwnDocuments: true }); + const f = await block.getFromSourceFilters(makeUser({ did: 'did:me' }), null); + const s = JSON.stringify(f.$set.__sourceTag__.$cond.if.$and); + assert.ok(s.includes('did:me')); + assert.ok(s.includes('$owner')); + }); + + it('schema option adds $eq schema clause', async () => { + const block = makeAddon({ dataType: 'vc-documents', filters: [], schema: '#S' }); + const f = await block.getFromSourceFilters(makeUser(), null); + const s = JSON.stringify(f.$set.__sourceTag__.$cond.if.$and); + assert.ok(s.includes('$schema')); + }); + + it('globalFilters are appended to the cond clauses', async () => { + const block = makeAddon({ dataType: 'vc-documents', filters: [] }); + const f = await block.getFromSourceFilters(makeUser(), [{ $eq: ['Z', '$marker'] }]); + const s = JSON.stringify(f.$set.__sourceTag__.$cond.if.$and); + assert.ok(s.includes('$marker')); + }); + + it('else branch keeps existing __sourceTag__', async () => { + const block = makeAddon({ dataType: 'vc-documents', filters: [] }); + const f = await block.getFromSourceFilters(makeUser(), null); + assert.equal(f.$set.__sourceTag__.$cond.else, '$__sourceTag__'); + }); +}); + +describe('@unit exec-ui ButtonBlockAddon extra envelope', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('getData includes hideWhenDiscontinued and dialogOptions from options', async () => { + const { block } = makeBlock(ButtonBlockAddon, { + options: { + name: 'X', + hideWhenDiscontinued: true, + dialog: true, + dialogOptions: { dialogTitle: 'T' }, + }, + }); + const d = await block.getData(makeUser()); + assert.equal(d.hideWhenDiscontinued, true); + assert.equal(d.dialog, true); + assert.deepEqual(d.dialogOptions, { dialogTitle: 'T' }); + }); + + it('blockType static is buttonBlockAddon', () => { + assert.equal(ButtonBlockAddon.blockType, 'buttonBlockAddon'); + }); +}); + +describe('@unit exec-ui DropdownBlockAddon extra', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + it('blockType static is dropdownBlockAddon', () => { + assert.equal(DropdownBlockAddon.blockType, 'dropdownBlockAddon'); + }); + + it('getData with no sources yields empty documents', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'n', optionValue: 'v', field: 'f' }, + }); + block.getSources = async () => []; + const d = await block.getData(makeUser({ location: LocationType.LOCAL })); + assert.deepEqual(d.documents, []); + assert.equal(d.readonly, false); + }); + + it('getData value comes from document.id', async () => { + const { block } = makeBlock(DropdownBlockAddon, { + options: { optionName: 'option.n', optionValue: 'option.v', field: 'f' }, + }); + block.getSources = async () => ([{ id: 'the-id', option: { n: 'N', v: 'V' } }]); + const d = await block.getData(makeUser()); + assert.equal(d.documents[0].value, 'the-id'); + }); +}); + +describe('@unit exec-ui InterfaceDocumentsSource extra branches', () => { + after(() => { restoreHarness(); restoreSideEffects(); }); + before(() => silenceSideEffects()); + + function configure(block, docs, opts = {}) { + block.getCommonAddons = () => (opts.commonAddons || []); + block.getFiltersAddons = () => (opts.filterAddons || []); + block.getGlobalSources = async () => docs; + block.getDataByAggregationFilters = async () => docs; + block.getGlobalSourcesFilters = async () => ({ filters: [], dataType: 'vc-documents' }); + block.components = Object.assign(block.components, { getPolicyCommentsCount: async () => 0 }); + Object.defineProperty(block, 'children', { value: opts.children || [], configurable: true }); + } + + it('blockType static is interfaceDocumentsSourceBlock', () => { + assert.equal(InterfaceDocumentsSource.blockType, 'interfaceDocumentsSourceBlock'); + }); + + it('enableSorting option routes through aggregation path', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: true } }, + }); + let aggCalled = false; + configure(block, [{ id: 'a', document: { id: 'a' } }]); + block.getDataByAggregationFilters = async () => { aggCalled = true; return [{ id: 'a', document: { id: 'a' } }]; }; + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(aggCalled, true); + assert.equal(d.data.length, 1); + }); + + it('no pagination addon leaves pagination flags unset', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configure(block, []); + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.page, undefined); + assert.equal(d.totalCount, undefined); + }); + + it('viewHistory true when a history addon is present', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + const history = { blockType: 'historyAddon', uuid: 'h', options: {} }; + configure(block, [], { commonAddons: [history] }); + block.databaseServer.getDocumentStateHistory = async () => []; + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.viewHistory, true); + }); + + it('getData merges uiMetaData props into the envelope', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false, title: 'Docs' } }, + }); + configure(block, []); + const d = await block.getData(makeUser(), 'x', {}); + assert.equal(d.title, 'Docs'); + }); + + it('getData with no docs returns empty data array', async () => { + const { block } = makeBlock(InterfaceDocumentsSource, { + options: { uiMetaData: { fields: [], enableSorting: false } }, + }); + configure(block, []); + const d = await block.getData(makeUser(), 'x', {}); + assert.deepEqual(d.data, []); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/impact-addon-extra.test.mjs b/policy-service/tests/unit-tests/blocks/impact-addon-extra.test.mjs new file mode 100644 index 0000000000..c312c8fc53 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/impact-addon-extra.test.mjs @@ -0,0 +1,76 @@ +import { assert } from 'chai'; +import '../../../dist/policy-engine/block-validators/index.js'; +import { TokenOperationAddon } from '../../../dist/policy-engine/block-validators/blocks/impact-addon.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + checkBlockError(err) { if (err) { this.errors.push(err); } } +} + +const ref = (options) => ({ options, children: [] }); + +describe('@unit P0 TokenOperationAddon (impactAddon) extra', () => { + it('blockType is impactAddon', () => { + assert.equal(TokenOperationAddon.blockType, 'impactAddon'); + }); + + it('passes Primary Impacts with a string amount', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '12', impactType: 'Primary Impacts' })); + assert.deepEqual(v.errors, []); + }); + + it('passes Secondary Impacts with a string amount', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '0.5', impactType: 'Secondary Impacts' })); + assert.deepEqual(v.errors, []); + }); + + it('flags missing amount (not set)', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ impactType: 'Primary Impacts' })); + assert.include(v.errors, 'Option "amount" is not set'); + }); + + it('flags empty-string amount as not set', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '', impactType: 'Primary Impacts' })); + assert.include(v.errors, 'Option "amount" is not set'); + }); + + it('flags numeric amount as wrong type', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: 10, impactType: 'Primary Impacts' })); + assert.include(v.errors, 'Option "amount" must be a string'); + }); + + it('flags missing impactType', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '1' })); + assert.include(v.errors, 'Option "impactType" must be one of [Primary Impacts, Secondary Impacts]'); + }); + + it('flags impactType outside the allowed set', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({ amount: '1', impactType: 'Tertiary' })); + assert.include(v.errors, 'Option "impactType" must be one of [Primary Impacts, Secondary Impacts]'); + }); + + it('reports both amount and impactType issues together', async () => { + const v = new FakeValidator(); + await TokenOperationAddon.validate(v, ref({})); + assert.equal(v.errors.length, 2); + }); + + it('captures unhandled exception from artifact lookup', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await TokenOperationAddon.validate(v, ref({ amount: '1', impactType: 'Primary Impacts', artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/property-validator-blocks.test.mjs b/policy-service/tests/unit-tests/blocks/property-validator-blocks.test.mjs new file mode 100644 index 0000000000..7dc030b2fb --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/property-validator-blocks.test.mjs @@ -0,0 +1,147 @@ +import { assert } from 'chai'; +import { PropertyValidator } from '../../../dist/policy-engine/block-validators/index.js'; + +describe('@unit P0 PropertyValidator.selectValidator', () => { + const reqs = ['a', 'b', 'c']; + + it('returns null when value is in the requirements set', () => { + assert.isNull(PropertyValidator.selectValidator('x', 'a', reqs)); + }); + + it('returns null for the middle requirement', () => { + assert.isNull(PropertyValidator.selectValidator('x', 'b', reqs)); + }); + + it('returns null for the last requirement', () => { + assert.isNull(PropertyValidator.selectValidator('x', 'c', reqs)); + }); + + it('returns an error message when value is missing from the set', () => { + assert.equal( + PropertyValidator.selectValidator('mode', 'z', reqs), + 'Option "mode" must be one of [a, b, c]', + ); + }); + + it('includes the option name in the error', () => { + const err = PropertyValidator.selectValidator('impactType', 'nope', reqs); + assert.match(err, /^Option "impactType"/); + }); + + it('joins requirements with ", "', () => { + const err = PropertyValidator.selectValidator('m', 'x', ['one', 'two']); + assert.include(err, '[one, two]'); + }); + + it('rejects undefined value', () => { + assert.isNotNull(PropertyValidator.selectValidator('m', undefined, reqs)); + }); + + it('rejects null value', () => { + assert.isNotNull(PropertyValidator.selectValidator('m', null, reqs)); + }); + + it('rejects empty string when not in set', () => { + assert.isNotNull(PropertyValidator.selectValidator('m', '', reqs)); + }); + + it('flags empty string even when present in set (find returns falsy)', () => { + assert.isNotNull(PropertyValidator.selectValidator('m', '', ['', 'x'])); + }); + + it('does strict equality (number 1 not equal to string "1")', () => { + assert.isNotNull(PropertyValidator.selectValidator('m', 1, ['1'])); + }); + + it('accepts a matching number value', () => { + assert.isNull(PropertyValidator.selectValidator('m', 1, [1, 2])); + }); + + it('error message for empty requirements list', () => { + assert.equal( + PropertyValidator.selectValidator('m', 'x', []), + 'Option "m" must be one of []', + ); + }); + + it('accepts boolean true when present in set', () => { + assert.isNull(PropertyValidator.selectValidator('flag', true, [true, false])); + }); +}); + +describe('@unit P0 PropertyValidator.inputValidator', () => { + it('returns "is not set" when value is empty string', () => { + assert.equal( + PropertyValidator.inputValidator('amount', '', 'string'), + 'Option "amount" is not set', + ); + }); + + it('returns "is not set" when value is undefined', () => { + assert.equal( + PropertyValidator.inputValidator('amount', undefined, 'string'), + 'Option "amount" is not set', + ); + }); + + it('returns "is not set" when value is null', () => { + assert.equal( + PropertyValidator.inputValidator('amount', null, 'string'), + 'Option "amount" is not set', + ); + }); + + it('returns "is not set" when value is 0 (falsy)', () => { + assert.equal( + PropertyValidator.inputValidator('amount', 0, 'string'), + 'Option "amount" is not set', + ); + }); + + it('returns "is not set" when value is false (falsy)', () => { + assert.equal( + PropertyValidator.inputValidator('flag', false, 'string'), + 'Option "flag" is not set', + ); + }); + + it('returns null for a valid string value', () => { + assert.isNull(PropertyValidator.inputValidator('amount', '10', 'string')); + }); + + it('flags a non-string value when type is requested', () => { + assert.equal( + PropertyValidator.inputValidator('amount', 42, 'string'), + 'Option "amount" must be a string', + ); + }); + + it('does not flag type mismatch when type is not provided', () => { + assert.isNull(PropertyValidator.inputValidator('amount', 42, undefined)); + }); + + it('does not flag type mismatch when type is empty string', () => { + assert.isNull(PropertyValidator.inputValidator('amount', 42, '')); + }); + + it('flags a truthy object value when string type requested', () => { + assert.equal( + PropertyValidator.inputValidator('cfg', { a: 1 }, 'string'), + 'Option "cfg" must be a string', + ); + }); + + it('echoes the requested type in the error message', () => { + const err = PropertyValidator.inputValidator('n', 5, 'number-ish'); + assert.include(err, 'must be a number-ish'); + }); + + it('accepts a non-empty whitespace string', () => { + assert.isNull(PropertyValidator.inputValidator('n', ' ', 'string')); + }); + + it('includes the option name in the not-set error', () => { + const err = PropertyValidator.inputValidator('threshold', undefined, 'string'); + assert.include(err, '"threshold"'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/request-button-multisign.test.mjs b/policy-service/tests/unit-tests/blocks/request-button-multisign.test.mjs new file mode 100644 index 0000000000..766f43616d --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/request-button-multisign.test.mjs @@ -0,0 +1,291 @@ +import { assert } from 'chai'; +import { RequestVcDocumentBlock } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block.js'; +import { RequestVcDocumentBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/request-vc-document-block-addon.js'; +import { ButtonBlock } from '../../../dist/policy-engine/block-validators/blocks/button-block.js'; +import { ButtonBlockAddon } from '../../../dist/policy-engine/block-validators/blocks/button-block-addon.js'; +import { MultiSignBlock } from '../../../dist/policy-engine/block-validators/blocks/multi-sign-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this.schemas = opts.schemas || new Set(); + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + validateSchemaVariable(name, value, required) { + if (required && !value) { return `Option "${name}" is not set`; } + if (value && !this.schemas.has(value)) { return `Schema "${value}" not exist`; } + return null; + } + checkBlockError(err) { if (err) { this.errors.push(err); } } +} + +describe('@unit P0 RequestVcDocumentBlock.validate', () => { + it('blockType is requestVcDocumentBlock', () => { + assert.equal(RequestVcDocumentBlock.blockType, 'requestVcDocumentBlock'); + }); + + it('flags missing required schema', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlock.validate(v, { options: {}, children: [] }); + assert.include(v.errors, 'Option "schema" is not set'); + }); + + it('flags an unknown schema id', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']) }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#B' }, children: [] }); + assert.include(v.errors, 'Schema "#B" not exist'); + }); + + it('flags an unknown presetSchema id', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']) }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#A', presetSchema: '#Z' }, children: [] }); + assert.include(v.errors, 'Schema "#Z" not exist'); + }); + + it('passes for a valid schema with no preset', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']) }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#A' }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('passes for a valid schema and valid presetSchema', async () => { + const v = new FakeValidator({ schemas: new Set(['#A', '#P']) }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#A', presetSchema: '#P' }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('presetSchema is optional (not required) so absence is OK', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']) }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#A', presetSchema: undefined }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']), throwGetArtifact: true }); + await RequestVcDocumentBlock.validate(v, { options: { schema: '#A', artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 RequestVcDocumentBlockAddon extra', () => { + it('preset=false does not require presetSchema', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']) }); + await RequestVcDocumentBlockAddon.validate(v, { + options: { schema: '#A', preset: false, buttonName: 'b', dialogTitle: 'd' }, + children: [], + }); + assert.deepEqual(v.errors, []); + }); + + it('preset=true with valid presetSchema passes', async () => { + const v = new FakeValidator({ schemas: new Set(['#A', '#P']) }); + await RequestVcDocumentBlockAddon.validate(v, { + options: { schema: '#A', preset: true, presetSchema: '#P', buttonName: 'b', dialogTitle: 'd' }, + children: [], + }); + assert.deepEqual(v.errors, []); + }); + + it('flags missing required schema', async () => { + const v = new FakeValidator(); + await RequestVcDocumentBlockAddon.validate(v, { + options: { buttonName: 'b', dialogTitle: 'd' }, + children: [], + }); + assert.include(v.errors, 'Option "schema" is not set'); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ schemas: new Set(['#A']), throwGetArtifact: true }); + await RequestVcDocumentBlockAddon.validate(v, { + options: { schema: '#A', buttonName: 'b', dialogTitle: 'd', artifacts: [{ uuid: 'a' }] }, + children: [], + }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 ButtonBlock.validate', () => { + const ui = (buttons) => ({ options: { uiMetaData: { buttons } }, children: [] }); + + it('blockType is buttonBlock', () => { + assert.equal(ButtonBlock.blockType, 'buttonBlock'); + }); + + it('rejects missing uiMetaData', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, { options: {}, children: [] }); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('rejects non-object uiMetaData', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, { options: { uiMetaData: 'x' }, children: [] }); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('rejects when buttons is not an array', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, { options: { uiMetaData: { buttons: 'oops' } }, children: [] }); + assert.include(v.errors, 'Option "uiMetaData.buttons" must be an array'); + }); + + it('passes an empty buttons array', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([])); + assert.deepEqual(v.errors, []); + }); + + it('rejects a button missing its tag', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ type: 'selector', filters: [] }])); + assert.include(v.errors, 'Option "tag" is not set'); + }); + + it('rejects when button.filters is not an array', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'selector', filters: 'nope' }])); + assert.include(v.errors, 'Option "button.filters" must be an array'); + }); + + it('rejects a filter missing type and field', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'selector', filters: [{}] }])); + assert.include(v.errors, 'Option "type" is not set'); + assert.include(v.errors, 'Option "field" is not set'); + }); + + it('passes a valid selector button', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'selector', filters: [{ type: 'equal', field: 'f' }] }])); + assert.deepEqual(v.errors, []); + }); + + it('selector-dialog requires title and description', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'selector-dialog', filters: [] }])); + assert.include(v.errors, 'Option "title" is not set'); + assert.include(v.errors, 'Option "description" is not set'); + }); + + it('passes a valid selector-dialog button', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'selector-dialog', title: 'T', description: 'D', filters: [] }])); + assert.deepEqual(v.errors, []); + }); + + it('rejects an unknown button type', async () => { + const v = new FakeValidator(); + await ButtonBlock.validate(v, ui([{ tag: 't', type: 'mystery', filters: [] }])); + assert.include(v.errors, 'Option "type" must be a "selector|selector-dialog"'); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await ButtonBlock.validate(v, { options: { uiMetaData: { buttons: [] }, artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 ButtonBlockAddon.validate', () => { + it('blockType is buttonBlockAddon', () => { + assert.equal(ButtonBlockAddon.blockType, 'buttonBlockAddon'); + }); + + it('rejects empty button name', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, { options: {}, children: [] }); + assert.include(v.errors, 'Button name is empty'); + }); + + it('passes a simple named button without dialog', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, { options: { name: 'Go' }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('dialog without dialogOptions flags both title and result path', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, { options: { name: 'Go', dialog: true }, children: [] }); + assert.include(v.errors, 'Dialog title is empty'); + assert.include(v.errors, 'Dialog result field path is empty'); + }); + + it('dialog with title but no result path flags only the result path', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, { + options: { name: 'Go', dialog: true, dialogOptions: { dialogTitle: 'T' } }, + children: [], + }); + assert.notInclude(v.errors, 'Dialog title is empty'); + assert.include(v.errors, 'Dialog result field path is empty'); + }); + + it('passes a complete dialog config', async () => { + const v = new FakeValidator(); + await ButtonBlockAddon.validate(v, { + options: { name: 'Go', dialog: true, dialogOptions: { dialogTitle: 'T', dialogResultFieldPath: 'a.b' } }, + children: [], + }); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await ButtonBlockAddon.validate(v, { options: { name: 'Go', artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 MultiSignBlock.validate', () => { + const ref = (o = {}) => ({ options: o, children: [] }); + + it('blockType is multiSignBlock', () => { + assert.equal(MultiSignBlock.blockType, 'multiSignBlock'); + }); + + it('rejects missing threshold', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "threshold" is not set'); + }); + + it('rejects threshold of 0 (falsy) as not set', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 0 })); + assert.include(v.errors, 'Option "threshold" is not set'); + }); + + it('accepts threshold of 50', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 50 })); + assert.deepEqual(v.errors, []); + }); + + it('accepts threshold string "100" (boundary)', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '100' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects threshold above 100', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 150 })); + assert.include(v.errors, '"threshold" value must be between 0 and 100'); + }); + + it('accepts a numeric string threshold within range', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '75' })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await MultiSignBlock.validate(v, ref({ threshold: 50, artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/retirement-serials.test.mjs b/policy-service/tests/unit-tests/blocks/retirement-serials.test.mjs new file mode 100644 index 0000000000..2e844ecdd0 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/retirement-serials.test.mjs @@ -0,0 +1,173 @@ +import { assert } from 'chai'; +import { RetirementBlock } from '../../../dist/policy-engine/block-validators/blocks/retirement-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._templateMissing = !!opts.templateMissing; + this._tokenMissing = !!opts.tokenMissing; + } + addError(msg) { this.errors.push(msg); } + tokenTemplateNotExist() { return this._templateMissing; } + async tokenNotExist() { return this._tokenMissing; } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } +} + +const baseRef = (overrides = {}) => ({ + options: { + tokenId: '0.0.1', + accountType: 'default', + ...overrides, + }, + children: [], +}); + +const errorsFor = async (overrides = {}, opts = {}) => { + const v = new FakeValidator(opts); + await RetirementBlock.validate(v, baseRef(overrides)); + return v.errors; +}; + +describe('RetirementBlock.validate - token resolution', () => { + it('exposes the retirementDocumentBlock block type', () => { + assert.equal(RetirementBlock.blockType, 'retirementDocumentBlock'); + }); + + it('passes a minimal tokenId-based config', async () => { + assert.deepEqual(await errorsFor(), []); + }); + + it('rejects missing tokenId when not using a template', async () => { + assert.include(await errorsFor({ tokenId: undefined }), 'Option "tokenId" is not set'); + }); + + it('rejects non-string tokenId', async () => { + assert.include(await errorsFor({ tokenId: 123 }), 'Option "tokenId" must be a string'); + }); + + it('rejects unknown tokenId from registry', async () => { + assert.include(await errorsFor({ tokenId: '0.0.999' }, { tokenMissing: true }), 'Token with id 0.0.999 does not exist'); + }); + + it('rejects missing template when useTemplate=true', async () => { + assert.include(await errorsFor({ useTemplate: true, tokenId: undefined }), 'Option "template" is not set'); + }); + + it('rejects unknown template when useTemplate=true', async () => { + assert.include( + await errorsFor({ useTemplate: true, template: 't1', tokenId: undefined }, { templateMissing: true }), + 'Token "t1" does not exist' + ); + }); + + it('passes a valid template config', async () => { + assert.deepEqual(await errorsFor({ useTemplate: true, template: 't1', tokenId: undefined }), []); + }); +}); + +describe('RetirementBlock.validate - account type and rule', () => { + it('rejects unknown accountType', async () => { + assert.include(await errorsFor({ accountType: 'weird' }), 'Option "accountType" must be one of default,custom'); + }); + + it('accepts custom accountType with accountId', async () => { + assert.deepEqual(await errorsFor({ accountType: 'custom', accountId: '0.0.5' }), []); + }); + + it('rejects custom accountType without accountId', async () => { + assert.include(await errorsFor({ accountType: 'custom' }), 'Option "accountId" is not set'); + }); + + it('rejects non-string rule', async () => { + assert.include(await errorsFor({ rule: 42 }), 'Option "rule" must be a string'); + }); + + it('accepts a string rule', async () => { + assert.deepEqual(await errorsFor({ rule: 'someRule' }), []); + }); + + it('rejects non-string serialNumbersExpression', async () => { + assert.include(await errorsFor({ serialNumbersExpression: 5 }), 'Option "serial numbers" must be a string'); + }); +}); + +describe('RetirementBlock.validate - serial number expressions', () => { + it('accepts a single integer serial', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: '1' }), []); + }); + + it('accepts a list of integer serials', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: '1,2,3' }), []); + }); + + it('accepts a field reference token', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: 'field0' }), []); + }); + + it('accepts an integer range', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: '1-3' }), []); + }); + + it('accepts a field-based range', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: 'field0-field1' }), []); + }); + + it('trims whitespace around tokens', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: ' 1 , 2 ' }), []); + }); + + it('ignores empty tokens between commas', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: '1,,2' }), []); + }); + + it('rejects an illegal character in a token', async () => { + const errors = await errorsFor({ serialNumbersExpression: '1@2' }); + assert.isTrue(errors.some(e => /character "@" is not allowed/.test(e))); + }); + + it('rejects a space-containing illegal character mid-token', async () => { + const errors = await errorsFor({ serialNumbersExpression: 'a b' }); + assert.isTrue(errors.some(e => /is not allowed/.test(e))); + }); + + it('rejects an integer below 1', async () => { + const errors = await errorsFor({ serialNumbersExpression: '0' }); + assert.isTrue(errors.some(e => /must be greater than or equal to 1/.test(e))); + }); + + it('rejects a leading-dash range', async () => { + const errors = await errorsFor({ serialNumbersExpression: '-3' }); + assert.isTrue(errors.some(e => /both start and end are required/.test(e))); + }); + + it('rejects a trailing-dash range', async () => { + const errors = await errorsFor({ serialNumbersExpression: '3-' }); + assert.isTrue(errors.some(e => /both start and end are required/.test(e))); + }); + + it('rejects a range with multiple dashes', async () => { + const errors = await errorsFor({ serialNumbersExpression: '1-2-3' }); + assert.isTrue(errors.some(e => /only one '-' is allowed/.test(e))); + }); + + it('rejects a numeric range where end <= start', async () => { + const errors = await errorsFor({ serialNumbersExpression: '5-3' }); + assert.isTrue(errors.some(e => /End serial number must be greater than start/.test(e))); + }); + + it('rejects a numeric range where end == start', async () => { + const errors = await errorsFor({ serialNumbersExpression: '4-4' }); + assert.isTrue(errors.some(e => /End serial number must be greater than start/.test(e))); + }); + + it('reports each invalid token in a list', async () => { + const errors = await errorsFor({ serialNumbersExpression: '0,-3' }); + assert.isTrue(errors.some(e => /must be greater than or equal to 1/.test(e))); + assert.isTrue(errors.some(e => /both start and end are required/.test(e))); + }); + + it('does not error for valid mixed list', async () => { + assert.deepEqual(await errorsFor({ serialNumbersExpression: '1, 2-5, field0, field1-field2' }), []); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-aggregate-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-aggregate-block.test.mjs new file mode 100644 index 0000000000..35bdae5371 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-aggregate-block.test.mjs @@ -0,0 +1,159 @@ +import { assert } from 'chai'; +import { AggregateBlock } from '../../../dist/policy-engine/blocks/aggregate-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; + +const mk = () => Object.create(AggregateBlock.prototype); + +function makeRef(overrides = {}) { + const calls = { triggered: [], removed: [], created: [], errors: [], logs: [], backups: 0 }; + const ref = { + uuid: 'agg-uuid', + policyId: 'policy-1', + policyOwner: 'owner-x', + dryRun: false, + blockType: 'aggregateDocumentBlock', + databaseServer: { + async removeAggregateDocument(hash, uuid) { calls.removed.push({ hash, uuid }); }, + async removeAggregateDocuments(docs) { calls.removed.push(docs); }, + async createAggregateDocuments(item, uuid) { calls.created.push({ item, uuid }); }, + async getAggregateDocuments() { return overrides.rawEntities || []; }, + }, + async getOptions() { return overrides.options || {}; }, + async triggerEvents(type, user, state) { calls.triggered.push({ type, user, state }); }, + log(m) { calls.logs.push(m); }, + error(m) { calls.errors.push(m); }, + backup() { calls.backups++; }, + ...overrides.ref, + }; + return { ref, calls }; +} + +function withRef(ref, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = () => { }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; +}); + +describe('AggregateBlock runtime — aggregateScope', () => { + const block = mk(); + + it('returns {} for null scopes', () => { + assert.deepEqual(block.aggregateScope(null), {}); + }); + + it('returns {} for empty scopes array', () => { + assert.deepEqual(block.aggregateScope([]), {}); + }); + + it('collects single-key values into arrays', () => { + const result = block.aggregateScope([{ a: 1 }, { a: 2 }, { a: 3 }]); + assert.deepEqual(result, { a: [1, 2, 3] }); + }); + + it('collects multi-key values preserving order', () => { + const result = block.aggregateScope([{ a: 1, b: 10 }, { a: 2, b: 20 }]); + assert.deepEqual(result, { a: [1, 2], b: [10, 20] }); + }); + + it('uses keys from the first scope only', () => { + const result = block.aggregateScope([{ a: 1 }, { a: 2, b: 99 }]); + assert.deepEqual(Object.keys(result), ['a']); + }); + + it('pushes undefined for missing key in later scope', () => { + const result = block.aggregateScope([{ a: 1, b: 2 }, { a: 3 }]); + assert.deepEqual(result.b, [2, undefined]); + }); +}); + +describe('AggregateBlock runtime — expressions', () => { + const block = mk(); + + it('returns {} when expressions are undefined', () => { + const { ref } = makeRef(); + assert.deepEqual(block.expressions(ref, undefined, { document: {} }), {}); + }); + + it('returns {} when expressions are empty', () => { + const { ref } = makeRef(); + assert.deepEqual(block.expressions(ref, [], { document: {} }), {}); + }); +}); + +describe('AggregateBlock runtime — popDocuments', () => { + it('removes the aggregate document by hash and uuid', async () => { + const { ref, calls } = makeRef(); + const block = mk(); + await block.popDocuments(ref, { hash: 'h1' }); + assert.deepEqual(calls.removed[0], { hash: 'h1', uuid: 'agg-uuid' }); + }); +}); + +describe('AggregateBlock runtime — removeDocuments', () => { + it('returns documents untouched when list is empty', async () => { + const { ref } = makeRef(); + const block = mk(); + const out = await block.removeDocuments(ref, []); + assert.deepEqual(out, []); + }); + + it('calls databaseServer.removeAggregateDocuments for non-empty list', async () => { + const { ref, calls } = makeRef(); + const block = mk(); + const docs = [{ id: '1' }]; + await block.removeDocuments(ref, docs); + assert.lengthOf(calls.removed, 1); + }); + + it('restores _id/id from sourceDocumentId and drops sourceDocumentId', async () => { + const { ref } = makeRef(); + const block = mk(); + const docs = [{ sourceDocumentId: { toString: () => 'src-1' } }]; + const out = await block.removeDocuments(ref, docs); + assert.equal(out[0].id, 'src-1'); + assert.equal(out[0]._id.toString(), 'src-1'); + assert.notProperty(out[0], 'sourceDocumentId'); + }); + + it('leaves documents without sourceDocumentId unchanged', async () => { + const { ref } = makeRef(); + const block = mk(); + const docs = [{ id: 'keep' }]; + const out = await block.removeDocuments(ref, docs); + assert.equal(out[0].id, 'keep'); + }); +}); + +describe('AggregateBlock runtime — onPopEvent', () => { + it('pops each document of an array', async () => { + const { ref, calls } = makeRef(); + const block = mk(); + await withRef(ref, () => block.onPopEvent({ data: { data: [{ hash: 'a' }, { hash: 'b' }] } })); + assert.equal(calls.removed.length, 2); + assert.equal(calls.backups, 1); + }); + + it('pops a single document', async () => { + const { ref, calls } = makeRef(); + const block = mk(); + await withRef(ref, () => block.onPopEvent({ data: { data: { hash: 'solo' } } })); + assert.deepEqual(calls.removed[0], { hash: 'solo', uuid: 'agg-uuid' }); + }); +}); + +describe('AggregateBlock runtime — tickCron', () => { + it('returns early when aggregateType is not period', async () => { + const { ref, calls } = makeRef({ options: { aggregateType: 'cumulative' } }); + const block = mk(); + await withRef(ref, () => block.tickCron({ data: [], user: null })); + assert.equal(calls.triggered.length, 0); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-documents-source-addon.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-documents-source-addon.test.mjs new file mode 100644 index 0000000000..bc33c3e9e6 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-documents-source-addon.test.mjs @@ -0,0 +1,196 @@ +import { assert } from 'chai'; +import { DocumentsSourceAddon } from '../../../dist/policy-engine/blocks/documents-source-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origUpdate = PolicyComponentsUtils.BlockUpdateFn; + +const rawSetData = Object.getPrototypeOf(DocumentsSourceAddon.prototype).setData; + +function mk() { + const b = Object.create(DocumentsSourceAddon.prototype); + b.state = {}; + return b; +} + +function makeRef(options, dbOverrides = {}, refOverrides = {}) { + const calls = { vc: [], did: [], approval: [], updates: [] }; + const ref = { + uuid: 'src-uuid', + blockType: 'documentsSourceAddon', + policyId: 'p1', + tag: 'src-tag', + parent: { id: 'parent' }, + async getOptions() { return options; }, + async getFilters() { return {}; }, + getSelectiveAttributes() { return []; }, + databaseServer: { + async getVcDocuments(f, o, c) { calls.vc.push({ f, o, c }); return [{ id: '1', option: {} }]; }, + async getDidDocuments(f, o, c) { calls.did.push({ f, o, c }); return [{ id: 'd', option: {} }]; }, + async getApprovalDocuments(f, o, c) { calls.approval.push({ f }); return [{ id: 'a', option: {} }]; }, + ...dbOverrides, + }, + ...refOverrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.BlockUpdateFn = (p, u) => { calls.updates.push({ p, u }); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.BlockUpdateFn = origUpdate; +}); + +describe('DocumentsSourceAddon runtime — getFromSource (filter building)', () => { + it('throws when filters option is not an array', async () => { + const block = mk(); + const { ref } = makeRef({ dataType: 'vc-documents', filters: 'nope' }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.getFromSource({ id: 'u', did: 'did:u' }, null, false); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /filters option must be an array/); + }); + + it('adds owner/schema/initId filters and queries VC documents', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + dataType: 'vc-documents', + filters: [], + onlyOwnDocuments: true, + schema: '#Foo', + }); + await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'did:u' }, null, false)); + assert.lengthOf(calls.vc, 1); + const f = calls.vc[0].f; + assert.equal(f.owner, 'did:u'); + assert.equal(f.schema, '#Foo'); + assert.deepEqual(f.initId, { $exists: false }); + assert.equal(f.policyId, 'p1'); + }); + + it('translates a configured equal filter into an $eq expression', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + dataType: 'vc-documents', + filters: [{ field: 'status', type: 'equal', value: 'approved' }], + }); + await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, false)); + assert.deepEqual(calls.vc[0].f.status, { $eq: 'approved' }); + }); + + it('throws on an unknown configured filter type', async () => { + const block = mk(); + const { ref } = makeRef({ + dataType: 'vc-documents', + filters: [{ field: 'x', type: 'bogus', value: 'y' }], + }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.getFromSource({ id: 'u', did: 'd' }, null, false); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /Unknown filter type/); + }); + + it('merges in global filters', async () => { + const block = mk(); + const { ref, calls } = makeRef({ dataType: 'vc-documents', filters: [] }); + await withRef(ref, {}, () => + block.getFromSource({ id: 'u', did: 'd' }, { extra: 1 }, false)); + assert.equal(calls.vc[0].f.extra, 1); + }); + + it('tags results with the source tag when not counting', async () => { + const block = mk(); + const { ref } = makeRef({ dataType: 'vc-documents', filters: [] }); + const data = await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, false)); + assert.equal(data[0].__sourceTag__, 'src-tag'); + }); + + it('routes to did-documents for that dataType', async () => { + const block = mk(); + const { ref, calls } = makeRef({ dataType: 'did-documents', filters: [] }); + await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, false)); + assert.lengthOf(calls.did, 1); + }); + + it('routes to approval documents for the approve dataType', async () => { + const block = mk(); + const { ref, calls } = makeRef({ dataType: 'approve', filters: [] }); + await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, false)); + assert.lengthOf(calls.approval, 1); + }); + + it('returns an empty array when counting the source dataType', async () => { + const block = mk(); + const { ref } = makeRef({ dataType: 'source', filters: [] }); + const count = await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, true)); + assert.deepEqual(count, []); + }); + + it('throws for an unknown dataType', async () => { + const block = mk(); + const { ref } = makeRef({ dataType: 'mystery', filters: [] }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.getFromSource({ id: 'u', did: 'd' }, null, false); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /is unknown/); + }); + + it('orders by configured field/direction', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + dataType: 'vc-documents', + filters: [], + orderDirection: 'DESC', + orderField: 'createDate', + }); + await withRef(ref, {}, () => block.getFromSource({ id: 'u', did: 'd' }, null, false)); + assert.deepEqual(calls.vc[0].o.orderBy, { createDate: 'DESC' }); + }); +}); + +describe('DocumentsSourceAddon runtime — getFromSourceFilters', () => { + it('throws when filters option is not an array', async () => { + const block = mk(); + const { ref } = makeRef({ filters: null }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.getFromSourceFilters({ id: 'u', did: 'd' }, null); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /filters option must be an array/); + }); + + it('returns an aggregation $set blockFilter', async () => { + const block = mk(); + const { ref } = makeRef({ filters: [] }); + const out = await withRef(ref, {}, () => block.getFromSourceFilters({ id: 'u', did: 'd' }, null)); + assert.property(out, '$set'); + assert.property(out.$set, '__sourceTag__'); + }); +}); + +describe('DocumentsSourceAddon runtime — setData', () => { + it('stores state per user and triggers a block update', async () => { + const block = mk(); + const { ref, calls } = makeRef({ filters: [] }); + await withRef(ref, calls, () => + rawSetData.call(block, { id: 'u' }, { orderField: 'x', orderDirection: 'asc' })); + assert.deepEqual(block.state.u, { orderField: 'x', orderDirection: 'asc' }); + assert.lengthOf(calls.updates, 1); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-documents-source.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-documents-source.test.mjs new file mode 100644 index 0000000000..34cd531e95 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-documents-source.test.mjs @@ -0,0 +1,153 @@ +import { assert } from 'chai'; +import { InterfaceDocumentsSource } from '../../../dist/policy-engine/blocks/documents-source.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; +const origUpdate = PolicyComponentsUtils.BlockUpdateFn; + +const basePrototype = Object.getPrototypeOf(InterfaceDocumentsSource.prototype); +const rawSetData = basePrototype.setData; +const rawOnAddonEvent = basePrototype.onAddonEvent; + +function mk() { + const b = Object.create(InterfaceDocumentsSource.prototype); + b.state = {}; + return b; +} + +function makeRef(options, overrides = {}) { + const calls = { triggered: [], externals: [], updates: [], agg: [] }; + const ref = { + uuid: 'ds-uuid', + blockType: 'interfaceDocumentsSourceBlock', + actionType: 'local', + policyId: 'p1', + parent: { id: 'parent' }, + async getOptions() { return options; }, + async getGlobalSources() { return overrides.sources || []; }, + async getGlobalSourcesFilters() { return overrides.filtersAndDataType || { filters: [], dataType: 'vc-documents' }; }, + async triggerEvents(tag, user, state, status) { calls.triggered.push({ tag, user, state, status }); }, + databaseServer: { + getDocumentAggregationFilters(arg) { calls.agg.push(arg); }, + getDryRun() { return false; }, + async getVcDocumentsByAggregation() { return overrides.aggResult || []; }, + async getDidDocumentsByAggregation() { return overrides.aggResult || []; }, + async getApprovalDocumentsByAggregation() { return overrides.aggResult || []; }, + }, + ...overrides.ref, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + PolicyComponentsUtils.BlockUpdateFn = (p, u) => { calls.updates.push({ p, u }); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; + PolicyComponentsUtils.BlockUpdateFn = origUpdate; +}); + +describe('InterfaceDocumentsSource runtime — setData', () => { + it('stores per-user state and fires update + external Set', async () => { + const block = mk(); + const { ref, calls } = makeRef({}); + await withRef(ref, calls, () => rawSetData.call(block, { id: 'u' }, { foo: 1 })); + assert.deepEqual(block.state.u, { foo: 1 }); + assert.lengthOf(calls.updates, 1); + assert.lengthOf(calls.externals, 1); + }); +}); + +describe('InterfaceDocumentsSource runtime — _getData', () => { + it('uses getGlobalSources when common sorting is off', async () => { + const block = mk(); + let used = null; + const { ref } = makeRef({}, { + ref: { + async getGlobalSources() { used = 'global'; return [{ id: 'x' }]; }, + }, + }); + const out = await withRef(ref, {}, () => block._getData({ id: 'u' }, ref, false, {}, null, undefined, undefined)); + assert.equal(used, 'global'); + assert.deepEqual(out, [{ id: 'x' }]); + }); + + it('uses aggregation path when common sorting is on', async () => { + const block = mk(); + const { ref, calls } = makeRef({}, { aggResult: [{ id: 'agg' }] }); + const out = await withRef(ref, calls, () => + block._getData({ id: 'u' }, ref, true, {}, null, undefined, undefined)); + assert.deepEqual(out, [{ id: 'agg' }]); + assert.isAbove(calls.agg.length, 0); + }); +}); + +describe('InterfaceDocumentsSource runtime — onAddonEvent', () => { + it('throws when the target document is not present', async () => { + const block = mk(); + const { ref } = makeRef({ uiMetaData: { fields: [] } }, { sources: [{ id: 'a' }] }); + let threw = null; + await withRef(ref, {}, async () => { + try { + await rawOnAddonEvent.call(block, { id: 'u' }, 'tag', 'missing', async () => ({ data: {} }), null); + } catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /Document is not found/); + }); + + it('runs the handler and triggers the tag event when found', async () => { + const block = mk(); + const { ref, calls } = makeRef( + { uiMetaData: { fields: [] } }, + { sources: [{ id: 'a', __sourceTag__: null }] }); + await withRef(ref, calls, () => + rawOnAddonEvent.call(block, { id: 'u' }, 'btn', 'a', async (doc) => ({ data: { ...doc, handled: true } }), null)); + assert.lengthOf(calls.triggered, 1); + assert.equal(calls.triggered[0].tag, 'btn'); + assert.isTrue(calls.triggered[0].state.data.handled); + assert.lengthOf(calls.externals, 1); + }); +}); + +describe('InterfaceDocumentsSource runtime — getDataByAggregationFilters dispatch', () => { + it('queries VC documents for the vc-documents dataType', async () => { + const block = mk(); + const { ref, calls } = makeRef({}, { + filtersAndDataType: { filters: [], dataType: 'vc-documents' }, + aggResult: [{ id: 'vc' }], + }); + const out = await withRef(ref, calls, () => + block.getDataByAggregationFilters(ref, { id: 'u' }, {}, null, null, undefined)); + assert.deepEqual(out, [{ id: 'vc' }]); + }); + + it('returns [] for an unknown dataType', async () => { + const block = mk(); + const { ref, calls } = makeRef({}, { + filtersAndDataType: { filters: [], dataType: 'unknown' }, + }); + const out = await withRef(ref, calls, () => + block.getDataByAggregationFilters(ref, { id: 'u' }, {}, null, null, undefined)); + assert.deepEqual(out, []); + }); + + it('adds a SORT aggregation stage when sortState is provided', async () => { + const block = mk(); + const { ref, calls } = makeRef({}, { + filtersAndDataType: { filters: [], dataType: 'did-documents' }, + }); + await withRef(ref, calls, () => + block.getDataByAggregationFilters(ref, { id: 'u' }, + { orderField: 'createDate', orderDirection: 'desc' }, null, null, undefined)); + const sortStage = calls.agg.find(a => a.sortObject); + assert.isOk(sortStage); + assert.deepEqual(sortStage.sortObject, { createDate: -1 }); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-dropdown-block-addon.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-dropdown-block-addon.test.mjs new file mode 100644 index 0000000000..cbdab48621 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-dropdown-block-addon.test.mjs @@ -0,0 +1,113 @@ +import { assert } from 'chai'; +import { DropdownBlockAddon } from '../../../dist/policy-engine/blocks/dropdown-block-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; + +const basePrototype = Object.getPrototypeOf(DropdownBlockAddon.prototype); +const rawGetData = basePrototype.getData; +const rawSetData = basePrototype.setData; + +const mk = () => Object.create(DropdownBlockAddon.prototype); + +function makeRef(options, sources, overrides = {}) { + const calls = { backups: 0, addon: [] }; + const ref = { + uuid: 'dd-uuid', + blockType: 'dropdownBlockAddon', + actionType: 'local', + tag: 'dd-tag', + async getOptions() { return options; }, + async getSources() { return sources; }, + backup() { calls.backups++; }, + parent: { id: 'parent' }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, parentRef, fn) { + PolicyComponentsUtils.GetBlockRef = (target) => { + if (parentRef && target === ref.parent) { + return parentRef; + } + return ref; + }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; +}); + +describe('DropdownBlockAddon runtime — getData', () => { + const options = { optionName: 'name', optionValue: 'id', field: 'choice' }; + + it('maps each source document to {name, optionValue, value}', async () => { + const block = mk(); + const sources = [ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ]; + const { ref } = makeRef(options, sources); + const data = await withRef(ref, null, () => rawGetData.call(block, { id: 'u1', location: 'local' })); + assert.deepEqual(data.documents, [ + { name: 'Alpha', optionValue: 'a', value: 'a' }, + { name: 'Beta', optionValue: 'b', value: 'b' }, + ]); + }); + + it('spreads block options onto the result', async () => { + const block = mk(); + const { ref } = makeRef(options, []); + const data = await withRef(ref, null, () => rawGetData.call(block, { id: 'u1', location: 'local' })); + assert.equal(data.field, 'choice'); + assert.equal(data.optionName, 'name'); + assert.deepEqual(data.documents, []); + }); + + it('readonly true for REMOTE block + REMOTE user', async () => { + const block = mk(); + const { ref } = makeRef(options, [], { actionType: 'remote' }); + const data = await withRef(ref, null, () => rawGetData.call(block, { id: 'u1', location: 'remote' })); + assert.isTrue(data.readonly); + }); +}); + +describe('DropdownBlockAddon runtime — setData', () => { + const options = { optionName: 'name', optionValue: 'id', field: 'choice' }; + + it('throws when the chosen document is not in the sources', async () => { + const block = mk(); + const { ref } = makeRef(options, [{ id: 'a' }]); + let threw = null; + await withRef(ref, {}, async () => { + try { + await rawSetData.call(block, { id: 'u1' }, { dropdownDocumentId: 'missing', documentId: 'd1' }); + } catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /doesn't exist in dropdown options/); + }); + + it('invokes parent.onAddonEvent and applies the selected value, then backs up', async () => { + const block = mk(); + const sources = [{ id: 'a', id2: 'val-a' }]; + const opts = { optionName: 'name', optionValue: 'id2', field: 'choice' }; + const { ref, calls } = makeRef(opts, sources); + const parentCalls = []; + const parentRef = { + async onAddonEvent(user, tag, documentId, handler) { + const result = handler({ existing: true }); + parentCalls.push({ user, tag, documentId, result }); + }, + }; + await withRef(ref, parentRef, () => + rawSetData.call(block, { id: 'u1' }, { dropdownDocumentId: 'a', documentId: 'd1' })); + assert.lengthOf(parentCalls, 1); + assert.equal(parentCalls[0].tag, 'dd-tag'); + assert.equal(parentCalls[0].documentId, 'd1'); + assert.equal(parentCalls[0].result.data.choice, 'val-a'); + assert.equal(calls.backups, 1); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-external-data-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-external-data-block.test.mjs new file mode 100644 index 0000000000..eb066e4d52 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-external-data-block.test.mjs @@ -0,0 +1,133 @@ +import { assert } from 'chai'; +import { ExternalDataBlock } from '../../../dist/policy-engine/blocks/external-data-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origCache = PolicyComponentsUtils.getDocumentCacheFields; + +const mk = () => Object.create(ExternalDataBlock.prototype); + +function makeRef(overrides = {}) { + const calls = { errors: [] }; + const ref = { + uuid: 'ext-uuid', + blockType: 'externalDataBlock', + policyId: 'p1', + options: {}, + children: [], + error(m) { calls.errors.push(m); }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.getDocumentCacheFields = origCache; +}); + +describe('ExternalDataBlock runtime — getValidators', () => { + it('returns only ValidatorBlock children', () => { + const block = mk(); + const { ref } = makeRef({ + children: [ + { blockClassName: 'ValidatorBlock', id: 'v1' }, + { blockClassName: 'OtherBlock', id: 'o1' }, + { blockClassName: 'ValidatorBlock', id: 'v2' }, + ], + }); + const validators = withRef(ref, () => block.getValidators()); + assert.deepEqual(validators.map(v => v.id), ['v1', 'v2']); + }); + + it('returns an empty list when there are no validators', () => { + const block = mk(); + const { ref } = makeRef({ children: [{ blockClassName: 'X' }] }); + assert.deepEqual(withRef(ref, () => block.getValidators()), []); + }); +}); + +describe('ExternalDataBlock runtime — validateDocuments', () => { + it('returns null when every validator passes', async () => { + const block = mk(); + const { ref } = makeRef({ + children: [ + { blockClassName: 'ValidatorBlock', async run() { return null; } }, + { blockClassName: 'ValidatorBlock', async run() { return null; } }, + ], + }); + const out = await withRef(ref, () => block.validateDocuments({ id: 'u' }, { data: {} })); + assert.isNull(out); + }); + + it('returns the first validator error encountered', async () => { + const block = mk(); + const { ref } = makeRef({ + children: [ + { blockClassName: 'ValidatorBlock', async run() { return null; } }, + { blockClassName: 'ValidatorBlock', async run() { return 'bad doc'; } }, + { blockClassName: 'ValidatorBlock', async run() { return 'never reached'; } }, + ], + }); + const out = await withRef(ref, () => block.validateDocuments({ id: 'u' }, { data: {} })); + assert.equal(out, 'bad doc'); + }); + + it('passes the policy user and state into each validator', async () => { + const block = mk(); + let captured; + const { ref } = makeRef({ + children: [{ blockClassName: 'ValidatorBlock', async run(e) { captured = e; return null; } }], + }); + await withRef(ref, () => block.validateDocuments({ id: 'u9' }, { data: { x: 1 } })); + assert.equal(captured.user.id, 'u9'); + assert.deepEqual(captured.data, { data: { x: 1 } }); + }); +}); + +describe('ExternalDataBlock runtime — getSchema', () => { + it('returns null when no schema option is configured', async () => { + const block = mk(); + const { ref } = makeRef({ options: {} }); + const out = await withRef(ref, () => block.getSchema()); + assert.isNull(out); + }); + + it('returns a cached schema instance without reloading', async () => { + const block = mk(); + block.schema = { cached: true }; + const { ref } = makeRef({ options: { schema: '#Foo' } }); + const out = await withRef(ref, () => block.getSchema()); + assert.deepEqual(out, { cached: true }); + }); +}); + +describe('ExternalDataBlock runtime — getRelationships error path', () => { + it('wraps load failures in a BlockActionError and logs', async () => { + const block = mk(); + const { ref, calls } = makeRef(); + let threw = null; + try { + await withRef(ref, () => block.getRelationships(ref, 'ref-id')); + } catch (e) { threw = e; } + assert.isNotNull(threw); + assert.match(threw.message, /Invalid relationships/); + assert.isAbove(calls.errors.length, 0); + }); +}); + +describe('ExternalDataBlock runtime — beforeInit', () => { + it('registers credentialSubject.0.id into the document cache', async () => { + const block = mk(); + const cache = new Set(); + PolicyComponentsUtils.getDocumentCacheFields = () => cache; + const { ref } = makeRef(); + await withRef(ref, () => block.beforeInit()); + assert.isTrue(cache.has('credentialSubject.0.id')); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-filters-addon-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-filters-addon-block.test.mjs new file mode 100644 index 0000000000..bc130f2241 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-filters-addon-block.test.mjs @@ -0,0 +1,182 @@ +import { assert } from 'chai'; +import { FiltersAddonBlock } from '../../../dist/policy-engine/blocks/filters-addon-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; + +const rawSetData = Object.getPrototypeOf(FiltersAddonBlock.prototype).setData; + +function mk() { + const b = Object.create(FiltersAddonBlock.prototype); + b.previousState = {}; + b.previousFilters = {}; + b.state = { lastData: null, lastValue: null }; + return b; +} + +function makeRef(options, sources = [], overrides = {}) { + const calls = { setFilters: [], externals: [] }; + const ref = { + uuid: 'flt-uuid', + blockType: 'filtersAddon', + actionType: 'local', + filters: {}, + async getOptions() { return options; }, + async getSources() { return sources; }, + setFilters(f, user) { calls.setFilters.push({ f, user }); ref.filters[user.id] = f; }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; +}); + +describe('FiltersAddonBlock runtime — addQuery', () => { + it('writes an $eq expression for the configured field', async () => { + const block = mk(); + const { ref } = makeRef({ field: 'colour', queryType: 'equal' }); + const filter = {}; + await withRef(ref, {}, () => block.addQuery(filter, 'red', { id: 'u' })); + assert.deepEqual(filter.colour, { $eq: 'red' }); + }); + + it('writes an $in expression for an in query', async () => { + const block = mk(); + const { ref } = makeRef({ field: 'tag', queryType: 'in' }); + const filter = {}; + await withRef(ref, {}, () => block.addQuery(filter, 'a,b', { id: 'u' })); + assert.deepEqual(filter.tag, { $in: ['a', 'b'] }); + }); +}); + +describe('FiltersAddonBlock runtime — checkValues', () => { + const block = mk(); + const { ref } = makeRef({ queryType: 'equal' }); + + it('returns false when lastData is not an array', async () => { + const out = await withRef(ref, {}, () => + block.checkValues({ lastData: null }, 'x', { id: 'u' })); + assert.isFalse(out); + }); + + it('returns true when a scalar value is present in lastData', async () => { + const out = await withRef(ref, {}, () => + block.checkValues({ lastData: [{ value: 'a' }, { value: 'b' }] }, 'b', { id: 'u' })); + assert.isTrue(out); + }); + + it('returns false when the scalar value is absent', async () => { + const out = await withRef(ref, {}, () => + block.checkValues({ lastData: [{ value: 'a' }] }, 'z', { id: 'u' })); + assert.isFalse(out); + }); +}); + +describe('FiltersAddonBlock runtime — getData', () => { + it('builds dropdown data and dedupes by value', async () => { + const block = mk(); + const options = { type: 'dropdown', optionName: 'name', optionValue: 'id', canBeEmpty: true }; + const sources = [ + { id: '1', name: 'One' }, + { id: '1', name: 'One dup' }, + { id: '2', name: 'Two' }, + ]; + const { ref } = makeRef(options, sources); + const data = await withRef(ref, {}, () => block.getData({ id: 'u' })); + assert.equal(data.type, 'dropdown'); + assert.lengthOf(data.data, 2); + assert.deepEqual(data.data.map(d => d.value), ['1', '2']); + }); + + it('returns the stored filterValue for input type', async () => { + const block = mk(); + block.state.u = { lastValue: 'typed' }; + const options = { type: 'input', canBeEmpty: true }; + const { ref } = makeRef(options, []); + const data = await withRef(ref, {}, () => block.getData({ id: 'u' })); + assert.equal(data.type, 'input'); + assert.equal(data.filterValue, 'typed'); + }); + + it('marks readonly for REMOTE block + REMOTE user', async () => { + const block = mk(); + const { ref } = makeRef({ type: 'input', canBeEmpty: true }, [], { actionType: 'remote' }); + const data = await withRef(ref, {}, () => block.getData({ id: 'u', location: 'remote' })); + assert.isTrue(data.readonly); + }); +}); + +describe('FiltersAddonBlock runtime — resetFilters', () => { + it('restores previousState and previousFilters and clears them', async () => { + const block = mk(); + block.previousState.u = { lastValue: 'old' }; + block.previousFilters.u = { f: 1 }; + const { ref } = makeRef({}); + await withRef(ref, {}, () => block.resetFilters({ id: 'u' })); + assert.deepEqual(block.state.u, { lastValue: 'old' }); + assert.deepEqual(ref.filters.u, { f: 1 }); + assert.isUndefined(block.previousState.u); + assert.isUndefined(block.previousFilters.u); + }); + + it('is a no-op when there is nothing to restore', async () => { + const block = mk(); + const { ref } = makeRef({}); + await withRef(ref, {}, () => block.resetFilters({ id: 'u' })); + assert.isUndefined(block.state.u); + }); +}); + +describe('FiltersAddonBlock runtime — setFiltersStrict', () => { + it('throws when data is missing', async () => { + const block = mk(); + const { ref } = makeRef({ type: 'input' }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.setFiltersStrict({ id: 'u' }, null); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /filter value is unknown/); + }); + + it('throws when value empty and canBeEmpty is false (input)', async () => { + const block = mk(); + const { ref } = makeRef({ type: 'input', canBeEmpty: false }); + let threw = null; + await withRef(ref, {}, async () => { + try { await block.setFiltersStrict({ id: 'u' }, { filterValue: '' }); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + }); + + it('applies the filter for a non-empty input value', async () => { + const block = mk(); + const { ref, calls } = makeRef({ type: 'input', field: 'q', queryType: 'equal', canBeEmpty: false }); + await withRef(ref, calls, () => block.setFiltersStrict({ id: 'u' }, { filterValue: 'hello' })); + assert.lengthOf(calls.setFilters, 1); + assert.deepEqual(calls.setFilters[0].f.q, { $eq: 'hello' }); + assert.equal(block.state.u.lastValue, 'hello'); + }); +}); + +describe('FiltersAddonBlock runtime — setData', () => { + it('delegates to setFilterState and emits an external Set event', async () => { + const block = mk(); + const { ref, calls } = makeRef({ type: 'input', field: 'q', queryType: 'equal', canBeEmpty: false }); + await withRef(ref, calls, () => rawSetData.call(block, { id: 'u' }, { filterValue: 'v' })); + assert.lengthOf(calls.setFilters, 1); + assert.lengthOf(calls.externals, 1); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-http-request-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-http-request-block.test.mjs new file mode 100644 index 0000000000..d579f38367 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-http-request-block.test.mjs @@ -0,0 +1,58 @@ +import { assert } from 'chai'; +import { HttpRequestBlock } from '../../../dist/policy-engine/blocks/http-request-block.js'; + +const basePrototype = Object.getPrototypeOf(HttpRequestBlock.prototype); +const block = Object.create(HttpRequestBlock.prototype); + +describe('HttpRequestBlock runtime — getFieldByPath', () => { + it('reads a top-level field', () => { + assert.equal(block.getFieldByPath({ a: 1 }, 'a'), 1); + }); + + it('reads a nested field via dotted path', () => { + assert.equal(block.getFieldByPath({ a: { b: { c: 'deep' } } }, 'a.b.c'), 'deep'); + }); + + it('returns empty string when an intermediate node is undefined', () => { + assert.equal(block.getFieldByPath({ a: {} }, 'a.b.c'), ''); + }); + + it('returns empty string for a missing top-level field', () => { + assert.equal(block.getFieldByPath({}, 'x.y'), ''); + }); + + it('returns undefined for an existing-but-undefined leaf', () => { + assert.isUndefined(block.getFieldByPath({ a: undefined }, 'a')); + }); +}); + +describe('HttpRequestBlock runtime — replaceVariablesInString', () => { + it('replaces a single ${var} token', () => { + const out = block.replaceVariablesInString('Hi ${name}', { name: 'Bob' }); + assert.equal(out, 'Hi Bob'); + }); + + it('replaces a nested-path token', () => { + const out = block.replaceVariablesInString('${user.did}', { user: { did: 'did:9' } }); + assert.equal(out, 'did:9'); + }); + + it('leaves a string with no tokens unchanged', () => { + assert.equal(block.replaceVariablesInString('plain text', {}), 'plain text'); + }); + + it('substitutes empty string when an intermediate path node is missing', () => { + const out = block.replaceVariablesInString('x=${a.b.c}', { a: {} }); + assert.equal(out, 'x='); + }); + + it('replaces a token inside a JSON-shaped string', () => { + const out = block.replaceVariablesInString('{"u":"${username}"}', { username: 'alice' }); + assert.equal(out, '{"u":"alice"}'); + }); + + it('keeps the value when the path resolves to a number', () => { + const out = block.replaceVariablesInString('n=${count}', { count: 7 }); + assert.equal(out, 'n=7'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-pagination-addon.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-pagination-addon.test.mjs new file mode 100644 index 0000000000..af84e96c72 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-pagination-addon.test.mjs @@ -0,0 +1,150 @@ +import { assert } from 'chai'; +import { PaginationAddon } from '../../../dist/policy-engine/blocks/pagination-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; +const origUpdate = PolicyComponentsUtils.BlockUpdateFn; + +const mk = () => { + const b = Object.create(PaginationAddon.prototype); + b.state = {}; + b.prevState = {}; + return b; +}; + +function makeRef(totalCount = 100, overrides = {}) { + const calls = { sources: [], backups: 0, externals: [], updates: [] }; + const ref = { + uuid: 'pag-uuid', + blockType: 'paginationAddon', + actionType: 'local', + parent: { + async getGlobalSources(user, data, count) { + calls.sources.push({ user, data, count }); + return totalCount; + }, + }, + backup() { calls.backups++; }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + PolicyComponentsUtils.BlockUpdateFn = (p, u) => { calls.updates.push({ p, u }); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; + PolicyComponentsUtils.BlockUpdateFn = origUpdate; +}); + +describe('PaginationAddon runtime — getState', () => { + it('initialises default state for a new user', async () => { + const block = mk(); + const { ref } = makeRef(100); + const state = await withRef(ref, {}, () => block.getState({ id: 'u1', location: 'local' })); + assert.equal(state.itemsPerPage, 10); + assert.equal(state.page, 0); + assert.equal(state.id, 'pag-uuid'); + assert.equal(state.blockType, 'paginationAddon'); + }); + + it('overwrites size with the live total count', async () => { + const block = mk(); + const { ref } = makeRef(57); + const state = await withRef(ref, {}, () => block.getState({ id: 'u1', location: 'local' })); + assert.equal(state.size, 57); + }); + + it('keeps existing page/itemsPerPage across calls', async () => { + const block = mk(); + block.state.u1 = { size: 5, itemsPerPage: 25, page: 3 }; + const { ref } = makeRef(99); + const state = await withRef(ref, {}, () => block.getState({ id: 'u1', location: 'local' })); + assert.equal(state.itemsPerPage, 25); + assert.equal(state.page, 3); + assert.equal(state.size, 99); + }); + + it('marks readonly when REMOTE block and REMOTE user', async () => { + const block = mk(); + const { ref } = makeRef(10, { actionType: 'remote' }); + const state = await withRef(ref, {}, () => block.getState({ id: 'u1', location: 'remote' })); + assert.isTrue(state.readonly); + }); + + it('is not readonly when user is local', async () => { + const block = mk(); + const { ref } = makeRef(10, { actionType: 'remote' }); + const state = await withRef(ref, {}, () => block.getState({ id: 'u1', location: 'local' })); + assert.isFalse(state.readonly); + }); +}); + +describe('PaginationAddon runtime — setState', () => { + it('stores prevState and applies new values', async () => { + const block = mk(); + block.state.u1 = { size: 10, itemsPerPage: 10, page: 0 }; + const { ref } = makeRef(10); + await withRef(ref, {}, () => + block.setState({ id: 'u1', location: 'local' }, { size: 1, itemsPerPage: 50, page: 2 })); + assert.equal(block.state.u1.itemsPerPage, 50); + assert.equal(block.state.u1.page, 2); + assert.deepEqual(block.prevState.u1, { size: 10, itemsPerPage: 10, page: 0 }); + }); + + it('corrects size to live total count', async () => { + const block = mk(); + const { ref } = makeRef(33); + await withRef(ref, {}, () => + block.setState({ id: 'u1', location: 'local' }, { size: 99, itemsPerPage: 10, page: 0 })); + assert.equal(block.state.u1.size, 33); + }); +}); + +describe('PaginationAddon runtime — resetPagination', () => { + it('restores prevState and clears it', async () => { + const block = mk(); + block.prevState.u1 = { size: 1, itemsPerPage: 10, page: 9 }; + block.state.u1 = { size: 2, itemsPerPage: 20, page: 1 }; + await block.resetPagination({ id: 'u1' }); + assert.deepEqual(block.state.u1, { size: 1, itemsPerPage: 10, page: 9 }); + assert.isUndefined(block.prevState.u1); + }); + + it('is a no-op when there is no prevState', async () => { + const block = mk(); + block.state.u1 = { size: 2, itemsPerPage: 20, page: 1 }; + await block.resetPagination({ id: 'u1' }); + assert.deepEqual(block.state.u1, { size: 2, itemsPerPage: 20, page: 1 }); + }); +}); + +describe('PaginationAddon runtime — getData', () => { + it('delegates to getState', async () => { + const block = mk(); + const { ref } = makeRef(12); + const data = await withRef(ref, {}, () => block.getData({ id: 'u1', location: 'local' })); + assert.equal(data.size, 12); + assert.equal(data.itemsPerPage, 10); + }); +}); + +describe('PaginationAddon runtime — setData', () => { + it('stores raw data, fires update/external, backs up', async () => { + const block = mk(); + const calls = { externals: [], updates: [] }; + const { ref } = makeRef(10); + await withRef(ref, calls, () => + block.setData({ id: 'u1' }, { itemsPerPage: 5, page: 7 })); + assert.deepEqual(block.state.u1, { itemsPerPage: 5, page: 7 }); + assert.lengthOf(calls.updates, 1); + assert.lengthOf(calls.externals, 1); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-report-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-report-block.test.mjs new file mode 100644 index 0000000000..9ea8961cc7 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-report-block.test.mjs @@ -0,0 +1,182 @@ +import { assert } from 'chai'; +import { ReportBlock } from '../../../dist/policy-engine/blocks/report-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; + +const rawSetData = Object.getPrototypeOf(ReportBlock.prototype).setData; + +function mk() { + const b = Object.create(ReportBlock.prototype); + b.state = {}; + return b; +} + +function makeRef(overrides = {}) { + const calls = { externals: [], backups: 0, vc: [], vp: [] }; + const ref = { + uuid: 'rep-uuid', + blockType: 'reportBlock', + actionType: 'local', + policyId: 'p1', + async getOptions() { return { uiMetaData: { vpSectionHeader: 'H' } }; }, + getItems() { return []; }, + databaseServer: { + async getVcDocument() { return null; }, + async getVcDocuments() { return []; }, + async getVpDocument() { return null; }, + async getVpDocuments() { return []; }, + async getVPMintInformation() { return {}; }, + }, + backup() { calls.backups++; }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; +}); + +describe('ReportBlock runtime — getUserName', () => { + it('returns null for a falsy did', async () => { + const block = mk(); + assert.isNull(await block.getUserName(null, {}, 'uid')); + }); + + it('returns a cached username from the map', async () => { + const block = mk(); + const map = { 'did:1': 'alice' }; + assert.equal(await block.getUserName('did:1', map, 'uid'), 'alice'); + }); +}); + +describe('ReportBlock runtime — addReportByVC', () => { + it('builds the vcDocument and records variable ids', async () => { + const block = mk(); + const vc = { + tag: 't', hash: 'h', owner: 'did:o', + document: { id: 'docId', credentialSubject: [{ id: 'subjId' }] }, + }; + const report = {}; + const variables = {}; + const out = await block.addReportByVC(report, variables, vc); + assert.equal(out.vcDocument.type, 'VC'); + assert.equal(out.vcDocument.hash, 'h'); + assert.equal(out.vcDocument.issuer, 'did:o'); + assert.equal(variables.documentId, 'docId'); + assert.equal(variables.documentSubjectId, 'subjId'); + }); +}); + +describe('ReportBlock runtime — addReportByVCs', () => { + it('separates impacts and plain documents and sets variable ids', async () => { + const block = mk(); + const { ref } = makeRef(); + const vcs = [ + { id: 'plain1', credentialSubject: [{ id: 's1', type: 'Foo' }] }, + { + id: 'impact1', + credentialSubject: [{ id: 's2', type: 'ActivityImpact', impactType: 'CO2', label: 'L', amount: 1 }], + }, + { id: 'mint', credentialSubject: [{ id: 'm' }] }, + ]; + const report = {}; + const variables = {}; + const out = await withRef(ref, {}, () => + block.addReportByVCs(report, variables, vcs, { tag: 'tg', owner: 'did:o' })); + assert.lengthOf(out.impacts, 1); + assert.equal(out.impacts[0].impactType, 'CO2'); + assert.deepEqual(variables.documentIds, ['plain1']); + assert.deepEqual(variables.documentSubjectIds, ['s1']); + }); + + it('does not set impacts when there are none', async () => { + const block = mk(); + const { ref } = makeRef(); + const vcs = [ + { id: 'plain1', credentialSubject: [{ id: 's1', type: 'Foo' }] }, + { id: 'mint', credentialSubject: [{ id: 'm' }] }, + ]; + const report = {}; + const out = await withRef(ref, {}, () => + block.addReportByVCs(report, {}, vcs, { tag: 'tg', owner: 'o' })); + assert.isUndefined(out.impacts); + }); +}); + +describe('ReportBlock runtime — addReportByVP', () => { + it('builds vpDocument and mintDocument from the presentation', async () => { + const block = mk(); + const { ref } = makeRef(); + const vp = { + tag: 'tg', hash: 'h', owner: 'did:o', + messageId: 'm1', mainDocument: 'm1', + amount: 5, mintAmount: 5, transferAmount: 0, + mintExpected: 5, transferExpected: 0, wasTransferNeeded: false, + tokenIds: ['0.0.1'], + document: { + verifiableCredential: [ + { id: 'vc1', credentialSubject: [{ id: 's1', type: 'Foo' }] }, + { id: 'mint', credentialSubject: [{ id: 'm', date: '2021', amount: 5 }] }, + ], + }, + }; + const out = await withRef(ref, {}, () => block.addReportByVP({}, {}, vp)); + assert.equal(out.vpDocument.type, 'VP'); + assert.equal(out.vpDocument.hash, 'h'); + assert.equal(out.mintDocument.type, 'VC'); + assert.equal(out.mintDocument.tokenId, '0.0.1'); + assert.isNull(out.mintDocument.mainDocument); + }); +}); + +describe('ReportBlock runtime — reportUserMap', () => { + it('resolves usernames for each present section', async () => { + const block = mk(); + const { ref } = makeRef({}); + const report = { + vpDocument: { username: 'did:1' }, + vcDocument: { username: 'did:2' }, + mintDocument: null, + policyDocument: null, + policyCreatorDocument: null, + documents: null, + }; + const map = { 'did:1': 'one', 'did:2': 'two' }; + block.getUserName = async (did) => map[did] || did; + const out = await withRef(ref, {}, () => block.reportUserMap(report, 'uid')); + assert.equal(out.vpDocument.username, 'one'); + assert.equal(out.vcDocument.username, 'two'); + }); +}); + +describe('ReportBlock runtime — getData (no stored hash)', () => { + it('returns an empty report shell when there is no lastValue', async () => { + const block = mk(); + const { ref } = makeRef(); + const data = await withRef(ref, {}, () => block.getData({ id: 'u', location: 'local' })); + assert.isNull(data.hash); + assert.isNull(data.data); + assert.deepEqual(data.uiMetaData, { vpSectionHeader: 'H' }); + }); +}); + +describe('ReportBlock runtime — setData', () => { + it('stores the filterValue as lastValue, emits external Set, backs up', async () => { + const block = mk(); + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => rawSetData.call(block, { id: 'u' }, { filterValue: 'hash-x' })); + assert.equal(block.state.u.lastValue, 'hash-x'); + assert.lengthOf(calls.externals, 1); + assert.equal(calls.backups, 1); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-report-item-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-report-item-block.test.mjs new file mode 100644 index 0000000000..cc569ad7fa --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-report-item-block.test.mjs @@ -0,0 +1,174 @@ +import { assert } from 'chai'; +import { ReportItemBlock } from '../../../dist/policy-engine/blocks/report-item-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; +const origCache = PolicyComponentsUtils.getDocumentCacheFields; + +const mk = () => Object.create(ReportItemBlock.prototype); + +function makeRef(options, docs = [], overrides = {}) { + const ref = { + uuid: 'ri-uuid', + blockType: 'reportItemBlock', + policyId: 'p1', + options, + async getOptions() { return options; }, + getItems() { return []; }, + databaseServer: { + async getVcDocuments() { return docs; }, + async getVcDocument() { return docs[0] || null; }, + }, + ...overrides, + }; + return { ref }; +} + +function withRef(ref, externals, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { externals.push(e); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; + PolicyComponentsUtils.getDocumentCacheFields = origCache; +}); + +describe('ReportItemBlock runtime — run filter building', () => { + it('builds $eq filter from a value-typed filter', async () => { + const block = mk(); + let captured; + const { ref } = makeRef({ + multiple: false, + filters: [{ field: 'status', type: 'equal', typeValue: 'value', value: 'ok' }], + }, []); + ref.databaseServer.getVcDocument = async (f) => { captured = f; return null; }; + const resultFields = []; + await withRef(ref, [], () => block.run(resultFields, {})); + assert.deepEqual(captured.status, { $eq: 'ok' }); + assert.deepEqual(captured.policyId, { $eq: 'p1' }); + assert.deepEqual(captured.schema, { $ne: '#UserRole' }); + }); + + it('resolves a variable-typed filter from the variables bag', async () => { + const block = mk(); + let captured; + const { ref } = makeRef({ + multiple: false, + filters: [{ field: 'owner', type: 'equal', typeValue: 'variable', value: 'docOwner' }], + }, []); + ref.databaseServer.getVcDocument = async (f) => { captured = f; return null; }; + await withRef(ref, [], () => block.run([], { docOwner: 'did:x' })); + assert.deepEqual(captured.owner, { $eq: 'did:x' }); + }); + + it('builds $in from a scalar value', async () => { + const block = mk(); + let captured; + const { ref } = makeRef({ + multiple: false, + filters: [{ field: 'k', type: 'in', typeValue: 'value', value: 'v' }], + }, []); + ref.databaseServer.getVcDocument = async (f) => { captured = f; return null; }; + await withRef(ref, [], () => block.run([], {})); + assert.deepEqual(captured.k, { $in: ['v'] }); + }); + + it('builds $nin from an array value', async () => { + const block = mk(); + let captured; + const { ref } = makeRef({ + multiple: false, + filters: [{ field: 'k', type: 'not_in', typeValue: 'value', value: ['a', 'b'] }], + }, []); + ref.databaseServer.getVcDocument = async (f) => { captured = f; return null; }; + await withRef(ref, [], () => block.run([], {})); + assert.deepEqual(captured.k, { $nin: ['a', 'b'] }); + }); + + it('throws BlockActionError on an unknown filter type', async () => { + const block = mk(); + const { ref } = makeRef({ + multiple: false, + filters: [{ field: 'k', type: 'bogus', typeValue: 'value', value: 'v' }], + }, []); + let threw = null; + await withRef(ref, [], async () => { + try { await block.run([], {}); } + catch (e) { threw = e; } + }); + assert.isNotNull(threw); + assert.match(threw.message, /Unknown filter type/); + }); +}); + +describe('ReportItemBlock runtime — run result fields', () => { + it('pushes a result item and flags notFoundDocuments when empty', async () => { + const block = mk(); + const { ref } = makeRef({ multiple: false, icon: 'i', title: 't' }, []); + ref.databaseServer.getVcDocument = async () => null; + const resultFields = []; + const [notFound, returned] = await withRef(ref, [], () => block.run(resultFields, {})); + assert.isTrue(notFound); + assert.lengthOf(resultFields, 1); + assert.equal(resultFields[0].title, 't'); + assert.isTrue(resultFields[0].notFoundDocuments); + assert.strictEqual(returned, resultFields); + }); + + it('populates single document fields and extracts variables', async () => { + const block = mk(); + const vc = { tag: 'tg', id: 'vcid', credentialSubject: [{ thing: 42 }] }; + const { ref } = makeRef({ + multiple: false, + variables: [{ name: 'extracted', value: 'thing' }], + }, [vc]); + ref.databaseServer.getVcDocument = async () => vc; + const variables = {}; + const resultFields = []; + await withRef(ref, [], () => block.run(resultFields, variables)); + assert.isFalse(resultFields[0].notFoundDocuments); + assert.equal(resultFields[0].tag, 'tg'); + }); + + it('collects multiple documents into an array', async () => { + const block = mk(); + const docs = [ + { tag: 'a', id: '1', credentialSubject: [{}] }, + { tag: 'b', id: '2', credentialSubject: [{}] }, + ]; + const { ref } = makeRef({ multiple: true }, docs); + ref.databaseServer.getVcDocuments = async () => docs; + const resultFields = []; + await withRef(ref, [], () => block.run(resultFields, {})); + assert.lengthOf(resultFields[0].document, 2); + }); + + it('emits an external Run event', async () => { + const block = mk(); + const { ref } = makeRef({ multiple: false }, []); + ref.databaseServer.getVcDocument = async () => null; + const externals = []; + await withRef(ref, externals, () => block.run([], {})); + assert.lengthOf(externals, 1); + }); +}); + +describe('ReportItemBlock runtime — beforeInit', () => { + it('registers document.* filter and variable paths into the cache', async () => { + const block = mk(); + const cache = new Set(); + PolicyComponentsUtils.getDocumentCacheFields = () => cache; + const { ref } = makeRef({ + filters: [{ field: 'document.foo' }, { field: 'other' }], + variables: [{ value: 'document.bar' }, { value: 'plain' }], + }); + await withRef(ref, [], () => block.beforeInit()); + assert.isTrue(cache.has('foo')); + assert.isTrue(cache.has('bar')); + assert.isFalse(cache.has('other')); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-switch-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-switch-block.test.mjs new file mode 100644 index 0000000000..c727e07711 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-switch-block.test.mjs @@ -0,0 +1,161 @@ +import { assert } from 'chai'; +import { SwitchBlock } from '../../../dist/policy-engine/blocks/switch-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; + +const basePrototype = Object.getPrototypeOf(SwitchBlock.prototype); +const rawRunAction = basePrototype.runAction; + +const mk = () => Object.create(SwitchBlock.prototype); + +function makeRef(options, overrides = {}) { + const calls = { triggered: [], logs: [], errors: [], backups: 0, externals: [] }; + const ref = { + uuid: 'sw-uuid', + policyId: 'p1', + blockType: 'switchBlock', + async getOptions() { return options; }, + async triggerEvents(type, user, state, status) { + calls.triggered.push({ type, user, state, status }); + }, + log(m) { calls.logs.push(m); }, + error(m) { calls.errors.push(m); }, + backup() { calls.backups++; }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; +}); + +describe('SwitchBlock runtime — aggregateScope', () => { + const block = mk(); + + it('returns null for null scopes', () => { + assert.isNull(block.aggregateScope(null)); + }); + + it('returns null for empty scopes', () => { + assert.isNull(block.aggregateScope([])); + }); + + it('collects values per first-scope key', () => { + const out = block.aggregateScope([{ a: 1 }, { a: 2 }]); + assert.deepEqual(out, { a: [1, 2] }); + }); + + it('uses only keys from the first scope', () => { + const out = block.aggregateScope([{ a: 1 }, { a: 2, b: 9 }]); + assert.deepEqual(Object.keys(out), ['a']); + }); +}); + +describe('SwitchBlock runtime — getScope', () => { + const block = mk(); + + it('returns null for falsy docs', () => { + assert.isNull(block.getScope(null)); + }); + + it('returns {} for a single doc without a document body', () => { + assert.deepEqual(block.getScope({ owner: 'x' }), {}); + }); + + it('returns {} for an empty array (aggregate of nothing)', () => { + assert.isNull(block.getScope([])); + }); +}); + +describe('SwitchBlock runtime — runAction conditions', () => { + it('unconditional fires its tag plus refresh and release', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + executionFlow: 'allTrue', + conditions: [{ type: 'unconditional', tag: 'go' }], + }); + const out = await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u', userId: 'uid' }, data: { data: { owner: 'o' } }, actionStatus: null })); + const types = calls.triggered.map(t => t.type); + assert.include(types, 'go'); + assert.include(types, 'RefreshEvent'); + assert.include(types, 'ReleaseEvent'); + assert.deepEqual(out, { data: { owner: 'o' } }); + }); + + it('firstTrue stops after the first matching condition', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + executionFlow: 'firstTrue', + conditions: [ + { type: 'unconditional', tag: 'first' }, + { type: 'unconditional', tag: 'second' }, + ], + }); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: { owner: 'o' } }, actionStatus: null })); + const tags = calls.triggered.map(t => t.type); + assert.include(tags, 'first'); + assert.notInclude(tags, 'second'); + }); + + it('allTrue evaluates every unconditional condition', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + executionFlow: 'allTrue', + conditions: [ + { type: 'unconditional', tag: 'first' }, + { type: 'unconditional', tag: 'second' }, + ], + }); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: { owner: 'o' } }, actionStatus: null })); + const tags = calls.triggered.map(t => t.type); + assert.include(tags, 'first'); + assert.include(tags, 'second'); + }); + + it('equal condition with no scope yields false (tag not fired)', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + executionFlow: 'allTrue', + conditions: [{ type: 'equal', tag: 'eq', value: 'x == 1' }], + }); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: { owner: 'o' } }, actionStatus: null })); + const tags = calls.triggered.map(t => t.type); + assert.notInclude(tags, 'eq'); + assert.include(tags, 'ReleaseEvent'); + }); + + it('always emits an external Run event and backs up', async () => { + const block = mk(); + const { ref, calls } = makeRef({ executionFlow: 'allTrue', conditions: [] }); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: { owner: 'o' } }, actionStatus: null })); + assert.lengthOf(calls.externals, 1); + assert.equal(calls.backups, 1); + }); + + it('handles array document input (owner taken from first element)', async () => { + const block = mk(); + const { ref, calls } = makeRef({ + executionFlow: 'allTrue', + conditions: [{ type: 'unconditional', tag: 'go' }], + }); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: [{ owner: 'first' }] }, actionStatus: null })); + const tags = calls.triggered.map(t => t.type); + assert.include(tags, 'go'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-timer-block.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-timer-block.test.mjs new file mode 100644 index 0000000000..6e2164ef98 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-timer-block.test.mjs @@ -0,0 +1,141 @@ +import { assert } from 'chai'; +import { TimerBlock } from '../../../dist/policy-engine/blocks/timer-block.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origExt = PolicyComponentsUtils.ExternalEventFn; + +const basePrototype = Object.getPrototypeOf(TimerBlock.prototype); +const rawRunAction = basePrototype.runAction; +const rawStartAction = basePrototype.startAction; +const rawStopAction = basePrototype.stopAction; +const rawTickCron = basePrototype.tickCron; + +function mk() { + const b = Object.create(TimerBlock.prototype); + b.state = {}; + return b; +} + +function makeRef(overrides = {}) { + const calls = { triggered: [], logs: [], saved: 0, backups: 0, externals: [] }; + const ref = { + uuid: 'tm-uuid', + blockType: 'timerBlock', + async getOptions() { return {}; }, + async triggerEvents(type, user, state, status) { + calls.triggered.push({ type, user, state, status }); + }, + async saveState() { calls.saved++; }, + log(m) { calls.logs.push(m); }, + backup() { calls.backups++; }, + ...overrides, + }; + return { ref, calls }; +} + +function withRef(ref, calls, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.ExternalEventFn = (e) => { calls.externals.push(e); }; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.ExternalEventFn = origExt; +}); + +describe('TimerBlock runtime — getUserId', () => { + const block = mk(); + + it('returns null when there is no document', () => { + assert.isNull(block.getUserId({ data: {} })); + }); + + it('returns owner for a single non-group document', () => { + assert.equal(block.getUserId({ data: { data: { owner: 'did:1' } } }), 'did:1'); + }); + + it('returns group:owner for a grouped document', () => { + assert.equal(block.getUserId({ data: { data: { owner: 'd', group: 'g' } } }), 'g:d'); + }); + + it('uses the first element of an array document', () => { + assert.equal(block.getUserId({ data: { data: [{ owner: 'd1' }, { owner: 'd2' }] } }), 'd1'); + }); + + it('returns null for an empty array document', () => { + assert.isNull(block.getUserId({ data: { data: [] } })); + }); +}); + +describe('TimerBlock runtime — runAction', () => { + it('marks the user active and fires run/release/refresh', async () => { + const block = mk(); + const { ref, calls } = makeRef(); + const out = await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: { owner: 'did:1' } }, actionStatus: null })); + assert.isTrue(block.state['did:1']); + const types = calls.triggered.map(t => t.type); + assert.include(types, 'RunEvent'); + assert.include(types, 'ReleaseEvent'); + assert.include(types, 'RefreshEvent'); + assert.equal(calls.saved, 1); + assert.equal(calls.backups, 1); + assert.deepEqual(out, { data: { owner: 'did:1' } }); + }); + + it('still saves state and triggers events without a resolvable user id', async () => { + const block = mk(); + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => + rawRunAction.call(block, { user: { id: 'u' }, data: { data: {} }, actionStatus: null })); + assert.deepEqual(block.state, {}); + assert.equal(calls.saved, 1); + }); +}); + +describe('TimerBlock runtime — startAction / stopAction', () => { + it('startAction sets state true and saves', async () => { + const block = mk(); + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => + rawStartAction.call(block, { data: { data: { owner: 'd1' } } })); + assert.isTrue(block.state.d1); + assert.equal(calls.saved, 1); + assert.equal(calls.backups, 1); + }); + + it('stopAction sets state false and saves', async () => { + const block = mk(); + block.state.d1 = true; + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => + rawStopAction.call(block, { data: { data: { owner: 'd1' } } })); + assert.isFalse(block.state.d1); + assert.equal(calls.saved, 1); + }); +}); + +describe('TimerBlock runtime — tickCron', () => { + it('fires the timer event with only active users', async () => { + const block = mk(); + block.state = { a: true, b: false, c: true }; + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => rawTickCron.call(block, ref)); + const timer = calls.triggered.find(t => t.type === 'TimerEvent'); + assert.isOk(timer); + assert.deepEqual(timer.state.sort(), ['a', 'c']); + assert.equal(calls.backups, 1); + assert.lengthOf(calls.externals, 1); + }); + + it('fires the timer event with an empty map when no active users', async () => { + const block = mk(); + block.state = { a: false }; + const { ref, calls } = makeRef(); + await withRef(ref, calls, () => rawTickCron.call(block, ref)); + const timer = calls.triggered.find(t => t.type === 'TimerEvent'); + assert.deepEqual(timer.state, []); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/runtime-ui-getdata-blocks.test.mjs b/policy-service/tests/unit-tests/blocks/runtime-ui-getdata-blocks.test.mjs new file mode 100644 index 0000000000..b4af3ada7e --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/runtime-ui-getdata-blocks.test.mjs @@ -0,0 +1,162 @@ +import { assert } from 'chai'; +import { InformationBlock } from '../../../dist/policy-engine/blocks/information-block.js'; +import { InterfaceContainerBlock } from '../../../dist/policy-engine/blocks/container-block.js'; +import { HttpRequestUIAddon } from '../../../dist/policy-engine/blocks/http-request-ui-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const origGetRef = PolicyComponentsUtils.GetBlockRef; + +function makeRef(options = {}, overrides = {}) { + return { + uuid: 'ui-uuid', + blockType: 'someUiBlock', + actionType: 'local', + mockId: 'mock-1', + async getOptions() { return options; }, + ...overrides, + }; +} + +function withRef(ref, fn) { + PolicyComponentsUtils.GetBlockRef = () => ref; + return fn(); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; +}); + +describe('InformationBlock runtime — getData', () => { + it('returns block identity and uiMetaData', async () => { + const block = Object.create(InformationBlock.prototype); + const ref = makeRef({ uiMetaData: { title: 'Hi', type: 'text' } }); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.equal(data.id, 'ui-uuid'); + assert.deepEqual(data.uiMetaData, { title: 'Hi', type: 'text' }); + }); + + it('tolerates missing options', async () => { + const block = Object.create(InformationBlock.prototype); + const ref = makeRef(null); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.isUndefined(data.uiMetaData); + }); + + it('is readonly only for REMOTE block + REMOTE user', async () => { + const block = Object.create(InformationBlock.prototype); + const ref = makeRef({}, { actionType: 'remote' }); + const remote = await withRef(ref, () => block.getData({ id: 'u1', location: 'remote' })); + const local = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.isTrue(remote.readonly); + assert.isFalse(local.readonly); + }); +}); + +function containerRef(options, children, overrides = {}) { + return makeRef(options, { + children, + updateDataState() { }, + ...overrides, + }); +} + +const activeChild = (blockType, uiMetaData) => ({ + blockType, + uuid: `${blockType}-id`, + options: { uiMetaData }, + defaultActive: true, + isActive() { return true; }, + hasPermission() { return true; }, +}); + +const inactiveChild = (blockType) => ({ + blockType, + uuid: `${blockType}-id`, + options: {}, + defaultActive: true, + isActive() { return false; }, + hasPermission() { return true; }, +}); + +const noPermChild = (blockType) => ({ + blockType, + uuid: `${blockType}-id`, + options: {}, + defaultActive: true, + isActive() { return true; }, + hasPermission() { return false; }, +}); + +describe('InterfaceContainerBlock runtime — getData (decorated)', () => { + it('merges uiMetaData from own getData with child list', async () => { + const block = Object.create(InterfaceContainerBlock.prototype); + const ref = containerRef({ uiMetaData: { type: 'tabs' } }, [activeChild('a', { x: 1 })]); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.deepEqual(data.uiMetaData, { type: 'tabs' }); + assert.lengthOf(data.blocks, 1); + assert.equal(data.blocks[0].blockType, 'a'); + }); + + it('emits undefined for inactive children', async () => { + const block = Object.create(InterfaceContainerBlock.prototype); + const ref = containerRef({}, [inactiveChild('b')]); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.deepEqual(data.blocks, [undefined]); + }); + + it('gates out children without permission', async () => { + const block = Object.create(InterfaceContainerBlock.prototype); + const ref = containerRef({}, [noPermChild('c')]); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.deepEqual(data.blocks, [undefined]); + }); + + it('preserves order across active and inactive children', async () => { + const block = Object.create(InterfaceContainerBlock.prototype); + const ref = containerRef({}, [activeChild('a'), inactiveChild('b'), activeChild('c')]); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.equal(data.blocks[0].blockType, 'a'); + assert.isUndefined(data.blocks[1]); + assert.equal(data.blocks[2].blockType, 'c'); + }); +}); + +describe('HttpRequestUIAddon runtime — getData', () => { + it('echoes the configured request options', async () => { + const block = Object.create(HttpRequestUIAddon.prototype); + const ref = makeRef({ + method: 'post', + url: 'https://api.example/x', + headers: [{ name: 'A', value: 'b' }], + authentication: 'bearerToken', + authenticationClientId: 'cid', + authenticationScopes: 'read', + authenticationURL: 'https://auth', + }); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.equal(data.method, 'post'); + assert.equal(data.url, 'https://api.example/x'); + assert.equal(data.authentication, 'bearerToken'); + assert.equal(data.authenticationClientId, 'cid'); + assert.equal(data.authenticationScopes, 'read'); + assert.equal(data.authenticationURL, 'https://auth'); + assert.deepEqual(data.headers, [{ name: 'A', value: 'b' }]); + assert.equal(data.mockId, 'mock-1'); + }); + + it('passes through undefined optional fields', async () => { + const block = Object.create(HttpRequestUIAddon.prototype); + const ref = makeRef({ method: 'get', url: 'u' }); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'local' })); + assert.equal(data.method, 'get'); + assert.isUndefined(data.authentication); + assert.isUndefined(data.headers); + }); + + it('readonly true for REMOTE block + REMOTE user', async () => { + const block = Object.create(HttpRequestUIAddon.prototype); + const ref = makeRef({ method: 'get', url: 'u' }, { actionType: 'remote' }); + const data = await withRef(ref, () => block.getData({ id: 'u1', location: 'remote' })); + assert.isTrue(data.readonly); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/send-reassign-revoke-split-multisign.test.mjs b/policy-service/tests/unit-tests/blocks/send-reassign-revoke-split-multisign.test.mjs new file mode 100644 index 0000000000..0172772ba2 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/send-reassign-revoke-split-multisign.test.mjs @@ -0,0 +1,257 @@ +import { assert } from 'chai'; +import { SendToGuardianBlock } from '../../../dist/policy-engine/block-validators/blocks/send-to-guardian-block.js'; +import { ReassigningBlock } from '../../../dist/policy-engine/block-validators/blocks/reassigning.block.js'; +import { RevokeBlock } from '../../../dist/policy-engine/block-validators/blocks/revoke-block.js'; +import { SplitBlock } from '../../../dist/policy-engine/block-validators/blocks/split-block.js'; +import { MultiSignBlock } from '../../../dist/policy-engine/block-validators/blocks/multi-sign-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._topicMissing = !!opts.topicMissing; + this._throwOnArtifact = !!opts.throwOnArtifact; + } + addError(msg) { this.errors.push(msg); } + topicTemplateNotExist() { return this._topicMissing; } + async getArtifact() { + if (this._throwOnArtifact) { throw new Error('boom'); } + return {}; + } + getErrorMessage(err) { return err?.message ?? String(err); } +} + +const ref = (options = {}) => ({ options, children: [] }); + +describe('SendToGuardianBlock.validate', () => { + it('exposes the sendToGuardianBlock block type', () => { + assert.equal(SendToGuardianBlock.blockType, 'sendToGuardianBlock'); + }); + + it('passes when nothing is set', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataType vc-documents', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'vc-documents' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataType did-documents', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'did-documents' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataType approve', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'approve' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataType hedera', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'hedera' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects an unknown dataType', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'mystery' })); + assert.include(v.errors, 'Option "dataType" must be one of vc-documents|did-documents|approve|hedera'); + }); + + it('accepts dataSource auto', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'auto' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataSource database', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'database' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataSource hedera with root topic', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera', topic: 'root' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataSource hedera with no topic', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera' })); + assert.deepEqual(v.errors, []); + }); + + it('accepts dataSource hedera with an existing topic template', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera', topic: 't1' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects dataSource hedera with a missing topic template', async () => { + const v = new FakeValidator({ topicMissing: true }); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'hedera', topic: 't1' })); + assert.include(v.errors, 'Topic "t1" does not exist'); + }); + + it('rejects an unknown dataSource', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataSource: 'cloud' })); + assert.include(v.errors, 'Option "dataSource" must be one of auto|database|hedera'); + }); + + it('prefers dataType branch over dataSource branch', async () => { + const v = new FakeValidator(); + await SendToGuardianBlock.validate(v, ref({ dataType: 'vc-documents', dataSource: 'cloud' })); + assert.deepEqual(v.errors, []); + }); + + it('wraps a thrown error as Unhandled exception', async () => { + const v = new FakeValidator({ throwOnArtifact: true }); + await SendToGuardianBlock.validate(v, { options: { artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.include(v.errors, 'Unhandled exception boom'); + }); +}); + +describe('ReassigningBlock.validate', () => { + it('exposes the reassigningBlock block type', () => { + assert.equal(ReassigningBlock.blockType, 'reassigningBlock'); + }); + + it('passes with empty options', async () => { + const v = new FakeValidator(); + await ReassigningBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('errors on a missing artifact (CommonBlock delegation)', async () => { + const v = new FakeValidator(); + await ReassigningBlock.validate(v, { options: { artifacts: [null] }, children: [] }); + assert.include(v.errors, 'Artifact does not exist'); + }); + + it('wraps a thrown error as Unhandled exception', async () => { + const v = new FakeValidator({ throwOnArtifact: true }); + await ReassigningBlock.validate(v, { options: { artifacts: [{ uuid: 'a' }] }, children: [] }); + assert.include(v.errors, 'Unhandled exception boom'); + }); +}); + +describe('RevokeBlock.validate', () => { + it('exposes the revokeBlock block type', () => { + assert.equal(RevokeBlock.blockType, 'revokeBlock'); + }); + + it('errors when uiMetaData is missing', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('errors when uiMetaData is not an object', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: 'nope' })); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('passes with a minimal uiMetaData object', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: {} })); + assert.deepEqual(v.errors, []); + }); + + it('errors when updatePrevDoc set without prevDocStatus', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true } })); + assert.include(v.errors, 'Option "Status Value" is not set'); + }); + + it('passes when updatePrevDoc set with prevDocStatus', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true, prevDocStatus: 'Revoked' } })); + assert.deepEqual(v.errors, []); + }); + + it('passes when updatePrevDoc is false', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: false } })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('SplitBlock.validate', () => { + it('exposes the splitBlock block type', () => { + assert.equal(SplitBlock.blockType, 'splitBlock'); + }); + + it('errors when threshold is not set', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "threshold" is not set'); + }); + + it('errors when threshold is zero (falsy)', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({ threshold: 0 })); + assert.include(v.errors, 'Option "threshold" is not set'); + }); + + it('passes with a numeric threshold', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({ threshold: 10 })); + assert.deepEqual(v.errors, []); + }); + + it('passes with a string threshold', async () => { + const v = new FakeValidator(); + await SplitBlock.validate(v, ref({ threshold: '5.5' })); + assert.deepEqual(v.errors, []); + }); +}); + +describe('MultiSignBlock.validate', () => { + it('exposes the multiSignBlock block type', () => { + assert.equal(MultiSignBlock.blockType, 'multiSignBlock'); + }); + + it('errors when threshold is not set', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "threshold" is not set'); + }); + + it('passes with a valid percentage threshold', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 50 })); + assert.deepEqual(v.errors, []); + }); + + it('passes at the lower bound of 0 via string (truthy)', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '0' })); + assert.deepEqual(v.errors, []); + }); + + it('passes at the upper bound of 100', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 100 })); + assert.deepEqual(v.errors, []); + }); + + it('errors when threshold exceeds 100', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: 150 })); + assert.include(v.errors, '"threshold" value must be between 0 and 100'); + }); + + it('errors when threshold is negative', async () => { + const v = new FakeValidator(); + await MultiSignBlock.validate(v, ref({ threshold: '-5' })); + assert.include(v.errors, '"threshold" value must be between 0 and 100'); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/simple-and-revoke.test.mjs b/policy-service/tests/unit-tests/blocks/simple-and-revoke.test.mjs new file mode 100644 index 0000000000..5528ce4b42 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/simple-and-revoke.test.mjs @@ -0,0 +1,151 @@ +import { assert } from 'chai'; +import { CustomLogicBlock } from '../../../dist/policy-engine/block-validators/blocks/custom-logic-block.js'; +import { InterfaceStepBlock } from '../../../dist/policy-engine/block-validators/blocks/step-block.js'; +import { ExtractDataBlock } from '../../../dist/policy-engine/block-validators/blocks/extract-data.js'; +import { MessagesReportBlock } from '../../../dist/policy-engine/block-validators/blocks/messages-report-block.js'; +import { ReportItemBlock } from '../../../dist/policy-engine/block-validators/blocks/report-item-block.js'; +import { RevokeBlock } from '../../../dist/policy-engine/block-validators/blocks/revoke-block.js'; +import { RevocationBlock } from '../../../dist/policy-engine/block-validators/blocks/revocation-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } +} + +const ref = (options = {}) => ({ options, children: [] }); +const artifactRef = () => ({ options: { artifacts: [{ uuid: 'a' }] }, children: [] }); + +const passthroughBlocks = [ + ['CustomLogicBlock', CustomLogicBlock, 'customLogicBlock'], + ['InterfaceStepBlock', InterfaceStepBlock, 'interfaceStepBlock'], + ['ExtractDataBlock', ExtractDataBlock, 'extractDataBlock'], + ['MessagesReportBlock', MessagesReportBlock, 'messagesReportBlock'], + ['ReportItemBlock', ReportItemBlock, 'reportItemBlock'], +]; + +for (const [label, Block, type] of passthroughBlocks) { + describe(`@unit P0 ${label} (CommonBlock passthrough)`, () => { + it(`blockType is ${type}`, () => { + assert.equal(Block.blockType, type); + }); + + it('passes empty options without errors', async () => { + const v = new FakeValidator(); + await Block.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('passes when artifacts resolve', async () => { + const v = new FakeValidator(); + await Block.validate(v, artifactRef()); + assert.deepEqual(v.errors, []); + }); + + it('reports a missing/null artifact via CommonBlock', async () => { + const v = new FakeValidator(); + await Block.validate(v, { options: { artifacts: [null] }, children: [] }); + assert.include(v.errors, 'Artifact does not exist'); + }); + + it('captures unhandled exception from getArtifact', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await Block.validate(v, artifactRef()); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); + }); +} + +describe('@unit P0 RevokeBlock.validate', () => { + it('blockType is revokeBlock', () => { + assert.equal(RevokeBlock.blockType, 'revokeBlock'); + }); + + it('rejects missing uiMetaData', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({})); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('rejects non-object uiMetaData', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: 'oops' })); + assert.include(v.errors, 'Option "uiMetaData" is not set'); + }); + + it('returns early after uiMetaData error (no Status Value error)', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({})); + assert.equal(v.errors.length, 1); + }); + + it('rejects updatePrevDoc without prevDocStatus', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true } })); + assert.include(v.errors, 'Option "Status Value" is not set'); + }); + + it('passes when updatePrevDoc true and prevDocStatus set', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: true, prevDocStatus: 'Done' } })); + assert.deepEqual(v.errors, []); + }); + + it('passes when updatePrevDoc is false', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: { updatePrevDoc: false } })); + assert.deepEqual(v.errors, []); + }); + + it('passes for a minimal empty-object uiMetaData', async () => { + const v = new FakeValidator(); + await RevokeBlock.validate(v, ref({ uiMetaData: {} })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await RevokeBlock.validate(v, { options: { artifacts: [{ uuid: 'a' }], uiMetaData: {} }, children: [] }); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 RevocationBlock.validate', () => { + it('blockType is revocationBlock', () => { + assert.equal(RevocationBlock.blockType, 'revocationBlock'); + }); + + it('passes with empty options', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({})); + assert.deepEqual(v.errors, []); + }); + + it('rejects updatePrevDoc without prevDocStatus', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({ updatePrevDoc: true })); + assert.include(v.errors, 'Option "Status Value" is not set'); + }); + + it('passes when updatePrevDoc true and prevDocStatus set', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({ updatePrevDoc: true, prevDocStatus: 'Revoked' })); + assert.deepEqual(v.errors, []); + }); + + it('passes when updatePrevDoc is false', async () => { + const v = new FakeValidator(); + await RevocationBlock.validate(v, ref({ updatePrevDoc: false })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await RevocationBlock.validate(v, artifactRef()); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/token-blocks-extra.test.mjs b/policy-service/tests/unit-tests/blocks/token-blocks-extra.test.mjs new file mode 100644 index 0000000000..1cc096fda0 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/token-blocks-extra.test.mjs @@ -0,0 +1,292 @@ +import { assert } from 'chai'; +import { CreateTokenBlock } from '../../../dist/policy-engine/block-validators/blocks/create-token-block.js'; +import { TokenActionBlock } from '../../../dist/policy-engine/block-validators/blocks/token-action-block.js'; +import { TokenConfirmationBlock } from '../../../dist/policy-engine/block-validators/blocks/token-confirmation-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._template = opts.template ?? null; + this._templateMissing = !!opts.templateMissing; + this._tokenMissing = !!opts.tokenMissing; + } + addError(msg) { this.errors.push(msg); } + getTokenTemplate() { return this._template; } + tokenTemplateNotExist() { return this._templateMissing; } + async tokenNotExist() { return this._tokenMissing; } + async getArtifact() { return {}; } + getErrorMessage(err) { return err?.message ?? String(err); } +} + +const fullTemplate = (overrides = {}) => ({ + tokenType: 'non-fungible', + tokenName: 'n', + tokenSymbol: 's', + enableAdmin: true, + enableWipe: false, + enableKYC: true, + enableFreeze: true, + ...overrides, +}); + +describe('CreateTokenBlock.validate', () => { + it('exposes the createTokenBlock block type', () => { + assert.equal(CreateTokenBlock.blockType, 'createTokenBlock'); + }); + + it('errors when template option is empty', async () => { + const v = new FakeValidator(); + await CreateTokenBlock.validate(v, { options: {}, children: [] }); + assert.include(v.errors, 'Template can not be empty'); + }); + + it('errors when template does not exist in registry', async () => { + const v = new FakeValidator({ template: null }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl' }, children: [] }); + assert.include(v.errors, 'Token "tpl" does not exist'); + }); + + it('errors when autorun combined with defaultActive', async () => { + const v = new FakeValidator({ template: fullTemplate() }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true, defaultActive: true }, children: [] }); + assert.include(v.errors, `Autorun can't be use with default active`); + }); + + it('passes a complete non-fungible autorun template', async () => { + const v = new FakeValidator({ template: fullTemplate() }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('passes a complete fungible autorun template with decimals', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenType: 'fungible', decimals: 2 }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('errors when fungible autorun template is missing decimals', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenType: 'fungible' }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing tokenType', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenType: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing tokenName', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenName: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing tokenSymbol', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenSymbol: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing enableAdmin', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableAdmin: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing enableKYC', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableKYC: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when autorun template is missing enableFreeze', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableFreeze: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('errors when enableWipe true but wipeContractId missing', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableWipe: true, wipeContractId: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.include(v.errors, 'Autorun requires all fields to be filled in token template'); + }); + + it('passes when enableWipe true and wipeContractId is empty string', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableWipe: true, wipeContractId: '' }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('passes when enableWipe true and wipeContractId is provided', async () => { + const v = new FakeValidator({ template: fullTemplate({ enableWipe: true, wipeContractId: '0.0.7' }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl', autorun: true }, children: [] }); + assert.deepEqual(v.errors, []); + }); + + it('does not check template fields when autorun is off', async () => { + const v = new FakeValidator({ template: fullTemplate({ tokenType: null, tokenName: null }) }); + await CreateTokenBlock.validate(v, { options: { template: 'tpl' }, children: [] }); + assert.deepEqual(v.errors, []); + }); +}); + +const actionRef = (overrides = {}) => ({ + options: { accountType: 'default', action: 'associate', ...overrides }, + children: [], +}); + +describe('TokenActionBlock.validate', () => { + it('exposes the tokenActionBlock block type', () => { + assert.equal(TokenActionBlock.blockType, 'tokenActionBlock'); + }); + + it('passes a default associate config with tokenId', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects unknown accountType', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ accountType: 'nope', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "accountType" must be one of default,custom'); + }); + + it('allows associate/dissociate only for default account type', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ accountType: 'default', action: 'dissociate', tokenId: '0.0.1' })); + assert.notInclude(v.errors.join('|'), 'Option "action" must be one of'); + }); + + it('rejects associate for custom account type', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ accountType: 'custom', action: 'associate', accountId: '0.0.5', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "action" must be one of freeze,unfreeze,grantKyc,revokeKyc'); + }); + + it('accepts freeze for custom account type', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ accountType: 'custom', action: 'freeze', accountId: '0.0.5', tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects an unknown action', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ action: 'explode', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "action" must be one of associate,dissociate,freeze,unfreeze,grantKyc,revokeKyc'); + }); + + it('rejects missing tokenId without template', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({})); + assert.include(v.errors, 'Option "tokenId" is not set'); + }); + + it('rejects non-string tokenId', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ tokenId: 5 })); + assert.include(v.errors, 'Option "tokenId" must be a string'); + }); + + it('rejects unknown tokenId', async () => { + const v = new FakeValidator({ tokenMissing: true }); + await TokenActionBlock.validate(v, actionRef({ tokenId: '0.0.9' })); + assert.include(v.errors, 'Token with id 0.0.9 does not exist'); + }); + + it('rejects missing template when useTemplate=true', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ useTemplate: true })); + assert.include(v.errors, 'Option "template" is not set'); + }); + + it('rejects unknown template when useTemplate=true', async () => { + const v = new FakeValidator({ templateMissing: true }); + await TokenActionBlock.validate(v, actionRef({ useTemplate: true, template: 't1' })); + assert.include(v.errors, 'Token "t1" does not exist'); + }); + + it('rejects custom accountType without accountId', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, actionRef({ accountType: 'custom', action: 'freeze', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "accountId" is not set'); + }); +}); + +const confirmRef = (overrides = {}) => ({ + options: { accountType: 'default', action: 'associate', ...overrides }, + children: [], +}); + +describe('TokenConfirmationBlock.validate', () => { + it('exposes the tokenConfirmationBlock block type', () => { + assert.equal(TokenConfirmationBlock.blockType, 'tokenConfirmationBlock'); + }); + + it('passes a valid associate config', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); + + it('passes a valid dissociate config', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ action: 'dissociate', tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); + + it('rejects freeze action (not allowed for confirmation)', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ action: 'freeze', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "action" must be one of associate,dissociate'); + }); + + it('rejects unknown accountType', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ accountType: 'nope', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "accountType" must be one of default,custom'); + }); + + it('rejects missing tokenId without template', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({})); + assert.include(v.errors, 'Option "tokenId" is not set'); + }); + + it('rejects non-string tokenId', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ tokenId: 7 })); + assert.include(v.errors, 'Option "tokenId" must be a string'); + }); + + it('rejects unknown tokenId', async () => { + const v = new FakeValidator({ tokenMissing: true }); + await TokenConfirmationBlock.validate(v, confirmRef({ tokenId: '0.0.9' })); + assert.include(v.errors, 'Token with id 0.0.9 does not exist'); + }); + + it('rejects missing template when useTemplate=true', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ useTemplate: true })); + assert.include(v.errors, 'Option "template" is not set'); + }); + + it('rejects unknown template when useTemplate=true', async () => { + const v = new FakeValidator({ templateMissing: true }); + await TokenConfirmationBlock.validate(v, confirmRef({ useTemplate: true, template: 't1' })); + assert.include(v.errors, 'Token "t1" does not exist'); + }); + + it('rejects custom accountType without accountId', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ accountType: 'custom', tokenId: '0.0.1' })); + assert.include(v.errors, 'Option "accountId" is not set'); + }); + + it('accepts custom accountType with accountId', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, confirmRef({ accountType: 'custom', accountId: '0.0.5', tokenId: '0.0.1' })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/blocks/token-family.test.mjs b/policy-service/tests/unit-tests/blocks/token-family.test.mjs new file mode 100644 index 0000000000..69c6ed6b84 --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/token-family.test.mjs @@ -0,0 +1,190 @@ +import { assert } from 'chai'; +import { TokenActionBlock } from '../../../dist/policy-engine/block-validators/blocks/token-action-block.js'; +import { TokenConfirmationBlock } from '../../../dist/policy-engine/block-validators/blocks/token-confirmation-block.js'; +import { MintBlock } from '../../../dist/policy-engine/block-validators/blocks/mint-block.js'; + +class FakeValidator { + constructor(opts = {}) { + this.errors = []; + this._tokenMissing = !!opts.tokenMissing; + this._templateMissing = !!opts.templateMissing; + this._throw = !!opts.throwGetArtifact; + } + addError(msg) { this.errors.push(msg); } + getErrorMessage(err) { return err?.message ?? String(err); } + async getArtifact() { if (this._throw) { throw new Error('artifact-down'); } return {}; } + tokenTemplateNotExist() { return this._templateMissing; } + async tokenNotExist() { return this._tokenMissing; } +} + +describe('@unit P0 TokenActionBlock extra', () => { + const base = (o = {}) => ({ options: { accountType: 'default', action: 'associate', tokenId: '0.0.1', ...o }, children: [] }); + + it('blockType is tokenActionBlock', () => { + assert.equal(TokenActionBlock.blockType, 'tokenActionBlock'); + }); + + it('rejects non-string tokenId', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ tokenId: 999 })); + assert.include(v.errors, 'Option "tokenId" must be a string'); + }); + + it('useTemplate without template reports missing template', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ useTemplate: true, tokenId: undefined })); + assert.include(v.errors, 'Option "template" is not set'); + }); + + it('useTemplate with unknown template reports does not exist', async () => { + const v = new FakeValidator({ templateMissing: true }); + await TokenActionBlock.validate(v, base({ useTemplate: true, template: 'T', tokenId: undefined })); + assert.include(v.errors, 'Token "T" does not exist'); + }); + + it('useTemplate with known template passes', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ useTemplate: true, template: 'T', tokenId: undefined })); + assert.deepEqual(v.errors, []); + }); + + it('custom accountType with valid action and accountId passes', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ accountType: 'custom', action: 'freeze', accountId: 'a' })); + assert.deepEqual(v.errors, []); + }); + + it('custom accountType allows all freeze-family actions', async () => { + for (const action of ['freeze', 'unfreeze', 'grantKyc', 'revokeKyc']) { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ accountType: 'custom', action, accountId: 'a' })); + assert.deepEqual(v.errors, [], `action=${action}`); + } + }); + + it('rejects unknown action under default accountType', async () => { + const v = new FakeValidator(); + await TokenActionBlock.validate(v, base({ action: 'teleport' })); + assert.isTrue(v.errors.some((e) => e.startsWith('Option "action" must be one of'))); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await TokenActionBlock.validate(v, base({ artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 TokenConfirmationBlock.validate', () => { + const base = (o = {}) => ({ options: { accountType: 'default', action: 'associate', tokenId: '0.0.1', ...o }, children: [] }); + + it('blockType is tokenConfirmationBlock', () => { + assert.equal(TokenConfirmationBlock.blockType, 'tokenConfirmationBlock'); + }); + + it('passes a minimal valid config', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base()); + assert.deepEqual(v.errors, []); + }); + + it('allows action associate and dissociate', async () => { + for (const action of ['associate', 'dissociate']) { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ action })); + assert.deepEqual(v.errors, [], `action=${action}`); + } + }); + + it('rejects action outside associate/dissociate', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ action: 'freeze' })); + assert.include(v.errors, 'Option "action" must be one of associate,dissociate'); + }); + + it('rejects unknown accountType', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ accountType: 'weird' })); + assert.include(v.errors, 'Option "accountType" must be one of default,custom'); + }); + + it('rejects missing tokenId when not using template', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ tokenId: undefined })); + assert.include(v.errors, 'Option "tokenId" is not set'); + }); + + it('rejects non-string tokenId', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ tokenId: 5 })); + assert.include(v.errors, 'Option "tokenId" must be a string'); + }); + + it('rejects unknown tokenId from registry', async () => { + const v = new FakeValidator({ tokenMissing: true }); + await TokenConfirmationBlock.validate(v, base({ tokenId: '0.0.999' })); + assert.include(v.errors, 'Token with id 0.0.999 does not exist'); + }); + + it('useTemplate without template reports missing template', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ useTemplate: true, tokenId: undefined })); + assert.include(v.errors, 'Option "template" is not set'); + }); + + it('useTemplate with unknown template reports does not exist', async () => { + const v = new FakeValidator({ templateMissing: true }); + await TokenConfirmationBlock.validate(v, base({ useTemplate: true, template: 'X', tokenId: undefined })); + assert.include(v.errors, 'Token "X" does not exist'); + }); + + it('custom accountType without accountId reports not set', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ accountType: 'custom' })); + assert.include(v.errors, 'Option "accountId" is not set'); + }); + + it('custom accountType with accountId passes', async () => { + const v = new FakeValidator(); + await TokenConfirmationBlock.validate(v, base({ accountType: 'custom', accountId: 'a' })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await TokenConfirmationBlock.validate(v, base({ artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); +}); + +describe('@unit P0 MintBlock extra', () => { + const base = (o = {}) => ({ options: { rule: 'r', accountType: 'default', tokenId: '0.0.1', ...o }, children: [] }); + + it('blockType is mintDocumentBlock', () => { + assert.equal(MintBlock.blockType, 'mintDocumentBlock'); + }); + + it('custom accountType with accountId passes', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, base({ accountType: 'custom', accountId: 'a' })); + assert.deepEqual(v.errors, []); + }); + + it('useTemplate with known template passes', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, base({ useTemplate: true, template: 't1', tokenId: undefined })); + assert.deepEqual(v.errors, []); + }); + + it('captures unhandled exception path', async () => { + const v = new FakeValidator({ throwGetArtifact: true }); + await MintBlock.validate(v, base({ artifacts: [{ uuid: 'a' }] })); + assert.equal(v.errors.some((e) => /artifact-down/.test(e)), true); + }); + + it('accepts default accountType (no accountId required)', async () => { + const v = new FakeValidator(); + await MintBlock.validate(v, base({ accountType: 'default' })); + assert.deepEqual(v.errors, []); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/common-variables-store.test.mjs b/policy-service/tests/unit-tests/helpers/common-variables-store.test.mjs new file mode 100644 index 0000000000..0cc18558a7 --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/common-variables-store.test.mjs @@ -0,0 +1,43 @@ +import { assert } from 'chai'; +import { CommonVariables } from '../../../dist/helpers/common-variables.js'; + +describe('CommonVariables store', () => { + it('round-trips a stored value', () => { + const cv = new CommonVariables(); + cv.setVariable('alpha', 'one'); + assert.equal(cv.getVariable('alpha'), 'one'); + }); + + it('returns undefined for an unknown key', () => { + const cv = new CommonVariables(); + assert.equal(cv.getVariable('does-not-exist-xyz'), undefined); + }); + + it('overwrites an existing key with the latest value', () => { + const cv = new CommonVariables(); + cv.setVariable('beta', 1); + cv.setVariable('beta', 2); + assert.equal(cv.getVariable('beta'), 2); + }); + + it('stores and returns null distinctly from undefined', () => { + const cv = new CommonVariables(); + cv.setVariable('gamma', null); + assert.strictEqual(cv.getVariable('gamma'), null); + }); + + it('preserves object identity for stored references', () => { + const cv = new CommonVariables(); + const obj = { nested: true }; + cv.setVariable('delta', obj); + assert.strictEqual(cv.getVariable('delta'), obj); + }); + + it('keeps distinct keys independent', () => { + const cv = new CommonVariables(); + cv.setVariable('k1', 'v1'); + cv.setVariable('k2', 'v2'); + assert.equal(cv.getVariable('k1'), 'v1'); + assert.equal(cv.getVariable('k2'), 'v2'); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/common-variables.test.mjs b/policy-service/tests/unit-tests/helpers/common-variables.test.mjs new file mode 100644 index 0000000000..438fb6a67d --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/common-variables.test.mjs @@ -0,0 +1,55 @@ +import { assert } from 'chai'; +import { CommonVariables } from '../../../dist/helpers/common-variables.js'; + +describe('@unit CommonVariables', () => { + it('stores and retrieves a value by name', () => { + const vars = new CommonVariables(); + vars.setVariable('alpha', 123); + assert.equal(vars.getVariable('alpha'), 123); + }); + + it('returns undefined for an unknown variable', () => { + const vars = new CommonVariables(); + assert.equal(vars.getVariable('does-not-exist-xyz'), undefined); + }); + + it('overwrites an existing value', () => { + const vars = new CommonVariables(); + vars.setVariable('beta', 'first'); + vars.setVariable('beta', 'second'); + assert.equal(vars.getVariable('beta'), 'second'); + }); + + it('supports object and array values', () => { + const vars = new CommonVariables(); + const obj = { a: 1 }; + const arr = [1, 2, 3]; + vars.setVariable('obj', obj); + vars.setVariable('arr', arr); + assert.strictEqual(vars.getVariable('obj'), obj); + assert.strictEqual(vars.getVariable('arr'), arr); + }); + + it('supports null and falsy values distinct from undefined', () => { + const vars = new CommonVariables(); + vars.setVariable('nullish', null); + vars.setVariable('zero', 0); + vars.setVariable('empty', ''); + assert.equal(vars.getVariable('nullish'), null); + assert.equal(vars.getVariable('zero'), 0); + assert.equal(vars.getVariable('empty'), ''); + }); + + it('behaves as a singleton: state is shared across instances', () => { + const a = new CommonVariables(); + a.setVariable('shared-key', 'shared-value'); + const b = new CommonVariables(); + assert.equal(b.getVariable('shared-key'), 'shared-value'); + }); + + it('singleton returns the same underlying instance', () => { + const a = new CommonVariables(); + const b = new CommonVariables(); + assert.strictEqual(a, b); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/custom-logic-python-packages.test.mjs b/policy-service/tests/unit-tests/helpers/custom-logic-python-packages.test.mjs new file mode 100644 index 0000000000..3804ece55e --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/custom-logic-python-packages.test.mjs @@ -0,0 +1,59 @@ +import { assert } from 'chai'; +import { + PYTHON_PACKAGES, + IMPORT_TO_PACKAGE, + selectPackagesForImports, +} from '../../../dist/policy-engine/helpers/workers/python-packages.js'; + +describe('custom logic python-packages helpers', () => { + describe('PYTHON_PACKAGES allowlist', () => { + it('is a non-empty list of unique package names', () => { + assert.isArray(PYTHON_PACKAGES); + assert.isAbove(PYTHON_PACKAGES.length, 0); + assert.equal(new Set(PYTHON_PACKAGES).size, PYTHON_PACKAGES.length); + }); + + it('includes the scientific packages user scripts rely on', () => { + for (const pkg of ['numpy', 'pandas', 'scipy', 'scikit-learn']) { + assert.include(PYTHON_PACKAGES, pkg); + } + }); + }); + + describe('IMPORT_TO_PACKAGE map', () => { + it('maps the sklearn import name to the scikit-learn package', () => { + assert.equal(IMPORT_TO_PACKAGE.sklearn, 'scikit-learn'); + }); + + it('has a null prototype so prototype keys do not resolve', () => { + assert.isNull(Object.getPrototypeOf(IMPORT_TO_PACKAGE)); + assert.isUndefined(IMPORT_TO_PACKAGE.constructor); + assert.isUndefined(IMPORT_TO_PACKAGE.__proto__); + }); + }); + + describe('selectPackagesForImports', () => { + it('returns an empty list for no imports', () => { + assert.deepEqual(selectPackagesForImports([]), []); + }); + + it('keeps only allowlisted imports and drops unknown ones', () => { + const result = selectPackagesForImports(['numpy', 'os', 'sys', 'pandas']); + assert.sameMembers(result, ['numpy', 'pandas']); + }); + + it('translates aliased import names via IMPORT_TO_PACKAGE', () => { + assert.deepEqual(selectPackagesForImports(['sklearn']), ['scikit-learn']); + }); + + it('deduplicates when an alias and its target both appear', () => { + const result = selectPackagesForImports(['sklearn', 'scikit-learn']); + assert.deepEqual(result, ['scikit-learn']); + }); + + it('accepts any iterable of import names (e.g. a Set)', () => { + const result = selectPackagesForImports(new Set(['numpy', 'numpy', 'unknown'])); + assert.deepEqual(result, ['numpy']); + }); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/decorators/pure-decorators.test.mjs b/policy-service/tests/unit-tests/helpers/decorators/pure-decorators.test.mjs new file mode 100644 index 0000000000..17e3f7f01b --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/decorators/pure-decorators.test.mjs @@ -0,0 +1,208 @@ +import { assert } from 'chai'; +import { UIAddon } from '../../../../dist/policy-engine/helpers/decorators/ui-addon.js'; +import { ValidatorBlock } from '../../../../dist/policy-engine/helpers/decorators/validator-block.js'; +import { SourceAddon } from '../../../../dist/policy-engine/helpers/decorators/source-addon.js'; +import { TokenAddon } from '../../../../dist/policy-engine/helpers/decorators/token-addon.js'; +import { TokenBlock } from '../../../../dist/policy-engine/helpers/decorators/token-block.js'; +import { CalculateAddon } from '../../../../dist/policy-engine/helpers/decorators/calculate-addon.js'; +import { Report } from '../../../../dist/policy-engine/helpers/decorators/report-block.js'; +import { ReportItem } from '../../../../dist/policy-engine/helpers/decorators/report-item-block.js'; +import { SetRelationshipsBlock } from '../../../../dist/policy-engine/helpers/decorators/set-relationships-block.js'; +import { CalculateBlock } from '../../../../dist/policy-engine/helpers/decorators/calculate-block.js'; + +const opts = (over = {}) => Object.assign({ blockType: 'testBlock', children: [] }, over); +function inst(Decorator, o = opts()) { + const Cls = Decorator(o)(class { }); + return new Cls('uuid', false, 'tag', [], null, {}, { databaseServer: {} }); +} +function setChildren(b, children) { + b._children = children; +} + +describe('@unit UIAddon decorator', () => { + it('sets blockClassName to UIAddon', () => { + assert.equal(inst(UIAddon).blockClassName, 'UIAddon'); + }); + it('carries blockType from options', () => { + assert.equal(inst(UIAddon, opts({ blockType: 'uiX' })).blockType, 'uiX'); + }); +}); + +describe('@unit ValidatorBlock decorator', () => { + it('sets blockClassName to ValidatorBlock', () => { + assert.equal(inst(ValidatorBlock).blockClassName, 'ValidatorBlock'); + }); + it('run resolves undefined when no super.run', async () => { + assert.isUndefined(await inst(ValidatorBlock).run({})); + }); +}); + +describe('@unit SourceAddon decorator', () => { + it('sets blockClassName to SourceAddon', () => { + assert.equal(inst(SourceAddon).blockClassName, 'SourceAddon'); + }); + it('getFromSource returns [] when no super and countResult false', () => { + assert.deepEqual(inst(SourceAddon).getFromSource(null, null, false), []); + }); + it('getFromSource returns 0 when no super and countResult true', () => { + assert.equal(inst(SourceAddon).getFromSource(null, null, true), 0); + }); + it('getFromSourceFilters returns null when no super', () => { + assert.isNull(inst(SourceAddon).getFromSourceFilters(null, null)); + }); + it('getAddons returns only filtersAddon children', () => { + const b = inst(SourceAddon); + setChildren(b, [ + { blockType: 'filtersAddon' }, + { blockType: 'other' }, + { blockType: 'filtersAddon' }, + ]); + assert.equal(b.getAddons().length, 2); + }); + it('getSelectiveAttributes filters selectiveAttributes children', () => { + const b = inst(SourceAddon); + setChildren(b, [{ blockType: 'selectiveAttributes' }, { blockType: 'x' }]); + assert.equal(b.getSelectiveAttributes().length, 1); + }); + it('getFilters merges filters from filter addons', async () => { + const b = inst(SourceAddon); + setChildren(b, [ + { blockType: 'filtersAddon', getFilters: async () => ({ a: 1 }) }, + { blockType: 'filtersAddon', getFilters: async () => ({ b: 2 }) }, + ]); + assert.deepEqual(await b.getFilters(null), { a: 1, b: 2 }); + }); + it('getFilters empty when no filter addons', async () => { + const b = inst(SourceAddon); + setChildren(b, [{ blockType: 'other' }]); + assert.deepEqual(await b.getFilters(null), {}); + }); +}); + +describe('@unit TokenAddon decorator', () => { + it('sets blockClassName to TokenAddon', () => { + assert.equal(inst(TokenAddon).blockClassName, 'TokenAddon'); + }); + it('run returns scope when no super.run', async () => { + const scope = { v: 1 }; + assert.strictEqual(await inst(TokenAddon).run(scope), scope); + }); +}); + +describe('@unit TokenBlock decorator', () => { + it('sets blockClassName to TokenBlock', () => { + assert.equal(inst(TokenBlock).blockClassName, 'TokenBlock'); + }); + it('getAddons returns only TokenAddon children', () => { + const b = inst(TokenBlock); + setChildren(b, [ + { blockClassName: 'TokenAddon' }, + { blockClassName: 'Other' }, + { blockClassName: 'TokenAddon' }, + ]); + assert.equal(b.getAddons().length, 2); + }); + it('getAddons empty when no token addons', () => { + const b = inst(TokenBlock); + setChildren(b, [{ blockClassName: 'X' }]); + assert.deepEqual(b.getAddons(), []); + }); +}); + +describe('@unit CalculateAddon decorator', () => { + it('sets blockClassName to CalculateAddon', () => { + assert.equal(inst(CalculateAddon).blockClassName, 'CalculateAddon'); + }); + it('run returns scope when no super.run', async () => { + const scope = { x: 1 }; + assert.strictEqual(await inst(CalculateAddon).run(scope), scope); + }); + it('evaluate computes formula against scope', () => { + assert.equal(inst(CalculateAddon).evaluate('a + b', { a: 2, b: 3 }), 5); + }); + it('evaluate returns "Incorrect formula" on error', () => { + assert.equal(inst(CalculateAddon).evaluate('a +', {}), 'Incorrect formula'); + }); + it('parse returns true for valid formula', () => { + assert.isTrue(inst(CalculateAddon).parse('a + b')); + }); + it('parse returns false for invalid formula', () => { + assert.isFalse(inst(CalculateAddon).parse('a +')); + }); + it('getVariables returns input when no super', () => { + const vars = { a: 1 }; + assert.strictEqual(inst(CalculateAddon).getVariables(vars), vars); + }); +}); + +describe('@unit Report decorator', () => { + it('sets blockClassName to ReportBlock', () => { + assert.equal(inst(Report).blockClassName, 'ReportBlock'); + }); + it('getItems returns only ReportItemBlock children', () => { + const b = inst(Report); + setChildren(b, [ + { blockClassName: 'ReportItemBlock' }, + { blockClassName: 'Other' }, + ]); + assert.equal(b.getItems().length, 1); + }); +}); + +describe('@unit ReportItem decorator', () => { + it('sets blockClassName to ReportItemBlock', () => { + assert.equal(inst(ReportItem).blockClassName, 'ReportItemBlock'); + }); + it('run returns fieldsResult when no super.run', async () => { + const fr = { f: 1 }; + assert.strictEqual(await inst(ReportItem).run(fr), fr); + }); + it('getItems collects nested ReportItemBlock children', () => { + const b = inst(ReportItem); + setChildren(b, [{ blockClassName: 'ReportItemBlock' }, { blockClassName: 'ReportItemBlock' }, { blockClassName: 'x' }]); + assert.equal(b.getItems().length, 2); + }); +}); + +describe('@unit CalculateBlock decorator', () => { + it('sets blockClassName to CalculateBlock', () => { + assert.equal(inst(CalculateBlock).blockClassName, 'CalculateBlock'); + }); + it('getAddons returns only CalculateAddon children', () => { + const b = inst(CalculateBlock); + setChildren(b, [ + { blockClassName: 'CalculateAddon' }, + { blockClassName: 'Other' }, + { blockClassName: 'CalculateAddon' }, + ]); + assert.equal(b.getAddons().length, 2); + }); + it('getAddons empty when no calculate addons', () => { + const b = inst(CalculateBlock); + setChildren(b, [{ blockClassName: 'X' }]); + assert.deepEqual(b.getAddons(), []); + }); + it('carries blockType from options', () => { + assert.equal(inst(CalculateBlock, opts({ blockType: 'calcX' })).blockType, 'calcX'); + }); +}); + +describe('@unit SetRelationshipsBlock decorator', () => { + it('sets blockClassName to SetRelationshipsBlock', () => { + assert.equal(inst(SetRelationshipsBlock).blockClassName, 'SetRelationshipsBlock'); + }); + it('getSources concatenates data from SourceAddon children', async () => { + const b = inst(SetRelationshipsBlock); + setChildren(b, [ + { blockClassName: 'SourceAddon', getFromSource: async () => [1, 2] }, + { blockClassName: 'SourceAddon', getFromSource: async () => [3] }, + { blockClassName: 'Other', getFromSource: async () => [99] }, + ]); + assert.deepEqual(await b.getSources(null, null), [1, 2, 3]); + }); + it('getSources empty when no source addons', async () => { + const b = inst(SetRelationshipsBlock); + setChildren(b, [{ blockClassName: 'X' }]); + assert.deepEqual(await b.getSources(null, null), []); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/math-group.test.mjs b/policy-service/tests/unit-tests/helpers/math-group.test.mjs new file mode 100644 index 0000000000..2d2f2cec4b --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/math-group.test.mjs @@ -0,0 +1,179 @@ +import { assert } from 'chai'; +import { MathGroup } from '../../../dist/policy-engine/helpers/math-model/math-group.js'; +import { MathItemType } from '../../../dist/policy-engine/helpers/math-model/math-item.type.js'; + +const makeItem = (name, empty = false, valid = true) => ({ + name, + empty, + valid, + validated: false, + validate() { + this.validated = true; + }, + toJson() { + return { name: this.name }; + } +}); + +describe('MathGroup', () => { + it('defaults to the GROUP type', () => { + assert.equal(new MathGroup().type, MathItemType.GROUP); + }); + + it('assigns a string id', () => { + assert.isString(new MathGroup().id); + }); + + it('uses the provided name', () => { + assert.equal(new MathGroup('payments').name, 'payments'); + }); + + it('defaults name to an empty string', () => { + assert.equal(new MathGroup().name, ''); + }); + + it('starts with an empty items list', () => { + assert.deepEqual(new MathGroup().items, []); + }); + + it('starts valid', () => { + assert.isTrue(new MathGroup().valid); + }); + + it('adds items', () => { + const g = new MathGroup(); + g.add(makeItem('a')); + g.add(makeItem('b')); + assert.equal(g.items.length, 2); + }); + + it('deletes a specific item by reference', () => { + const g = new MathGroup(); + const a = makeItem('a'); + const b = makeItem('b'); + g.add(a); + g.add(b); + g.delete(a); + assert.deepEqual(g.items, [b]); + }); + + it('delete is a no-op when the item is not present', () => { + const g = new MathGroup(); + const a = makeItem('a'); + g.add(a); + g.delete(makeItem('other')); + assert.deepEqual(g.items, [a]); + }); + + it('validate calls validate on non-empty items only', () => { + const g = new MathGroup(); + const a = makeItem('a', false); + const b = makeItem('b', true); + g.add(a); + g.add(b); + g.validate(); + assert.isTrue(a.validated); + assert.isFalse(b.validated); + }); + + it('validate is true when all non-empty items are valid', () => { + const g = new MathGroup(); + g.add(makeItem('a', false, true)); + g.add(makeItem('b', false, true)); + g.validate(); + assert.isTrue(g.valid); + }); + + it('validate is false when any non-empty item is invalid', () => { + const g = new MathGroup(); + g.add(makeItem('a', false, true)); + g.add(makeItem('b', false, false)); + g.validate(); + assert.isFalse(g.valid); + }); + + it('validate ignores invalidity of empty items', () => { + const g = new MathGroup(); + g.add(makeItem('a', false, true)); + g.add(makeItem('b', true, false)); + g.validate(); + assert.isTrue(g.valid); + }); + + it('validate populates validatedItems excluding empties', () => { + const g = new MathGroup(); + g.add(makeItem('a', false)); + g.add(makeItem('b', true)); + g.add(makeItem('c', false)); + g.validate(); + assert.equal(g.validatedItems.length, 2); + }); + + it('reorder returns undefined', () => { + assert.equal(new MathGroup().reorder(0, 1), undefined); + }); + + it('toJson reports the group type and name', () => { + const g = new MathGroup('grp'); + const json = g.toJson(); + assert.equal(json.type, MathItemType.GROUP); + assert.equal(json.name, 'grp'); + }); + + it('toJson defaults a missing name to empty string', () => { + const g = new MathGroup(); + g.name = undefined; + assert.equal(g.toJson().name, ''); + }); + + it('toJson excludes empty items', () => { + const g = new MathGroup(); + g.add(makeItem('a', false)); + g.add(makeItem('b', true)); + assert.deepEqual(g.toJson().items, [{ name: 'a' }]); + }); + + it('instance from rebuilds items via the create callback', () => { + const g = new MathGroup(); + const result = g.from({ name: 'x', items: [{ name: 'i1' }, { name: 'i2' }] }, (cfg) => makeItem(cfg.name)); + assert.equal(result, g); + assert.equal(g.name, 'x'); + assert.equal(g.items.length, 2); + }); + + it('instance from skips items the create callback rejects', () => { + const g = new MathGroup(); + g.from({ name: 'x', items: [{ name: 'keep' }, { name: 'drop' }] }, (cfg) => (cfg.name === 'keep' ? makeItem(cfg.name) : null)); + assert.equal(g.items.length, 1); + assert.equal(g.items[0].name, 'keep'); + }); + + it('instance from returns null for non-object input', () => { + assert.isNull(new MathGroup().from(null, () => null)); + assert.isNull(new MathGroup().from('str', () => null)); + }); + + it('instance from defaults a missing name to empty string', () => { + const g = new MathGroup('old'); + g.from({ items: [] }, () => null); + assert.equal(g.name, ''); + }); + + it('instance from tolerates a non-array items field', () => { + const g = new MathGroup(); + g.from({ name: 'x', items: 'not-an-array' }, () => makeItem('z')); + assert.deepEqual(g.items, []); + }); + + it('static from builds a populated MathGroup', () => { + const g = MathGroup.from({ type: MathItemType.GROUP, name: 'y', items: [{ name: 'a' }] }, (cfg) => makeItem(cfg.name)); + assert.instanceOf(g, MathGroup); + assert.equal(g.name, 'y'); + assert.equal(g.items.length, 1); + }); + + it('static from returns null for non-object input', () => { + assert.isNull(MathGroup.from(null, () => null)); + assert.isNull(MathGroup.from(42, () => null)); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/messages-report.test.mjs b/policy-service/tests/unit-tests/helpers/messages-report.test.mjs new file mode 100644 index 0000000000..7f42fc9b1b --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/messages-report.test.mjs @@ -0,0 +1,162 @@ +import { assert } from 'chai'; +import esmock from 'esmock'; + +const MessageType = { + VCDocument: 'VC-Document', + VPDocument: 'VP-Document', + RoleDocument: 'Role-Document', +}; + +const { MessagesReport } = await esmock.strict( + '../../../dist/policy-engine/helpers/messages-report.js', + { + '@guardian/common': { + DIDMessage: class {}, + HederaDid: { parse: () => ({ topicId: 't' }) }, + Message: class {}, + MessageAction: { PublishSchema: 'PublishSchema', PublishSystemSchema: 'PublishSystemSchema', CreateDID: 'CreateDID' }, + MessageServer: class {}, + MessageType, + SchemaMessage: class {}, + TopicMessage: class {}, + UrlType: { url: 'url' }, + VCMessage: class {}, + Workers: class {}, + }, + '@guardian/interfaces': { + TopicType: { PolicyTopic: 'PolicyTopic' }, + WorkerTaskType: { GET_TOKEN_INFO: 'GET_TOKEN_INFO' }, + }, + }, +); + +const tenant = { tenantId: 'tenant-1' }; + +describe('@unit MessagesReport.toJson', () => { + it('returns an empty report for a fresh instance', () => { + const report = new MessagesReport(tenant); + const out = report.toJson(); + assert.deepEqual(out, { roles: [], topics: [], schemas: [], users: [], tokens: [] }); + }); + + it('returns top-level topics with empty children/messages', () => { + const report = new MessagesReport(tenant); + report.topics.set('0.0.1', { topicId: '0.0.1' }); + const out = report.toJson(); + assert.equal(out.topics.length, 1); + assert.equal(out.topics[0].topicId, '0.0.1'); + assert.deepEqual(out.topics[0].children, []); + assert.deepEqual(out.topics[0].messages, []); + }); + + it('nests a child topic under its parent', () => { + const report = new MessagesReport(tenant); + report.topics.set('parent', { topicId: 'parent' }); + report.topics.set('child', { topicId: 'child', parentId: 'parent' }); + const out = report.toJson(); + assert.equal(out.topics.length, 1); + assert.equal(out.topics[0].topicId, 'parent'); + assert.equal(out.topics[0].children.length, 1); + assert.equal(out.topics[0].children[0].topicId, 'child'); + }); + + it('keeps a child as top-level when its parent is absent', () => { + const report = new MessagesReport(tenant); + report.topics.set('child', { topicId: 'child', parentId: 'ghost' }); + const out = report.toJson(); + assert.equal(out.topics.length, 1); + assert.equal(out.topics[0].topicId, 'child'); + }); + + it('attaches messages to their topic', () => { + const report = new MessagesReport(tenant); + report.topics.set('0.0.1', { topicId: '0.0.1' }); + report.messages.set('m2', { id: 'b', topicId: '0.0.1', type: MessageType.VCDocument }); + report.messages.set('m1', { id: 'a', topicId: '0.0.1', type: MessageType.VCDocument }); + const out = report.toJson(); + assert.equal(out.topics[0].messages.length, 2); + assert.deepEqual(out.topics[0].messages.map((m) => m.id).sort(), ['a', 'b']); + }); + + it('assigns a sequential order property by sorted id across messages', () => { + const report = new MessagesReport(tenant); + report.topics.set('0.0.1', { topicId: '0.0.1' }); + report.messages.set('m2', { id: 'b', topicId: '0.0.1', type: MessageType.VCDocument }); + report.messages.set('m1', { id: 'a', topicId: '0.0.1', type: MessageType.VCDocument }); + report.toJson(); + const byId = (id) => [...report.messages.values()].find((m) => m.id === id); + assert.equal(byId('a').order, 0); + assert.equal(byId('b').order, 1); + }); + + it('drops messages whose topic is not in the report', () => { + const report = new MessagesReport(tenant); + report.messages.set('m1', { id: 'a', topicId: 'unknown', type: MessageType.VCDocument }); + const out = report.toJson(); + assert.deepEqual(out.topics, []); + }); + + it('ignores null message entries', () => { + const report = new MessagesReport(tenant); + report.topics.set('0.0.1', { topicId: '0.0.1' }); + report.messages.set('m1', null); + const out = report.toJson(); + assert.equal(out.topics[0].messages.length, 0); + }); + + it('collects RoleDocument messages into roles', () => { + const report = new MessagesReport(tenant); + report.topics.set('0.0.1', { topicId: '0.0.1' }); + report.messages.set('m1', { id: 'a', topicId: '0.0.1', type: MessageType.RoleDocument }); + const out = report.toJson(); + assert.equal(out.roles.length, 1); + assert.equal(out.roles[0].id, 'a'); + }); + + it('collects schemas from the schemas map', () => { + const report = new MessagesReport(tenant); + report.schemas.set('s1', { iri: 'iri-1' }); + report.schemas.set('s2', { iri: 'iri-2' }); + const out = report.toJson(); + assert.equal(out.schemas.length, 2); + }); + + it('collects only non-null users', () => { + const report = new MessagesReport(tenant); + report.users.set('did:1', { did: 'did:1' }); + report.users.set('did:2', null); + const out = report.toJson(); + assert.equal(out.users.length, 1); + assert.equal(out.users[0].did, 'did:1'); + }); + + it('collects tokens from the tokens map', () => { + const report = new MessagesReport(tenant); + report.tokens.set('tok', { tokenId: 'tok' }); + const out = report.toJson(); + assert.equal(out.tokens.length, 1); + assert.equal(out.tokens[0].tokenId, 'tok'); + }); +}); + +describe('@unit MessagesReport.needDocument', () => { + it('returns true for VC/VP/Role document types', () => { + const report = new MessagesReport(tenant); + assert.equal(report.needDocument({ type: MessageType.VCDocument }), true); + assert.equal(report.needDocument({ type: MessageType.VPDocument }), true); + assert.equal(report.needDocument({ type: MessageType.RoleDocument }), true); + }); + + it('returns false for other types', () => { + const report = new MessagesReport(tenant); + assert.equal(report.needDocument({ type: 'Topic' }), false); + }); +}); + +describe('@unit MessagesReport.getToken', () => { + it('returns null when the worker throws', async () => { + const report = new MessagesReport(tenant); + const out = await report.getToken({ dryRun: null, mockId: null }, 'tok', null); + assert.equal(out, null); + }); +}); diff --git a/policy-service/tests/unit-tests/helpers/table-field.test.mjs b/policy-service/tests/unit-tests/helpers/table-field.test.mjs new file mode 100644 index 0000000000..ee70baf250 --- /dev/null +++ b/policy-service/tests/unit-tests/helpers/table-field.test.mjs @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +import { gzipSync } from 'node:zlib'; +import { + parseCsvToTable, + decodeGridFileText, + isPlainObject, + isTableValue, + isTableWithFileId, + parseIfJson, + hydrateTablesInObject, + collectTablesPack +} from '../../../dist/policy-engine/helpers/table-field.js'; + +describe('table-field helpers', () => { + it('exports are defined', () => { + assert.equal(typeof parseCsvToTable, 'function'); + assert.equal(typeof decodeGridFileText, 'function'); + assert.equal(typeof isPlainObject, 'function'); + assert.equal(typeof isTableValue, 'function'); + assert.equal(typeof isTableWithFileId, 'function'); + assert.equal(typeof parseIfJson, 'function'); + assert.equal(typeof hydrateTablesInObject, 'function'); + assert.equal(typeof collectTablesPack, 'function'); + }); + + describe('parseCsvToTable', () => { + it('parses a basic comma-delimited table', () => { + const { columnKeys, rows } = parseCsvToTable('a,b,c\n1,2,3\n4,5,6'); + assert.deepEqual(columnKeys, ['a', 'b', 'c']); + assert.deepEqual(rows, [ + { a: '1', b: '2', c: '3' }, + { a: '4', b: '5', c: '6' } + ]); + }); + + it('returns empty result for empty input', () => { + const { columnKeys, rows } = parseCsvToTable(''); + assert.deepEqual(columnKeys, []); + assert.deepEqual(rows, []); + }); + + it('returns header only with no data rows', () => { + const { columnKeys, rows } = parseCsvToTable('x,y'); + assert.deepEqual(columnKeys, ['x', 'y']); + assert.deepEqual(rows, []); + }); + + it('strips a leading BOM from the header', () => { + const { columnKeys } = parseCsvToTable('a,b\n1,2'); + assert.deepEqual(columnKeys, ['a', 'b']); + }); + + it('trims header keys and cell values', () => { + const { columnKeys, rows } = parseCsvToTable(' a , b \n 1 , 2 '); + assert.deepEqual(columnKeys, ['a', 'b']); + assert.deepEqual(rows, [{ a: '1', b: '2' }]); + }); + + it('honours quoted fields containing the delimiter', () => { + const { rows } = parseCsvToTable('a,b\n"x,y",z'); + assert.deepEqual(rows, [{ a: 'x,y', b: 'z' }]); + }); + + it('honours quoted fields containing newlines', () => { + const { rows } = parseCsvToTable('a,b\n"line1\nline2",z'); + assert.deepEqual(rows, [{ a: 'line1\nline2', b: 'z' }]); + }); + + it('handles escaped double quotes inside a quoted field', () => { + const { rows } = parseCsvToTable('a,b\n"he said ""hi""",z'); + assert.deepEqual(rows, [{ a: 'he said "hi"', b: 'z' }]); + }); + + it('supports an alternate delimiter', () => { + const { columnKeys, rows } = parseCsvToTable('a;b\n1;2', ';'); + assert.deepEqual(columnKeys, ['a', 'b']); + assert.deepEqual(rows, [{ a: '1', b: '2' }]); + }); + + it('handles CRLF line endings', () => { + const { rows } = parseCsvToTable('a,b\r\n1,2\r\n3,4'); + assert.deepEqual(rows, [ + { a: '1', b: '2' }, + { a: '3', b: '4' } + ]); + }); + + it('skips rows that are entirely blank', () => { + const { rows } = parseCsvToTable('a,b\n1,2\n,\n3,4'); + assert.deepEqual(rows, [ + { a: '1', b: '2' }, + { a: '3', b: '4' } + ]); + }); + + it('fills missing trailing cells with empty strings', () => { + const { rows } = parseCsvToTable('a,b,c\n1,2'); + assert.deepEqual(rows, [{ a: '1', b: '2', c: '' }]); + }); + + it('uses the column index as key when a header is empty', () => { + const { columnKeys, rows } = parseCsvToTable('a,,c\n1,2,3'); + assert.deepEqual(columnKeys, ['a', '', 'c']); + assert.deepEqual(rows, [{ a: '1', 1: '2', c: '3' }]); + }); + + it('parses a single-column table', () => { + const { columnKeys, rows } = parseCsvToTable('h\n1\n2'); + assert.deepEqual(columnKeys, ['h']); + assert.deepEqual(rows, [{ h: '1' }, { h: '2' }]); + }); + + it('handles a trailing newline without producing a blank row', () => { + const { rows } = parseCsvToTable('a,b\n1,2\n'); + assert.deepEqual(rows, [{ a: '1', b: '2' }]); + }); + + it('handles lone CR line endings', () => { + const { rows } = parseCsvToTable('a,b\r1,2\r3,4'); + assert.deepEqual(rows, [ + { a: '1', b: '2' }, + { a: '3', b: '4' } + ]); + }); + + it('keeps an empty quoted field as an empty string', () => { + const { rows } = parseCsvToTable('a,b\n"",z'); + assert.deepEqual(rows, [{ a: '', b: 'z' }]); + }); + + it('treats a partially-blank row as data, not skipped', () => { + const { rows } = parseCsvToTable('a,b\n1,'); + assert.deepEqual(rows, [{ a: '1', b: '' }]); + }); + + it('parses values with leading and trailing quoted whitespace', () => { + const { rows } = parseCsvToTable('a\n" spaced "'); + assert.deepEqual(rows, [{ a: 'spaced' }]); + }); + }); + + describe('decodeGridFileText', () => { + it('decodes a plain utf8 buffer', async () => { + const text = await decodeGridFileText(Buffer.from('hello world', 'utf8')); + assert.equal(text, 'hello world'); + }); + + it('decompresses a gzip buffer', async () => { + const gz = gzipSync(Buffer.from('compressed payload', 'utf8')); + const text = await decodeGridFileText(gz); + assert.equal(text, 'compressed payload'); + }); + + it('handles an empty buffer', async () => { + const text = await decodeGridFileText(Buffer.alloc(0)); + assert.equal(text, ''); + }); + + it('respects an explicit encoding', async () => { + const buf = Buffer.from('abc', 'utf8'); + const text = await decodeGridFileText(buf, 'base64'); + assert.equal(text, buf.toString('base64')); + }); + }); + + describe('isPlainObject', () => { + it('is true for an object literal', () => { + assert.equal(isPlainObject({ a: 1 }), true); + }); + it('is true for an empty object', () => { + assert.equal(isPlainObject({}), true); + }); + it('is false for null', () => { + assert.equal(isPlainObject(null), false); + }); + it('is false for undefined', () => { + assert.equal(isPlainObject(undefined), false); + }); + it('is false for an array', () => { + assert.equal(isPlainObject([1, 2]), false); + }); + it('is false for a primitive string', () => { + assert.equal(isPlainObject('x'), false); + }); + it('is false for a number', () => { + assert.equal(isPlainObject(5), false); + }); + it('is false for a class instance', () => { + class Foo {} + assert.equal(isPlainObject(new Foo()), false); + }); + }); + + describe('isTableValue', () => { + it('is true for a plain object with type table', () => { + assert.equal(isTableValue({ type: 'table' }), true); + }); + it('is false for a plain object with a different type', () => { + assert.equal(isTableValue({ type: 'other' }), false); + }); + it('is false for a non-object', () => { + assert.equal(isTableValue('table'), false); + }); + it('is false for null', () => { + assert.equal(isTableValue(null), false); + }); + }); + + describe('isTableWithFileId', () => { + it('is true for a table with a non-empty fileId', () => { + assert.equal(isTableWithFileId({ type: 'table', fileId: 'abc' }), true); + }); + it('is false when fileId is an empty string', () => { + assert.equal(isTableWithFileId({ type: 'table', fileId: '' }), false); + }); + it('is false when fileId is whitespace only', () => { + assert.equal(isTableWithFileId({ type: 'table', fileId: ' ' }), false); + }); + it('is false when fileId is missing', () => { + assert.equal(isTableWithFileId({ type: 'table' }), false); + }); + it('is false when fileId is not a string', () => { + assert.equal(isTableWithFileId({ type: 'table', fileId: 123 }), false); + }); + it('is false when not a table', () => { + assert.equal(isTableWithFileId({ type: 'x', fileId: 'abc' }), false); + }); + }); + + describe('parseIfJson', () => { + it('parses a JSON object string', () => { + assert.deepEqual(parseIfJson('{"a":1}'), { a: 1 }); + }); + it('parses a JSON array string', () => { + assert.deepEqual(parseIfJson('[1,2,3]'), [1, 2, 3]); + }); + it('trims surrounding whitespace before parsing', () => { + assert.deepEqual(parseIfJson(' {"a":1} '), { a: 1 }); + }); + it('returns the original for a non-string input', () => { + const obj = { a: 1 }; + assert.equal(parseIfJson(obj), obj); + }); + it('returns the original for an empty string', () => { + assert.equal(parseIfJson(''), ''); + }); + it('returns the original for plain text', () => { + assert.equal(parseIfJson('hello'), 'hello'); + }); + it('returns the original for malformed JSON', () => { + assert.equal(parseIfJson('{not json}'), '{not json}'); + }); + it('returns a number input unchanged', () => { + assert.equal(parseIfJson(42), 42); + }); + }); + + describe('hydrateTablesInObject', () => { + it('returns a no-op disposer for null root', async () => { + const dispose = await hydrateTablesInObject(null, async () => ''); + assert.equal(typeof dispose, 'function'); + dispose(); + }); + + it('hydrates a table node from CSV and disposes it', async () => { + const root = { table: { type: 'table', fileId: 'f1' } }; + const loader = async (id) => { + assert.equal(id, 'f1'); + return 'a,b\n1,2'; + }; + const dispose = await hydrateTablesInObject(root, loader); + assert.deepEqual(root.table.columnKeys, ['a', 'b']); + assert.deepEqual(root.table.rows, [{ a: '1', b: '2' }]); + dispose(); + assert.equal(root.table.columnKeys, undefined); + assert.equal(root.table.rows, undefined); + }); + + it('does not reload a table already containing columns and rows', async () => { + const root = { + table: { type: 'table', fileId: 'f1', columnKeys: ['x'], rows: [{ x: '9' }] } + }; + let called = false; + const dispose = await hydrateTablesInObject(root, async () => { + called = true; + return 'a\n1'; + }); + assert.equal(called, false); + assert.deepEqual(root.table.rows, [{ x: '9' }]); + dispose(); + }); + + it('hydrates a JSON-string table value and restores the string on dispose', async () => { + const root = { cell: JSON.stringify({ type: 'table', fileId: 'f2' }) }; + const dispose = await hydrateTablesInObject(root, async () => 'h\nv'); + assert.equal(typeof root.cell, 'object'); + assert.equal(root.cell.type, 'table'); + dispose(); + assert.equal(typeof root.cell, 'string'); + }); + + it('hydrates tables nested inside arrays', async () => { + const root = { list: [{ type: 'table', fileId: 'f3' }] }; + const dispose = await hydrateTablesInObject(root, async () => 'k\n7'); + assert.deepEqual(root.list[0].rows, [{ k: '7' }]); + dispose(); + }); + }); + + describe('collectTablesPack', () => { + it('collects a single hydrated table', () => { + const root = { + t: { type: 'table', fileId: 'f1', rows: [{ a: '1' }], columnKeys: ['a'] } + }; + const pack = collectTablesPack(root); + assert.deepEqual(pack, { f1: { rows: [{ a: '1' }], columnKeys: ['a'] } }); + }); + + it('ignores tables missing rows or columnKeys', () => { + const root = { t: { type: 'table', fileId: 'f1' } }; + assert.deepEqual(collectTablesPack(root), {}); + }); + + it('collects tables nested in arrays and objects', () => { + const root = { + arr: [{ type: 'table', fileId: 'a', rows: [], columnKeys: [] }], + nested: { inner: { type: 'table', fileId: 'b', rows: [{ x: '1' }], columnKeys: ['x'] } } + }; + const pack = collectTablesPack(root); + assert.deepEqual(Object.keys(pack).sort(), ['a', 'b']); + }); + + it('returns the provided accumulator for null root', () => { + const acc = {}; + assert.equal(collectTablesPack(null, acc), acc); + assert.deepEqual(acc, {}); + }); + + it('merges into an existing accumulator', () => { + const acc = { existing: { rows: [], columnKeys: [] } }; + const root = { t: { type: 'table', fileId: 'f', rows: [], columnKeys: [] } }; + const pack = collectTablesPack(root, acc); + assert.deepEqual(Object.keys(pack).sort(), ['existing', 'f']); + }); + }); +}); diff --git a/policy-service/tests/unit-tests/interfaces/interface-enums.test.mjs b/policy-service/tests/unit-tests/interfaces/interface-enums.test.mjs new file mode 100644 index 0000000000..d2baf307c0 --- /dev/null +++ b/policy-service/tests/unit-tests/interfaces/interface-enums.test.mjs @@ -0,0 +1,69 @@ +import { assert } from 'chai'; +import { PolicyInputEventType, PolicyOutputEventType, EventActor } from '../../../dist/policy-engine/interfaces/policy-event-type.js'; +import { BlockCacheType } from '../../../dist/policy-engine/interfaces/block-cache.type.js'; +import { DocumentType } from '../../../dist/policy-engine/interfaces/document.type.js'; + +describe('@unit PolicyInputEventType enum', () => { + it('maps key to identical string value', () => { + assert.equal(PolicyInputEventType.RunEvent, 'RunEvent'); + assert.equal(PolicyInputEventType.TimerEvent, 'TimerEvent'); + assert.equal(PolicyInputEventType.GetDataEvent, 'GetDataEvent'); + }); + it('includes module/tool events', () => { + assert.equal(PolicyInputEventType.ModuleEvent, 'ModuleEvent'); + assert.equal(PolicyInputEventType.ToolEvent, 'ToolEvent'); + }); + it('all values are unique', () => { + const values = Object.values(PolicyInputEventType); + assert.equal(new Set(values).size, values.length); + }); +}); + +describe('@unit PolicyOutputEventType enum', () => { + it('maps known outputs', () => { + assert.equal(PolicyOutputEventType.RunEvent, 'RunEvent'); + assert.equal(PolicyOutputEventType.ErrorEvent, 'ErrorEvent'); + assert.equal(PolicyOutputEventType.CreateGroup, 'CreateGroup'); + assert.equal(PolicyOutputEventType.JoinGroup, 'JoinGroup'); + }); + it('includes signature quorum events', () => { + assert.equal(PolicyOutputEventType.SignatureQuorumReachedEvent, 'SignatureQuorumReachedEvent'); + assert.equal(PolicyOutputEventType.SignatureSetInsufficientEvent, 'SignatureSetInsufficientEvent'); + }); + it('all values are unique', () => { + const values = Object.values(PolicyOutputEventType); + assert.equal(new Set(values).size, values.length); + }); +}); + +describe('@unit EventActor enum', () => { + it('Owner and Issuer are lowercase strings', () => { + assert.equal(EventActor.Owner, 'owner'); + assert.equal(EventActor.Issuer, 'issuer'); + }); + it('EventInitiator is the empty string', () => { + assert.equal(EventActor.EventInitiator, ''); + }); +}); + +describe('@unit BlockCacheType enum', () => { + it('has Short and Long', () => { + assert.equal(BlockCacheType.Short, 'Short'); + assert.equal(BlockCacheType.Long, 'Long'); + }); + it('has exactly two members', () => { + assert.equal(Object.keys(BlockCacheType).length, 2); + }); +}); + +describe('@unit DocumentType enum', () => { + it('maps VC/VP/DID', () => { + assert.equal(DocumentType.VerifiableCredential, 'VerifiableCredential'); + assert.equal(DocumentType.VerifiablePresentation, 'VerifiablePresentation'); + assert.equal(DocumentType.DID, 'DID'); + }); + it('all values unique', () => { + const values = Object.values(DocumentType); + assert.equal(new Set(values).size, values.length); + }); +}); diff --git a/policy-service/tests/unit-tests/multi-policy/synchronization-service.test.mjs b/policy-service/tests/unit-tests/multi-policy/synchronization-service.test.mjs new file mode 100644 index 0000000000..6a73df4fb9 --- /dev/null +++ b/policy-service/tests/unit-tests/multi-policy/synchronization-service.test.mjs @@ -0,0 +1,134 @@ +import { assert } from 'chai'; +import esmock from 'esmock'; + +let cronInstances = []; + +class FakeCronJob { + constructor(mask, onTick) { + this.mask = mask; + this.onTick = onTick; + this.started = false; + this.stopped = false; + cronInstances.push(this); + } + start() { this.started = true; } + stop() { this.stopped = true; } +} + +const loggerCalls = []; +const fakeLogger = { + info: (...a) => { loggerCalls.push(['info', ...a]); }, + error: (...a) => { loggerCalls.push(['error', ...a]); }, + warn: (...a) => { loggerCalls.push(['warn', ...a]); }, +}; + +const { SynchronizationService } = await esmock.strict( + '../../../dist/policy-engine/multi-policy-service/synchronization-service.js', + { + 'cron': { CronJob: FakeCronJob }, + '../../../dist/policy-engine/mint/mint-service.js': { MintService: { multiMint() {} } }, + '@guardian/common': { + DatabaseServer: class {}, + MessageAction: {}, + MessageServer: class {}, + MultiPolicyTransaction: class {}, + NotificationHelper: { init() { return {}; } }, + PinoLogger: class {}, + Policy: class {}, + SynchronizationMessage: class {}, + Token: class {}, + TopicConfig: class {}, + Users: class {}, + Workers: class {}, + MgsUsers: class {}, + }, + '@guardian/interfaces': { + PolicyStatus: { PUBLISH: 'PUBLISH', DRAFT: 'DRAFT' }, + WorkerTaskType: {}, + TenantContext: { fromTenantId() { return {}; } }, + }, + }, +); + +const PUBLISH = 'PUBLISH'; + +describe('@unit SynchronizationService.start', () => { + beforeEach(() => { + cronInstances = []; + loggerCalls.length = 0; + delete process.env.MULTI_POLICY_SCHEDULER; + }); + + it('returns false when policy is not PUBLISH', () => { + const policy = { status: 'DRAFT', synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + assert.equal(svc.start(), false); + assert.equal(cronInstances.length, 0); + }); + + it('returns false when synchronizationTopicId is missing', () => { + const policy = { status: PUBLISH, synchronizationTopicId: null }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + assert.equal(svc.start(), false); + assert.equal(cronInstances.length, 0); + }); + + it('starts a cron job and returns true when PUBLISH with topic', () => { + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + assert.equal(svc.start(), true); + assert.equal(cronInstances.length, 1); + assert.equal(cronInstances[0].started, true); + }); + + it('uses the default cron mask when env var is unset', () => { + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + svc.start(); + assert.equal(cronInstances[0].mask, '0 0 * * *'); + }); + + it('uses MULTI_POLICY_SCHEDULER env override for the cron mask', () => { + process.env.MULTI_POLICY_SCHEDULER = '*/5 * * * *'; + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + svc.start(); + assert.equal(cronInstances[0].mask, '*/5 * * * *'); + }); + + it('logs an info line on successful start', () => { + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + svc.start(); + assert.equal(loggerCalls.some((c) => c[0] === 'info'), true); + }); + + it('is idempotent: a second start returns true without creating a new job', () => { + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + assert.equal(svc.start(), true); + assert.equal(svc.start(), true); + assert.equal(cronInstances.length, 1); + }); +}); + +describe('@unit SynchronizationService.stop', () => { + beforeEach(() => { cronInstances = []; }); + + it('does nothing when no job is running', () => { + const policy = { status: 'DRAFT', synchronizationTopicId: null }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + assert.doesNotThrow(() => svc.stop()); + }); + + it('stops the running job and allows restart', () => { + const policy = { status: PUBLISH, synchronizationTopicId: '0.0.1' }; + const svc = new SynchronizationService(policy, fakeLogger, 'owner'); + svc.start(); + const job = cronInstances[0]; + svc.stop(); + assert.equal(job.stopped, true); + svc.start(); + assert.equal(cronInstances.length, 2); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/associate-dissociate-token.test.mjs b/policy-service/tests/unit-tests/policy-engine/associate-dissociate-token.test.mjs new file mode 100644 index 0000000000..44306e63f9 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/associate-dissociate-token.test.mjs @@ -0,0 +1,131 @@ +import { assert } from 'chai'; +import { AssociateToken } from '../../../dist/policy-engine/policy-actions/associate-token.js'; +import { DissociateToken } from '../../../dist/policy-engine/policy-actions/dissociate-token.js'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +const token = { tokenId: 't1', tokenName: 'Tk', tokenSymbol: 'SYM', tokenType: 'fungible' }; +let saved; + +describe('AssociateToken / DissociateToken actions', () => { +beforeEach(() => { + saved = { + getUserCredentials: PolicyUtils.getUserCredentials, + getHederaAccountId: PolicyUtils.getHederaAccountId, + associate: PolicyUtils.associate, + dissociate: PolicyUtils.dissociate, + getBlock: PolicyComponentsUtils.GetBlockByTag, + }; + PolicyUtils.getUserCredentials = async () => ({ + loadRelayerAccount: async () => 'relayer-loaded' + }); + PolicyUtils.getHederaAccountId = async () => '0.0.999'; + PolicyUtils.associate = async () => true; + PolicyUtils.dissociate = async () => true; + PolicyComponentsUtils.GetBlockByTag = () => ({ tag: 'block-tag' }); +}); + +afterEach(() => { + PolicyUtils.getUserCredentials = saved.getUserCredentials; + PolicyUtils.getHederaAccountId = saved.getHederaAccountId; + PolicyUtils.associate = saved.associate; + PolicyUtils.dissociate = saved.dissociate; + PolicyComponentsUtils.GetBlockByTag = saved.getBlock; +}); + +const ref = { tag: 'ref-tag' }; + +describe('AssociateToken', () => { + it('local() returns the associate result', async () => { + const out = await AssociateToken.local({ ref, token, user: 'did:u', relayerAccount: 'r', userId: null }); + assert.isTrue(out); + }); + + it('request() builds an AssociateToken document', async () => { + const out = await AssociateToken.request({ ref, token, user: 'did:u', relayerAccount: 'r', userId: null }); + assert.equal(out.owner, 'did:u'); + assert.equal(out.accountId, '0.0.999'); + assert.equal(out.relayerAccount, 'r'); + assert.equal(out.blockTag, 'ref-tag'); + assert.equal(out.document.type, 'associate-token'); + assert.deepEqual(out.document.token, token); + assert.match(out.uuid, /[0-9a-f-]{36}/); + }); + + it('response() resolves the block, associates and returns the result', async () => { + const row = { policyId: 'p1', blockTag: 'bt', document: { token } }; + const out = await AssociateToken.response({ row, user: { did: 'did:u' }, relayerAccount: 'r', userId: null }); + assert.equal(out.type, 'associate-token'); + assert.equal(out.owner, 'did:u'); + assert.isTrue(out.associate); + assert.deepEqual(out.token, token); + }); + + it('complete() returns true when associate is truthy', async () => { + assert.isTrue(await AssociateToken.complete({ document: { associate: true } }, null)); + }); + + it('complete() returns false when associate is falsy', async () => { + assert.isFalse(await AssociateToken.complete({ document: { associate: false } }, null)); + }); + + it('validate() returns true when account and relayer match', async () => { + const req = { accountId: '0.0.1', relayerAccount: 'r' }; + const res = { accountId: '0.0.1', relayerAccount: 'r' }; + assert.isTrue(await AssociateToken.validate(req, res, null)); + }); + + it('validate() returns false when accounts differ', async () => { + const req = { accountId: '0.0.1', relayerAccount: 'r' }; + const res = { accountId: '0.0.2', relayerAccount: 'r' }; + assert.isFalse(await AssociateToken.validate(req, res, null)); + }); + + it('validate() returns false when request is missing', async () => { + assert.isFalse(await AssociateToken.validate(null, { accountId: 'a' }, null)); + }); + + it('validate() returns false (catch) when an arg throws on access', async () => { + const thrower = new Proxy({}, { get() { throw new Error('boom'); } }); + assert.isFalse(await AssociateToken.validate(thrower, { accountId: 'a', relayerAccount: 'r' }, null)); + }); +}); + +describe('DissociateToken', () => { + it('local() returns the dissociate result', async () => { + const out = await DissociateToken.local({ ref, token, user: 'did:u', relayerAccount: 'r', userId: null }); + assert.isTrue(out); + }); + + it('request() builds a DissociateToken document', async () => { + const out = await DissociateToken.request({ ref, token, user: 'did:u', relayerAccount: 'r', userId: null }); + assert.equal(out.document.type, 'dissociate-token'); + assert.deepEqual(out.document.token, token); + }); + + it('response() returns the dissociate result', async () => { + const row = { policyId: 'p1', blockTag: 'bt', document: { token } }; + const out = await DissociateToken.response({ row, user: { did: 'did:u' }, relayerAccount: 'r', userId: null }); + assert.equal(out.type, 'dissociate-token'); + assert.isTrue(out.dissociate); + }); + + it('complete() reflects the dissociate flag', async () => { + assert.isTrue(await DissociateToken.complete({ document: { dissociate: 1 } }, null)); + assert.isFalse(await DissociateToken.complete({ document: { dissociate: 0 } }, null)); + }); + + it('validate() returns false when relayer accounts differ', async () => { + const req = { accountId: '0.0.1', relayerAccount: 'r1' }; + const res = { accountId: '0.0.1', relayerAccount: 'r2' }; + assert.isFalse(await DissociateToken.validate(req, res, null)); + }); + + it('validate() returns true for matching request/response', async () => { + const req = { accountId: '0.0.1', relayerAccount: 'r' }; + const res = { accountId: '0.0.1', relayerAccount: 'r' }; + assert.isTrue(await DissociateToken.validate(req, res, null)); + }); +}); + +}); diff --git a/policy-service/tests/unit-tests/policy-engine/backup-collections.test.mjs b/policy-service/tests/unit-tests/policy-engine/backup-collections.test.mjs new file mode 100644 index 0000000000..7474fbb107 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/backup-collections.test.mjs @@ -0,0 +1,178 @@ +import { assert } from 'chai'; +import { DiffActionType } from '../../../dist/policy-engine/db-restore/index.js'; +import { + ApproveCollectionBackup, + DidCollectionBackup, + DocStateCollectionBackup, + ExternalCollectionBackup, + MintRequestCollectionBackup, + MintTransactionCollectionBackup, + MultiDocCollectionBackup, + PolicyCommentCollectionBackup, + PolicyDiscussionCollectionBackup, + PolicyInvitationsCollectionBackup, + RoleCollectionBackup, + StateCollectionBackup, + TagCollectionBackup, + TokenCollectionBackup, + TopicCollectionBackup, + VpCollectionBackup, +} from '../../../dist/policy-engine/db-restore/collections/index.js'; + +const expose = (Cls) => { + class T extends Cls { + hash(...args) { return this.actionHash(...args); } + backupData(row) { return this.createBackupData(row); } + diffData(newRow, oldRow) { return this.createDiffData(newRow, oldRow); } + check(newRow, oldRow) { return this.checkDocument(newRow, oldRow); } + need(newRow, oldRow) { return this.needLoadFile(newRow, oldRow); } + clear(row) { return this.clearFile(row); } + } + return new T('tenant', 'policy', 'owner', 'message'); +}; + +const fileBacked = [ + ['VpCollectionBackup', VpCollectionBackup], + ['ApproveCollectionBackup', ApproveCollectionBackup], + ['PolicyCommentCollectionBackup', PolicyCommentCollectionBackup], + ['PolicyDiscussionCollectionBackup', PolicyDiscussionCollectionBackup], + ['MultiDocCollectionBackup', MultiDocCollectionBackup], +]; + +const propOnly = [ + ['DidCollectionBackup', DidCollectionBackup], + ['StateCollectionBackup', StateCollectionBackup], + ['RoleCollectionBackup', RoleCollectionBackup], + ['TokenCollectionBackup', TokenCollectionBackup], + ['TagCollectionBackup', TagCollectionBackup], + ['DocStateCollectionBackup', DocStateCollectionBackup], + ['TopicCollectionBackup', TopicCollectionBackup], + ['ExternalCollectionBackup', ExternalCollectionBackup], + ['MintRequestCollectionBackup', MintRequestCollectionBackup], + ['MintTransactionCollectionBackup', MintTransactionCollectionBackup], + ['PolicyInvitationsCollectionBackup', PolicyInvitationsCollectionBackup], +]; + +for (const [name, Cls] of [...fileBacked, ...propOnly]) { + describe(`${name} pure methods`, () => { + it('createBackupData keeps only the hashes', () => { + const out = expose(Cls).backupData({ _propHash: 'p', _docHash: 'd', owner: 'o', extra: 1 }); + assert.deepEqual(out, { _propHash: 'p', _docHash: 'd' }); + }); + + it('checkDocument is false when both hashes are equal', () => { + assert.isFalse(expose(Cls).check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'a', _propHash: 'b' })); + }); + + it('checkDocument is true when the doc hash differs', () => { + assert.isTrue(expose(Cls).check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'x', _propHash: 'b' })); + }); + + it('checkDocument is true when the prop hash differs', () => { + assert.isTrue(expose(Cls).check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'a', _propHash: 'y' })); + }); + + it('actionHash without a row is a 32-char md5 hex', () => { + const out = expose(Cls).hash('', { type: DiffActionType.Create, id: 'i' }); + assert.match(out, /^[0-9a-f]{32}$/); + }); + + it('actionHash is deterministic', () => { + const b = expose(Cls); + const action = { type: DiffActionType.Update, id: 'i' }; + assert.equal(b.hash('seed', action), b.hash('seed', action)); + }); + + it('actionHash incorporates the row hashes', () => { + const b = expose(Cls); + const action = { type: DiffActionType.Create, id: 'i' }; + assert.notEqual(b.hash('', action, { _propHash: 'p', _docHash: 'd' }), b.hash('', action)); + }); + }); +} + +for (const [name, Cls] of fileBacked) { + describe(`${name} file handling`, () => { + it('needLoadFile is true when there is no old row', () => { + assert.isTrue(expose(Cls).need({ _docHash: 'a' })); + }); + + it('needLoadFile is false when doc hashes match', () => { + assert.isFalse(expose(Cls).need({ _docHash: 'a' }, { _docHash: 'a' })); + }); + + it('needLoadFile is true when doc hashes differ', () => { + assert.isTrue(expose(Cls).need({ _docHash: 'a' }, { _docHash: 'b' })); + }); + + it('clearFile removes the document field', async () => { + const out = await expose(Cls).clear({ document: 'x', keep: 1 }); + assert.deepEqual(out, { keep: 1 }); + }); + }); +} + +for (const [name, Cls] of propOnly) { + describe(`${name} file handling`, () => { + it('needLoadFile is false without an old row', () => { + assert.isFalse(expose(Cls).need({ _docHash: 'a' })); + }); + + it('needLoadFile is false even when doc hashes differ', () => { + assert.isFalse(expose(Cls).need({ _docHash: 'a' }, { _docHash: 'b' })); + }); + + it('clearFile keeps the row untouched', async () => { + const out = await expose(Cls).clear({ document: 'x', keep: 1 }); + assert.deepEqual(out, { document: 'x', keep: 1 }); + }); + }); +} + +describe('createDiffData per collection', () => { + it('VpCollectionBackup drops documentFileId', async () => { + const out = await expose(VpCollectionBackup).diffData({ a: 1, documentFileId: 'f' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('ApproveCollectionBackup drops documentFileId', async () => { + const out = await expose(ApproveCollectionBackup).diffData({ a: 1, documentFileId: 'f' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('MultiDocCollectionBackup drops documentFileId', async () => { + const out = await expose(MultiDocCollectionBackup).diffData({ a: 1, documentFileId: 'f' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('PolicyCommentCollectionBackup drops both file ids', async () => { + const out = await expose(PolicyCommentCollectionBackup).diffData({ a: 1, documentFileId: 'f', encryptedDocumentFileId: 'g' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('PolicyDiscussionCollectionBackup drops both file ids', async () => { + const out = await expose(PolicyDiscussionCollectionBackup).diffData({ a: 1, documentFileId: 'f', encryptedDocumentFileId: 'g' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('TagCollectionBackup strips db ids and dates', async () => { + const out = await expose(TagCollectionBackup).diffData({ _id: '1', id: '2', createDate: 'c', updateDate: 'u', a: 1 }); + assert.deepEqual(out, { a: 1 }); + }); + + it('DidCollectionBackup keeps file ids in the diff', async () => { + const out = await expose(DidCollectionBackup).diffData({ a: 1, documentFileId: 'f' }); + assert.deepEqual(out, { a: 1, documentFileId: 'f' }); + }); + + it('returns only changed keys when an old row is given', async () => { + const out = await expose(VpCollectionBackup).diffData({ a: 1, b: 2 }, { a: 1, b: 3 }); + assert.deepEqual(out, { b: 2 }); + }); + + it('includes keys removed from the new row as undefined', async () => { + const out = await expose(TokenCollectionBackup).diffData({ a: 1 }, { a: 1, gone: 5 }); + assert.deepEqual(Object.keys(out), ['gone']); + assert.isUndefined(out.gone); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/file-helper.test.mjs b/policy-service/tests/unit-tests/policy-engine/file-helper.test.mjs new file mode 100644 index 0000000000..df3c650dc1 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/file-helper.test.mjs @@ -0,0 +1,170 @@ +import { assert } from 'chai'; +import { FileHelper } from '../../../dist/policy-engine/db-restore/file-helper.js'; + +describe('FileHelper', () => { + it('exposes the diff file name constant', () => { + assert.equal(FileHelper.FileName, 'diff'); + }); + + it('zips and unzips a string round-trip', async () => { + const buffer = await FileHelper.zipFile('hello world'); + const result = await FileHelper.unZipFile(buffer); + assert.equal(result, 'hello world'); + }); + + it('zips and unzips an empty string', async () => { + const buffer = await FileHelper.zipFile(''); + const result = await FileHelper.unZipFile(buffer); + assert.equal(result, ''); + }); + + it('zips and unzips multi-line content', async () => { + const content = 'a\nb\nc'; + const buffer = await FileHelper.zipFile(content); + assert.equal(await FileHelper.unZipFile(buffer), content); + }); + + it('throws when the zip does not contain a diff entry', async () => { + let threw = false; + try { + await FileHelper.unZipFile(new ArrayBuffer(8)); + } catch (error) { + threw = true; + } + assert.isTrue(threw); + }); + + it('returns empty string when encrypting a null action', () => { + assert.equal(FileHelper._encryptAction(null), ''); + }); + + it('returns empty string when encrypting an undefined action', () => { + assert.equal(FileHelper._encryptAction(undefined), ''); + }); + + it('serializes an action to JSON with a trailing newline', () => { + const out = FileHelper._encryptAction({ type: 'insert', id: 'a' }); + assert.equal(out, JSON.stringify({ type: 'insert', id: 'a' }) + '\n'); + }); + + it('encrypts a collection with hash headers and one line per action', () => { + const out = FileHelper._encryptCollection({ + hash: 'H', + fullHash: 'FH', + actions: [{ type: 'a' }, { type: 'b' }] + }); + assert.include(out, 'HASH: H'); + assert.include(out, 'HASH: FH'); + const lines = out.split('\n').filter((l) => l.length > 0); + assert.equal(lines.length, 4); + }); + + it('encrypts a null collection with empty hash headers', () => { + const out = FileHelper._encryptCollection(null); + assert.include(out, 'HASH: \n'); + }); + + it('encrypts keys with target/key pairs', () => { + const out = FileHelper._encryptKeys({ + hash: 'h', + fullHash: 'fh', + actions: [{ target: 't1', key: 'k1' }, { target: 't2', key: 'k2' }] + }); + assert.include(out, 't1\n'); + assert.include(out, 'k1\n'); + assert.include(out, 't2\n'); + assert.include(out, 'k2\n'); + }); + + it('encrypts null keys with empty hashes', () => { + const out = FileHelper._encryptKeys(null); + assert.include(out, 'HASH: \n'); + }); + + it('round-trips a keys diff preserving metadata and actions', () => { + const diff = { + uuid: 'u1', + index: 3, + type: 'keys', + lastUpdate: new Date('2024-01-01T00:00:00.000Z'), + discussionsKeys: { + hash: 'h', + fullHash: 'fh', + actions: [{ target: 't1', key: 'k1' }, { target: 't2', key: 'k2' }] + } + }; + const decoded = FileHelper.decryptFile(FileHelper.encryptFile(diff)); + assert.equal(decoded.uuid, 'u1'); + assert.equal(decoded.index, 3); + assert.equal(decoded.type, 'keys'); + assert.equal(decoded.lastUpdate.toISOString(), '2024-01-01T00:00:00.000Z'); + assert.equal(decoded.discussionsKeys.actions.length, 2); + assert.deepEqual(decoded.discussionsKeys.actions[0], { target: 't1', key: 'k1' }); + }); + + it('round-trips a keys diff with no actions', () => { + const diff = { + uuid: 'u2', + index: 0, + type: 'keys', + lastUpdate: null, + discussionsKeys: { hash: '', fullHash: '', actions: [] } + }; + const decoded = FileHelper.decryptFile(FileHelper.encryptFile(diff)); + assert.equal(decoded.type, 'keys'); + assert.equal(decoded.discussionsKeys.actions.length, 0); + }); + + it('round-trips a backup diff producing all collection arrays', () => { + const diff = { uuid: 'b', index: 0, type: 'backup', lastUpdate: null }; + const decoded = FileHelper.decryptFile(FileHelper.encryptFile(diff)); + assert.equal(decoded.type, 'backup'); + assert.isArray(decoded.vcCollection.actions); + assert.isArray(decoded.vpCollection.actions); + assert.isArray(decoded.policyCommentCollection.actions); + }); + + it('round-trips backup collection actions', () => { + const diff = { + uuid: 'b2', + index: 1, + type: 'backup', + lastUpdate: new Date('2023-06-15T12:00:00.000Z'), + vcCollection: { hash: 'a', fullHash: 'b', actions: [{ type: 'insert', data: { x: 1 } }] } + }; + const decoded = FileHelper.decryptFile(FileHelper.encryptFile(diff)); + assert.equal(decoded.vcCollection.actions.length, 1); + assert.deepEqual(decoded.vcCollection.actions[0], { type: 'insert', data: { x: 1 } }); + }); + + it('treats a diff type the same as backup on round-trip', () => { + const diff = { uuid: 'd', index: 2, type: 'diff', lastUpdate: null }; + const decoded = FileHelper.decryptFile(FileHelper.encryptFile(diff)); + assert.equal(decoded.type, 'diff'); + assert.isArray(decoded.vcCollection.actions); + }); + + it('throws when the version header is missing', () => { + assert.throws(() => FileHelper.decryptFile('TYPE: backup\n'), /Invalid version/); + }); + + it('throws when the type is invalid', () => { + const file = 'VERSION: 1.0.0\nDATE: \nUUID: u\nINDEX: 0\nTYPE: bad\n'; + assert.throws(() => FileHelper.decryptFile(file), /Invalid type/); + }); + + it('encryptFile always writes version 1.0.0 header', () => { + const out = FileHelper.encryptFile({ uuid: 'u', index: 0, type: 'keys', lastUpdate: null, discussionsKeys: { hash: '', fullHash: '', actions: [] } }); + assert.isTrue(out.startsWith('VERSION: 1.0.0\n')); + }); + + it('encryptFile writes an empty date for a null lastUpdate', () => { + const out = FileHelper.encryptFile({ uuid: 'u', index: 0, type: 'keys', lastUpdate: null, discussionsKeys: { hash: '', fullHash: '', actions: [] } }); + assert.include(out, 'DATE: \n'); + }); + + it('loadFile returns null for a falsy id', async () => { + assert.equal(await FileHelper.loadFile(null), null); + assert.equal(await FileHelper.loadFile(undefined), null); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-action-type.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-action-type.test.mjs new file mode 100644 index 0000000000..7a9ee3f3f7 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-action-type.test.mjs @@ -0,0 +1,34 @@ +import { assert } from 'chai'; +import { PolicyActionType } from '../../../dist/policy-engine/policy-actions/policy-action.type.js'; + +const expected = { + SignAndSendRole: 'sign-and-send-role', + GenerateDID: 'generate-did', + SignVC: 'sign-vc', + SendMessage: 'send-message', + SendMessages: 'send-messages', + CreateTopic: 'create-topic', + AssociateToken: 'associate-token', + DissociateToken: 'dissociate-token', + AddRelayerAccount: 'add-relayer-account', + CreatePolicyDiscussion: 'create-policy-discussion', + CreatePolicyComment: 'create-policy-comment', + DisconnectPolicy: 'disconnect-policy' +}; + +describe('PolicyActionType', () => { + for (const [key, value] of Object.entries(expected)) { + it(`maps ${key} to "${value}"`, () => { + assert.equal(PolicyActionType[key], value); + }); + } + + it('exposes exactly the expected members', () => { + assert.deepEqual(Object.keys(PolicyActionType).sort(), Object.keys(expected).sort()); + }); + + it('has unique string values', () => { + const values = Object.values(PolicyActionType); + assert.equal(new Set(values).size, values.length); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-actions-utils.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-actions-utils.test.mjs new file mode 100644 index 0000000000..9618860118 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-actions-utils.test.mjs @@ -0,0 +1,112 @@ +import { assert } from 'chai'; +import { PolicyStatus, PolicyAvailability } from '@guardian/interfaces'; +import { PolicyActionsUtils } from '../../../dist/policy-engine/policy-actions/utils.js'; + +describe('PolicyActionsUtils.needKey', () => { + const noKeyStatuses = [ + PolicyStatus.DRY_RUN, + PolicyStatus.DEMO, + PolicyStatus.VIEW, + PolicyStatus.DRAFT, + PolicyStatus.PUBLISH_ERROR + ]; + + for (const status of noKeyStatuses) { + it(`returns false for ${status} regardless of availability (private)`, () => { + assert.isFalse(PolicyActionsUtils.needKey(status, PolicyAvailability.PRIVATE)); + }); + it(`returns false for ${status} regardless of availability (public)`, () => { + assert.isFalse(PolicyActionsUtils.needKey(status, PolicyAvailability.PUBLIC)); + }); + } + + it('returns false for PUBLISH when availability is PUBLIC', () => { + assert.isFalse(PolicyActionsUtils.needKey(PolicyStatus.PUBLISH, PolicyAvailability.PUBLIC)); + }); + + it('returns true for PUBLISH when availability is PRIVATE', () => { + assert.isTrue(PolicyActionsUtils.needKey(PolicyStatus.PUBLISH, PolicyAvailability.PRIVATE)); + }); + + it('returns false for DISCONTINUED when availability is PUBLIC', () => { + assert.isFalse(PolicyActionsUtils.needKey(PolicyStatus.DISCONTINUED, PolicyAvailability.PUBLIC)); + }); + + it('returns true for DISCONTINUED when availability is PRIVATE', () => { + assert.isTrue(PolicyActionsUtils.needKey(PolicyStatus.DISCONTINUED, PolicyAvailability.PRIVATE)); + }); + + it('returns false for an unknown status', () => { + assert.isFalse(PolicyActionsUtils.needKey('SOMETHING_ELSE', PolicyAvailability.PRIVATE)); + }); + + it('returns false for an undefined status', () => { + assert.isFalse(PolicyActionsUtils.needKey(undefined, PolicyAvailability.PRIVATE)); + }); +}); + +describe('PolicyActionsUtils.validate', () => { + it('returns false for an unknown document type', async () => { + const result = await PolicyActionsUtils.validate({ tenantId: null }, { document: { type: 'NOPE' } }, {}, null); + assert.isFalse(result); + }); + + it('returns false when request is missing', async () => { + const result = await PolicyActionsUtils.validate({ tenantId: null }, null, {}, null); + assert.isFalse(result); + }); + + it('returns false when document is missing', async () => { + const result = await PolicyActionsUtils.validate({ tenantId: null }, {}, {}, null); + assert.isFalse(result); + }); +}); + +describe('PolicyActionsUtils.complete', () => { + it('returns false for an unknown document type', async () => { + const result = await PolicyActionsUtils.complete({ tenantId: null }, { document: { type: 'NOPE' } }, {}, 'owner', null, 'pid'); + assert.isFalse(result); + }); + + it('returns false when remoteAction is missing', async () => { + const result = await PolicyActionsUtils.complete({ tenantId: null }, null, {}, 'owner', null, 'pid'); + assert.isFalse(result); + }); + + it('returns false when document is missing', async () => { + const result = await PolicyActionsUtils.complete({ tenantId: null }, {}, {}, 'owner', null, 'pid'); + assert.isFalse(result); + }); +}); + +describe('PolicyActionsUtils.response', () => { + it('throws "Invalid command" for an unknown document type', async () => { + let message = null; + try { + await PolicyActionsUtils.response({ row: { document: { type: 'NOPE' } } }); + } catch (error) { + message = error.message; + } + assert.equal(message, 'Invalid command'); + }); + + it('throws "Invalid command" when options are empty', async () => { + let message = null; + try { + await PolicyActionsUtils.response({}); + } catch (error) { + message = error.message; + } + assert.equal(message, 'Invalid command'); + }); + + it('throws "Invalid command" when row document is missing', async () => { + let message = null; + try { + await PolicyActionsUtils.response({ row: {} }); + } catch (error) { + message = error.message; + } + assert.equal(message, 'Invalid command'); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-user.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-user.test.mjs new file mode 100644 index 0000000000..4aa7e8e845 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-user.test.mjs @@ -0,0 +1,231 @@ +import { assert } from 'chai'; +import { LocationType, Permissions, PolicyStatus } from '@guardian/interfaces'; +import { PolicyUser, VirtualUser } from '../../../dist/policy-engine/policy-user.js'; + +const instance = { + policyId: 'policy-1', + policyOwner: 'did:owner', + policyStatus: PolicyStatus.DRAFT, + locationType: LocationType.LOCAL +}; + +function makeAuthUser(extra = {}) { + return Object.assign({ + id: 'user-1', + did: 'did:user', + username: 'alice', + hederaAccountId: '0.0.123', + permissions: ['PERM_A'], + location: LocationType.LOCAL + }, extra); +} + +describe('PolicyUser', function () { + describe('constructor with string arg', function () { + it('sets did from string', function () { + const user = new PolicyUser('did:str', instance); + assert.equal(user.did, 'did:str'); + }); + it('sets username to null', function () { + const user = new PolicyUser('did:str', instance); + assert.isNull(user.username); + }); + it('sets permissions to empty array', function () { + const user = new PolicyUser('did:str', instance); + assert.deepEqual(user.permissions, []); + }); + it('sets location to LOCAL', function () { + const user = new PolicyUser('did:str', instance); + assert.equal(user.location, LocationType.LOCAL); + }); + it('sets userId to null', function () { + const user = new PolicyUser('did:str', instance); + assert.isNull(user.userId); + }); + it('sets hederaAccountId to null', function () { + const user = new PolicyUser('did:str', instance); + assert.isNull(user.hederaAccountId); + }); + it('sets id equal to did when no group', function () { + const user = new PolicyUser('did:str', instance); + assert.equal(user.id, 'did:str'); + }); + }); + + describe('constructor with auth user arg', function () { + it('copies did, username, userId and hederaAccountId', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.equal(user.did, 'did:user'); + assert.equal(user.username, 'alice'); + assert.equal(user.userId, 'user-1'); + assert.equal(user.hederaAccountId, '0.0.123'); + }); + it('copies permissions', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.deepEqual(user.permissions, ['PERM_A']); + }); + it('defaults permissions to empty array when missing', function () { + const user = new PolicyUser(makeAuthUser({ permissions: undefined }), instance); + assert.deepEqual(user.permissions, []); + }); + it('defaults location to LOCAL when missing', function () { + const user = new PolicyUser(makeAuthUser({ location: undefined }), instance); + assert.equal(user.location, LocationType.LOCAL); + }); + it('keeps explicit remote location', function () { + const user = new PolicyUser(makeAuthUser({ location: LocationType.REMOTE }), instance); + assert.equal(user.location, LocationType.REMOTE); + }); + it('copies instance fields', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.equal(user.policyId, 'policy-1'); + assert.equal(user.policyOwner, 'did:owner'); + assert.equal(user.policyStatus, PolicyStatus.DRAFT); + assert.equal(user.policyLocation, LocationType.LOCAL); + }); + it('initializes role, group and roleMessage to null', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.isNull(user.role); + assert.isNull(user.group); + assert.isNull(user.roleMessage); + }); + }); + + describe('setCurrentGroup', function () { + it('returns this', function () { + const user = new PolicyUser('did:a', instance); + assert.strictEqual(user.setCurrentGroup(null), user); + }); + it('sets role, group and roleMessage from group object', function () { + const user = new PolicyUser('did:a', instance); + user.setCurrentGroup({ role: 'Registrant', uuid: 'g1', messageId: 'm1' }); + assert.equal(user.role, 'Registrant'); + assert.equal(user.group, 'g1'); + assert.equal(user.roleMessage, 'm1'); + }); + it('prefixes id with group uuid', function () { + const user = new PolicyUser('did:a', instance); + user.setCurrentGroup({ role: 'r', uuid: 'g1', messageId: 'm1' }); + assert.equal(user.id, 'g1:did:a'); + }); + it('resets to nulls when group is null', function () { + const user = new PolicyUser('did:a', instance); + user.setCurrentGroup({ role: 'r', uuid: 'g1', messageId: 'm1' }); + user.setCurrentGroup(null); + assert.isNull(user.role); + assert.isNull(user.group); + assert.isNull(user.roleMessage); + assert.equal(user.id, 'did:a'); + }); + it('fills missing group fields with null', function () { + const user = new PolicyUser('did:a', instance); + user.setCurrentGroup({}); + assert.isNull(user.role); + assert.isNull(user.group); + assert.isNull(user.roleMessage); + }); + }); + + describe('equal', function () { + it('compares only did when no group and no uuid', function () { + const user = new PolicyUser('did:a', instance); + assert.isTrue(user.equal('did:a', null)); + assert.isFalse(user.equal('did:b', null)); + }); + it('compares did and group when group set', function () { + const user = new PolicyUser('did:a', instance); + user.setCurrentGroup({ uuid: 'g1' }); + assert.isTrue(user.equal('did:a', 'g1')); + assert.isFalse(user.equal('did:a', 'g2')); + assert.isFalse(user.equal('did:b', 'g1')); + }); + it('fails when uuid passed but user has no group', function () { + const user = new PolicyUser('did:a', instance); + assert.isFalse(user.equal('did:a', 'g1')); + }); + }); + + describe('isAdmin', function () { + it('true when did equals policy owner', function () { + const user = new PolicyUser('did:owner', instance); + assert.isTrue(user.isAdmin); + }); + it('true when user has manage permission', function () { + const user = new PolicyUser(makeAuthUser({ + permissions: [Permissions.POLICIES_POLICY_MANAGE] + }), instance); + assert.isTrue(user.isAdmin); + }); + it('false for plain user', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.isFalse(user.isAdmin); + }); + it('false when policy location is remote even for owner', function () { + const remoteInstance = Object.assign({}, instance, { locationType: LocationType.REMOTE }); + const user = new PolicyUser('did:owner', remoteInstance); + assert.isFalse(user.isAdmin); + }); + }); + + describe('getAuthUser', function () { + it('returns auth user shape', function () { + const user = new PolicyUser(makeAuthUser(), instance); + assert.deepEqual(user.getAuthUser(), { + id: 'user-1', + username: 'alice', + did: 'did:user', + hederaAccountId: '0.0.123', + permissions: ['PERM_A'], + location: LocationType.LOCAL + }); + }); + }); + + describe('toJson', function () { + it('returns full json shape', function () { + const user = new PolicyUser(makeAuthUser(), instance); + user.setCurrentGroup({ role: 'r1', uuid: 'g1', messageId: 'm1' }); + assert.deepEqual(user.toJson(), { + id: 'g1:did:user', + did: 'did:user', + username: 'alice', + role: 'r1', + group: 'g1', + roleMessage: 'm1', + virtual: false, + isAdmin: false, + policyId: 'policy-1' + }); + }); + it('virtual is false for PolicyUser', function () { + const user = new PolicyUser('did:a', instance); + assert.isFalse(user.virtual); + }); + }); +}); + +describe('VirtualUser', function () { + it('virtual is true', function () { + const user = new VirtualUser({ did: 'did:v', username: 'v' }, instance); + assert.isTrue(user.virtual); + }); + it('copies fields from virtual user object', function () { + const user = new VirtualUser({ did: 'did:v', username: 'v', hederaAccountId: '0.0.9' }, instance); + assert.equal(user.did, 'did:v'); + assert.equal(user.username, 'v'); + assert.equal(user.hederaAccountId, '0.0.9'); + }); + it('tolerates null arg', function () { + const user = new VirtualUser(null, instance); + assert.isUndefined(user.did); + assert.isTrue(user.virtual); + }); + it('toJson reports virtual true', function () { + const user = new VirtualUser({ did: 'did:v' }, instance); + assert.isTrue(user.toJson().virtual); + }); + it('inherits isAdmin owner check', function () { + const user = new VirtualUser({ did: 'did:owner' }, instance); + assert.isTrue(user.isAdmin); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-utils-custom-formula.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-utils-custom-formula.test.mjs new file mode 100644 index 0000000000..14c7aa2f71 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-utils-custom-formula.test.mjs @@ -0,0 +1,34 @@ +import { assert } from 'chai'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; + +describe('@unit PolicyUtils.evaluateCustomFormula custom comparators', () => { + it('uses the custom strict equal operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('1 == 1', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('1 == 2', {})); + }); + it('uses the custom strict unequal operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('1 != 2', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('1 != 1', {})); + }); + it('uses the custom smaller operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('1 < 2', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('2 < 1', {})); + }); + it('uses the custom smallerEq operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('2 <= 2', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('3 <= 2', {})); + }); + it('uses the custom larger operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('3 > 2', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('1 > 2', {})); + }); + it('uses the custom largerEq operator', () => { + assert.isTrue(PolicyUtils.evaluateCustomFormula('3 >= 3', {})); + assert.isFalse(PolicyUtils.evaluateCustomFormula('2 >= 3', {})); + }); + it('uses the custom compare function', () => { + assert.equal(PolicyUtils.evaluateCustomFormula('compare(3, 2)', {}), 1); + assert.equal(PolicyUtils.evaluateCustomFormula('compare(2, 3)', {}), -1); + assert.equal(PolicyUtils.evaluateCustomFormula('compare(2, 2)', {}), 0); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-utils-extra.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-utils-extra.test.mjs new file mode 100644 index 0000000000..b4ec5cd30b --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-utils-extra.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; + +let PolicyUtils, QueryType; +try { + ({ PolicyUtils, QueryType } = await import('../../../dist/policy-engine/helpers/utils.js')); +} catch (e) { + console.warn('[policy-utils-extra.test] dist import failed:', e.message); +} + +describe('@unit PolicyUtils — additional pure helpers', () => { + if (!PolicyUtils) { it.skip('dist not available', () => {}); return; } + + it('PolicyUtils and QueryType are defined', () => { + assert.equal(typeof PolicyUtils, 'function'); + assert.equal(typeof QueryType, 'object'); + assert.equal(QueryType.eq, 'equal'); + assert.equal(QueryType.regex, 'regex'); + }); + + describe('getQueryFilter', () => { + it('builds an $eq expression for a plain string value', () => { + const f = PolicyUtils.getQueryFilter('foo', 'bar'); + assert.deepEqual(f, { $eq: ['$foo', 'bar'] }); + }); + + it('rewrites credentialSubject.0 prefix to firstCredentialSubject', () => { + const f = PolicyUtils.getQueryFilter('document.credentialSubject.0.id', 'x'); + assert.deepEqual(f, { $eq: ['$firstCredentialSubject.id', 'x'] }); + }); + + it('rewrites verifiableCredential.0 prefix', () => { + const f = PolicyUtils.getQueryFilter('document.verifiableCredential.0.field', 'y'); + assert.deepEqual(f, { $eq: ['$firstVerifiableCredential.field', 'y'] }); + }); + + it('uses the operation from an object value', () => { + const f = PolicyUtils.getQueryFilter('field', { $gt: 'abc' }); + assert.deepEqual(f, { $gt: ['$field', 'abc'] }); + }); + + it('produces an $or for numeric string/number values', () => { + const f = PolicyUtils.getQueryFilter('field', '5'); + assert.deepEqual(f, { $or: [{ $eq: ['$field', '5'] }, { $eq: ['$field', 5] }] }); + }); + + it('produces an $and for $ne over numeric values', () => { + const f = PolicyUtils.getQueryFilter('field', { $ne: '5' }); + assert.deepEqual(f, { $and: [{ $ne: ['$field', '5'] }, { $ne: ['$field', 5] }] }); + }); + + it('produces a double $not/$in $and for $nin over numeric values', () => { + const f = PolicyUtils.getQueryFilter('field', { $nin: '5' }); + assert.deepEqual(f, { + $and: [ + { $not: { $in: ['$field', '5'] } }, + { $not: { $in: ['$field', 5] } } + ] + }); + }); + + it('produces a $not/$in for $nin over a non-numeric value', () => { + const f = PolicyUtils.getQueryFilter('field', { $nin: 'abc' }); + assert.deepEqual(f, { $not: { $in: ['$field', 'abc'] } }); + }); + }); + + describe('deepAssign', () => { + it('throws when target is null', () => { + assert.throws(() => PolicyUtils.deepAssign(null, {}), TypeError); + }); + + it('merges shallow scalar properties', () => { + const out = PolicyUtils.deepAssign({ a: 1 }, { b: 2 }); + assert.deepEqual(out, { a: 1, b: 2 }); + }); + + it('deeply merges nested objects', () => { + const out = PolicyUtils.deepAssign({ a: { x: 1 } }, { a: { y: 2 } }); + assert.deepEqual(out, { a: { x: 1, y: 2 } }); + }); + + it('clones array entries rather than sharing references', () => { + const source = { list: [{ v: 1 }] }; + const out = PolicyUtils.deepAssign({}, source); + assert.deepEqual(out.list, [{ v: 1 }]); + assert.notEqual(out.list[0], source.list[0]); + }); + + it('overwrites a non-object target key with an object source', () => { + const out = PolicyUtils.deepAssign({ a: 5 }, { a: { x: 1 } }); + assert.deepEqual(out, { a: { x: 1 } }); + }); + + it('ignores non-object sources', () => { + const out = PolicyUtils.deepAssign({ a: 1 }, 42, 'str'); + assert.deepEqual(out, { a: 1 }); + }); + + it('returns the same target reference', () => { + const target = {}; + assert.equal(PolicyUtils.deepAssign(target, { a: 1 }), target); + }); + }); + + describe('setDocumentTags', () => { + it('does nothing when document has no document field', () => { + const doc = {}; + PolicyUtils.setDocumentTags(doc, [{ inheritTags: true, messageId: 'm' }]); + assert.deepEqual(doc, {}); + }); + + it('does nothing for an empty tag list', () => { + const doc = { document: {} }; + PolicyUtils.setDocumentTags(doc, []); + assert.equal(doc.document.tags, undefined); + }); + + it('appends inherited tags into document.tags', () => { + const doc = { document: {} }; + PolicyUtils.setDocumentTags(doc, [ + { name: 'n', messageId: 'm1', inheritTags: true } + ]); + assert.equal(doc.document.tags.length, 1); + assert.equal(doc.document.tags[0].messageId, 'm1'); + }); + + it('skips tags that are not inheritable', () => { + const doc = { document: { tags: [] } }; + PolicyUtils.setDocumentTags(doc, [{ messageId: 'm', inheritTags: false }]); + assert.equal(doc.document.tags.length, 0); + }); + + it('does not duplicate a tag already present by messageId', () => { + const doc = { document: { tags: [{ messageId: 'm1' }] } }; + PolicyUtils.setDocumentTags(doc, [{ messageId: 'm1', inheritTags: true }]); + assert.equal(doc.document.tags.length, 1); + }); + }); + + describe('getSchemaContext', () => { + it('returns a synthetic schema context in dry-run', () => { + const ctx = PolicyUtils.getSchemaContext({ dryRun: true }, { iri: '#abc' }); + assert.equal(ctx, 'schema#abc'); + }); + + it('returns the contextURL when not in dry-run', () => { + const ctx = PolicyUtils.getSchemaContext({ dryRun: false }, { contextURL: 'http://ctx' }); + assert.equal(ctx, 'http://ctx'); + }); + }); + + describe('checkDocumentSchema', () => { + const ref = { dryRun: false }; + + it('returns true when there is no document', () => { + assert.equal(PolicyUtils.checkDocumentSchema(ref, null, { iri: '#a', contextURL: 'c' }), true); + }); + + it('matches a single-subject document against context and iri', () => { + const schema = { iri: '#MySchema', contextURL: 'http://ctx' }; + const doc = { + document: { + credentialSubject: { '@context': ['http://ctx'], type: 'MySchema' } + } + }; + assert.equal(PolicyUtils.checkDocumentSchema(ref, doc, schema), true); + }); + + it('returns false when the subject type does not match', () => { + const schema = { iri: '#MySchema', contextURL: 'http://ctx' }; + const doc = { + document: { + credentialSubject: { '@context': ['http://ctx'], type: 'Other' } + } + }; + assert.equal(PolicyUtils.checkDocumentSchema(ref, doc, schema), false); + }); + + it('matches an array-subject document', () => { + const schema = { iri: '#S', contextURL: 'http://ctx' }; + const doc = { + document: { + credentialSubject: [{ '@context': ['http://ctx'], type: 'S' }] + } + }; + assert.equal(PolicyUtils.checkDocumentSchema(ref, doc, schema), true); + }); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-utils-pure-helpers.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-utils-pure-helpers.test.mjs new file mode 100644 index 0000000000..7b9a509f0b --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-utils-pure-helpers.test.mjs @@ -0,0 +1,341 @@ +import { assert } from 'chai'; +import { PolicyUtils, QueryType } from '../../../dist/policy-engine/helpers/utils.js'; +import { DocumentType } from '../../../dist/policy-engine/interfaces/document.type.js'; + +describe('PolicyUtils.variables', () => { + it('extracts free symbol names from a formula', () => { + assert.deepEqual(PolicyUtils.variables('a + b * c'), ['a', 'b', 'c']); + }); + it('returns an empty array for a constant', () => { + assert.deepEqual(PolicyUtils.variables('2 + 3'), []); + }); + it('returns an empty array when parsing fails', () => { + assert.deepEqual(PolicyUtils.variables('2 +'), []); + }); +}); + +describe('PolicyUtils.evaluateFormula / evaluateCustomFormula', () => { + it('evaluates a valid expression with scope', () => { + assert.equal(PolicyUtils.evaluateFormula('x * 2', { x: 4 }), 8); + }); + it('returns "Incorrect formula" on error', () => { + assert.equal(PolicyUtils.evaluateFormula('x +', {}), 'Incorrect formula'); + }); + it('custom formula evaluates a valid expression', () => { + assert.equal(PolicyUtils.evaluateCustomFormula('1 + 1', {}), 2); + }); + it('custom formula returns "Incorrect formula" on error', () => { + assert.equal(PolicyUtils.evaluateCustomFormula('@@', {}), 'Incorrect formula'); + }); +}); + +describe('PolicyUtils.aggregateSerialRange', () => { + it('builds an ascending inclusive range', () => { + assert.deepEqual(PolicyUtils.aggregateSerialRange(3, 5), [3, 4, 5]); + }); + it('normalises swapped bounds', () => { + assert.deepEqual(PolicyUtils.aggregateSerialRange(5, 3), [3, 4, 5]); + }); + it('returns a single element when bounds are equal', () => { + assert.deepEqual(PolicyUtils.aggregateSerialRange(7, 7), [7]); + }); +}); + +describe('PolicyUtils.tokenAmount', () => { + it('rounds by default', () => { + assert.deepEqual(PolicyUtils.tokenAmount({ decimals: '2' }, 1.234, 'round'), [123, '1.23']); + }); + it('ceils when method=ceil', () => { + assert.deepEqual(PolicyUtils.tokenAmount({ decimals: '2' }, 1.231, 'ceil'), [124, '1.24']); + }); + it('floors when method=floor', () => { + assert.deepEqual(PolicyUtils.tokenAmount({ decimals: '2' }, 1.239, 'floor'), [123, '1.23']); + }); + it('treats a non-numeric decimals as zero', () => { + assert.deepEqual(PolicyUtils.tokenAmount({ decimals: 'x' }, 5.6, 'round'), [6, '6']); + }); +}); + +describe('PolicyUtils.splitChunk', () => { + it('splits into chunks of the given size', () => { + assert.deepEqual(PolicyUtils.splitChunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); + }); + it('returns an empty array for an empty input', () => { + assert.deepEqual(PolicyUtils.splitChunk([], 3), []); + }); +}); + +describe('PolicyUtils.getObjectValue', () => { + it('reads a nested path', () => { + assert.equal(PolicyUtils.getObjectValue({ a: { b: { c: 7 } } }, 'a.b.c'), 7); + }); + it('reads the last array element via the "L" key', () => { + assert.equal(PolicyUtils.getObjectValue({ a: [{ b: 1 }, { b: 2 }] }, 'a.L.b'), 2); + }); + it('returns null on a broken path', () => { + assert.isNull(PolicyUtils.getObjectValue({ a: null }, 'a.b')); + }); + it('returns null when field is empty', () => { + assert.isNull(PolicyUtils.getObjectValue({ a: 1 }, '')); + }); +}); + +describe('PolicyUtils.setObjectValue', () => { + it('sets a nested path', () => { + const data = { a: { b: {} } }; + PolicyUtils.setObjectValue(data, 'a.b.c', 9); + assert.equal(data.a.b.c, 9); + }); + it('sets via the "L" array key', () => { + const data = { a: [{ b: 0 }, { b: 0 }] }; + PolicyUtils.setObjectValue(data, 'a.L.b', 42); + assert.equal(data.a[1].b, 42); + }); + it('does nothing for an empty field', () => { + const data = { a: 1 }; + PolicyUtils.setObjectValue(data, '', 5); + assert.deepEqual(data, { a: 1 }); + }); + it('returns early on a broken path', () => { + const data = { a: null }; + PolicyUtils.setObjectValue(data, 'a.b.c', 5); + assert.isNull(data.a); + }); +}); + +describe('PolicyUtils.getArray', () => { + it('wraps a scalar in an array', () => { + assert.deepEqual(PolicyUtils.getArray(5), [5]); + }); + it('passes an array through', () => { + assert.deepEqual(PolicyUtils.getArray([1, 2]), [1, 2]); + }); +}); + +describe('PolicyUtils.getSubjectId / getCredentialSubject', () => { + it('reads id from an array credentialSubject', () => { + assert.equal(PolicyUtils.getSubjectId({ document: { credentialSubject: [{ id: 'A' }] } }), 'A'); + }); + it('reads id from an object credentialSubject', () => { + assert.equal(PolicyUtils.getSubjectId({ document: { credentialSubject: { id: 'B' } } }), 'B'); + }); + it('returns null when there is no document', () => { + assert.isNull(PolicyUtils.getSubjectId({})); + }); + it('getCredentialSubject reads the first array element', () => { + assert.deepEqual(PolicyUtils.getCredentialSubject({ document: { credentialSubject: [{ x: 1 }] } }), { x: 1 }); + }); + it('getCredentialSubject returns null without a document', () => { + assert.isNull(PolicyUtils.getCredentialSubject(null)); + }); + it('getCredentialSubjectByDocument handles object and array', () => { + assert.deepEqual(PolicyUtils.getCredentialSubjectByDocument({ credentialSubject: { y: 2 } }), { y: 2 }); + assert.deepEqual(PolicyUtils.getCredentialSubjectByDocument({ credentialSubject: [{ z: 3 }] }), { z: 3 }); + assert.isNull(PolicyUtils.getCredentialSubjectByDocument(null)); + }); +}); + +describe('PolicyUtils.getDocumentType', () => { + it('returns VerifiablePresentation', () => { + assert.equal(PolicyUtils.getDocumentType({ document: { verifiableCredential: [] } }), DocumentType.VerifiablePresentation); + }); + it('returns VerifiableCredential', () => { + assert.equal(PolicyUtils.getDocumentType({ document: { credentialSubject: {} } }), DocumentType.VerifiableCredential); + }); + it('returns DID', () => { + assert.equal(PolicyUtils.getDocumentType({ document: { verificationMethod: {} } }), DocumentType.DID); + }); + it('returns null for an unrecognised document', () => { + assert.isNull(PolicyUtils.getDocumentType({ document: {} })); + assert.isNull(PolicyUtils.getDocumentType(null)); + }); +}); + +describe('PolicyUtils.checkDocumentField', () => { + const doc = { a: 5, list: ['x', 'y'] }; + it('equal', () => { + assert.isTrue(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'equal', value: 5 })); + assert.isFalse(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'equal', value: 6 })); + }); + it('not_equal', () => { + assert.isTrue(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'not_equal', value: 6 })); + }); + it('in / not_in', () => { + assert.isTrue(PolicyUtils.checkDocumentField(doc, { field: 'list', type: 'in', value: 'x' })); + assert.isFalse(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'in', value: 'x' })); + assert.isTrue(PolicyUtils.checkDocumentField(doc, { field: 'list', type: 'not_in', value: 'q' })); + assert.isFalse(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'not_in', value: 'q' })); + }); + it('unknown type returns false', () => { + assert.isFalse(PolicyUtils.checkDocumentField(doc, { field: 'a', type: 'weird', value: 5 })); + }); + it('no document returns false', () => { + assert.isFalse(PolicyUtils.checkDocumentField(null, { field: 'a', type: 'equal', value: 5 })); + }); +}); + +describe('PolicyUtils.getDocumentRef', () => { + it('reads ref from a VC credentialSubject', () => { + assert.equal(PolicyUtils.getDocumentRef({ document: { credentialSubject: { ref: 'R1' } } }), 'R1'); + }); + it('reads ref from an array credentialSubject', () => { + assert.equal(PolicyUtils.getDocumentRef({ document: { credentialSubject: [{ ref: 'R2' }] } }), 'R2'); + }); + it('reads ref from a VP verifiableCredential', () => { + assert.equal(PolicyUtils.getDocumentRef({ + document: { verifiableCredential: [{ credentialSubject: [{ ref: 'R3' }] }] } + }), 'R3'); + }); + it('returns null when no ref exists', () => { + assert.isNull(PolicyUtils.getDocumentRef({ document: {} })); + assert.isNull(PolicyUtils.getDocumentRef(null)); + }); +}); + +describe('PolicyUtils.getErrorMessage', () => { + it('returns the string itself', () => { + assert.equal(PolicyUtils.getErrorMessage('boom'), 'boom'); + }); + it('returns error.message', () => { + assert.equal(PolicyUtils.getErrorMessage(new Error('m')), 'm'); + }); + it('returns error.error', () => { + assert.equal(PolicyUtils.getErrorMessage({ error: 'e' }), 'e'); + }); + it('returns error.name', () => { + assert.equal(PolicyUtils.getErrorMessage({ name: 'n' }), 'n'); + }); + it('returns a fallback for an unidentified error', () => { + assert.equal(PolicyUtils.getErrorMessage({}), 'Unidentified error'); + }); +}); + +describe('PolicyUtils.parseFilterValue', () => { + const cases = [ + ['eq:1', QueryType.eq, '1'], + ['ne:2', QueryType.ne, '2'], + ['in:a,b', QueryType.in, 'a,b'], + ['nin:x', QueryType.nin, 'x'], + ['gt:3', QueryType.gt, '3'], + ['gte:4', QueryType.gte, '4'], + ['lt:5', QueryType.lt, '5'], + ['lte:6', QueryType.lte, '6'], + ['regex:abc', QueryType.regex, 'abc'], + ]; + for (const [input, type, value] of cases) { + it(`parses "${input}"`, () => { + assert.deepEqual(PolicyUtils.parseFilterValue(input), [type, value]); + }); + } + it('returns [null, value] for an unprefixed string', () => { + assert.deepEqual(PolicyUtils.parseFilterValue('plain'), [null, 'plain']); + }); +}); + +describe('PolicyUtils.getQueryValue', () => { + it('coerces numbers to strings then applies the type', () => { + assert.equal(PolicyUtils.getQueryValue(QueryType.eq, 5), '5'); + }); + it('returns null for non-string non-number', () => { + assert.isNull(PolicyUtils.getQueryValue(QueryType.eq, {})); + }); + it('splits in/nin into arrays', () => { + assert.deepEqual(PolicyUtils.getQueryValue(QueryType.in, 'a,b'), ['a', 'b']); + assert.deepEqual(PolicyUtils.getQueryValue(QueryType.nin, 'a,b'), ['a', 'b']); + }); + it('wraps regex in .* anchors', () => { + assert.equal(PolicyUtils.getQueryValue(QueryType.regex, 'x'), '.*x.*'); + }); + it('returns null for an unknown query type', () => { + assert.isNull(PolicyUtils.getQueryValue('weird', 'x')); + }); +}); + +describe('PolicyUtils.getQueryExpression', () => { + it('returns null for null/undefined value', () => { + assert.isNull(PolicyUtils.getQueryExpression(QueryType.eq, null)); + assert.isNull(PolicyUtils.getQueryExpression(QueryType.eq, undefined)); + }); + it('maps each query type to a Mongo operator', () => { + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.eq, 1), { $eq: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.ne, 1), { $ne: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.in, [1]), { $in: [1] }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.nin, [1]), { $nin: [1] }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.gt, 1), { $gt: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.gte, 1), { $gte: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.lt, 1), { $lt: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.lte, 1), { $lte: 1 }); + assert.deepEqual(PolicyUtils.getQueryExpression(QueryType.regex, 'x'), { $regex: 'x' }); + }); + it('returns null for an unknown query type', () => { + assert.isNull(PolicyUtils.getQueryExpression('weird', 1)); + }); +}); + +describe('PolicyUtils.parseQuery', () => { + it('parses a user_defined filter end to end', () => { + const out = PolicyUtils.parseQuery('user_defined', 'in:a,b'); + assert.equal(out.type, QueryType.in); + assert.deepEqual(out.value, ['a', 'b']); + assert.deepEqual(out.expression, { $in: ['a', 'b'] }); + }); + it('parses an explicit type', () => { + const out = PolicyUtils.parseQuery(QueryType.eq, 'val'); + assert.equal(out.type, QueryType.eq); + assert.equal(out.value, 'val'); + assert.deepEqual(out.expression, { $eq: 'val' }); + }); +}); + +describe('PolicyUtils.parseQueryNumberValue', () => { + it('returns string/number pair for a scalar number', () => { + assert.deepEqual(PolicyUtils.parseQueryNumberValue('5'), ['5', 5]); + }); + it('returns null for a non-number scalar', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue('abc')); + }); + it('returns paired arrays for an array of numbers', () => { + assert.deepEqual(PolicyUtils.parseQueryNumberValue([1, 2]), [['1', '2'], [1, 2]]); + }); + it('returns null for an array with a non-number', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue([1, 'x'])); + }); + it('returns null for an empty array', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue([])); + }); +}); + +describe('PolicyUtils.getQueryFilter number branches', () => { + it('builds an $or for a numeric $gt operation', () => { + const out = PolicyUtils.getQueryFilter('document.field', { $gt: 5 }); + assert.property(out, '$or'); + }); + it('builds an $and for a numeric $nin operation', () => { + const out = PolicyUtils.getQueryFilter('document.field', { $nin: 5 }); + assert.property(out, '$and'); + }); + it('builds an $and for a numeric $ne operation', () => { + const out = PolicyUtils.getQueryFilter('document.field', { $ne: 5 }); + assert.property(out, '$and'); + }); + it('builds a $not/$in for a non-numeric $nin operation', () => { + const out = PolicyUtils.getQueryFilter('document.field', { $nin: 'abc' }); + assert.property(out, '$not'); + }); + it('builds a plain operator for a non-numeric value', () => { + const out = PolicyUtils.getQueryFilter('document.field', { $eq: 'abc' }); + assert.property(out, '$eq'); + }); + it('rewrites credentialSubject key aliases', () => { + const out = PolicyUtils.getQueryFilter('document.credentialSubject.0.x', 'abc'); + assert.deepEqual(out, { $eq: ['$firstCredentialSubject.x', 'abc'] }); + }); +}); + +describe('PolicyUtils.createVcFromSubject', () => { + it('builds a VC document carrying the subject', () => { + const vc = PolicyUtils.createVcFromSubject({ id: 'x', type: 'T', value: 1 }); + assert.isFunction(vc.getCredentialSubject); + assert.isObject(vc.getCredentialSubject(0)); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-utils-pure.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-utils-pure.test.mjs new file mode 100644 index 0000000000..62e64d4f95 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-utils-pure.test.mjs @@ -0,0 +1,169 @@ +import { assert } from 'chai'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; + +describe('@unit PolicyUtils.deepAssign', () => { + it('throws on null target', () => { + assert.throws(() => PolicyUtils.deepAssign(null, {}), TypeError); + }); + it('shallow-copies primitive properties', () => { + assert.deepEqual(PolicyUtils.deepAssign({}, { a: 1, b: 'x' }), { a: 1, b: 'x' }); + }); + it('deep-merges nested objects', () => { + const out = PolicyUtils.deepAssign({ a: { x: 1 } }, { a: { y: 2 } }); + assert.deepEqual(out, { a: { x: 1, y: 2 } }); + }); + it('clones arrays (no shared reference)', () => { + const src = { list: [{ a: 1 }] }; + const out = PolicyUtils.deepAssign({}, src); + assert.deepEqual(out.list, [{ a: 1 }]); + assert.notStrictEqual(out.list[0], src.list[0]); + }); + it('replaces non-object target prop with object when source nested', () => { + const out = PolicyUtils.deepAssign({ a: 5 }, { a: { z: 1 } }); + assert.deepEqual(out.a, { z: 1 }); + }); + it('ignores non-object sources', () => { + const out = PolicyUtils.deepAssign({ a: 1 }, 5, 'str', null); + assert.deepEqual(out, { a: 1 }); + }); + it('later sources override earlier ones', () => { + assert.deepEqual(PolicyUtils.deepAssign({}, { a: 1 }, { a: 2 }), { a: 2 }); + }); + it('returns the target reference', () => { + const t = {}; + assert.strictEqual(PolicyUtils.deepAssign(t, { a: 1 }), t); + }); + it('copies array of primitives', () => { + assert.deepEqual(PolicyUtils.deepAssign({}, { nums: [1, 2, 3] }), { nums: [1, 2, 3] }); + }); +}); + +describe('@unit PolicyUtils.getVCScope / template delegators', () => { + it('getVCScope returns first credential subject fields', () => { + const item = { + getCredentialSubject: (i) => ({ getFields: () => ({ index: i }) }), + }; + assert.deepEqual(PolicyUtils.getVCScope(item), { index: 0 }); + }); + it('getTokenTemplate delegates to ref.components', () => { + const ref = { components: { getTokenTemplate: (n) => ({ n }) } }; + assert.deepEqual(PolicyUtils.getTokenTemplate(ref, 'TK'), { n: 'TK' }); + }); + it('getGroupTemplate delegates to ref.components', () => { + const ref = { components: { getGroupTemplate: (n) => ({ g: n }) } }; + assert.deepEqual(PolicyUtils.getGroupTemplate(ref, 'G'), { g: 'G' }); + }); + it('getRoleTemplate delegates to ref.components', () => { + const ref = { components: { getRoleTemplate: (n) => ({ r: n }) } }; + assert.deepEqual(PolicyUtils.getRoleTemplate(ref, 'R'), { r: 'R' }); + }); +}); + +describe('@unit PolicyUtils.autoCalculateField', () => { + it('evaluates a simple expression against the document', () => { + const field = { expression: 'a + b', path: 'sum' }; + assert.equal(PolicyUtils.autoCalculateField(field, { a: 2, b: 3 }), 5); + }); + it('throws Invalid expression on bad expression', () => { + const field = { expression: 'a +', path: 'broken' }; + assert.throws(() => PolicyUtils.autoCalculateField(field, { a: 1 }), /Invalid expression: broken/); + }); + it('can reference document fields by name (with scope)', () => { + const field = { expression: 'price * qty', path: 'total' }; + assert.equal(PolicyUtils.autoCalculateField(field, { price: 4, qty: 2 }), 8); + }); +}); + +describe('@unit PolicyUtils.autoCalculateFields', () => { + it('no-op for null/non-object document', () => { + assert.isUndefined(PolicyUtils.autoCalculateFields([], null)); + assert.isUndefined(PolicyUtils.autoCalculateFields([], 'str')); + assert.isUndefined(PolicyUtils.autoCalculateFields([], [1, 2])); + }); + it('sets autocalculated leaf field', () => { + const doc = { a: 1, b: 2 }; + PolicyUtils.autoCalculateFields([{ name: 'sum', autocalculate: true, expression: 'a + b' }], doc); + assert.equal(doc.sum, 3); + }); + it('deletes field when expression yields undefined', () => { + const doc = { a: 1 }; + PolicyUtils.autoCalculateFields([{ name: 'x', autocalculate: true, expression: 'undefined' }], doc); + assert.notProperty(doc, 'x'); + }); + it('skips non-autocalculate non-ref fields', () => { + const doc = { a: 1 }; + PolicyUtils.autoCalculateFields([{ name: 'a', autocalculate: false }], doc); + assert.deepEqual(doc, { a: 1 }); + }); + it('recurses into nested ref object field', () => { + const doc = { nested: { a: 2, b: 5 } }; + PolicyUtils.autoCalculateFields([ + { name: 'nested', isRef: true, fields: [{ name: 'sum', autocalculate: true, expression: 'a + b' }] }, + ], doc); + assert.equal(doc.nested.sum, 7); + }); + it('recurses into array ref field elements', () => { + const doc = { items: [{ a: 1, b: 1 }, { a: 2, b: 3 }] }; + PolicyUtils.autoCalculateFields([ + { name: 'items', isRef: true, fields: [{ name: 'sum', autocalculate: true, expression: 'a + b' }] }, + ], doc); + assert.equal(doc.items[0].sum, 2); + assert.equal(doc.items[1].sum, 5); + }); +}); + +describe('@unit PolicyUtils.getQueryFilter', () => { + it('builds $eq filter for a simple string value', () => { + const f = PolicyUtils.getQueryFilter('field', 'abc'); + assert.deepEqual(f, { $eq: ['$field', 'abc'] }); + }); + it('rewrites credentialSubject path prefix', () => { + const f = PolicyUtils.getQueryFilter('document.credentialSubject.0.x', 'v'); + assert.deepEqual(f, { $eq: ['$firstCredentialSubject.x', 'v'] }); + }); + it('rewrites verifiableCredential path prefix', () => { + const f = PolicyUtils.getQueryFilter('document.verifiableCredential.0.y', 'v'); + assert.deepEqual(f, { $eq: ['$firstVerifiableCredential.y', 'v'] }); + }); + it('object value carries the operation', () => { + const f = PolicyUtils.getQueryFilter('field', { $gt: 'zzz' }); + assert.deepEqual(f, { $gt: ['$field', 'zzz'] }); + }); + it('$nin string builds $not $in', () => { + const f = PolicyUtils.getQueryFilter('field', { $nin: 'abc' }); + assert.deepEqual(f, { $not: { $in: ['$field', 'abc'] } }); + }); + it('numeric value produces $or of string and number forms', () => { + const f = PolicyUtils.getQueryFilter('field', { $eq: 5 }); + assert.property(f, '$or'); + assert.deepEqual(f.$or[0], { $eq: ['$field', '5'] }); + assert.deepEqual(f.$or[1], { $eq: ['$field', 5] }); + }); + it('numeric $ne produces $and form', () => { + const f = PolicyUtils.getQueryFilter('field', { $ne: 5 }); + assert.property(f, '$and'); + }); + it('numeric $nin produces $and of two $not $in', () => { + const f = PolicyUtils.getQueryFilter('field', { $nin: 7 }); + assert.property(f, '$and'); + assert.equal(f.$and.length, 2); + }); +}); + +describe('@unit PolicyUtils.parseQueryNumberValue', () => { + it('returns string/number tuple for numeric scalar', () => { + assert.deepEqual(PolicyUtils.parseQueryNumberValue(5), ['5', 5]); + }); + it('returns null for non-numeric scalar', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue('abc')); + }); + it('handles numeric array', () => { + assert.deepEqual(PolicyUtils.parseQueryNumberValue([1, 2]), [['1', '2'], [1, 2]]); + }); + it('returns null when array has a non-numeric element', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue([1, 'x'])); + }); + it('returns null for empty array', () => { + assert.isNull(PolicyUtils.parseQueryNumberValue([])); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/policy-utils-schema-tags.test.mjs b/policy-service/tests/unit-tests/policy-engine/policy-utils-schema-tags.test.mjs new file mode 100644 index 0000000000..e88d48cfea --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/policy-utils-schema-tags.test.mjs @@ -0,0 +1,161 @@ +import { assert } from 'chai'; +import { PolicyUtils } from '../../../dist/policy-engine/helpers/utils.js'; + +const schema = (overrides = {}) => ({ + iri: '#MyType', + contextURL: 'https://context.example/schema', + ...overrides, +}); + +const vcDocument = (credentialSubject) => ({ document: { credentialSubject } }); + +describe('PolicyUtils.getSchemaContext', () => { + it('returns the context url for a normal policy', () => { + assert.equal(PolicyUtils.getSchemaContext({ dryRun: false }, schema()), 'https://context.example/schema'); + }); + + it('returns a synthetic context in dry run mode', () => { + assert.equal(PolicyUtils.getSchemaContext({ dryRun: 'dry' }, schema()), 'schema#MyType'); + }); +}); + +describe('PolicyUtils.checkDocumentSchema', () => { + const ref = { dryRun: false }; + + it('accepts a matching object credential subject', () => { + const doc = vcDocument({ '@context': ['https://context.example/schema'], type: 'MyType' }); + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('accepts a matching array credential subject', () => { + const doc = vcDocument([{ '@context': ['https://context.example/schema'], type: 'MyType' }]); + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('rejects a wrong subject type', () => { + const doc = vcDocument({ '@context': ['https://context.example/schema'], type: 'OtherType' }); + assert.isFalse(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('rejects a missing context', () => { + const doc = vcDocument({ '@context': ['https://other.example'], type: 'MyType' }); + assert.isFalse(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('only checks the first subject of an array', () => { + const doc = vcDocument([ + { '@context': ['https://context.example/schema'], type: 'MyType' }, + { '@context': ['https://other.example'], type: 'OtherType' }, + ]); + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('matches a string context by substring', () => { + const doc = vcDocument({ '@context': 'https://context.example/schema#x', type: 'MyType' }); + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, doc, schema())); + }); + + it('passes documents without an inner document', () => { + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, {}, schema())); + assert.isTrue(PolicyUtils.checkDocumentSchema(ref, null, schema())); + }); + + it('uses the dry run context when in dry run mode', () => { + const doc = vcDocument({ '@context': ['schema#MyType'], type: 'MyType' }); + assert.isTrue(PolicyUtils.checkDocumentSchema({ dryRun: 'dry' }, doc, schema())); + }); +}); + +describe('PolicyUtils.setGuardianVersion', () => { + it('sets the version for code versions above 1.1.0', () => { + const subject = {}; + PolicyUtils.setGuardianVersion(subject, { codeVersion: '1.2.0' }); + assert.isString(subject.guardianVersion); + assert.isNotEmpty(subject.guardianVersion); + }); + + it('does nothing for code version 1.1.0', () => { + const subject = {}; + PolicyUtils.setGuardianVersion(subject, { codeVersion: '1.1.0' }); + assert.notProperty(subject, 'guardianVersion'); + }); + + it('does nothing for older code versions', () => { + const subject = {}; + PolicyUtils.setGuardianVersion(subject, { codeVersion: '1.0.0' }); + assert.notProperty(subject, 'guardianVersion'); + }); +}); + +describe('PolicyUtils.setDocumentTags', () => { + const tag = (overrides = {}) => ({ + name: 'tag-name', + description: 'desc', + owner: 'did:owner', + target: 'target-1', + topicId: 'topic-1', + messageId: 'msg-1', + inheritTags: true, + localTarget: 'ignored', + ...overrides, + }); + + it('appends an inheritable tag as a short tag', () => { + const document = { document: {} }; + PolicyUtils.setDocumentTags(document, [tag()]); + assert.deepEqual(document.document.tags, [{ + name: 'tag-name', + description: 'desc', + owner: 'did:owner', + target: 'target-1', + topicId: 'topic-1', + messageId: 'msg-1', + inheritTags: true, + }]); + }); + + it('skips tags that are not inheritable', () => { + const document = { document: {} }; + PolicyUtils.setDocumentTags(document, [tag({ inheritTags: false })]); + assert.deepEqual(document.document.tags, []); + }); + + it('deduplicates by message id', () => { + const document = { document: {} }; + PolicyUtils.setDocumentTags(document, [tag(), tag({ name: 'other' })]); + assert.lengthOf(document.document.tags, 1); + }); + + it('keeps existing tags', () => { + const document = { document: { tags: [{ messageId: 'old' }] } }; + PolicyUtils.setDocumentTags(document, [tag()]); + assert.lengthOf(document.document.tags, 2); + assert.equal(document.document.tags[0].messageId, 'old'); + }); + + it('does nothing without an inner document', () => { + const document = {}; + PolicyUtils.setDocumentTags(document, [tag()]); + assert.notProperty(document, 'document'); + }); + + it('does nothing for an empty tag list', () => { + const document = { document: {} }; + PolicyUtils.setDocumentTags(document, []); + assert.notProperty(document.document, 'tags'); + }); + + it('does nothing for a null tag list', () => { + const document = { document: {} }; + PolicyUtils.setDocumentTags(document, null); + assert.notProperty(document.document, 'tags'); + }); +}); + +describe('PolicyUtils.getGroupTemplates', () => { + it('delegates to the components service', () => { + const templates = [{ name: 'group' }]; + const ref = { components: { getGroupTemplates: () => templates } }; + assert.strictEqual(PolicyUtils.getGroupTemplates(ref), templates); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/record-utils.test.mjs b/policy-service/tests/unit-tests/policy-engine/record-utils.test.mjs new file mode 100644 index 0000000000..f8549a23e9 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/record-utils.test.mjs @@ -0,0 +1,83 @@ +import { assert } from 'chai'; +import { RecordUtils } from '../../../dist/policy-engine/record-utils.js'; + +const unknownId = () => 'no-such-policy-' + Math.random().toString(36).slice(2); + +describe('RecordUtils (no registered components)', () => { + it('GetRecordingController returns null', () => { + assert.isNull(RecordUtils.GetRecordingController(unknownId())); + }); + + it('GetRunAndRecordController returns null', () => { + assert.isNull(RecordUtils.GetRunAndRecordController(unknownId())); + }); + + it('StartRecording returns false', async () => { + assert.isFalse(await RecordUtils.StartRecording(unknownId())); + }); + + it('StopRecording returns false', async () => { + assert.isFalse(await RecordUtils.StopRecording(unknownId())); + }); + + it('StopRunning returns true', async () => { + assert.isTrue(await RecordUtils.StopRunning(unknownId())); + }); + + it('DestroyRecording returns false', async () => { + assert.isFalse(await RecordUtils.DestroyRecording(unknownId())); + }); + + it('DestroyRunning returns true', async () => { + assert.isTrue(await RecordUtils.DestroyRunning(unknownId())); + }); + + it('FastForward returns false', async () => { + assert.isFalse(await RecordUtils.FastForward(unknownId(), {})); + }); + + it('RetryStep returns false', async () => { + assert.isFalse(await RecordUtils.RetryStep(unknownId(), {})); + }); + + it('SkipStep returns false', async () => { + assert.isFalse(await RecordUtils.SkipStep(unknownId(), {})); + }); + + it('GetRecordStatus returns an object containing the policyId', () => { + const id = unknownId(); + assert.deepEqual(RecordUtils.GetRecordStatus(id), { policyId: id }); + }); + + it('GetRecordedActions returns null', async () => { + assert.isNull(await RecordUtils.GetRecordedActions(unknownId())); + }); + + it('GetRecordResults returns null', async () => { + assert.isNull(await RecordUtils.GetRecordResults(unknownId())); + }); + + it('RunRecord returns null', async () => { + assert.isNull(await RecordUtils.RunRecord(unknownId(), [], [], {})); + }); + + it('RecordSelectGroup resolves without throwing', async () => { + assert.equal(await RecordUtils.RecordSelectGroup(unknownId(), {}, 'uuid'), undefined); + }); + + it('RecordSetBlockData resolves without throwing', async () => { + assert.equal(await RecordUtils.RecordSetBlockData(unknownId(), {}, {}, {}), undefined); + }); + + it('RecordExternalData resolves without throwing', async () => { + assert.equal(await RecordUtils.RecordExternalData(unknownId(), {}), undefined); + }); + + it('RecordCreateUser resolves without throwing', async () => { + assert.equal(await RecordUtils.RecordCreateUser(unknownId(), 'did', {}), undefined); + }); + + it('RecordSetUser resolves without throwing', async () => { + assert.equal(await RecordUtils.RecordSetUser(unknownId(), 'did'), undefined); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/restore-collections.test.mjs b/policy-service/tests/unit-tests/policy-engine/restore-collections.test.mjs new file mode 100644 index 0000000000..05f116b8f8 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/restore-collections.test.mjs @@ -0,0 +1,183 @@ +import { assert } from 'chai'; +import { DiffActionType } from '../../../dist/policy-engine/db-restore/index.js'; +import { + ApproveCollectionRestore, + DidCollectionRestore, + DocStateCollectionRestore, + ExternalCollectionRestore, + MintRequestCollectionRestore, + MintTransactionCollectionRestore, + MultiDocCollectionRestore, + PolicyCommentCollectionRestore, + PolicyDiscussionCollectionRestore, + PolicyInvitationsCollectionRestore, + RoleCollectionRestore, + StateCollectionRestore, + TagCollectionRestore, + TokenCollectionRestore, + TopicCollectionRestore, + VpCollectionRestore, +} from '../../../dist/policy-engine/db-restore/collections/index.js'; + +const expose = (Cls) => { + class T extends Cls { + hash(...args) { return this.actionHash(...args); } + row(data, id) { return this.createRow(data, id); } + } + return new T('tenant', 'policy', 'owner', 'message'); +}; + +const allRestores = [ + ['VpCollectionRestore', VpCollectionRestore], + ['ApproveCollectionRestore', ApproveCollectionRestore], + ['PolicyCommentCollectionRestore', PolicyCommentCollectionRestore], + ['PolicyDiscussionCollectionRestore', PolicyDiscussionCollectionRestore], + ['MultiDocCollectionRestore', MultiDocCollectionRestore], + ['DidCollectionRestore', DidCollectionRestore], + ['StateCollectionRestore', StateCollectionRestore], + ['RoleCollectionRestore', RoleCollectionRestore], + ['TokenCollectionRestore', TokenCollectionRestore], + ['TagCollectionRestore', TagCollectionRestore], + ['DocStateCollectionRestore', DocStateCollectionRestore], + ['TopicCollectionRestore', TopicCollectionRestore], + ['ExternalCollectionRestore', ExternalCollectionRestore], + ['MintRequestCollectionRestore', MintRequestCollectionRestore], + ['MintTransactionCollectionRestore', MintTransactionCollectionRestore], + ['PolicyInvitationsCollectionRestore', PolicyInvitationsCollectionRestore], +]; + +const b64 = (value) => Buffer.from(value).toString('base64'); + +for (const [name, Cls] of allRestores) { + describe(`${name} actionHash`, () => { + it('without a row is a 32-char md5 hex', () => { + const out = expose(Cls).hash('', { type: DiffActionType.Create, id: 'i' }); + assert.match(out, /^[0-9a-f]{32}$/); + }); + + it('is deterministic', () => { + const r = expose(Cls); + const action = { type: DiffActionType.Update, id: 'i' }; + assert.equal(r.hash('seed', action), r.hash('seed', action)); + }); + + it('incorporates the row hashes', () => { + const r = expose(Cls); + const action = { type: DiffActionType.Create, id: 'i' }; + assert.notEqual(r.hash('', action, { _propHash: 'p', _docHash: 'd' }), r.hash('', action)); + }); + }); +} + +describe('VpCollectionRestore.createRow', () => { + it('parses a base64 document into JSON', () => { + const out = expose(VpCollectionRestore).row({ document: b64('{"a":1}') }, 'id'); + assert.deepEqual(out.document, { a: 1 }); + }); + + it('drops documentFileId', () => { + const out = expose(VpCollectionRestore).row({ documentFileId: 'f', x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); + + it('leaves rows without a document untouched', () => { + const out = expose(VpCollectionRestore).row({ x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); +}); + +describe('ApproveCollectionRestore.createRow', () => { + it('parses a base64 document into JSON', () => { + const out = expose(ApproveCollectionRestore).row({ document: b64('{"ok":true}') }, 'id'); + assert.deepEqual(out.document, { ok: true }); + }); + + it('drops documentFileId', () => { + const out = expose(ApproveCollectionRestore).row({ documentFileId: 'f', x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); +}); + +describe('MultiDocCollectionRestore.createRow', () => { + it('parses a base64 document into JSON', () => { + const out = expose(MultiDocCollectionRestore).row({ document: b64('{"n":2}') }, 'id'); + assert.deepEqual(out.document, { n: 2 }); + }); + + it('drops documentFileId', () => { + const out = expose(MultiDocCollectionRestore).row({ documentFileId: 'f', x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); +}); + +describe('PolicyCommentCollectionRestore.createRow', () => { + it('decodes a base64 encryptedDocument into a string', () => { + const out = expose(PolicyCommentCollectionRestore).row({ encryptedDocument: b64('secret') }, 'id'); + assert.equal(out.encryptedDocument, 'secret'); + }); + + it('drops both file ids', () => { + const out = expose(PolicyCommentCollectionRestore).row({ documentFileId: 'f', encryptedDocumentFileId: 'g', x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); +}); + +describe('PolicyDiscussionCollectionRestore.createRow', () => { + it('decodes a base64 encryptedDocument into a string', () => { + const out = expose(PolicyDiscussionCollectionRestore).row({ encryptedDocument: b64('secret') }, 'id'); + assert.equal(out.encryptedDocument, 'secret'); + }); + + it('drops both file ids', () => { + const out = expose(PolicyDiscussionCollectionRestore).row({ documentFileId: 'f', encryptedDocumentFileId: 'g', x: 1 }, 'id'); + assert.deepEqual(out, { x: 1 }); + }); +}); + +describe('MintRequestCollectionRestore.createRow', () => { + it('marks the row readonly', () => { + const out = expose(MintRequestCollectionRestore).row({ x: 1 }, 'id'); + assert.isTrue(out.readonly); + }); + + it('keeps other fields', () => { + const out = expose(MintRequestCollectionRestore).row({ x: 1 }, 'id'); + assert.equal(out.x, 1); + }); +}); + +describe('MintTransactionCollectionRestore.createRow', () => { + it('marks the row readonly', () => { + const out = expose(MintTransactionCollectionRestore).row({ x: 1 }, 'id'); + assert.isTrue(out.readonly); + }); +}); + +describe('TokenCollectionRestore.createRow', () => { + it('marks the row as view', () => { + const out = expose(TokenCollectionRestore).row({ x: 1 }, 'id'); + assert.isTrue(out.view); + assert.equal(out.x, 1); + }); +}); + +const passthrough = [ + ['DidCollectionRestore', DidCollectionRestore], + ['StateCollectionRestore', StateCollectionRestore], + ['RoleCollectionRestore', RoleCollectionRestore], + ['TagCollectionRestore', TagCollectionRestore], + ['DocStateCollectionRestore', DocStateCollectionRestore], + ['TopicCollectionRestore', TopicCollectionRestore], + ['ExternalCollectionRestore', ExternalCollectionRestore], + ['PolicyInvitationsCollectionRestore', PolicyInvitationsCollectionRestore], +]; + +for (const [name, Cls] of passthrough) { + describe(`${name}.createRow`, () => { + it('returns the data unchanged', () => { + const data = { x: 1, document: 'raw', documentFileId: 'f' }; + const out = expose(Cls).row(data, 'id'); + assert.deepEqual(out, { x: 1, document: 'raw', documentFileId: 'f' }); + }); + }); +} diff --git a/policy-service/tests/unit-tests/policy-engine/synchronization-service.test.mjs b/policy-service/tests/unit-tests/policy-engine/synchronization-service.test.mjs new file mode 100644 index 0000000000..342a04e27e --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/synchronization-service.test.mjs @@ -0,0 +1,114 @@ +import { assert } from 'chai'; +import { PolicyStatus } from '@guardian/interfaces'; +import { SynchronizationService } from '../../../dist/policy-engine/multi-policy-service/synchronization-service.js'; + +const makeLogger = () => { + const calls = []; + return { + calls, + info(...args) { calls.push(['info', ...args]); }, + error(...args) { calls.push(['error', ...args]); } + }; +}; + +describe('SynchronizationService.start (pre-IO branches)', () => { + it('returns false when status is not PUBLISH', () => { + const service = new SynchronizationService( + { status: PolicyStatus.DRAFT, synchronizationTopicId: 'topic-1' }, + makeLogger(), + null + ); + assert.isFalse(service.start()); + }); + + it('returns false for DRY_RUN status', () => { + const service = new SynchronizationService( + { status: PolicyStatus.DRY_RUN, synchronizationTopicId: 'topic-1' }, + makeLogger(), + null + ); + assert.isFalse(service.start()); + }); + + it('returns false when there is no synchronizationTopicId', () => { + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: null }, + makeLogger(), + null + ); + assert.isFalse(service.start()); + }); + + it('returns false when synchronizationTopicId is an empty string', () => { + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: '' }, + makeLogger(), + null + ); + assert.isFalse(service.start()); + }); + + it('does not log when start is rejected', () => { + const logger = makeLogger(); + const service = new SynchronizationService( + { status: PolicyStatus.DRAFT, synchronizationTopicId: 'topic-1' }, + logger, + null + ); + service.start(); + assert.equal(logger.calls.length, 0); + }); + + it('schedules and reports true for a published policy, and is idempotent', () => { + const logger = makeLogger(); + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: 'topic-1' }, + logger, + null + ); + try { + assert.isTrue(service.start()); + assert.isTrue(service.start()); + assert.equal(logger.calls.filter((c) => c[0] === 'info').length, 1); + } finally { + service.stop(); + } + }); +}); + +describe('SynchronizationService.stop', () => { + it('is a no-op when no job is scheduled', () => { + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: 'topic-1' }, + makeLogger(), + null + ); + assert.doesNotThrow(() => service.stop()); + }); + + it('stops a scheduled job and allows restart', () => { + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: 'topic-1' }, + makeLogger(), + null + ); + service.start(); + service.stop(); + try { + assert.isTrue(service.start()); + } finally { + service.stop(); + } + }); + + it('can be called twice safely', () => { + const service = new SynchronizationService( + { status: PolicyStatus.PUBLISH, synchronizationTopicId: 'topic-1' }, + makeLogger(), + null + ); + service.start(); + service.stop(); + assert.doesNotThrow(() => service.stop()); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/vc-collection.test.mjs b/policy-service/tests/unit-tests/policy-engine/vc-collection.test.mjs new file mode 100644 index 0000000000..4282d6a042 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/vc-collection.test.mjs @@ -0,0 +1,113 @@ +import { assert } from 'chai'; +import * as DB from '../../../dist/policy-engine/db-restore/index.js'; + +const { VcCollectionBackup, VcCollectionRestore, DiffActionType } = DB; + +class TestVcBackup extends VcCollectionBackup { + hash(...args) { return this.actionHash(...args); } + backupData(row) { return this.createBackupData(row); } + diffData(newRow, oldRow) { return this.createDiffData(newRow, oldRow); } + check(newRow, oldRow) { return this.checkDocument(newRow, oldRow); } + need(newRow, oldRow) { return this.needLoadFile(newRow, oldRow); } + clear(row) { return this.clearFile(row); } +} + +class TestVcRestore extends VcCollectionRestore { + hash(...args) { return this.actionHash(...args); } + row(data, id) { return this.createRow(data, id); } +} + +const makeBackup = () => new TestVcBackup('tenant', 'policy', 'owner', 'message'); +const makeRestore = () => new TestVcRestore('tenant', 'policy', 'owner', 'message'); + +describe('VcCollectionBackup pure methods', () => { + it('createBackupData keeps only the hashes', () => { + const out = makeBackup().backupData({ _propHash: 'p', _docHash: 'd', extra: 1 }); + assert.deepEqual(out, { _propHash: 'p', _docHash: 'd' }); + }); + + it('checkDocument is false when both hashes are equal', () => { + assert.isFalse(makeBackup().check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'a', _propHash: 'b' })); + }); + + it('checkDocument is true when the doc hash differs', () => { + assert.isTrue(makeBackup().check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'x', _propHash: 'b' })); + }); + + it('checkDocument is true when the prop hash differs', () => { + assert.isTrue(makeBackup().check({ _docHash: 'a', _propHash: 'b' }, { _docHash: 'a', _propHash: 'y' })); + }); + + it('needLoadFile is true when there is no old row', () => { + assert.isTrue(makeBackup().need({ _docHash: 'a' })); + }); + + it('needLoadFile is false when doc hashes match', () => { + assert.isFalse(makeBackup().need({ _docHash: 'a' }, { _docHash: 'a' })); + }); + + it('needLoadFile is true when doc hashes differ', () => { + assert.isTrue(makeBackup().need({ _docHash: 'a' }, { _docHash: 'b' })); + }); + + it('createDiffData drops document file ids', async () => { + const out = await makeBackup().diffData({ a: 1, documentFileId: 'f', encryptedDocumentFileId: 'g' }); + assert.deepEqual(out, { a: 1 }); + }); + + it('clearFile removes the document field', async () => { + const out = await makeBackup().clear({ document: 'x', keep: 1 }); + assert.deepEqual(out, { keep: 1 }); + }); + + it('actionHash without a row is a 32-char md5 hex', () => { + const out = makeBackup().hash('', { type: DiffActionType.Create, id: 'i' }); + assert.match(out, /^[0-9a-f]{32}$/); + }); + + it('actionHash incorporates the row hashes', () => { + const b = makeBackup(); + const action = { type: DiffActionType.Create, id: 'i' }; + assert.notEqual(b.hash('', action, { _propHash: 'p', _docHash: 'd' }), b.hash('', action)); + }); + + it('actionHash is deterministic', () => { + const b = makeBackup(); + const action = { type: DiffActionType.Create, id: 'i' }; + assert.equal(b.hash('seed', action), b.hash('seed', action)); + }); +}); + +describe('VcCollectionRestore pure methods', () => { + it('actionHash without a row is a 32-char md5 hex', () => { + assert.match(makeRestore().hash('', { type: DiffActionType.Create, id: 'i' }), /^[0-9a-f]{32}$/); + }); + + it('createRow parses a base64 document into an object', () => { + const data = { document: Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64') }; + const row = makeRestore().row(data, 'id'); + assert.deepEqual(row.document, { hello: 'world' }); + }); + + it('createRow strips file ids', () => { + const data = { + document: Buffer.from('{}').toString('base64'), + documentFileId: 'f', + encryptedDocumentFileId: 'g' + }; + const row = makeRestore().row(data, 'id'); + assert.isUndefined(row.documentFileId); + assert.isUndefined(row.encryptedDocumentFileId); + }); + + it('createRow decodes the encrypted document base64 string', () => { + const data = { encryptedDocument: Buffer.from('cipher-text').toString('base64') }; + const row = makeRestore().row(data, 'id'); + assert.equal(row.encryptedDocument, 'cipher-text'); + }); + + it('createRow leaves data without documents untouched', () => { + const row = makeRestore().row({ a: 1 }, 'id'); + assert.deepEqual(row, { a: 1 }); + }); +}); diff --git a/policy-service/tests/unit-tests/policy-engine/vc-documents-utils-pure.test.mjs b/policy-service/tests/unit-tests/policy-engine/vc-documents-utils-pure.test.mjs new file mode 100644 index 0000000000..1d724ef751 --- /dev/null +++ b/policy-service/tests/unit-tests/policy-engine/vc-documents-utils-pure.test.mjs @@ -0,0 +1,159 @@ +import { assert } from 'chai'; +import { PolicyVcDocumentsUtils } from '../../../dist/policy-engine/policy-vc-documents-utils.js'; + +const getNestedValue = PolicyVcDocumentsUtils.getNestedValue.bind(PolicyVcDocumentsUtils); +const setNestedValue = PolicyVcDocumentsUtils.setNestedValue.bind(PolicyVcDocumentsUtils); +const mapFilteredFields = PolicyVcDocumentsUtils.mapFilteredFields.bind(PolicyVcDocumentsUtils); + +describe('PolicyVcDocumentsUtils.getNestedValue', function () { + it('returns value for top-level key', function () { + assert.deepEqual(getNestedValue({ a: 1 }, 'a'), [1]); + }); + it('returns value for nested path', function () { + assert.deepEqual(getNestedValue({ a: { b: { c: 'x' } } }, 'a.b.c'), ['x']); + }); + it('returns undefined for missing key', function () { + assert.isUndefined(getNestedValue({ a: 1 }, 'b')); + }); + it('returns undefined for missing nested key', function () { + assert.isUndefined(getNestedValue({ a: { b: 1 } }, 'a.c.d')); + }); + it('returns undefined for null object', function () { + assert.isUndefined(getNestedValue(null, 'a')); + }); + it('returns undefined for undefined object', function () { + assert.isUndefined(getNestedValue(undefined, 'a.b')); + }); + it('collects values across array items', function () { + const obj = { items: [{ v: 1 }, { v: 2 }, { v: 3 }] }; + assert.deepEqual(getNestedValue(obj, 'items.v'), [1, 2, 3]); + }); + it('skips array items missing the key', function () { + const obj = { items: [{ v: 1 }, {}, { v: 3 }] }; + assert.deepEqual(getNestedValue(obj, 'items.v'), [1, 3]); + }); + it('skips non-object array items', function () { + const obj = { items: [1, { v: 2 }, 'x'] }; + assert.deepEqual(getNestedValue(obj, 'items.v'), [2]); + }); + it('traverses nested arrays of objects', function () { + const obj = { a: [{ b: [{ c: 1 }, { c: 2 }] }, { b: [{ c: 3 }] }] }; + assert.deepEqual(getNestedValue(obj, 'a.b.c'), [1, 2, 3]); + }); + it('returns undefined when path hits a primitive midway', function () { + assert.isUndefined(getNestedValue({ a: 5 }, 'a.b')); + }); + it('returns null values present at the leaf', function () { + assert.deepEqual(getNestedValue({ a: null }, 'a'), [null]); + }); + it('returns array value when leaf is an array on a plain object', function () { + assert.deepEqual(getNestedValue({ a: { b: [1, 2] } }, 'a.b'), [[1, 2]]); + }); +}); + +describe('PolicyVcDocumentsUtils.setNestedValue', function () { + it('sets top-level key', function () { + const obj = { a: 1 }; + setNestedValue(obj, 'a', 2); + assert.equal(obj.a, 2); + }); + it('sets nested path', function () { + const obj = { a: { b: { c: 1 } } }; + setNestedValue(obj, 'a.b.c', 9); + assert.equal(obj.a.b.c, 9); + }); + it('creates object for missing keys before the second-last and array at the second-last', function () { + const obj = {}; + setNestedValue(obj, 'a.b.c', 'x'); + assert.deepEqual(obj, { a: { b: [] } }); + }); + it('creates array container for second-last missing key', function () { + const obj = {}; + setNestedValue(obj, 'a.b', 'x'); + assert.isArray(obj.a); + }); + it('replaces null intermediate values', function () { + const obj = { a: { b: null } }; + setNestedValue(obj, 'a.b.c.d', 1); + assert.deepEqual(obj.a.b, { c: [] }); + }); + it('sets value on each object item of an array leaf', function () { + const obj = { items: [{ v: 1 }, { v: 2 }] }; + setNestedValue(obj, 'items.v', 7); + assert.deepEqual(obj.items, [{ v: 7 }, { v: 7 }]); + }); + it('distributes array values by index across array items', function () { + const obj = { items: [{ v: 1 }, { v: 2 }] }; + setNestedValue(obj, 'items.v', [10, 20]); + assert.deepEqual(obj.items, [{ v: 10 }, { v: 20 }]); + }); + it('ignores extra items when value array is shorter', function () { + const obj = { items: [{ v: 1 }, { v: 2 }, { v: 3 }] }; + setNestedValue(obj, 'items.v', [10, 20]); + assert.deepEqual(obj.items, [{ v: 10 }, { v: 20 }, { v: 3 }]); + }); + it('skips non-object items in array', function () { + const obj = { items: [{ v: 1 }, 5] }; + setNestedValue(obj, 'items.v', 9); + assert.deepEqual(obj.items, [{ v: 9 }, 5]); + }); + it('traverses arrays for deeper paths', function () { + const obj = { items: [{ inner: { v: 1 } }, { inner: { v: 2 } }] }; + setNestedValue(obj, 'items.inner.v', 5); + assert.deepEqual(obj.items, [{ inner: { v: 5 } }, { inner: { v: 5 } }]); + }); + it('does nothing for null target', function () { + assert.doesNotThrow(() => setNestedValue(null, 'a.b', 1)); + }); + it('does nothing when current is a primitive', function () { + const obj = { a: 5 }; + setNestedValue(obj, 'a.b', 1); + assert.equal(obj.a, 5); + }); +}); + +describe('PolicyVcDocumentsUtils.mapFilteredFields', function () { + it('copies plain fields', function () { + const target = {}; + mapFilteredFields({ tag: 't', owner: 'o' }, target); + assert.deepEqual(target, { tag: 't', owner: 'o' }); + }); + it('excludes protected keys', function () { + const target = {}; + mapFilteredFields({ + hash: 'h', + signature: 1, + hederaStatus: 's', + messageHash: 'mh', + messageId: 'mid', + encryptedDocument: 'e', + encryptedDocumentFileId: 'f', + document: {}, + relationships: [], + keep: 'yes' + }, target); + assert.deepEqual(target, { keep: 'yes' }); + }); + it('excludes underscore-prefixed keys', function () { + const target = {}; + mapFilteredFields({ _id: 'x', _meta: 1, ok: 2 }, target); + assert.deepEqual(target, { ok: 2 }); + }); + it('deep clones copied values', function () { + const source = { option: { status: 'NEW' } }; + const target = {}; + mapFilteredFields(source, target); + source.option.status = 'CHANGED'; + assert.equal(target.option.status, 'NEW'); + }); + it('overwrites existing target keys', function () { + const target = { tag: 'old' }; + mapFilteredFields({ tag: 'new' }, target); + assert.equal(target.tag, 'new'); + }); + it('handles empty source', function () { + const target = { a: 1 }; + mapFilteredFields({}, target); + assert.deepEqual(target, { a: 1 }); + }); +}); diff --git a/policy-service/tests/unit-tests/record/record-utils-classes.test.mjs b/policy-service/tests/unit-tests/record/record-utils-classes.test.mjs new file mode 100644 index 0000000000..7261f157af --- /dev/null +++ b/policy-service/tests/unit-tests/record/record-utils-classes.test.mjs @@ -0,0 +1,310 @@ +import { assert } from 'chai'; +import { + GenerateUUID, + GenerateDID, + RowDocument, + Utils, + RecordItemStack +} from '../../../dist/policy-engine/record/utils.js'; + +describe('record/utils GenerateUUID', () => { + it('stores oldValue and newValue', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.oldValue, 'old'); + assert.equal(g.newValue, 'new'); + }); + + it('replace maps a bare old value to the new value', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.replace('old'), 'new'); + }); + + it('replace maps a urn:uuid: prefixed value', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.replace('urn:uuid:old'), 'urn:uuid:new'); + }); + + it('replace passes through an unknown string', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.replace('other'), 'other'); + }); + + it('replace passes through non-string values', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.replace(42), 42); + assert.deepEqual(g.replace({ a: 1 }), { a: 1 }); + assert.equal(g.replace(null), null); + }); +}); + +describe('record/utils GenerateDID', () => { + it('stores oldValue and newValue', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.oldValue, 'did:old'); + assert.equal(g.newValue, 'did:new'); + }); + + it('replace returns the new value on an exact match', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace('did:old'), 'did:new'); + }); + + it('replace passes through a non-matching string', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace('did:other'), 'did:other'); + }); + + it('replace passes through non-string values', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace(7), 7); + assert.equal(g.replace(undefined), undefined); + }); +}); + +describe('record/utils RowDocument', () => { + const baseDoc = (overrides = {}) => ({ + id: 'id1', + _id: 'oid1', + document: { id: 'docId' }, + ...overrides + }); + + it('check returns truthy only when id, _id, document and document.id all exist', () => { + assert.ok(RowDocument.check(baseDoc())); + assert.notOk(RowDocument.check({ id: 'a', _id: 'b', document: {} })); + assert.notOk(RowDocument.check({ id: 'a', _id: 'b' })); + assert.notOk(RowDocument.check({ _id: 'b', document: { id: 'd' } })); + assert.notOk(RowDocument.check(null)); + }); + + it('detects type vc from dryRunClass VcDocumentCollection', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'VcDocumentCollection' }), null, null); + assert.equal(r.type, 'vc'); + }); + + it('detects type vp from dryRunClass VpDocumentCollection', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'VpDocumentCollection' }), null, null); + assert.equal(r.type, 'vp'); + }); + + it('detects type did from dryRunClass DidDocumentCollection', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'DidDocumentCollection' }), null, null); + assert.equal(r.type, 'did'); + }); + + it('detects type did when a did field is present', () => { + const r = new RowDocument(baseDoc({ did: 'did:x' }), null, null); + assert.equal(r.type, 'did'); + }); + + it('detects type vc when a schema field is present', () => { + const r = new RowDocument(baseDoc({ schema: 's#1' }), null, null); + assert.equal(r.type, 'vc'); + }); + + it('defaults type to vp when no markers are present', () => { + const r = new RowDocument(baseDoc(), null, null); + assert.equal(r.type, 'vp'); + }); + + it('builds a document.id filter', () => { + const r = new RowDocument(baseDoc(), null, null); + assert.deepEqual(r.filters, { 'document.id': 'docId' }); + }); + + it('replace writes the new row into the parent at the key and returns root', () => { + const parent = { field: { old: true } }; + const root = { parent }; + const r = new RowDocument(baseDoc(), parent, 'field'); + const out = r.replace(root, { fresh: true }); + assert.equal(out, root); + assert.deepEqual(parent.field, { fresh: true }); + }); + + it('replace returns the row directly when there is no parent/key', () => { + const r = new RowDocument(baseDoc(), null, null); + const out = r.replace({ root: true }, { fresh: true }); + assert.deepEqual(out, { fresh: true }); + }); + + it('replace returns root unchanged when row is falsy', () => { + const parent = { field: 1 }; + const root = { parent }; + const r = new RowDocument(baseDoc(), parent, 'field'); + assert.equal(r.replace(root, null), root); + assert.equal(parent.field, 1); + }); + + it('replace carries over assignedToGroup/assignedTo/option props', () => { + const doc = baseDoc({ assignedToGroup: 'g', assignedTo: 'u', option: { x: 1 } }); + const r = new RowDocument(doc, null, null); + const out = r.replace({}, { fresh: true }); + assert.equal(out.assignedToGroup, 'g'); + assert.equal(out.assignedTo, 'u'); + assert.deepEqual(out.option, { x: 1 }); + }); +}); + +describe('record/utils Utils.replaceAllValues', () => { + it('returns falsy input unchanged', () => { + const g = new GenerateUUID('a', 'b'); + assert.equal(Utils.replaceAllValues(null, g), null); + assert.equal(Utils.replaceAllValues(0, g), 0); + }); + + it('replaces a matching scalar string', () => { + const g = new GenerateUUID('a', 'b'); + assert.equal(Utils.replaceAllValues('a', g), 'b'); + }); + + it('replaces matching strings throughout a nested object', () => { + const g = new GenerateUUID('a', 'b'); + const obj = { x: 'a', y: { z: 'a', keep: 'c' } }; + const out = Utils.replaceAllValues(obj, g); + assert.deepEqual(out, { x: 'b', y: { z: 'b', keep: 'c' } }); + }); + + it('replaces matching strings inside arrays', () => { + const g = new GenerateUUID('a', 'b'); + const out = Utils.replaceAllValues(['a', 'c', 'a'], g); + assert.deepEqual(out, ['b', 'c', 'b']); + }); + + it('mutates and returns the same object reference', () => { + const g = new GenerateUUID('a', 'b'); + const obj = { x: 'a' }; + assert.equal(Utils.replaceAllValues(obj, g), obj); + }); + + it('leaves objects with no matching strings untouched', () => { + const g = new GenerateDID('did:a', 'did:b'); + const obj = { x: 'plain', n: 5 }; + assert.deepEqual(Utils.replaceAllValues(obj, g), { x: 'plain', n: 5 }); + }); +}); + +describe('record/utils Utils.findAllDocuments', () => { + const doc = (id) => ({ id, _id: '_' + id, document: { id: 'd' + id }, schema: 's' }); + + it('returns [] for non-object input', () => { + assert.deepEqual(Utils.findAllDocuments(null), []); + assert.deepEqual(Utils.findAllDocuments('x'), []); + }); + + it('finds a single top-level document', () => { + const results = Utils.findAllDocuments({ a: doc('1') }); + assert.equal(results.length, 1); + assert.instanceOf(results[0], RowDocument); + }); + + it('finds documents nested inside arrays', () => { + const results = Utils.findAllDocuments({ list: [doc('1'), doc('2')] }); + assert.equal(results.length, 2); + }); + + it('records the parent and key for a found document', () => { + const tree = { wrap: doc('1') }; + const results = Utils.findAllDocuments(tree); + assert.equal(results[0].parent, tree); + assert.equal(results[0].key, 'wrap'); + }); + + it('does not recurse into a matched document', () => { + const nested = doc('outer'); + nested.document.inner = doc('inner'); + const results = Utils.findAllDocuments({ root: nested }); + assert.equal(results.length, 1); + }); +}); + +describe('record/utils RecordItemStack', () => { + const items = () => [{ name: 'a' }, { name: 'b' }, { name: 'c' }]; + + it('starts empty with index 0', () => { + const s = new RecordItemStack(); + assert.equal(s.count, 0); + assert.equal(s.index, 0); + assert.equal(s.current, undefined); + }); + + it('setItems loads a copy and resets the index', () => { + const s = new RecordItemStack(); + s.setItems(items()); + assert.equal(s.count, 3); + assert.equal(s.index, 0); + assert.deepEqual(s.current, { name: 'a' }); + }); + + it('setItems deep-copies so source mutations do not leak in', () => { + const src = items(); + const s = new RecordItemStack(); + s.setItems(src); + src[0].name = 'mutated'; + assert.equal(s.current.name, 'a'); + }); + + it('setItems with a non-array yields an empty stack', () => { + const s = new RecordItemStack(); + s.setItems(null); + assert.equal(s.count, 0); + }); + + it('next advances the index and returns the next item', () => { + const s = new RecordItemStack(); + s.setItems(items()); + assert.deepEqual(s.next(), { name: 'b' }); + assert.equal(s.index, 1); + assert.deepEqual(s.next(), { name: 'c' }); + }); + + it('next past the end returns undefined', () => { + const s = new RecordItemStack(); + s.setItems(items()); + s.next(); + s.next(); + assert.equal(s.next(), undefined); + assert.equal(s.index, 3); + }); + + it('prev steps the index back', () => { + const s = new RecordItemStack(); + s.setItems(items()); + s.next(); + s.next(); + assert.deepEqual(s.prev(), { name: 'b' }); + assert.equal(s.index, 1); + }); + + it('nextIndex advances index without returning', () => { + const s = new RecordItemStack(); + s.setItems(items()); + s.nextIndex(); + assert.equal(s.index, 1); + assert.deepEqual(s.current, { name: 'b' }); + }); + + it('clearIndex resets the index but keeps items', () => { + const s = new RecordItemStack(); + s.setItems(items()); + s.next(); + s.clearIndex(); + assert.equal(s.index, 0); + assert.equal(s.count, 3); + }); + + it('clear re-copies the source and resets the index', () => { + const s = new RecordItemStack(); + s.setItems(items()); + s.next(); + s.items[0].name = 'dirty'; + s.clear(); + assert.equal(s.index, 0); + assert.equal(s.current.name, 'a'); + }); + + it('items getter exposes the working copy', () => { + const s = new RecordItemStack(); + s.setItems(items()); + assert.equal(s.items.length, 3); + }); +}); diff --git a/policy-service/tests/unit-tests/record/record-utils.test.mjs b/policy-service/tests/unit-tests/record/record-utils.test.mjs new file mode 100644 index 0000000000..085b1a315c --- /dev/null +++ b/policy-service/tests/unit-tests/record/record-utils.test.mjs @@ -0,0 +1,208 @@ +import { assert } from 'chai'; +import esmock from 'esmock'; + +let currentComponents = null; + +const { RecordUtils } = await esmock.strict( + '../../../dist/policy-engine/record-utils.js', + { + '../../../dist/policy-engine/policy-components-utils.js': { + PolicyComponentsUtils: { + GetPolicyComponents: () => currentComponents, + }, + }, + }, +); + +function setComponents(c) { + currentComponents = c; +} + +describe('@unit RecordUtils (no components)', () => { + beforeEach(() => setComponents(null)); + + it('GetRecordingController returns null', () => { + assert.equal(RecordUtils.GetRecordingController('p'), null); + }); + + it('GetRunAndRecordController returns null', () => { + assert.equal(RecordUtils.GetRunAndRecordController('p'), null); + }); + + it('StartRecording returns false', async () => { + assert.equal(await RecordUtils.StartRecording('p'), false); + }); + + it('StopRecording returns false', async () => { + assert.equal(await RecordUtils.StopRecording('p'), false); + }); + + it('StopRunning returns true', async () => { + assert.equal(await RecordUtils.StopRunning('p'), true); + }); + + it('DestroyRecording returns false', async () => { + assert.equal(await RecordUtils.DestroyRecording('p'), false); + }); + + it('DestroyRunning returns true', async () => { + assert.equal(await RecordUtils.DestroyRunning('p'), true); + }); + + it('FastForward returns false', async () => { + assert.equal(await RecordUtils.FastForward('p', {}), false); + }); + + it('RetryStep returns false', async () => { + assert.equal(await RecordUtils.RetryStep('p', {}), false); + }); + + it('SkipStep returns false', async () => { + assert.equal(await RecordUtils.SkipStep('p', {}), false); + }); + + it('RunRecord returns null', async () => { + assert.equal(await RecordUtils.RunRecord('p', [], [], {}), null); + }); + + it('GetRecordStatus returns { policyId } when no controller', () => { + assert.deepEqual(RecordUtils.GetRecordStatus('p'), { policyId: 'p' }); + }); + + it('GetRecordedActions returns null when no controller', async () => { + assert.equal(await RecordUtils.GetRecordedActions('p'), null); + }); + + it('GetRecordResults returns null when no controller', async () => { + assert.equal(await RecordUtils.GetRecordResults('p'), null); + }); + + it('RecordSelectGroup is a no-op when no recording controller', async () => { + assert.equal(await RecordUtils.RecordSelectGroup('p', {}, 'u'), undefined); + }); + + it('RecordSetBlockData is a no-op when no recording controller', async () => { + assert.equal(await RecordUtils.RecordSetBlockData('p', {}, {}, {}), undefined); + }); + + it('RecordExternalData is a no-op when no recording controller', async () => { + assert.equal(await RecordUtils.RecordExternalData('p', {}), undefined); + }); + + it('RecordCreateUser is a no-op when no recording controller', async () => { + assert.equal(await RecordUtils.RecordCreateUser('p', 'did', {}), undefined); + }); + + it('RecordSetUser is a no-op when no recording controller', async () => { + assert.equal(await RecordUtils.RecordSetUser('p', 'did'), undefined); + }); +}); + +describe('@unit RecordUtils (with components)', () => { + it('GetRecordingController returns the components recordingController', () => { + const ctrl = { id: 'rec' }; + setComponents({ recordingController: ctrl }); + assert.strictEqual(RecordUtils.GetRecordingController('p'), ctrl); + }); + + it('GetRunAndRecordController returns the runAndRecordController', () => { + const ctrl = { id: 'rar' }; + setComponents({ runAndRecordController: ctrl }); + assert.strictEqual(RecordUtils.GetRunAndRecordController('p'), ctrl); + }); + + it('StartRecording delegates to components.startRecording', async () => { + setComponents({ startRecording: async () => true }); + assert.equal(await RecordUtils.StartRecording('p'), true); + }); + + it('StopRecording delegates to components.stopRecording', async () => { + setComponents({ stopRecording: async () => true }); + assert.equal(await RecordUtils.StopRecording('p'), true); + }); + + it('StopRunning delegates to components.stopRunning', async () => { + setComponents({ stopRunning: async () => false }); + assert.equal(await RecordUtils.StopRunning('p'), false); + }); + + it('DestroyRecording delegates to components.destroyRecording', async () => { + setComponents({ destroyRecording: async () => true }); + assert.equal(await RecordUtils.DestroyRecording('p'), true); + }); + + it('FastForward delegates with options', async () => { + let received; + setComponents({ fastForward: async (opts) => { received = opts; return true; } }); + assert.equal(await RecordUtils.FastForward('p', { a: 1 }), true); + assert.deepEqual(received, { a: 1 }); + }); + + it('RetryStep delegates to components.retryStep', async () => { + setComponents({ retryStep: async () => true }); + assert.equal(await RecordUtils.RetryStep('p', {}), true); + }); + + it('SkipStep delegates to components.skipStep', async () => { + setComponents({ skipStep: async () => true }); + assert.equal(await RecordUtils.SkipStep('p', {}), true); + }); + + it('RunRecord delegates actions/results/options', async () => { + let args; + setComponents({ runRecord: async (a, r, o) => { args = [a, r, o]; return 'started'; } }); + const out = await RecordUtils.RunRecord('p', [1], [2], { x: 1 }); + assert.equal(out, 'started'); + assert.deepEqual(args, [[1], [2], { x: 1 }]); + }); + + it('GetRecordStatus returns controller.getStatus()', () => { + setComponents({ runAndRecordController: { getStatus: () => ({ s: 'ok' }) } }); + assert.deepEqual(RecordUtils.GetRecordStatus('p'), { s: 'ok' }); + }); + + it('GetRecordedActions returns controller.getActions()', async () => { + setComponents({ runAndRecordController: { getActions: async () => [1, 2] } }); + assert.deepEqual(await RecordUtils.GetRecordedActions('p'), [1, 2]); + }); + + it('GetRecordResults returns controller.getResults()', async () => { + setComponents({ runAndRecordController: { getResults: async () => ['r'] } }); + assert.deepEqual(await RecordUtils.GetRecordResults('p'), ['r']); + }); + + it('RecordSelectGroup forwards user and uuid', async () => { + let args; + setComponents({ recordingController: { selectGroup: async (u, id) => { args = [u, id]; } } }); + await RecordUtils.RecordSelectGroup('p', { did: 'd' }, 'uuid-1'); + assert.deepEqual(args, [{ did: 'd' }, 'uuid-1']); + }); + + it('RecordSetBlockData forwards all params', async () => { + let args; + setComponents({ recordingController: { setBlockData: async (...a) => { args = a; } } }); + await RecordUtils.RecordSetBlockData('p', { did: 'd' }, { tag: 'b' }, { v: 1 }, 'aid', 123); + assert.deepEqual(args, [{ did: 'd' }, { tag: 'b' }, { v: 1 }, 'aid', 123]); + }); + + it('RecordExternalData forwards data', async () => { + let args; + setComponents({ recordingController: { externalData: async (...a) => { args = a; } } }); + await RecordUtils.RecordExternalData('p', { d: 1 }, 'aid', 5); + assert.deepEqual(args, [{ d: 1 }, 'aid', 5]); + }); + + it('RecordCreateUser forwards did and data', async () => { + let args; + setComponents({ recordingController: { createUser: async (...a) => { args = a; } } }); + await RecordUtils.RecordCreateUser('p', 'did:x', { name: 'n' }); + assert.deepEqual(args, ['did:x', { name: 'n' }]); + }); + + it('RecordSetUser forwards did', async () => { + let args; + setComponents({ recordingController: { setUser: async (...a) => { args = a; } } }); + await RecordUtils.RecordSetUser('p', 'did:y'); + assert.deepEqual(args, ['did:y']); + }); +}); diff --git a/policy-service/tests/unit-tests/record/utils.test.mjs b/policy-service/tests/unit-tests/record/utils.test.mjs new file mode 100644 index 0000000000..a91c767b13 --- /dev/null +++ b/policy-service/tests/unit-tests/record/utils.test.mjs @@ -0,0 +1,282 @@ +import { assert } from 'chai'; +import { + GenerateUUID, + GenerateDID, + RowDocument, + Utils, + RecordItemStack, +} from '../../../dist/policy-engine/record/utils.js'; + +describe('@unit record/GenerateUUID', () => { + it('exposes old and new values', () => { + const g = new GenerateUUID('old', 'new'); + assert.equal(g.oldValue, 'old'); + assert.equal(g.newValue, 'new'); + }); + + it('replaces a bare uuid match', () => { + const g = new GenerateUUID('aaa', 'bbb'); + assert.equal(g.replace('aaa'), 'bbb'); + }); + + it('replaces the urn:uuid-prefixed form', () => { + const g = new GenerateUUID('aaa', 'bbb'); + assert.equal(g.replace('urn:uuid:aaa'), 'urn:uuid:bbb'); + }); + + it('returns the value unchanged when no match', () => { + const g = new GenerateUUID('aaa', 'bbb'); + assert.equal(g.replace('ccc'), 'ccc'); + }); + + it('returns non-string values unchanged', () => { + const g = new GenerateUUID('aaa', 'bbb'); + const obj = { aaa: 1 }; + assert.strictEqual(g.replace(obj), obj); + assert.equal(g.replace(42), 42); + }); +}); + +describe('@unit record/GenerateDID', () => { + it('exposes old and new values', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.oldValue, 'did:old'); + assert.equal(g.newValue, 'did:new'); + }); + + it('replaces an exact match', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace('did:old'), 'did:new'); + }); + + it('does not replace a urn-prefixed form (exact match only)', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace('urn:uuid:did:old'), 'urn:uuid:did:old'); + }); + + it('returns the value unchanged when no match', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace('did:other'), 'did:other'); + }); + + it('returns non-string values unchanged', () => { + const g = new GenerateDID('did:old', 'did:new'); + assert.equal(g.replace(7), 7); + }); +}); + +describe('@unit record/RowDocument', () => { + const baseDoc = (extra = {}) => ({ + id: 'id-1', + _id: 'oid-1', + document: { id: 'doc-1' }, + assignedToGroup: 'g', + assignedTo: 'u', + option: { o: 1 }, + ...extra, + }); + + it('check is truthy only when id, _id and document.id present', () => { + assert.ok(RowDocument.check(baseDoc())); + assert.notOk(RowDocument.check({ id: 'x', _id: 'y', document: {} })); + assert.notOk(RowDocument.check(null)); + assert.notOk(RowDocument.check({ id: 'x' })); + }); + + it('detects vc type via dryRunClass', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'VcDocumentCollection' }), null, null); + assert.equal(r.type, 'vc'); + }); + + it('detects vp type via dryRunClass', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'VpDocumentCollection' }), null, null); + assert.equal(r.type, 'vp'); + }); + + it('detects did type via dryRunClass', () => { + const r = new RowDocument(baseDoc({ dryRunClass: 'DidDocumentCollection' }), null, null); + assert.equal(r.type, 'did'); + }); + + it('detects did type via did property', () => { + const r = new RowDocument(baseDoc({ did: 'did:abc' }), null, null); + assert.equal(r.type, 'did'); + }); + + it('detects vc type via schema property', () => { + const r = new RowDocument(baseDoc({ schema: 'iri' }), null, null); + assert.equal(r.type, 'vc'); + }); + + it('defaults to vp type', () => { + const r = new RowDocument(baseDoc(), null, null); + assert.equal(r.type, 'vp'); + }); + + it('builds filters from document.id', () => { + const r = new RowDocument(baseDoc(), null, null); + assert.deepEqual(r.filters, { 'document.id': 'doc-1' }); + }); + + it('replace returns root unchanged when row is falsy', () => { + const r = new RowDocument(baseDoc(), {}, 'k'); + const root = { foo: 'bar' }; + assert.strictEqual(r.replace(root, null), root); + }); + + it('replace assigns into parent[key] and restores props', () => { + const parent = {}; + const r = new RowDocument(baseDoc(), parent, 'k'); + const root = { parent }; + const out = r.replace(root, { document: { id: 'doc-1' } }); + assert.strictEqual(out, root); + assert.equal(parent.k.assignedToGroup, 'g'); + assert.equal(parent.k.assignedTo, 'u'); + assert.deepEqual(parent.k.option, { o: 1 }); + }); + + it('replace returns the new row when no parent/key', () => { + const r = new RowDocument(baseDoc(), null, null); + const row = { document: { id: 'doc-1' } }; + const out = r.replace({ root: true }, row); + assert.strictEqual(out, row); + assert.equal(row.assignedToGroup, 'g'); + }); +}); + +describe('@unit record/Utils.replaceAllValues', () => { + it('returns falsy obj unchanged', () => { + const value = new GenerateUUID('a', 'b'); + assert.equal(Utils.replaceAllValues(null, value), null); + assert.equal(Utils.replaceAllValues(undefined, value), undefined); + }); + + it('replaces matching primitives recursively in arrays', () => { + const value = new GenerateUUID('a', 'b'); + const out = Utils.replaceAllValues(['a', 'c', 'a'], value); + assert.deepEqual(out, ['b', 'c', 'b']); + }); + + it('replaces matching values inside nested objects', () => { + const value = new GenerateUUID('a', 'b'); + const out = Utils.replaceAllValues({ x: 'a', y: { z: 'a', w: 'keep' } }, value); + assert.deepEqual(out, { x: 'b', y: { z: 'b', w: 'keep' } }); + }); + + it('mutates the input object in place', () => { + const value = new GenerateUUID('a', 'b'); + const input = { x: 'a' }; + Utils.replaceAllValues(input, value); + assert.equal(input.x, 'b'); + }); +}); + +describe('@unit record/Utils.findAllDocuments', () => { + const doc = (id) => ({ id, _id: '_' + id, document: { id: 'd' + id } }); + + it('returns empty array for non-object input', () => { + assert.deepEqual(Utils.findAllDocuments(null), []); + assert.deepEqual(Utils.findAllDocuments('string'), []); + }); + + it('finds a top-level document', () => { + const results = Utils.findAllDocuments(doc('1')); + assert.equal(results.length, 1); + assert.instanceOf(results[0], RowDocument); + }); + + it('finds documents nested in arrays', () => { + const results = Utils.findAllDocuments([doc('1'), doc('2')]); + assert.equal(results.length, 2); + }); + + it('finds documents nested in object properties', () => { + const results = Utils.findAllDocuments({ a: doc('1'), b: { c: doc('2') } }); + assert.equal(results.length, 2); + }); + + it('does not recurse into a matched document', () => { + const nested = doc('1'); + nested.child = doc('2'); + const results = Utils.findAllDocuments(nested); + assert.equal(results.length, 1); + }); +}); + +describe('@unit record/RecordItemStack', () => { + const item = (n) => ({ n }); + + it('starts empty', () => { + const s = new RecordItemStack(); + assert.equal(s.count, 0); + assert.equal(s.index, 0); + assert.equal(s.current, undefined); + }); + + it('setItems deep-copies items (not shared references)', () => { + const s = new RecordItemStack(); + const src = [item(1), item(2)]; + s.setItems(src); + assert.equal(s.count, 2); + assert.notStrictEqual(s.items[0], src[0]); + assert.deepEqual(s.items[0], src[0]); + }); + + it('setItems with a non-array resets to empty', () => { + const s = new RecordItemStack(); + s.setItems([item(1)]); + s.setItems(null); + assert.equal(s.count, 0); + }); + + it('current points at index 0 after setItems', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2)]); + assert.deepEqual(s.current, item(1)); + }); + + it('next advances the index and returns the next item', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2), item(3)]); + assert.deepEqual(s.next(), item(2)); + assert.equal(s.index, 1); + }); + + it('prev decrements the index', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2)]); + s.next(); + assert.deepEqual(s.prev(), item(1)); + assert.equal(s.index, 0); + }); + + it('nextIndex advances without returning', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2)]); + s.nextIndex(); + assert.equal(s.index, 1); + }); + + it('clearIndex resets index to 0', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2)]); + s.next(); + s.clearIndex(); + assert.equal(s.index, 0); + }); + + it('clear restores items from source and resets index', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2)]); + s.next(); + s.clear(); + assert.equal(s.index, 0); + assert.equal(s.count, 2); + }); + + it('count reflects the number of items', () => { + const s = new RecordItemStack(); + s.setItems([item(1), item(2), item(3)]); + assert.equal(s.count, 3); + }); +}); diff --git a/queue-service/tests/config.test.mjs b/queue-service/tests/config.test.mjs new file mode 100644 index 0000000000..5fab7386b5 --- /dev/null +++ b/queue-service/tests/config.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; + +describe('queue-service config module', () => { + it('imports without throwing and runs dotenv.config()', async () => { + const mod = await import('../dist/config.js'); + assert.ok(mod); + }); +}); diff --git a/queue-service/tests/environment.test.mjs b/queue-service/tests/environment.test.mjs new file mode 100644 index 0000000000..fc90b905d9 --- /dev/null +++ b/queue-service/tests/environment.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { ApplicationEnvironment } from '../dist/environment.js'; + +describe('ApplicationEnvironment (queue-service)', () => { + it('is exported as an object', () => { + assert.equal(typeof ApplicationEnvironment, 'object'); + assert.notEqual(ApplicationEnvironment, null); + }); + + it('exposes a boolean demoMode flag defaulting to true', () => { + assert.equal(typeof ApplicationEnvironment.demoMode, 'boolean'); + assert.equal(ApplicationEnvironment.demoMode, true); + }); +}); diff --git a/queue-service/tests/task-entity.test.mjs b/queue-service/tests/task-entity.test.mjs new file mode 100644 index 0000000000..a495bae3da --- /dev/null +++ b/queue-service/tests/task-entity.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { TaskEntity } from '../dist/entity/task.js'; + +describe('TaskEntity', () => { + it('instantiates with no constructor arguments', () => { + const t = new TaskEntity(); + assert.ok(t); + }); + + it('all task fields are independently assignable', () => { + const t = new TaskEntity(); + t.userId = 'u1'; + t.taskId = 't1'; + t.priority = 5; + t.type = 'CREATE_ACCOUNT'; + t.data = { foo: 'bar' }; + t.sent = false; + t.isRetryableTask = true; + t.attempts = 3; + t.processedTime = new Date('2024-01-01T00:00:00Z'); + t.done = false; + t.isError = false; + t.errorReason = null; + t.attempt = 1; + t.interception = null; + t.tenantContext = { tenantId: 'tenant-1' }; + + assert.equal(t.userId, 'u1'); + assert.equal(t.taskId, 't1'); + assert.equal(t.priority, 5); + assert.equal(t.type, 'CREATE_ACCOUNT'); + assert.deepEqual(t.data, { foo: 'bar' }); + assert.equal(t.sent, false); + assert.equal(t.isRetryableTask, true); + assert.equal(t.attempts, 3); + assert.ok(t.processedTime instanceof Date); + assert.equal(t.done, false); + assert.equal(t.isError, false); + assert.equal(t.errorReason, null); + assert.equal(t.attempt, 1); + assert.equal(t.interception, null); + assert.deepEqual(t.tenantContext, { tenantId: 'tenant-1' }); + }); + + it('userId/taskId/interception accept null (nullable fields)', () => { + const t = new TaskEntity(); + t.userId = null; + t.interception = null; + assert.equal(t.userId, null); + assert.equal(t.interception, null); + }); +}); diff --git a/topic-listener-service/package.json b/topic-listener-service/package.json index 3b0e3f876f..e27f9419b0 100644 --- a/topic-listener-service/package.json +++ b/topic-listener-service/package.json @@ -46,7 +46,8 @@ "dev": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")", "dev:docker": "nodemon .", "lint": "tslint --config ../tslint.json --project .", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "mocha tests/**/*.test.mjs --reporter mocha-junit-reporter --reporter-options mochaFile=../test_results/topic-listener-service.xml --exit" }, "type": "module", "types": "dist/index.d.ts", diff --git a/topic-listener-service/tests/constants.test.mjs b/topic-listener-service/tests/constants.test.mjs new file mode 100644 index 0000000000..474cb8c047 --- /dev/null +++ b/topic-listener-service/tests/constants.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { DEFAULT_MONGO } from '../dist/constants/index.js'; + +describe('DEFAULT_MONGO constants', () => { + it('exposes the expected mongo pool defaults', () => { + assert.deepEqual(DEFAULT_MONGO, { + MIN_POOL_SIZE: '1', + MAX_POOL_SIZE: '5', + MAX_IDLE_TIME_MS: '30000', + }); + }); + + it('values are strings (env-style) so they slot into env-driven config', () => { + assert.equal(typeof DEFAULT_MONGO.MIN_POOL_SIZE, 'string'); + assert.equal(typeof DEFAULT_MONGO.MAX_POOL_SIZE, 'string'); + assert.equal(typeof DEFAULT_MONGO.MAX_IDLE_TIME_MS, 'string'); + }); + + it('min pool size is <= max pool size', () => { + assert.ok( + Number(DEFAULT_MONGO.MIN_POOL_SIZE) <= Number(DEFAULT_MONGO.MAX_POOL_SIZE) + ); + }); +}); diff --git a/topic-listener-service/tests/environment.test.mjs b/topic-listener-service/tests/environment.test.mjs new file mode 100644 index 0000000000..1e2c8dff91 --- /dev/null +++ b/topic-listener-service/tests/environment.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { ApplicationEnvironment } from '../dist/environment.js'; + +describe('topic-listener-service ApplicationEnvironment', () => { + it('exposes a boolean demoMode flag', () => { + assert.equal(typeof ApplicationEnvironment.demoMode, 'boolean'); + }); + + it('demoMode defaults to true', () => { + assert.equal(ApplicationEnvironment.demoMode, true); + }); + + it('is mutable so callers can flip it at runtime', () => { + const original = ApplicationEnvironment.demoMode; + ApplicationEnvironment.demoMode = false; + assert.equal(ApplicationEnvironment.demoMode, false); + ApplicationEnvironment.demoMode = original; + }); +}); diff --git a/topic-listener-service/tests/message.test.mjs b/topic-listener-service/tests/message.test.mjs new file mode 100644 index 0000000000..7b3f453997 --- /dev/null +++ b/topic-listener-service/tests/message.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { Message } from '../dist/api/message.js'; + +const b64 = (s) => Buffer.from(s).toString('base64'); + +const singleMsg = { + consensus_timestamp: '1700000000.000000001', + topic_id: '0.0.1234', + message: b64('hello'), + sequence_number: 7, + payer_account_id: '0.0.42', +}; + +const chunked = (n, total, valid_start = '1700000000.111111111') => ({ + consensus_timestamp: `170000000${n}.000000000`, + topic_id: '0.0.5555', + message: b64(`chunk-${n}`), + sequence_number: 100 + n, + payer_account_id: '0.0.99', + chunk_info: { + number: n, + total, + initial_transaction_id: { transaction_valid_start: valid_start }, + }, +}); + +describe('Message.getChunkId', () => { + it('returns valid_start when chunk_info is present', () => { + const m = chunked(1, 2, '1700000000.222222222'); + assert.equal(Message.getChunkId(m), '1700000000.222222222'); + }); + + it('returns null when chunk_info is missing', () => { + assert.equal(Message.getChunkId(singleMsg), null); + }); + + it('returns null on null/undefined input', () => { + assert.equal(Message.getChunkId(null), null); + assert.equal(Message.getChunkId(undefined), null); + }); + + it('returns null when initial_transaction_id is missing', () => { + const m = { chunk_info: { number: 1, total: 1 } }; + assert.equal(Message.getChunkId(m), null); + }); +}); + +describe('Message.addChunk and compressMessages', () => { + it('marks a single non-chunked message as COMPRESSED with decoded data', () => { + const m = new Message(); + m.addChunk(singleMsg); + assert.equal(m.status, 'COMPRESSED'); + assert.equal(m.data, 'hello'); + assert.equal(m.index, 7); + assert.equal(m.chunkId, null); + assert.equal(m.topicId, '0.0.1234'); + assert.equal(m.consensusTimestamp, '1700000000.000000001'); + assert.equal(m.owner, '0.0.42'); + }); + + it('stays COMPRESSING until all chunks have arrived, then concatenates', () => { + const m = new Message(); + m.addChunk(chunked(1, 3)); + assert.equal(m.status, 'COMPRESSING'); + assert.equal(m.data, null); + assert.equal(m.chunkId, '1700000000.111111111'); + assert.equal(m.index, 101); + + m.addChunk(chunked(2, 3)); + assert.equal(m.status, 'COMPRESSING'); + assert.equal(m.data, null); + + m.addChunk(chunked(3, 3)); + assert.equal(m.status, 'COMPRESSED'); + assert.equal(m.data, 'chunk-1chunk-2chunk-3'); + }); + + it('keeps the first chunk metadata as the canonical message attributes', () => { + const m = new Message(); + const c1 = chunked(1, 2, '1700000000.AAA'); + const c2 = chunked(2, 2, '1700000000.AAA'); + m.addChunk(c1); + m.addChunk(c2); + assert.equal(m.index, c1.sequence_number); + assert.equal(m.chunkId, '1700000000.AAA'); + assert.equal(m.topicId, c1.topic_id); + assert.equal(m.consensusTimestamp, c1.consensus_timestamp); + assert.equal(m.owner, c1.payer_account_id); + }); +}); + +describe('Message.compressData', () => { + it('returns null when any chunk message is non-string', () => { + const m = new Message(); + m.addChunk(singleMsg); + // Inject a non-string payload to simulate a malformed chunk + m.messages.push({ message: 12345, chunkNumber: 2, chunkTotal: 2 }); + assert.equal(m.compressData(), null); + }); + + it('decodes base64 chunks in array order', () => { + const m = new Message(); + m.messages = [ + { message: b64('foo') }, + { message: b64('bar') }, + { message: b64('baz') }, + ]; + assert.equal(m.compressData(), 'foobarbaz'); + }); +}); + +describe('Message.toJson', () => { + it('exposes the canonical message shape', () => { + const m = new Message(); + m.addChunk(singleMsg); + assert.deepEqual(m.toJson(), { + sequenceNumber: 7, + message: 'hello', + topicId: '0.0.1234', + consensusTimestamp: '1700000000.000000001', + owner: '0.0.42', + }); + }); + + it('returns null message when still compressing', () => { + const m = new Message(); + m.addChunk(chunked(1, 2)); + const j = m.toJson(); + assert.equal(j.message, null); + assert.equal(j.sequenceNumber, 101); + }); +}); diff --git a/topic-listener-service/tests/module-imports.test.mjs b/topic-listener-service/tests/module-imports.test.mjs new file mode 100644 index 0000000000..c1174688d1 --- /dev/null +++ b/topic-listener-service/tests/module-imports.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; + +describe('topic-listener-service module barrels', () => { + it('imports the interface barrel without throwing', async () => { + const mod = await import('../dist/interface/index.js'); + assert.ok(mod); + }); + + it('imports the config module (runs dotenv.config())', async () => { + const mod = await import('../dist/config.js'); + assert.ok(mod); + }); +}); diff --git a/topic-listener-service/tests/mongo-constants.test.mjs b/topic-listener-service/tests/mongo-constants.test.mjs new file mode 100644 index 0000000000..4eedc7604f --- /dev/null +++ b/topic-listener-service/tests/mongo-constants.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { DEFAULT } from '../dist/constants/mongo.js'; + +describe('topic-listener-service mongo defaults', () => { + it('exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); +}); diff --git a/topic-listener-service/tests/mongo-initialization.test.mjs b/topic-listener-service/tests/mongo-initialization.test.mjs new file mode 100644 index 0000000000..21c2441b43 --- /dev/null +++ b/topic-listener-service/tests/mongo-initialization.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { mongoInitialization } from '../dist/helpers/mongo-initialization.js'; + +describe('mongoInitialization', () => { + let original; + + before(() => { + original = process.env.DB_DATABASE; + }); + + after(() => { + if (original === undefined) { delete process.env.DB_DATABASE; } + else { process.env.DB_DATABASE = original; } + }); + + it('is an async function', () => { + assert.equal(typeof mongoInitialization, 'function'); + assert.equal(mongoInitialization.constructor.name, 'AsyncFunction'); + }); + + it('returns null when DB_DATABASE is not set', async () => { + delete process.env.DB_DATABASE; + const result = await mongoInitialization(); + assert.equal(result, null); + }); + + it('returns null when DB_DATABASE is an empty string', async () => { + process.env.DB_DATABASE = ''; + const result = await mongoInitialization(); + assert.equal(result, null); + }); + + it('returns a promise', () => { + delete process.env.DB_DATABASE; + const p = mongoInitialization(); + assert.equal(typeof p.then, 'function'); + return p; + }); + + it('invokes MikroORM.init when DB_DATABASE is set (env-provided pool sizes)', () => { + const savedPool = { + min: process.env.MIN_POOL_SIZE, + max: process.env.MAX_POOL_SIZE, + idle: process.env.MAX_IDLE_TIME_MS, + }; + process.env.DB_DATABASE = 'topic-listener-test'; + process.env.MIN_POOL_SIZE = '2'; + process.env.MAX_POOL_SIZE = '8'; + process.env.MAX_IDLE_TIME_MS = '1000'; + try { + const result = mongoInitialization(); + assert.notEqual(result, null); + assert.equal(typeof result.then, 'function'); + result.then(() => {}, () => {}); + } finally { + const restore = (key, val) => { + if (val === undefined) { delete process.env[key]; } else { process.env[key] = val; } + }; + restore('MIN_POOL_SIZE', savedPool.min); + restore('MAX_POOL_SIZE', savedPool.max); + restore('MAX_IDLE_TIME_MS', savedPool.idle); + } + }); +}); diff --git a/topic-listener-service/tests/topic-listener-entity.test.mjs b/topic-listener-service/tests/topic-listener-entity.test.mjs new file mode 100644 index 0000000000..7b34ab099a --- /dev/null +++ b/topic-listener-service/tests/topic-listener-entity.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { TopicListener } from '../dist/entity/topic-listener.js'; + +describe('TopicListener entity', () => { + it('is constructible and extends BaseEntity (object instance)', () => { + const t = new TopicListener(); + assert.ok(t instanceof TopicListener); + }); + + it('accepts topicId / name / searchIndex / sendIndex / network assignments', () => { + const t = new TopicListener(); + t.topicId = '0.0.123'; + t.name = 'demo'; + t.searchIndex = 0; + t.sendIndex = 0; + t.network = 'testnet'; + assert.equal(t.topicId, '0.0.123'); + assert.equal(t.name, 'demo'); + assert.equal(t.searchIndex, 0); + assert.equal(t.sendIndex, 0); + assert.equal(t.network, 'testnet'); + }); +}); diff --git a/worker-service/tests/axios-constants.test.mjs b/worker-service/tests/axios-constants.test.mjs new file mode 100644 index 0000000000..de3e339bf2 --- /dev/null +++ b/worker-service/tests/axios-constants.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import { MAX_REDIRECTS } from '../dist/constants/axios.js'; + +describe('worker-service axios constants', () => { + it('MAX_REDIRECTS.DEFAULT is 5', () => { + assert.equal(MAX_REDIRECTS.DEFAULT, 5); + }); +}); diff --git a/worker-service/tests/fireblocks-helper-dispatch.test.mjs b/worker-service/tests/fireblocks-helper-dispatch.test.mjs new file mode 100644 index 0000000000..20a80c8a8b --- /dev/null +++ b/worker-service/tests/fireblocks-helper-dispatch.test.mjs @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import { FireblocksHelper } from '../dist/api/helpers/fireblocks-helper.js'; + +function makeHelper(overrides = {}) { + const helper = new FireblocksHelper( + overrides.apiKey ?? 'api-key', + overrides.privateKey ?? 'priv-key', + overrides.vaultId ?? 'vault-7', + overrides.assetId ?? 'HBAR_TEST', + ); + helper.delay = async () => null; + return helper; +} + +describe('@unit FireblocksHelper constructor / config', () => { + let originalBaseUrl; + + beforeEach(() => { + originalBaseUrl = process.env.FIREBLOCKS_BASEURL; + }); + + afterEach(() => { + if (originalBaseUrl === undefined) { + delete process.env.FIREBLOCKS_BASEURL; + } else { + process.env.FIREBLOCKS_BASEURL = originalBaseUrl; + } + }); + + it('constructs and exposes an SDK client', () => { + const helper = new FireblocksHelper('k', 'p', 'v', 'HBAR'); + assert.ok(helper.client); + assert.equal(typeof helper.client.createTransaction, 'function'); + assert.equal(typeof helper.client.getTransactionById, 'function'); + }); + + it('stores the constructor arguments on the instance', () => { + const helper = new FireblocksHelper('AK', 'PK', 'V1', 'ASSET'); + assert.equal(helper.apiKey, 'AK'); + assert.equal(helper.privateKey, 'PK'); + assert.equal(helper.vaultId, 'V1'); + assert.equal(helper.assetId, 'ASSET'); + }); + + it('constructs with the default base url when env is unset', () => { + delete process.env.FIREBLOCKS_BASEURL; + assert.doesNotThrow(() => new FireblocksHelper('k', 'p', 'v', 'HBAR')); + }); + + it('constructs with a custom base url from env', () => { + process.env.FIREBLOCKS_BASEURL = 'https://sandbox.fireblocks.io'; + assert.doesNotThrow(() => new FireblocksHelper('k', 'p', 'v', 'HBAR')); + }); +}); + +describe('@unit FireblocksHelper.createTransaction — request mapping', () => { + it('maps message bytes to a hex content payload', async () => { + const helper = makeHelper(); + let received; + helper.client.createTransaction = async (req) => { + received = req; + return { id: 'tx-1' }; + }; + helper.client.getTransactionById = async () => ({ status: 'COMPLETED' }); + await helper.createTransaction(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + assert.equal( + received.extraParameters.rawMessageData.messages[0].content, + 'deadbeef', + ); + }); + + it('sends operation RAW', async () => { + const helper = makeHelper(); + let received; + helper.client.createTransaction = async (req) => { received = req; return { id: 'tx' }; }; + helper.client.getTransactionById = async () => ({ status: 'COMPLETED' }); + await helper.createTransaction(new Uint8Array([1])); + assert.equal(received.operation, 'RAW'); + }); + + it('sets the source to the configured vault account', async () => { + const helper = makeHelper({ vaultId: 'vault-99' }); + let received; + helper.client.createTransaction = async (req) => { received = req; return { id: 'tx' }; }; + helper.client.getTransactionById = async () => ({ status: 'COMPLETED' }); + await helper.createTransaction(new Uint8Array([1])); + assert.deepEqual(received.source, { type: 'VAULT_ACCOUNT', id: 'vault-99' }); + }); + + it('passes the configured assetId through', async () => { + const helper = makeHelper({ assetId: 'HBAR_PROD' }); + let received; + helper.client.createTransaction = async (req) => { received = req; return { id: 'tx' }; }; + helper.client.getTransactionById = async () => ({ status: 'COMPLETED' }); + await helper.createTransaction(new Uint8Array([1])); + assert.equal(received.assetId, 'HBAR_PROD'); + }); + + it('encodes an empty message as an empty hex string', async () => { + const helper = makeHelper(); + let received; + helper.client.createTransaction = async (req) => { received = req; return { id: 'tx' }; }; + helper.client.getTransactionById = async () => ({ status: 'COMPLETED' }); + await helper.createTransaction(new Uint8Array([])); + assert.equal(received.extraParameters.rawMessageData.messages[0].content, ''); + }); + + it('queries the transaction id returned by createTransaction', async () => { + const helper = makeHelper(); + helper.client.createTransaction = async () => ({ id: 'tx-xyz' }); + let queriedId; + helper.client.getTransactionById = async (id) => { queriedId = id; return { status: 'COMPLETED' }; }; + await helper.createTransaction(new Uint8Array([1])); + assert.equal(queriedId, 'tx-xyz'); + }); +}); + +describe('@unit FireblocksHelper.createTransaction — result handling', () => { + it('returns the completed transaction info', async () => { + const helper = makeHelper(); + const completed = { id: 'tx', status: 'COMPLETED' }; + helper.client.createTransaction = async () => ({ id: 'tx' }); + helper.client.getTransactionById = async () => completed; + const result = await helper.createTransaction(new Uint8Array([1])); + assert.equal(result, completed); + }); + + it('polls until the transaction completes, then returns it', async () => { + const helper = makeHelper(); + helper.client.createTransaction = async () => ({ id: 'tx' }); + const statuses = ['SUBMITTED', 'SUBMITTED', 'COMPLETED']; + let i = 0; + helper.client.getTransactionById = async () => ({ status: statuses[i++] }); + const result = await helper.createTransaction(new Uint8Array([1])); + assert.equal(result.status, 'COMPLETED'); + assert.equal(i, 3); + }); + + it('swallows a FAILED-status error and resolves to undefined', async () => { + const helper = makeHelper(); + helper.client.createTransaction = async () => ({ id: 'tx-f' }); + helper.client.getTransactionById = async () => ({ status: 'FAILED' }); + const result = await helper.createTransaction(new Uint8Array([1])); + assert.equal(result, undefined); + }); + + it('swallows SDK errors from createTransaction and resolves to undefined', async () => { + const helper = makeHelper(); + helper.client.createTransaction = async () => { throw new Error('boom'); }; + const result = await helper.createTransaction(new Uint8Array([1])); + assert.equal(result, undefined); + }); +}); + +describe('@unit FireblocksHelper.getTransactionResult — status branches', () => { + for (const status of ['CANCELLED', 'FAILED', 'BLOCKED', 'REJECTED']) { + it(`throws for terminal status ${status}`, async () => { + const helper = makeHelper(); + helper.client.getTransactionById = async () => ({ status }); + await assert.rejects( + () => helper.getTransactionResult('tx-id'), + new RegExp(`Fireblocks transaction "tx-id" failed with status ${status}`), + ); + }); + } + + it('returns the info object directly on COMPLETED', async () => { + const helper = makeHelper(); + const info = { status: 'COMPLETED', foo: 1 }; + helper.client.getTransactionById = async () => info; + const out = await helper.getTransactionResult('tx-c'); + assert.equal(out, info); + }); + + it('recurses through non-terminal statuses before completing', async () => { + const helper = makeHelper(); + const seq = ['PENDING_SIGNATURE', 'BROADCASTING', 'COMPLETED']; + let i = 0; + let delays = 0; + helper.delay = async () => { delays++; return null; }; + helper.client.getTransactionById = async () => ({ status: seq[i++] }); + const out = await helper.getTransactionResult('tx-r'); + assert.equal(out.status, 'COMPLETED'); + assert.equal(delays, 2); + }); + + it('includes the transaction id in the failure message', async () => { + const helper = makeHelper(); + helper.client.getTransactionById = async () => ({ status: 'REJECTED' }); + await assert.rejects( + () => helper.getTransactionResult('abc-123'), + /"abc-123"/, + ); + }); +}); + +describe('@unit FireblocksHelper.delay', () => { + it('resolves (to undefined) after the timer elapses', async () => { + const helper = new FireblocksHelper('k', 'p', 'v', 'HBAR'); + const out = await helper.delay(0); + assert.equal(out, undefined); + }); +}); diff --git a/worker-service/tests/hedera-sdk-helper-extra.test.mjs b/worker-service/tests/hedera-sdk-helper-extra.test.mjs new file mode 100644 index 0000000000..c6853b36fb --- /dev/null +++ b/worker-service/tests/hedera-sdk-helper-extra.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { HederaSDKHelper, NetworkOptions } from '../dist/api/helpers/hedera-sdk-helper.js'; +import { Environment } from '@guardian/common'; + +describe('HederaSDKHelper.setNetwork', () => { + afterEach(() => { + Environment.setMirrorNodes([]); + Environment.setNodes({}); + Environment.setNetwork('testnet'); + }); + + it('returns the HederaSDKHelper class (chainable)', () => { + const opts = new NetworkOptions(); + assert.equal(HederaSDKHelper.setNetwork(opts), HederaSDKHelper); + }); + + it('applies the network from the options to Environment', () => { + const opts = new NetworkOptions(); + opts.network = 'mainnet'; + HederaSDKHelper.setNetwork(opts); + assert.equal(Environment.network, 'mainnet'); + }); + + it('applies testnet from options', () => { + const opts = new NetworkOptions(); + opts.network = 'testnet'; + HederaSDKHelper.setNetwork(opts); + assert.equal(Environment.network, 'testnet'); + }); + + it('applies previewnet from options', () => { + const opts = new NetworkOptions(); + opts.network = 'previewnet'; + HederaSDKHelper.setNetwork(opts); + assert.equal(Environment.network, 'previewnet'); + }); + + it('propagates nodes to Environment', () => { + const opts = new NetworkOptions(); + opts.network = 'testnet'; + opts.nodes = { 'a:50211': '0.0.3' }; + HederaSDKHelper.setNetwork(opts); + assert.deepEqual(Environment.nodes, { 'a:50211': '0.0.3' }); + }); + + it('propagates mirrorNodes to Environment', () => { + const opts = new NetworkOptions(); + opts.network = 'testnet'; + opts.mirrorNodes = ['https://m.example']; + HederaSDKHelper.setNetwork(opts); + assert.deepEqual(Environment.mirrorNodes, ['https://m.example']); + }); + + it('throws when given an unknown network', () => { + const opts = new NetworkOptions(); + opts.network = 'nope'; + assert.throws(() => HederaSDKHelper.setNetwork(opts), /Unknown network: nope/); + }); +}); + +describe('HederaSDKHelper.setTransactionResponseCallback / transactionResponse', () => { + afterEach(() => { + HederaSDKHelper.setTransactionResponseCallback(null); + }); + + it('setTransactionResponseCallback is a static function', () => { + assert.equal(typeof HederaSDKHelper.setTransactionResponseCallback, 'function'); + }); + + it('invokes the registered synchronous callback with the account', () => { + let seen; + HederaSDKHelper.setTransactionResponseCallback((acc) => { seen = acc; }); + HederaSDKHelper.transactionResponse('0.0.5'); + assert.equal(seen, '0.0.5'); + }); + + it('does nothing (no throw) when no callback is registered', () => { + HederaSDKHelper.setTransactionResponseCallback(null); + assert.doesNotThrow(() => HederaSDKHelper.transactionResponse('0.0.1')); + }); + + it('swallows a synchronous throw from the callback', () => { + HederaSDKHelper.setTransactionResponseCallback(() => { throw new Error('boom'); }); + assert.doesNotThrow(() => HederaSDKHelper.transactionResponse('0.0.9')); + }); + + it('accepts an async callback without throwing', () => { + HederaSDKHelper.setTransactionResponseCallback(async () => 'ok'); + assert.doesNotThrow(() => HederaSDKHelper.transactionResponse('0.0.2')); + }); + + it('swallows a rejected promise from an async callback', async () => { + HederaSDKHelper.setTransactionResponseCallback(async () => { throw new Error('async boom'); }); + assert.doesNotThrow(() => HederaSDKHelper.transactionResponse('0.0.3')); + await new Promise((r) => setTimeout(r, 5)); + }); +}); + +describe('HederaSDKHelper.client', () => { + before(() => { + Environment.setMirrorNodes([]); + Environment.setNodes({}); + Environment.setNetwork('testnet'); + }); + + it('creates a client without an operator', () => { + const client = HederaSDKHelper.client(); + assert.ok(client); + assert.equal(client.operatorAccountId, null); + }); + + it('returns a client object with a setOperator method', () => { + const client = HederaSDKHelper.client(); + assert.equal(typeof client.setOperator, 'function'); + }); + + it('does not set operator when only operatorId is provided', () => { + const client = HederaSDKHelper.client('0.0.2'); + assert.equal(client.operatorAccountId, null); + }); +}); + +describe('HederaSDKHelper.checkAccount — additional edge cases', () => { + it('accepts 0.0.0', () => { + assert.equal(HederaSDKHelper.checkAccount('0.0.0'), true); + }); + + it('rejects a trailing dot', () => { + assert.equal(HederaSDKHelper.checkAccount('0.0.1.'), false); + }); + + it('rejects whitespace-only', () => { + assert.equal(HederaSDKHelper.checkAccount(' '), false); + }); + + it('accepts a bare account number (shard/realm default to 0)', () => { + assert.equal(HederaSDKHelper.checkAccount('123'), true); + }); + + it('returns false for the number 0 (falsy)', () => { + assert.equal(HederaSDKHelper.checkAccount(0), false); + }); + + it('rejects negative components', () => { + assert.equal(HederaSDKHelper.checkAccount('-1.0.1'), false); + }); +}); diff --git a/worker-service/tests/hedera-sdk-helper.test.mjs b/worker-service/tests/hedera-sdk-helper.test.mjs new file mode 100644 index 0000000000..ea807ac40f --- /dev/null +++ b/worker-service/tests/hedera-sdk-helper.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { + HederaSDKHelper, + NetworkOptions, + MAX_FEE, + INITIAL_BALANCE, +} from '../dist/api/helpers/hedera-sdk-helper.js'; + +describe('hedera-sdk-helper module constants', () => { + it('MAX_FEE is a positive number', () => { + assert.equal(typeof MAX_FEE, 'number'); + assert.ok(MAX_FEE > 0); + }); + + it('MAX_FEE defaults to 30', () => { + assert.equal(MAX_FEE, 30); + }); + + it('INITIAL_BALANCE is 30', () => { + assert.equal(INITIAL_BALANCE, 30); + }); +}); + +describe('HederaSDKHelper static constants', () => { + it('REST_API_MAX_LIMIT is 100', () => { + assert.equal(HederaSDKHelper.REST_API_MAX_LIMIT, 100); + }); + + it('MAX_TIMEOUT is a positive number', () => { + assert.equal(typeof HederaSDKHelper.MAX_TIMEOUT, 'number'); + assert.ok(HederaSDKHelper.MAX_TIMEOUT > 0); + }); + + it('MAX_TIMEOUT defaults to 10 minutes (600000 ms)', () => { + assert.equal(HederaSDKHelper.MAX_TIMEOUT, 600000); + }); +}); + +describe('HederaSDKHelper.checkAccount', () => { + it('returns true for a valid account id 0.0.1', () => { + assert.equal(HederaSDKHelper.checkAccount('0.0.1'), true); + }); + + it('returns true for a valid account id with large number', () => { + assert.equal(HederaSDKHelper.checkAccount('0.0.123456'), true); + }); + + it('returns true for shard.realm.num form 1.2.3', () => { + assert.equal(HederaSDKHelper.checkAccount('1.2.3'), true); + }); + + it('returns false for a malformed string', () => { + assert.equal(HederaSDKHelper.checkAccount('not-an-account'), false); + }); + + it('returns false for a partial id', () => { + assert.equal(HederaSDKHelper.checkAccount('0.0'), false); + }); + + it('returns false for an empty string', () => { + assert.equal(HederaSDKHelper.checkAccount(''), false); + }); + + it('returns false for null', () => { + assert.equal(HederaSDKHelper.checkAccount(null), false); + }); + + it('returns false for undefined', () => { + assert.equal(HederaSDKHelper.checkAccount(undefined), false); + }); + + it('returns a boolean type for valid input', () => { + assert.equal(typeof HederaSDKHelper.checkAccount('0.0.2'), 'boolean'); + }); + + it('returns a boolean type for invalid input', () => { + assert.equal(typeof HederaSDKHelper.checkAccount('@@@'), 'boolean'); + }); +}); + +describe('NetworkOptions defaults', () => { + it('constructs with default network "testnet"', () => { + const opts = new NetworkOptions(); + assert.equal(opts.network, 'testnet'); + }); + + it('defaults localNodeAddress to an empty string', () => { + const opts = new NetworkOptions(); + assert.equal(opts.localNodeAddress, ''); + }); + + it('defaults localNodeProtocol to an empty string', () => { + const opts = new NetworkOptions(); + assert.equal(opts.localNodeProtocol, ''); + }); + + it('defaults nodes to an empty object', () => { + const opts = new NetworkOptions(); + assert.deepEqual(opts.nodes, {}); + }); + + it('defaults mirrorNodes to an empty array', () => { + const opts = new NetworkOptions(); + assert.deepEqual(opts.mirrorNodes, []); + }); + + it('produces independent instances (no shared object refs)', () => { + const a = new NetworkOptions(); + const b = new NetworkOptions(); + a.nodes.x = '0.0.5'; + a.mirrorNodes.push('m'); + assert.deepEqual(b.nodes, {}); + assert.deepEqual(b.mirrorNodes, []); + }); + + it('allows mutation of network field', () => { + const opts = new NetworkOptions(); + opts.network = 'mainnet'; + assert.equal(opts.network, 'mainnet'); + }); +}); + +describe('HederaSDKHelper.setTransactionLogSender', () => { + it('is a static function', () => { + assert.equal(typeof HederaSDKHelper.setTransactionLogSender, 'function'); + }); + + it('accepts a function without throwing', () => { + assert.doesNotThrow(() => HederaSDKHelper.setTransactionLogSender(async () => undefined)); + }); +}); diff --git a/worker-service/tests/hedera-utils-dispatch.test.mjs b/worker-service/tests/hedera-utils-dispatch.test.mjs new file mode 100644 index 0000000000..adc01cb281 --- /dev/null +++ b/worker-service/tests/hedera-utils-dispatch.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { PrivateKey } from '@hiero-ledger/sdk'; +import { HederaUtils } from '../dist/api/helpers/utils.js'; + +describe('@unit HederaUtils.parsPrivateKey — key form coverage', () => { + it('parses an ED25519 DER-encoded string', () => { + const key = PrivateKey.generateED25519(); + const result = HederaUtils.parsPrivateKey(key.toStringDer(), true, 'Op Key'); + assert.equal(result.toString(), key.toString()); + }); + + it('parses an ED25519 raw hex string', () => { + const key = PrivateKey.generateED25519(); + const result = HederaUtils.parsPrivateKey(key.toStringRaw()); + assert.equal(result.toString(), key.toString()); + }); + + it('parses an ECDSA DER-encoded string', () => { + const key = PrivateKey.generateECDSA(); + const result = HederaUtils.parsPrivateKey(key.toStringDer()); + assert.equal(result.toString(), key.toString()); + }); + + it('returns a PrivateKey instance for a valid string', () => { + const key = PrivateKey.generate(); + const result = HederaUtils.parsPrivateKey(key.toString()); + assert.ok(result instanceof PrivateKey); + }); + + it('round-trips the default-generated key string', () => { + const key = PrivateKey.generate(); + assert.equal(HederaUtils.parsPrivateKey(key.toString()).toString(), key.toString()); + }); + + it('passes through an ECDSA PrivateKey object unchanged', () => { + const key = PrivateKey.generateECDSA(); + assert.equal(HederaUtils.parsPrivateKey(key, true, 'X'), key); + }); + + it('passes through a PrivateKey object even when notNull=false', () => { + const key = PrivateKey.generate(); + assert.equal(HederaUtils.parsPrivateKey(key, false), key); + }); +}); + +describe('@unit HederaUtils.parsPrivateKey — invalid input branch', () => { + it('throws "Invalid Private Key" for an arbitrary word', () => { + assert.throws(() => HederaUtils.parsPrivateKey('garbage'), /^Error: Invalid Private Key$/); + }); + + it('throws "Invalid " using a custom name', () => { + assert.throws(() => HederaUtils.parsPrivateKey('garbage', true, 'Topic Key'), /Invalid Topic Key/); + }); + + it('throws Invalid for a too-short hex string', () => { + assert.throws(() => HederaUtils.parsPrivateKey('abcdef'), /Invalid Private Key/); + }); + + it('throws Invalid for a whitespace-only non-empty string', () => { + assert.throws(() => HederaUtils.parsPrivateKey(' '), /Invalid Private Key/); + }); + + it('throws an Error instance (not a string) on invalid input', () => { + try { + HederaUtils.parsPrivateKey('nope'); + assert.fail('expected throw'); + } catch (e) { + assert.ok(e instanceof Error); + } + }); + + it('throws Invalid rather than not-set for a non-empty bad string with notNull=false', () => { + assert.throws(() => HederaUtils.parsPrivateKey('nope', false, 'K'), /Invalid K/); + }); +}); + +describe('@unit HederaUtils.parsPrivateKey — empty/null branch', () => { + it('throws not-set for null with default keyName', () => { + assert.throws(() => HederaUtils.parsPrivateKey(null), /^Error: Private Key is not set$/); + }); + + it('throws not-set for undefined when notNull=true', () => { + assert.throws(() => HederaUtils.parsPrivateKey(undefined, true, 'Admin Key'), /Admin Key is not set/); + }); + + it('treats 0 as falsy and throws not-set', () => { + assert.throws(() => HederaUtils.parsPrivateKey(0, true, 'Z'), /Z is not set/); + }); + + it('returns null for null when notNull=false', () => { + assert.equal(HederaUtils.parsPrivateKey(null, false), null); + }); + + it('returns null for empty string when notNull=false with a custom name', () => { + assert.equal(HederaUtils.parsPrivateKey('', false, 'Whatever'), null); + }); + + it('returns null for undefined when notNull=false', () => { + assert.equal(HederaUtils.parsPrivateKey(undefined, false), null); + }); +}); + +describe('@unit HederaUtils.randomKey — structure', () => { + it('returns a non-empty string', () => { + const text = HederaUtils.randomKey(); + assert.equal(typeof text, 'string'); + assert.ok(text.length > 0); + }); + + it('decode(randomKey) is a Uint8Array', () => { + assert.ok(HederaUtils.decode(HederaUtils.randomKey()) instanceof Uint8Array); + }); + + it('encode/decode round-trip is byte-lossy for non-ASCII bytes (utf8 corruption)', () => { + const original = new Uint8Array([0xff, 0xfe, 0x80, 0x90]); + const roundTripped = HederaUtils.decode(HederaUtils.encode(original)); + assert.notDeepEqual(Array.from(roundTripped), Array.from(original)); + assert.ok(roundTripped.length > original.length); + }); +}); + +describe('@unit HederaUtils.encode/decode — additional binary coverage', () => { + it('encode produces the latin1/utf8 string of a Buffer', () => { + assert.equal(HederaUtils.encode(Buffer.from([72, 73])), 'HI'); + }); + + it('decode then encode round-trips a printable ASCII string', () => { + const s = 'Guardian-123'; + assert.equal(HederaUtils.encode(HederaUtils.decode(s)), s); + }); + + it('decode length equals utf8 byte length, not char length', () => { + const out = HederaUtils.decode('café'); + assert.equal(out.length, Buffer.byteLength('café', 'utf8')); + }); + + it('encode of a single zero byte is a NUL string of length 1', () => { + const out = HederaUtils.encode(new Uint8Array([0])); + assert.equal(out.length, 1); + assert.equal(out.charCodeAt(0), 0); + }); + + it('decode of multibyte text yields more bytes than characters', () => { + const out = HederaUtils.decode('☃'); + assert.ok(out.length > 1); + }); + + it('encode accepts a plain number array via Buffer.from', () => { + assert.equal(HederaUtils.encode([97, 98, 99]), 'abc'); + }); +}); diff --git a/worker-service/tests/hedera-utils-extra.test.mjs b/worker-service/tests/hedera-utils-extra.test.mjs new file mode 100644 index 0000000000..983b29eb45 --- /dev/null +++ b/worker-service/tests/hedera-utils-extra.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { PrivateKey } from '@hiero-ledger/sdk'; +import { HederaUtils, timeout } from '../dist/api/helpers/utils.js'; + +describe('HederaUtils.encode — additional cases', () => { + it('encodes an empty Uint8Array to an empty string', () => { + assert.equal(HederaUtils.encode(new Uint8Array([])), ''); + }); + + it('encodes ASCII bytes to the matching string', () => { + const bytes = new Uint8Array([104, 105]); + assert.equal(HederaUtils.encode(bytes), 'hi'); + }); + + it('returns a string type', () => { + assert.equal(typeof HederaUtils.encode(new Uint8Array([1])), 'string'); + }); + + it('encodes from a Buffer instance', () => { + assert.equal(HederaUtils.encode(Buffer.from('abc')), 'abc'); + }); +}); + +describe('HederaUtils.decode — additional cases', () => { + it('decodes an empty string to an empty Uint8Array', () => { + const out = HederaUtils.decode(''); + assert.ok(out instanceof Uint8Array); + assert.equal(out.length, 0); + }); + + it('decodes ASCII text to the correct byte values', () => { + assert.deepEqual(Array.from(HederaUtils.decode('AB')), [65, 66]); + }); + + it('returns a Uint8Array type', () => { + assert.ok(HederaUtils.decode('x') instanceof Uint8Array); + }); + + it('round-trips pure ASCII bytes through encode then decode', () => { + const original = new Uint8Array([0, 10, 65, 90, 127]); + const round = HederaUtils.decode(HederaUtils.encode(original)); + assert.deepEqual(Array.from(round), Array.from(original)); + }); +}); + +describe('HederaUtils.randomKey — additional cases', () => { + it('produces unique values across many calls', () => { + const set = new Set(); + for (let i = 0; i < 50; i++) { + set.add(HederaUtils.randomKey()); + } + assert.equal(set.size, 50); + }); + + it('returns a non-empty string each call', () => { + const key = HederaUtils.randomKey(); + assert.equal(typeof key, 'string'); + assert.ok(key.length > 0); + }); + + it('always returns a string', () => { + for (let i = 0; i < 5; i++) { + assert.equal(typeof HederaUtils.randomKey(), 'string'); + } + }); +}); + +describe('HederaUtils.parsPrivateKey — additional branches', () => { + it('defaults keyName to "Private Key" on invalid string', () => { + assert.throws(() => HederaUtils.parsPrivateKey('bad', true), /Invalid Private Key/); + }); + + it('throws Invalid even when notNull=false for a non-empty bad string', () => { + assert.throws(() => HederaUtils.parsPrivateKey('bad', false), /Invalid Private Key/); + }); + + it('uses the custom keyName in the not-set message', () => { + assert.throws(() => HederaUtils.parsPrivateKey(null, true, 'Op Key'), /Op Key is not set/); + }); + + it('returns null for undefined when notNull=false', () => { + assert.equal(HederaUtils.parsPrivateKey(undefined, false), null); + }); + + it('returns the same PrivateKey object instance when given one', () => { + const key = PrivateKey.generate(); + assert.equal(HederaUtils.parsPrivateKey(key, true), key); + }); + + it('treats notNull default (true) as throwing on empty string', () => { + assert.throws(() => HederaUtils.parsPrivateKey(''), /Private Key is not set/); + }); +}); + +describe('timeout decorator', () => { + it('is exported as a function factory', () => { + assert.equal(typeof timeout, 'function'); + assert.equal(typeof timeout(1000), 'function'); + }); + + it('resolves with the underlying method result before the timeout', async () => { + class Svc { + async fast() { + return 'done'; + } + } + const descriptor = Object.getOwnPropertyDescriptor(Svc.prototype, 'fast'); + timeout(1000)(Svc.prototype, 'fast', descriptor); + Object.defineProperty(Svc.prototype, 'fast', descriptor); + const result = await new Svc().fast(); + assert.equal(result, 'done'); + }); + + it('rejects with the timeout error when the method is too slow', async () => { + class Svc { + async slow() { + return new Promise((resolve) => setTimeout(() => resolve('late'), 100)); + } + } + const descriptor = Object.getOwnPropertyDescriptor(Svc.prototype, 'slow'); + timeout(10, 'too slow')(Svc.prototype, 'slow', descriptor); + Object.defineProperty(Svc.prototype, 'slow', descriptor); + await assert.rejects(() => new Svc().slow(), /too slow/); + }); + + it('uses the default timeout message when none is supplied', async () => { + class Svc { + async slow() { + return new Promise((resolve) => setTimeout(() => resolve('late'), 100)); + } + } + const descriptor = Object.getOwnPropertyDescriptor(Svc.prototype, 'slow'); + timeout(10)(Svc.prototype, 'slow', descriptor); + Object.defineProperty(Svc.prototype, 'slow', descriptor); + await assert.rejects(() => new Svc().slow(), /Transaction timeout exceeded/); + }); +}); diff --git a/worker-service/tests/hedera-utils.test.mjs b/worker-service/tests/hedera-utils.test.mjs new file mode 100644 index 0000000000..6696feba7d --- /dev/null +++ b/worker-service/tests/hedera-utils.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { PrivateKey } from '@hiero-ledger/sdk'; +import { HederaUtils } from '../dist/api/helpers/utils.js'; + +describe('HederaUtils.encode / decode', () => { + it('encodes a Uint8Array to a string and back', () => { + const original = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = HederaUtils.encode(original); + const decoded = HederaUtils.decode(encoded); + assert.deepEqual(Array.from(decoded), [1, 2, 3, 4, 5]); + }); + + it('round-trips ASCII text via decode/encode', () => { + const decoded = HederaUtils.decode('hello'); + const encoded = HederaUtils.encode(decoded); + assert.equal(encoded, 'hello'); + }); +}); + +describe('HederaUtils.randomKey', () => { + it('returns a non-empty string', () => { + const key = HederaUtils.randomKey(); + assert.equal(typeof key, 'string'); + assert.ok(key.length > 0); + }); + + it('produces different keys across calls', () => { + const a = HederaUtils.randomKey(); + const b = HederaUtils.randomKey(); + assert.notEqual(a, b); + }); +}); + +describe('HederaUtils.parsPrivateKey', () => { + it('parses a valid private key string into a PrivateKey instance', () => { + const seed = PrivateKey.generate(); + const result = HederaUtils.parsPrivateKey(seed.toString()); + assert.ok(result, 'expected a PrivateKey'); + // The parsed key should round-trip to the same string. + assert.equal(result.toString(), seed.toString()); + }); + + it('returns the input unchanged when it is already a PrivateKey', () => { + const seed = PrivateKey.generate(); + const result = HederaUtils.parsPrivateKey(seed); + assert.equal(result, seed); + }); + + it('throws "Invalid " for malformed strings', () => { + assert.throws( + () => HederaUtils.parsPrivateKey('not-a-key', true, 'Operator Key'), + /Invalid Operator Key/, + ); + }); + + it('throws " is not set" when notNull=true and key is empty', () => { + assert.throws( + () => HederaUtils.parsPrivateKey('', true, 'Wallet Key'), + /Wallet Key is not set/, + ); + assert.throws( + () => HederaUtils.parsPrivateKey(null, true), + /Private Key is not set/, + ); + }); + + it('returns null when notNull=false and the key is empty', () => { + assert.equal(HederaUtils.parsPrivateKey(null, false), null); + assert.equal(HederaUtils.parsPrivateKey('', false), null); + }); +}); diff --git a/worker-service/tests/mongo-constants.test.mjs b/worker-service/tests/mongo-constants.test.mjs new file mode 100644 index 0000000000..a5b42b70eb --- /dev/null +++ b/worker-service/tests/mongo-constants.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { DEFAULT } from '../dist/constants/mongo.js'; +import { DEFAULT_MONGO, MAX_REDIRECTS } from '../dist/constants/index.js'; + +describe('worker-service mongo defaults', () => { + it('DEFAULT exposes pool/idle defaults as numeric strings', () => { + assert.equal(DEFAULT.MIN_POOL_SIZE, '1'); + assert.equal(DEFAULT.MAX_POOL_SIZE, '5'); + assert.equal(DEFAULT.MAX_IDLE_TIME_MS, '30000'); + }); + it('DEFAULT_MONGO from constants/index.js is the same DEFAULT', () => { + assert.equal(DEFAULT_MONGO, DEFAULT); + }); + it('barrel re-exports MAX_REDIRECTS', () => { + assert.equal(MAX_REDIRECTS.DEFAULT, 5); + }); +}); diff --git a/worker-service/tests/mongo-initialization-init.test.mjs b/worker-service/tests/mongo-initialization-init.test.mjs new file mode 100644 index 0000000000..dab10e1854 --- /dev/null +++ b/worker-service/tests/mongo-initialization-init.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const DIST = '../dist/helpers/mongo-initialization.js'; + +function makeMocks() { + const rec = { initArgs: null }; + const mocks = { + '@mikro-orm/core': { + MikroORM: { + init: async (opts) => { rec.initArgs = opts; return { orm: true }; }, + }, + }, + '@mikro-orm/mongodb': { MongoDriver: class {} }, + }; + return { rec, mocks }; +} + +describe('@unit mongoInitialization with DB_DATABASE set', function () { + this.timeout(60000); + let saved; + beforeEach(() => { + saved = { + db: process.env.DB_DATABASE, + min: process.env.MIN_POOL_SIZE, + max: process.env.MAX_POOL_SIZE, + idle: process.env.MAX_IDLE_TIME_MS, + }; + }); + afterEach(() => { + const restore = (k, v) => { if (v === undefined) { delete process.env[k]; } else { process.env[k] = v; } }; + restore('DB_DATABASE', saved.db); + restore('MIN_POOL_SIZE', saved.min); + restore('MAX_POOL_SIZE', saved.max); + restore('MAX_IDLE_TIME_MS', saved.idle); + }); + + it('calls MikroORM.init and applies default pool options', async () => { + process.env.DB_DATABASE = 'guardian_db'; + delete process.env.MIN_POOL_SIZE; + delete process.env.MAX_POOL_SIZE; + delete process.env.MAX_IDLE_TIME_MS; + const { rec, mocks } = makeMocks(); + const { mongoInitialization } = await esmock(DIST, mocks); + const result = await mongoInitialization(); + assert.deepEqual(result, { orm: true }); + assert.ok(rec.initArgs); + assert.equal(rec.initArgs.ensureIndexes, true); + assert.equal(typeof rec.initArgs.driverOptions.minPoolSize, 'number'); + assert.equal(typeof rec.initArgs.driverOptions.maxPoolSize, 'number'); + assert.equal(typeof rec.initArgs.driverOptions.maxIdleTimeMS, 'number'); + }); + + it('parses pool overrides from the environment', async () => { + process.env.DB_DATABASE = 'guardian_db'; + process.env.MIN_POOL_SIZE = '7'; + process.env.MAX_POOL_SIZE = '42'; + process.env.MAX_IDLE_TIME_MS = '9999'; + const { rec, mocks } = makeMocks(); + const { mongoInitialization } = await esmock(DIST, mocks); + await mongoInitialization(); + assert.equal(rec.initArgs.driverOptions.minPoolSize, 7); + assert.equal(rec.initArgs.driverOptions.maxPoolSize, 42); + assert.equal(rec.initArgs.driverOptions.maxIdleTimeMS, 9999); + }); +}); diff --git a/worker-service/tests/mongo-initialization.test.mjs b/worker-service/tests/mongo-initialization.test.mjs new file mode 100644 index 0000000000..ef62b12b7b --- /dev/null +++ b/worker-service/tests/mongo-initialization.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { mongoInitialization } from '../dist/helpers/mongo-initialization.js'; + +describe('mongoInitialization', () => { + let original; + + before(() => { + original = process.env.DB_DATABASE; + }); + + after(() => { + if (original === undefined) { delete process.env.DB_DATABASE; } + else { process.env.DB_DATABASE = original; } + }); + + it('is an async function', () => { + assert.equal(typeof mongoInitialization, 'function'); + assert.equal(mongoInitialization.constructor.name, 'AsyncFunction'); + }); + + it('returns null when DB_DATABASE is not set', async () => { + delete process.env.DB_DATABASE; + const result = await mongoInitialization(); + assert.equal(result, null); + }); + + it('returns null when DB_DATABASE is an empty string', async () => { + process.env.DB_DATABASE = ''; + const result = await mongoInitialization(); + assert.equal(result, null); + }); + + it('returns a promise', () => { + delete process.env.DB_DATABASE; + const p = mongoInitialization(); + assert.equal(typeof p.then, 'function'); + return p; + }); +}); diff --git a/worker-service/tests/transaction-logger-extra.test.mjs b/worker-service/tests/transaction-logger-extra.test.mjs new file mode 100644 index 0000000000..f59eafdbec --- /dev/null +++ b/worker-service/tests/transaction-logger-extra.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { TransactionLogger } from '../dist/api/helpers/transaction-logger.js'; + +const baseTx = (extra = {}) => ({ transactionId: 'tx', transactionMemo: '', ...extra }); + +describe('TransactionLogger.getTransactionMetadata — token lifecycle', () => { + it('TokenAssociateTransaction reports tokens associated', () => { + const out = TransactionLogger.getTransactionMetadata('TokenAssociateTransaction', baseTx()); + assert.match(out, /tokens associated: 1/); + assert.match(out, /payer sigs: 1/); + assert.match(out, /total sigs: 1/); + }); + + it('TokenDissociateTransaction reports tokens dissociated', () => { + const out = TransactionLogger.getTransactionMetadata('TokenDissociateTransaction', baseTx()); + assert.match(out, /tokens dissociated: 1/); + }); + + it('TokenFreezeTransaction includes total sigs', () => { + const out = TransactionLogger.getTransactionMetadata('TokenFreezeTransaction', baseTx()); + assert.match(out, /total sigs: 1/); + }); + + it('TokenUnfreezeTransaction includes total sigs', () => { + const out = TransactionLogger.getTransactionMetadata('TokenUnfreezeTransaction', baseTx()); + assert.match(out, /total sigs: 1/); + }); + + it('TokenGrantKycTransaction includes payer sigs', () => { + const out = TransactionLogger.getTransactionMetadata('TokenGrantKycTransaction', baseTx()); + assert.match(out, /payer sigs: 1/); + }); + + it('TokenRevokeKycTransaction includes payer sigs', () => { + const out = TransactionLogger.getTransactionMetadata('TokenRevokeKycTransaction', baseTx()); + assert.match(out, /payer sigs: 1/); + }); + + it('TokenUpdateTransaction reports memo size only', () => { + const out = TransactionLogger.getTransactionMetadata('TokenUpdateTransaction', baseTx()); + assert.match(out, /payer sigs: 1/); + assert.match(out, /memo size: 0/); + }); + + it('TokenDeleteTransaction reports memo size', () => { + const out = TransactionLogger.getTransactionMetadata('TokenDeleteTransaction', baseTx()); + assert.match(out, /memo size: 0/); + }); + + it('TokenWipeTransaction includes total sigs', () => { + const out = TransactionLogger.getTransactionMetadata('TokenWipeTransaction', baseTx()); + assert.match(out, /total sigs: 1/); + }); +}); + +describe('TransactionLogger.getTransactionMetadata — mint and transfer', () => { + it('TokenMintTransaction labels Fungible Token', () => { + const out = TransactionLogger.getTransactionMetadata('TokenMintTransaction', baseTx()); + assert.match(out, /Fungible Token/); + }); + + it('TokenMintNFTTransaction labels Non-Fungible Token and counts NFTs', () => { + const tx = baseTx({ metadata: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])] }); + const out = TransactionLogger.getTransactionMetadata('TokenMintNFTTransaction', tx); + assert.match(out, /Non-Fungible Token/); + assert.match(out, /of NFTs minted: 2/); + assert.match(out, /bytes of metadata per NFT: 3/); + }); + + it('TransferTransaction labels Fungible Token and includes amount metadata', () => { + const out = TransactionLogger.getTransactionMetadata('TransferTransaction', baseTx(), 42); + assert.match(out, /Fungible Token/); + assert.match(out, /amount: 42/); + }); + + it('NFTTransferTransaction labels Non-Fungible Token', () => { + const out = TransactionLogger.getTransactionMetadata('NFTTransferTransaction', baseTx(), 'abc'); + assert.match(out, /Non-Fungible Token/); + assert.match(out, /of NFTs transferred: 3/); + }); +}); + +describe('TransactionLogger.getTransactionMetadata — account and topic', () => { + it('AccountCreateTransaction includes total sigs', () => { + const out = TransactionLogger.getTransactionMetadata('AccountCreateTransaction', baseTx()); + assert.match(out, /total sigs: 1/); + }); + + it('TopicCreateTransaction reports admin and submit keys', () => { + const tx = baseTx({ adminKey: {}, submitKey: null, topicMemo: 'memo' }); + const out = TransactionLogger.getTransactionMetadata('TopicCreateTransaction', tx); + assert.match(out, /admin keys: 1/); + assert.match(out, /submit keys: 0/); + assert.match(out, /topic memo size: 4/); + }); + + it('TopicMessageSubmitTransaction reports message size', () => { + const tx = baseTx({ message: 'hello world' }); + const out = TransactionLogger.getTransactionMetadata('TopicMessageSubmitTransaction', tx); + assert.match(out, /message size: 11/); + }); +}); + +describe('TransactionLogger.getTransactionMetadata — generic behaviour', () => { + it('always prefixes the txid', () => { + const out = TransactionLogger.getTransactionMetadata('Anything', baseTx({ transactionId: 'XYZ' })); + assert.match(out, /txid: XYZ/); + }); + + it('returns just the txid prefix for an unrecognised transaction name', () => { + const out = TransactionLogger.getTransactionMetadata('SomethingElse', baseTx({ transactionId: 'T1' })); + assert.equal(out, 'txid: T1; '); + }); + + it('returns an empty string for undefined transaction', () => { + assert.equal(TransactionLogger.getTransactionMetadata('TokenCreateTransaction', undefined), ''); + }); + + it('counts multi-byte UTF-8 memo size in bytes not chars', () => { + const out = TransactionLogger.getTransactionMetadata('TokenDeleteTransaction', baseTx({ transactionMemo: 'é' })); + assert.match(out, /memo size: 2/); + }); +}); diff --git a/worker-service/tests/transaction-logger-string-size.test.mjs b/worker-service/tests/transaction-logger-string-size.test.mjs new file mode 100644 index 0000000000..509e72a83a --- /dev/null +++ b/worker-service/tests/transaction-logger-string-size.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { TransactionLogger } from '../dist/api/helpers/transaction-logger.js'; + +describe('TransactionLogger.stringSize', () => { + it('returns the byte length of an ASCII string', () => { + assert.equal(TransactionLogger.stringSize('hello'), 5); + }); + + it('returns 0 for an empty string (still a string)', () => { + assert.equal(TransactionLogger.stringSize(''), 0); + }); + + it('counts multi-byte UTF-8 chars in bytes', () => { + assert.equal(TransactionLogger.stringSize('é'), 2); + }); + + it('counts a 4-byte emoji as 4 bytes', () => { + assert.equal(TransactionLogger.stringSize('😀'), 4); + }); + + it('returns null for null', () => { + assert.equal(TransactionLogger.stringSize(null), null); + }); + + it('returns null for undefined', () => { + assert.equal(TransactionLogger.stringSize(undefined), null); + }); + + it('returns null for 0 (falsy non-string)', () => { + assert.equal(TransactionLogger.stringSize(0), null); + }); + + it('returns the length of an array', () => { + assert.equal(TransactionLogger.stringSize([1, 2, 3]), 3); + }); + + it('returns the length of a Uint8Array', () => { + assert.equal(TransactionLogger.stringSize(new Uint8Array([1, 2, 3, 4])), 4); + }); + + it('returns the length of a non-empty buffer', () => { + assert.equal(TransactionLogger.stringSize(Buffer.from('abcd')), 4); + }); + + it('returns null for an empty array (falsy length-zero? array is truthy)', () => { + assert.equal(TransactionLogger.stringSize([]), 0); + }); + + it('treats a long ASCII string byte-for-byte', () => { + const s = 'x'.repeat(100); + assert.equal(TransactionLogger.stringSize(s), 100); + }); + + it('counts mixed ASCII + multibyte correctly', () => { + assert.equal(TransactionLogger.stringSize('aé'), 3); + }); +}); + +describe('TransactionLogger.getTransactionData — additional cases', () => { + it('includes the network value verbatim', () => { + const out = TransactionLogger.getTransactionData('id', null, 'previewnet', 'Tx', 'u'); + assert.equal(out.network, 'previewnet'); + }); + + it('nests userId under payload', () => { + const out = TransactionLogger.getTransactionData('id', null, 'testnet', 'Tx', 'user-99'); + assert.deepEqual(out.payload, { userId: 'user-99' }); + }); + + it('preserves the transactionName field', () => { + const out = TransactionLogger.getTransactionData('id', null, 'testnet', 'MyTx', 'u'); + assert.equal(out.transactionName, 'MyTx'); + }); + + it('serialises operatorAccountId via toString', () => { + const client = { operatorAccountId: { toString: () => '0.0.777' } }; + const out = TransactionLogger.getTransactionData('id', client, 'testnet', 'Tx', 'u'); + assert.equal(out.operatorAccountId, '0.0.777'); + }); + + it('sets operatorAccountId undefined when client lacks operatorAccountId', () => { + const out = TransactionLogger.getTransactionData('id', {}, 'testnet', 'Tx', 'u'); + assert.equal(out.operatorAccountId, undefined); + }); + + it('keeps the id field unchanged', () => { + const out = TransactionLogger.getTransactionData('the-id', null, 'testnet', 'Tx', 'u'); + assert.equal(out.id, 'the-id'); + }); + + it('returns an object with exactly the expected keys', () => { + const out = TransactionLogger.getTransactionData('id', null, 'testnet', 'Tx', 'u'); + assert.deepEqual(Object.keys(out).sort(), ['id', 'network', 'operatorAccountId', 'payload', 'transactionName'].sort()); + }); +}); diff --git a/worker-service/tests/transaction-logger.test.mjs b/worker-service/tests/transaction-logger.test.mjs new file mode 100644 index 0000000000..eea3aef9b3 --- /dev/null +++ b/worker-service/tests/transaction-logger.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { TransactionLogger } from '../dist/api/helpers/transaction-logger.js'; + +describe('TransactionLogger.getTransactionData', () => { + it('packages id/network/operator/transactionName/userId into a payload', () => { + const client = { operatorAccountId: { toString: () => '0.0.42' } }; + const result = TransactionLogger.getTransactionData( + 'tx-1', client, 'testnet', 'TokenCreateTransaction', 'user-7', + ); + assert.deepEqual(result, { + id: 'tx-1', + network: 'testnet', + operatorAccountId: '0.0.42', + transactionName: 'TokenCreateTransaction', + payload: { userId: 'user-7' }, + }); + }); + + it('handles null client (no operator account id)', () => { + const result = TransactionLogger.getTransactionData( + 'tx-2', null, 'mainnet', 'TopicCreateTransaction', null, + ); + assert.equal(result.operatorAccountId, undefined); + assert.equal(result.payload.userId, null); + }); +}); + +describe('TransactionLogger.getTransactionMetadata', () => { + it('returns "" for a null transaction', () => { + assert.equal( + TransactionLogger.getTransactionMetadata('TokenCreateTransaction', null), + '', + ); + }); + + it('embeds the txid for any transaction', () => { + const fakeTx = { transactionId: 'tx-9' }; + const out = TransactionLogger.getTransactionMetadata('Unknown', fakeTx); + assert.match(out, /txid: tx-9/); + }); + + it('reports key/name/symbol sizes for TokenCreateTransaction', () => { + const fakeTx = { + transactionId: 'tx-10', + adminKey: {}, + kycKey: null, + wipeKey: {}, + pauseKey: null, + supplyKey: {}, + freezeKey: null, + tokenName: 'Hello', + tokenSymbol: 'HEY', + tokenMemo: '', + transactionMemo: '', + }; + const out = TransactionLogger.getTransactionMetadata('TokenCreateTransaction', fakeTx); + assert.match(out, /admin keys: 1/); + assert.match(out, /KYC keys: 0/); + assert.match(out, /wipe keys: 1/); + assert.match(out, /pause keys: 0/); + assert.match(out, /supply keys: 1/); + assert.match(out, /freeze keys: 0/); + assert.match(out, /token name size: 5/); // 'Hello'.length === 5 + assert.match(out, /token symbol size: 3/); + }); +}); diff --git a/worker-service/tests/transaction-metadata-exhaustive.test.mjs b/worker-service/tests/transaction-metadata-exhaustive.test.mjs new file mode 100644 index 0000000000..702f0ee950 --- /dev/null +++ b/worker-service/tests/transaction-metadata-exhaustive.test.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { TransactionLogger } from '../dist/api/helpers/transaction-logger.js'; + +const tx = (extra = {}) => ({ transactionId: 'TID', transactionMemo: '', ...extra }); +const meta = (name, t, m) => TransactionLogger.getTransactionMetadata(name, t, m); + +const utf8 = (s) => Buffer.from(s, 'utf8').length; + +describe('TransactionLogger.getTransactionMetadata exact output (token/contract decision logic)', () => { + describe('guard branches', () => { + for (const falsy of [undefined, null, 0, '', false]) { + it(`returns '' for a falsy transaction (${JSON.stringify(falsy)})`, () => { + assert.equal(meta('TokenMintTransaction', falsy), ''); + }); + } + + it('prefixes txid for an unknown transaction name', () => { + assert.equal(meta('Nope', tx({ transactionId: 'AB' })), 'txid: AB; '); + }); + + for (const id of ['0.0.1@1', 'XYZ', 'a.b.c']) { + it(`reflects the transactionId ${id} in the prefix`, () => { + assert.equal(meta('Unknown', tx({ transactionId: id })), `txid: ${id}; `); + }); + } + }); + + describe('TokenCreateTransaction key flags', () => { + const keyNames = ['adminKey', 'kycKey', 'wipeKey', 'pauseKey', 'supplyKey', 'freezeKey']; + const labels = { + adminKey: 'admin keys', + kycKey: 'KYC keys', + wipeKey: 'wipe keys', + pauseKey: 'pause keys', + supplyKey: 'supply keys', + freezeKey: 'freeze keys', + }; + + it('all keys absent -> all flags 0', () => { + const out = meta('TokenCreateTransaction', tx()); + for (const n of keyNames) { + assert.match(out, new RegExp(`${labels[n]}: 0; `)); + } + assert.match(out, /payer sigs: 1; /); + }); + + for (const present of keyNames) { + it(`only ${present} present -> ${labels[present]} is 1, rest 0`, () => { + const out = meta('TokenCreateTransaction', tx({ [present]: {} })); + for (const n of keyNames) { + const expected = n === present ? 1 : 0; + assert.match(out, new RegExp(`${labels[n]}: ${expected}; `)); + } + }); + } + + it('all keys present -> all flags 1', () => { + const present = {}; + for (const n of keyNames) { + present[n] = {}; + } + const out = meta('TokenCreateTransaction', tx(present)); + for (const n of keyNames) { + assert.match(out, new RegExp(`${labels[n]}: 1; `)); + } + }); + + for (const [name, symbol, memo] of [ + ['Tok', 'TK', ''], + ['LongerName', 'SYMB', 'a memo'], + ['', '', ''], + ['émojiنname', 'ÜP', 'çé'], + ]) { + it(`reports byte sizes for name='${name}' symbol='${symbol}' memo='${memo}'`, () => { + const out = meta('TokenCreateTransaction', tx({ tokenName: name, tokenSymbol: symbol, tokenMemo: memo })); + assert.match(out, new RegExp(`token name size: ${utf8(name)}; `)); + assert.match(out, new RegExp(`token symbol size: ${utf8(symbol)}; `)); + assert.match(out, new RegExp(`token memo size: ${utf8(memo)}; `)); + }); + } + }); + + describe('simple sigs+memo token transactions exact string', () => { + const simpleTwoSig = [ + 'TokenAssociateTransaction', + 'TokenDissociateTransaction', + 'TokenFreezeTransaction', + 'TokenUnfreezeTransaction', + 'TokenGrantKycTransaction', + 'TokenRevokeKycTransaction', + 'TokenWipeTransaction', + 'AccountCreateTransaction', + ]; + + const extraLine = { + TokenAssociateTransaction: 'tokens associated: 1; ', + TokenDissociateTransaction: 'tokens dissociated: 1; ', + }; + + for (const name of simpleTwoSig) { + for (const memo of ['', 'hello', 'mémo']) { + it(`${name} memo='${memo}' exact output`, () => { + const out = meta(name, tx({ transactionId: 'X', transactionMemo: memo })); + let expected = 'txid: X; payer sigs: 1; total sigs: 1; '; + if (extraLine[name]) { + expected = `txid: X; payer sigs: 1; total sigs: 1; ${extraLine[name]}`; + } + expected += `memo size: ${utf8(memo)}; `; + assert.equal(out, expected); + }); + } + } + }); + + describe('TokenUpdate / TokenDelete (payer sig + memo only)', () => { + for (const name of ['TokenUpdateTransaction', 'TokenDeleteTransaction']) { + for (const memo of ['', 'm', 'multi byte é']) { + it(`${name} memo='${memo}' exact output`, () => { + const out = meta(name, tx({ transactionId: 'U', transactionMemo: memo })); + assert.equal(out, `txid: U; payer sigs: 1; memo size: ${utf8(memo)}; `); + }); + } + } + }); + + describe('TokenMintTransaction (fungible)', () => { + for (const memo of ['', 'mint', 'çmint']) { + it(`fungible mint memo='${memo}' exact output`, () => { + const out = meta('TokenMintTransaction', tx({ transactionId: 'M', transactionMemo: memo })); + assert.equal(out, `txid: M; Fungible Token; payer sigs: 1; total sigs: 1; memo size: ${utf8(memo)}; `); + }); + } + }); + + describe('TokenMintNFTTransaction (non-fungible serial/byte math)', () => { + const cases = [ + [[new Uint8Array([1])], 1, 1], + [[new Uint8Array([1, 2, 3])], 1, 3], + [[new Uint8Array([1, 2]), new Uint8Array([3, 4])], 2, 2], + [[new Uint8Array(10), new Uint8Array(10), new Uint8Array(10)], 3, 10], + ]; + for (const [mdata, count, firstLen] of cases) { + it(`${count} NFT(s), first metadata ${firstLen} bytes`, () => { + const out = meta('TokenMintNFTTransaction', tx({ transactionId: 'N', metadata: mdata })); + assert.match(out, /Non-Fungible Token; /); + assert.match(out, new RegExp(`of NFTs minted: ${count};`)); + assert.match(out, new RegExp(`bytes of metadata per NFT: ${firstLen};`)); + }); + } + + it('counts NFTs independent of per-item size', () => { + const out = meta('TokenMintNFTTransaction', tx({ + metadata: [new Uint8Array(5), new Uint8Array(99), new Uint8Array(1), new Uint8Array(2), new Uint8Array(3)], + })); + assert.match(out, /of NFTs minted: 5;/); + assert.match(out, /bytes of metadata per NFT: 5;/); + }); + }); + + describe('TransferTransaction (fungible amount math)', () => { + for (const amount of [0, 1, 42, 1000000, -5, '7', 'abc']) { + it(`amount=${JSON.stringify(amount)} echoed into output`, () => { + const out = meta('TransferTransaction', tx({ transactionId: 'F' }), amount); + assert.match(out, /Fungible Token; /); + assert.match(out, new RegExp(`amount: ${amount}; `)); + }); + } + + it('exact fungible transfer string', () => { + const out = meta('TransferTransaction', tx({ transactionId: 'F', transactionMemo: 'mm' }), 99); + assert.equal(out, 'txid: F; Fungible Token; payer sigs: 1; total sigs: 1; amount: 99; memo size: 2; '); + }); + }); + + describe('NFTTransferTransaction (serial-count via stringSize of metadata)', () => { + for (const [m, size] of [['a', 1], ['abc', 3], ['', 0], ['héllo', utf8('héllo')]]) { + it(`metadata='${m}' -> of NFTs transferred: ${size}`, () => { + const out = meta('NFTTransferTransaction', tx({ transactionId: 'NT' }), m); + assert.match(out, /Non-Fungible Token; /); + assert.match(out, new RegExp(`of NFTs transferred: ${size}; `)); + }); + } + + it('counts bytes for a Uint8Array metadata', () => { + const out = meta('NFTTransferTransaction', tx(), new Uint8Array([1, 2, 3, 4])); + assert.match(out, /of NFTs transferred: 4; /); + }); + }); + + describe('TopicCreateTransaction key flags + memo sizes', () => { + const matrix = [ + [{}, {}, 1, 1], + [{}, null, 1, 0], + [null, {}, 0, 1], + [null, null, 0, 0], + ]; + for (const [adminKey, submitKey, a, s] of matrix) { + it(`admin=${a} submit=${s}`, () => { + const out = meta('TopicCreateTransaction', tx({ adminKey, submitKey, topicMemo: 'memo' })); + assert.match(out, new RegExp(`admin keys: ${a}; `)); + assert.match(out, new RegExp(`submit keys: ${s}; `)); + assert.match(out, /topic memo size: 4; /); + }); + } + + for (const tm of ['', 'topic', 'çtopic']) { + it(`topic memo size for '${tm}'`, () => { + const out = meta('TopicCreateTransaction', tx({ topicMemo: tm })); + assert.match(out, new RegExp(`topic memo size: ${utf8(tm)}; `)); + }); + } + }); + + describe('TopicMessageSubmitTransaction message size', () => { + for (const msg of ['', 'x', 'hello world', 'çé', 'a'.repeat(50)]) { + it(`message size for length ${msg.length}`, () => { + const out = meta('TopicMessageSubmitTransaction', tx({ message: msg })); + assert.match(out, new RegExp(`message size: ${utf8(msg)}; `)); + }); + } + }); + + describe('memo byte-size accounting across token types', () => { + for (const [memo, bytes] of [['', 0], ['a', 1], ['ab', 2], ['é', 2], ['🙂', 4], ['aé🙂', 7]]) { + it(`TokenWipeTransaction memo '${memo}' -> ${bytes} bytes`, () => { + const out = meta('TokenWipeTransaction', tx({ transactionMemo: memo })); + assert.match(out, new RegExp(`memo size: ${bytes}; `)); + }); + } + }); +}); + +describe('TransactionLogger.getTransactionData (pure payload shaping)', () => { + it('builds the canonical log payload with operator account id', () => { + const client = { operatorAccountId: { toString: () => '0.0.42' } }; + const out = TransactionLogger.getTransactionData('id-1', client, 'testnet', 'TokenMintTransaction', 'user-1'); + assert.deepEqual(out, { + id: 'id-1', + network: 'testnet', + operatorAccountId: '0.0.42', + transactionName: 'TokenMintTransaction', + payload: { userId: 'user-1' }, + }); + }); + + it('tolerates a null client (operatorAccountId undefined)', () => { + const out = TransactionLogger.getTransactionData('id-2', null, 'mainnet', 'TransferTransaction', null); + assert.equal(out.operatorAccountId, undefined); + assert.equal(out.network, 'mainnet'); + assert.deepEqual(out.payload, { userId: null }); + }); + + it('tolerates a client without operatorAccountId', () => { + const out = TransactionLogger.getTransactionData('id-3', {}, 'testnet', 'TokenWipeTransaction', 'u'); + assert.equal(out.operatorAccountId, undefined); + }); + + for (const net of ['testnet', 'mainnet', 'previewnet', 'localnode']) { + it(`passes through network '${net}'`, () => { + const out = TransactionLogger.getTransactionData('i', null, net, 'X', 'u'); + assert.equal(out.network, net); + }); + } +});