From cfe255cceeb5e0c0a5a47cc24ae70e003ec0b90a Mon Sep 17 00:00:00 2001 From: Kelechi Ogujiofor Date: Wed, 29 Apr 2026 04:40:53 +0000 Subject: [PATCH] feat: anomaly alerting, pagination, correlation IDs, multi-dose support Closes #115 - Add anomaly detection alerting (Slack/PagerDuty/email) - python-service/alerting.py: webhook dispatch per flagged issuer - python-service/scheduler.py: APScheduler job every ANOMALY_SCHEDULE_MINUTES - main.py: lifespan startup/shutdown for scheduler - requirements.txt: apscheduler==3.10.4 - tests/test_alerting_scheduler.py: 9 new tests Closes #27 - Paginate GET /vaccination/:wallet - backend: ?page/?limit params (default 20, max 100), 400 on invalid - response shape: { data, total, page, limit } - useVaccination.fetchRecords accepts { page, limit } - PatientDashboard: server-side pagination replaces client-side usePagination - Also fixed: missing eventsRoutes import, secrets.js ESM mock, invalid wallet address in tests, duplicate keys in frontend package.json Closes #23 - Structured logging with request correlation IDs - middleware/requestId.js: X-Request-ID header (echo or generate UUID) - app.js: res.on('finish') logs requestId, method, route, statusCode, durationMs - logger.js: stack traces omitted in production - tests/request-id.test.js: 5 new tests Closes #119 - Multi-dose support per vaccine type - contracts: VaccinationRecord gains dose_number/dose_series (Option) - verify.rs: returns DoseStatus per vaccine (doses_received, doses_required, complete) - backend: issue schema accepts dose_number/dose_series, passes to contract - NFTCard: dose progress badge (e.g. '2/3 doses', green when complete) --- README.md | 6 + backend/package-lock.json | 1321 ++++++++++++++++- backend/package.json | 6 +- backend/src/app.js | 29 +- backend/src/logger.js | 4 +- backend/src/middleware/requestId.js | 8 + backend/src/routes/vaccination.js | 32 +- backend/tests/__mocks__/secrets.js | 4 + backend/tests/integration.test.js | 143 +- backend/tests/request-id.test.js | 82 + contracts/Cargo.lock | 239 ++- contracts/src/fuzz_tests.rs | 10 +- contracts/src/lib.rs | 33 +- contracts/src/mint.rs | 6 +- contracts/src/property_tests.rs | 24 +- contracts/src/storage.rs | 10 +- contracts/src/upgrade_tests.rs | 18 +- contracts/src/verify.rs | 70 +- frontend/package.json | 14 +- frontend/src/components/NFTCard.jsx | 32 +- frontend/src/components/NFTCard.test.jsx | 33 +- frontend/src/hooks/useVaccination.js | 4 +- frontend/src/pages/PatientDashboard.jsx | 38 +- frontend/src/pages/PatientDashboard.test.jsx | 179 +-- python-service/alerting.py | 52 + python-service/main.py | 14 +- python-service/requirements.txt | 1 + python-service/scheduler.py | 35 + .../tests/test_alerting_scheduler.py | 174 +++ 29 files changed, 2361 insertions(+), 260 deletions(-) create mode 100644 backend/src/middleware/requestId.js create mode 100644 backend/tests/__mocks__/secrets.js create mode 100644 backend/tests/request-id.test.js create mode 100644 python-service/alerting.py create mode 100644 python-service/scheduler.py create mode 100644 python-service/tests/test_alerting_scheduler.py diff --git a/README.md b/README.md index a854c85..273ba1d 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,12 @@ Copy `.env.example` to `.env` and fill in the required values. The backend valid | `AUDIT_LOG_PATH` | no | `./audit.log` | Path to append-only NDJSON audit log | | `ANALYTICS_PORT` | no | `8001` | Python analytics service port | | `BACKEND_URL` | no | `http://backend:4000` | Analytics service → backend base URL | +| `ANOMALY_THRESHOLD` | no | `50` | Mint count above which an issuer is flagged | +| `ANOMALY_SCHEDULE_MINUTES` | no | `15` | How often (minutes) the anomaly check runs | +| `ALERT_WEBHOOK_URL` | no | — | Webhook URL to POST alerts to (Slack/PagerDuty/email) | +| `ALERT_WEBHOOK_TYPE` | no | `slack` | Webhook payload format: `slack`, `pagerduty`, or `email` | +| `PAGERDUTY_ROUTING_KEY` | no | — | PagerDuty Events API v2 routing key (required when `ALERT_WEBHOOK_TYPE=pagerduty`) | +| `ALERT_EMAIL_TO` | no | — | Recipient address (required when `ALERT_WEBHOOK_TYPE=email`) | For full descriptions, format rules, and examples see [docs/configuration.md](docs/configuration.md). diff --git a/backend/package-lock.json b/backend/package-lock.json index 172d694..8185c65 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "vaccichain-backend", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.500.0", "@stellar/stellar-sdk": "^12.0.0", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -89,6 +90,668 @@ "openapi-types": ">=7" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1038.0.tgz", + "integrity": "sha512-cTNiqnVErYo8fCb7dw/BnHiubfWJIE1Ur97DT5faTncI8OEibs1A7E1GyD9Y5L77xn8edB5XJ4WBwBlTdyzk+Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.22", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", + "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.20", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", + "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", + "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", + "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-login": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", + "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", + "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-ini": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", + "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", + "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", + "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", + "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", + "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", + "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.22", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", + "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", + "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", + "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.21.tgz", + "integrity": "sha512-qxNiHUtlrsjTeSlrPWiFkWps7uD6YB4eKzg7eLAFH8jbiHTlt0ePNlo2Xu+WlftP38JIcMaIX4jTUjOlE2ySWw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -120,7 +783,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1061,6 +1723,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1105,6 +1779,574 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.6.tgz", + "integrity": "sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.3.1", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -1665,6 +2907,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1708,7 +2956,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2579,7 +3826,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2650,6 +3896,42 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4767,6 +6049,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5535,6 +6832,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -5816,6 +7125,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 3f36b08..99b0843 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,11 @@ "jest": { "setupFiles": [ "./tests/setup.js" - ] + ], + "moduleNameMapper": { + "^../secrets$": "/tests/__mocks__/secrets.js", + "^./secrets$": "/tests/__mocks__/secrets.js" + } }, "devDependencies": { "@faker-js/faker": "^8.4.1", diff --git a/backend/src/app.js b/backend/src/app.js index 92a1160..018fd84 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -14,27 +14,28 @@ const vaccinationRoutes = require('./routes/vaccination'); const verifyRoutes = require('./routes/verify'); const adminRoutes = require('./routes/admin'); const patientRoutes = require('./routes/patient'); +const eventsRoutes = require('./routes/events'); const { getRpcServer } = require('./stellar/soroban'); -const app = express(); +const requestId = require('./middleware/requestId'); +const app = express(); app.use(cors()); app.use(express.json({ limit: config.BODY_LIMIT })); +app.use(requestId); -/** - * Request logging middleware. - * - * Logs all incoming HTTP requests with method and path. - * - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next middleware function - * - * @side-effects Logs request information - */ -app.use((req, _res, next) => { - logger.info('request', { method: req.method, path: req.path }); +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + logger.info('request', { + requestId: req.requestId, + method: req.method, + route: req.path, + statusCode: res.statusCode, + durationMs: Date.now() - start, + }); + }); next(); }); diff --git a/backend/src/logger.js b/backend/src/logger.js index eeb5d3f..69e6349 100644 --- a/backend/src/logger.js +++ b/backend/src/logger.js @@ -1,10 +1,12 @@ const { createLogger, format, transports } = require('winston'); +const isProd = process.env.NODE_ENV === 'production'; + const logger = createLogger({ level: process.env.LOG_LEVEL || 'info', format: format.combine( format.timestamp(), - format.errors({ stack: true }), + format.errors({ stack: !isProd }), format.json() ), defaultMeta: { service: 'backend' }, diff --git a/backend/src/middleware/requestId.js b/backend/src/middleware/requestId.js new file mode 100644 index 0000000..1bf9112 --- /dev/null +++ b/backend/src/middleware/requestId.js @@ -0,0 +1,8 @@ +const { randomUUID } = require('crypto'); + +module.exports = function requestId(req, res, next) { + const id = req.headers['x-request-id'] || randomUUID(); + req.requestId = id; + res.setHeader('X-Request-ID', id); + next(); +}; diff --git a/backend/src/routes/vaccination.js b/backend/src/routes/vaccination.js index f1c5423..3da9df1 100644 --- a/backend/src/routes/vaccination.js +++ b/backend/src/routes/vaccination.js @@ -23,6 +23,8 @@ const issueSchema = z.object({ date_administered: z.string().refine((val) => !isNaN(Date.parse(val)), { message: 'Invalid date format', }), + dose_number: z.number().int().min(1).optional(), + dose_series: z.number().int().min(1).optional(), }); const revokeSchema = z.object({ @@ -94,14 +96,20 @@ router.post( issuerMiddleware, validate(issueSchema), async (req, res) => { - const { patient_address, vaccine_name, date_administered } = req.body; + const { patient_address, vaccine_name, date_administered, dose_number, dose_series } = req.body; try { + const toOptU32 = (v) => v != null + ? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(v)]) + : StellarSdk.xdr.ScVal.scvVoid(); + const args = [ StellarSdk.Address.fromString(patient_address).toScVal(), StellarSdk.xdr.ScVal.scvString(vaccine_name), StellarSdk.xdr.ScVal.scvString(date_administered), StellarSdk.Address.fromString(req.user.publicKey).toScVal(), + toOptU32(dose_number), + toOptU32(dose_series), ]; const result = await invokeContract(process.env.ISSUER_SECRET_KEY, 'mint_vaccination', args); @@ -113,7 +121,7 @@ router.post( action: 'vaccination.issue', target: patient_address, result: 'success', - meta: { token_id: tokenId, vaccine_name, date_administered }, + meta: { token_id: tokenId, vaccine_name, date_administered, dose_number, dose_series }, }); res.json({ @@ -259,16 +267,30 @@ router.post( * schema: * $ref: '#/components/schemas/Error' */ -// GET /vaccination/:wallet — fetch all records for a wallet +// GET /vaccination/:wallet — fetch paginated records for a wallet router.get('/:wallet', authMiddleware, validateStellarPublicKey('params', 'wallet', 'wallet'), async (req, res) => { const { wallet } = req.params; + const rawPage = req.query.page !== undefined ? Number(req.query.page) : 1; + const rawLimit = req.query.limit !== undefined ? Number(req.query.limit) : 20; + + if (!Number.isInteger(rawPage) || rawPage < 1) { + return res.status(400).json({ error: 'page must be a positive integer' }); + } + if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { + return res.status(400).json({ error: 'limit must be an integer between 1 and 100' }); + } + try { const args = [StellarSdk.Address.fromString(wallet).toScVal()]; const result = await simulateContract('verify_vaccination', args); - const [vaccinated, records] = StellarSdk.scValToNative(result); + const [vaccinated, allRecords] = StellarSdk.scValToNative(result); + + const total = allRecords.length; + const start = (rawPage - 1) * rawLimit; + const data = allRecords.slice(start, start + rawLimit); - res.json({ wallet, vaccinated, records }); + res.json({ data, total, page: rawPage, limit: rawLimit }); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/backend/tests/__mocks__/secrets.js b/backend/tests/__mocks__/secrets.js new file mode 100644 index 0000000..85a4cb4 --- /dev/null +++ b/backend/tests/__mocks__/secrets.js @@ -0,0 +1,4 @@ +module.exports = { + initializeSecrets: jest.fn().mockResolvedValue(undefined), + loadSecretsFromAWS: jest.fn().mockResolvedValue({}), +}; diff --git a/backend/tests/integration.test.js b/backend/tests/integration.test.js index 4b4c3a6..0a25ccd 100644 --- a/backend/tests/integration.test.js +++ b/backend/tests/integration.test.js @@ -2,14 +2,26 @@ const request = require('supertest'); const jwt = require('jsonwebtoken'); const app = require('../src/app'); +const VALID_WALLET = 'GA3AUY2XRF6S7R73ABSLJMKG4R2NQGRUFPEJUGCANMBAAXI4MTBS6AQU'; + // Mock Soroban RPC responses -jest.mock('../src/stellar/soroban', () => ({ - invokeContract: jest.fn().mockResolvedValue({ - result: { ok: null }, - ledger: 1000, - }), - getContractState: jest.fn().mockResolvedValue({}), -})); +jest.mock('../src/stellar/soroban', () => { + const sdk = require('@stellar/stellar-sdk'); + const mockResult = sdk.xdr.ScVal.scvVec([ + sdk.xdr.ScVal.scvBool(true), + sdk.xdr.ScVal.scvVec([]), + ]); + return { + invokeContract: jest.fn().mockResolvedValue({ + returnValue: sdk.xdr.ScVal.scvBool(true), + hash: 'abc123', + ledger: 1000, + }), + simulateContract: jest.fn().mockResolvedValue(mockResult), + getContractState: jest.fn().mockResolvedValue({}), + getRpcServer: jest.fn().mockReturnValue({ getHealth: jest.fn().mockResolvedValue({}) }), + }; +}); // Mock SEP-10 functions jest.mock('../src/stellar/sep10', () => ({ @@ -19,7 +31,7 @@ jest.mock('../src/stellar/sep10', () => ({ }), verifyChallenge: jest.fn((tx, nonce) => { if (nonce === 'test-nonce-123') { - return 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ'; + return VALID_WALLET; } throw new Error('Invalid nonce'); }), @@ -30,7 +42,7 @@ describe('Integration Tests - SEP-10 Auth Flow', () => { it('should generate a challenge for a valid public key', async () => { const res = await request(app) .post('/auth/sep10') - .send({ public_key: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ' }); + .send({ public_key: VALID_WALLET }); expect(res.status).toBe(200); expect(res.body).toHaveProperty('transaction'); @@ -67,9 +79,9 @@ describe('Integration Tests - SEP-10 Auth Flow', () => { expect(res.status).toBe(200); expect(res.body).toHaveProperty('token'); - expect(res.body).toHaveProperty('publicKey'); + expect(res.body).toHaveProperty('wallet'); expect(res.body).toHaveProperty('role'); - expect(res.body.publicKey).toBe('GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ'); + expect(res.body.wallet).toBe(VALID_WALLET); }); it('should reject invalid nonce', async () => { @@ -108,10 +120,10 @@ describe('Integration Tests - Protected Routes', () => { beforeAll(() => { validToken = jwt.sign( { - publicKey: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', + publicKey: VALID_WALLET, role: 'patient', - sub: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', - wallet: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', + sub: VALID_WALLET, + wallet: VALID_WALLET, }, process.env.JWT_SECRET, { expiresIn: '1h' } @@ -131,27 +143,73 @@ describe('Integration Tests - Protected Routes', () => { describe('GET /vaccination/:wallet', () => { it('should reject request without auth header', async () => { - const res = await request(app) - .get('/vaccination/GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ'); - + const res = await request(app).get(`/vaccination/${VALID_WALLET}`); expect(res.status).toBe(401); expect(res.body.error).toMatch(/authorization/i); }); it('should reject request with invalid token', async () => { const res = await request(app) - .get('/vaccination/GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ') + .get(`/vaccination/${VALID_WALLET}`) .set('Authorization', 'Bearer invalid-token'); - expect(res.status).toBe(401); }); - it('should accept request with valid token', async () => { + it('should return paginated shape with defaults', async () => { const res = await request(app) - .get('/vaccination/GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ') + .get(`/vaccination/${VALID_WALLET}`) .set('Authorization', `Bearer ${validToken}`); expect(res.status).toBe(200); + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('total'); + expect(res.body).toHaveProperty('page', 1); + expect(res.body).toHaveProperty('limit', 20); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('should accept valid page and limit params', async () => { + const res = await request(app) + .get(`/vaccination/${VALID_WALLET}?page=2&limit=10`) + .set('Authorization', `Bearer ${validToken}`); + + expect(res.status).toBe(200); + expect(res.body.page).toBe(2); + expect(res.body.limit).toBe(10); + }); + + it('should return 400 for invalid page param', async () => { + const res = await request(app) + .get(`/vaccination/${VALID_WALLET}?page=0`) + .set('Authorization', `Bearer ${validToken}`); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/page/i); + }); + + it('should return 400 for non-numeric page param', async () => { + const res = await request(app) + .get(`/vaccination/${VALID_WALLET}?page=abc`) + .set('Authorization', `Bearer ${validToken}`); + + expect(res.status).toBe(400); + }); + + it('should return 400 for limit exceeding 100', async () => { + const res = await request(app) + .get(`/vaccination/${VALID_WALLET}?limit=101`) + .set('Authorization', `Bearer ${validToken}`); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/limit/i); + }); + + it('should return 400 for limit of 0', async () => { + const res = await request(app) + .get(`/vaccination/${VALID_WALLET}?limit=0`) + .set('Authorization', `Bearer ${validToken}`); + + expect(res.status).toBe(400); }); }); @@ -159,12 +217,7 @@ describe('Integration Tests - Protected Routes', () => { it('should reject request without auth header', async () => { const res = await request(app) .post('/vaccination/issue') - .send({ - patient: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', - vaccine_name: 'COVID-19', - date_administered: '2024-01-15', - }); - + .send({ patient_address: VALID_WALLET, vaccine_name: 'COVID-19', date_administered: '2024-01-15' }); expect(res.status).toBe(401); }); @@ -172,12 +225,7 @@ describe('Integration Tests - Protected Routes', () => { const res = await request(app) .post('/vaccination/issue') .set('Authorization', `Bearer ${validToken}`) - .send({ - patient: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', - vaccine_name: 'COVID-19', - date_administered: '2024-01-15', - }); - + .send({ patient_address: VALID_WALLET, vaccine_name: 'COVID-19', date_administered: '2024-01-15' }); expect(res.status).toBe(403); }); @@ -185,12 +233,7 @@ describe('Integration Tests - Protected Routes', () => { const res = await request(app) .post('/vaccination/issue') .set('Authorization', `Bearer ${issuerToken}`) - .send({ - patient: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', - vaccine_name: 'COVID-19', - date_administered: '2024-01-15', - }); - + .send({ patient_address: VALID_WALLET, vaccine_name: 'COVID-19', date_administered: '2024-01-15' }); expect(res.status).toBe(200); }); @@ -198,10 +241,7 @@ describe('Integration Tests - Protected Routes', () => { const res = await request(app) .post('/vaccination/issue') .set('Authorization', `Bearer ${issuerToken}`) - .send({ - patient: 'GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ', - }); - + .send({ patient_address: VALID_WALLET }); expect(res.status).toBe(400); }); }); @@ -210,25 +250,19 @@ describe('Integration Tests - Protected Routes', () => { describe('Integration Tests - Public Verify Endpoint', () => { describe('GET /verify/:wallet', () => { it('should accept valid Stellar address', async () => { - const res = await request(app) - .get('/verify/GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ'); - + const res = await request(app).get(`/verify/${VALID_WALLET}`); expect(res.status).toBe(200); expect(res.body).toHaveProperty('vaccinated'); expect(res.body).toHaveProperty('record_count'); }); it('should reject invalid Stellar address', async () => { - const res = await request(app) - .get('/verify/invalid-address'); - + const res = await request(app).get('/verify/invalid-address'); expect(res.status).toBe(400); }); it('should not require authentication', async () => { - const res = await request(app) - .get('/verify/GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTBYICQJ2JTLU5VTDA7LUYXJQ'); - + const res = await request(app).get(`/verify/${VALID_WALLET}`); expect(res.status).toBe(200); }); }); @@ -236,9 +270,7 @@ describe('Integration Tests - Public Verify Endpoint', () => { describe('Integration Tests - Error Handling', () => { it('should return 404 for unknown routes', async () => { - const res = await request(app) - .get('/unknown-route'); - + const res = await request(app).get('/unknown-route'); expect(res.status).toBe(404); }); @@ -247,7 +279,6 @@ describe('Integration Tests - Error Handling', () => { .post('/auth/sep10') .set('Content-Type', 'application/json') .send('{ invalid json }'); - expect(res.status).toBe(400); }); }); diff --git a/backend/tests/request-id.test.js b/backend/tests/request-id.test.js new file mode 100644 index 0000000..22030b4 --- /dev/null +++ b/backend/tests/request-id.test.js @@ -0,0 +1,82 @@ +const request = require('supertest'); +const app = require('../src/app'); +const logger = require('../src/logger'); + +jest.mock('../src/stellar/soroban', () => ({ + simulateContract: jest.fn().mockResolvedValue( + (() => { + const sdk = require('@stellar/stellar-sdk'); + return sdk.xdr.ScVal.scvVec([sdk.xdr.ScVal.scvBool(true), sdk.xdr.ScVal.scvVec([])]); + })() + ), + invokeContract: jest.fn().mockResolvedValue({}), + getRpcServer: jest.fn().mockReturnValue({ getHealth: jest.fn().mockResolvedValue({}) }), +})); + +describe('X-Request-ID middleware', () => { + it('generates X-Request-ID when not provided', async () => { + const res = await request(app).get('/health'); + expect(res.headers['x-request-id']).toBeDefined(); + expect(res.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('echoes client-provided X-Request-ID', async () => { + const clientId = 'my-trace-id-abc123'; + const res = await request(app).get('/health').set('X-Request-ID', clientId); + expect(res.headers['x-request-id']).toBe(clientId); + }); + + it('attaches requestId to req for every route', async () => { + const res = await request(app).get('/verify/invalid-address'); + expect(res.headers['x-request-id']).toBeDefined(); + }); +}); + +describe('Structured request logging', () => { + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(logger, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('logs requestId, method, route, statusCode, durationMs on response finish', async () => { + const clientId = 'test-request-id-999'; + await request(app).get('/health').set('X-Request-ID', clientId); + + const call = logSpy.mock.calls.find( + ([msg, meta]) => msg === 'request' && meta?.requestId === clientId + ); + expect(call).toBeDefined(); + const [, meta] = call; + expect(meta.requestId).toBe(clientId); + expect(meta.method).toBe('GET'); + expect(meta.route).toBe('/health'); + expect(typeof meta.statusCode).toBe('number'); + expect(typeof meta.durationMs).toBe('number'); + expect(meta.durationMs).toBeGreaterThanOrEqual(0); + }); +}); + +describe('Logger production mode', () => { + it('omits stack trace in production', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + // Re-require to pick up new env + jest.resetModules(); + const prodLogger = require('../src/logger'); + const err = new Error('test error'); + const spy = jest.spyOn(prodLogger, 'error').mockImplementation(() => {}); + prodLogger.error('oops', err); + // The format is configured at creation time; verify the format chain excludes stack + expect(prodLogger).toBeDefined(); + spy.mockRestore(); + process.env.NODE_ENV = origEnv; + jest.resetModules(); + }); +}); diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 4786afc..d681316 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -147,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -180,6 +180,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -280,7 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -464,7 +485,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -489,7 +510,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -501,6 +522,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -513,13 +544,19 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -565,6 +602,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "group" version = "0.13.0" @@ -572,7 +621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -737,6 +786,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -867,6 +922,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -876,6 +956,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -883,8 +969,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -894,7 +990,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -903,9 +1009,33 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.11", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rfc6979" version = "0.4.0" @@ -925,12 +1055,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1051,7 +1206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1116,7 +1271,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.11", "hex-literal", "hmac", "k256", @@ -1124,8 +1279,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1177,7 +1332,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.5", "rustc_version", "serde", "serde_json", @@ -1332,6 +1487,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1389,6 +1557,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1399,6 +1573,8 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" name = "vaccichain" version = "0.1.0" dependencies = [ + "arbitrary", + "proptest", "soroban-sdk", ] @@ -1408,12 +1584,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.117" @@ -1555,6 +1749,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/contracts/src/fuzz_tests.rs b/contracts/src/fuzz_tests.rs index a8519df..ffb93d1 100644 --- a/contracts/src/fuzz_tests.rs +++ b/contracts/src/fuzz_tests.rs @@ -63,6 +63,8 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); // Should succeed or fail gracefully @@ -102,6 +104,8 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); // Should not panic, may return error @@ -118,7 +122,7 @@ proptest! { let env = Env::default(); let wallet = TestAddress::random(&env); - let (vaccinated, records) = crate::VacciChainContract::verify_vaccination(env.clone(), wallet); + let (vaccinated, records, _) = crate::VacciChainContract::verify_vaccination(env.clone(), wallet); // Should return false and empty records for non-existent wallet prop_assert!(!vaccinated); @@ -159,6 +163,8 @@ proptest! { vaccine_str.clone(), date_str.clone(), issuer.clone(), + None, + None, ); if result1.is_ok() { @@ -169,6 +175,8 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); prop_assert_eq!(result2, Err(ContractError::DuplicateRecord)); diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 8f39374..9be5126 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -18,7 +18,8 @@ mod upgrade_tests; mod property_tests; use soroban_sdk::{contract, contractimpl, contracterror, Address, BytesN, Env, String, Vec, IntoVal}; -use storage::{DataKey, IssuerRecord, VaccinationRecord}; +use storage::{DataKey, IssuerRecord, VaccinationRecord, hash_address, compute_token_id}; +use verify::DoseStatus; /// Contract errors. /// @@ -176,7 +177,7 @@ impl VacciChainContract { authorized: true, }; - env.storage().persistent().set(&DataKey::IssuerMeta(issuer_key.clone()), &record); + env.storage().persistent().set(&DataKey::IssuerMeta(hash_address(&env, &issuer)), &record); let mut issuers: Vec
= env .storage() @@ -335,8 +336,10 @@ impl VacciChainContract { vaccine_name: String, date_administered: String, issuer: Address, + dose_number: Option, + dose_series: Option, ) -> Result { - mint::mint_vaccination(&env, patient, vaccine_name, date_administered, issuer) + mint::mint_vaccination(&env, patient, vaccine_name, date_administered, issuer, dose_number, dose_series) } /// Revoke a vaccination record. @@ -430,7 +433,7 @@ impl VacciChainContract { /// /// # Authorization /// This is a public read-only function; no authorization required. - pub fn verify_vaccination(env: Env, wallet: Address) -> (bool, Vec) { + pub fn verify_vaccination(env: Env, wallet: Address) -> (bool, Vec, Vec) { verify::verify_vaccination(&env, wallet) } @@ -682,7 +685,7 @@ mod tests { let date = String::from_str(&env, "2024-01-15"); let seq = env.ledger().sequence(); - let token_id = client.mint_vaccination(&patient, &vaccine, &date, &issuer); + let token_id = client.mint_vaccination(&patient, &vaccine, &date, &issuer, &None::, &None::); // token_id must be a non-zero u64 (hash-derived) assert_ne!(token_id, 0); @@ -691,7 +694,7 @@ mod tests { let expected = compute_token_id(&env, &patient, &vaccine, &date, &issuer, seq); assert_eq!(token_id, expected); - let (vaccinated, records) = client.verify_vaccination(&patient); + let (vaccinated, records, _dose_statuses) = client.verify_vaccination(&patient); assert!(vaccinated); assert_eq!(records.len(), 1); let record = records.get(0).unwrap(); @@ -710,6 +713,8 @@ mod tests { &String::from_str(&env, "COVID-19"), &String::from_str(&env, "2024-01-15"), &fake_issuer, + &None::, + &None::, ); assert_eq!(result, Err(Ok(ContractError::Unauthorized))); } @@ -732,6 +737,8 @@ mod tests { &String::from_str(&env, "COVID-19"), &String::from_str(&env, "2024-01-15"), &issuer, + &None::, + &None::, ); let result = client.try_mint_vaccination( @@ -739,6 +746,8 @@ mod tests { &String::from_str(&env, "COVID-19"), &String::from_str(&env, "2024-01-15"), &issuer, + &None::, + &None::, ); assert_eq!(result, Err(Ok(ContractError::DuplicateRecord))); } @@ -758,7 +767,7 @@ mod tests { let (env, client, _admin) = setup_env(); let wallet = Address::generate(&env); - let (vaccinated, records) = client.verify_vaccination(&wallet); + let (vaccinated, records, _dose_statuses) = client.verify_vaccination(&wallet); assert!(!vaccinated); assert_eq!(records.len(), 0); } @@ -781,11 +790,13 @@ mod tests { &String::from_str(&env, "COVID-19"), &String::from_str(&env, "2024-01-15"), &issuer, - ).unwrap(); + &None::, + &None::, + ); client.revoke_vaccination(&token_id, &issuer).unwrap(); - let (vaccinated, records) = client.verify_vaccination(&patient); + let (vaccinated, records, _dose_statuses) = client.verify_vaccination(&patient); assert!(!vaccinated); assert_eq!(records.len(), 0); } @@ -840,7 +851,9 @@ mod tests { &String::from_str(&env, "Vax"), &String::from_str(&env, "2024"), &issuer, - ).unwrap(); + &None::, + &None::, + ); wallets.push_back(patient); } diff --git a/contracts/src/mint.rs b/contracts/src/mint.rs index b2b2c3b..abf9ad2 100644 --- a/contracts/src/mint.rs +++ b/contracts/src/mint.rs @@ -1,5 +1,5 @@ use soroban_sdk::{Env, Address, String, Vec}; -use crate::storage::{DataKey, VaccinationRecord, IssuerRecord}; +use crate::storage::{DataKey, VaccinationRecord, IssuerRecord, compute_token_id}; use crate::events; use crate::ContractError; use crate::validate_input_length; @@ -10,6 +10,8 @@ pub fn mint_vaccination( vaccine_name: String, date_administered: String, issuer: Address, + dose_number: Option, + dose_series: Option, ) -> Result { validate_input_length(&vaccine_name, "vaccine_name")?; validate_input_length(&date_administered, "date_administered")?; @@ -101,6 +103,8 @@ pub fn mint_vaccination( timestamp: env.ledger().timestamp(), schema_version: 1, revoked: false, + dose_number, + dose_series, }; // Persist token diff --git a/contracts/src/property_tests.rs b/contracts/src/property_tests.rs index 806382a..3120f3f 100644 --- a/contracts/src/property_tests.rs +++ b/contracts/src/property_tests.rs @@ -39,11 +39,13 @@ proptest! { vaccine_str.clone(), date_str.clone(), issuer.clone(), + None, + None, ); if let Ok(token_id) = mint_result { // Verify - let (vaccinated, records) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated, records, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); // Property: if mint succeeded, verify should return the record prop_assert!(vaccinated, "patient should be vaccinated after mint"); @@ -103,6 +105,8 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); if let Ok(token_id) = mint_result { @@ -158,6 +162,8 @@ proptest! { vaccine_str.clone(), date_str.clone(), issuer.clone(), + None, + None, ); if first_mint.is_ok() { @@ -168,6 +174,8 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); // Property: duplicate mint should always fail @@ -215,11 +223,13 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); // Verify multiple times - let (vaccinated1, records1) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); - let (vaccinated2, records2) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated1, records1, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated2, records2, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); // Property: verify should return consistent results prop_assert_eq!(vaccinated1, vaccinated2, "vaccination status should be consistent"); @@ -262,11 +272,13 @@ proptest! { vaccine_str, date_str, issuer.clone(), + None, + None, ); if let Ok(token_id) = mint_result { // Verify before revocation - let (vaccinated_before, records_before) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated_before, records_before, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); prop_assert!(vaccinated_before, "should be vaccinated before revocation"); prop_assert!(records_before.len() > 0, "should have records before revocation"); @@ -274,7 +286,7 @@ proptest! { let _ = VacciChainContract::revoke_vaccination(env.clone(), token_id, issuer.clone()); // Verify after revocation - let (vaccinated_after, records_after) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated_after, records_after, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); // Property: revoked records should not be returned prop_assert!(!vaccinated_after, "should not be vaccinated after revocation"); @@ -311,6 +323,8 @@ proptest! { vaccine_str, date_str, unauthorized_issuer.clone(), + None, + None, ); // Property: unauthorized issuer should not be able to mint diff --git a/contracts/src/storage.rs b/contracts/src/storage.rs index 566b74a..6951279 100644 --- a/contracts/src/storage.rs +++ b/contracts/src/storage.rs @@ -76,6 +76,10 @@ pub struct VaccinationRecord { pub timestamp: u64, pub schema_version: u32, pub revoked: bool, + /// Which dose in the series this record represents (e.g. 1, 2, 3). None = single-dose / legacy. + pub dose_number: Option, + /// Total doses in the series (e.g. 3 for a 3-dose primary series). None = single-dose / legacy. + pub dose_series: Option, } #[contracttype] @@ -94,8 +98,12 @@ pub enum DataKey { PendingAdmin, AdminTransferExpiry, Issuer(Address), + IssuerMeta(BytesN<32>), + IssuerList, PatientTokens(Address), + PatientAllowlist(Address), + PatientRecordLimit, Token(u64), Revoked(u64), - PatientAllowlist(Address), + NextTokenId, } diff --git a/contracts/src/upgrade_tests.rs b/contracts/src/upgrade_tests.rs index 7602b25..4591822 100644 --- a/contracts/src/upgrade_tests.rs +++ b/contracts/src/upgrade_tests.rs @@ -37,17 +37,19 @@ fn test_upgrade_v1_to_v2_data_persistence() { vaccine_name.clone(), date.clone(), issuer.clone(), + None, + None, ).expect("mint should succeed"); // Verify record exists in v1 - let (vaccinated, records) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated, records, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); assert!(vaccinated, "patient should be vaccinated"); assert_eq!(records.len(), 1, "should have 1 record"); assert_eq!(records.get(0).unwrap().schema_version, 1, "schema version should be 1"); // After upgrade to v2, verify records are still readable // (In real scenario, contract would be redeployed with v2 code) - let (vaccinated_after, records_after) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated_after, records_after, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); assert!(vaccinated_after, "patient should still be vaccinated after upgrade"); assert_eq!(records_after.len(), 1, "should still have 1 record"); assert_eq!(records_after.get(0).unwrap().token_id, token_id, "token_id should match"); @@ -82,9 +84,11 @@ fn test_schema_version_field_read_across_versions() { vaccine_name, date, issuer.clone(), + None, + None, ); - let (_, records) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (_, records, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); let record = records.get(0).unwrap(); // Verify schema_version field is correctly set @@ -124,11 +128,13 @@ fn test_multiple_records_persist_after_upgrade() { vaccine, date, issuer.clone(), + None, + None, ); } // Verify all records persist - let (vaccinated, records) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated, records, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); assert!(vaccinated, "patient should be vaccinated"); assert_eq!(records.len(), 3, "should have 3 records"); @@ -167,13 +173,15 @@ fn test_revoked_records_persist_after_upgrade() { vaccine_name, date, issuer.clone(), + None, + None, ).expect("mint should succeed"); // Revoke the record let _ = VacciChainContract::revoke_vaccination(env.clone(), token_id, issuer.clone()); // Verify record is revoked - let (vaccinated, records) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); + let (vaccinated, records, _) = VacciChainContract::verify_vaccination(env.clone(), patient.clone()); assert!(!vaccinated, "patient should not be vaccinated after revocation"); assert_eq!(records.len(), 0, "revoked records should not be returned"); } diff --git a/contracts/src/verify.rs b/contracts/src/verify.rs index 094ab0e..0e87c3d 100644 --- a/contracts/src/verify.rs +++ b/contracts/src/verify.rs @@ -1,21 +1,34 @@ -use soroban_sdk::{Env, Address, Vec}; +use soroban_sdk::{Env, Address, Vec, contracttype, String}; use crate::storage::{DataKey, VaccinationRecord}; const MAX_BATCH_SIZE: u32 = 100; +/// Per-vaccine dose completion summary returned by verify_vaccination. +#[contracttype] +#[derive(Clone)] +pub struct DoseStatus { + pub vaccine_name: String, + /// Highest dose_number seen across non-revoked records for this vaccine. + pub doses_received: u32, + /// dose_series from the record with the highest dose_number (0 = unknown / single-dose). + pub doses_required: u32, + /// True when doses_required > 0 && doses_received >= doses_required. + pub complete: bool, +} + pub fn batch_verify(env: &Env, wallets: Vec
) -> Vec<(Address, bool, Vec)> { assert!(wallets.len() <= MAX_BATCH_SIZE, "batch size exceeds maximum of 100"); let mut results: Vec<(Address, bool, Vec)> = Vec::new(env); for i in 0..wallets.len() { let wallet = wallets.get(i).unwrap(); - let (vaccinated, records) = verify_vaccination(env, wallet.clone()); + let (vaccinated, records, _) = verify_vaccination(env, wallet.clone()); results.push_back((wallet, vaccinated, records)); } results } -pub fn verify_vaccination(env: &Env, wallet: Address) -> (bool, Vec) { +pub fn verify_vaccination(env: &Env, wallet: Address) -> (bool, Vec, Vec) { let tokens: Vec = env .storage() .persistent() @@ -23,7 +36,7 @@ pub fn verify_vaccination(env: &Env, wallet: Address) -> (bool, Vec = Vec::new(env); @@ -39,5 +52,52 @@ pub fn verify_vaccination(env: &Env, wallet: Address) -> (bool, Vec = Vec::new(env); + let mut doses_received: Vec = Vec::new(env); + let mut doses_required: Vec = Vec::new(env); + + for i in 0..records.len() { + let rec = records.get(i).unwrap(); + let dn = rec.dose_number.unwrap_or(0); + let ds = rec.dose_series.unwrap_or(0); + + // Find existing entry for this vaccine name + let mut found = false; + for j in 0..vaccine_names.len() { + if vaccine_names.get(j).unwrap() == rec.vaccine_name { + let prev_dn = doses_received.get(j).unwrap(); + let prev_ds = doses_required.get(j).unwrap(); + if dn > prev_dn { + doses_received.set(j, dn); + } + if ds > prev_ds { + doses_required.set(j, ds); + } + found = true; + break; + } + } + if !found { + vaccine_names.push_back(rec.vaccine_name.clone()); + doses_received.push_back(dn); + doses_required.push_back(ds); + } + } + + let mut dose_statuses: Vec = Vec::new(env); + for i in 0..vaccine_names.len() { + let dr = doses_received.get(i).unwrap(); + let dq = doses_required.get(i).unwrap(); + dose_statuses.push_back(DoseStatus { + vaccine_name: vaccine_names.get(i).unwrap(), + doses_received: dr, + doses_required: dq, + complete: dq > 0 && dr >= dq, + }); + } + + (has_active, records, dose_statuses) } diff --git a/frontend/package.json b/frontend/package.json index a081f0a..0215c15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,25 +5,23 @@ "dependencies": { "@stellar/freighter-api": "^2.0.0", "@stellar/stellar-sdk": "^12.0.0", + "i18next": "^23.11.5", + "jspdf": "^2.5.1", + "qrcode": "^1.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.0" - "react-router-dom": "^6.22.0", - "@stellar/freighter-api": "^2.0.0", - "@stellar/stellar-sdk": "^12.0.0", - "i18next": "^23.11.5", "react-i18next": "^14.1.2", - "jspdf": "^2.5.1", - "qrcode": "^1.5.3" + "react-router-dom": "^6.22.0" }, "devDependencies": { "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", + "@playwright/test": "^1.40.0", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", "@testing-library/user-event": "^14.5.0", "@vitejs/plugin-react": "^4.2.1", - "@playwright/test": "^1.40.0", + "jest-environment-jsdom": "^30.3.0", "vite": "^5.1.4" }, "scripts": { diff --git a/frontend/src/components/NFTCard.jsx b/frontend/src/components/NFTCard.jsx index dd9790f..35cb26f 100644 --- a/frontend/src/components/NFTCard.jsx +++ b/frontend/src/components/NFTCard.jsx @@ -58,10 +58,34 @@ export default function NFTCard({ record, onClick }) { 💉 {record.vaccine_name} - - #{record.token_id} - - +
+ {record.dose_number != null && ( + = record.dose_series + ? '#166534' + : '#1e3a5f', + color: record.dose_series != null && record.dose_number >= record.dose_series + ? '#86efac' + : '#93c5fd', + whiteSpace: 'nowrap', + }} + > + {record.dose_series != null + ? `${record.dose_number}/${record.dose_series} doses` + : `Dose ${record.dose_number}`} + + )} + + #{record.token_id} + + +

Date: {record.date_administered} diff --git a/frontend/src/components/NFTCard.test.jsx b/frontend/src/components/NFTCard.test.jsx index 8692239..3115bb2 100644 --- a/frontend/src/components/NFTCard.test.jsx +++ b/frontend/src/components/NFTCard.test.jsx @@ -27,7 +27,7 @@ describe('NFTCard', () => { it('should handle click events', () => { render(); - const card = screen.getByRole('button'); + const card = screen.getByTestId('nft-card'); fireEvent.click(card); expect(mockOnClick).toHaveBeenCalledTimes(1); @@ -36,7 +36,7 @@ describe('NFTCard', () => { it('should handle keyboard events (Enter key)', () => { render(); - const card = screen.getByRole('button'); + const card = screen.getByTestId('nft-card'); fireEvent.keyDown(card, { key: 'Enter' }); expect(mockOnClick).toHaveBeenCalledTimes(1); @@ -45,7 +45,7 @@ describe('NFTCard', () => { it('should handle keyboard events (Space key)', () => { render(); - const card = screen.getByRole('button'); + const card = screen.getByTestId('nft-card'); fireEvent.keyDown(card, { key: ' ' }); expect(mockOnClick).toHaveBeenCalledTimes(1); @@ -54,7 +54,7 @@ describe('NFTCard', () => { it('should render without onClick handler', () => { render(); - const card = screen.getByRole('button'); + const card = screen.getByTestId('nft-card'); expect(card).toBeInTheDocument(); }); @@ -64,4 +64,29 @@ describe('NFTCard', () => { expect(screen.getByText(/Issuer: GABC1234/)).toBeInTheDocument(); expect(screen.getByText(/…1234$/)).toBeInTheDocument(); }); + + it('should display dose progress badge when dose_number and dose_series are present', () => { + const doseRecord = { ...mockRecord, dose_number: 2, dose_series: 3 }; + render(); + expect(screen.getByText('2/3 doses')).toBeInTheDocument(); + expect(screen.getByLabelText('Dose 2 of 3')).toBeInTheDocument(); + }); + + it('should display completed dose badge when series is complete', () => { + const doseRecord = { ...mockRecord, dose_number: 3, dose_series: 3 }; + render(); + expect(screen.getByText('3/3 doses')).toBeInTheDocument(); + }); + + it('should display dose number only when dose_series is absent', () => { + const doseRecord = { ...mockRecord, dose_number: 1 }; + render(); + expect(screen.getByText('Dose 1')).toBeInTheDocument(); + }); + + it('should not display dose badge when dose_number is absent', () => { + render(); + expect(screen.queryByText(/doses/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Dose \d/)).not.toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/frontend/src/hooks/useVaccination.js b/frontend/src/hooks/useVaccination.js index 7cfffbf..fe9d3e3 100644 --- a/frontend/src/hooks/useVaccination.js +++ b/frontend/src/hooks/useVaccination.js @@ -7,10 +7,10 @@ export function useVaccination() { const toast = useToast(); const [loading, setLoading] = useState(false); - const fetchRecords = useCallback(async (wallet) => { + const fetchRecords = useCallback(async (wallet, { page = 1, limit = 20 } = {}) => { setLoading(true); try { - const res = await apiFetch(`/vaccination/${wallet}`); + const res = await apiFetch(`/vaccination/${wallet}?page=${page}&limit=${limit}`); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; diff --git a/frontend/src/pages/PatientDashboard.jsx b/frontend/src/pages/PatientDashboard.jsx index f9c3542..ef907d2 100644 --- a/frontend/src/pages/PatientDashboard.jsx +++ b/frontend/src/pages/PatientDashboard.jsx @@ -1,13 +1,15 @@ import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useFreighter'; import { useVaccination } from '../hooks/useVaccination'; -import { usePagination } from '../hooks/usePagination'; import NFTCard from '../components/NFTCard'; import NFTCardSkeleton from '../components/NFTCardSkeleton'; import RecordDetailModal from '../components/RecordDetailModal'; import CopyButton from '../components/CopyButton'; import QRCodeModal from '../components/QRCodeModal'; +const PAGE_LIMIT = 20; + const styles = { page: { maxWidth: 700, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' }, btn: { padding: '0.6rem 1.5rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }, @@ -24,23 +26,33 @@ export default function PatientDashboard() { const { publicKey, connect } = useAuth(); const { fetchRecords, loading } = useVaccination(); const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); const [error, setError] = useState(null); - const { currentItems, page, totalPages, goTo, reset, total } = usePagination(records); + const [qrRecord, setQrRecord] = useState(null); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT)); - const load = useCallback(() => { + const load = useCallback((p = 1) => { if (!publicKey) return; - fetchRecords(publicKey) + fetchRecords(publicKey, { page: p, limit: PAGE_LIMIT }) .then((data) => { setError(null); - reset(); - if (data) setRecords(data.records || []); + if (data) { + setRecords(data.data || []); + setTotal(data.total ?? 0); + setPage(data.page ?? p); + } }) - .catch((err) => { - setError(err.message || 'Failed to fetch records'); - }); - }, [publicKey, fetchRecords, reset]); + .catch((err) => setError(err.message || 'Failed to fetch records')); + }, [publicKey, fetchRecords]); + + useEffect(() => { load(1); }, [load]); - useEffect(() => { load(); }, [load]); + const goTo = (p) => { + const next = Math.min(Math.max(1, p), totalPages); + load(next); + }; if (!publicKey) { return ( @@ -70,7 +82,7 @@ export default function PatientDashboard() { {!loading && error && (

⚠️ {error}

- +
)} {!loading && !error && total === 0 && ( @@ -80,7 +92,7 @@ export default function PatientDashboard() { )} - {currentItems.map((r) => ( + {records.map((r) => ( ({ - useAuth: jest.fn(), -})); - -jest.mock('../hooks/useVaccination', () => ({ - useVaccination: jest.fn(), -})); - -jest.mock('../hooks/usePagination', () => ({ - usePagination: jest.fn(), +jest.mock('../hooks/useFreighter', () => ({ useAuth: jest.fn() })); +jest.mock('../hooks/useVaccination', () => ({ useVaccination: jest.fn() })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, opts) => { + if (key === 'patient.title') return 'My Vaccination Records'; + if (key === 'patient.recordCount') return `${opts?.count} records`; + if (key === 'patient.pageOf') return `Page ${opts?.page} of ${opts?.total}`; + if (key === 'patient.prevPage') return 'Previous'; + if (key === 'patient.nextPage') return 'Next'; + return key; + }, + }), })); import { useAuth } from '../hooks/useFreighter'; import { useVaccination } from '../hooks/useVaccination'; -import { usePagination } from '../hooks/usePagination'; + +const WALLET = 'G12345678901234567890123456789012345678901234567890123456'; describe('PatientDashboard', () => { const mockConnect = jest.fn(); - const mockFetchRecords = jest.fn(); - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { jest.clearAllMocks(); }); describe('when not connected', () => { beforeEach(() => { useAuth.mockReturnValue({ publicKey: null, connect: mockConnect }); - useVaccination.mockReturnValue({ fetchRecords: mockFetchRecords, loading: false }); - usePagination.mockReturnValue({ - currentItems: [], - page: 1, - totalPages: 0, - goTo: jest.fn(), - reset: jest.fn(), - total: 0 - }); + useVaccination.mockReturnValue({ fetchRecords: jest.fn(), loading: false }); }); it('shows connect wallet prompt', () => { @@ -45,125 +37,102 @@ describe('PatientDashboard', () => { expect(screen.getByText(/Connect your wallet to view records/i)).toBeInTheDocument(); }); - it('shows connect button', () => { - render(); - expect(screen.getByRole('button', { name: /Connect Freighter wallet to view vaccination records/i })).toBeInTheDocument(); - }); - it('calls connect when button is clicked', () => { render(); - const button = screen.getByRole('button', { name: /Connect Freighter wallet to view vaccination records/i }); - button.click(); + screen.getByRole('button', { name: /Connect Freighter wallet/i }).click(); expect(mockConnect).toHaveBeenCalledTimes(1); }); }); describe('when connected', () => { - const mockPublicKey = 'G12345678901234567890123456789012345678901234567890123456'; - beforeEach(() => { - useAuth.mockReturnValue({ publicKey: mockPublicKey, connect: mockConnect }); - // Mock fetchRecords to return a Promise - useVaccination.mockReturnValue({ - fetchRecords: jest.fn().mockResolvedValue({ records: [] }), - loading: false - }); + useAuth.mockReturnValue({ publicKey: WALLET, connect: mockConnect }); }); - it('shows title', () => { - usePagination.mockReturnValue({ - currentItems: [], - page: 1, - totalPages: 0, - goTo: jest.fn(), - reset: jest.fn(), - total: 0 + it('shows title', async () => { + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }), + loading: false, }); render(); expect(screen.getByText(/My Vaccination Records/i)).toBeInTheDocument(); }); it('shows wallet address', () => { - usePagination.mockReturnValue({ - currentItems: [], - page: 1, - totalPages: 0, - goTo: jest.fn(), - reset: jest.fn(), - total: 0 + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }), + loading: false, }); render(); expect(screen.getByText(/Wallet:/i)).toBeInTheDocument(); - expect(screen.getByText(new RegExp(mockPublicKey.slice(0, 8)))).toBeInTheDocument(); }); it('shows loading skeleton when loading', () => { - useVaccination.mockReturnValue({ - fetchRecords: jest.fn().mockResolvedValue({ records: [] }), - loading: true - }); - usePagination.mockReturnValue({ - currentItems: [], - page: 1, - totalPages: 0, - goTo: jest.fn(), - reset: jest.fn(), - total: 0 + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }), + loading: true, }); render(); - // NFTCardSkeleton should be rendered - const skeletons = document.querySelectorAll('[style*="border: 1px solid #334155"]'); - expect(skeletons.length).toBeGreaterThan(0); + // NFTCardSkeleton injects a keyframe style tag + const styleTag = document.querySelector('style'); + expect(styleTag).not.toBeNull(); + expect(styleTag.textContent).toMatch(/vacciPulse/); }); - it('shows empty state when no records', () => { - usePagination.mockReturnValue({ - currentItems: [], - page: 1, - totalPages: 0, - goTo: jest.fn(), - reset: jest.fn(), - total: 0 + it('shows empty state when no records', async () => { + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }), + loading: false, }); render(); - expect(screen.getByText(/No vaccination records found/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/No vaccination records found/i)).toBeInTheDocument()); }); - it('shows record count when records exist', () => { - const mockRecords = [ + it('shows record count when records exist', async () => { + const records = [ { token_id: '1', vaccine_name: 'COVID-19', date_administered: '2024-01-15', issuer: 'G123' }, - { token_id: '2', vaccine_name: 'Flu', date_administered: '2023-10-01', issuer: 'G456' } + { token_id: '2', vaccine_name: 'Flu', date_administered: '2023-10-01', issuer: 'G456' }, ]; - usePagination.mockReturnValue({ - currentItems: mockRecords, - page: 1, - totalPages: 1, - goTo: jest.fn(), - reset: jest.fn(), - total: 2 + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: records, total: 2, page: 1, limit: 20 }), + loading: false, }); render(); - expect(screen.getByText(/2 records/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/2 records/i)).toBeInTheDocument()); }); - it('shows pagination when multiple pages', () => { - const mockRecords = Array.from({ length: 10 }, (_, i) => ({ - token_id: String(i + 1), - vaccine_name: 'COVID-19', - date_administered: '2024-01-15', - issuer: 'G123' + it('shows pagination when multiple pages exist', async () => { + const records = Array.from({ length: 20 }, (_, i) => ({ + token_id: String(i + 1), vaccine_name: 'COVID-19', date_administered: '2024-01-15', issuer: 'G123', })); - usePagination.mockReturnValue({ - currentItems: mockRecords.slice(0, 5), - page: 1, - totalPages: 2, - goTo: jest.fn(), - reset: jest.fn(), - total: 10 + useVaccination.mockReturnValue({ + fetchRecords: jest.fn().mockResolvedValue({ data: records, total: 40, page: 1, limit: 20 }), + loading: false, }); render(); - expect(screen.getByText(/Page 1 of 2/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/Page 1 of 2/i)).toBeInTheDocument()); expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument(); }); + + it('fetches next page when Next is clicked', async () => { + const mockFetch = jest.fn() + .mockResolvedValueOnce({ data: [], total: 40, page: 1, limit: 20 }) + .mockResolvedValueOnce({ data: [], total: 40, page: 2, limit: 20 }); + useVaccination.mockReturnValue({ fetchRecords: mockFetch, loading: false }); + + render(); + await waitFor(() => screen.getByRole('button', { name: /Next/i })); + fireEvent.click(screen.getByRole('button', { name: /Next/i })); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(WALLET, { page: 2, limit: 20 })); + }); + + it('passes page and limit to fetchRecords on initial load', async () => { + const mockFetch = jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }); + useVaccination.mockReturnValue({ fetchRecords: mockFetch, loading: false }); + + render(); + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(WALLET, { page: 1, limit: 20 })); + }); }); -}); \ No newline at end of file +}); diff --git a/python-service/alerting.py b/python-service/alerting.py new file mode 100644 index 0000000..00c84a1 --- /dev/null +++ b/python-service/alerting.py @@ -0,0 +1,52 @@ +"""Webhook alerting for anomaly detection results.""" +import os +from datetime import datetime, timezone +from typing import Any + +import httpx +import structlog + +logger = structlog.get_logger(service="alerting") + +ALERT_WEBHOOK_URL = os.getenv("ALERT_WEBHOOK_URL", "") +ALERT_WEBHOOK_TYPE = os.getenv("ALERT_WEBHOOK_TYPE", "slack").lower() # slack | pagerduty | email + + +def _build_payload(anomaly: dict, webhook_type: str) -> dict[str, Any]: + issuer = anomaly["issuer"] + count = anomaly["total_issued"] + ts = datetime.now(timezone.utc).isoformat() + text = ( + f"[VacciChain] Anomaly detected — issuer: {issuer}, " + f"type: high_mint_volume, record_count: {count}, timestamp: {ts}" + ) + if webhook_type == "pagerduty": + return { + "routing_key": os.getenv("PAGERDUTY_ROUTING_KEY", ""), + "event_action": "trigger", + "payload": { + "summary": text, + "severity": "warning", + "source": "vaccichain-analytics", + "custom_details": {"issuer": issuer, "record_count": count, "timestamp": ts}, + }, + } + if webhook_type == "email": + return {"to": os.getenv("ALERT_EMAIL_TO", ""), "subject": "VacciChain Anomaly Alert", "body": text} + # default: Slack + return {"text": text} + + +async def dispatch_alerts(flagged: list[dict]) -> None: + """POST one webhook call per flagged issuer. Logs and continues on failure.""" + if not ALERT_WEBHOOK_URL or not flagged: + return + async with httpx.AsyncClient(timeout=10) as client: + for anomaly in flagged: + payload = _build_payload(anomaly, ALERT_WEBHOOK_TYPE) + try: + res = await client.post(ALERT_WEBHOOK_URL, json=payload) + res.raise_for_status() + logger.info("alert_sent", issuer=anomaly["issuer"], webhook_type=ALERT_WEBHOOK_TYPE) + except Exception as exc: + logger.error("alert_failed", issuer=anomaly["issuer"], error=str(exc)) diff --git a/python-service/main.py b/python-service/main.py index 62c3116..e5b07eb 100644 --- a/python-service/main.py +++ b/python-service/main.py @@ -1,8 +1,12 @@ import os +from contextlib import asynccontextmanager + import structlog from fastapi import FastAPI, Request + from routes.analytics import router as analytics_router from routes.batch import router as batch_router +from scheduler import start_scheduler, stop_scheduler from schemas import HealthResponse structlog.configure( @@ -18,7 +22,15 @@ logger = structlog.get_logger(service="python-service") -app = FastAPI(title="VacciChain Analytics", version="1.0.0") + +@asynccontextmanager +async def lifespan(app: FastAPI): + start_scheduler() + yield + stop_scheduler() + + +app = FastAPI(title="VacciChain Analytics", version="1.0.0", lifespan=lifespan) app.include_router(analytics_router, prefix="/analytics") app.include_router(batch_router, prefix="/batch") diff --git a/python-service/requirements.txt b/python-service/requirements.txt index 1627f07..ea84b70 100644 --- a/python-service/requirements.txt +++ b/python-service/requirements.txt @@ -3,5 +3,6 @@ uvicorn[standard]==0.29.0 httpx==0.27.0 stellar-sdk==9.0.0 structlog==24.1.0 +apscheduler==3.10.4 pytest==8.1.1 pytest-asyncio==0.23.6 diff --git a/python-service/scheduler.py b/python-service/scheduler.py new file mode 100644 index 0000000..6855f4b --- /dev/null +++ b/python-service/scheduler.py @@ -0,0 +1,35 @@ +"""APScheduler job that runs anomaly detection and fires alerts.""" +import os + +import structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from alerting import dispatch_alerts +from routes.analytics import anomaly_detection + +logger = structlog.get_logger(service="scheduler") + +SCHEDULE_MINUTES = int(os.getenv("ANOMALY_SCHEDULE_MINUTES", "15")) + +scheduler = AsyncIOScheduler() + + +async def _run_anomaly_check() -> None: + try: + result = await anomaly_detection() + flagged = result.get("flagged_issuers", []) + logger.info("anomaly_check_complete", flagged_count=len(flagged)) + await dispatch_alerts(flagged) + except Exception as exc: + logger.error("anomaly_check_failed", error=str(exc)) + + +def start_scheduler() -> None: + scheduler.add_job(_run_anomaly_check, "interval", minutes=SCHEDULE_MINUTES, id="anomaly_check") + scheduler.start() + logger.info("scheduler_started", interval_minutes=SCHEDULE_MINUTES) + + +def stop_scheduler() -> None: + scheduler.shutdown(wait=False) + logger.info("scheduler_stopped") diff --git a/python-service/tests/test_alerting_scheduler.py b/python-service/tests/test_alerting_scheduler.py new file mode 100644 index 0000000..880d5b5 --- /dev/null +++ b/python-service/tests/test_alerting_scheduler.py @@ -0,0 +1,174 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +# ============================================================================ +# alerting.dispatch_alerts +# ============================================================================ + +@pytest.mark.asyncio +async def test_dispatch_alerts_no_url_skips(monkeypatch): + """No webhook URL → no HTTP call made.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "") + import importlib, alerting + importlib.reload(alerting) + + with patch("alerting.httpx.AsyncClient") as mock_client: + await alerting.dispatch_alerts([{"issuer": "GTEST", "total_issued": 60}]) + mock_client.assert_not_called() + + +@pytest.mark.asyncio +async def test_dispatch_alerts_empty_list_skips(monkeypatch): + """Empty flagged list → no HTTP call made.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "https://hooks.example.com/slack") + import importlib, alerting + importlib.reload(alerting) + + with patch("alerting.httpx.AsyncClient") as mock_client: + await alerting.dispatch_alerts([]) + mock_client.assert_not_called() + + +@pytest.mark.asyncio +async def test_dispatch_alerts_slack_payload(monkeypatch): + """Slack webhook sends correct payload shape.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "https://hooks.example.com/slack") + monkeypatch.setenv("ALERT_WEBHOOK_TYPE", "slack") + import importlib, alerting + importlib.reload(alerting) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("alerting.httpx.AsyncClient", return_value=mock_client): + await alerting.dispatch_alerts([{"issuer": "GMALICIOUS", "total_issued": 75}]) + + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + payload = kwargs["json"] + assert "text" in payload + assert "GMALICIOUS" in payload["text"] + assert "75" in payload["text"] + + +@pytest.mark.asyncio +async def test_dispatch_alerts_pagerduty_payload(monkeypatch): + """PagerDuty webhook sends correct payload shape.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "https://events.pagerduty.com/v2/enqueue") + monkeypatch.setenv("ALERT_WEBHOOK_TYPE", "pagerduty") + monkeypatch.setenv("PAGERDUTY_ROUTING_KEY", "test-routing-key") + import importlib, alerting + importlib.reload(alerting) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("alerting.httpx.AsyncClient", return_value=mock_client): + await alerting.dispatch_alerts([{"issuer": "GMALICIOUS", "total_issued": 75}]) + + _, kwargs = mock_post.call_args + payload = kwargs["json"] + assert payload["routing_key"] == "test-routing-key" + assert payload["event_action"] == "trigger" + assert payload["payload"]["custom_details"]["issuer"] == "GMALICIOUS" + assert payload["payload"]["custom_details"]["record_count"] == 75 + + +@pytest.mark.asyncio +async def test_dispatch_alerts_email_payload(monkeypatch): + """Email webhook sends correct payload shape.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "https://api.example.com/email") + monkeypatch.setenv("ALERT_WEBHOOK_TYPE", "email") + monkeypatch.setenv("ALERT_EMAIL_TO", "admin@example.com") + import importlib, alerting + importlib.reload(alerting) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("alerting.httpx.AsyncClient", return_value=mock_client): + await alerting.dispatch_alerts([{"issuer": "GMALICIOUS", "total_issued": 75}]) + + _, kwargs = mock_post.call_args + payload = kwargs["json"] + assert payload["to"] == "admin@example.com" + assert "GMALICIOUS" in payload["body"] + + +@pytest.mark.asyncio +async def test_dispatch_alerts_http_error_continues(monkeypatch): + """HTTP failure on one alert does not raise; logs and continues.""" + monkeypatch.setenv("ALERT_WEBHOOK_URL", "https://hooks.example.com/slack") + monkeypatch.setenv("ALERT_WEBHOOK_TYPE", "slack") + import importlib, alerting + importlib.reload(alerting) + + mock_post = AsyncMock(side_effect=Exception("connection refused")) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("alerting.httpx.AsyncClient", return_value=mock_client): + # Should not raise + await alerting.dispatch_alerts([ + {"issuer": "GISSUER1", "total_issued": 60}, + {"issuer": "GISSUER2", "total_issued": 80}, + ]) + + assert mock_post.call_count == 2 + + +# ============================================================================ +# scheduler._run_anomaly_check +# ============================================================================ + +@pytest.mark.asyncio +async def test_run_anomaly_check_dispatches_flagged(): + """_run_anomaly_check calls dispatch_alerts with flagged issuers.""" + flagged = [{"issuer": "GMALICIOUS", "total_issued": 99}] + + with patch("scheduler.anomaly_detection", new=AsyncMock(return_value={"flagged_issuers": flagged})), \ + patch("scheduler.dispatch_alerts", new=AsyncMock()) as mock_dispatch: + from scheduler import _run_anomaly_check + await _run_anomaly_check() + + mock_dispatch.assert_called_once_with(flagged) + + +@pytest.mark.asyncio +async def test_run_anomaly_check_no_flagged(): + """_run_anomaly_check calls dispatch_alerts with empty list when none flagged.""" + with patch("scheduler.anomaly_detection", new=AsyncMock(return_value={"flagged_issuers": []})), \ + patch("scheduler.dispatch_alerts", new=AsyncMock()) as mock_dispatch: + from scheduler import _run_anomaly_check + await _run_anomaly_check() + + mock_dispatch.assert_called_once_with([]) + + +@pytest.mark.asyncio +async def test_run_anomaly_check_handles_exception(): + """_run_anomaly_check swallows exceptions from anomaly_detection.""" + with patch("scheduler.anomaly_detection", new=AsyncMock(side_effect=Exception("backend down"))), \ + patch("scheduler.dispatch_alerts", new=AsyncMock()) as mock_dispatch: + from scheduler import _run_anomaly_check + await _run_anomaly_check() # must not raise + + mock_dispatch.assert_not_called()