diff --git a/docs/rest-apis/devportal/labels.md b/docs/rest-apis/devportal/labels.md index b7ebf5e72..94b7888d0 100644 --- a/docs/rest-apis/devportal/labels.md +++ b/docs/rest-apis/devportal/labels.md @@ -139,6 +139,13 @@ This operation requires Basic Auth authentication. +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|limit|query|integer|false|Maximum number of records to return.| +|offset|query|integer|false|Number of records to skip before returning results.| + > Example responses > 200 Response diff --git a/portals/developer-portal/configs/config-platform-api.toml.example b/portals/developer-portal/configs/config-platform-api.toml.example index 325a79457..b588ac7d9 100644 --- a/portals/developer-portal/configs/config-platform-api.toml.example +++ b/portals/developer-portal/configs/config-platform-api.toml.example @@ -80,7 +80,7 @@ region = "us" [[auth.file_based.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write 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_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write 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_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" +scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write 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_write 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:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:mcp_key_read dp:mcp_key_create dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write 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_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" # --------------------------------------------------------------------------- # TLS 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 1d623e206..9f451a32e 100644 --- a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml +++ b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml @@ -692,6 +692,9 @@ paths: summary: List labels description: Returns all labels configured for the organization. operationId: listLabels + parameters: + - $ref: "#/components/parameters/limit" + - $ref: "#/components/parameters/offset" responses: "200": $ref: "#/components/responses/LabelsResponse" diff --git a/portals/developer-portal/it/Makefile b/portals/developer-portal/it/Makefile index 68ce19f5d..215f4f02c 100644 --- a/portals/developer-portal/it/Makefile +++ b/portals/developer-portal/it/Makefile @@ -43,33 +43,40 @@ ensure-test-tag: # 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. +# Each target captures the exit code of `docker compose up` itself — which +# `--exit-code-from ` sets to that service's container exit code, and which +# is also non-zero when a dependency fails to start — then always tears down and +# re-exits with it. Do NOT recover the code via `docker compose ps -q | +# docker inspect`: `ps -q` lists only RUNNING containers, so once the service has +# exited it returns nothing and the code is silently lost (the target then passes +# even when tests fail). Keep the capture in the same shell as `up`. test: ensure-test-tag - -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 + @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; \ + EXIT=$$?; \ + docker compose -f docker-compose.test.yaml down -v --remove-orphans; \ + exit $$EXIT # 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 postgres devportal cypress --abort-on-container-exit --exit-code-from cypress - $(MAKE) _collect-exit COMPOSE_FILE=docker-compose.test.postgres.yaml SERVICE=cypress + @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; \ + EXIT=$$?; \ + docker compose -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ + exit $$EXIT # 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 + @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; \ + EXIT=$$?; \ + docker compose -f docker-compose.test.yaml down -v --remove-orphans; \ + exit $$EXIT # 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 $(SERVICE) | xargs docker inspect --format='{{.State.ExitCode}}' 2>/dev/null || echo 0); \ - docker compose -f $(COMPOSE_FILE) down -v --remove-orphans; \ + @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; \ + EXIT=$$?; \ + docker compose -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ exit $$EXIT # Open Cypress interactive UI — runs against a LOCALLY running devportal (not in Docker). diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index 1a221507c..37c53d82a 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -48,7 +48,7 @@ region = "us" [[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" +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:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_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, @@ -56,14 +56,14 @@ scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_de [[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" +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:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_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" +scopes = "dp:org_read dp:api_read dp:api_content_read dp:mcp_read dp:mcp_content_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" diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index e830e594e..669f608d9 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -48,11 +48,13 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | - [ -f /certs/cert.pem ] && [ -f /certs/key.pem ] && exit 0 - openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ - -keyout /certs/key.pem -out /certs/cert.pem \ - -subj "/O=WSO2 API Platform/CN=platform-api" \ - -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + if [ ! -f /certs/cert.pem ] || [ ! -f /certs/key.pem ]; then + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout /certs/key.pem -out /certs/cert.pem \ + -subj "/O=WSO2 API Platform/CN=platform-api" \ + -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + fi + chown 10001:10001 /certs/cert.pem /certs/key.pem volumes: - platform-api-certs:/certs diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 9bcfb17ea..685f92b26 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -32,11 +32,13 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | - [ -f /certs/cert.pem ] && [ -f /certs/key.pem ] && exit 0 - openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ - -keyout /certs/key.pem -out /certs/cert.pem \ - -subj "/O=WSO2 API Platform/CN=platform-api" \ - -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + if [ ! -f /certs/cert.pem ] || [ ! -f /certs/key.pem ]; then + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout /certs/key.pem -out /certs/cert.pem \ + -subj "/O=WSO2 API Platform/CN=platform-api" \ + -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + fi + chown 10001:10001 /certs/cert.pem /certs/key.pem volumes: - platform-api-certs:/certs diff --git a/portals/developer-portal/it/rest-api/api-content/api-content.spec.js b/portals/developer-portal/it/rest-api/api-content/api-content.spec.js index 570235a4e..e5b9c8642 100644 --- a/portals/developer-portal/it/rest-api/api-content/api-content.spec.js +++ b/portals/developer-portal/it/rest-api/api-content/api-content.spec.js @@ -62,7 +62,7 @@ async function uploadContent(role, handle, zip, { method = 'post', imageMetadata const req = method === 'put' ? client.as(role).putMultipart(`/apis/${handle}/assets`) : client.as(role).postMultipart(`/apis/${handle}/assets`); - req.attach('apiContent', zip, 'content.zip'); + req.attach('content', zip, 'content.zip'); if (imageMetadata) req.field('imageMetadata', JSON.stringify(imageMetadata)); return req; } 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 index 8f1a7fd4e..f629a4fac 100644 --- 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 @@ -107,10 +107,9 @@ describe('APIs via artifact ZIP upload', () => { // 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 metadata field - // path this doesn't risk drifting the id/handle as a side effect of the update. + // stay stable across the round-trip — apiDao.update() never touches the handle + // (it is immutable after creation), so no update path can + // drift the id/handle as a side effect. 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'); 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 index 3688984ca..f6499e10d 100644 --- a/portals/developer-portal/it/rest-api/apis/rest-apis.spec.js +++ b/portals/developer-portal/it/rest-api/apis/rest-apis.spec.js @@ -232,14 +232,10 @@ describe('REST APIs', () => { // YAML/artifact uploads. A `labels` array sent via the plain JSON metadata // 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. + // NOTE: the PUT bodies here include `id: api.id`, but it is no longer required — + // apiDao.update() leaves the handle untouched (it is immutable after creation), + // so an omitted `id` no longer drifts the resource's + // identifier. The field is kept only for explicitness. it('changes labels on update (new labels replace old ones)', async () => { const labelA = await createLabel(); const labelB = await createLabel(); diff --git a/portals/developer-portal/it/rest-api/jest.config.js b/portals/developer-portal/it/rest-api/jest.config.js index 1096fb93c..83448b813 100644 --- a/portals/developer-portal/it/rest-api/jest.config.js +++ b/portals/developer-portal/it/rest-api/jest.config.js @@ -21,6 +21,9 @@ module.exports = { testMatch: ['**/*.spec.js'], globalSetup: '/support/global-setup.js', globalTeardown: '/support/global-teardown.js', + // Registers a per-suite afterAll that deletes resources tracked via + // support/cleanup.js, so specs don't accumulate objects in the shared org. + setupFilesAfterEnv: ['/support/autoCleanup.js'], testTimeout: 10000, reporters: [ 'default', 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 index ff65d822b..de773be99 100644 --- a/portals/developer-portal/it/rest-api/subscriptions/webhook-events.spec.js +++ b/portals/developer-portal/it/rest-api/subscriptions/webhook-events.spec.js @@ -63,7 +63,7 @@ describe('subscriptions webhook events', () => { it('publishes and delivers subscription.created', async () => { const since = new Date(); - const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); expect(create.status).toBe(201); const event = await waitForEvent({ orgHandle: client.ORG_HANDLE, type: 'subscription.created', since }); @@ -86,7 +86,7 @@ describe('subscriptions webhook events', () => { }); it('publishes and delivers subscription.plan_changed', async () => { - const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); const since = new Date(); const change = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'Silver' }); @@ -111,7 +111,7 @@ describe('subscriptions webhook events', () => { }); it('publishes and delivers subscription.token_regenerated', async () => { - const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); const since = new Date(); const regen = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/regenerate-token`, {}); @@ -140,7 +140,7 @@ describe('subscriptions webhook events', () => { }); it('publishes and delivers subscription.deleted', async () => { - const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); const since = new Date(); const del = await client.as('developer').del(`/subscriptions/${create.body.subscriptionId}`); @@ -177,7 +177,7 @@ describe('subscriptions webhook events', () => { publicKey, }); - const create = await client.as('developer').post('/subscriptions', { apiId: api.id, subscriptionPlanId: 'Gold' }); + const create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); const since = new Date(); const regen = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/regenerate-token`, {}); @@ -211,7 +211,7 @@ describe('subscriptions webhook events', () => { // 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 create = await client.as('developer').post('/subscriptions', { artifactId: api.id, subscriptionPlanId: 'Gold' }); const since = new Date(); const res = await client.as('developer').post(`/subscriptions/${create.body.subscriptionId}/change-plan`, { planId: 'DoesNotExist' }); diff --git a/portals/developer-portal/it/rest-api/support/autoCleanup.js b/portals/developer-portal/it/rest-api/support/autoCleanup.js new file mode 100644 index 000000000..c4ec54126 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/autoCleanup.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. +// -------------------------------------------------------------------- + +// Registered for every spec file via jest.config.js `setupFilesAfterEnv`. Runs a +// single afterAll in each suite that deletes every resource tracked through +// support/cleanup.js during that file's run, so the shared org doesn't accumulate +// APIs / MCP servers / labels / views / subscriptions across the suite. +// +// Registered here (before the spec's own hooks) so it runs LAST among afterAll +// hooks — after any spec-level afterAll — while the devportal is still up. + +const { cleanupResources } = require('./cleanup'); + +afterAll(cleanupResources); diff --git a/portals/developer-portal/it/rest-api/support/cleanup.js b/portals/developer-portal/it/rest-api/support/cleanup.js new file mode 100644 index 000000000..dfc875b27 --- /dev/null +++ b/portals/developer-portal/it/rest-api/support/cleanup.js @@ -0,0 +1,105 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Per-suite resource cleanup so specs don't pile up APIs / MCP servers / labels / +// views / subscriptions / etc. in the single shared org (file-based auth is +// one-org, so every spec runs against `default`). globalTeardown can't do this — +// it runs in its own process with no view of what the specs created — so cleanup +// happens in an afterAll registered for every suite via jest.config.js's +// setupFilesAfterEnv (support/autoCleanup.js). This module's state is shared +// within a single spec file's worker (Node's require cache), which is exactly the +// scope that afterAll tears down. +// +// Coverage: +// - JSON creations via client.as(role).post('/', {...}) are tracked +// AUTOMATICALLY by the client (autoTrackFromResponse below) — no per-spec code. +// - Multipart creations (POST /apis, /mcp-servers) go through fixtures.createApi +// or a spec's own helper, which call trackResource() explicitly. + +// NOTE: `client` is required lazily inside cleanupResources() — client.js requires +// this module (for the auto-track hook), so requiring it back at top would be a +// load-time cycle. By call time (afterAll) client is fully initialised. + +// Top-level collections whose POST / creates a resource deletable via +// DELETE //. Only EXACT collection-root paths are tracked, so +// sub-resource posts (e.g. /apis/{id}/api-keys, /subscriptions/{id}/change-plan) +// are never mistaken for a top-level resource. +const TRACKABLE_COLLECTIONS = new Set([ + 'apis', 'mcp-servers', 'labels', 'views', 'applications', + 'key-managers', 'subscriptions', 'api-workflows', 'webhook-subscribers', +]); + +// { collection, id, role } entries for top-level resources to DELETE /{collection}/{id}. +const registry = []; + +// Register a created top-level resource for deletion after the suite. +// collection — the API path segment, e.g. 'apis', 'mcp-servers', 'labels'. +// id — the resource id/handle used in DELETE /{collection}/{id}. +// role — the auth role that owns it (must be logged in); it created the +// resource, so it has both a live session and the delete scope. +function trackResource(collection, id, role = 'admin') { + if (collection && id) { + registry.push({ collection, id, role }); + } +} + +// Called by the client after every as(role).post(). Registers a resource when the +// POST targeted a top-level collection root and succeeded. The id comes from the +// request body (`{ id }` for labels/views/apps/…) or the response +// (`id`/`subscriptionId`). +function autoTrackFromResponse(path, body, res, role) { + const segments = String(path || '').split('?')[0].replace(/^\/+/, '').split('/'); + if (segments.length !== 1) return; // sub-resource / nested path — not a top-level create + const collection = segments[0]; + if (!TRACKABLE_COLLECTIONS.has(collection)) return; + if (!res || res.status < 200 || res.status >= 300) return; + const id = (body && body.id) || (res.body && (res.body.id || res.body.subscriptionId)); + trackResource(collection, id, role); +} + +// Delete everything tracked, newest-first so dependents (e.g. a subscription) are +// removed before their parent (e.g. the API it points at). A resource whose +// delete is refused because a sibling still references it (the API delete guards +// return 409 while subscriptions/active keys exist) is retried on the next pass, +// once that sibling is gone; passes stop as soon as one makes no progress. +// Best-effort throughout: a 404 means a test already deleted it, and cleanup +// failures (blocked deletes, an expired session) never fail the suite. +async function cleanupResources() { + const client = require('./client'); // lazy — avoids a load-time require cycle + let pending = registry.splice(0); // drain the registry into the working set + const MAX_PASSES = 3; + for (let pass = 0; pass < MAX_PASSES && pending.length; pass++) { + const blocked = []; + // Newest-first within a pass. + for (let i = pending.length - 1; i >= 0; i--) { + const entry = pending[i]; + try { + const res = await client.as(entry.role).del(`/${entry.collection}/${entry.id}`); + // 2xx = deleted, 404 = already gone; anything else (409/500) may + // clear once a sibling is removed, so retry it next pass. + if (res.status >= 400 && res.status !== 404) blocked.push(entry); + } catch (_) { + // session gone or transport error — drop it, cleanup is best-effort + } + } + if (blocked.length === pending.length) break; // no progress — stop retrying + pending = blocked; + } +} + +module.exports = { trackResource, autoTrackFromResponse, cleanupResources, registry }; diff --git a/portals/developer-portal/it/rest-api/support/client.js b/portals/developer-portal/it/rest-api/support/client.js index 1b0b7ab94..ace2d2ce9 100644 --- a/portals/developer-portal/it/rest-api/support/client.js +++ b/portals/developer-portal/it/rest-api/support/client.js @@ -32,6 +32,7 @@ // Request as soon as the function returns, before the chaining happens. const supertest = require('supertest'); +const { autoTrackFromResponse } = require('./cleanup'); const BASE_URL = process.env.DEVPORTAL_BASE_URL || 'http://localhost:3000'; const API_PREFIX = '/api/v0.9'; @@ -103,12 +104,23 @@ function as(role) { return { get: (path) => agent.get(`${API_PREFIX}${path}`), - post: (path, body) => withXsrf(agent.post(`${API_PREFIX}${path}`)).send(body), + // post resolves to the response like before (every caller just awaits it — + // none chain supertest methods on the return), with a tap that auto-registers + // a created top-level resource for afterAll cleanup (support/cleanup.js). + post: (path, body) => withXsrf(agent.post(`${API_PREFIX}${path}`)).send(body) + .then((res) => { autoTrackFromResponse(path, body, res, role); return res; }), 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}`)), + // .field()/.attach() before awaiting. Because the request is built by + // chaining after this returns, we can't tap it with .then; instead listen + // for superagent's 'response' event, which fires with the parsed body on + // await and lets us auto-register the created API/MCP for afterAll cleanup. + postMultipart: (path) => { + const req = withXsrf(agent.post(`${API_PREFIX}${path}`)); + req.on('response', (res) => autoTrackFromResponse(path, undefined, res, role)); + return req; + }, putMultipart: (path) => withXsrf(agent.put(`${API_PREFIX}${path}`)), }; } diff --git a/portals/developer-portal/it/rest-api/support/fixtures.js b/portals/developer-portal/it/rest-api/support/fixtures.js index a958158e8..8c31ce62f 100644 --- a/portals/developer-portal/it/rest-api/support/fixtures.js +++ b/portals/developer-portal/it/rest-api/support/fixtures.js @@ -38,6 +38,7 @@ async function createView(overrides = {}) { let labels = overrides.labels; if (!labels) { const labelId = uniqueHandle('label'); + // client.post auto-tracks the label + view for afterAll cleanup. await client.as('admin').post('/labels', { id: labelId, displayName: labelId }); labels = [labelId]; } @@ -108,6 +109,7 @@ async function createApi(overrides = {}) { if (res.status !== 201) { throw new Error(`Failed to seed API: ${res.status} ${JSON.stringify(res.body)}`); } + // client.postMultipart auto-tracks the created API/MCP for afterAll cleanup. return res.body; } @@ -125,6 +127,7 @@ async function createWebhookSubscriber(overrides = {}) { if (res.status !== 201) { throw new Error(`Failed to seed webhook subscriber: ${res.status} ${JSON.stringify(res.body)}`); } + // client.post auto-tracks the subscriber for afterAll cleanup. return res.body; } diff --git a/portals/developer-portal/src/dao/apiDao.js b/portals/developer-portal/src/dao/apiDao.js index 426acde80..acbad98da 100644 --- a/portals/developer-portal/src/dao/apiDao.js +++ b/portals/developer-portal/src/dao/apiDao.js @@ -82,7 +82,6 @@ const update = async (orgId, apiId, apiMetadata, updatedBy, t) => { ref_id: apiMetadata.referenceId, status: apiMetadata.status, name: apiMetadata.name, - handle: apiMetadata.handle ? apiMetadata.handle : `${apiMetadata.name.toLowerCase().replace(/\s+/g, '')}-v${apiMetadata.version}`, description: apiMetadata.description, version: apiMetadata.version, type: apiMetadata.type, diff --git a/portals/developer-portal/src/services/apiMetadataService.js b/portals/developer-portal/src/services/apiMetadataService.js index 7a895a4ad..f7cae3bc3 100644 --- a/portals/developer-portal/src/services/apiMetadataService.js +++ b/portals/developer-portal/src/services/apiMetadataService.js @@ -1217,7 +1217,7 @@ const deleteAPIFile = async (req, res) => { } else { apiFileResponse = await apiFileDao.deleteAll(fileType, orgId, apiId); } - if (!apiFileResponse) { + if (apiFileResponse) { res.status(204).send(); } else { util.sendError(res, 404, 'API Content not found');