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');