diff --git a/.github/workflows/devportal-integration-test.yml b/.github/workflows/devportal-integration-test.yml new file mode 100644 index 0000000000..93f094aad2 --- /dev/null +++ b/.github/workflows/devportal-integration-test.yml @@ -0,0 +1,91 @@ +name: Developer Portal Integration Test + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - 'portals/developer-portal/**' + - '.github/workflows/devportal-integration-test.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + rest-api-test: + name: REST API tests (${{ matrix.db }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + db: [sqlite, postgres] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build developer-portal image + run: make -C portals/developer-portal build + + - name: Run REST API integration tests (${{ matrix.db }}) + run: | + if [ "${{ matrix.db }}" = "postgres" ]; then + make -C portals/developer-portal/it test-rest-api-postgres + else + make -C portals/developer-portal/it test-rest-api + fi + + - name: Upload test reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: rest-api-test-reports-${{ matrix.db }} + path: portals/developer-portal/it/reports/ + retention-days: 7 + + - name: Debug on failure - dump container logs + if: failure() + run: | + echo "=== Docker containers ===" + docker ps -a + + ui-test: + name: UI E2E tests (Cypress) + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build developer-portal image + run: make -C portals/developer-portal build + + - name: Run Cypress integration tests + run: make -C portals/developer-portal it + + - name: Upload Cypress reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: cypress-reports + path: portals/developer-portal/it/reports/ + retention-days: 7 + + - name: Debug on failure - dump container logs + if: failure() + run: | + echo "=== Docker containers ===" + docker ps -a diff --git a/docs/rest-apis/devportal/api-workflows.md b/docs/rest-apis/devportal/api-workflows.md index c4c0160570..9568839ee8 100644 --- a/docs/rest-apis/devportal/api-workflows.md +++ b/docs/rest-apis/devportal/api-workflows.md @@ -135,7 +135,9 @@ This operation requires Basic Auth authentication. "apiWorkflowDefinition": "string", "markdownContent": "string", "createdAt": "May 7, 2026", - "updatedAt": "string" + "updatedAt": "string", + "createdBy": "string", + "updatedBy": "string" } ], "pagination": { @@ -171,6 +173,8 @@ Status Code **200** |»» markdownContent|string¦null|false|none|none| |»» createdAt|string|false|none|none| |»» updatedAt|string¦null|false|none|none| +|»» createdBy|string¦null|false|none|none| +|»» updatedBy|string¦null|false|none|none| |» pagination|[Pagination](schemas.md#schemapagination)|false|none|Standard pagination metadata returned with collection responses.| |»» total|integer|true|none|Total number of records matching the query.| |»» limit|integer|true|none|Maximum number of records returned in this response.| diff --git a/docs/rest-apis/devportal/apis.md b/docs/rest-apis/devportal/apis.md index df18e5ed0b..278dc1f867 100644 --- a/docs/rest-apis/devportal/apis.md +++ b/docs/rest-apis/devportal/apis.md @@ -19,7 +19,7 @@ curl -X POST https://localhost:3000/api/v0.9/apis \ ``` -Creates Developer Portal API metadata from either a full API artifact ZIP, an API metadata YAML file (`api.yaml` / `devportal.yaml` / `mcp.yaml`), or an `apiMetadata` JSON string. An API definition file is required unless supplied by the artifact ZIP. The YAML `spec` block accepts: `displayName`, `version`, `description`, `type`, `status`, `agentVisibility`, `tags`, `labels`, `referenceId`, `endpoints` (sandboxUrl, productionUrl), `businessInformation` (owners), and `subscriptionPlans`. The service also stores labels, subscription plan mappings, image metadata, and schema definitions for GraphQL APIs when provided. MCP servers must be created via `POST /api/v0.9/mcp-servers` instead — a request whose resolved `type` is `MCP` is rejected with `400`. +Creates Developer Portal API metadata from either a full API artifact ZIP, an API metadata YAML file (`api.yaml` / `devportal.yaml` / `mcp.yaml`), or an `apiMetadata` JSON string. An API definition file is required unless supplied by the artifact ZIP. The YAML `spec` block accepts: `displayName`, `version`, `description`, `type`, `status`, `agentVisibility`, `tags`, `labels`, `referenceId`, `endpoints` (sandboxUrl, productionUrl), `businessInformation` (owners), and `subscriptionPlans`. The service also stores labels, subscription plan mappings, image metadata, and schema definitions for GraphQL APIs when provided. Via the JSON `apiMetadata` field, `type` is required — an omitted type is rejected with `400` (via YAML, an omitted `spec.type` defaults to `REST`). MCP servers must be created via `POST /api/v0.9/mcp-servers` instead — a request whose resolved `type` is `MCP` is rejected with `400`. `subscriptionPlans` links existing org-level plans to this API by name — it does not create plans. In YAML it is a string array (`["Gold", "Silver"]`). In the JSON `apiMetadata` field it is an object array where only `id` is used (`[{"id":"Gold"}]`); extra fields such as `planId`, `displayName`, or `requestCount` are ignored. > Payload @@ -315,7 +315,7 @@ Status Code **200** |»»» version|string|false|none|none| |»»» status|string|false|none|API lifecycle status.| |»»» description|string|false|none|none| -|»»» type|string|false|none|none| +|»»» type|string|false|none|The stored/returned type constant (src/utils/constants.js API_TYPE) — distinct from the request-time keyword accepted on create/update (see `type` in ApiMetadataMultipartBody: REST, SOAP, MCP, WS, WEBSUB, GRAPHQL). REST maps to `RestApi` and WEBSUB maps to `WebSubApi`; the rest are returned unchanged.| |»»» referenceId|string¦null|false|none|External reference ID. Present when the API was created from a `devportal.yaml` artifact whose `spec` block sets `referenceId` — the create response echoes the parsed YAML back.| |»»» agentVisibility|string|false|none|none| |»»» addedLabels|[string]|false|none|none| @@ -389,11 +389,11 @@ Status Code **200** |---|---| |status|PUBLISHED| |status|DEPRECATED| -|type|REST| +|type|RestApi| |type|SOAP| -|type|MCP| +|type|Mcp| |type|WS| -|type|WEBSUB| +|type|WebSubApi| |type|GRAPHQL| |agentVisibility|VISIBLE| |agentVisibility|HIDDEN| @@ -566,7 +566,7 @@ curl -X PUT https://localhost:3000/api/v0.9/apis/{apiId} \ ``` -Updates Developer Portal API metadata and its stored definition. Accepts the same YAML spec fields and `apiMetadata` JSON format as the create operation. The update flow can also adjust label mappings, subscription plan mappings, schema definitions, and image metadata. Status changes to unpublished are rejected when active subscriptions exist. +Updates Developer Portal API metadata and its stored definition. Accepts the same YAML spec fields and `apiMetadata` JSON format as the create operation. The update flow can also adjust label mappings, subscription plan mappings, schema definitions, and image metadata. Status changes to unpublished are rejected when active subscriptions exist. `type` is required (see the create operation) and is immutable — it must match the API's existing type; a different value is rejected with `409`. > Payload diff --git a/docs/rest-apis/devportal/mcp-servers.md b/docs/rest-apis/devportal/mcp-servers.md index 6247130d48..327397961f 100644 --- a/docs/rest-apis/devportal/mcp-servers.md +++ b/docs/rest-apis/devportal/mcp-servers.md @@ -19,7 +19,7 @@ curl -X POST https://localhost:3000/api/v0.9/mcp-servers \ ``` -Creates Developer Portal MCP server metadata. Mirrors `POST /api/v0.9/apis` — same artifact ZIP, YAML (`api.yaml` / `devportal.yaml` / `mcp.yaml`), and `apiMetadata` JSON input formats — but the created record is always typed `MCP`, regardless of what `type` is supplied. +Creates Developer Portal MCP server metadata. Mirrors `POST /api/v0.9/apis` — same artifact ZIP, YAML (`api.yaml` / `devportal.yaml` / `mcp.yaml`), and `apiMetadata` JSON input formats — but the created record is always typed `MCP`. Via the JSON `apiMetadata` field, `type` must be explicitly `MCP`; an omitted type or any other value is rejected with a 400 (use `POST /api/v0.9/apis` instead). > Payload @@ -314,7 +314,7 @@ Status Code **200** |»»» version|string|false|none|none| |»»» status|string|false|none|API lifecycle status.| |»»» description|string|false|none|none| -|»»» type|string|false|none|none| +|»»» type|string|false|none|The stored/returned type constant (src/utils/constants.js API_TYPE) — distinct from the request-time keyword accepted on create/update (see `type` in ApiMetadataMultipartBody: REST, SOAP, MCP, WS, WEBSUB, GRAPHQL). REST maps to `RestApi` and WEBSUB maps to `WebSubApi`; the rest are returned unchanged.| |»»» referenceId|string¦null|false|none|External reference ID. Present when the API was created from a `devportal.yaml` artifact whose `spec` block sets `referenceId` — the create response echoes the parsed YAML back.| |»»» agentVisibility|string|false|none|none| |»»» addedLabels|[string]|false|none|none| @@ -388,11 +388,11 @@ Status Code **200** |---|---| |status|PUBLISHED| |status|DEPRECATED| -|type|REST| +|type|RestApi| |type|SOAP| -|type|MCP| +|type|Mcp| |type|WS| -|type|WEBSUB| +|type|WebSubApi| |type|GRAPHQL| |agentVisibility|VISIBLE| |agentVisibility|HIDDEN| @@ -561,7 +561,7 @@ curl -X PUT https://localhost:3000/api/v0.9/mcp-servers/{mcpServerId} \ ``` -Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`. +Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`, including `type` being required and immutable — it must stay `MCP`; any other value is rejected with `400`, via the same resolveTypeOrReject check `POST /mcp-servers` uses. > Payload diff --git a/docs/rest-apis/devportal/organizations.md b/docs/rest-apis/devportal/organizations.md index 6c3570a082..ce9c755460 100644 --- a/docs/rest-apis/devportal/organizations.md +++ b/docs/rest-apis/devportal/organizations.md @@ -79,7 +79,9 @@ This operation requires Basic Auth authentication. "cpRefId": "string", "configuration": { "devportalMode": "DEFAULT" - } + }, + "createdAt": "2019-08-24T14:15:22Z", + "updatedAt": "2019-08-24T14:15:22Z" } ``` @@ -224,7 +226,9 @@ This operation requires Basic Auth authentication. "cpRefId": "string", "configuration": { "devportalMode": "DEFAULT" - } + }, + "createdAt": "2019-08-24T14:15:22Z", + "updatedAt": "2019-08-24T14:15:22Z" } ], "pagination": { @@ -268,6 +272,8 @@ Status Code **200** |»» cpRefId|string¦null|false|none|Control Plane reference ID. Included in outbound webhook event payloads so subscribers can correlate this organization with its Control Plane (Platform API) counterpart. Not used for authentication or org resolution.| |»» configuration|object|false|none|Organization portal configuration. Always includes `devportalMode`; may contain additional free-form keys set by the caller.| |»»» devportalMode|string|false|none|Controls the mode of the developer portal.| +|»» createdAt|string(date-time)¦null|false|none|none| +|»» updatedAt|string(date-time)¦null|false|none|none| |» pagination|[Pagination](schemas.md#schemapagination)|false|none|Standard pagination metadata returned with collection responses.| |»» total|integer|true|none|Total number of records matching the query.| |»» limit|integer|true|none|Maximum number of records returned in this response.| @@ -361,7 +367,9 @@ This operation requires Basic Auth authentication. "cpRefId": "string", "configuration": { "devportalMode": "DEFAULT" - } + }, + "createdAt": "2019-08-24T14:15:22Z", + "updatedAt": "2019-08-24T14:15:22Z" } ``` @@ -493,7 +501,9 @@ This operation requires Basic Auth authentication. "cpRefId": "string", "configuration": { "devportalMode": "DEFAULT" - } + }, + "createdAt": "2019-08-24T14:15:22Z", + "updatedAt": "2019-08-24T14:15:22Z" } ``` diff --git a/docs/rest-apis/devportal/schemas.md b/docs/rest-apis/devportal/schemas.md index 448ce4a759..73b0963d96 100644 --- a/docs/rest-apis/devportal/schemas.md +++ b/docs/rest-apis/devportal/schemas.md @@ -258,7 +258,9 @@ Ad hoc error shape used by the Subscriptions and API Keys handlers, which build "cpRefId": "string", "configuration": { "devportalMode": "DEFAULT" - } + }, + "createdAt": "2019-08-24T14:15:22Z", + "updatedAt": "2019-08-24T14:15:22Z" } ``` @@ -276,6 +278,8 @@ Ad hoc error shape used by the Subscriptions and API Keys handlers, which build |cpRefId|string¦null|false|none|Control Plane reference ID. Included in outbound webhook event payloads so subscribers can correlate this organization with its Control Plane (Platform API) counterpart. Not used for authentication or org resolution.| |configuration|object|false|none|Organization portal configuration. Always includes `devportalMode`; may contain additional free-form keys set by the caller.| |» devportalMode|string|false|none|Controls the mode of the developer portal.| +|createdAt|string(date-time)¦null|false|none|none| +|updatedAt|string(date-time)¦null|false|none|none| #### Enumerated Values @@ -348,7 +352,7 @@ Ad hoc error shape used by the Subscriptions and API Keys handlers, which build "version": "string", "status": "PUBLISHED", "description": "string", - "type": "REST", + "type": "RestApi", "referenceId": "string", "agentVisibility": "VISIBLE", "addedLabels": [ @@ -439,7 +443,7 @@ and "version": "string", "status": "PUBLISHED", "description": "string", - "type": "REST", + "type": "RestApi", "referenceId": "string", "agentVisibility": "VISIBLE", "addedLabels": [ @@ -542,7 +546,7 @@ and "version": "string", "status": "PUBLISHED", "description": "string", - "type": "REST", + "type": "RestApi", "referenceId": "string", "agentVisibility": "VISIBLE", "addedLabels": [ @@ -583,7 +587,7 @@ Fields are returned at the root of ApiMetadataResponse / ApiMetadataCreateRespon |version|string|false|none|none| |status|string|false|none|API lifecycle status.| |description|string|false|none|none| -|type|string|false|none|none| +|type|string|false|none|The stored/returned type constant (src/utils/constants.js API_TYPE) — distinct from the request-time keyword accepted on create/update (see `type` in ApiMetadataMultipartBody: REST, SOAP, MCP, WS, WEBSUB, GRAPHQL). REST maps to `RestApi` and WEBSUB maps to `WebSubApi`; the rest are returned unchanged.| |referenceId|string¦null|false|none|External reference ID. Present when the API was created from a `devportal.yaml` artifact whose `spec` block sets `referenceId` — the create response echoes the parsed YAML back.| |agentVisibility|string|false|none|none| |addedLabels|[string]|false|none|none| @@ -599,11 +603,11 @@ Fields are returned at the root of ApiMetadataResponse / ApiMetadataCreateRespon |---|---| |status|PUBLISHED| |status|DEPRECATED| -|type|REST| +|type|RestApi| |type|SOAP| -|type|MCP| +|type|Mcp| |type|WS| -|type|WEBSUB| +|type|WebSubApi| |type|GRAPHQL| |agentVisibility|VISIBLE| |agentVisibility|HIDDEN| @@ -1891,7 +1895,9 @@ Access token response proxied from the key manager's token endpoint. Field names "apiWorkflowDefinition": "string", "markdownContent": "string", "createdAt": "May 7, 2026", - "updatedAt": "string" + "updatedAt": "string", + "createdBy": "string", + "updatedBy": "string" } ``` @@ -1911,6 +1917,8 @@ Access token response proxied from the key manager's token endpoint. Field names |markdownContent|string¦null|false|none|none| |createdAt|string|false|none|none| |updatedAt|string¦null|false|none|none| +|createdBy|string¦null|false|none|none| +|updatedBy|string¦null|false|none|none| #### Enumerated Values diff --git a/portals/developer-portal/.gitignore b/portals/developer-portal/.gitignore index f811f3c790..316d3328eb 100644 --- a/portals/developer-portal/.gitignore +++ b/portals/developer-portal/.gitignore @@ -99,3 +99,6 @@ resources/mock .env configs/config-platform-api.toml + +# Test reports +it/reports/* diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 9700595b0a..38578c5fad 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -52,14 +52,19 @@ What happens automatically on first start: ### Test ```bash -# Run Cypress integration tests headlessly inside Docker +# Run the Cypress UI E2E suite headlessly inside Docker make it +# Run the REST API suite (Jest + Supertest) +make -C it test-rest-api + # Open Cypress interactive UI — requires the portal running locally first make it-open ``` -For integration test details, see [it/README.md](it/README.md). +Both suites also run on pull requests via the +[Developer Portal Integration Test](../../.github/workflows/devportal-integration-test.yml) +GitHub Actions workflow. For integration test details, see [it/README.md](it/README.md). ### Clean @@ -106,9 +111,13 @@ Run `make help` to see the full list. Summary: | Target | Description | |--------|-------------| -| `make it` | Run Cypress integration tests against SQLite (headless, in Docker) | -| `make it-postgres` | Run Cypress integration tests against PostgreSQL (headless, in Docker) | +| `make it` | Run the Cypress UI E2E suite against SQLite (headless, in Docker) | +| `make it-postgres` | Run the Cypress UI E2E suite against PostgreSQL (headless, in Docker) | | `make it-open` | Open Cypress interactive UI (requires the portal running locally) | +| `make -C it test-rest-api` | Run the REST API suite (Jest + Supertest) against SQLite | +| `make -C it test-rest-api-postgres` | Run the REST API suite against PostgreSQL | + +See [it/README.md](it/README.md) for the full list of test commands and suite details. ### Database diff --git a/portals/developer-portal/database/queries/search-apis.postgres.sql b/portals/developer-portal/database/queries/search-apis.postgres.sql index 124c99320b..0c1d2e2479 100644 --- a/portals/developer-portal/database/queries/search-apis.postgres.sql +++ b/portals/developer-portal/database/queries/search-apis.postgres.sql @@ -20,9 +20,15 @@ -- Loaded and executed by src/dao/apiMetadata.js :: searchAPIMetadata(). -- -- Named parameters (passed as Sequelize replacements): --- :searchTerm — the user-supplied search string --- :orgId — the organisation UUID to scope results to --- :viewId — the view UUID to scope results to (API must have a label mapped to this view) +-- :searchTerm — the user-supplied search string +-- :orgId — the organisation UUID to scope results to +-- :viewId — nullable; the view UUID to scope results to (API must have a label mapped +-- to this view). `view` is an optional query param — when omitted, results +-- are unscoped by view rather than matching nothing. +-- :includeType — nullable; when set, only rows with metadata.type = :includeType match +-- :excludeType — nullable; when set, rows with metadata.type = :excludeType are excluded +-- (keeps /apis and /mcp-servers list results type-scoped at the SQL level +-- rather than relying on callers to filter in application code) -- -- Other dialects use a LIKE-based fallback in searchAPIMetadataFallback(). @@ -38,7 +44,7 @@ SELECT '[]' ) AS "DP_API_SUBSCRIPTION_PLAN_MAPPING", COALESCE( - ARRAY_AGG(DISTINCT lbl.name) FILTER (WHERE lbl.name IS NOT NULL), + ARRAY_AGG(DISTINCT lbl.handle) FILTER (WHERE lbl.handle IS NOT NULL), '{}' ) AS "DP_LABELs", COALESCE( @@ -96,11 +102,16 @@ WHERE ) ) AND metadata.org_uuid = :orgId - AND EXISTS ( - SELECT 1 - FROM dp_api_label_mappings alm - JOIN dp_view_label_mappings vlm ON alm.label_uuid = vlm.label_uuid - WHERE alm.api_uuid = metadata.uuid AND vlm.view_uuid = :viewId + AND (:includeType::text IS NULL OR metadata.type = :includeType) + AND (:excludeType::text IS NULL OR metadata.type != :excludeType) + AND ( + :viewId::uuid IS NULL + OR EXISTS ( + SELECT 1 + FROM dp_api_label_mappings alm + JOIN dp_view_label_mappings vlm ON alm.label_uuid = vlm.label_uuid + WHERE alm.api_uuid = metadata.uuid AND vlm.view_uuid = :viewId + ) ) GROUP BY metadata.uuid diff --git a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml index 4a78f6e59b..6d110685d5 100644 --- a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml +++ b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml @@ -254,8 +254,9 @@ paths: `status`, `agentVisibility`, `tags`, `labels`, `referenceId`, `endpoints` (sandboxUrl, productionUrl), `businessInformation` (owners), and `subscriptionPlans`. The service also stores labels, subscription plan mappings, image metadata, and schema definitions for - GraphQL APIs when provided. MCP servers must be created via `POST /api/v0.9/mcp-servers` instead — a - request whose resolved `type` is `MCP` is rejected with `400`. + GraphQL APIs when provided. Via the JSON `apiMetadata` field, `type` is required — an omitted type is + rejected with `400` (via YAML, an omitted `spec.type` defaults to `REST`). MCP servers must be created via + `POST /api/v0.9/mcp-servers` instead — a request whose resolved `type` is `MCP` is rejected with `400`. `subscriptionPlans` links existing org-level plans to this API by name — it does not create plans. In YAML it is a string array (`["Gold", "Silver"]`). In the JSON `apiMetadata` field it is an object array where only @@ -337,7 +338,8 @@ paths: Updates Developer Portal API metadata and its stored definition. Accepts the same YAML spec fields and `apiMetadata` JSON format as the create operation. The update flow can also adjust label mappings, subscription plan mappings, schema definitions, and image metadata. Status changes to unpublished are rejected when active - subscriptions exist. + subscriptions exist. `type` is required (see the create operation) and is immutable — it must match the + API's existing type; a different value is rejected with `409`. operationId: updateApiMetadata requestBody: $ref: "#/components/requestBodies/ApiMetadataMultipartBody" @@ -1252,7 +1254,8 @@ paths: description: >- Creates Developer Portal MCP server metadata. Mirrors `POST /api/v0.9/apis` — same artifact ZIP, YAML (`api.yaml` / `devportal.yaml` / `mcp.yaml`), and `apiMetadata` JSON input formats — but the created record is - always typed `MCP`, regardless of what `type` is supplied. + always typed `MCP`. Via the JSON `apiMetadata` field, `type` must be explicitly `MCP`; an omitted type or any + other value is rejected with a 400 (use `POST /api/v0.9/apis` instead). operationId: createMcpServer requestBody: $ref: "#/components/requestBodies/ApiMetadataMultipartBody" @@ -1325,7 +1328,9 @@ paths: - MCP Servers summary: Update MCP server metadata description: >- - Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`. + Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`, + including `type` being required and immutable — it must stay `MCP`; any other value is rejected with `400`, + via the same resolveTypeOrReject check `POST /mcp-servers` uses. operationId: updateMcpServer requestBody: $ref: "#/components/requestBodies/ApiMetadataMultipartBody" @@ -4224,6 +4229,14 @@ components: additionalProperties: true example: devportalMode: DEFAULT + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true additionalProperties: false OrganizationContentUploadResponse: type: object @@ -4355,12 +4368,17 @@ components: type: string type: type: string + description: >- + The stored/returned type constant (src/utils/constants.js API_TYPE) — distinct + from the request-time keyword accepted on create/update (see `type` in + ApiMetadataMultipartBody: REST, SOAP, MCP, WS, WEBSUB, GRAPHQL). REST maps to + `RestApi` and WEBSUB maps to `WebSubApi`; the rest are returned unchanged. enum: - - REST + - RestApi - SOAP - - MCP + - Mcp - WS - - WEBSUB + - WebSubApi - GRAPHQL referenceId: type: string @@ -5478,6 +5496,12 @@ components: updatedAt: type: string nullable: true + createdBy: + type: string + nullable: true + updatedBy: + type: string + nullable: true additionalProperties: false APIWorkflowPromptResponse: type: object diff --git a/portals/developer-portal/it/Makefile b/portals/developer-portal/it/Makefile index f078755d77..68ce19f5de 100644 --- a/portals/developer-portal/it/Makefile +++ b/portals/developer-portal/it/Makefile @@ -16,7 +16,7 @@ # under the License. # -------------------------------------------------------------------- -.PHONY: all test test-postgres open clean deps ensure-test-tag +.PHONY: all test test-postgres test-rest-api test-rest-api-postgres open clean deps ensure-test-tag VERSION ?= $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.1-SNAPSHOT") DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform @@ -41,34 +41,48 @@ ensure-test-tag: # Run Cypress tests headlessly against SQLite (CI-friendly, no external DB required). # Requires the developer-portal image to be built first: make build (from portals/developer-portal/). +# Service list is explicit — the compose file also defines rest-api-tests, which +# `up` with no args would otherwise start too. test: ensure-test-tag - CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.yaml up --abort-on-container-exit --exit-code-from cypress - $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.yaml + -CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.yaml up devportal cypress --abort-on-container-exit --exit-code-from cypress + $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.yaml SERVICE=cypress # Run Cypress tests headlessly against PostgreSQL. # Requires the developer-portal image to be built first: make build (from portals/developer-portal/). test-postgres: ensure-test-tag - CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.postgres.yaml up --abort-on-container-exit --exit-code-from cypress - $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.postgres.yaml + -CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.postgres.yaml up postgres devportal cypress --abort-on-container-exit --exit-code-from cypress + $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.postgres.yaml SERVICE=cypress -# Capture the exit code from the cypress container even after compose exits. +# Run the REST API integration test suite (Jest + Supertest) against SQLite. +# Requires the developer-portal image to be built first: make build (from portals/developer-portal/). +test-rest-api: ensure-test-tag + -DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.yaml up devportal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests + $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.yaml SERVICE=rest-api-tests + +# Run the REST API integration test suite (Jest + Supertest) against PostgreSQL. +test-rest-api-postgres: ensure-test-tag + -DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.postgres.yaml up postgres devportal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests + $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.postgres.yaml SERVICE=rest-api-tests + +# Capture the exit code from the given service container even after compose exits. COMPOSE_FILE ?= docker-compose.test.yaml +SERVICE ?= cypress _collect-exit: - @EXIT=$$(docker compose -f $(COMPOSE_FILE) ps -q cypress | xargs docker inspect --format='{{.State.ExitCode}}' 2>/dev/null || echo 0); \ + @EXIT=$$(docker compose -f $(COMPOSE_FILE) ps -q $(SERVICE) | xargs docker inspect --format='{{.State.ExitCode}}' 2>/dev/null || echo 0); \ docker compose -f $(COMPOSE_FILE) down -v --remove-orphans; \ exit $$EXIT # Open Cypress interactive UI — runs against a LOCALLY running devportal (not in Docker). # Start your devportal with `docker compose up` in portals/developer-portal/ first. open: deps - CYPRESS_BASE_URL=https://localhost:3000 npx cypress open --e2e --config-file cypress.config.js + cd ui && CYPRESS_BASE_URL=https://localhost:3000 npx cypress open --e2e --config-file cypress.config.js # Install Node dependencies (only needed for `make open`). deps: - npm install + cd ui && npm install # Tear down any leftover test containers and volumes. clean: docker compose -f docker-compose.test.yaml down -v --remove-orphans 2>/dev/null || true docker compose -f docker-compose.test.postgres.yaml down -v --remove-orphans 2>/dev/null || true - rm -rf reports/videos reports/screenshots reports/*.json reports/*.html node_modules/.cache + rm -rf reports/videos reports/screenshots reports/*.json reports/*.html ui/node_modules/.cache rest-api/node_modules diff --git a/portals/developer-portal/it/README.md b/portals/developer-portal/it/README.md index 9ab37bab15..913935f164 100644 --- a/portals/developer-portal/it/README.md +++ b/portals/developer-portal/it/README.md @@ -1,67 +1,86 @@ # Developer Portal Integration Tests -End-to-end integration tests for the Developer Portal, validating UI rendering, REST API behaviour, and service health using Cypress. +Integration tests for the Developer Portal. There are two suites, both run against a +real portal instance in Docker Compose: + +- **REST API suite** (`rest-api/`) — Jest + Supertest tests that exercise the + Admin/DevPortal REST APIs, webhook delivery, key generation, and database side effects. +- **UI E2E suite** (`ui/`) — Cypress tests that validate portal rendering, authentication + flows, try-out consoles, theming, and search in a headless browser. + +Each suite can run against either **SQLite** (default, no external DB) or **PostgreSQL**. ## Architecture ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Test Suite (Cypress) │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐ │ -│ │ Spec Files │ │ Commands │ │ Fixtures │ │ -│ │ (JS/cy.js) │ │ (custom) │ │ (org.json, ...) │ │ -│ └───────────────┘ └───────────────┘ └───────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ +┌──────────────────────────────┐ ┌──────────────────────────────┐ +│ REST API suite (Jest) │ │ UI suite (Cypress) │ +│ rest-api/**/*.spec.js │ │ ui/cypress/e2e/**/*.cy.js │ +│ (Supertest + DB asserts) │ │ (headless Electron) │ +└──────────────┬───────────────┘ └──────────────┬───────────────┘ + │ │ + ▼ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Docker Compose Environment │ -│ ┌────────────────────────┐ ┌─────────────────────────────┐ │ -│ │ developer-portal │ │ postgres │ │ -│ │ :3000 (HTTP) │◄───────│ :5432 │ │ -│ │ /health │ │ (seeded with ACME org) │ │ -│ └────────────────────────┘ └─────────────────────────────┘ │ +│ ┌──────────────┐ ┌──────────────────────┐ ┌────────────────┐ │ +│ │ platform-api │◄──│ developer-portal │──►│ postgres │ │ +│ │ :9243 │ │ :3000 (HTTP) │ │ :5432 │ │ +│ │ (auth/IdP) │ │ /health │ │ (postgres │ │ +│ └──────────────┘ └──────────────────────┘ │ profile only)│ │ +│ └────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` **Components:** -- **Cypress** — E2E and REST API test framework -- **Docker Compose** — Orchestrates the devportal and its Postgres database for testing -- **Seed Data** — ACME organisation and default view pre-loaded from `artifacts/docker-init/` +- **platform-api** — provides file-based auth / IdP so the REST API suite can perform real + session logins (`admin`/`admin`, `publisher`/`publisher`, `developer`/`developer`). +- **developer-portal** — the pre-built image under test, tagged `:test` by `make ensure-test-tag`. +- **Jest + Supertest** — REST API test framework. +- **Cypress** — UI E2E test framework (headless Electron). +- **SQLite / PostgreSQL** — SQLite by default; the `-postgres` targets swap in a Postgres service. ## Prerequisites - Docker and Docker Compose -- Built `developer-portal` image (run `make build` from `portals/developer-portal/`) +- A built `developer-portal` image — run `make build` from `portals/developer-portal/` first. + (The Make targets here auto-tag that image as `:test`.) ## Quick Start ```bash -# Build the developer-portal image and run all tests +# 1. Build the developer-portal image (from the portal root) cd portals/developer-portal && make build -cd it && make test + +# 2. Run the tests (from this directory) +cd it +make test-rest-api # REST API suite (Jest, SQLite) +make test # UI E2E suite (Cypress, SQLite) ``` ## Project Structure ``` portals/developer-portal/it/ -├── cypress/ -│ ├── e2e/ -│ │ ├── smoke.cy.js # Health, portal load, and asset tests -│ │ └── api-listing.cy.js # REST API and API browse page tests -│ ├── fixtures/ -│ │ └── org.json # Seeded ACME organisation IDs and handles -│ └── support/ -│ ├── commands.js # Custom Cypress commands (apiRequest, visitPortal, ...) -│ └── e2e.js # Global support file (imports commands) -├── reports/ # Test artifacts (generated at runtime) -│ ├── videos/ # Cypress screen recordings -│ └── screenshots/ # Screenshots on failure -├── cypress.config.js # Cypress configuration -├── docker-compose.test.yaml # Test environment orchestration -├── Makefile # Developer commands -├── package.json +├── rest-api/ # REST API suite (Jest + Supertest) +│ ├── /*.spec.js # apis, api-keys, applications, subscriptions, +│ │ # key-managers, mcp-servers, webhook-subscribers, ... +│ ├── support/ # client.js, db.js, fixtures.js, webhook-sink.js, +│ │ # global-setup.js, global-teardown.js, wait-for.js, ... +│ ├── jest.config.js # Jest config (jest-junit → reports/rest-api-results.xml) +│ └── package.json +├── ui/ # UI E2E suite (Cypress) +│ ├── cypress/ +│ │ ├── e2e/ # 000-smoke, auth, rest-apis, graphql-apis, +│ │ │ └── **/*.cy.js # mcp-servers, websocket-apis, design-mode, search, ... +│ │ ├── fixtures/ # org.json, users.json +│ │ └── support/ # commands.js, e2e.js, commands/{auth,applications}.js +│ ├── cypress.config.js +│ └── package.json +├── configs/ # config-platform-api-it.toml (auth/IdP config) +├── reports/ # Test artifacts (generated at runtime) +├── docker-compose.test.yaml # Test environment (SQLite) +├── docker-compose.test.postgres.yaml # Test environment (PostgreSQL) +├── Makefile # Developer commands └── README.md ``` @@ -69,73 +88,115 @@ portals/developer-portal/it/ | Command | Description | |---------|-------------| -| `make test` | Run all Cypress specs headlessly inside Docker (CI-friendly) | -| `make open` | Open Cypress interactive UI against a locally running devportal | +| `make test` | Run the Cypress UI suite headlessly (SQLite, CI-friendly) | +| `make test-postgres` | Run the Cypress UI suite headlessly (PostgreSQL) | +| `make test-rest-api` | Run the Jest REST API suite (SQLite) | +| `make test-rest-api-postgres` | Run the Jest REST API suite (PostgreSQL) | +| `make open` | Open the Cypress interactive UI against a locally running devportal | | `make deps` | Install Node dependencies (only needed for `make open`) | | `make clean` | Remove test containers, volumes, and report artifacts | -## Running Tests +> All targets require the `developer-portal` image to be built first (`make build` in +> `portals/developer-portal/`). `make ensure-test-tag` (run automatically) tags it as `:test`. -```bash -# Run all tests (headless, inside Docker) -make test +You can also run both UI suites from the portal root: `make -C portals/developer-portal it` +(SQLite) and `make -C portals/developer-portal it-postgres`. -# Open the interactive Cypress UI against a local devportal -# First start the devportal: docker compose up (from portals/developer-portal/) -make open -``` +## Continuous Integration -**Note:** `make open` launches Cypress directly in E2E mode (`--e2e`), bypassing the Launchpad setup screen and showing the spec list immediately. +Both suites run automatically on pull requests that touch `portals/developer-portal/**`, +via [`.github/workflows/devportal-integration-test.yml`](../../../.github/workflows/devportal-integration-test.yml): -## Test Reports +- **`rest-api-test`** — builds the image and runs `make test-rest-api` / + `make test-rest-api-postgres` in an `sqlite` × `postgres` matrix. +- **`ui-test`** — builds the image and runs `make test` (Cypress, SQLite). -After running tests, reports are available at: +Test reports (`it/reports/`) are uploaded as workflow artifacts on every run. The workflow +can also be triggered manually via **workflow_dispatch**. -| Report | Location | -|--------|----------| -| Screen recordings | `reports/videos/*.mp4` | -| Failure screenshots | `reports/screenshots/` | +> **Note:** `make open` launches Cypress directly in E2E mode (`--e2e`), bypassing the +> Launchpad setup screen and showing the spec list immediately. It runs against a locally +> running devportal (start it with `docker compose up` from `portals/developer-portal/`). -## Custom Commands +## Cypress Custom Commands + +Defined in `ui/cypress/support/`: | Command | Description | |---------|-------------| -| `cy.visitPortal(path)` | Navigate to a path inside the ACME/default portal view | -| `cy.portalUrl(path)` | Build a URL under the ACME/default view without visiting it | +| `cy.visitPortal(path)` | Navigate to a path inside the default portal view | +| `cy.portalUrl(path)` | Build a URL under the default view without visiting it | | `cy.apiRequest(method, path, options)` | `cy.request` wrapper that injects the IT API key header for admin-protected endpoints | +| `cy.login(username, password)` | Perform a real login flow (see `support/commands/auth.js`) | +| `cy.logout()` | Log the current user out | +| `cy.createApplication(name)` / `cy.deleteApplication(name)` | Create/delete an application (see `support/commands/applications.js`) | + +## Example Tests -## Example Test +**UI (Cypress)** — `ui/cypress/e2e/`: ```js describe('Developer Portal — API Listing', () => { - context('REST API', () => { - it('GET /devportal/organizations returns 200 with an array', () => { - cy.apiRequest('GET', '/devportal/organizations').then((resp) => { - expect(resp.status).to.eq(200); - expect(resp.body).to.be.an('array'); - }); + it('GET /devportal/organizations returns 200 with an array', () => { + cy.apiRequest('GET', '/devportal/organizations').then((resp) => { + expect(resp.status).to.eq(200); + expect(resp.body).to.be.an('array'); }); }); - context('UI — API browse page', () => { - it('loads the API list page without errors', () => { - cy.visitPortal('/apis'); - cy.get('body').should('be.visible'); - cy.get('body').should('not.contain.text', '500'); - }); + it('loads the API browse page without errors', () => { + cy.visitPortal('/apis'); + cy.get('body').should('be.visible'); + cy.get('body').should('not.contain.text', '500'); }); }); ``` -## Authentication in Tests +**REST API (Jest + Supertest)** — `rest-api/`: + +```js +const client = require('../support/client'); + +describe('Organizations REST API', () => { + beforeAll(async () => { + await client.login('admin'); // real session login via platform-api + }); + + it('lists organizations', async () => { + const res = await client.as('admin').get('/organizations'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); +``` -The IT environment enables API key authentication so that Cypress can call admin-protected REST endpoints without a login session. The key is configured in `docker-compose.test.yaml` and automatically injected by `cy.apiRequest()` via the `x-wso2-api-key` request header. +## Test Reports -UI tests that exercise browser-side rendering do not require authentication — they access the public-facing portal routes directly. +After a run, artifacts are available under `reports/`: + +| Report | Location | +|--------|----------| +| REST API JUnit results | `reports/rest-api-results.xml` | +| Cypress screen recordings | `reports/videos/*.mp4` | +| Cypress failure screenshots | `reports/screenshots/` | + +## Authentication in Tests + +- **REST API suite** performs **real session logins** against `platform-api` using the + file-based users defined in `configs/config-platform-api-it.toml` + (`admin`/`admin`, `publisher`/`publisher`, `developer`/`developer`). +- **UI suite** uses both real login flows (`auth/` specs) and, for admin-protected REST + calls, an IT API key injected via the `x-wso2-api-key` header. ## Adding New Tests -1. Create a new spec file under `cypress/e2e/` (e.g., `my-feature.cy.js`). -2. Use `cy.visitPortal(path)` for UI tests and `cy.apiRequest(method, path)` for REST API tests. -3. Reference `cy.fixture('org')` to access the seeded ACME organisation identifiers. -4. Run `make test` to verify before committing. +**REST API (Jest):** +1. Add a `*.spec.js` file under the relevant `rest-api//` directory. +2. Use the helpers in `rest-api/support/` (`client.js` for authenticated requests, + `db.js` to assert database side effects, `webhook-sink.js` for webhook delivery, etc.). +3. Run `make test-rest-api` to verify before committing. + +**UI (Cypress):** +1. Add a `*.cy.js` file under the relevant `ui/cypress/e2e//` directory. +2. Use the custom commands in `ui/cypress/support/` and fixtures in `ui/cypress/fixtures/`. +3. Run `make test` to verify before committing. diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml new file mode 100644 index 0000000000..63707d3ec1 --- /dev/null +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -0,0 +1,68 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# -------------------------------------------------------------------- +# +# Platform API file-based auth config for the developer-portal backend IT +# suite. Modeled on ../../configs/config-platform-api.toml.example, with three +# accounts (admin / publisher / developer, password == username) instead of +# just admin, so tests exercise real role-scoped authorization rather than the +# devportal-it-test-key API key bypass. +# +# Regenerate a password hash: htpasswd -bnBC 10 "" | tr -d ':\n' +# -------------------------------------------------------------------- + +log_level = "INFO" +port = "9243" + +[database] +driver = "sqlite3" +path = "/app/data/platform-api-it.db" + +[auth.jwt] +enabled = true +issuer = "platform-api-it" + +[auth.idp] +enabled = false + +[auth.file_based] +enabled = true + +[auth.file_based.organization] +id = "default" +display_name = "Default" +region = "us" + +# Full access — organization management plus every resource area. +[[auth.file_based.users]] +username = "admin" +password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." +scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" + +# Content/config manager — APIs, MCP servers, key managers, subscription +# plans, views/labels, webhook subscribers, API workflows. No org management, +# no application ownership (that's the developer's). +[[auth.file_based.users]] +username = "publisher" +password_hash = "$2y$10$BN9I5oPs34clNmhlO0CX0uDMKMnh9xkczGGmuLiInXSe/KOF5wqFW" +scopes = "dp:org_read dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read" + +# Portal end-user — read APIs/MCP servers, own applications, subscriptions, +# and API keys. No org, key-manager, view/label, or webhook-subscriber management. +[[auth.file_based.users]] +username = "developer" +password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" +scopes = "dp:org_read dp:api_read dp:api_content_read dp:mcp_read dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:view_read dp:label_read" + +[tls] +cert_dir = "/app/data/certs" + +[default_devportal] +enabled = false diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index ba6be74095..dfa35b0f10 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -41,14 +41,34 @@ services: networks: - it-devportal-network + platform-api: + image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + command: ["-config", "/etc/platform-api/config-platform-api.toml"] + environment: + AUTH_JWT_SECRET_KEY: "it-test-jwt-secret-key-not-for-production" + volumes: + - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro + - platform-api-data:/app/data + healthcheck: + test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + networks: + - it-devportal-network + devportal: image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/developer-portal:test depends_on: postgres: condition: service_healthy + platform-api: + condition: service_healthy volumes: - ./test-config.toml:/app/configs/config.toml:ro environment: + # DB-agnostic settings (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_DP_DATABASE_HOST: postgres APIP_DP_DATABASE_PORT: 5432 APIP_DP_DATABASE_TYPE: postgres @@ -77,12 +97,49 @@ services: CYPRESS_BASE_URL: "http://devportal:3000" CYPRESS_API_KEY: "devportal-it-test-key" volumes: - - .:/e2e + - ./ui:/e2e - ./reports:/e2e/reports networks: - it-devportal-network command: ["cypress", "run", "--headless", "--browser", "electron"] + rest-api-tests: + image: node:22-alpine + depends_on: + devportal: + condition: service_healthy + working_dir: /rest-api + environment: + DEVPORTAL_BASE_URL: "http://devportal:3000" + DEVPORTAL_ORG_HANDLE: "default" + DEVPORTAL_ADMIN_USERNAME: "admin" + DEVPORTAL_ADMIN_PASSWORD: "admin" + DEVPORTAL_PUBLISHER_USERNAME: "publisher" + DEVPORTAL_PUBLISHER_PASSWORD: "publisher" + DEVPORTAL_DEVELOPER_USERNAME: "developer" + DEVPORTAL_DEVELOPER_PASSWORD: "developer" + DEVPORTAL_DB_DIALECT: postgres + DEVPORTAL_DB_HOST: postgres + DEVPORTAL_DB_PORT: 5432 + DEVPORTAL_DB_USERNAME: devportal + DEVPORTAL_DB_PASSWORD: devportal + DEVPORTAL_DB_DATABASE: devportal + # Reachable from inside the compose network by the webhook dispatcher. + WEBHOOK_SINK_URL: "http://rest-api-tests:4500" + MOCK_TOKEN_ENDPOINT_URL: "http://rest-api-tests:4504" + volumes: + - ./rest-api:/rest-api + - ./reports:/rest-api/reports + networks: + - it-devportal-network + # better-sqlite3 (an optionalDependency used only by the SQLite variant) is the + # sole reason this image would need a native build toolchain — skip it here with + # --omit=optional so the Postgres run needs no apk add python3/make/g++. + command: ["sh", "-c", "npm install --no-audit --no-fund --omit=optional && npm test"] + networks: it-devportal-network: driver: bridge + +volumes: + platform-api-data: diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 305929509c..8901de1441 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -25,12 +25,31 @@ # The devportal service uses the pre-built image tagged :test (set by make ensure-test-tag). services: + platform-api: + image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + command: ["-config", "/etc/platform-api/config-platform-api.toml"] + environment: + AUTH_JWT_SECRET_KEY: "it-test-jwt-secret-key-not-for-production" + volumes: + - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro + - platform-api-data:/app/data + healthcheck: + test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + networks: + - it-devportal-network + devportal: image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/developer-portal:test - volumes: - - ./test-config.toml:/app/configs/config.toml:ro + depends_on: + platform-api: + condition: service_healthy environment: - # SQLite — no external database required. + # SQLite — no external database required. Shared, DB-agnostic settings + # (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_DP_DATABASE_TYPE: sqlite APIP_DP_DATABASE_FILE: /tmp/devportal-it.db APIP_DP_SERVER_BASEURL: "http://devportal:3000" @@ -40,6 +59,14 @@ services: timeout: 5s retries: 20 start_period: 20s + volumes: + - ./test-config.toml:/app/configs/config.toml:ro + # Named volume at /tmp (not a new mount point) so Docker copies the image's + # existing /tmp ownership/permissions into it on first use — a fresh mount + # at a brand-new path would be root-owned and unwritable by the app user. + # Shared with rest-api-tests (read-only, different path there) so specs can + # assert DP_EVENTS/DP_EVENT_DELIVERIES rows directly. + - sqlite-data:/tmp networks: - it-devportal-network @@ -55,12 +82,51 @@ services: CYPRESS_BASE_URL: "http://devportal:3000" CYPRESS_API_KEY: "devportal-it-test-key" volumes: - - .:/e2e + - ./ui:/e2e - ./reports:/e2e/reports networks: - it-devportal-network command: ["cypress", "run", "--headless", "--browser", "electron"] + rest-api-tests: + image: node:22-alpine + depends_on: + devportal: + condition: service_healthy + working_dir: /rest-api + environment: + DEVPORTAL_BASE_URL: "http://devportal:3000" + # Real session login (config-platform-api-it.toml) — password == username + # for all three, matching the admin/admin convention documented in + # configs/config-platform-api.toml.example. + DEVPORTAL_ORG_HANDLE: "default" + DEVPORTAL_ADMIN_USERNAME: "admin" + DEVPORTAL_ADMIN_PASSWORD: "admin" + DEVPORTAL_PUBLISHER_USERNAME: "publisher" + DEVPORTAL_PUBLISHER_PASSWORD: "publisher" + DEVPORTAL_DEVELOPER_USERNAME: "developer" + DEVPORTAL_DEVELOPER_PASSWORD: "developer" + DEVPORTAL_DB_DIALECT: sqlite + DEVPORTAL_DB_STORAGE: /shared-db/devportal-it.db + # Reachable from inside the compose network by the webhook dispatcher. + WEBHOOK_SINK_URL: "http://rest-api-tests:4500" + MOCK_TOKEN_ENDPOINT_URL: "http://rest-api-tests:4504" + volumes: + - ./rest-api:/rest-api + - ./reports:/rest-api/reports + # Mounted at a path distinct from this container's own /tmp (needed for + # npm install), read-only since only the devportal process should write it. + - sqlite-data:/shared-db:ro + networks: + - it-devportal-network + # better-sqlite3 compiles from source on node:22-alpine (no prebuilt binary + # for this platform) — needs python3/make/g++ for node-gyp. + command: ["sh", "-c", "apk add --no-cache python3 make g++ >/dev/null && npm install --no-audit --no-fund && npm test"] + networks: it-devportal-network: driver: bridge + +volumes: + sqlite-data: + platform-api-data: diff --git a/portals/developer-portal/it/rest-api/ai-discovery/llms-txt.spec.js b/portals/developer-portal/it/rest-api/ai-discovery/llms-txt.spec.js new file mode 100644 index 0000000000..8f938acd56 --- /dev/null +++ b/portals/developer-portal/it/rest-api/ai-discovery/llms-txt.spec.js @@ -0,0 +1,77 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// GET /llms.txt (portal-wide) and GET /:orgName/views/:viewName/llms.txt +// (org/view-specific discovery index) — src/app.js, src/controllers/apiContentController.js +// loadLlmsTxt. Neither is under /api/v0.9 nor session-authenticated (public +// agent-discovery routes) — use client.raw() rather than client.as(role). + +const client = require('../support/client'); +const { createApi } = require('../support/fixtures'); + +describe('AI/LLM discovery (llms.txt)', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('GET /llms.txt returns the portal-wide discovery index as text/plain', async () => { + const res = await client.raw().get('/llms.txt'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + expect(res.text).toContain('AI Agent Entry Point'); + }); + + it("GET /:org/views/:view/llms.txt lists that view's visible APIs and MCP servers", async () => { + const api = await createApi({ name: 'Discoverable API', labels: ['default'] }); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/llms.txt`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + expect(res.text).toContain(api.name); + }); + + it('excludes APIs with agentVisibility HIDDEN from the discovery index', async () => { + const visible = await createApi({ name: 'Visible Discoverable API', labels: ['default'], agentVisibility: 'VISIBLE' }); + const hidden = await createApi({ name: 'Hidden Discoverable API', labels: ['default'], agentVisibility: 'HIDDEN' }); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/llms.txt`); + expect(res.status).toBe(200); + expect(res.text).toContain(visible.name); + expect(res.text).not.toContain(hidden.name); + }); + + // aiEnabled is toggled via the session-authenticated settings page route + // (src/routes/pages/settingsRoute.js — ensureAuthenticated + CSRF), not the + // REST API this suite otherwise exercises — client.page(role) reuses the same + // logged-in session/CSRF token as client.as(role), just without the /api/v0.9 + // prefix, so this stays in the Jest suite instead of needing Cypress. + it('returns 404 when aiEnabled is false for the org', async () => { + const configPath = `/${client.ORG_HANDLE}/views/default/llms-config`; + try { + const save = await client.page('publisher').put(configPath, { aiEnabled: false, portalName: '', portalDescription: '' }); + expect(save.status).toBe(200); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/llms.txt`); + expect(res.status).toBe(404); + } finally { + // Restore — this view/org is shared by every other test in this file. + // Inside finally so a failed/non-200 disable PUT can't leave aiEnabled off. + await client.page('publisher').put(configPath, { aiEnabled: true, portalName: '', portalDescription: '' }); + } + }); +}); diff --git a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js new file mode 100644 index 0000000000..2d2cf157dc --- /dev/null +++ b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js @@ -0,0 +1,128 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// API keys are scoped under their owning resource: POST /apis/{apiId}/api-keys/generate, +// GET /apis/{apiId}/api-keys, POST .../regenerate, .../revoke, .../associate, .../dissociate. +// Key `id` must match ^[a-z0-9][a-z0-9_-]{0,127}$ — uniqueHandle()'s dashes/digits satisfy this. +// `publisher` manages API keys; `developer` owns the application used in +// associate/dissociate tests. + +const client = require('../support/client'); +const { createApi, uniqueHandle } = require('../support/fixtures'); + +describe('API keys', () => { + let api; + + beforeAll(async () => { + await client.login('publisher'); + await client.login('developer'); + }); + + beforeEach(async () => { + api = await createApi(); + }); + + it('generates an API key', async () => { + const id = uniqueHandle('key').toLowerCase(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + expect(res.status).toBe(201); + expect(res.body.id).toBe(id); + expect(res.body.key).toBeDefined(); + expect(res.body.status).toBe('ACTIVE'); + }); + + it('lists API keys for an API', async () => { + const id = uniqueHandle('key').toLowerCase(); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + + const res = await client.as('publisher').get(`/apis/${api.id}/api-keys`); + expect(res.status).toBe(200); + expect(res.body.list.some((k) => k.id === id)).toBe(true); + }); + + it('regenerates an API key', async () => { + const id = uniqueHandle('key').toLowerCase(); + const create = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId: id }); + expect(res.status).toBe(200); + expect(res.body.key).toBeDefined(); + expect(res.body.key).not.toBe(create.body.key); + }); + + it('revokes an API key', async () => { + const id = uniqueHandle('key').toLowerCase(); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + + const revoke = await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId: id }); + expect(revoke.status).toBe(204); + + const list = await client.as('publisher').get(`/apis/${api.id}/api-keys`); + const key = list.body.list.find((k) => k.id === id); + expect(key.status).toBe('REVOKED'); + }); + + it('rejects regenerating a revoked key', async () => { + const id = uniqueHandle('key').toLowerCase(); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId: id }); + + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId: id }); + expect(res.status).toBe(409); + }); + + // NOTE: associate/dissociate resolve the target application via + // resolveAppId(orgId, util.resolveActor(req), appHandle) — i.e. scoped to + // the *caller's own* created_by, not just API-key scope. A publisher can + // generate the key but can never associate it with another user's + // application (404 "Application not found") even with full dp:api_key_manage + // scope — only the app's own owner can call associate/dissociate on it. + // That's surprising given the OpenAPI description ("Associates ... with an + // application, for analytics attribution only") doesn't mention this + // same-owner constraint, and it's not how a publisher distributing keys to + // subscribers would expect this to work — worth a source-level look at + // apiKeyController.js:273 (associateApiKeyApplication) and its dissociate + // counterpart. Using `developer` throughout here since it's the only role + // that can currently make this call succeed for its own app. + it('associates an API key with an application', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + const appId = uniqueHandle('app'); + await client.as('developer').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('developer').post('/applications', { id: appId, displayName: 'Assoc App', description: 'd' }); + + const res = await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); + expect(res.status).toBe(200); + }); + + it('dissociates an API key from an application', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + const appId = uniqueHandle('app'); + await client.as('developer').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('developer').post('/applications', { id: appId, displayName: 'Dissoc App', description: 'd' }); + await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); + + const res = await client.as('developer').post(`/apis/${api.id}/api-keys/dissociate`, { keyId }); + expect(res.status).toBe(204); + }); + + it('rejects generating a key with an invalid id format', async () => { + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: 'Invalid ID!' }); + expect(res.status).toBe(400); + }); + +}); diff --git a/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js new file mode 100644 index 0000000000..bd1b6d3206 --- /dev/null +++ b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js @@ -0,0 +1,264 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Follows ../applications/webhook-events.spec.js for the full working pattern. +// VALID_EVENT_TYPES for this resource (src/services/webhooks/eventPublisher.js): +// apikey.generated, apikey.regenerated, apikey.revoked, apikey.application_updated. + +const crypto = require('crypto'); +const client = require('../support/client'); +const db = require('../support/db'); +const { waitForEvent, waitForDelivery, poll } = require('../support/wait-for'); +const { createApi, createWebhookSubscriber, uniqueHandle } = require('../support/fixtures'); +const { createWebhookSink, resolveSinkUrl } = require('../support/webhook-sink'); +const { decryptFromEnvelope } = require('../support/envelopeCrypto'); + +describe('api-keys webhook events', () => { + let api; + let sink; + let subscriber; + const sinkUrl = resolveSinkUrl(4502); + + beforeAll(async () => { + await client.login('publisher'); + await client.login('developer'); + await client.login('admin'); + sink = createWebhookSink(); + await sink.start(Number(sinkUrl.port)); + }); + + afterAll(async () => { + await sink.stop(); + await db.close(); + }); + + beforeEach(async () => { + api = await createApi(); + subscriber = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['apikey.*'], + }); + }); + + afterEach(async () => { + await client.as('admin').del(`/webhook-subscribers/${subscriber.id}`); + sink.received.length = 0; + }); + + it('publishes and delivers apikey.generated', async () => { + const since = new Date(); + const keyId = uniqueHandle('key').toLowerCase(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + expect(res.status).toBe(201); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.generated', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('apikey.generated'); + expect(received).toBeDefined(); + // `key` is passed as a secretField (see apiKeyService.generate) — this + // subscriber has no publicKey, so it's delivered without any encrypted + // fields rather than the plaintext ever appearing in `data`. + expect(received.body.encrypted_fields).toEqual([]); + expect(received.body.data).toEqual({ + key_id: res.body.keyId, + handle: keyId, + display_name: keyId, + expires_at: null, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + expect(received.body.data.key).toBeUndefined(); + }); + + it('publishes and delivers apikey.regenerated', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + + const since = new Date(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId }); + expect(res.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.regenerated', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('apikey.regenerated'); + expect(received).toBeDefined(); + expect(received.body.encrypted_fields).toEqual([]); + expect(received.body.data).toEqual({ + key_id: generated.body.keyId, + handle: keyId, + display_name: keyId, + expires_at: null, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + expect(received.body.data.key).toBeUndefined(); + }); + + it('publishes and delivers apikey.revoked', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + + const since = new Date(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId }); + expect(res.status).toBe(204); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.revoked', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('apikey.revoked'); + expect(received).toBeDefined(); + expect(received.body.data).toEqual({ + key_id: generated.body.keyId, + handle: keyId, + display_name: keyId, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + }); + + it('publishes and delivers apikey.application_updated on associate/dissociate', async () => { + // See api-keys.spec.js's note on associate/dissociate: the target app is + // resolved by the caller's own created_by, so the same role must both + // own the app and call associate. + const keyId = uniqueHandle('key').toLowerCase(); + const appId = uniqueHandle('app'); + await client.as('developer').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('developer').post('/applications', { id: appId, displayName: 'Assoc App', description: 'd' }); + + const since = new Date(); + const assoc = await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); + expect(assoc.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.application_updated', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('apikey.application_updated'); + expect(received).toBeDefined(); + // `application.id` here is the app's internal uuid (apiKeyService.resolveApp), + // never exposed over REST — only handle/display_name are checkable directly. + expect(received.body.data).toEqual({ + key_id: expect.any(String), + handle: keyId, + display_name: keyId, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + application: { id: expect.any(String), display_name: 'Assoc App', handle: appId }, + }); + }); + + it('encrypts the key value field to the subscriber public key when configured', async () => { + // TODO(pqc): migrate — RSA keypair mirrors the app's quantum-vulnerable + // envelope crypto (src/services/webhooks/envelopeCrypto.js); migrate together. + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const encryptedSubscriber = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['apikey.*'], + publicKey, + }); + + const keyId = uniqueHandle('key').toLowerCase(); + const since = new Date(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + expect(res.status).toBe(201); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.generated', since }); + + // Two subscribers now match apikey.* (the outer describe's default `subscriber` + // plus this one), so waitForDelivery's single-match-by-eventUuid could resolve to + // either one's delivery row — poll the sink directly for the specific delivery + // that has encrypted_fields, rather than racing on which subscriber's row lands + // first in the DB. + const received = await poll(() => sink.received.find((r) => r.body?.event_id === event.uuid && r.body?.encrypted_fields?.length)); + expect(received).toBeDefined(); + expect(received.body.encrypted_fields).toEqual(['key']); + expect(received.body.data.key).toEqual({ + wrappedKey: expect.any(String), + iv: expect.any(String), + tag: expect.any(String), + ciphertext: expect.any(String), + }); + + const decrypted = decryptFromEnvelope(privateKey, received.body.data.key); + expect(decrypted).toBe(res.body.key); + + await client.as('admin').del(`/webhook-subscribers/${encryptedSubscriber.id}`); + }); + + // These close a gap the tests above don't cover: response-correctness and + // event-correctness are asserted in separate suites, but a bug could make + // one right and the other wrong on the *same* call (e.g. regenerate.js's + // 409 guard at apiKeyService.js:210 firing after publish() instead of + // before it). Assert the combination directly instead of trusting that + // each half being independently correct implies the pair is. + describe('action + event consistency', () => { + it('does not publish apikey.regenerated when regenerate is rejected as already revoked', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId }); + + const since = new Date(); + const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId }); + expect(res.status).toBe(409); + + const events = await db.findEvents({ orgUuid: await db.findOrgUuidByHandle(client.ORG_HANDLE), type: 'apikey.regenerated', since }); + expect(events).toHaveLength(0); + expect(sink.findDeliveryFor('apikey.regenerated')).toBeUndefined(); + }); + + it('dissociate clears the application from both the list response and apikey.application_updated', async () => { + const keyId = uniqueHandle('key').toLowerCase(); + const appId = uniqueHandle('app'); + await client.as('developer').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('developer').post('/applications', { id: appId, displayName: 'Dissoc App', description: 'd' }); + await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); + + const since = new Date(); + const res = await client.as('developer').post(`/apis/${api.id}/api-keys/dissociate`, { keyId }); + expect(res.status).toBe(204); + + const list = await client.as('developer').get(`/apis/${api.id}/api-keys`); + const key = list.body.list.find((k) => k.id === keyId); + expect(key.appId).toBeNull(); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'apikey.application_updated', since }); + expect(event).toBeDefined(); + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + // Both associate and dissociate fire apikey.application_updated, so + // sink.findDeliveryFor (first-match-by-type) would return associate's + // delivery here — match on event_id instead to get the dissociate one. + const received = sink.received.find((r) => r.body?.event_id === event.uuid); + expect(received).toBeDefined(); + expect(received.body.data.application).toBeNull(); + }); + }); +}); diff --git a/portals/developer-portal/it/rest-api/api-workflows/api-workflows.spec.js b/portals/developer-portal/it/rest-api/api-workflows/api-workflows.spec.js new file mode 100644 index 0000000000..c6e8bdeb58 --- /dev/null +++ b/portals/developer-portal/it/rest-api/api-workflows/api-workflows.spec.js @@ -0,0 +1,123 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /views/{viewId}/api-workflows (Arazzo-based agent workflows). +// APIWorkflowCreateRequest requires { displayName, description }; content goes in +// `apiWorkflowDefinition` (JSON/YAML) when contentType is the default ARAZZO. +// `admin` manages the view/label setup; `publisher` manages workflow content. + +const client = require('../support/client'); +const { uniqueHandle, createView } = require('../support/fixtures'); + +const SAMPLE_ARAZZO = { + arazzo: '1.0.0', + info: { title: 'Sample workflow', version: '1.0.0' }, + sourceDescriptions: [], + workflows: [], +}; + +describe('API workflows', () => { + let view; + + beforeAll(async () => { + await client.login('admin'); + await client.login('publisher'); + }); + + beforeEach(async () => { + view = await createView(); + }); + + it('creates an API workflow (Arazzo file_content + agent_prompt)', async () => { + const id = uniqueHandle('workflow'); + const res = await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id, + displayName: 'Weather Onboarding', + description: 'Guides users through the Weather API onboarding workflow.', + apiWorkflowDefinition: SAMPLE_ARAZZO, + }); + expect(res.status).toBe(201); + }); + + it('retrieves an API workflow', async () => { + const id = uniqueHandle('workflow'); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id, displayName: 'Retrievable Workflow', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, + }); + + const res = await client.as('publisher').get(`/views/${view.id}/api-workflows/${id}`); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Retrievable Workflow'); + }); + + it('updates an API workflow', async () => { + const id = uniqueHandle('workflow'); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id, displayName: 'Original Workflow', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, + }); + + const res = await client.as('publisher').put(`/views/${view.id}/api-workflows/${id}`, { + displayName: 'Updated Workflow', + }); + expect(res.status).toBe(200); + + const get = await client.as('publisher').get(`/views/${view.id}/api-workflows/${id}`); + expect(get.body.displayName).toBe('Updated Workflow'); + }); + + it('deletes an API workflow', async () => { + const id = uniqueHandle('workflow'); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id, displayName: 'To Delete', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, + }); + + const del = await client.as('publisher').del(`/views/${view.id}/api-workflows/${id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/views/${view.id}/api-workflows/${id}`); + expect(get.status).toBe(404); + }); + + it('lists API workflows for a view', async () => { + const id = uniqueHandle('workflow'); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id, displayName: 'Listed Workflow', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, + }); + + const res = await client.as('publisher').get(`/views/${view.id}/api-workflows`); + expect(res.status).toBe(200); + expect(res.body.list.some((w) => w.id === id)).toBe(true); + }); + + it('includes HIDDEN workflows in the publisher listing with their agentVisibility flag', async () => { + const visibleId = uniqueHandle('workflow-visible'); + const hiddenId = uniqueHandle('workflow-hidden'); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id: visibleId, displayName: 'Visible', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, agentVisibility: 'VISIBLE', + }); + await client.as('publisher').post(`/views/${view.id}/api-workflows`, { + id: hiddenId, displayName: 'Hidden', description: 'd', apiWorkflowDefinition: SAMPLE_ARAZZO, agentVisibility: 'HIDDEN', + }); + + const res = await client.as('publisher').get(`/views/${view.id}/api-workflows`); + expect(res.body.list.some((w) => w.id === visibleId)).toBe(true); + const hidden = res.body.list.find((w) => w.id === hiddenId); + expect(hidden).toBeDefined(); + expect(hidden.agentVisibility).toBe('HIDDEN'); + }); +}); diff --git a/portals/developer-portal/it/rest-api/api-workflows/generate-prompt.spec.js b/portals/developer-portal/it/rest-api/api-workflows/generate-prompt.spec.js new file mode 100644 index 0000000000..fec85728ce --- /dev/null +++ b/portals/developer-portal/it/rest-api/api-workflows/generate-prompt.spec.js @@ -0,0 +1,56 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST /views/{viewId}/api-workflows/generate-prompt — generates the LLM agent +// execution prompt text from a proposed workflow's name/description/APIs, before +// the workflow itself necessarily exists (APIWorkflowPromptRequest requires only +// { displayName, description }). `admin` manages the view/label setup; +// `publisher` manages workflow content. + +const client = require('../support/client'); +const { createView } = require('../support/fixtures'); + +describe('API workflow prompt generation', () => { + let view; + + beforeAll(async () => { + await client.login('admin'); + await client.login('publisher'); + }); + + beforeEach(async () => { + view = await createView(); + }); + + it('generates an agent prompt from a workflow definition', async () => { + const res = await client.as('publisher').post(`/views/${view.id}/api-workflows/generate-prompt`, { + displayName: 'Weather Onboarding', + description: 'Guides users through the Weather API onboarding workflow.', + }); + expect(res.status).toBe(200); + expect(typeof res.body.agentPrompt).toBe('string'); + expect(res.body.agentPrompt.length).toBeGreaterThan(0); + }); + + it('rejects prompt generation missing the required fields', async () => { + const res = await client.as('publisher').post(`/views/${view.id}/api-workflows/generate-prompt`, { + displayName: 'Missing Description', + }); + expect(res.status).toBe(400); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/artifact-zip-apis.spec.js b/portals/developer-portal/it/rest-api/apis/artifact-zip-apis.spec.js new file mode 100644 index 0000000000..17911b965a --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/artifact-zip-apis.spec.js @@ -0,0 +1,180 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/PUT /apis via the `artifact` full-ZIP upload path (as opposed to the JSON +// `apiMetadata` field or the standalone `api`/`apiDefinition` YAML pair) — see +// apiMetadataService.js's extractFullApiBundleFromUploadedZip. A ZIP must contain +// one of api.yaml/mcp.yaml/devportal.yaml plus a definition file +// (definition.(yaml|yml|json) or apiDefinition.(yaml|yml|json)); `web`/`docs` +// directories are optional (extractApiContentFromUploadedZip's 'artifact' mode +// tolerates neither being present). + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); +const { createZip } = require('../support/zipBuilder'); + +const SAMPLE_DEFINITION = JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} }); + +function buildApiYaml({ handle, displayName, version, description, labels }) { + const labelsLine = labels !== undefined ? ` labels: [${labels.map((l) => `"${l}"`).join(', ')}]\n` : ''; + return `metadata:\n name: ${handle}\nspec:\n displayName: "${displayName}"\n version: "${version}"\n description: "${description}"\n type: REST\n status: PUBLISHED\n${labelsLine} endpoints:\n sandboxUrl: https://sandbox.example.invalid/${handle}\n productionUrl: https://backend.example.invalid/${handle}\n`; +} + +function buildZip(overrides = {}) { + const handle = overrides.handle || uniqueHandle('zip-api'); + const entries = [ + { + name: 'api.yaml', + content: buildApiYaml({ + handle, + displayName: overrides.displayName || `Zip API ${handle}`, + version: overrides.version || 'v1.0', + description: overrides.description || 'created from a zip artifact', + labels: overrides.labels, + }), + }, + { name: 'definition.json', content: overrides.definition || SAMPLE_DEFINITION }, + ]; + return { handle, zip: createZip(entries) }; +} + +async function createApiFromZip(overrides = {}) { + const { handle, zip } = buildZip(overrides); + const res = await client.as('publisher').postMultipart('/apis').attach('artifact', zip, 'artifact.zip'); + if (res.status !== 201) { + throw new Error(`Failed to seed API from zip: ${res.status} ${JSON.stringify(res.body)}`); + } + return { handle, body: res.body }; +} + +describe('APIs via artifact ZIP upload', () => { + beforeAll(async () => { + await client.login('publisher'); + await client.login('admin'); + }); + + it('creates a REST API from an artifact ZIP', async () => { + const { handle } = await createApiFromZip({ displayName: 'Created From Zip', description: 'first version' }); + + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.status).toBe(200); + expect(get.body.name).toBe('Created From Zip'); + expect(get.body.description).toBe('first version'); + expect(get.body.type).toBe('RestApi'); + expect(get.body.endPoints.productionURL).toBe(`https://backend.example.invalid/${handle}`); + }); + + it('rejects an artifact ZIP missing a metadata file (api.yaml/mcp.yaml/devportal.yaml)', async () => { + const zip = createZip([{ name: 'definition.json', content: SAMPLE_DEFINITION }]); + const res = await client.as('publisher').postMultipart('/apis').attach('artifact', zip, 'artifact.zip'); + expect(res.status).toBe(400); + }); + + it('rejects an artifact ZIP missing a definition file', async () => { + const handle = uniqueHandle('zip-api'); + const zip = createZip([{ name: 'api.yaml', content: buildApiYaml({ handle, displayName: 'No Definition', version: 'v1.0', description: 'd' }) }]); + const res = await client.as('publisher').postMultipart('/apis').attach('artifact', zip, 'artifact.zip'); + expect(res.status).toBe(400); + }); + + it('updates a REST API via an artifact ZIP upload', async () => { + const { handle } = await createApiFromZip({ displayName: 'Original Via Zip', description: 'original' }); + + const { zip: updateZip } = buildZip({ handle, displayName: 'Updated Via Zip', description: 'updated' }); + const put = await client.as('publisher').putMultipart(`/apis/${handle}`).attach('artifact', updateZip, 'artifact.zip'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.body.name).toBe('Updated Via Zip'); + expect(get.body.description).toBe('updated'); + }); + + // The specific scenario worth checking on its own: create via zip, then re-upload + // essentially the same zip with one small field changed. Both the metadata handle + // (metadata.name in the YAML, not spec.displayName) and the API's own `id` must + // stay stable across the round-trip — apiDao.update() only recomputes the handle + // from name+version when the request supplies no handle at all; the YAML path + // always supplies one via `metadata.name`, so unlike the JSON apiMetadata field + // path this doesn't risk drifting the id/handle as a side effect of the update. + it('creates via zip then updates the same API with the same zip plus a small change', async () => { + const { handle, zip: firstZip } = buildZip({ displayName: 'Stable Zip API', description: 'version one' }); + const create = await client.as('publisher').postMultipart('/apis').attach('artifact', firstZip, 'artifact.zip'); + expect(create.status).toBe(201); + expect(create.body.id).toBe(handle); + + // Same handle/displayName/version — only `description` differs. + const { zip: secondZip } = buildZip({ handle, displayName: 'Stable Zip API', description: 'version two (small change)' }); + const put = await client.as('publisher').putMultipart(`/apis/${handle}`).attach('artifact', secondZip, 'artifact.zip'); + expect(put.status).toBe(200); + expect(put.body.id).toBe(handle); + + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.status).toBe(200); + expect(get.body.id).toBe(handle); + expect(get.body.name).toBe('Stable Zip API'); + expect(get.body.description).toBe('version two (small change)'); + expect(get.body.type).toBe('RestApi'); + expect(get.body.endPoints.productionURL).toBe(`https://backend.example.invalid/${handle}`); + }); + + describe('label handling via ZIP (spec.labels)', () => { + // mapDevportalYamlToApiMetadata used to run every `spec.labels` through + // util.normalizeStringArray, which returns `[]` whether the YAML omitted + // `labels` entirely or set it to an empty list — collapsing "not mentioned" + // and "explicitly none" into the same value. That broke both directions: + // create defaulted to no labels instead of ['default'] when omitted, and + // update wiped every existing label on any change that didn't re-list them. + it('defaults to the "default" label when a create ZIP omits spec.labels', async () => { + const { handle } = await createApiFromZip({ displayName: 'No Labels Field' }); + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.body.labels).toEqual(['default']); + }); + + it('attaches no labels when a create ZIP explicitly sets spec.labels to an empty list', async () => { + const { handle } = await createApiFromZip({ displayName: 'Explicit Empty Labels', labels: [] }); + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.body.labels).toEqual([]); + }); + + it('leaves existing labels untouched when an update ZIP omits spec.labels', async () => { + const labelId = uniqueHandle('label'); + await client.as('admin').post('/labels', { id: labelId, displayName: 'Zip Label' }); + const { handle } = await createApiFromZip({ displayName: 'Keeps Labels', labels: [labelId] }); + + const { zip: updateZip } = buildZip({ handle, displayName: 'Keeps Labels', description: 'unrelated change' }); + const put = await client.as('publisher').putMultipart(`/apis/${handle}`).attach('artifact', updateZip, 'artifact.zip'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.body.labels).toEqual([labelId]); + }); + + it('clears existing labels when an update ZIP explicitly sets spec.labels to an empty list', async () => { + const labelId = uniqueHandle('label'); + await client.as('admin').post('/labels', { id: labelId, displayName: 'Zip Label' }); + const { handle } = await createApiFromZip({ displayName: 'Clears Labels', labels: [labelId] }); + + const { zip: updateZip } = buildZip({ handle, displayName: 'Clears Labels', description: 'cleared', labels: [] }); + const put = await client.as('publisher').putMultipart(`/apis/${handle}`).attach('artifact', updateZip, 'artifact.zip'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${handle}`); + expect(get.body.labels).toEqual([]); + }); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/graphql-apis.spec.js b/portals/developer-portal/it/rest-api/apis/graphql-apis.spec.js new file mode 100644 index 0000000000..81192312a8 --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/graphql-apis.spec.js @@ -0,0 +1,141 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /apis (type: GRAPHQL). Unlike REST/SOAP, GraphQL takes a +// `schemaDefinition` multipart field in addition to (or instead of) apiDefinition. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +const SAMPLE_SCHEMA = 'type Query { hello: String }'; + +async function createGraphQLApi(overrides = {}) { + const id = overrides.id || uniqueHandle('graphql-api'); + const metadata = { + id, + name: overrides.name || `Test GraphQL API ${id}`, + version: overrides.version || 'v1.0', + type: 'GRAPHQL', + status: overrides.status || 'PUBLISHED', + endPoints: overrides.endPoints || { + productionURL: `https://backend.example.invalid/${id}`, + sandboxURL: `https://sandbox.example.invalid/${id}`, + }, + }; + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify(metadata)) + .attach('schemaDefinition', Buffer.from(overrides.schema || SAMPLE_SCHEMA), 'schema.graphql'); + if (res.status !== 201) { + throw new Error(`Failed to seed GraphQL API: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('GraphQL APIs', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('creates a GraphQL API with a schemaDefinition file', async () => { + const api = await createGraphQLApi(); + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + expect(res.body.type).toBe('GRAPHQL'); + }); + + it('updates a GraphQL API and its schema', async () => { + const api = await createGraphQLApi(); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated GraphQL API', + version: 'v1.0', + type: 'GRAPHQL', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('schemaDefinition', Buffer.from('type Query { goodbye: String }'), 'schema.graphql'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated GraphQL API'); + }); + + it('deletes a GraphQL API', async () => { + const api = await createGraphQLApi(); + const del = await client.as('publisher').del(`/apis/${api.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.status).toBe(404); + }); + + it('rejects a GraphQL API created without a schemaDefinition or apiDefinition', async () => { + const id = uniqueHandle('graphql-api-no-schema'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'No Schema API', + version: 'v1.0', + type: 'GRAPHQL', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })); + expect(res.status).toBe(400); + }); + + it('retrieves the stored GraphQL schema', async () => { + // The uploaded file's own name ('schema.graphql') isn't what's persisted — the + // schema is always stored under the fixed name apiDefinition.graphql and + // type=API_DEFINITION (apiMetadataService.js's createAPIMetadata, using + // constants.FILE_NAME.API_DEFINITION_GRAPHQL), same category REST/SOAP + // definitions use. + const schema = 'type Query { hello: String }'; + const api = await createGraphQLApi({ schema }); + const res = await client.as('publisher').get(`/apis/${api.id}/assets?type=API_DEFINITION&fileName=apiDefinition.graphql`); + expect(res.status).toBe(200); + // .graphql isn't in util.isTextFile's allowlist, so it's served with a generic + // binary content-type rather than text/plain — read whichever field supertest + // populated for that content-type. + const body = res.text !== undefined ? res.text : Buffer.from(res.body).toString('utf8'); + expect(body).toBe(schema); + }); + + it('returns 404 (not a hang) for a schema file that was never uploaded', async () => { + const id = uniqueHandle('graphql-api-no-file'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'No File API', + version: 'v1.0', + type: 'GRAPHQL', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('schemaDefinition', Buffer.from('type Query { hello: String }'), 'schema.graphql'); + expect(res.status).toBe(201); + + const get = await client.as('publisher').get(`/apis/${res.body.id}/assets?type=API_DEFINITION&fileName=does-not-exist.graphql`); + expect(get.status).toBe(404); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/rest-apis.spec.js b/portals/developer-portal/it/rest-api/apis/rest-apis.spec.js new file mode 100644 index 0000000000..f4d5822ed5 --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/rest-apis.spec.js @@ -0,0 +1,352 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /apis (type: REST). See docs/devportal-openapi-spec-v0.9.yaml +// ApiMetadataMultipartBody — creation is multipart (apiMetadata JSON + apiDefinition +// file). Publisher owns API management; follow support/fixtures.js createApi() +// for the request shape; organizations.spec.js for the assertion style. + +const client = require('../support/client'); +const { createApi, uniqueHandle } = require('../support/fixtures'); + +async function createLabel(overrides = {}) { + const id = overrides.id || uniqueHandle('label'); + const res = await client.as('admin').post('/labels', { id, displayName: overrides.displayName || id }); + if (res.status !== 201) { + throw new Error(`Failed to seed label: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('REST APIs', () => { + beforeAll(async () => { + await client.login('publisher'); + await client.login('admin'); + }); + + it('creates and retrieves a REST API', async () => { + const api = await createApi({ type: 'REST' }); + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + // The stored/returned type is the internal constant ("RestApi"), not the + // request-time enum value ("REST") — src/utils/constants.js API_TYPE mapping. + expect(res.body.type).toBe('RestApi'); + }); + + it('updates a REST API', async () => { + const api = await createApi({ type: 'REST', name: 'Original Name' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated Name', + version: 'v1.0', + type: 'REST', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated Name'); + }); + + it('deletes a REST API', async () => { + const api = await createApi({ type: 'REST' }); + const del = await client.as('publisher').del(`/apis/${api.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.status).toBe(404); + }); + + it('lists REST APIs with name/version/tag filters', async () => { + const name = uniqueHandle('Filterable API'); + await createApi({ type: 'REST', name, version: 'v2.0' }); + + const res = await client.as('publisher').get(`/apis?apiName=${encodeURIComponent(name)}&version=v2.0`); + expect(res.status).toBe(200); + expect(res.body.list.some((a) => a.name === name)).toBe(true); + }); + + it('rejects creation with an invalid OpenAPI definition', async () => { + const id = uniqueHandle('bad-api'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Bad API', + version: 'v1.0', + type: 'REST', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from('not a valid openapi document'), 'definition.json'); + expect(res.status).toBe(400); + }); + + it('rejects creation when type is omitted', async () => { + const id = uniqueHandle('no-type-api'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'No Type API', + version: 'v1.0', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(res.status).toBe(400); + }); + + it('rejects an update when type is omitted', async () => { + const api = await createApi({ type: 'REST' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated Without Type', + version: 'v1.0', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(400); + }); + + // type is immutable once created — resolveTypeOrReject only guards the /apis-vs- + // /mcp-servers boundary, so this is a separate check against the record's own + // existing type (apiMetadataService.js's updateAPIMetadata). + it('rejects changing type on update', async () => { + const api = await createApi({ type: 'REST' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Should Stay REST', + version: 'v1.0', + type: 'SOAP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(''), 'definition.wsdl'); + expect(put.status).toBe(409); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.body.type).toBe('RestApi'); + }); + + it('rejects requests without an authenticated session', async () => { + const res = await client.raw().get(`${client.API_PREFIX}/apis`); + // No credentials → both security handlers (OAuth2Security/apiKeyAuth) throw + // 401; 403 is only for authenticated-but-insufficient-scope requests. + expect(res.status).toBe(401); + }); + + it('rejects a request whose resolved type is MCP (must use /mcp-servers)', async () => { + const id = uniqueHandle('should-be-mcp'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Should Be MCP', + version: 'v1.0', + type: 'MCP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(res.status).toBe(400); + }); + + it("rejects a role without API management scope (developer can't create an API)", async () => { + await client.login('developer'); + const id = uniqueHandle('forbidden-api'); + const res = await client + .as('developer') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Should Be Forbidden', + version: 'v1.0', + type: 'REST', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(res.status).toBe(403); + }); + + it('uploads and retrieves an OpenAPI definition', async () => { + // createApi()'s default upload (support/fixtures.js) — fileName must match + // what was actually persisted, not a hardcoded 'apiDefinition.json': the file + // keeps its original uploaded name ('definition.json') under type=API_DEFINITION + // (src/utils/constants.js DOC_TYPES.API_DEFINITION). + const api = await createApi({ type: 'REST' }); + const res = await client.as('publisher').get(`/apis/${api.id}/assets?type=API_DEFINITION&fileName=definition.json`); + expect(res.status).toBe(200); + // .json gets served as application/json (util.retrieveContentType), so supertest + // auto-parses it into res.body rather than leaving it as raw text. + expect(res.body).toEqual({ openapi: '3.0.3', info: { title: 'Fixture API', version: '1.0.0' }, paths: { '/ping': { get: { responses: { 200: { description: 'ok' } } } } } }); + }); + + it('returns 404 (not a hang) for a definition file that was never uploaded', async () => { + const api = await createApi({ type: 'REST' }); + const res = await client.as('publisher').get(`/apis/${api.id}/assets?type=API_DEFINITION&fileName=does-not-exist.json`); + expect(res.status).toBe(404); + }); + + // apiDao.getByCondition (single-GET and name/version-filtered list) previously had + // no Labels include at all, so labels set at creation never came back on GET — + // only view-filtered/search listings (which use a different DAO query) exposed them. + it('creates an API with labels and returns them on GET', async () => { + const label = await createLabel(); + const api = await createApi({ type: 'REST', labels: [label.id] }); + + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + expect(res.body.labels).toEqual([label.id]); + }); + + describe('label updates', () => { + // apiDao.update() has no `labels` column — labels only ever change via the + // add/remove diff computed in updateAPIMetadata, which used to only run for + // YAML/artifact uploads. A `labels` array sent via the plain JSON apiMetadata + // field (what every other update test in this suite uses) was a silent no-op. + // + // NOTE: every PUT body here includes `id: api.id`. Without it, apiDao.update() + // recomputes the handle from name+version (`handle: apiMetadata.handle ? ... : + // slugify(name)-v(version)`) since the JSON update path never re-derives handle + // from an omitted `id` the way create does — silently changing the resource's + // own identifier out from under a follow-up GET by the original id. That's a + // separate, pre-existing quirk (not something these label tests should mask by + // relying on it) — every other update test in this suite is unaffected only + // because none of them re-GET by the original id afterward. + it('changes labels on update (new labels replace old ones)', async () => { + const labelA = await createLabel(); + const labelB = await createLabel(); + const api = await createApi({ type: 'REST', labels: [labelA.id] }); + + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + id: api.id, + name: api.name, + version: api.version, + type: 'REST', + status: 'PUBLISHED', + labels: [labelB.id], + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.body.labels).toEqual([labelB.id]); + }); + + it('keeps labels unchanged when the same labels are resent on update', async () => { + const label = await createLabel(); + const api = await createApi({ type: 'REST', labels: [label.id] }); + + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + id: api.id, + name: 'Same Labels Resent', + version: api.version, + type: 'REST', + status: 'PUBLISHED', + labels: [label.id], + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.body.labels).toEqual([label.id]); + }); + + it('adds labels on update to an API created without any', async () => { + const label = await createLabel(); + const api = await createApi({ type: 'REST', labels: [] }); + + const beforeGet = await client.as('publisher').get(`/apis/${api.id}`); + expect(beforeGet.body.labels).toEqual([]); + + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + id: api.id, + name: api.name, + version: api.version, + type: 'REST', + status: 'PUBLISHED', + labels: [label.id], + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.body.labels).toEqual([label.id]); + }); + + it('removes all labels on update when an empty array is sent', async () => { + const label = await createLabel(); + const api = await createApi({ type: 'REST', labels: [label.id] }); + + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + id: api.id, + name: api.name, + version: api.version, + type: 'REST', + status: 'PUBLISHED', + labels: [], + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.3', info: { title: 'x', version: '1' }, paths: {} })), 'definition.json'); + expect(put.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.body.labels).toEqual([]); + }); + }); + + // apiViewQuery and apiSearchQuery are independently optional (docs/devportal- + // openapi-spec-v0.9.yaml) — searchFallback used to crash with a 500 when `view` + // was omitted (viewDao.getId querying `handle: undefined`), instead of searching + // unscoped by view. + it('searches APIs by free-text query without a view filter', async () => { + const name = uniqueHandle('Searchable Query API'); + await createApi({ type: 'REST', name }); + + const res = await client.as('publisher').get(`/apis?query=${encodeURIComponent(name)}`); + expect(res.status).toBe(200); + expect(res.body.list.some((a) => a.name === name)).toBe(true); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/soap-apis.spec.js b/portals/developer-portal/it/rest-api/apis/soap-apis.spec.js new file mode 100644 index 0000000000..1b1943da8c --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/soap-apis.spec.js @@ -0,0 +1,80 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /apis (type: SOAP). apiDefinition is a WSDL file instead +// of an OpenAPI document. + +const client = require('../support/client'); +const { createApi, uniqueHandle } = require('../support/fixtures'); + +const SAMPLE_WSDL = ''; + +describe('SOAP APIs', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('creates a SOAP API with a WSDL definition', async () => { + const api = await createApi({ type: 'SOAP', definition: SAMPLE_WSDL, definitionFileName: 'definition.wsdl' }); + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + expect(res.body.type).toBe('SOAP'); + }); + + it('updates a SOAP API', async () => { + const api = await createApi({ type: 'SOAP', definition: SAMPLE_WSDL, definitionFileName: 'definition.wsdl' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated SOAP API', + version: 'v1.0', + type: 'SOAP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(SAMPLE_WSDL), 'definition.wsdl'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated SOAP API'); + }); + + it('deletes a SOAP API', async () => { + const api = await createApi({ type: 'SOAP', definition: SAMPLE_WSDL, definitionFileName: 'definition.wsdl' }); + const del = await client.as('publisher').del(`/apis/${api.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.status).toBe(404); + }); + + it('rejects creation without any definition file', async () => { + const id = uniqueHandle('soap-api-no-def'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'No Definition SOAP API', + version: 'v1.0', + type: 'SOAP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })); + expect(res.status).toBe(400); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/websocket-apis.spec.js b/portals/developer-portal/it/rest-api/apis/websocket-apis.spec.js new file mode 100644 index 0000000000..1c9069ed62 --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/websocket-apis.spec.js @@ -0,0 +1,64 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /apis (type: WS). createAPIMetadata still requires *some* +// apiDefinition file for every non-GraphQL type (src/services/apiMetadataService.js +// validation), even though WS has no real schema concept — the fixture just +// attaches a placeholder file to satisfy that. + +const client = require('../support/client'); +const { createApi } = require('../support/fixtures'); + +describe('WebSocket APIs', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('creates a WebSocket API', async () => { + const api = await createApi({ type: 'WS' }); + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + expect(res.body.type).toBe('WS'); + }); + + it('updates a WebSocket API', async () => { + const api = await createApi({ type: 'WS' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated WS API', + version: 'v1.0', + type: 'WS', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from('{}'), 'definition.json'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated WS API'); + }); + + it('deletes a WebSocket API', async () => { + const api = await createApi({ type: 'WS' }); + const del = await client.as('publisher').del(`/apis/${api.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.status).toBe(404); + }); +}); diff --git a/portals/developer-portal/it/rest-api/apis/websub-apis.spec.js b/portals/developer-portal/it/rest-api/apis/websub-apis.spec.js new file mode 100644 index 0000000000..327237136d --- /dev/null +++ b/portals/developer-portal/it/rest-api/apis/websub-apis.spec.js @@ -0,0 +1,63 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /apis (type: WEBSUB). This is devportal's own registration +// of an already-published WebSub API's metadata — not to be confused with the +// event-gateway's WebSub API management (gateway.api-platform.wso2.com). + +const client = require('../support/client'); +const { createApi } = require('../support/fixtures'); + +describe('WebSub APIs', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('creates a WebSub API', async () => { + const api = await createApi({ type: 'WEBSUB' }); + const res = await client.as('publisher').get(`/apis/${api.id}`); + expect(res.status).toBe(200); + expect(res.body.type).toBe('WebSubApi'); + }); + + it('updates a WebSub API', async () => { + const api = await createApi({ type: 'WEBSUB' }); + const put = await client + .as('publisher') + .putMultipart(`/apis/${api.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated WebSub API', + version: 'v1.0', + type: 'WEBSUB', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from('{}'), 'definition.json'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated WebSub API'); + }); + + it('deletes a WebSub API', async () => { + const api = await createApi({ type: 'WEBSUB' }); + const del = await client.as('publisher').del(`/apis/${api.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/apis/${api.id}`); + expect(get.status).toBe(404); + }); +}); diff --git a/portals/developer-portal/it/rest-api/applications/applications.spec.js b/portals/developer-portal/it/rest-api/applications/applications.spec.js new file mode 100644 index 0000000000..a880fd0a32 --- /dev/null +++ b/portals/developer-portal/it/rest-api/applications/applications.spec.js @@ -0,0 +1,85 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /applications (plain JSON — see +// docs/devportal-openapi-spec-v0.9.yaml ApplicationRequest). Applications are +// owned by their creator, so this uses the `developer` role throughout. Async +// event side effects are covered separately in webhook-events.spec.js. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +describe('applications', () => { + beforeAll(async () => { + await client.login('developer'); + }); + + it('creates and retrieves an application', async () => { + const id = uniqueHandle('app'); + const create = await client.as('developer').post('/applications', { + id, + displayName: 'Test App', + description: 'IT fixture application', + }); + expect(create.status).toBe(201); + + const get = await client.as('developer').get(`/applications/${id}`); + expect(get.status).toBe(200); + expect(get.body.displayName).toBe('Test App'); + }); + + it('updates an application', async () => { + const id = uniqueHandle('app'); + await client.as('developer').post('/applications', { id, displayName: 'Original', description: 'd' }); + + const put = await client.as('developer').put(`/applications/${id}`, { + displayName: 'Renamed App', + description: 'updated description', + }); + expect(put.status).toBe(200); + expect(put.body.displayName).toBe('Renamed App'); + }); + + it('deletes an application', async () => { + const id = uniqueHandle('app'); + await client.as('developer').post('/applications', { id, displayName: 'To Delete', description: 'd' }); + + const del = await client.as('developer').del(`/applications/${id}`); + expect(del.status).toBe(200); + + const get = await client.as('developer').get(`/applications/${id}`); + expect(get.status).toBe(404); + }); + + it('lists applications', async () => { + const id = uniqueHandle('app'); + await client.as('developer').post('/applications', { id, displayName: 'Listed App', description: 'd' }); + + const res = await client.as('developer').get('/applications'); + expect(res.status).toBe(200); + expect(res.body.list.some((a) => a.id === id)).toBe(true); + }); + + it('rejects creation missing displayName/description', async () => { + const res = await client.as('developer').post('/applications', { id: uniqueHandle('app') }); + expect(res.status).toBe(400); + }); + + // Covered in ../key-managers/token-generation.spec.js (generate-keys mapping + // + generate-token flow against a mock OAuth token endpoint). +}); diff --git a/portals/developer-portal/it/rest-api/applications/webhook-events.spec.js b/portals/developer-portal/it/rest-api/applications/webhook-events.spec.js new file mode 100644 index 0000000000..e0fa254e8f --- /dev/null +++ b/portals/developer-portal/it/rest-api/applications/webhook-events.spec.js @@ -0,0 +1,218 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Reference spec for the webhook pipeline end-to-end: register a subscriber +// pointed at a local sink -> trigger application.created via the real API -> +// assert the dp_events row -> assert the actual HTTP delivery payload/headers. +// Every other `*/webhook-events.spec.js` file should follow this shape. +// +// Every test in this file shares the one fixed org (client.ORG_HANDLE) — each +// registers its own webhook subscriber and deletes it in afterEach, since +// subscribers (unlike applications) would otherwise accumulate and start +// double-delivering events to earlier tests' leftover subscribers. + +const client = require('../support/client'); +const db = require('../support/db'); +const { waitForEvent, waitForDelivery } = require('../support/wait-for'); +const { createWebhookSubscriber, uniqueHandle } = require('../support/fixtures'); +const { createWebhookSink, resolveSinkUrl } = require('../support/webhook-sink'); + +describe('applications webhook events', () => { + let sink; + let subscriber; + + // WEBHOOK_SINK_URL (set in it/docker-compose.test*.yaml) gives the hostname the + // devportal container can reach this test container at — see resolveSinkUrl's + // comment in support/webhook-sink.js for why the port always stays local to this + // file rather than coming from the (shared-across-spec-files) env var. + const sinkUrl = resolveSinkUrl(4500); + + beforeAll(async () => { + await client.login('developer'); + await client.login('admin'); + sink = createWebhookSink(); + await sink.start(Number(sinkUrl.port)); + }); + + afterAll(async () => { + await sink.stop(); + await db.close(); + }); + + beforeEach(async () => { + subscriber = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['application.*'], + }); + }); + + afterEach(async () => { + await client.as('admin').del(`/webhook-subscribers/${subscriber.id}`); + sink.received.length = 0; + }); + + it('publishes and delivers application.created', async () => { + const since = new Date(); + const appRes = await client.as('developer').post('/applications', { + id: uniqueHandle('app'), + displayName: 'Webhook Test App', + description: 'Created to verify application.created delivery', + }); + expect(appRes.status).toBe(201); + + // aggregate_uuid is the application's internal id, which the REST + // response never exposes (only its handle) — scope by org + type + time instead. + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + expect(event).toBeDefined(); + expect(event.payload).toBeDefined(); + + // dp_event_deliveries.subscriber_id stores the subscriber's internal + // uuid, not the REST-facing handle (subscriber.id) — the API never + // exposes that uuid, so scope by event only (fine here: one subscriber). + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('application.created'); + expect(received).toBeDefined(); + + // Full delivered envelope (src/services/webhooks/deliveryWorker.js buildEnvelope) + // and the exact payload built in devportalController.js saveApplication. + expect(received.body.event_type).toBe('application.created'); + expect(received.body.event_id).toBe(event.uuid); + expect(received.body.occurred_at).toBeDefined(); + expect(received.body.org).toEqual({ ref_id: expect.any(String) }); + expect(received.body.encrypted_fields).toEqual([]); + expect(received.headers['x-devportal-event-id']).toBe(event.uuid); + + expect(received.body.data).toEqual({ + application_id: expect.any(String), + display_name: 'Webhook Test App', + handle: appRes.body.id, + description: 'Created to verify application.created delivery', + type: 'web', + }); + }); + + it('publishes and delivers application.updated', async () => { + const id = uniqueHandle('app'); + const createRes = await client.as('developer').post('/applications', { id, displayName: 'Original', description: 'd' }); + expect(createRes.status).toBe(201); + + const updateSince = new Date(); + const updateRes = await client.as('developer').put(`/applications/${id}`, { displayName: 'Renamed', description: 'd2' }); + expect(updateRes.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.updated', since: updateSince }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('application.updated'); + expect(received).toBeDefined(); + expect(received.body.event_type).toBe('application.updated'); + expect(received.body.data).toEqual({ + application_id: expect.any(String), + display_name: 'Renamed', + handle: id, + description: 'd2', + type: 'web', + }); + }); + + it('publishes and delivers application.deleted', async () => { + const id = uniqueHandle('app'); + await client.as('developer').post('/applications', { id, displayName: 'To Delete', description: 'd' }); + + const since = new Date(); + const delRes = await client.as('developer').del(`/applications/${id}`); + expect(delRes.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.deleted', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('application.deleted'); + expect(received).toBeDefined(); + expect(received.body.event_type).toBe('application.deleted'); + expect(received.body.data).toEqual({ + application_id: expect.any(String), + display_name: 'To Delete', + handle: id, + }); + }); + + it('does not deliver to a subscriber whose event pattern does not match', async () => { + // A second subscriber on the same org that only wants apikey.* events — + // application.created shouldn't be delivered to it. + const nonMatching = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['apikey.*'], + }); + + const since = new Date(); + const appRes = await client.as('developer').post('/applications', { + id: uniqueHandle('app'), + displayName: 'Pattern Mismatch App', + description: 'd', + }); + expect(appRes.status).toBe(201); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + // Exactly one delivery row for this event — the apikey.*-only subscriber + // never matched, so it never got one. + const deliveries = await db.findDeliveries({ eventUuid: event.uuid }); + expect(deliveries).toHaveLength(1); + + await client.as('admin').del(`/webhook-subscribers/${nonMatching.id}`); + }); + + // Closes a gap the tests above don't cover on their own: response-correctness + // (applications.spec.js) and event-correctness (this file) are asserted in + // separate suites, so a bug that gets one right and the other wrong on the + // same call would pass both. Assert the combination directly. + describe('action + event consistency', () => { + it('does not publish a second application.deleted when deleting an already-deleted application', async () => { + // `since` is captured before EITHER delete (not between them): the first + // delete's own event can land in the same millisecond as a since-the-second- + // delete timestamp, and `db.findEvents`' `occurred_at >= since` would then + // wrongly count it as "new". Filtering by handle instead of a narrow time + // window avoids that boundary race. + const id = uniqueHandle('app'); + const since = new Date(); + await client.as('developer').post('/applications', { id, displayName: 'Delete Twice', description: 'd' }); + await client.as('developer').del(`/applications/${id}`); + + const res = await client.as('developer').del(`/applications/${id}`); + expect(res.status).toBe(404); + + const events = await db.findEvents({ orgUuid: await db.findOrgUuidByHandle(client.ORG_HANDLE), type: 'application.deleted', since }); + // payload is TEXT on SQLite (a string) but jsonb on Postgres (an object). + const forThisApp = events.filter((e) => { + const payload = typeof e.payload === 'string' ? JSON.parse(e.payload) : e.payload; + return payload.handle === id; + }); + expect(forThisApp).toHaveLength(1); + }); + }); +}); diff --git a/portals/developer-portal/it/rest-api/auth/file-based-login.spec.js b/portals/developer-portal/it/rest-api/auth/file-based-login.spec.js new file mode 100644 index 0000000000..399c0fdfbb --- /dev/null +++ b/portals/developer-portal/it/rest-api/auth/file-based-login.spec.js @@ -0,0 +1,86 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// "File-based" login (config.identityProvider unset) is NOT a local credential +// store inside devportal — POST /:orgName/views/:viewName/login +// (src/controllers/authController.js handleLocalLogin) proxies the username/ +// password to Platform API's own file-based auth (POST /api/portal/v0.9/auth/login, +// see it/configs/config-platform-api-it.toml for the seeded admin/publisher/ +// developer accounts) and decodes the returned JWT into the devportal session. +// This is the same login flow support/client.js's login() uses for every other +// spec in this suite — here it's exercised directly to check the HTTP-level +// contract (redirects, cookies, error responses) rather than just using it as +// plumbing. + +const client = require('../support/client'); + +describe('file-based (demo mode) login', () => { + it('logs in with valid credentials and establishes a session', async () => { + const agent = require('supertest').agent(client.BASE_URL); + const res = await agent + .post(`/${client.ORG_HANDLE}/views/default/login`) + .type('form') + .send({ username: 'admin', password: 'admin' }) + .redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).not.toContain('error='); + expect(res.headers['set-cookie'].some((c) => c.startsWith('connect.sid='))).toBe(true); + }); + + it('rejects an incorrect password', async () => { + const res = await client.raw() + .post(`/${client.ORG_HANDLE}/views/default/login`) + .type('form') + .send({ username: 'admin', password: 'wrong-password' }) + .redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('error='); + }); + + it('rejects a non-existent username', async () => { + const res = await client.raw() + .post(`/${client.ORG_HANDLE}/views/default/login`) + .type('form') + .send({ username: 'no-such-user', password: 'whatever' }) + .redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('error='); + }); + + it('rejects when username or password is missing', async () => { + const res = await client.raw() + .post(`/${client.ORG_HANDLE}/views/default/login`) + .type('form') + .send({ username: 'admin' }) + .redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('Username+and+password+are+required'); + }); + + it('session cookie grants access to an authenticated-only endpoint afterward', async () => { + await client.login('admin'); + const res = await client.as('admin').get(`/organizations/${client.ORG_HANDLE}`); + expect(res.status).toBe(200); + expect(res.body.id).toBe(client.ORG_HANDLE); + }); + + it('rejects requests without an authenticated session', async () => { + const res = await client.raw().get(`${client.API_PREFIX}/organizations/${client.ORG_HANDLE}`); + expect([401, 403]).toContain(res.status); + }); +}); diff --git a/portals/developer-portal/it/rest-api/jest.config.js b/portals/developer-portal/it/rest-api/jest.config.js new file mode 100644 index 0000000000..1096fb93c4 --- /dev/null +++ b/portals/developer-portal/it/rest-api/jest.config.js @@ -0,0 +1,29 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +module.exports = { + testEnvironment: 'node', + testMatch: ['**/*.spec.js'], + globalSetup: '/support/global-setup.js', + globalTeardown: '/support/global-teardown.js', + testTimeout: 10000, + reporters: [ + 'default', + ['jest-junit', { outputDirectory: 'reports', outputName: 'rest-api-results.xml' }], + ], +}; diff --git a/portals/developer-portal/it/rest-api/key-managers/key-managers.spec.js b/portals/developer-portal/it/rest-api/key-managers/key-managers.spec.js new file mode 100644 index 0000000000..da99434e1c --- /dev/null +++ b/portals/developer-portal/it/rest-api/key-managers/key-managers.spec.js @@ -0,0 +1,100 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /key-managers. type must be one of +// ASGARDEO | WSO2IS | KEYCLOAK | GENERIC_OIDC (KeyManagerRequest schema). +// Body accepts YAML or JSON per the handler; keep the fixture JSON-only. +// `admin` manages org-level integration config. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +describe('key managers', () => { + beforeAll(async () => { + await client.login('admin'); + }); + + it('creates a key manager (type: ASGARDEO) with a token endpoint', async () => { + const id = uniqueHandle('km'); + const res = await client.as('admin').post('/key-managers', { + id, + displayName: 'Test Asgardeo KM', + type: 'ASGARDEO', + tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + expect(res.status).toBe(201); + expect(res.body.id).toBe(id); + expect(res.body.type).toBe('ASGARDEO'); + }); + + it('retrieves a key manager', async () => { + const id = uniqueHandle('km'); + await client.as('admin').post('/key-managers', { + id, displayName: 'Test KM', type: 'ASGARDEO', tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + + const res = await client.as('admin').get(`/key-managers/${id}`); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Test KM'); + }); + + it('updates a key manager', async () => { + const id = uniqueHandle('km'); + await client.as('admin').post('/key-managers', { + id, displayName: 'Original KM', type: 'ASGARDEO', tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + + const res = await client.as('admin').put(`/key-managers/${id}`, { displayName: 'Renamed KM' }); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Renamed KM'); + }); + + it('deletes a key manager', async () => { + const id = uniqueHandle('km'); + await client.as('admin').post('/key-managers', { + id, displayName: 'To Delete', type: 'ASGARDEO', tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + + const del = await client.as('admin').del(`/key-managers/${id}`); + expect(del.status).toBe(204); + + const get = await client.as('admin').get(`/key-managers/${id}`); + expect(get.status).toBe(404); + }); + + it('lists key managers for an org', async () => { + const id = uniqueHandle('km'); + await client.as('admin').post('/key-managers', { + id, displayName: 'Listed KM', type: 'ASGARDEO', tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + + const res = await client.as('admin').get('/key-managers'); + expect(res.status).toBe(200); + expect(res.body.list.some((km) => km.id === id)).toBe(true); + }); + + it('rejects an unsupported type value', async () => { + const res = await client.as('admin').post('/key-managers', { + id: uniqueHandle('km'), + displayName: 'Bad Type KM', + type: 'NOT_A_REAL_TYPE', + tokenEndpoint: 'https://asgardeo.example.invalid/oauth2/token', + }); + expect(res.status).toBe(400); + }); +}); diff --git a/portals/developer-portal/it/rest-api/key-managers/token-generation.spec.js b/portals/developer-portal/it/rest-api/key-managers/token-generation.spec.js new file mode 100644 index 0000000000..0308b85481 --- /dev/null +++ b/portals/developer-portal/it/rest-api/key-managers/token-generation.spec.js @@ -0,0 +1,159 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST /applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token +// (src/controllers/devportalController.js -> src/services/oauthTokenService.js +// -> axios.post(tokenEndpoint, ..., { auth: { username: clientId, password: clientSecret } })). +// Request: { consumerSecret, scopes?, validityPeriod? } +// Response: { accessToken, validityTime, tokenScopes } +// +// Rather than requiring a live Asgardeo/WSO2IS tenant, this stands up a minimal +// OAuth2 client-credentials token endpoint locally (like support/webhook-sink.js +// does for webhook delivery) and points a key manager at it — oauthTokenService.js +// just POSTs to whatever tokenEndpoint the key manager is configured with. +// `admin` creates the key manager; `developer` owns the application and its +// key mapping (appDao scopes application lookups by the caller's created_by). + +const http = require('http'); +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +const VALID_SECRET = 'valid-consumer-secret'; + +function createMockTokenServer() { + let server; + let lastRequestParams = new URLSearchParams(); + function start(port) { + return new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + let rawBody = ''; + req.on('data', (chunk) => { rawBody += chunk; }); + req.on('end', () => { + lastRequestParams = new URLSearchParams(rawBody); + const auth = req.headers.authorization || ''; + const [, encoded] = auth.split(' '); + const [, password] = Buffer.from(encoded || '', 'base64').toString('utf8').split(':'); + if (password !== VALID_SECRET) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'invalid_client' })); + } + // Echo back what was actually posted (oauthTokenService.js's + // generateToken form-encodes `scope`/`expiry_time`) rather than a + // fixed response, so tests can prove the request round-trip instead + // of just checking a value that would look identical either way. + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + access_token: 'mock-access-token', + expires_in: Number(lastRequestParams.get('expiry_time')) || 3600, + scope: lastRequestParams.get('scope') || '', + })); + }); + }); + server.on('error', reject); + server.listen(port, () => resolve(server.address().port)); + }); + } + function stop() { + return new Promise((resolve) => (server ? server.close(resolve) : resolve())); + } + function getLastRequestParams() { + return lastRequestParams; + } + return { start, stop, getLastRequestParams }; +} + +describe('OAuth token generation', () => { + let tokenServer; + // Same reachability pattern as support/webhook-sink.js's WEBHOOK_SINK_URL — + // devportal must be able to reach this test container at this address. + const tokenUrl = new URL(process.env.MOCK_TOKEN_ENDPOINT_URL || 'http://localhost:4504'); + + beforeAll(async () => { + await client.login('admin'); + await client.login('developer'); + tokenServer = createMockTokenServer(); + await tokenServer.start(Number(tokenUrl.port)); + }); + + afterAll(async () => { + await tokenServer.stop(); + }); + + async function setupAppWithKeyMapping() { + const kmId = uniqueHandle('km'); + await client.as('admin').post('/key-managers', { + id: kmId, displayName: 'Mock KM', type: 'GENERIC_OIDC', tokenEndpoint: tokenUrl.href, + }); + + const appId = uniqueHandle('app'); + await client.as('developer').post('/applications', { id: appId, displayName: 'Token Test App', description: 'd' }); + + const mapping = await client.as('developer').post(`/applications/${appId}/generate-keys`, { + keyManager: kmId, + type: 'PRODUCTION', + consumerKey: 'mock-consumer-key', + }); + expect(mapping.status).toBe(200); + return { appId, keyMappingId: mapping.body.keyMappingId }; + } + + it('generates a token for an application via its key manager mapping', async () => { + const { appId, keyMappingId } = await setupAppWithKeyMapping(); + + const res = await client.as('developer').post( + `/applications/${appId}/oauth-keys/${keyMappingId}/generate-token`, + { consumerSecret: VALID_SECRET } + ); + expect(res.status).toBe(200); + expect(res.body.accessToken).toBe('mock-access-token'); + expect(res.body.validityTime).toBe(3600); + }); + + it('rejects generation with an incorrect consumer secret', async () => { + const { appId, keyMappingId } = await setupAppWithKeyMapping(); + + const res = await client.as('developer').post( + `/applications/${appId}/oauth-keys/${keyMappingId}/generate-token`, + { consumerSecret: 'wrong-secret' } + ); + // The mock key manager returns 401 for a bad secret; the devportal must + // propagate that client error, not mask it as a 500. + expect([400, 401]).toContain(res.status); + }); + + it('applies the default scope/validity period when omitted', async () => { + const { appId, keyMappingId } = await setupAppWithKeyMapping(); + + const res = await client.as('developer').post( + `/applications/${appId}/oauth-keys/${keyMappingId}/generate-token`, + { consumerSecret: VALID_SECRET } + ); + expect(res.status).toBe(200); + + // devportalController.js's generateOAuthKeys defaults omitted scopes/ + // validityPeriod to ['default']/3600 before calling generateToken, which + // form-encodes them as scope=default&expiry_time=3600 in the upstream POST — + // assert on what the mock endpoint actually received, since the response + // alone would look identical whether or not the defaults were ever sent. + const params = tokenServer.getLastRequestParams(); + expect(params.get('scope')).toBe('default'); + expect(params.get('expiry_time')).toBe('3600'); + expect(res.body.tokenScopes).toEqual(['default']); + expect(res.body.validityTime).toBe(3600); + }); +}); diff --git a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js new file mode 100644 index 0000000000..d94ec202b7 --- /dev/null +++ b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js @@ -0,0 +1,244 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /mcp-servers. Mirrors /apis exactly (same +// apiMetadataService.createAPIMetadata under the hood via asMcpRequest()) — see +// src/services/mcpServerService.js. `type` must be explicitly 'MCP' on both create and +// update; resolveTypeOrReject (apiMetadataService.js) rejects it being omitted or any +// other value, symmetric with /apis requiring an explicit non-MCP type. + +const client = require('../support/client'); +const { uniqueHandle, createApi } = require('../support/fixtures'); + +async function createMcpServer(overrides = {}) { + const id = overrides.id || uniqueHandle('mcp-server'); + const metadata = { + id, + name: overrides.name || `Test MCP Server ${id}`, + version: overrides.version || 'v1.0', + type: 'MCP', + status: overrides.status || 'PUBLISHED', + endPoints: overrides.endPoints || { + productionURL: `https://backend.example.invalid/${id}`, + sandboxURL: `https://sandbox.example.invalid/${id}`, + }, + }; + const res = await client + .as('publisher') + .postMultipart('/mcp-servers') + .field('apiMetadata', JSON.stringify(metadata)) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + if (res.status !== 201) { + throw new Error(`Failed to seed MCP server: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('MCP servers', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('creates and retrieves an MCP server', async () => { + const mcp = await createMcpServer(); + const res = await client.as('publisher').get(`/mcp-servers/${mcp.id}`); + expect(res.status).toBe(200); + expect(res.body.type).toBe('Mcp'); + }); + + it('rejects creating an MCP server when type is omitted', async () => { + const id = uniqueHandle('mcp-server'); + const res = await client + .as('publisher') + .postMultipart('/mcp-servers') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Omitted Type Test', + version: 'v1.0', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(res.status).toBe(400); + }); + + // Symmetric with the /apis rejection below ('rejects a /apis creation request whose + // resolved type is MCP') — resolveTypeOrReject (apiMetadataService.js) now rejects an + // explicit type mismatch on both sides instead of /mcp-servers silently overriding it. + it('rejects a /mcp-servers creation request whose resolved type is not MCP', async () => { + const id = uniqueHandle('should-use-apis-endpoint'); + const res = await client + .as('publisher') + .postMultipart('/mcp-servers') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Wrong Endpoint', + version: 'v1.0', + type: 'REST', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(res.status).toBe(400); + }); + + it('rejects updating an MCP server to a non-MCP type', async () => { + const mcp = await createMcpServer(); + const put = await client + .as('publisher') + .putMultipart(`/mcp-servers/${mcp.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Should Stay MCP', + version: 'v1.0', + type: 'REST', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(put.status).toBe(400); + }); + + it('updates an MCP server', async () => { + const mcp = await createMcpServer(); + const put = await client + .as('publisher') + .putMultipart(`/mcp-servers/${mcp.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated MCP Server', + version: 'v1.0', + type: 'MCP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(put.status).toBe(200); + expect(put.body.name).toBe('Updated MCP Server'); + }); + + it('rejects updating an MCP server when type is omitted', async () => { + const mcp = await createMcpServer(); + const put = await client + .as('publisher') + .putMultipart(`/mcp-servers/${mcp.id}`) + .field('apiMetadata', JSON.stringify({ + name: 'Updated MCP Server', + version: 'v1.0', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(put.status).toBe(400); + }); + + it('deletes an MCP server', async () => { + const mcp = await createMcpServer(); + const del = await client.as('publisher').del(`/mcp-servers/${mcp.id}`); + expect(del.status).toBe(200); + + const get = await client.as('publisher').get(`/mcp-servers/${mcp.id}`); + expect(get.status).toBe(404); + }); + + it('lists MCP servers', async () => { + const name = uniqueHandle('Listable MCP Server'); + await createMcpServer({ name }); + + const res = await client.as('publisher').get(`/mcp-servers?apiName=${encodeURIComponent(name)}`); + expect(res.status).toBe(200); + expect(res.body.list.some((m) => m.name === name)).toBe(true); + }); + + it('rejects a /apis creation request whose resolved type is MCP', async () => { + const id = uniqueHandle('should-use-mcp-endpoint'); + const res = await client + .as('publisher') + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify({ + id, + name: 'Wrong Endpoint', + version: 'v1.0', + type: 'MCP', + status: 'PUBLISHED', + endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' }, + })) + .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json'); + expect(res.status).toBe(400); + }); + + it('generates an API key scoped to an MCP server', async () => { + const mcp = await createMcpServer(); + const keyId = uniqueHandle('mcp-key').toLowerCase(); + const res = await client.as('publisher').post(`/mcp-servers/${mcp.id}/api-keys/generate`, { id: keyId }); + expect(res.status).toBe(201); + expect(res.body.id).toBe(keyId); + expect(res.body.key).toBeDefined(); + }); + + // /mcp-servers and /apis share the same dp_api_metadata table, distinguished only + // by `type` — resolveScopedApiId (apiMetadataService.js:313) is what's supposed to + // keep the two families from resolving each other's handles. The tests above only + // cover MCP-created-via-/apis being rejected; these cover the reverse (a plain REST + // API resolved via /mcp-servers) and list isolation in both directions. + describe('type isolation between /mcp-servers and /apis', () => { + it('does not resolve a plain REST API handle via GET /mcp-servers/{id}', async () => { + const api = await createApi(); + const res = await client.as('publisher').get(`/mcp-servers/${api.id}`); + expect(res.status).toBe(404); + }); + + it('does not resolve an MCP server handle via GET /apis/{id}', async () => { + const mcp = await createMcpServer(); + const res = await client.as('publisher').get(`/apis/${mcp.id}`); + expect(res.status).toBe(404); + }); + + it('excludes plain REST APIs from the /mcp-servers list', async () => { + const name = uniqueHandle('Should Not Appear In MCP List'); + await createApi({ name }); + + const res = await client.as('publisher').get(`/mcp-servers?apiName=${encodeURIComponent(name)}`); + expect(res.status).toBe(200); + expect(res.body.list.some((m) => m.name === name)).toBe(false); + }); + + it('excludes MCP servers from the /apis list', async () => { + const name = uniqueHandle('Should Not Appear In Apis List'); + await createMcpServer({ name }); + + const res = await client.as('publisher').get(`/apis?apiName=${encodeURIComponent(name)}`); + expect(res.status).toBe(200); + expect(res.body.list.some((a) => a.name === name)).toBe(false); + }); + + // Same resolveApiId scoping (apiKeyController.js:49-62), but exercised via the + // api-keys sub-resource rather than the parent record — a distinct code path + // worth covering on its own since mcpServerKeysHandler's asMcpRequest aliasing + // (mcpServerKeysHandler.js:32-36) is what's supposed to keep it scoped too. + it('rejects GET /apis/{id}/api-keys when the handle belongs to an MCP server', async () => { + const mcp = await createMcpServer(); + const res = await client.as('publisher').get(`/apis/${mcp.id}/api-keys`); + expect(res.status).toBe(404); + }); + + it('rejects GET /mcp-servers/{id}/api-keys when the handle belongs to a plain REST API', async () => { + const api = await createApi(); + const res = await client.as('publisher').get(`/mcp-servers/${api.id}/api-keys`); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/portals/developer-portal/it/rest-api/organizations/organizations.spec.js b/portals/developer-portal/it/rest-api/organizations/organizations.spec.js new file mode 100644 index 0000000000..53e6b173fe --- /dev/null +++ b/portals/developer-portal/it/rest-api/organizations/organizations.spec.js @@ -0,0 +1,109 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Reference spec for the suite: fully implemented, used as the template for +// the other resources. Covers POST/GET/PUT/DELETE plus validation and a basic +// authorization check (unauthenticated access). Organization CRUD is the one +// place a fresh org-per-test still makes sense — admin can manage additional +// orgs beyond the fixed one every account is seeded into (org creation isn't +// scoped to the caller's own org). + +const client = require('../support/client'); +const { createOrganization, deleteOrganization, uniqueHandle } = require('../support/fixtures'); + +describe('organizations', () => { + let org; + + beforeAll(async () => { + await client.login('admin'); + }); + + afterEach(async () => { + if (org) { + await deleteOrganization(org.id); + org = undefined; + } + }); + + it('creates and retrieves an organization', async () => { + org = await createOrganization(); + + const res = await client.as('admin').get(`/organizations/${org.id}`); + expect(res.status).toBe(200); + expect(res.body.id).toBe(org.id); + expect(res.body.displayName).toBe(org.displayName); + }); + + it('updates an organization', async () => { + org = await createOrganization(); + + const res = await client.as('admin').put(`/organizations/${org.id}`, { + id: org.id, + idpRefId: org.id, + displayName: 'Updated Display Name', + }); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Updated Display Name'); + }); + + it('deletes an organization', async () => { + org = await createOrganization(); + + const del = await client.as('admin').del(`/organizations/${org.id}`); + expect(del.status).toBe(204); + + const get = await client.as('admin').get(`/organizations/${org.id}`); + expect(get.status).toBe(404); + + org = undefined; // already deleted, skip afterEach cleanup + }); + + it('rejects creation with a missing required field', async () => { + const res = await client.as('admin').post('/organizations', { + id: uniqueHandle('org'), + // displayName and idpRefId omitted + }); + expect(res.status).toBe(400); + }); + + it('rejects creating a duplicate organization handle', async () => { + org = await createOrganization(); + + const res = await client.as('admin').post('/organizations', { + id: org.id, + displayName: 'Duplicate', + idpRefId: org.id, + }); + expect(res.status).toBe(409); + }); + + it('rejects requests without an authenticated session', async () => { + const res = await client.raw().get(`${client.API_PREFIX}/organizations`); + expect([401, 403]).toContain(res.status); + }); + + it("rejects a role without org management scope (developer can't create an org)", async () => { + await client.login('developer'); + const res = await client.as('developer').post('/organizations', { + id: uniqueHandle('org'), + displayName: 'Should Be Forbidden', + idpRefId: uniqueHandle('org'), + }); + expect(res.status).toBe(403); + }); +}); diff --git a/portals/developer-portal/it/rest-api/package-lock.json b/portals/developer-portal/it/rest-api/package-lock.json new file mode 100644 index 0000000000..e6a94b07f6 --- /dev/null +++ b/portals/developer-portal/it/rest-api/package-lock.json @@ -0,0 +1,4879 @@ +{ + "name": "developer-portal-it-rest-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "developer-portal-it-rest-api", + "version": "1.0.0", + "devDependencies": { + "cookiejar": "^2.1.4", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "nock": "^13.5.5", + "pg": "^8.14.0", + "supertest": "^7.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^11.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001802", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-junit": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz", + "integrity": "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mkdirp": "^1.0.4", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2", + "xml": "^1.0.1" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/portals/developer-portal/it/rest-api/package.json b/portals/developer-portal/it/rest-api/package.json new file mode 100644 index 0000000000..20538c1d47 --- /dev/null +++ b/portals/developer-portal/it/rest-api/package.json @@ -0,0 +1,21 @@ +{ + "name": "developer-portal-it-rest-api", + "version": "1.0.0", + "description": "REST API integration tests for the Developer Portal", + "private": true, + "scripts": { + "test": "jest --runInBand", + "test:watch": "jest --watch" + }, + "devDependencies": { + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "supertest": "^7.0.0", + "cookiejar": "^2.1.4", + "nock": "^13.5.5", + "pg": "^8.14.0" + }, + "optionalDependencies": { + "better-sqlite3": "^11.3.0" + } +} diff --git a/portals/developer-portal/it/rest-api/subscriptions/subscriptions.spec.js b/portals/developer-portal/it/rest-api/subscriptions/subscriptions.spec.js new file mode 100644 index 0000000000..ca9bdc610b --- /dev/null +++ b/portals/developer-portal/it/rest-api/subscriptions/subscriptions.spec.js @@ -0,0 +1,98 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST /subscriptions, GET/PUT/DELETE /subscriptions/{subId}, +// POST /subscriptions/{subId}/change-plan, POST /subscriptions/{subId}/regenerate-token. +// The shared org gets default plans (Gold/Silver/Bronze/Unlimited/AsyncUnlimited) +// via DP_GENERATEDEFAULTSUBPLANS=true (docker-compose.test*.yaml) — an API must +// still link a plan by name via its `subscriptionPlans` field before subscribing +// to it. `publisher` creates the API; `developer` (the consumer) subscribes to it. + +const client = require('../support/client'); +const { createApi } = require('../support/fixtures'); + +describe('subscriptions', () => { + let api; + + beforeAll(async () => { + await client.login('publisher'); + await client.login('developer'); + }); + + beforeEach(async () => { + api = await createApi({ subscriptionPlans: [{ id: 'Gold' }, { id: 'Silver' }] }); + }); + + it('creates a subscription for an application to an API plan', async () => { + const res = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + expect(res.status).toBe(201); + expect(res.body.apiId).toBe(api.id); + expect(res.body.subscriptionPlanName).toBe('Gold'); + expect(res.body.status).toBe('ACTIVE'); + expect(res.body.subscriptionToken).toBeDefined(); + }); + + it('retrieves a subscription', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const res = await client.as('developer').get(`/subscriptions/${create.body.subscriptionId}`); + expect(res.status).toBe(200); + expect(res.body.apiId).toBe(api.id); + }); + + it('changes a subscription plan', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const res = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'Silver' }); + expect(res.status).toBe(200); + expect(res.body.subscriptionPlanName).toBe('Silver'); + // Same subscription uuid/token, only the plan changed. + expect(res.body.subscriptionToken).toBe(create.body.subscriptionToken); + }); + + it('regenerates a subscription token', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const res = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/regenerate-token`, {}); + expect(res.status).toBe(200); + expect(res.body.subscriptionToken).toBeDefined(); + expect(res.body.subscriptionToken).not.toBe(create.body.subscriptionToken); + }); + + it('deletes a subscription', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const del = await client.as('developer').del(`/subscriptions/${create.body.subscriptionId}`); + expect(del.status).toBe(200); + + const get = await client.as('developer').get(`/subscriptions/${create.body.subscriptionId}`); + expect(get.status).toBe(404); + }); + + it('rejects subscribing with a plan not linked to the API', async () => { + const res = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Bronze' }); + expect(res.status).toBe(400); + }); + + it('rejects subscribing to a non-existent API', async () => { + const res = await client.as('developer').post('/subscriptions', { apiId: 'does-not-exist', subscriptionPlanId: 'Gold' }); + expect(res.status).toBe(404); + }); + + it('rejects change-plan to a plan that does not exist in the org', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const res = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'DoesNotExist' }); + expect(res.status).toBe(400); + }); +}); diff --git a/portals/developer-portal/it/rest-api/subscriptions/webhook-events.spec.js b/portals/developer-portal/it/rest-api/subscriptions/webhook-events.spec.js new file mode 100644 index 0000000000..ff65d822b6 --- /dev/null +++ b/portals/developer-portal/it/rest-api/subscriptions/webhook-events.spec.js @@ -0,0 +1,224 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Follows ../applications/webhook-events.spec.js for the full working pattern. +// VALID_EVENT_TYPES for this resource (src/services/webhooks/eventPublisher.js): +// subscription.created, subscription.updated, subscription.deleted, +// subscription.plan_changed, subscription.token_regenerated. + +const crypto = require('crypto'); +const client = require('../support/client'); +const db = require('../support/db'); +const { waitForEvent, waitForDelivery, poll } = require('../support/wait-for'); +const { createApi, createWebhookSubscriber } = require('../support/fixtures'); +const { createWebhookSink, resolveSinkUrl } = require('../support/webhook-sink'); +const { decryptFromEnvelope } = require('../support/envelopeCrypto'); + +describe('subscriptions webhook events', () => { + let api; + let sink; + let subscriber; + const sinkUrl = resolveSinkUrl(4501); + + beforeAll(async () => { + await client.login('publisher'); + await client.login('developer'); + await client.login('admin'); + sink = createWebhookSink(); + await sink.start(Number(sinkUrl.port)); + }); + + afterAll(async () => { + await sink.stop(); + await db.close(); + }); + + beforeEach(async () => { + api = await createApi({ subscriptionPlans: [{ id: 'Gold' }, { id: 'Silver' }] }); + subscriber = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['subscription.*'], + }); + }); + + afterEach(async () => { + await client.as('admin').del(`/webhook-subscribers/${subscriber.id}`); + sink.received.length = 0; + }); + + it('publishes and delivers subscription.created', async () => { + const since = new Date(); + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + expect(create.status).toBe(201); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.created', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('subscription.created'); + expect(received).toBeDefined(); + // subscriber_id is the developer's IdP subject, not the portal-internal + // user uuid — file-based auth's `sub` claim for the `developer` account. + expect(received.body.data).toEqual({ + subscription_id: create.body.subscriptionId, + subscriber_id: expect.any(String), + status: create.body.status, + subscription_plan: { ref_id: null, name: 'Gold' }, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + }); + + it('publishes and delivers subscription.plan_changed', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + + const since = new Date(); + const change = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'Silver' }); + expect(change.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.plan_changed', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('subscription.plan_changed'); + expect(received).toBeDefined(); + expect(received.body.data).toEqual({ + subscription_id: create.body.subscriptionId, + subscriber_id: expect.any(String), + status: create.body.status, + subscription_plan: { ref_id: null, name: 'Silver' }, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + previous_plan: { ref_id: null, name: 'Gold' }, + }); + }); + + it('publishes and delivers subscription.token_regenerated', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + + const since = new Date(); + const regen = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/regenerate-token`, {}); + expect(regen.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.token_regenerated', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('subscription.token_regenerated'); + expect(received).toBeDefined(); + // `token` is passed as a secretField (subscriptionService.regenerateToken) — + // this subscriber has no publicKey, so it's delivered without any encrypted + // fields and the plaintext token never appears in `data`. + expect(received.body.encrypted_fields).toEqual([]); + expect(received.body.data).toEqual({ + subscription_id: create.body.subscriptionId, + subscriber_id: expect.any(String), + status: create.body.status, + subscription_plan: { ref_id: null, name: 'Gold' }, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + expect(received.body.data.token).toBeUndefined(); + }); + + it('publishes and delivers subscription.deleted', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + + const since = new Date(); + const del = await client.as('developer').del(`/subscriptions/${create.body.subscriptionId}`); + expect(del.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.deleted', since }); + expect(event).toBeDefined(); + + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + expect(delivery.status).toBe('DELIVERED'); + + const received = sink.findDeliveryFor('subscription.deleted'); + expect(received).toBeDefined(); + expect(received.body.data).toEqual({ + subscription_id: create.body.subscriptionId, + subscriber_id: expect.any(String), + status: create.body.status, + subscription_plan: { ref_id: null, name: 'Gold' }, + api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + }); + }); + + it('encrypts secret fields to the subscriber public key when configured', async () => { + // TODO(pqc): migrate — RSA keypair mirrors the app's quantum-vulnerable + // envelope crypto (src/services/webhooks/envelopeCrypto.js); migrate together. + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const encryptedSubscriber = await createWebhookSubscriber({ + targetUrl: sinkUrl.href, + events: ['subscription.*'], + publicKey, + }); + + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + + const since = new Date(); + const regen = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/regenerate-token`, {}); + expect(regen.status).toBe(200); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.token_regenerated', since }); + + // Two subscribers now match subscription.* (the outer describe's default + // `subscriber` plus this one) — poll the sink directly for the specific + // delivery that has encrypted_fields, rather than racing on which + // subscriber's DB delivery row lands first. + const received = await poll(() => sink.received.find((r) => r.body?.event_id === event.uuid && r.body?.encrypted_fields?.length)); + expect(received).toBeDefined(); + expect(received.body.encrypted_fields).toEqual(['token']); + expect(received.body.data.token).toEqual({ + wrappedKey: expect.any(String), + iv: expect.any(String), + tag: expect.any(String), + ciphertext: expect.any(String), + }); + + const decrypted = decryptFromEnvelope(privateKey, received.body.data.token); + expect(decrypted).toBe(regen.body.subscriptionToken); + + await client.as('admin').del(`/webhook-subscribers/${encryptedSubscriber.id}`); + }); + + // Closes a gap the tests above don't cover on their own: response-correctness + // (subscriptions.spec.js) and event-correctness (this file) are asserted in + // separate suites, so a bug that gets one right and the other wrong on the + // same call would pass both. Assert the combination directly. + describe('action + event consistency', () => { + it('does not publish subscription.plan_changed when change-plan targets a plan not linked to the API', async () => { + const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + + const since = new Date(); + const res = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'DoesNotExist' }); + expect(res.status).toBe(400); + + const events = await db.findEvents({ orgUuid: await db.findOrgUuidByHandle(client.ORG_HANDLE), type: 'subscription.plan_changed', since }); + expect(events).toHaveLength(0); + }); + }); +}); diff --git a/portals/developer-portal/it/rest-api/support/client.js b/portals/developer-portal/it/rest-api/support/client.js new file mode 100644 index 0000000000..1b0b7ab94c --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/client.js @@ -0,0 +1,141 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Real session auth against the file-based Platform API accounts configured in +// it/configs/config-platform-api-it.toml (admin / publisher / developer, +// password == username) — not the devportal-it-test-key API-key bypass. Every +// role is locked to the single "default" org that account was seeded into +// (file-based auth supports exactly one org), so all fixtures/specs share that +// org and rely on uniqueHandle() for per-test resource isolation instead of a +// fresh org per test. +// +// Usage: `await client.login('publisher')` once (e.g. in beforeAll), then +// `client.as('publisher').post(path, body)` synchronously anywhere after — +// this two-step split matters for postMultipart/putMultipart, whose callers +// need to chain .field()/.attach() before the request is awaited; an async +// wrapper around those would auto-adopt (and fire) the returned supertest +// Request as soon as the function returns, before the chaining happens. + +const supertest = require('supertest'); + +const BASE_URL = process.env.DEVPORTAL_BASE_URL || 'http://localhost:3000'; +const API_PREFIX = '/api/v0.9'; +const ORG_HANDLE = process.env.DEVPORTAL_ORG_HANDLE || 'default'; + +const CREDENTIALS = { + admin: { username: process.env.DEVPORTAL_ADMIN_USERNAME || 'admin', password: process.env.DEVPORTAL_ADMIN_PASSWORD || 'admin' }, + publisher: { username: process.env.DEVPORTAL_PUBLISHER_USERNAME || 'publisher', password: process.env.DEVPORTAL_PUBLISHER_PASSWORD || 'publisher' }, + developer: { username: process.env.DEVPORTAL_DEVELOPER_USERNAME || 'developer', password: process.env.DEVPORTAL_DEVELOPER_PASSWORD || 'developer' }, +}; + +// One supertest agent per role, logged in once and reused — the agent's cookie +// jar carries the session across every request made through it. +const agents = {}; +const xsrfTokens = {}; +const loginPromises = {}; + +const { CookieAccessInfo } = require('cookiejar'); + +function extractXsrfToken(agent) { + // supertest-agent (superagent under the hood) exposes its cookie jar this way. + const jar = agent.jar || agent._jar; + if (!jar) return undefined; + const cookies = jar.getCookies(CookieAccessInfo.All); + const match = cookies.find((c) => c.name === 'XSRF-TOKEN'); + return match ? match.value : undefined; +} + +// Call once per role before using `as(role)` — typically in a `beforeAll`. +// Safe to call more than once; the actual login only happens the first time. +async function login(role) { + if (loginPromises[role]) return loginPromises[role]; + const { username, password } = CREDENTIALS[role] || {}; + if (!username) throw new Error(`Unknown auth role '${role}'`); + + loginPromises[role] = (async () => { + const agent = supertest.agent(BASE_URL); + const res = await agent + .post(`/${ORG_HANDLE}/views/default/login`) + .type('form') + .send({ username, password }) + .redirects(0); + if (res.status !== 302 || /error=/.test(res.headers.location || '')) { + throw new Error(`Login failed for role '${role}': ${res.status} ${res.headers.location || ''}`); + } + agents[role] = agent; + // handleLocalLogin calls req.session.regenerate() partway through the + // login request, *after* the global CSRF-cookie middleware (src/app.js) + // already ran once for that request — so the XSRF-TOKEN cookie on the + // login response itself reflects the discarded pre-regenerate session, + // not the one the session cookie actually points to. One throwaway + // authenticated GET refreshes it to the real value for this session. + await agent.get(`${API_PREFIX}/organizations/${ORG_HANDLE}`); + xsrfTokens[role] = extractXsrfToken(agent); + })(); + return loginPromises[role]; +} + +// Synchronous accessor — throws if `login(role)` hasn't resolved yet, so a +// missing `await client.login(...)` fails loudly instead of hanging on `undefined`. +function as(role) { + const agent = agents[role]; + if (!agent) throw new Error(`No active session for role '${role}' — call \`await client.login('${role}')\` first (e.g. in beforeAll).`); + const xsrf = xsrfTokens[role]; + // The XSRF-TOKEN cookie's value *is* the expected token (double-submit + // pattern — see src/app.js), but requireCsrfForMutatingApi only reads it + // back from X-CSRF-Token (or csrf-token), never X-XSRF-TOKEN. + const withXsrf = (req) => (xsrf ? req.set('X-CSRF-Token', xsrf) : req); + + return { + get: (path) => agent.get(`${API_PREFIX}${path}`), + post: (path, body) => withXsrf(agent.post(`${API_PREFIX}${path}`)).send(body), + put: (path, body) => withXsrf(agent.put(`${API_PREFIX}${path}`)).send(body), + del: (path) => withXsrf(agent.delete(`${API_PREFIX}${path}`)), + // For multipart/form-data endpoints (e.g. POST/PUT /apis) — caller chains + // .field()/.attach() before awaiting. + postMultipart: (path) => withXsrf(agent.post(`${API_PREFIX}${path}`)), + putMultipart: (path) => withXsrf(agent.put(`${API_PREFIX}${path}`)), + }; +} + +// For session-authenticated PAGE routes (not under /api/v0.9) that also require +// CSRF — e.g. the settings page's `PUT /:org/views/:view/llms-config` — reusing +// the same logged-in agent/token as `as(role)`, just without the API prefix. +function page(role) { + const agent = agents[role]; + if (!agent) throw new Error(`No active session for role '${role}' — call \`await client.login('${role}')\` first (e.g. in beforeAll).`); + const xsrf = xsrfTokens[role]; + const withXsrf = (req) => (xsrf ? req.set('X-CSRF-Token', xsrf) : req); + + return { + get: (path) => agent.get(path), + put: (path, body) => withXsrf(agent.put(path)).send(body), + }; +} + +module.exports = { + BASE_URL, + API_PREFIX, + ORG_HANDLE, + login, + as, + page, + // Escape hatch for requests that shouldn't carry any session (auth tests, + // unauthenticated-access checks, etc.) — a plain, cookie-less client. + raw: () => supertest(BASE_URL), +}; diff --git a/portals/developer-portal/it/rest-api/support/db.js b/portals/developer-portal/it/rest-api/support/db.js new file mode 100644 index 0000000000..0ffc258201 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/db.js @@ -0,0 +1,109 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Read-only access to the devportal database so specs can assert on rows +// (DP_EVENTS, DP_EVENT_DELIVERIES, etc.) that aren't exposed via the REST API. +// Branches on DEVPORTAL_DB_DIALECT since IT runs against both SQLite and Postgres. + +const DIALECT = process.env.DEVPORTAL_DB_DIALECT || 'sqlite'; + +let pool; +let sqliteDb; + +function getSqlite() { + if (!sqliteDb) { + const Database = require('better-sqlite3'); + sqliteDb = new Database(process.env.DEVPORTAL_DB_STORAGE, { readonly: true, fileMustExist: true }); + } + return sqliteDb; +} + +function getPgPool() { + if (!pool) { + const { Pool } = require('pg'); + pool = new Pool({ + host: process.env.DEVPORTAL_DB_HOST, + port: Number(process.env.DEVPORTAL_DB_PORT || 5432), + user: process.env.DEVPORTAL_DB_USERNAME, + password: process.env.DEVPORTAL_DB_PASSWORD, + database: process.env.DEVPORTAL_DB_DATABASE, + }); + } + return pool; +} + +// Runs a SELECT and returns rows as plain objects, regardless of dialect. +// `sql` must use $1/$2-style placeholders; they're rewritten to `?` for SQLite. +async function query(sql, params = []) { + if (DIALECT === 'postgres') { + const { rows } = await getPgPool().query(sql, params); + return rows; + } + const sqliteSql = sql.replace(/\$\d+/g, '?'); + return getSqlite().prepare(sqliteSql).all(...params); +} + +// dp_organizations.id (the REST `id`/handle) is not the internal uuid stored as +// org_uuid on dp_events etc. — resolve it once per org via its unique handle. +async function findOrgUuidByHandle(handle) { + const rows = await query('SELECT uuid FROM dp_organizations WHERE handle = $1', [handle]); + return rows[0]?.uuid; +} + +// Columns per database/schema.postgres.sql: dp_events(uuid, type, org_uuid, +// aggregate_type, aggregate_uuid, payload, occurred_at, status). +// `since` (a Date) scopes results to events from the current test only, since +// aggregate_uuid is an internal id the REST responses never expose. +async function findEvents({ orgUuid, type, aggregateUuid, since }) { + const clauses = []; + const params = []; + if (orgUuid) { params.push(orgUuid); clauses.push(`org_uuid = $${params.length}`); } + if (type) { params.push(type); clauses.push(`type = $${params.length}`); } + if (aggregateUuid) { params.push(aggregateUuid); clauses.push(`aggregate_uuid = $${params.length}`); } + if (since) { + params.push(since.toISOString()); + // SQLite stores occurred_at as "YYYY-MM-DD HH:MM:SS.SSS +00:00" (space- + // separated, explicit offset) — a plain string >= against an ISO + // "...T...Z" value compares wrong lexicographically. datetime() normalizes + // both sides. Postgres compares timestamptz natively; no wrapping needed. + clauses.push(DIALECT === 'postgres' + ? `occurred_at >= $${params.length}` + : `datetime(occurred_at) >= datetime($${params.length})`); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + return query(`SELECT * FROM dp_events ${where} ORDER BY occurred_at DESC`, params); +} + +// Columns per database/schema.postgres.sql: dp_event_deliveries(uuid, event_uuid, +// subscriber_id, target_url, encrypted_fields, status, last_http_status, +// last_error, last_attempt_at, delivered_at). +async function findDeliveries({ eventUuid, subscriberId }) { + const clauses = []; + const params = []; + if (eventUuid) { params.push(eventUuid); clauses.push(`event_uuid = $${params.length}`); } + if (subscriberId) { params.push(subscriberId); clauses.push(`subscriber_id = $${params.length}`); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + return query(`SELECT * FROM dp_event_deliveries ${where}`, params); +} + +async function close() { + if (sqliteDb) sqliteDb.close(); + if (pool) await pool.end(); +} + +module.exports = { query, findEvents, findDeliveries, findOrgUuidByHandle, close }; diff --git a/portals/developer-portal/it/rest-api/support/envelopeCrypto.js b/portals/developer-portal/it/rest-api/support/envelopeCrypto.js new file mode 100644 index 0000000000..cf82330ae6 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/envelopeCrypto.js @@ -0,0 +1,43 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Test-side mirror of src/services/webhooks/envelopeCrypto.js's decryptFromEnvelope. +// Can't require the app source directly: the rest-api-tests container only has +// `it/rest-api` mounted (docker-compose.test*.yaml), not the rest of the repo. Kept in +// lockstep with the app's encryptToSubscriber — RSA-OAEP(SHA-256)-wrapped AES-256-GCM +// key, base64-encoded fields. + +const crypto = require('crypto'); + +function decryptFromEnvelope(privateKeyPem, envelope) { + // TODO(pqc): migrate — mirrors the app's quantum-vulnerable RSA-OAEP unwrap + // (src/services/webhooks/envelopeCrypto.js); migrate together. + const aesKey = crypto.privateDecrypt( + { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + Buffer.from(envelope.wrappedKey, 'base64') + ); + + const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'base64')); + decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(envelope.ciphertext, 'base64')), + decipher.final() + ]).toString('utf8'); +} + +module.exports = { decryptFromEnvelope }; diff --git a/portals/developer-portal/it/rest-api/support/fixtures.js b/portals/developer-portal/it/rest-api/support/fixtures.js new file mode 100644 index 0000000000..65ed08bac0 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/fixtures.js @@ -0,0 +1,138 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Seed helpers that go through the real REST API (not the DAO layer) so every +// spec exercises the same validation/authorization path production traffic +// does. Every fixture operates within the single shared org (client.ORG_HANDLE) +// that the admin/publisher/developer accounts are seeded into — resource +// uniqueness comes from uniqueHandle(), not a fresh org per test. Callers must +// have already done `await client.login(role)` for whichever role a fixture uses. + +const crypto = require('crypto'); + +const client = require('./client'); + +function uniqueHandle(prefix) { + return `${prefix}-${crypto.randomUUID()}`; +} + +// Seeds a view (ViewCreateRequest requires at least one label). Seeds its own +// label unless the caller passes `overrides.labels` (specs that manage label +// visibility themselves supply their own label ids). +async function createView(overrides = {}) { + let labels = overrides.labels; + if (!labels) { + const labelId = uniqueHandle('label'); + await client.as('admin').post('/labels', { id: labelId, displayName: labelId }); + labels = [labelId]; + } + const id = overrides.id || uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: overrides.displayName || id, labels }); + if (res.status !== 201) { + throw new Error(`Failed to seed view: ${res.status} ${JSON.stringify(res.body)}`); + } + return { id }; +} + +// Only used by organizations.spec.js's own CRUD tests — org creation isn't +// scoped to the caller's own org, so admin can manage additional orgs beyond +// the fixed one, even though no file-based account can ever log into them. +async function createOrganization(overrides = {}) { + const id = overrides.id || uniqueHandle('org'); + const res = await client.as('admin').post('/organizations', { + id, + displayName: overrides.displayName || `Test Org ${id}`, + idpRefId: overrides.idpRefId || id, + ...overrides, + }); + if (res.status !== 201) { + throw new Error(`Failed to seed organization: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +async function deleteOrganization(handleOrId) { + await client.as('admin').del(`/organizations/${handleOrId}`); +} + +const MINIMAL_OPENAPI_DEFINITION = JSON.stringify({ + openapi: '3.0.3', + info: { title: 'Fixture API', version: '1.0.0' }, + paths: { '/ping': { get: { responses: { 200: { description: 'ok' } } } } }, +}); + +// POST /apis takes multipart/form-data: `apiMetadata` (JSON string) + `apiDefinition` +// (file) — see docs/devportal-openapi-spec-v0.9.yaml ApiMetadataMultipartBody. +// `publisher` holds the API-management scopes; pass `role` to override. +async function createApi(overrides = {}) { + const { definition, definitionFileName: _definitionFileName, role = 'publisher', ...metadataOverrides } = overrides; + const id = overrides.id || uniqueHandle('api'); + const metadata = { + id, + name: overrides.name || `Test API ${id}`, + version: overrides.version || 'v1.0', + type: overrides.type || 'REST', + status: overrides.status || 'PUBLISHED', + // Required — createAPIMetadata rejects with 400 if endPoints is missing + // (src/services/apiMetadataService.js). + endPoints: overrides.endPoints || { + productionURL: `https://backend.example.invalid/${id}`, + sandboxURL: `https://sandbox.example.invalid/${id}`, + }, + ...metadataOverrides, + }; + // Extension drives validation in apiMetadataService.js's + // prepareApiDefinitionForStorage (.json parsed as JSON, .wsdl/.xml as text) — + // must match the actual content, not just be any file. + const definitionFileName = overrides.definitionFileName || 'definition.json'; + const res = await client + .as(role) + .postMultipart('/apis') + .field('apiMetadata', JSON.stringify(metadata)) + .attach('apiDefinition', Buffer.from(overrides.definition || MINIMAL_OPENAPI_DEFINITION), definitionFileName); + if (res.status !== 201) { + throw new Error(`Failed to seed API: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +// `admin` manages org-level integration config; pass `role` to override. +async function createWebhookSubscriber(overrides = {}) { + const { role = 'admin', ...bodyOverrides } = overrides; + const id = overrides.id || uniqueHandle('subscriber'); + const res = await client.as(role).post('/webhook-subscribers', { + id, + targetUrl: overrides.targetUrl, + events: overrides.events || [], + enabled: overrides.enabled !== undefined ? overrides.enabled : true, + ...bodyOverrides, + }); + if (res.status !== 201) { + throw new Error(`Failed to seed webhook subscriber: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +module.exports = { + uniqueHandle, + createView, + createOrganization, + deleteOrganization, + createApi, + createWebhookSubscriber, +}; diff --git a/portals/developer-portal/it/rest-api/support/global-setup.js b/portals/developer-portal/it/rest-api/support/global-setup.js new file mode 100644 index 0000000000..7e4183133a --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/global-setup.js @@ -0,0 +1,55 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Runs once before the whole Jest run: waits for the devportal container's +// /health endpoint so specs don't race the server/dispatcher startup. + +const http = require('http'); +const https = require('https'); + +const BASE_URL = process.env.DEVPORTAL_BASE_URL || 'http://localhost:3000'; +const TIMEOUT_MS = 60000; + +module.exports = async function globalSetup() { + const client = BASE_URL.startsWith('https') ? https : http; + const deadline = Date.now() + TIMEOUT_MS; + + await new Promise((resolve, reject) => { + const attempt = () => { + const req = client + .get(`${BASE_URL}/health`, (res) => { + if (res.statusCode === 200) return resolve(); + res.resume(); + retry(); + }) + .on('error', retry); + // A connection that neither responds nor errors (server accepted the + // socket but hangs) would otherwise never reach retry()/the deadline + // check, hanging setup forever. Destroy it so 'error' fires and retry() + // enforces TIMEOUT_MS. + req.setTimeout(5000, () => req.destroy(new Error('health check request timed out'))); + }; + const retry = () => { + if (Date.now() > deadline) { + return reject(new Error(`devportal did not become healthy within ${TIMEOUT_MS}ms`)); + } + setTimeout(attempt, 1000); + }; + attempt(); + }); +}; diff --git a/portals/developer-portal/it/rest-api/support/global-teardown.js b/portals/developer-portal/it/rest-api/support/global-teardown.js new file mode 100644 index 0000000000..4beac5cf4d --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/global-teardown.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// globalSetup/globalTeardown each run in their own process (no module state +// shared with specs), so there's nothing persistent here to close yet. Kept +// as a placeholder so per-suite cleanup has an obvious home if that changes. +module.exports = async function globalTeardown() {}; diff --git a/portals/developer-portal/it/rest-api/support/wait-for.js b/portals/developer-portal/it/rest-api/support/wait-for.js new file mode 100644 index 0000000000..5cfa3a6c86 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/wait-for.js @@ -0,0 +1,57 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Generic poll-until helper for the async webhook pipeline: event write -> dispatcher +// poll (every 2s per src/services/webhooks/dispatcher.js) -> delivery. + +const db = require('./db'); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function poll(fn, { timeoutMs = 15000, intervalMs = 500 } = {}) { + const deadline = Date.now() + timeoutMs; + let lastResult; + do { + lastResult = await fn(); + if (lastResult) return lastResult; + await sleep(intervalMs); + } while (Date.now() < deadline); + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +// orgHandle is the REST-facing handle (e.g. org.id); it's resolved to the +// internal org_uuid once since dp_events isn't keyed by the public handle. +async function waitForEvent({ orgHandle, orgUuid, type, aggregateUuid, since }, options) { + const resolvedOrgUuid = orgUuid || (orgHandle ? await db.findOrgUuidByHandle(orgHandle) : undefined); + return poll(async () => { + const rows = await db.findEvents({ orgUuid: resolvedOrgUuid, type, aggregateUuid, since }); + return rows[0]; + }, options); +} + +async function waitForDelivery({ eventUuid, subscriberId }, options) { + return poll(async () => { + const rows = await db.findDeliveries({ eventUuid, subscriberId }); + const delivered = rows.find((r) => r.status === 'DELIVERED' || r.status === 'FAILED'); + return delivered; + }, options); +} + +module.exports = { sleep, poll, waitForEvent, waitForDelivery }; diff --git a/portals/developer-portal/it/rest-api/support/webhook-sink.js b/portals/developer-portal/it/rest-api/support/webhook-sink.js new file mode 100644 index 0000000000..e204a2f478 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/webhook-sink.js @@ -0,0 +1,100 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// A minimal HTTP receiver that specs register as a dp_webhook_subscribers +// target_url, so the real dispatcher/delivery worker can POST to it and tests +// can assert on the payload/signature actually delivered over the wire. + +const http = require('http'); + +// WEBHOOK_SINK_URL (docker-compose.test*.yaml) gives the *hostname* the devportal +// container must use to reach this test container (its own service name, e.g. +// "rest-api-tests") — plain "localhost" wouldn't resolve across containers. But every +// */webhook-events.spec.js file needs its OWN port, since Jest runs spec files in +// parallel worker processes within this one container and they'd otherwise all try to +// bind whatever single port the env var's URL happens to carry. So: take the hostname +// from WEBHOOK_SINK_URL when set, but always keep the caller's own defaultPort — never +// the env var's port. +function resolveSinkUrl(defaultPort) { + const hostname = process.env.WEBHOOK_SINK_URL + ? new URL(process.env.WEBHOOK_SINK_URL).hostname + : 'localhost'; + return new URL(`http://${hostname}:${defaultPort}`); +} + +function createWebhookSink() { + const received = []; + let server; + // Overrides the HTTP status returned to the NEXT request only, then resets — + // lets tests simulate a delivery failure without a long-lived stateful mode. + let nextResponseStatus = 200; + + function respondWith(status) { + nextResponseStatus = status; + } + + function start(port = 0) { + return new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + let rawBody = ''; + req.on('data', (chunk) => { rawBody += chunk; }); + req.on('end', () => { + const status = nextResponseStatus; + nextResponseStatus = 200; + // Guard the parse: a non-JSON payload would otherwise throw + // inside this server callback and crash the Jest worker. On + // failure keep `body` null so the assertion fails clearly + // (with rawBody available) instead of the exception escaping. + let body = null; + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } + } + received.push({ + headers: req.headers, + rawBody, + body, + receivedAt: new Date(), + }); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: status < 300 })); + }); + }); + server.on('error', reject); + server.listen(port, () => resolve(server.address().port)); + }); + } + + function stop() { + return new Promise((resolve) => (server ? server.close(resolve) : resolve())); + } + + // Delivered envelope shape (src/services/webhooks/deliveryWorker.js): + // { event_id, event_type, occurred_at, org: { ref_id }, encrypted_fields: [], data: {...} } + // Signature, when the subscriber has a secret, arrives as X-Devportal-Signature. + function findDeliveryFor(eventType) { + return received.find((r) => r.body?.event_type === eventType); + } + + return { start, stop, received, findDeliveryFor, respondWith }; +} + +module.exports = { createWebhookSink, resolveSinkUrl }; diff --git a/portals/developer-portal/it/rest-api/support/zipBuilder.js b/portals/developer-portal/it/rest-api/support/zipBuilder.js new file mode 100644 index 0000000000..3e08cb1401 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/zipBuilder.js @@ -0,0 +1,112 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Minimal ZIP writer for artifact-upload tests (POST/PUT /apis with an `artifact` +// field) — no zip-writing library is a dependency of it/rest-api, and the server +// side only needs to unzip (via `unzipper`), so this only ever needs to produce +// valid input, not full ZIP feature coverage. Uses STORED (uncompressed) entries +// only, so no deflate implementation is needed — just each entry's bytes verbatim +// plus a CRC-32 (Node's built-in zlib.crc32, added in Node 21). + +const zlib = require('zlib'); + +function dosDateTime() { + // Fixed MS-DOS date/time (1980-01-01 00:00:00) — entry timestamps aren't + // inspected by anything on the read side here. + return { time: 0, date: 0x21 }; +} + +function writeUint16LE(buf, offset, value) { + buf.writeUInt16LE(value, offset); +} + +function writeUint32LE(buf, offset, value) { + buf.writeUInt32LE(value, offset); +} + +/** + * @param {Array<{ name: string, content: string | Buffer }>} entries + * @returns {Buffer} + */ +function createZip(entries) { + const { time, date } = dosDateTime(); + const localChunks = []; + const centralChunks = []; + let offset = 0; + + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + const contentBuf = Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content, 'utf8'); + const crc = zlib.crc32(contentBuf); + + const localHeader = Buffer.alloc(30); + writeUint32LE(localHeader, 0, 0x04034b50); + writeUint16LE(localHeader, 4, 20); // version needed + writeUint16LE(localHeader, 6, 0); // flags + writeUint16LE(localHeader, 8, 0); // compression: stored + writeUint16LE(localHeader, 10, time); + writeUint16LE(localHeader, 12, date); + writeUint32LE(localHeader, 14, crc); + writeUint32LE(localHeader, 18, contentBuf.length); // compressed size + writeUint32LE(localHeader, 22, contentBuf.length); // uncompressed size + writeUint16LE(localHeader, 26, nameBuf.length); + writeUint16LE(localHeader, 28, 0); // extra field length + + localChunks.push(localHeader, nameBuf, contentBuf); + + const centralHeader = Buffer.alloc(46); + writeUint32LE(centralHeader, 0, 0x02014b50); + writeUint16LE(centralHeader, 4, 20); // version made by + writeUint16LE(centralHeader, 6, 20); // version needed + writeUint16LE(centralHeader, 8, 0); // flags + writeUint16LE(centralHeader, 10, 0); // compression: stored + writeUint16LE(centralHeader, 12, time); + writeUint16LE(centralHeader, 14, date); + writeUint32LE(centralHeader, 16, crc); + writeUint32LE(centralHeader, 20, contentBuf.length); + writeUint32LE(centralHeader, 24, contentBuf.length); + writeUint16LE(centralHeader, 28, nameBuf.length); + writeUint16LE(centralHeader, 30, 0); // extra field length + writeUint16LE(centralHeader, 32, 0); // comment length + writeUint16LE(centralHeader, 34, 0); // disk number start + writeUint16LE(centralHeader, 36, 0); // internal attributes + writeUint32LE(centralHeader, 38, 0); // external attributes + writeUint32LE(centralHeader, 42, offset); // relative offset of local header + + centralChunks.push(centralHeader, nameBuf); + + offset += localHeader.length + nameBuf.length + contentBuf.length; + } + + const centralDirectoryStart = offset; + const centralDirectory = Buffer.concat(centralChunks); + + const eocd = Buffer.alloc(22); + writeUint32LE(eocd, 0, 0x06054b50); + writeUint16LE(eocd, 4, 0); // disk number + writeUint16LE(eocd, 6, 0); // disk with central directory + writeUint16LE(eocd, 8, entries.length); // records on this disk + writeUint16LE(eocd, 10, entries.length); // total records + writeUint32LE(eocd, 12, centralDirectory.length); + writeUint32LE(eocd, 16, centralDirectoryStart); + writeUint16LE(eocd, 20, 0); // comment length + + return Buffer.concat([...localChunks, centralDirectory, eocd]); +} + +module.exports = { createZip }; diff --git a/portals/developer-portal/it/rest-api/views-and-labels/labels.spec.js b/portals/developer-portal/it/rest-api/views-and-labels/labels.spec.js new file mode 100644 index 0000000000..ebc1d8158e --- /dev/null +++ b/portals/developer-portal/it/rest-api/views-and-labels/labels.spec.js @@ -0,0 +1,75 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /labels (src/routes/api/handlers/labelsHandler.js). +// LabelRequest requires { id, displayName }. `admin` manages org-level config. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +describe('labels', () => { + beforeAll(async () => { + await client.login('admin'); + }); + + it('creates a label', async () => { + const id = uniqueHandle('label'); + const res = await client.as('admin').post('/labels', { id, displayName: 'Premium APIs' }); + expect(res.status).toBe(201); + expect(res.body.id).toBe(id); + expect(res.body.displayName).toBe('Premium APIs'); + }); + + it('retrieves a label', async () => { + const id = uniqueHandle('label'); + await client.as('admin').post('/labels', { id, displayName: 'Retrieved Label' }); + + const res = await client.as('admin').get(`/labels/${id}`); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Retrieved Label'); + }); + + it('updates a label', async () => { + const id = uniqueHandle('label'); + await client.as('admin').post('/labels', { id, displayName: 'Original Label' }); + + const res = await client.as('admin').put(`/labels/${id}`, { id, displayName: 'Renamed Label' }); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Renamed Label'); + }); + + it('deletes a label', async () => { + const id = uniqueHandle('label'); + await client.as('admin').post('/labels', { id, displayName: 'To Delete' }); + + const del = await client.as('admin').del(`/labels/${id}`); + expect(del.status).toBe(204); + + const get = await client.as('admin').get(`/labels/${id}`); + expect(get.status).toBe(404); + }); + + it('lists labels for an org', async () => { + const id = uniqueHandle('label'); + await client.as('admin').post('/labels', { id, displayName: 'Listed Label' }); + + const res = await client.as('admin').get('/labels'); + expect(res.status).toBe(200); + expect(res.body.list.some((l) => l.id === id)).toBe(true); + }); +}); diff --git a/portals/developer-portal/it/rest-api/views-and-labels/view-label-visibility.spec.js b/portals/developer-portal/it/rest-api/views-and-labels/view-label-visibility.spec.js new file mode 100644 index 0000000000..dabec27ab3 --- /dev/null +++ b/portals/developer-portal/it/rest-api/views-and-labels/view-label-visibility.spec.js @@ -0,0 +1,89 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Cross-cutting behavior: confirms the label/view mapping actually filters +// which APIs GET /apis?view= returns (apiViewQuery parameter). `admin` manages +// labels/views; `publisher` creates the APIs. + +const client = require('../support/client'); +const { createApi, uniqueHandle, createView } = require('../support/fixtures'); + +async function createLabel(overrides = {}) { + const id = overrides.id || uniqueHandle('label'); + const res = await client.as('admin').post('/labels', { id, displayName: overrides.displayName || id }); + if (res.status !== 201) { + throw new Error(`Failed to seed label: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('view/label visibility filtering', () => { + beforeAll(async () => { + await client.login('admin'); + await client.login('publisher'); + }); + + it('GET /apis?view={viewName} only returns APIs whose labels intersect the view', async () => { + const labelA = await createLabel(); + const labelB = await createLabel(); + const viewA = await createView({ labels: [labelA.id] }); + const viewB = await createView({ labels: [labelB.id] }); + + const apiInA = await createApi({ labels: [labelA.id] }); + const apiInB = await createApi({ labels: [labelB.id] }); + + const resA = await client.as('publisher').get(`/apis?view=${viewA.id}`); + expect(resA.status).toBe(200); + expect(resA.body.list.some((a) => a.id === apiInA.id)).toBe(true); + expect(resA.body.list.some((a) => a.id === apiInB.id)).toBe(false); + + const resB = await client.as('publisher').get(`/apis?view=${viewB.id}`); + expect(resB.body.list.some((a) => a.id === apiInB.id)).toBe(true); + expect(resB.body.list.some((a) => a.id === apiInA.id)).toBe(false); + }); + + it('an API tagged with a label only appears in views that include that label', async () => { + const label = await createLabel(); + const matchingView = await createView({ labels: [label.id] }); + const otherLabel = await createLabel(); + const nonMatchingView = await createView({ labels: [otherLabel.id] }); + + const api = await createApi({ labels: [label.id] }); + + const matching = await client.as('publisher').get(`/apis?view=${matchingView.id}`); + expect(matching.body.list.some((a) => a.id === api.id)).toBe(true); + + const nonMatching = await client.as('publisher').get(`/apis?view=${nonMatchingView.id}`); + expect(nonMatching.body.list.some((a) => a.id === api.id)).toBe(false); + }); + + it('removing a label from a view hides previously-visible APIs from that view', async () => { + const label = await createLabel(); + const view = await createView({ labels: [label.id] }); + const api = await createApi({ labels: [label.id] }); + + const before = await client.as('publisher').get(`/apis?view=${view.id}`); + expect(before.body.list.some((a) => a.id === api.id)).toBe(true); + + const update = await client.as('admin').put(`/views/${view.id}`, { labels: [] }); + expect(update.status).toBe(200); + + const after = await client.as('publisher').get(`/apis?view=${view.id}`); + expect(after.body.list.some((a) => a.id === api.id)).toBe(false); + }); +}); diff --git a/portals/developer-portal/it/rest-api/views-and-labels/views.spec.js b/portals/developer-portal/it/rest-api/views-and-labels/views.spec.js new file mode 100644 index 0000000000..be1092a987 --- /dev/null +++ b/portals/developer-portal/it/rest-api/views-and-labels/views.spec.js @@ -0,0 +1,94 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /views. A view groups a set of labels to filter which +// APIs are visible in that portal view. ViewCreateRequest requires { id, labels }. +// `admin` manages org-level config. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +async function createLabel(overrides = {}) { + const id = overrides.id || uniqueHandle('label'); + const res = await client.as('admin').post('/labels', { id, displayName: overrides.displayName || id }); + if (res.status !== 201) { + throw new Error(`Failed to seed label: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('views', () => { + let label; + + beforeAll(async () => { + await client.login('admin'); + }); + + beforeEach(async () => { + label = await createLabel(); + }); + + it('creates a view', async () => { + const id = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: 'Partner APIs', labels: [label.id] }); + expect(res.status).toBe(201); + }); + + it('retrieves a view', async () => { + const id = uniqueHandle('view'); + await client.as('admin').post('/views', { id, displayName: 'Retrievable View', labels: [label.id] }); + + const res = await client.as('admin').get(`/views/${id}`); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe('Retrievable View'); + expect(res.body.labels).toContain(label.id); + }); + + it('updates a view (display name, label associations)', async () => { + const id = uniqueHandle('view'); + await client.as('admin').post('/views', { id, displayName: 'Original View', labels: [label.id] }); + const label2 = await createLabel(); + + const res = await client.as('admin').put(`/views/${id}`, { displayName: 'Updated View', labels: [label2.id] }); + expect(res.status).toBe(200); + + const get = await client.as('admin').get(`/views/${id}`); + expect(get.body.displayName).toBe('Updated View'); + expect(get.body.labels).toEqual([label2.id]); + }); + + it('deletes a view', async () => { + const id = uniqueHandle('view'); + await client.as('admin').post('/views', { id, displayName: 'To Delete', labels: [label.id] }); + + const del = await client.as('admin').del(`/views/${id}`); + expect(del.status).toBe(204); + + const get = await client.as('admin').get(`/views/${id}`); + expect(get.status).toBe(404); + }); + + it('lists views for an org', async () => { + const id = uniqueHandle('view'); + await client.as('admin').post('/views', { id, displayName: 'Listed View', labels: [label.id] }); + + const res = await client.as('admin').get('/views'); + expect(res.status).toBe(200); + expect(res.body.list.some((v) => v.id === id)).toBe(true); + }); +}); diff --git a/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-delivery.spec.js b/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-delivery.spec.js new file mode 100644 index 0000000000..0a7b707e7c --- /dev/null +++ b/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-delivery.spec.js @@ -0,0 +1,137 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Cross-cutting delivery-pipeline behavior, as opposed to any one resource's +// event payload. Follows ../applications/webhook-events.spec.js for the base +// sink/subscriber pattern. + +const crypto = require('crypto'); +const client = require('../support/client'); +const db = require('../support/db'); +const { waitForEvent, waitForDelivery, sleep } = require('../support/wait-for'); +const { createWebhookSubscriber, uniqueHandle } = require('../support/fixtures'); +const { createWebhookSink, resolveSinkUrl } = require('../support/webhook-sink'); + +describe('webhook delivery pipeline', () => { + let sink; + let subscriber; + const sinkUrl = resolveSinkUrl(4503); + + beforeAll(async () => { + await client.login('admin'); + await client.login('developer'); + sink = createWebhookSink(); + await sink.start(Number(sinkUrl.port)); + }); + + afterAll(async () => { + await sink.stop(); + await db.close(); + }); + + afterEach(async () => { + if (subscriber) { + await client.as('admin').del(`/webhook-subscribers/${subscriber.id}`); + subscriber = undefined; + } + sink.received.length = 0; + }); + + it('does not deliver to a subscriber whose event pattern does not match', async () => { + subscriber = await createWebhookSubscriber({ targetUrl: sinkUrl.href, events: ['apikey.*'] }); + + const since = new Date(); + await client.as('developer').post('/applications', { id: uniqueHandle('app'), displayName: 'Pattern Test', description: 'd' }); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + expect(event).toBeDefined(); + + // Give the dispatcher a full poll cycle to prove it deliberately skips + // this subscriber, rather than just not having gotten to it yet. + await sleep(2500); + const deliveries = await db.findDeliveries({ eventUuid: event.uuid }); + expect(deliveries).toHaveLength(0); + }); + + it('signs the payload with HMAC when the subscriber has a secret', async () => { + const secret = 'test-signing-secret'; + subscriber = await createWebhookSubscriber({ targetUrl: sinkUrl.href, events: ['application.*'], secret }); + + const since = new Date(); + await client.as('developer').post('/applications', { id: uniqueHandle('app'), displayName: 'Signed', description: 'd' }); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + await waitForDelivery({ eventUuid: event.uuid }); + + const received = sink.findDeliveryFor('application.created'); + expect(received).toBeDefined(); + const sigHeader = received.headers['x-devportal-signature']; + expect(sigHeader).toBeDefined(); + + // Recompute per signer.js: HMAC-SHA256(".", secret), hex. + const [tPart, v1Part] = sigHeader.split(','); + const ts = tPart.split('=')[1]; + const expectedHmac = crypto.createHmac('sha256', secret).update(`${ts}.${received.rawBody}`).digest('hex'); + expect(v1Part).toBe(`v1=${expectedHmac}`); + }); + + it('marks a delivery FAILED when the target responds with a non-2xx status', async () => { + subscriber = await createWebhookSubscriber({ targetUrl: sinkUrl.href, events: ['application.*'] }); + sink.respondWith(500); + + const since = new Date(); + await client.as('developer').post('/applications', { id: uniqueHandle('app'), displayName: 'Failing', description: 'd' }); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + const delivery = await waitForDelivery({ eventUuid: event.uuid }); + + // No retry scheduling for ordinary non-2xx responses — a single failed + // attempt is terminal (src/dao/eventDao.js markFailed: "Single attempt — + // no retry scheduling"). last_http_status/last_error record the failure. + expect(delivery.status).toBe('FAILED'); + expect(delivery.last_http_status).toBe(500); + }); + + it('does not attempt delivery to a disabled subscriber', async () => { + subscriber = await createWebhookSubscriber({ targetUrl: sinkUrl.href, events: ['application.*'], enabled: false }); + + const since = new Date(); + await client.as('developer').post('/applications', { id: uniqueHandle('app'), displayName: 'Disabled Sub', description: 'd' }); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + await sleep(2500); + const deliveries = await db.findDeliveries({ eventUuid: event.uuid }); + expect(deliveries).toHaveLength(0); + }); + + it('marks the source event ALL_DELIVERED once every matching subscriber has a terminal delivery status', async () => { + subscriber = await createWebhookSubscriber({ targetUrl: sinkUrl.href, events: ['application.*'] }); + + const since = new Date(); + await client.as('developer').post('/applications', { id: uniqueHandle('app'), displayName: 'All Delivered', description: 'd' }); + + const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'application.created', since }); + await waitForDelivery({ eventUuid: event.uuid }); + + // Match by the uuid captured from waitForEvent — filtering only by + // org+type could pick up an earlier application.created event from this suite. + const events = await db.findEvents({ orgUuid: (await db.findOrgUuidByHandle(client.ORG_HANDLE)), type: 'application.created' }); + const finalEvent = events.find((e) => e.uuid === event.uuid); + expect(finalEvent.status).toBe('ALL_DELIVERED'); + }); +}); diff --git a/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-subscribers.spec.js b/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-subscribers.spec.js new file mode 100644 index 0000000000..2f8128a574 --- /dev/null +++ b/portals/developer-portal/it/rest-api/webhook-subscribers/webhook-subscribers.spec.js @@ -0,0 +1,111 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// POST/GET/PUT/DELETE /webhook-subscribers, GET /webhook-subscribers/{id}/deliveries. +// Request shape: { id, displayName?, targetUrl, secret?, publicKey?, events?, enabled? } +// — see docs/devportal-openapi-spec-v0.9.yaml WebhookSubscriberRequest. +// `events` is a glob allowlist (trailing `*` only, e.g. "apikey.*"); empty/omitted +// means all event types. `admin` manages org-level integration config. + +const client = require('../support/client'); +const { createWebhookSubscriber, uniqueHandle } = require('../support/fixtures'); + +describe('webhook subscribers', () => { + beforeAll(async () => { + await client.login('admin'); + await client.login('developer'); + }); + + it('creates and retrieves a webhook subscriber', async () => { + const subscriber = await createWebhookSubscriber({ + targetUrl: 'https://example.invalid/webhook', + events: ['apikey.*'], + }); + const res = await client.as('admin').get(`/webhook-subscribers/${subscriber.id}`); + expect(res.status).toBe(200); + expect(res.body.targetUrl).toBe('https://example.invalid/webhook'); + // secret must never round-trip in a response + expect(res.body.secret).toBeUndefined(); + }); + + it('updates a webhook subscriber (target URL, events, enabled)', async () => { + const subscriber = await createWebhookSubscriber({ + targetUrl: 'https://example.invalid/webhook', + events: ['apikey.*'], + }); + + // NOTE: docs/devportal-openapi-spec-v0.9.yaml's WebhookSubscriberUpdateBody + // description says "all fields are optional; only supplied fields are + // updated", but it references the base WebhookSubscriberRequest schema, + // which requires `id` and `targetUrl` — the server enforces the schema, + // rejecting a PUT that omits `id` with a 400. Doc/implementation mismatch. + const res = await client.as('admin').put(`/webhook-subscribers/${subscriber.id}`, { + id: subscriber.id, + targetUrl: 'https://updated.example.invalid/webhook', + events: ['subscription.*'], + enabled: false, + }); + expect(res.status).toBe(200); + expect(res.body.targetUrl).toBe('https://updated.example.invalid/webhook'); + expect(res.body.enabled).toBe(false); + }); + + it('deletes a webhook subscriber', async () => { + const subscriber = await createWebhookSubscriber({ + targetUrl: 'https://example.invalid/webhook', + }); + + const del = await client.as('admin').del(`/webhook-subscribers/${subscriber.id}`); + expect(del.status).toBe(204); + + const get = await client.as('admin').get(`/webhook-subscribers/${subscriber.id}`); + expect(get.status).toBe(404); + }); + + it('lists deliveries for a subscriber', async () => { + const subscriber = await createWebhookSubscriber({ + targetUrl: 'https://example.invalid/webhook', + events: ['application.*'], + }); + await client.as('developer').post('/applications', { + id: uniqueHandle('delivery-list-app'), + displayName: 'Delivery List App', + description: 'd', + }); + + // No sink is listening at example.invalid, so the delivery attempt itself + // fails — this only asserts the endpoint surfaces delivery attempts at all. + const res = await client.as('admin').get(`/webhook-subscribers/${subscriber.id}/deliveries`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.list)).toBe(true); + }); + + it('accepts a pattern without a trailing wildcard as a literal exact-match filter', async () => { + // webhookSubscriberDao.js's matchSubscribers only special-cases patterns + // ending in ".*" (prefix match); anything else — like "*.created" — is + // compared with strict equality against the event type, so it's accepted + // at creation time but will simply never match any real event type. + const res = await client.as('admin').post('/webhook-subscribers', { + id: uniqueHandle('literal-pattern-subscriber'), + targetUrl: 'https://example.invalid/webhook', + events: ['*.created'], + }); + expect(res.status).toBe(201); + expect(res.body.events).toEqual(['*.created']); + }); +}); diff --git a/portals/developer-portal/it/test-config.toml b/portals/developer-portal/it/test-config.toml index 01457e8662..ff203c3db5 100644 --- a/portals/developer-portal/it/test-config.toml +++ b/portals/developer-portal/it/test-config.toml @@ -19,3 +19,9 @@ value = "devportal-it-test-key" [organization] default_name = "default" auto_create_subscription_plans = true + +[platform_api] +# File-based (local) auth against the Platform API sidecar. Host is identical in +# both DB variants; insecure accepts its self-signed cert inside the test network. +base_url = "https://platform-api:9243" +insecure = true diff --git a/portals/developer-portal/it/cypress.config.js b/portals/developer-portal/it/ui/cypress.config.js similarity index 100% rename from portals/developer-portal/it/cypress.config.js rename to portals/developer-portal/it/ui/cypress.config.js diff --git a/portals/developer-portal/it/cypress/e2e/000-smoke/001-smoke.cy.js b/portals/developer-portal/it/ui/cypress/e2e/000-smoke/001-smoke.cy.js similarity index 100% rename from portals/developer-portal/it/cypress/e2e/000-smoke/001-smoke.cy.js rename to portals/developer-portal/it/ui/cypress/e2e/000-smoke/001-smoke.cy.js diff --git a/portals/developer-portal/it/cypress/e2e/000-smoke/002-api-listing.cy.js b/portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js similarity index 100% rename from portals/developer-portal/it/cypress/e2e/000-smoke/002-api-listing.cy.js rename to portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js diff --git a/portals/developer-portal/it/cypress/e2e/001-basic/001-portal-access.cy.js b/portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js similarity index 100% rename from portals/developer-portal/it/cypress/e2e/001-basic/001-portal-access.cy.js rename to portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/asgardeo-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/asgardeo-login.cy.js new file mode 100644 index 0000000000..28a6ea41d8 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/asgardeo-login.cy.js @@ -0,0 +1,27 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Requires a live or mocked Asgardeo tenant reachable from the test +// environment — devportal must be configured with identityProvider pointed at +// it (see src/config/configLoader.js). Not runnable against the default +// docker-compose.test*.yaml stack without adding that tenant/config. + +describe('Asgardeo login', () => { + it.skip('redirects to Asgardeo and completes login, establishing a session'); + it.skip('maps the org/role claims from the Asgardeo token correctly'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js new file mode 100644 index 0000000000..da608f4276 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js @@ -0,0 +1,26 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// UI happy-path for demo-mode login. The REST contract for this flow (does the +// session cookie work, is a bad password rejected) is covered in +// backend/auth/file-based-login.spec.js — this spec is for the actual form UX. + +describe('file-based login UI', () => { + it.skip('logs in through the local login form and lands on the portal home page'); + it.skip('shows a validation error for an incorrect password'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/thunderid-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/thunderid-login.cy.js new file mode 100644 index 0000000000..e4900d5a73 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/thunderid-login.cy.js @@ -0,0 +1,24 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Same caveat as asgardeo-login.cy.js: needs a live or mocked ThunderID tenant, +// configured via identityProvider.fidp['thunderid'] (src/config/configLoader.js). + +describe('ThunderID login', () => { + it.skip('redirects to ThunderID and completes login, establishing a session'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/design-mode/api-content-override.cy.js b/portals/developer-portal/it/ui/cypress/e2e/design-mode/api-content-override.cy.js new file mode 100644 index 0000000000..aaa35ddf0b --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/design-mode/api-content-override.cy.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('design mode: API content overriding', () => { + it.skip('serves sample APIs from designMode.apiSamplesPath instead of the database'); + it.skip('serves sample MCP servers from designMode.mcpSamplesPath'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/design-mode/org-theme-override.cy.js b/portals/developer-portal/it/ui/cypress/e2e/design-mode/org-theme-override.cy.js new file mode 100644 index 0000000000..e863503b5f --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/design-mode/org-theme-override.cy.js @@ -0,0 +1,27 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Design mode (config.designMode.enabled) is filesystem/config-driven, not a +// REST API — src/middlewares/registerPartials.js resolves designMode.pathToLayout +// before falling back to src/defaultContent/. Requires a devportal instance +// started with DESIGN_MODE env/config pointed at a custom layout directory. + +describe('design mode: org theme overriding', () => { + it.skip('renders custom partials/styles from the configured pathToLayout'); + it.skip('falls back to default content for files not present in the override directory'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/graphql-apis/tryout.cy.js b/portals/developer-portal/it/ui/cypress/e2e/graphql-apis/tryout.cy.js new file mode 100644 index 0000000000..dad9192e16 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/graphql-apis/tryout.cy.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('GraphQL API tryout', () => { + it.skip('runs schema introspection in the tryout console'); + it.skip('executes a query against the tryout console and shows the response'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/mcp-servers/tryout.cy.js b/portals/developer-portal/it/ui/cypress/e2e/mcp-servers/tryout.cy.js new file mode 100644 index 0000000000..d7c85b37f5 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/mcp-servers/tryout.cy.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('MCP server tryout', () => { + it.skip('lists available tools in the tryout console'); + it.skip('invokes a tool and shows the response'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/rest-apis/documents.cy.js b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/documents.cy.js new file mode 100644 index 0000000000..c7d466af4a --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/documents.cy.js @@ -0,0 +1,23 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('REST API documents', () => { + it.skip('uploads and lists downloadable documents for an API'); + it.skip('renders a document on the API detail page'); + it.skip('deletes a document'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/rest-apis/theming.cy.js b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/theming.cy.js new file mode 100644 index 0000000000..a9ce8e1d61 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/theming.cy.js @@ -0,0 +1,25 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Visual/rendering concern only — no dedicated REST endpoint for "API theming" +// (theming rides inside API/org content assets). Covered here, not in backend/. + +describe('REST API theming', () => { + it.skip('applies a custom theme uploaded for the API landing page'); + it.skip('falls back to the default theme when none is configured'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/rest-apis/tryout.cy.js b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/tryout.cy.js new file mode 100644 index 0000000000..f8c728a1df --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/tryout.cy.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('REST API tryout', () => { + it.skip('sends a tryout request with a valid API key and shows the response'); + it.skip('shows an auth error when the tryout request uses an invalid/revoked key'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/search/api-search.cy.js b/portals/developer-portal/it/ui/cypress/e2e/search/api-search.cy.js new file mode 100644 index 0000000000..fd5fdda827 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/search/api-search.cy.js @@ -0,0 +1,22 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('API search UI', () => { + it.skip('free-text search narrows the API listing'); + it.skip('shows a no-results state for an unmatched query'); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/websocket-apis/tryout.cy.js b/portals/developer-portal/it/ui/cypress/e2e/websocket-apis/tryout.cy.js new file mode 100644 index 0000000000..6d76d8acf2 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/websocket-apis/tryout.cy.js @@ -0,0 +1,21 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +describe('WebSocket API tryout', () => { + it.skip('connects and exchanges a message in the tryout console (isWebSocketTryout)'); +}); diff --git a/portals/developer-portal/it/cypress/fixtures/org.json b/portals/developer-portal/it/ui/cypress/fixtures/org.json similarity index 100% rename from portals/developer-portal/it/cypress/fixtures/org.json rename to portals/developer-portal/it/ui/cypress/fixtures/org.json diff --git a/portals/developer-portal/it/cypress/fixtures/users.json b/portals/developer-portal/it/ui/cypress/fixtures/users.json similarity index 100% rename from portals/developer-portal/it/cypress/fixtures/users.json rename to portals/developer-portal/it/ui/cypress/fixtures/users.json diff --git a/portals/developer-portal/it/cypress/support/commands.js b/portals/developer-portal/it/ui/cypress/support/commands.js similarity index 100% rename from portals/developer-portal/it/cypress/support/commands.js rename to portals/developer-portal/it/ui/cypress/support/commands.js diff --git a/portals/developer-portal/it/cypress/support/commands/applications.js b/portals/developer-portal/it/ui/cypress/support/commands/applications.js similarity index 100% rename from portals/developer-portal/it/cypress/support/commands/applications.js rename to portals/developer-portal/it/ui/cypress/support/commands/applications.js diff --git a/portals/developer-portal/it/cypress/support/commands/auth.js b/portals/developer-portal/it/ui/cypress/support/commands/auth.js similarity index 100% rename from portals/developer-portal/it/cypress/support/commands/auth.js rename to portals/developer-portal/it/ui/cypress/support/commands/auth.js diff --git a/portals/developer-portal/it/cypress/support/e2e.js b/portals/developer-portal/it/ui/cypress/support/e2e.js similarity index 100% rename from portals/developer-portal/it/cypress/support/e2e.js rename to portals/developer-portal/it/ui/cypress/support/e2e.js diff --git a/portals/developer-portal/it/package-lock.json b/portals/developer-portal/it/ui/package-lock.json similarity index 100% rename from portals/developer-portal/it/package-lock.json rename to portals/developer-portal/it/ui/package-lock.json diff --git a/portals/developer-portal/it/package.json b/portals/developer-portal/it/ui/package.json similarity index 100% rename from portals/developer-portal/it/package.json rename to portals/developer-portal/it/ui/package.json diff --git a/portals/developer-portal/scripts/drift_check.js b/portals/developer-portal/scripts/drift_check.js index 4236a80f56..9a92f0c8b5 100644 --- a/portals/developer-portal/scripts/drift_check.js +++ b/portals/developer-portal/scripts/drift_check.js @@ -108,47 +108,49 @@ function responseSchema(operationId, status) { // --------------------------------------------------------------------------- const SAMPLES = [ - // Organizations — adminService.createOrganization/updateOrganization and - // devportalService.getOrganizationDetails all emit {orgId, orgName, businessOwner, - // businessOwnerContact, businessOwnerEmail, orgHandle, idpRefId, - // cpRefId, orgConfiguration}. No IDP claim-mapping fields (roleClaimName etc.) — - // those were removed from the response shape long ago. + // Organizations — adminService.createOrganization/updateOrganization/ + // getAllOrganizations and devportalService.getOrganizationDetails all emit + // {id, displayName, businessOwner, businessOwnerContact, businessOwnerEmail, + // idpRefId, cpRefId, configuration, createdAt, updatedAt} (adminService.js + // ~194-204). The previous org-prefixed field names (orgId/orgName/orgHandle/ + // orgConfiguration) haven't matched the real response in a long time. ['createOrganization', 201, { - orgId: 'org-1', orgName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', - businessOwnerEmail: 'jane@acme.example', orgHandle: 'acme', - idpRefId: 'ACME', cpRefId: 'cp-ref-1', orgConfiguration: {}, + id: 'acme', displayName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', + businessOwnerEmail: 'jane@acme.example', + idpRefId: 'ACME', cpRefId: 'cp-ref-1', configuration: { devportalMode: 'DEFAULT' }, + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }], - // adminService.getAllOrganizations builds the same shape per item (orgId, not - // orgID); adminService.getOrganizations wraps it via util.toPaginatedList. ['getOrganizations', 200, { list: [{ - orgName: 'Acme', orgId: 'org-1', businessOwner: 'Jane', + id: 'acme', displayName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', businessOwnerEmail: 'jane@acme.example', - orgHandle: 'acme', idpRefId: 'ACME', cpRefId: 'cp-ref-1', - orgConfiguration: {}, + idpRefId: 'ACME', cpRefId: 'cp-ref-1', configuration: { devportalMode: 'DEFAULT' }, + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }], pagination: { total: 1, limit: 20, offset: 0 }, }], ['getOrganization', 200, { - orgId: 'org-1', orgName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', - businessOwnerEmail: 'jane@acme.example', orgHandle: 'acme', - idpRefId: 'ACME', cpRefId: 'cp-ref-1', orgConfiguration: {}, + id: 'acme', displayName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', + businessOwnerEmail: 'jane@acme.example', + idpRefId: 'ACME', cpRefId: 'cp-ref-1', configuration: { devportalMode: 'DEFAULT' }, + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }], ['updateOrganization', 200, { - orgId: 'org-1', orgName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', - businessOwnerEmail: 'jane@acme.example', orgHandle: 'acme', - idpRefId: 'ACME', cpRefId: 'cp-ref-1', orgConfiguration: {}, + id: 'acme', displayName: 'Acme', businessOwner: 'Jane', businessOwnerContact: '+1', + businessOwnerEmail: 'jane@acme.example', + idpRefId: 'ACME', cpRefId: 'cp-ref-1', configuration: { devportalMode: 'DEFAULT' }, + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }], - // Org content — adminService.createOrgContent/updateOrgContent both - // res.status(201).send({ orgId, fileName }). - ['createOrgContent', 201, { orgId: 'org-1', fileName: 'theme.zip' }], - ['updateOrgContent', 201, { orgId: 'org-1', fileName: 'theme.zip' }], - // devportalService.getOrgContent (fileType-only branch) res.status(200).send(results) — - // a bare array, not paginated. - ['getOrgLayoutContentByFileType', 200, [ - { orgId: 'org-1', fileName: 'main.css', fileContent: 'body{}' }, - ]], + // Org content — createOrgContent/updateOrgContent/getOrgLayoutContentByFileType + // never existed as operationIds; that whole naming scheme was stale. The real, + // documented operation is `applyTheme` (POST /views/{viewId}/apply-theme), + // adminService.js:440-471 — a single upsert (atomic delete+recreate) covering + // what the old create/update split assumed were separate operations: + // `res.status(200).json({ id: organization.handle, fileName: zipFile.originalname })`. + // `resetTheme` (204, no body) and `getOrgAsset` (200, raw file content — not JSON) + // have nothing here to validate via AJV, so they're not worth a sample. + ['applyTheme', 200, { id: 'acme', fileName: 'theme.zip' }], // Subscriptions — subscriptionService.formatSubscriptionResponse shape ['createSubscription', 201, { @@ -166,73 +168,195 @@ const SAMPLES = [ createdAt: '2026-05-07T08:30:00.000Z', }], - // Labels — apiMetadataService.retrieveLabels wraps via util.toPaginatedList; - // createLabels/updateLabel echo req.body verbatim (clients send LabelRequest[]). - ['retrieveLabels', 200, { + // Labels — apiMetadataService.js. The real operationIds are `listLabels` + // (singular create is `createLabel`, not the invented plural "createLabels") + // and `updateLabel` responds 200, not 201. Every one returns `new LabelDTO(...)` + // (labelDto.js: {id, displayName} — not {name, displayName}), a single object + // for create/get/update, and listLabels wraps via util.toPaginatedList. + ['listLabels', 200, { list: [ - { name: 'premium', displayName: 'Premium APIs' }, - { name: 'internal', displayName: 'Internal APIs' }, + { id: 'premium', displayName: 'Premium APIs' }, + { id: 'internal', displayName: 'Internal APIs' }, ], pagination: { total: 2, limit: 20, offset: 0 }, }], - ['createLabels', 201, [{ name: 'premium', displayName: 'Premium APIs' }]], - ['updateLabel', 201, [{ name: 'premium', displayName: 'Premium APIs' }]], + ['createLabel', 201, { id: 'premium', displayName: 'Premium APIs' }], + ['updateLabel', 200, { id: 'premium', displayName: 'Premium APIs' }], - // Subscription Plans — subscriptionPlanDto.js shape: {planId, planName, displayName, - // description, requestCount, refId, orgId}. requestCount is always a string or null - // (computed in subscriptionPlanDao.js), never a raw number. Single-create - // (createSubscriptionPlan) returns one object; bulk-create (createSubscriptionPlans) - // returns an array, or a {message} when generateDefaultSubPlans disables bulk create. + // Subscription Plans — subscriptionPlanDto.js's SubscriptionPlan class shape: + // {id, displayName, description, refId, orgId, limits[]}, where each limit is + // {limitType, timeUnit, timeAmount, limitCount} (limitCount is a number unless + // it overflows a safe integer, in which case subscriptionPlanDao.js stringifies + // it). There is no planId/planName/requestCount field — that shape predates the + // current DTO. Single-create (createSubscriptionPlan) returns one object; + // bulk-create (createSubscriptionPlans) returns an array, or a {message} when + // generateDefaultSubPlans disables bulk create. ['addSubscriptionPlans', 201, { - planId: 'p1', planName: 'bronze', displayName: 'Bronze', - description: 'desc', requestCount: '1000', refId: null, orgId: 'org-1', + id: 'bronze', displayName: 'Bronze', description: 'desc', + limits: [{ limitType: 'REQUEST_COUNT', timeUnit: 'MONTH', timeAmount: 1, limitCount: 1000 }], + refId: null, orgId: 'org-1', + createdBy: 'alice@example.com', updatedBy: 'alice@example.com', + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }], ['addSubscriptionPlans', 201, [{ - planId: 'p1', planName: 'bronze', displayName: 'Bronze', - description: 'desc', requestCount: '1000', refId: null, orgId: 'org-1', + id: 'bronze', displayName: 'Bronze', description: 'desc', + limits: [{ limitType: 'REQUEST_COUNT', timeUnit: 'MONTH', timeAmount: 1, limitCount: 1000 }], + refId: null, orgId: 'org-1', + createdBy: 'alice@example.com', updatedBy: 'alice@example.com', + createdAt: '2026-05-07T08:30:00.000Z', updatedAt: '2026-05-07T08:30:00.000Z', }]], ['addSubscriptionPlans', 200, { message: "Bulk creation of subscription plans is not allowed because 'generateDefaultSubPlans' is enabled in the Developer Portal.", }], - // API Workflows — apiWorkflowService.createAPIWorkflow res.status(201).json({apiWorkflowId, name, status}); - // getAllAPIWorkflows wraps toAPIWorkflowDTO(...) items via util.toPaginatedList. - ['createApiWorkflow', 201, { apiWorkflowId: 'w1', name: 'workflow1', status: 'PUBLISHED' }], + // API Workflows — apiWorkflowService.js. createAPIWorkflow: + // `res.status(201).json({ id: apiWorkflow.handle, displayName: apiWorkflow.display_name, + // status })` (~line 230) — the handle is returned under `id`, not a separate + // apiWorkflowId/name pair (those never existed in this response). getAllAPIWorkflows + // wraps toAPIWorkflowDTO(...) items (~line 403) via util.toPaginatedList; each item + // also carries createdBy/updatedBy appended from audit data, on top of the DTO's own + // fields (id, displayName, description, agentPrompt, status, agentVisibility, + // contentType, apiWorkflowDefinition, markdownContent, createdAt, updatedAt). + ['createApiWorkflow', 201, { id: 'workflow-1', displayName: 'Workflow 1', status: 'PUBLISHED' }], ['getAllApiWorkflows', 200, { list: [{ - apiWorkflowId: 'w1', name: 'workflow1', handle: 'workflow-1', description: 'desc', + id: 'workflow-1', displayName: 'Workflow 1', description: 'desc', agentPrompt: 'prompt', status: 'PUBLISHED', agentVisibility: 'VISIBLE', contentType: 'ARAZZO', apiWorkflowDefinition: '{}', markdownContent: null, - createdAt: 'May 7, 2026', updatedAt: '2026-05-07T08:30:00Z', + createdAt: 'May 7, 2026', updatedAt: 'May 7, 2026', + createdBy: 'alice@example.com', updatedBy: 'alice@example.com', }], pagination: { total: 1, limit: 20, offset: 0 }, }], - // API Keys — apiKeyController (devportal source of truth, no CP lookup) - // generateApiKey res.status(201).json({ keyId, name, key, expiresAt, status }) + // API Keys — apiKeyController.js (devportal source of truth, no CP lookup). + // apiKeyService.generate/regenerate return { keyId, id, displayName, key, expiresAt, + // status, ...audit } (apiKeyService.js:197,248) — the key's handle is `id`, its + // display name is `displayName`; there is no `name` field. ['generateApiKey', 201, { - keyId: 'key-12345', name: 'weather_prod_key', + keyId: 'key-12345', id: 'weather_prod_key', displayName: 'Weather Prod Key', key: 'ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ', expiresAt: '2026-12-31T23:59:59.000Z', status: 'ACTIVE', }], // listApiKeys res.status(200).json(util.toPaginatedList(keys.map(mapKey), req)) — - // mapKey emits { keyId, name, status, expiresAt, createdAt, revokedAt?, apiId, appId, appName }. + // mapKey (apiKeyController.js:85-99) emits { keyId, id, displayName, status, + // expiresAt, createdAt, revokedAt?, apiId, appId, appDisplayName }. ['listApiKeys', 200, { list: [{ - keyId: 'key-12345', name: 'weather_prod_key', status: 'ACTIVE', + keyId: 'key-12345', id: 'weather_prod_key', displayName: 'Weather Prod Key', status: 'ACTIVE', expiresAt: null, createdAt: '2026-05-07T08:30:00.000Z', apiId: 'api-7f4c2a6b', - appId: null, appName: null, + appId: null, appDisplayName: null, }], pagination: { total: 1, limit: 20, offset: 0 }, }], - // regenerateApiKey res.status(200).json({ keyId, name, key, expiresAt, status }) + // regenerateApiKey — same shape as generateApiKey, minus displayName being + // re-derived (existing.display_name is carried through unchanged). ['regenerateApiKey', 200, { - keyId: 'key-12345', name: 'weather_prod_key', + keyId: 'key-12345', id: 'weather_prod_key', displayName: 'Weather Prod Key', key: 'ak_bmV3a2V5Zm9yZGVtb25zdHJhdGlvbg', expiresAt: null, status: 'ACTIVE', }], + // APIs — apiMetadataService.js. Verified against a live server, not just read + // from source: create/update/get shapes below are actual captured response + // bodies from IT probes run this session, not hand-derived. + // + // createAPIMetadata: `res.status(201).send({ ...apiMetadata, ...audit })` (line + // ~247) — spreads the parsed request metadata (name, version, type, status, + // agentVisibility, tags, labels, owners, endPoints, subscriptionPlans, the + // resolved `id`) plus audit fields. NOT an APIDTO — no `refId`/`apiTitle`/ + // `remotes`/`apiImageMetadata`, unlike get/update below. + ['createApiMetadata', 201, { + name: 'Zip API', version: 'v1.0', description: 'first', type: 'RestApi', + status: 'PUBLISHED', agentVisibility: 'VISIBLE', tags: [], labels: [], owners: {}, + endPoints: { sandboxURL: 'https://sandbox.example.invalid/zip-api-1', productionURL: 'https://backend.example.invalid/zip-api-1' }, + subscriptionPlans: [], id: 'zip-api-1', + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + // getApiMetadata: getMetadataFromDB wraps the DAO row in `new APIDTO(...)` + // (apiDto.js) — a different, larger shape than create's. + ['getApiMetadata', 200, { + id: 'zip-api-1', refId: null, name: 'Zip API', apiTitle: null, remotes: [], + version: 'v1.0', description: 'first', type: 'RestApi', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', apiImageMetadata: {}, tags: [], labels: [], + endPoints: { sandboxURL: 'https://sandbox.example.invalid/zip-api-1', productionURL: 'https://backend.example.invalid/zip-api-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + // updateAPIMetadata: `res.status(200).send(new APIDTO(updatedAPI[0].dataValues, + // audit))` (line ~698) — an APIDTO like get, but observed without `apiImageMetadata`/ + // `tags`/`labels` present when the update request didn't touch those fields + // (APIDTO only sets what its input row/audit actually carried at that point). + ['updateApiMetadata', 200, { + id: 'zip-api-1', refId: null, name: 'Zip API', apiTitle: null, remotes: [], + version: 'v1.0', description: 'second (changed)', type: 'RestApi', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', + endPoints: { sandboxURL: 'https://sandbox.example.invalid/zip-api-1', productionURL: 'https://backend.example.invalid/zip-api-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:52:41.706Z', + }], + // getAllApiMetadataForOrganization: util.toPaginatedList(retrievedAPIs.map(APIDTO)) — + // same per-item shape as getApiMetadata. + ['getAllApiMetadataForOrganization', 200, { + list: [{ + id: 'zip-api-1', refId: null, name: 'Zip API', apiTitle: null, remotes: [], + version: 'v1.0', description: 'first', type: 'RestApi', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', apiImageMetadata: {}, tags: [], labels: ['default'], + endPoints: { sandboxURL: 'https://sandbox.example.invalid/zip-api-1', productionURL: 'https://backend.example.invalid/zip-api-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + pagination: { total: 1, limit: 20, offset: 0 }, + }], + + // MCP Servers — mcpServerService.js delegates every one of these straight into + // the same apiMetadataService handlers above via asMcpRequest() (req.__forceApiType + // = 'MCP'), so the response shape is identical apart from `type` — not a separate + // implementation to independently verify. + ['createMcpServer', 201, { + name: 'Booking MCP', version: 'v1.0', description: 'first', type: 'Mcp', + status: 'PUBLISHED', agentVisibility: 'VISIBLE', tags: [], labels: [], owners: {}, + endPoints: { sandboxURL: 'https://sandbox.example.invalid/mcp-1', productionURL: 'https://backend.example.invalid/mcp-1' }, + subscriptionPlans: [], id: 'mcp-server-1', + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + ['getMcpServer', 200, { + id: 'mcp-server-1', refId: null, name: 'Booking MCP', apiTitle: null, remotes: [], + version: 'v1.0', description: 'first', type: 'Mcp', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', apiImageMetadata: {}, tags: [], labels: [], + endPoints: { sandboxURL: 'https://sandbox.example.invalid/mcp-1', productionURL: 'https://backend.example.invalid/mcp-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + ['updateMcpServer', 200, { + id: 'mcp-server-1', refId: null, name: 'Booking MCP', apiTitle: null, remotes: [], + version: 'v1.0', description: 'second (changed)', type: 'Mcp', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', + endPoints: { sandboxURL: 'https://sandbox.example.invalid/mcp-1', productionURL: 'https://backend.example.invalid/mcp-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:52:41.706Z', + }], + ['getAllMcpServersForOrganization', 200, { + list: [{ + id: 'mcp-server-1', refId: null, name: 'Booking MCP', apiTitle: null, remotes: [], + version: 'v1.0', description: 'first', type: 'Mcp', status: 'PUBLISHED', + agentVisibility: 'VISIBLE', apiImageMetadata: {}, tags: [], labels: ['default'], + endPoints: { sandboxURL: 'https://sandbox.example.invalid/mcp-1', productionURL: 'https://backend.example.invalid/mcp-1' }, + subscriptionPlans: [], + createdBy: 'publisher', updatedBy: 'publisher', + createdAt: '2026-07-07T12:48:30.875Z', updatedAt: '2026-07-07T12:48:30.875Z', + }], + pagination: { total: 1, limit: 20, offset: 0 }, + }], + // Webhook Events — webhookAdminController.formatEvent() shape. // listEvents res.json({ list: rows.map(formatEvent), pagination }) — NOT // { total, events } as a previous version of this comment claimed (that stale diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js index 6ee8bb82f9..2d01585570 100644 --- a/portals/developer-portal/src/controllers/apiContentController.js +++ b/portals/developer-portal/src/controllers/apiContentController.js @@ -1174,9 +1174,15 @@ async function buildLlmsTxtTemplateContent(req, orgId, orgName, viewName, config const agentVisibleAPIs = metaDataList.filter(api => api.agentVisibility !== 'HIDDEN'); const hiddenAPICount = metaDataList.length - agentVisibleAPIs.length; + // api.type holds the stored constant (e.g. "RestApi", "Mcp", "WebSubApi" — see + // constants.API_TYPE), not the enum key used below — map it back or every + // REST/MCP/WebSub API silently drops out of the discovery index. + const typeConstantToEnum = Object.fromEntries( + Object.entries(constants.API_TYPE).map(([enumKey, storedValue]) => [storedValue, enumKey]) + ); const byType = { REST: [], MCP: [], GRAPHQL: [], WS: [], WEBSUB: [] }; for (const api of agentVisibleAPIs) { - const type = api.type; + const type = typeConstantToEnum[api.type]; if (byType[type]) byType[type].push(api); } diff --git a/portals/developer-portal/src/controllers/devportalController.js b/portals/developer-portal/src/controllers/devportalController.js index c2bc960c9b..2d8f1d77ad 100644 --- a/portals/developer-portal/src/controllers/devportalController.js +++ b/portals/developer-portal/src/controllers/devportalController.js @@ -68,7 +68,7 @@ function parseApplicationDataFromRequest(req) { const listApplications = async (req, res) => { const orgId = req.orgId || ''; - const userId = req.auth?.userId || req.user?.sub; + const userId = util.resolveActor(req); try { const applications = await appDao.list(orgId, userId); const auditList = await userIdpReferenceDao.buildListAuditFields(applications.map(a => a.dataValues)); @@ -82,7 +82,7 @@ const listApplications = async (req, res) => { const saveApplication = async (req, res) => { const orgId = req.orgId || ''; - const userId = req.auth?.userId || req.user?.sub; + const userId = util.resolveActor(req); try { const applicationData = parseApplicationDataFromRequest(req); const application = await appDao.create(orgId, userId, applicationData); @@ -108,7 +108,7 @@ const saveApplication = async (req, res) => { const updateApplication = async (req, res) => { const orgId = req.orgId || ''; - const userId = req.auth?.userId || req.user?.sub; + const userId = util.resolveActor(req); try { const appHandle = req.params.applicationId; const appRecord = await appDao.getId(orgId, userId, appHandle); @@ -208,7 +208,7 @@ const finalizeApplicationDeletion = async (orgId, applicationId, userId, req) => const getApplication = async (req, res) => { const orgId = req.orgId || ''; - const userId = req.auth?.userId || req.user?.sub; + const userId = util.resolveActor(req); const applicationHandle = req.params.applicationId; try { const appRecord = await appDao.getId(orgId, userId, applicationHandle); @@ -228,7 +228,7 @@ const getApplication = async (req, res) => { }; const deleteApplication = async (req, res) => { - const userId = req.auth?.userId || req.user?.sub; + const userId = util.resolveActor(req); const applicationHandle = req.params.applicationId; const orgId = req.orgId || ''; let applicationId; @@ -263,7 +263,7 @@ const generateKeys = async (req, res) => { let orgId, appId, userId; try { orgId = req.orgId; - userId = req.auth?.userId || req[constants.USER_ID] || req.user?.sub; + userId = util.resolveActor(req); const appHandle = req.params.applicationId; const appRecord = await appDao.getId(orgId, userId, appHandle); if (!appRecord) { @@ -324,7 +324,7 @@ const generateKeys = async (req, res) => { const generateOAuthKeys = async (req, res) => { try { const orgId = req.orgId; - const userId = req.auth?.userId || req[constants.USER_ID] || req.user?.sub; + const userId = util.resolveActor(req); const applicationHandle = req.params.applicationId; const appRecord = await appDao.getId(orgId, userId, applicationHandle); if (!appRecord) { @@ -372,7 +372,7 @@ const generateOAuthKeys = async (req, res) => { const revokeOAuthKeys = async (req, res) => { try { const orgId = req.orgId; - const userId = req.auth?.userId || req[constants.USER_ID] || req.user?.sub; + const userId = util.resolveActor(req); const applicationHandle = req.params.applicationId; const appRecord = await appDao.getId(orgId, userId, applicationHandle); if (!appRecord) { diff --git a/portals/developer-portal/src/controllers/viewConfigureController.js b/portals/developer-portal/src/controllers/viewConfigureController.js index 3a4279915d..04d598aed5 100644 --- a/portals/developer-portal/src/controllers/viewConfigureController.js +++ b/portals/developer-portal/src/controllers/viewConfigureController.js @@ -26,8 +26,11 @@ const labelDao = require('../dao/labelDao'); const subscriptionPlanDao = require('../dao/subscriptionPlanDao'); const whDao = require('../dao/webhookSubscriberDao'); const { WebhookSubscriberDTO } = require('../dto/webhookSubscriberDto'); +const kmDao = require('../dao/keyManagerDao'); +const { KeyManagerDTO } = require('../dto/keyManagerDto'); const { VALID_EVENT_TYPES } = require('../services/webhooks/eventPublisher'); const apiWorkflowService = require('../services/apiWorkflowService'); +const util = require('../utils/util'); const { renderGivenTemplate, loadLayoutFromAPI } = require('../utils/util'); const { getSessionCsrfToken } = require('../middlewares/csrfProtection'); const { config } = require('../config/configLoader'); @@ -119,6 +122,16 @@ const loadViewSettingsPage = async (req, res) => { templateContent.webhookSubscribers = webhookSubscribers; templateContent.webhookEventTypes = [...VALID_EVENT_TYPES]; + let keyManagers = []; + try { + const keyManagerRecords = await kmDao.list(orgId); + keyManagers = keyManagerRecords.map(r => new KeyManagerDTO(r)); + } catch (err) { + logger.warn('Failed to load key managers for settings page', { error: err.message }); + } + templateContent.keyManagers = keyManagers; + templateContent.keyManagerTypes = ['ASGARDEO', 'WSO2IS', 'KEYCLOAK', 'GENERIC_OIDC']; + const configAsset = await orgDao.getContent({ orgId: orgId, fileType: constants.FILE_TYPE.LLMS_CONFIG, viewName, fileName: constants.FILE_NAME.LLMS_CONFIG }); diff --git a/portals/developer-portal/src/dao/apiDao.js b/portals/developer-portal/src/dao/apiDao.js index becbb69aee..426acde80a 100644 --- a/portals/developer-portal/src/dao/apiDao.js +++ b/portals/developer-portal/src/dao/apiDao.js @@ -209,6 +209,11 @@ const getByCondition = async (condition, t, tags) => { through: { attributes: [] }, required: false, include: [{ model: SubscriptionPlanLimit, as: 'limits' }] + }, { + model: Labels, + attributes: ["handle"], + through: { attributes: [] }, + required: false }, tagsInclude ], @@ -224,7 +229,7 @@ const getByCondition = async (condition, t, tags) => { } } -const list = async (orgId, viewName, t) => { +const list = async (orgId, viewName, t, typeFilter) => { const viewDao = require('./viewDao'); const viewId = await viewDao.getId(orgId, viewName, t); @@ -233,7 +238,9 @@ const list = async (orgId, viewName, t) => { const apiMetadataResponse = await APIMetadata.findAll({ where: { org_uuid: orgId, - status: { [Op.in]: [constants.API_STATUS.PUBLISHED, constants.API_STATUS.DEPRECATED] } + status: { [Op.in]: [constants.API_STATUS.PUBLISHED, constants.API_STATUS.DEPRECATED] }, + ...(typeFilter?.include && { type: typeFilter.include }), + ...(typeFilter?.exclude && { type: { [Op.ne]: typeFilter.exclude } }) }, include: [{ model: APIContent, @@ -319,10 +326,26 @@ const listFromAllViews = async (orgId, t) => { return apiList; }; -const searchFallback = async (orgId, searchTerm, viewName, t) => { +const searchFallback = async (orgId, searchTerm, viewName, t, typeFilter) => { const viewDao = require('./viewDao'); const pattern = `%${searchTerm}%`; + // `view` is optional (apiViewQuery in the OpenAPI spec) — search unscoped by view + // when it's omitted, rather than forcing a view-label join that would otherwise + // either crash (viewId undefined) or silently match nothing (a literal 'undefined'). const viewId = await viewDao.getId(orgId, viewName, t); + const labelsInclude = viewId + ? { + model: Labels, + attributes: ['handle'], + required: true, + through: { attributes: [] }, + where: { + uuid: { + [Op.in]: Sequelize.literal(`(SELECT "label_uuid" FROM "dp_view_label_mappings" WHERE "view_uuid" = '${viewId}')`) + } + } + } + : { model: Labels, attributes: ['handle'], required: false, through: { attributes: [] } }; const matchingTags = await Tags.findAll({ attributes: ['uuid'], @@ -343,6 +366,8 @@ const searchFallback = async (orgId, searchTerm, viewName, t) => { where: { org_uuid: orgId, status: { [Op.in]: [constants.API_STATUS.PUBLISHED, constants.API_STATUS.DEPRECATED] }, + ...(typeFilter?.include && { type: typeFilter.include }), + ...(typeFilter?.exclude && { type: { [Op.ne]: typeFilter.exclude } }), [Op.or]: [ Sequelize.where( Sequelize.cast(Sequelize.col('dp_api_metadata.metadata_search'), 'TEXT'), @@ -354,17 +379,7 @@ const searchFallback = async (orgId, searchTerm, viewName, t) => { include: [ { model: APIContent, where: { type: constants.DOC_TYPES.IMAGES }, required: false }, { model: SubscriptionPlan, through: { attributes: [] }, required: false, include: [{ model: SubscriptionPlanLimit, as: 'limits' }] }, - { - model: Labels, - attributes: ['handle'], - required: true, - through: { attributes: [] }, - where: { - uuid: { - [Op.in]: Sequelize.literal(`(SELECT "label_uuid" FROM "dp_view_label_mappings" WHERE "view_uuid" = '${viewId}')`) - } - } - }, + labelsInclude, { model: Tags, attributes: ['name'], @@ -376,15 +391,20 @@ const searchFallback = async (orgId, searchTerm, viewName, t) => { }); }; -const search = async (orgId, searchTerm, viewName, t) => { +const search = async (orgId, searchTerm, viewName, t, typeFilter) => { if (APIMetadata.sequelize.getDialect() !== 'postgres') { - return searchFallback(orgId, searchTerm, viewName, t); + return searchFallback(orgId, searchTerm, viewName, t, typeFilter); } try { const viewDao = require('./viewDao'); const viewId = await viewDao.getId(orgId, viewName, t); const results = await APIMetadata.sequelize.query(SEARCH_APIS_POSTGRES_SQL, { - replacements: { searchTerm, orgId, viewId }, + replacements: { + searchTerm, orgId, + viewId: viewId || null, + includeType: typeFilter?.include || null, + excludeType: typeFilter?.exclude || null, + }, type: Sequelize.QueryTypes.SELECT, }); return results; diff --git a/portals/developer-portal/src/dao/auditDao.js b/portals/developer-portal/src/dao/auditDao.js index bb170df11d..8b2adf212f 100644 --- a/portals/developer-portal/src/dao/auditDao.js +++ b/portals/developer-portal/src/dao/auditDao.js @@ -20,6 +20,11 @@ const Audit = require('../models/audit'); /** * Records one audit trail entry. Write-only — no list/query function, mirroring * platform-api's audit table, which is inspected directly in the database. + * + * Called fire-and-forget (see auditLogger.js), so a failure here is caught and + * logged by the caller and never blocks the request. For SQLite the shared + * Sequelize pool is capped at a single connection, so this autocommit write is + * serialized behind any open transaction rather than contending with it. */ const record = async (action, resourceUuid, resourceType, orgUuid, performedBy) => { return Audit.create({ diff --git a/portals/developer-portal/src/dao/organizationDao.js b/portals/developer-portal/src/dao/organizationDao.js index 306757d538..4dc428f702 100644 --- a/portals/developer-portal/src/dao/organizationDao.js +++ b/portals/developer-portal/src/dao/organizationDao.js @@ -16,8 +16,18 @@ * under the License. */ const { Organization, OrgContent } = require('../models/organization'); -const { Sequelize } = require('sequelize'); +const { Sequelize, Op } = require('sequelize'); const viewDao = require('./viewDao'); +const { Application, ApplicationKeyMapping, SubscriptionMapping } = require('../models/application'); +const { KeyManager } = require('../models/keyManager'); +const APIKey = require('../models/apiKey'); +const DPEvent = require('../models/event'); +const DPEventDelivery = require('../models/eventDelivery'); +const { APIWorkflow } = require('../models/apiWorkflow'); +const { WebhookSubscriber } = require('../models/webhookSubscriber'); +const View = require('../models/view'); +const Labels = require('../models/label'); +const Tags = require('../models/tag'); const create = async (orgData, t) => { let devPortalId = ""; @@ -127,7 +137,7 @@ const update = async (orgData, t) => { try { const existing = await get(orgData.orgId, t); const devPortalId = orgData.handle ? orgData.handle.toLowerCase() : existing.handle; - const [updatedRowsCount, updatedOrg] = await Organization.update( + const [updatedRowsCount] = await Organization.update( { display_name: orgData.displayName, business_owner: orgData.businessOwner, @@ -136,20 +146,23 @@ const update = async (orgData, t) => { handle: devPortalId, idp_ref_id: orgData.idpRefId, ...(orgData.cpRefId !== undefined && { cp_ref_id: orgData.cpRefId }), - configuration: orgData.configuration, + ...(orgData.configuration !== undefined && { configuration: orgData.configuration }), updated_by: orgData.updatedBy, updated_at: new Date() }, { where: { uuid: existing.uuid }, - returning: true, transaction: t, } ); if (updatedRowsCount < 1) { throw new Sequelize.EmptyResultError('Organization not found'); } - return [updatedRowsCount, updatedOrg]; + // `returning: true` only works on Postgres/MSSQL — Sequelize's sqlite + // dialect doesn't support RETURNING on UPDATE, so re-fetch explicitly + // instead (same pattern as applicationDao.update). + const updatedOrg = await Organization.findOne({ where: { uuid: existing.uuid }, transaction: t }); + return [updatedRowsCount, [updatedOrg]]; } catch (error) { if (error instanceof Sequelize.EmptyResultError) { throw error; @@ -158,9 +171,55 @@ const update = async (orgData, t) => { } }; +// Tables whose org_uuid FK is ON DELETE NO ACTION (database/schema.postgres.sql) +// block Organization.destroy() unless their rows are removed first. Tables with +// ON DELETE CASCADE/SET NULL (dp_api_metadata, dp_subscription_plans, dp_audit, +// dp_user_organization_mappings, and the *_mappings join tables) are left to the +// database to handle and aren't touched here. +const deleteOrgDependents = async (orgUuid, t) => { + const opts = { transaction: t }; + + const events = await DPEvent.findAll({ attributes: ['uuid'], where: { org_uuid: orgUuid }, ...opts }); + if (events.length) { + await DPEventDelivery.destroy({ where: { event_uuid: { [Op.in]: events.map(e => e.uuid) } }, ...opts }); + } + await DPEvent.destroy({ where: { org_uuid: orgUuid }, ...opts }); + + await APIKey.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await SubscriptionMapping.destroy({ where: { org_uuid: orgUuid }, ...opts }); + + const [apps, keyManagers] = await Promise.all([ + Application.findAll({ attributes: ['uuid'], where: { org_uuid: orgUuid }, ...opts }), + KeyManager.findAll({ attributes: ['uuid'], where: { org_uuid: orgUuid }, ...opts }), + ]); + if (apps.length || keyManagers.length) { + await ApplicationKeyMapping.destroy({ + where: { + [Op.or]: [ + ...(apps.length ? [{ app_uuid: { [Op.in]: apps.map(a => a.uuid) } }] : []), + ...(keyManagers.length ? [{ km_uuid: { [Op.in]: keyManagers.map(k => k.uuid) } }] : []), + ], + }, + ...opts, + }); + } + await Application.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await KeyManager.destroy({ where: { org_uuid: orgUuid }, ...opts }); + + await APIWorkflow.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await OrgContent.destroy({ where: { org_uuid: orgUuid }, ...opts }); + // dp_view_label_mappings/dp_api_label_mappings cascade automatically from + // dp_views/dp_labels ON DELETE CASCADE. + await View.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await Labels.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await Tags.destroy({ where: { org_uuid: orgUuid }, ...opts }); + await WebhookSubscriber.destroy({ where: { org_uuid: orgUuid }, ...opts }); +}; + const deleteOrg = async (orgId, t) => { try { const existing = await get(orgId, t); + await deleteOrgDependents(existing.uuid, t); const deletedRowsCount = await Organization.destroy({ where: { uuid: existing.uuid }, ...(t && { transaction: t }), diff --git a/portals/developer-portal/src/dao/viewDao.js b/portals/developer-portal/src/dao/viewDao.js index 1e8ef4fe43..11706e9a3f 100644 --- a/portals/developer-portal/src/dao/viewDao.js +++ b/portals/developer-portal/src/dao/viewDao.js @@ -118,6 +118,11 @@ const get = async (orgId, handle) => { } const getId = async (orgId, viewName, t) => { + // `view` is an optional query param on /apis and /mcp-servers (apiViewQuery in the + // OpenAPI spec) — a bare handle/display_name lookup with `undefined` throws at the + // Sequelize layer ("WHERE parameter has invalid undefined value") rather than the + // 404 below, so short-circuit before ever building that query. + if (!viewName) return undefined; try { let viewResponse = await View.findOne({ diff --git a/portals/developer-portal/src/pages/settings/page.hbs b/portals/developer-portal/src/pages/settings/page.hbs index 6e96993279..9da8c0108a 100644 --- a/portals/developer-portal/src/pages/settings/page.hbs +++ b/portals/developer-portal/src/pages/settings/page.hbs @@ -20,6 +20,7 @@ +
@@ -54,6 +55,9 @@ Webhooks + + Key Managers +
@@ -544,6 +548,73 @@ + +
+
+
+

Key Managers

+

Register the OAuth2 key managers applications can obtain tokens from. OAuth applications are created directly in the key manager itself — the portal only proxies token requests to it.

+
+ +
+ +
+ + + + + + + + + + + + {{#keyManagers}} + + + + + + + + {{/keyManagers}} + {{#unless keyManagers.length}} + + {{/unless}} + +
Display nameTypeToken endpointStatus
+ + {{type}} + {{tokenEndpoint}} + + {{#if enabled}} + + Enabled + + {{else}} + + Disabled + + {{/if}} + +
+ + +
+
No key managers yet. Add one to get started.
+
+
+ @@ -627,6 +698,21 @@ + + + + + +
@@ -808,7 +940,7 @@ + + + diff --git a/portals/developer-portal/src/services/apiMetadataService.js b/portals/developer-portal/src/services/apiMetadataService.js index 422fb82aea..4739edde07 100644 --- a/portals/developer-portal/src/services/apiMetadataService.js +++ b/portals/developer-portal/src/services/apiMetadataService.js @@ -84,7 +84,9 @@ const createAPIMetadata = async (req, res) => { } } else { apiMetadata = JSON.parse(req.body.apiMetadata); - apiMetadata.type = util.resolveApiType(apiMetadata.type); + // Type is resolved centrally below, using this raw (possibly omitted) value — + // don't resolve here, or an omitted type on a /mcp-servers request can no + // longer be told apart from an explicit REST. if (apiMetadata.id) { apiMetadata.handle = apiMetadata.id; } @@ -96,13 +98,7 @@ const createAPIMetadata = async (req, res) => { } } - if (req.__forceApiType) { - apiMetadata.type = req.__forceApiType; - } else if (apiMetadata.type === constants.API_TYPE.MCP) { - throw new Sequelize.ValidationError( - "MCP servers must be created via /api/v0.9/mcp-servers" - ); - } + apiMetadata.type = resolveTypeOrReject(apiMetadata.type, req.__forceApiType); // Validate input const hasGraphQLSchema = apiMetadata.type === constants.API_TYPE.GRAPHQL && @@ -316,6 +312,41 @@ async function resolveScopedApiId(req, orgId, apiHandle) { : apiDao.getIdExcludingType(orgId, apiHandle, constants.API_TYPE.MCP); } +/** + * Resolves a caller-supplied `type` against the resource family the request came in on, + * requiring an explicit match rather than silently defaulting or coercing it — symmetric + * with resolveScopedApiId's read-side scoping. `forceType` is `req.__forceApiType`: + * - /apis (forceType undefined): `type` is required; an explicit MCP type is rejected + * (MCP records must go through /mcp-servers). + * - /mcp-servers (forceType = 'MCP'): `type` is required and must resolve to MCP; any + * other type is rejected (non-MCP records must go through /apis). + * + * Applies to the JSON `apiMetadata` field path only. YAML/ZIP uploads resolve `type` + * via mapDevportalYamlToApiMetadata before this ever runs, defaulting a missing one to + * REST — real usage always states `type: MCP` explicitly there (see the api.yaml files + * under samples/mcps), so this doesn't affect any current caller. + */ +function resolveTypeOrReject(rawType, forceType) { + if (!rawType) { + throw new Sequelize.ValidationError("Missing required field 'type'"); + } + const resolved = util.resolveApiType(rawType); + if (forceType) { + if (resolved !== forceType) { + throw new Sequelize.ValidationError( + `Only type '${forceType}' is allowed via this endpoint — use /api/v0.9/apis for other API types` + ); + } + return forceType; + } + if (resolved === constants.API_TYPE.MCP) { + throw new Sequelize.ValidationError( + "MCP servers must be created via /api/v0.9/mcp-servers" + ); + } + return resolved; +} + const getAPIMetadata = async (req, res) => { const orgId = req.orgId; @@ -363,9 +394,8 @@ const getAllAPIMetadata = async (req, res) => { const apiVersion = req.query.version; const tags = req.query.tags; const view = req.query.view; - const retrievedAPIs = await getMetadataListFromDB(orgId, searchTerm, tags, apiName, apiVersion, view); - const nonMcpAPIs = retrievedAPIs.filter((api) => api.type !== constants.API_TYPE.MCP); - res.status(200).json(util.toPaginatedList(nonMcpAPIs, req)); + const retrievedAPIs = await getMetadataListFromDB(orgId, searchTerm, tags, apiName, apiVersion, view, { exclude: constants.API_TYPE.MCP }); + res.status(200).json(util.toPaginatedList(retrievedAPIs, req)); } catch (error) { logger.error('API metadata list retrieval failed', { error: error.message, @@ -381,7 +411,7 @@ const getAllAPIMetadata = async (req, res) => { } }; -const getMetadataListFromDB = async (orgId, searchTerm, tags, apiName, apiVersion, viewName) => { +const getMetadataListFromDB = async (orgId, searchTerm, tags, apiName, apiVersion, viewName, typeFilter) => { return await sequelize.transaction({ timeout: 60000, }, async (t) => { @@ -391,11 +421,13 @@ const getMetadataListFromDB = async (orgId, searchTerm, tags, apiName, apiVersio if (apiName) condition.name = apiName; if (apiVersion) condition.version = apiVersion; condition.org_uuid = orgId; + if (typeFilter?.include) condition.type = typeFilter.include; + if (typeFilter?.exclude) condition.type = { [Sequelize.Op.ne]: typeFilter.exclude }; retrievedAPIs = await apiDao.getByCondition(condition, t, tags); } else if (searchTerm) { - retrievedAPIs = await apiDao.search(orgId, searchTerm, viewName, t); + retrievedAPIs = await apiDao.search(orgId, searchTerm, viewName, t, typeFilter); } else if (viewName) { - retrievedAPIs = await apiDao.list(orgId, viewName, t); + retrievedAPIs = await apiDao.list(orgId, viewName, t, typeFilter); } // Create response object let apiCreationResponse = []; @@ -426,6 +458,11 @@ const updateAPIMetadata = async (req, res) => { if (!apiId) { return res.status(404).send("API not found"); } + // `type` is immutable after creation — resolveTypeOrReject below only enforces the + // /apis-vs-/mcp-servers family boundary, not that the value matches this specific + // record's existing type, so that has to be checked separately once it's resolved. + const [existingRecord] = await apiDao.getByCondition({ uuid: apiId, org_uuid: orgId }); + const existingType = existingRecord ? existingRecord.type : undefined; logger.debug('MCP API Definition file', { apiFileName, hasApiDefinitionFile: !!apiDefinitionFile, @@ -461,7 +498,9 @@ const updateAPIMetadata = async (req, res) => { } } else { apiMetadata = JSON.parse(req.body.apiMetadata); - apiMetadata.type = util.resolveApiType(apiMetadata.type); + // Type is resolved centrally below, using this raw (possibly omitted) value — + // don't resolve here, or an omitted type on a /mcp-servers request can no + // longer be told apart from an explicit REST. if (apiMetadata.id) { apiMetadata.handle = apiMetadata.id; } @@ -473,12 +512,9 @@ const updateAPIMetadata = async (req, res) => { } } - if (req.__forceApiType) { - apiMetadata.type = req.__forceApiType; - } else if (apiMetadata.type === constants.API_TYPE.MCP) { - throw new Sequelize.ValidationError( - "MCP servers must be updated via /api/v0.9/mcp-servers/{mcpServerId}" - ); + apiMetadata.type = resolveTypeOrReject(apiMetadata.type, req.__forceApiType); + if (existingType && apiMetadata.type !== existingType) { + throw new CustomError(409, 'Conflict', `API type cannot be changed after creation (existing type is '${existingType}')`); } // Validate input — spec file is optional on update (already stored from create) @@ -499,9 +535,13 @@ const updateAPIMetadata = async (req, res) => { apiMetadata.agentVisibility = normalizedUpdateAgentVisibility; } - // Compute added/removed labels diff for YAML and artifact paths + // Compute added/removed labels diff against the record's current labels — + // needed for every input format (JSON/YAML/artifact), not just YAML/artifact: + // apiDao.update() has no `labels` column of its own, so without this diff a + // `labels` array sent via the plain JSON apiMetadata field was silently a + // no-op (unlike tags/subscriptionPlans, which apply on every update path). let existingAPI; - if (orgId && apiId && Array.isArray(apiMetadata.labels) && (apiArtifactFile?.buffer || req.files?.api?.[0])) { + if (orgId && apiId && Array.isArray(apiMetadata.labels)) { existingAPI = await getMetadataFromDB(orgId, apiId); } if (Array.isArray(apiMetadata.labels) && !apiMetadata.addedLabels && existingAPI !== undefined) { @@ -530,7 +570,11 @@ const updateAPIMetadata = async (req, res) => { if (!updatedRows) { throw new Sequelize.EmptyResultError("No record found to update"); } - if (apiMetadata.addedLabels) { + // An empty array is a valid "no labels to add/remove" diff result (e.g. the + // same labels were resent) — `if (apiMetadata.addedLabels)` alone is truthy + // for `[]` too, which used to call deleteApiMapping with nothing to delete + // and misreport that as "API Labels not found to delete". + if (apiMetadata.addedLabels?.length) { const labels = apiMetadata.addedLabels; if (!Array.isArray(labels)) { throw new Sequelize.ValidationError( @@ -540,7 +584,7 @@ const updateAPIMetadata = async (req, res) => { await labelDao.createApiMapping(orgId, apiId, labels, userId, t); updatedAPI[0].dataValues.addedLabels = apiMetadata.addedLabels; } - if (apiMetadata.removedLabels) { + if (apiMetadata.removedLabels?.length) { const labels = apiMetadata.removedLabels; if (!Array.isArray(labels)) { throw new Sequelize.ValidationError( @@ -649,10 +693,15 @@ const updateAPIMetadata = async (req, res) => { if (apiArtifactFile?.buffer && artifactApiContent.length > 0) { await apiFileDao.upsertMany(artifactApiContent, apiId, orgId, userId, t); } - logUserAction('API_METADATA_UPDATED', req, { orgId, apiId, resourceUuid: apiId, resourceType: 'rest_api' }); const audit = await userIdpReferenceDao.buildSingleAuditFields(updatedAPI[0].dataValues); res.status(200).send(new APIDTO(updatedAPI[0].dataValues, audit)); }); + // Fired only after the transaction above has committed and released its + // connection — logUserAction's DB write is fire-and-forget on the shared + // single-connection SQLite pool, and running it while that connection is + // still checked out by an open transaction deadlocks against itself, + // surfacing as "database is locked" after the busy_timeout expires. + logUserAction('API_METADATA_UPDATED', req, { orgId, apiId, resourceUuid: apiId, resourceType: 'rest_api' }); } catch (error) { logger.error('API metadata update failed', { error: error.message, @@ -668,6 +717,7 @@ const deleteAPIMetadata = async (req, res) => { const orgId = req.orgId; const { apiId: apiHandle } = req.params; let apiId; + let deleted = false; await sequelize.transaction({ timeout: 60000, }, async (t) => { @@ -688,7 +738,7 @@ const deleteAPIMetadata = async (req, res) => { if (apiDeleteResponse === 0) { throw new Sequelize.EmptyResultError("Resource not found to delete"); } else { - logUserAction('API_METADATA_DELETED', req, { orgId, apiId, resourceUuid: apiId, resourceType: 'rest_api' }); + deleted = true; res.status(200).send("Resouce Deleted Successfully"); } } catch (error) { @@ -701,6 +751,12 @@ const deleteAPIMetadata = async (req, res) => { util.handleError(res, error); } }); + // Fired only after the transaction above has committed and released its + // connection — see the comment in updateAPIMetadata for why firing this + // while the connection is still checked out deadlocks against itself. + if (deleted) { + logUserAction('API_METADATA_DELETED', req, { orgId, apiId, resourceUuid: apiId, resourceType: 'rest_api' }); + } }; const createAPITemplate = async (req, res) => { @@ -1059,27 +1115,23 @@ const getAPIFile = async (req, res) => { } const fileExtension = path.extname(apiFileName).toLowerCase(); apiFileResponse = await apiFileDao.get(apiFileName, type, orgId, apiId); - if (apiFileResponse) { - apiFile = apiFileResponse.file_content; - //convert to text to check if link - const textContent = new TextDecoder().decode(apiFile); - if (textContent.startsWith("http") || textContent.startsWith("https")) { - apiFile = textContent; - contentType = constants.MIME_TYPES.TEXT; - } else if (util.isTextFile(fileExtension)) { - contentType = util.retrieveContentType(apiFileName, constants.TEXT) - } else { - contentType = util.retrieveContentType(apiFileName, constants.IMAGE); - } - res.set(constants.MIME_TYPES.CONYEMT_TYPE, contentType); - - if (apiFileResponse) { - // Send file content as text - return res.status(200).send(apiFile); - } else { - res.status(404).send("API File not found"); - } + if (!apiFileResponse) { + return res.status(404).send("API File not found"); + } + apiFile = apiFileResponse.file_content; + //convert to text to check if link + const textContent = new TextDecoder().decode(apiFile); + if (textContent.startsWith("http") || textContent.startsWith("https")) { + apiFile = textContent; + contentType = constants.MIME_TYPES.TEXT; + } else if (util.isTextFile(fileExtension)) { + contentType = util.retrieveContentType(apiFileName, constants.TEXT) + } else { + contentType = util.retrieveContentType(apiFileName, constants.IMAGE); } + res.set(constants.MIME_TYPES.CONYEMT_TYPE, contentType); + // Send file content as text + return res.status(200).send(apiFile); } catch (error) { logger.error('API content retrieval failed', { error: error.message, @@ -1573,6 +1625,7 @@ const updateView = async (req, res) => { const labels = req.body.labels; const viewHandle = req.params.viewId; const userId = util.resolveActor(req); + let updatedViewId; try { await sequelize.transaction({ timeout: 60000, @@ -1588,9 +1641,13 @@ const updateView = async (req, res) => { await viewDao.replaceLabels(orgId, viewId, labels, userId, t); } viewId = viewId ? viewId : await viewDao.getId(orgId, viewHandle, t); - logUserAction('VIEW_UPDATED', req, { orgId, viewId: viewHandle, resourceUuid: viewId, resourceType: 'view' }); + updatedViewId = viewId; res.status(200).send(req.body); }); + // Fired only after the transaction above has committed and released its + // connection — see the comment in updateAPIMetadata for why firing this + // while the connection is still checked out deadlocks against itself. + logUserAction('VIEW_UPDATED', req, { orgId, viewId: viewHandle, resourceUuid: updatedViewId, resourceType: 'view' }); } catch (error) { logger.error('view update error failed', { error: error.message, @@ -1648,7 +1705,7 @@ const getView = async (req, res) => { const getViewInfo = async (orgId, name) => { const view = await viewDao.get(orgId, name); - if (view.dataValues) { + if (view?.dataValues) { const audit = await userIdpReferenceDao.buildSingleAuditFields(view.dataValues); return new ViewDTO(view.dataValues, audit); } else { @@ -1897,7 +1954,15 @@ function mapDevportalYamlToApiMetadata(parsedYaml) { status: apiStatus, agentVisibility, tags: util.normalizeStringArray(spec.tags), - labels: util.normalizeStringArray(spec.labels), + // Unlike tags (always fully replaced on update — apiMetadata.tags || [] runs + // unconditionally), labels are add/remove-diffed against the record's current + // labels, and that diff only runs when `labels` is an array at all — so this + // must stay `undefined` when the YAML omits `labels` entirely, not `[]` + // (normalizeStringArray's default), or every YAML/artifact update would compute + // "remove everything currently attached" even when labels were never mentioned. + // Matches the JSON apiMetadata field path, where an omitted `labels` key is + // already `undefined` with no coercion. + labels: spec.labels !== undefined ? util.normalizeStringArray(spec.labels) : undefined, owners: { businessOwner: businessInformation.businessOwner, businessOwnerEmail: businessInformation.businessOwnerEmail, diff --git a/portals/developer-portal/src/services/mcpServerService.js b/portals/developer-portal/src/services/mcpServerService.js index c226ec81d1..541c952ba4 100644 --- a/portals/developer-portal/src/services/mcpServerService.js +++ b/portals/developer-portal/src/services/mcpServerService.js @@ -75,9 +75,8 @@ const getAllMcpServersForOrganization = async (req, res) => { const apiVersion = req.query.version; const tags = req.query.tags; const view = req.query.view; - const retrievedAPIs = await apiMetadataService.getMetadataListFromDB(orgId, searchTerm, tags, apiName, apiVersion, view); - const mcpServers = retrievedAPIs.filter((api) => api.type === constants.API_TYPE.MCP); - res.status(200).json(util.toPaginatedList(mcpServers, req)); + const retrievedAPIs = await apiMetadataService.getMetadataListFromDB(orgId, searchTerm, tags, apiName, apiVersion, view, { include: constants.API_TYPE.MCP }); + res.status(200).json(util.toPaginatedList(retrievedAPIs, req)); } catch (error) { util.handleError(res, error); } diff --git a/portals/developer-portal/src/services/oauthTokenService.js b/portals/developer-portal/src/services/oauthTokenService.js index 89a195abdc..932d970f67 100644 --- a/portals/developer-portal/src/services/oauthTokenService.js +++ b/portals/developer-portal/src/services/oauthTokenService.js @@ -17,6 +17,7 @@ */ const axios = require('axios'); const logger = require('../config/logger'); +const { CustomError } = require('../utils/errors/customErrors'); /** * Proxy a client_credentials token request to a key manager's token endpoint. @@ -63,9 +64,13 @@ async function generateToken(tokenEndpoint, clientId, clientSecret, scopes, vali status: error.response?.status || null, tokenEndpoint, }); - const err = new Error(`Token generation failed: ${error.message}`); - err.statusCode = error.response?.status ?? null; - throw err; + // Propagate a client error from the key manager (e.g. 401 for a bad + // consumer secret) as-is so the caller sees a 4xx, not a masked 500. + // Upstream 5xx, timeouts, and connection failures aren't the caller's + // fault, so they surface as a 500. + const upstreamStatus = error.response?.status; + const statusCode = upstreamStatus >= 400 && upstreamStatus < 500 ? upstreamStatus : 500; + throw new CustomError(statusCode, `Token generation failed: ${error.message}`); } } diff --git a/portals/developer-portal/src/services/webhooks/envelopeCrypto.js b/portals/developer-portal/src/services/webhooks/envelopeCrypto.js index d2946df120..9e25229579 100644 --- a/portals/developer-portal/src/services/webhooks/envelopeCrypto.js +++ b/portals/developer-portal/src/services/webhooks/envelopeCrypto.js @@ -44,6 +44,8 @@ function encryptToSubscriber(publicKeyPem, plaintext) { const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); + // TODO(pqc): migrate — RSA-OAEP is quantum-vulnerable; move to a hybrid + // (e.g. X25519 + ML-KEM-768) key wrap once subscriber keys support it. const wrappedKey = crypto.publicEncrypt( { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, aesKey @@ -65,6 +67,8 @@ function encryptToSubscriber(publicKeyPem, plaintext) { * @returns {string} */ function decryptFromEnvelope(privateKeyPem, envelope) { + // TODO(pqc): migrate — RSA-OAEP is quantum-vulnerable (mirror of the wrap in + // encryptToSubscriber; migrate both together). const aesKey = crypto.privateDecrypt( { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(envelope.wrappedKey, 'base64') diff --git a/portals/developer-portal/src/services/webhooks/eventPublisher.js b/portals/developer-portal/src/services/webhooks/eventPublisher.js index 785f562479..4150f69b73 100644 --- a/portals/developer-portal/src/services/webhooks/eventPublisher.js +++ b/portals/developer-portal/src/services/webhooks/eventPublisher.js @@ -81,12 +81,18 @@ async function publish(eventType, payload, opts) { if (secretFields) { const subscribers = await matchSubscribers(orgId, eventType); const perSubscriberEncrypted = {}; + // Subscribers without a publicKey (or where encryption fully fails) still get + // delivered — just without the encrypted fields — per the warning logged below. + // Only a *partial* encryption failure excludes a subscriber, since a half-encrypted + // payload can't be trusted as either fully plaintext or fully sealed. + const noEncryptedFieldsSubscriberIds = new Set(); for (const sub of subscribers) { if (!sub.publicKey) { logger.warn('Subscriber has no publicKey — secret event will be delivered without encrypted fields', { subscriberId: sub.id, eventType }); + noEncryptedFieldsSubscriberIds.add(sub.id); continue; } const encryptedForSub = {}; @@ -105,10 +111,12 @@ async function publish(eventType, payload, opts) { logger.error('Partial encryption for subscriber — skipping delivery', { subscriberId: sub.id, eventType }); + } else { + noEncryptedFieldsSubscriberIds.add(sub.id); } } - const deliverableSubscribers = subscribers.filter(s => perSubscriberEncrypted[s.id]); + const deliverableSubscribers = subscribers.filter(s => perSubscriberEncrypted[s.id] || noEncryptedFieldsSubscriberIds.has(s.id)); if (deliverableSubscribers.length > 0) { await eventDao.createDeliveries(event.uuid, deliverableSubscribers, perSubscriberEncrypted, transaction); event.status = 'DISPATCHED';