From 13d521feada39f3b424ff8a4377f9e9040f27e8e Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 15:18:57 +0530 Subject: [PATCH 1/9] Add end-to-end test case for API Platform + Gateway + Devportal integration --- .../workflows/platform-api-devportal-e2e.yml | 71 ++++ tests/integration-e2e/README.md | 120 +++++- tests/integration-e2e/docker-compose.yaml | 73 +++- .../features/devportal-webhook.feature | 62 +++ .../features/secured-api-invocation.feature | 20 + tests/integration-e2e/init-db.sql | 10 + .../integration-e2e/platform-api-config.toml | 4 + .../steps_devportal_lifecycle_test.go | 287 +++++++++++++ tests/integration-e2e/steps_devportal_test.go | 395 ++++++++++++++++++ tests/integration-e2e/steps_secured_test.go | 279 +++++++++++++ tests/integration-e2e/steps_test.go | 45 +- tests/integration-e2e/suite_test.go | 136 +++++- 12 files changed, 1473 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/platform-api-devportal-e2e.yml create mode 100644 tests/integration-e2e/features/devportal-webhook.feature create mode 100644 tests/integration-e2e/features/secured-api-invocation.feature create mode 100644 tests/integration-e2e/steps_devportal_lifecycle_test.go create mode 100644 tests/integration-e2e/steps_devportal_test.go create mode 100644 tests/integration-e2e/steps_secured_test.go diff --git a/.github/workflows/platform-api-devportal-e2e.yml b/.github/workflows/platform-api-devportal-e2e.yml new file mode 100644 index 0000000000..4f5bd7283f --- /dev/null +++ b/.github/workflows/platform-api-devportal-e2e.yml @@ -0,0 +1,71 @@ +name: Platform API + Gateway + Developer Portal E2E + +# Full three-plane end-to-end test: a subscription and API key created in the real +# developer portal are pushed to the real platform-api control plane over the signed +# webhook, propagated to the real gateway data plane, and used to invoke a secured +# API through the ingress. Also drives the credential lifecycle (revoke, expiry, +# plan change, pause/resume, token regen, remove) and verifies each at the gateway +# or via platform-api. +# +# The developer-portal scenarios are postgres-only (they need the shared postgres +# server for the devportal store + webhook round-trip), so this runs a single +# postgres job. It builds the developer-portal image on top of the platform-api, +# gateway and sample-service images, so it is heavier than the combined e2e and is +# intentionally kept off the per-PR critical path — it runs on demand and when the +# e2e itself changes. +on: + workflow_dispatch: + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + devportal-e2e: + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: '1.26.2' + cache-dependency-path: '**/go.sum' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build platform-api image + run: make -C platform-api build IMAGE_NAME=platform-api VERSION=it-e2e + + - name: Build gateway images (controller + runtime) + run: make build VERSION=it-e2e + working-directory: gateway + + - name: Build sample-service image + run: make build + working-directory: samples/sample-service + + - name: Build developer-portal image + run: make build VERSION=it-e2e + working-directory: portals/developer-portal + + - name: Run developer-portal e2e (postgres) + # make build above tags the devportal image under the default registry, so + # point the compose at it. E2E_TAGS=@devportal runs both the happy-path and + # the credential-lifecycle scenario (the latter is tagged @devportal @lifecycle). + env: + E2E_DB: postgres + E2E_TAGS: '@devportal' + DEVPORTAL_IMAGE: ghcr.io/wso2/api-platform/developer-portal:it-e2e + run: go test -run TestFeatures -count=1 -v -timeout 25m ./... + working-directory: tests/integration-e2e diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index 8772ecf04e..a84dc32994 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -6,6 +6,10 @@ engine**, so a single scenario exercises both products integrated end to end: an API created in platform-api is deployed to a gateway and served by the data plane. +On the postgres stack it additionally runs the **real developer portal**, so the +`@devportal` scenario exercises all three planes together: a credential created +in the portal reaches the gateway via the signed webhook to platform-api. + It complements the per-component cross-database suites: - `platform-api/it` — platform-api store on SQLite / PostgreSQL / SQL Server. - `gateway/it` — gateway store on SQLite / PostgreSQL / SQL Server. @@ -63,8 +67,10 @@ sync so the new deployment is picked up. Build the component images once (tagged `it-e2e`), then run the suite: ```bash -cd platform-api && docker build -t platform-api:it-e2e --build-context common=../common . +cd platform-api && docker build -t platform-api:it-e2e \ + --build-context common=../common --build-context httpkit=../httpkit . cd gateway && make build VERSION=it-e2e # gateway-controller / gateway-runtime :it-e2e +cd portals/developer-portal && docker build -t developer-portal:it-e2e . # only needed for @devportal cd tests/integration-e2e go test -run TestFeatures -v ./... # PostgreSQL (default) @@ -77,11 +83,22 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. - `E2E_DB` = `postgres` (default) | `sqlite` | `sqlserver`. - `E2E_KEEP=1` leaves the stack up after the run for inspection. -- `E2E_TAGS=@smoke` runs a tag subset. The `@multigateway` scenario runs only on - the postgres stack (the only one wired with a second gateway) and is otherwise - skipped automatically. -- `PA_HOST_PORT` / `GW_HTTP_PORT` / `GW2_HTTP_PORT` override the published host - ports to avoid clashing with other local stacks (defaults 9243 / 18080 / 18081). +- `E2E_TAGS=@smoke` runs a tag subset (other tags: `@secured`, `@multigateway`, + `@devportal`, and `@lifecycle` for the credential-lifecycle scenario — run it alone + with `E2E_TAGS="@devportal && @lifecycle"`). The `@multigateway` and `@devportal` + scenarios run only on the postgres stack (the only one wired with a second gateway + and the developer portal) and are otherwise skipped automatically. +- `PA_HOST_PORT` / `GW_HTTP_PORT` / `GW2_HTTP_PORT` / `DP_HOST_PORT` override the + published host ports to avoid clashing with other local stacks (defaults 9243 / + 18080 / 18081 / 3000). +- `DEVPORTAL_IMAGE` overrides the developer-portal image (default + `developer-portal:it-e2e`). `PA_WEBHOOK_KEY` is set automatically by the suite + (a container-readable copy of the webhook private key) — you don't normally set it. +- `PA_API_BASE` / `DP_API_BASE` override the REST resource-API base path for + platform-api and the developer portal respectively (default `/api/v0.9` each) — + set these when either product moves to a new API version, independently of the + other. `PA_PORTAL_BASE` (login, default `/api/portal/v0.9`) and `PA_WEBHOOK_BASE` + (webhook receiver, default `/api/internal/v0.9`) cover platform-api's other prefixes. ### Scenarios @@ -93,6 +110,97 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. 3. **Multi-gateway** (`@multigateway`, postgres) — the same API deployed to two gateways is served by both (fan-out), and undeploying from one leaves the other serving (per-gateway isolation). +4. **Secured API** (`@secured`, `features/secured-api-invocation.feature`) — a + **PUBLISHED** REST API guarded by the `api-key-auth` and + `subscription-validation` policies is deployed, then invoked through the data + plane. This exercises the full control-plane → data-plane credential chain: + create a subscription plan → create + publish the secured API offering it → + deploy → create an application → subscribe it under the plan (minting a + `Subscription-Key` token) → issue an `API-Key` → invoke the ingress. A request + with **both** valid headers returns 200; an unauthenticated request is + rejected (401/403). Runs on all databases (single gateway). + + Two properties this scenario relies on (verified from source): + - **Ordering** — the API is deployed *before* the subscription/key are + created, because platform-api only broadcasts `subscription.created` / + `apikey.created` to gateways where the artifact is already deployed (and + `POST …/api-keys` returns 503 when no gateway is connected). + - **No restart** — unlike deployments (which need the controller bounce + described above), subscriptions and API keys are pushed live over the + control-plane WebSocket and applied immediately, so the scenario just polls + the ingress until they propagate. +5. **Full suite via developer portal** (`@devportal`, postgres, + `features/devportal-webhook.feature`) — the same secured API, but the + subscription and API key are created in the **developer portal**, which fires + signed webhooks to platform-api; platform-api decrypts them, persists the + credentials, and propagates them to the gateway. The API is then invoked + through the gateway with those **portal-issued** credentials → 200 (and a + credential-less request is still rejected). This is the three-plane + (platform-api + gateway + devportal) round-trip. + + How the trust/transport is wired (all verified end to end): + - The devportal is added to the postgres compose stack with its **own database + on the shared postgres** (`devportal`, created by `init-db.sql`). Postgres, + not the devportal's default SQLite, because the org-update path needs + `UPDATE … RETURNING`, which SQLite doesn't provide; its schema is pre-loaded + from `database/schema.postgres.sql` (the devportal does not auto-migrate on + an external DB). + - Auth: the devportal accepts the platform-api admin JWT directly (shared + `DP_PLATFORMAPI_JWTSECRET`, org from the token's `org_handle` claim). The + admin must carry `dp:*` scopes, which platform-api's built-in admin lacks — + so the suite injects an admin (ap:* **and** dp:*) via the + `AUTH_FILE_BASED_USERS` env var (a mounted config's users are ignored; only + that env override wins). Bearer auth (not API-key mode) is used because the + write paths need a resolved user for `created_by`. + - `BeforeSuite` links the portal org (`cpRefId = "default"`, the platform-api + org handle) and registers a webhook subscriber pointing at + `…/api/internal/v0.9/webhook/events` with the shared HMAC secret and the RSA + **public** key `devportal-webhook.pub`. platform-api decrypts with the paired + `devportal-webhook.pem` (mounted in; the suite copies it to a 0644 file under + the compose dir — the container runs as uid 10001 and the source is 0600, and + `/tmp` isn't shared into the container VM). + - The delivery worker POSTs over raw https with the default agent, so the + devportal container sets `NODE_TLS_REJECT_UNAUTHORIZED=0` to accept + platform-api's self-signed cert. + - Webhooks are signed `t=,v1=` over `"."` and the key / + token fields are hybrid-encrypted (RSA-OAEP-SHA256 + AES-256-GCM). platform-api + re-encrypts the subscription token at rest, so + `DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY` must be 32 bytes (64 hex chars). + - platform-api resolves the event's **org, API and plan by handle**, so the + devportal org's `cpRefId`, the published API's `referenceId`, and the synced + plan's `refId` are each set to the corresponding platform-api handle. + - Delivery is fire-once on a ~2s poll; the scenario polls the ingress until the + credentials propagate. +6. **Developer-portal credential lifecycle** (`@devportal @lifecycle`, postgres, + same feature file) — after the same publish/deploy/subscribe/key setup, it drives + every credential-lifecycle change in the developer portal and verifies each via + the webhook propagation: + - **Change API key expiry** — set a past expiry; the gateway must reject the now + expired key (401), then serve again after the expiry is restored. (Verified at + the gateway, not via a control-plane GET: platform-api exposes **no** REST that + returns a webhook-created key's expiry — `/me/api-keys` is filtered to the + caller's own keys and webhook keys have no owner, and `/applications/{id}/api-keys` + needs a key→app mapping that neither the `apikey.application_updated` webhook nor + the direct `AddApplicationAPIKeys` REST can establish. This is a real product gap + worth flagging.) + - **Change subscription plan** — switch plans in the portal; platform-api's + `GET /subscriptions` then reports the new `subscriptionPlanName`. + - **Regenerate subscription token** — the new token works at the gateway (200) and + the old token is rejected. + - **Pause** the subscription (status `INACTIVE`) → gateway 403; **resume** → 200. + - **Revoke** the API key → gateway 401. + - **Remove** the subscription → gateway 403. + Isolation: revoked/expired key → **401** (api-key-auth), inactive/deleted + subscription → **403** (subscription-validation); each check leaves exactly one + credential invalid so the code identifies the cause (the key is re-issued before + the subscription is removed). + + Note: the scenarios are heavy (controller restarts + Envoy propagation). Each + plan/API uses a **unique** display name and handle — the gateway keys plans by + `(gateway_id, plan_name)`, so reusing a display name across scenarios collides and + the later plan (with its subscription) silently fails to sync. With that, the full + six-scenario run passes in one process (~3 min); it is still resource-intensive on + constrained hosts, where running per-tag (`E2E_TAGS=@devportal`, etc.) is lighter. ## Status — passing on all three databases diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index aacf4582a8..ea4078dcbd 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -25,6 +25,9 @@ services: # platform-api no longer auto-provisions its schema on external databases, # so init-db.sql applies it from here (mounted from the source tree). - ../../platform-api/internal/database/schema.postgres.sql:/schema/schema.postgres.sql:ro + # The developer portal likewise pre-loads its schema on postgres (it does + # not sequelize.sync() on external DBs); init-db.sql applies it too. + - ../../portals/developer-portal/database/schema.postgres.sql:/devportal-schema/schema.postgres.sql:ro ports: - "55433:5432" healthcheck: @@ -50,7 +53,9 @@ services: - DATABASE_USER=apip - DATABASE_PASSWORD=apip - DATABASE_SSL_MODE=disable - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Must be 32 bytes (64 hex chars); the subscription-token encryption path + # (exercised by the webhook subscription.created handler) rejects shorter keys. + - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # Force file-based auth + a stable org so the scenario can log in and the # org is seeded (env overrides the mounted config file). - AUTH_FILE_BASED_ENABLED=true @@ -59,8 +64,24 @@ services: - AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - AUTH_FILE_BASED_ORGANIZATION_REGION=us - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789 + # The @devportal stack injects the admin user (with dp:* scopes) here so the + # issued JWT is authorized on the developer portal. Empty (the default) means + # platform-api uses its built-in admin (ap:* scopes only). + - AUTH_FILE_BASED_USERS=${AUTH_FILE_BASED_USERS:-} + # Developer Portal webhook receiver (only the postgres stack runs the devportal). + # Enabled via env so the sqlite/sqlserver stacks — which have no devportal and no + # mounted key — leave it off. The private key must be readable by the container + # user (uid 10001); the suite copies the repo key to a 0644 temp and points + # PA_WEBHOOK_KEY at it (see prepareWebhookKey in suite_test.go). + - WEBHOOK_ENABLED=true + - WEBHOOK_SECRET=5bd108b058ac9b318faf771c82a3f88bf6d3be5cc51c221e7ee213dabdbdee22 + - WEBHOOK_PRIVATE_KEY_PATH=/etc/platform-api/devportal-webhook.pem + - WEBHOOK_GATEWAY_TYPE=wso2/api-platform volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro + # RSA private key paired with portals/developer-portal/devportal-webhook.pub; + # platform-api decrypts the webhook's encrypted key/token fields with it. + - ${PA_WEBHOOK_KEY:-../../portals/developer-portal/devportal-webhook.pem}:/etc/platform-api/devportal-webhook.pem:ro ports: - "${PA_HOST_PORT:-9243}:9243" depends_on: @@ -176,6 +197,56 @@ services: condition: service_started networks: [e2e] + # Developer Portal — the consumer-facing plane. Credentials (subscriptions, + # API keys) generated here are fired as signed webhooks to platform-api's + # receiver (…/api/internal/v0.9/webhook/events), which persists them and + # propagates to the gateway data plane over its existing WebSocket sync. Uses + # its own self-contained SQLite store (auto-migrated on boot) so it needs no + # database wiring. Started on demand by the godog suite (postgres stack only). + devportal: + image: ${DEVPORTAL_IMAGE:-developer-portal:it-e2e} + depends_on: + postgres: + condition: service_healthy + platform-api: + condition: service_started + environment: + # Its own database on the shared postgres server (created by init-db.sql). + # Postgres, not the devportal's default SQLite: the org-update path needs + # UPDATE ... RETURNING, which SQLite doesn't support. Schema auto-migrates. + - DP_DB_DIALECT=postgres + - DP_DB_HOST=postgres + - DP_DB_PORT=5432 + - DP_DB_DATABASE=devportal + - DP_DB_USERNAME=apip + - DP_DB_PASSWORD=apip + # Plain HTTP to avoid the self-signed cert dance in tests. + - DP_ADVANCED_HTTP=true + - DP_BASEURL=http://devportal:3000 + # Seed the "default" org so its handle matches platform-api's org handle + # (the webhook resolves the control-plane org by handle via org.ref_id). + - DP_DEFAULTORGNAME=default + - DP_DEMO_ENABLED=false + - DP_GENERATEDEFAULTSUBPLANS=false + - DP_LOGGING_CONSOLEONLY=true + # Validate platform-api-issued JWTs locally: this MUST equal the stack's + # AUTH_JWT_SECRET_KEY so the admin token is accepted by the devportal. + - DP_PLATFORMAPI_BASEURL=https://platform-api:9243 + - DP_PLATFORMAPI_JWTSECRET=e2e-integration-secret-key-0123456789 + - DP_PLATFORMAPI_INSECURE=true + # The webhook delivery worker POSTs over raw https with the default agent, + # and platform-api serves a self-signed cert here. DP_PLATFORMAPI_INSECURE + # only covers the auth client, so disable TLS verification globally for this + # throwaway test container to let signed webhook deliveries through. + - NODE_TLS_REJECT_UNAUTHORIZED=0 + # Stable AES-256-GCM key (64 hex) for encrypting the webhook subscriber + # secret at rest; must not change across restarts or the stored secret + # becomes undecryptable and outbound requests go unsigned. + - DP_ADVANCED_ENCRYPTIONKEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + ports: + - "${DP_HOST_PORT:-3000}:3000" + networks: [e2e] + networks: e2e: driver: bridge diff --git a/tests/integration-e2e/features/devportal-webhook.feature b/tests/integration-e2e/features/devportal-webhook.feature new file mode 100644 index 0000000000..7bb5ef3bb4 --- /dev/null +++ b/tests/integration-e2e/features/devportal-webhook.feature @@ -0,0 +1,62 @@ +Feature: Credentials issued in the developer portal authorize gateway invocation via webhook + As an API platform operator running the full product suite + I want an API key and subscription created in the developer portal to reach the gateway + through the signed webhook to platform-api + So that a consumer using developer-portal-issued credentials can invoke the API end to end. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @devportal + Scenario: A developer-portal API key and subscription propagate to the gateway and authorize a call + Given a subscription plan "e2e-gold" allowing 10000 requests per hour + And a published REST API secured with API key and subscription validation offering that plan + When I deploy the secured API to the gateway + Then an unauthenticated request to the secured API is rejected + When the subscription plan is synced to the developer portal + And the API is published to the developer portal linked to the platform API + And an application subscribed to the API is created in the developer portal + And an API key is generated in the developer portal + Then invoking the secured API through the gateway with the developer portal credentials returns 200 + And invoking the secured API through the gateway without credentials is rejected + + @devportal @lifecycle + Scenario: Developer-portal credential-lifecycle changes propagate to platform-api and the gateway + Given a subscription plan "e2e-gold" allowing 10000 requests per hour + And a published REST API secured with API key and subscription validation offering that plan + When I deploy the secured API to the gateway + Then an unauthenticated request to the secured API is rejected + When the subscription plan is synced to the developer portal + And a second subscription plan is synced to the developer portal + And the API is published to the developer portal linked to the platform API + And an application subscribed to the API is created in the developer portal + And an API key is generated in the developer portal + Then invoking the secured API through the gateway with the developer portal credentials returns 200 + # --- API key lifecycle (subscription stays ACTIVE throughout, so a rejection = 401 isolates the key) --- + # Change the API key expiry (to the past); the gateway must reject the expired key, then serve after restore. + When the API key is expired in the developer portal + Then invoking with the expired API key is rejected + When the API key expiry is restored in the developer portal + Then invoking the secured API through the gateway with the developer portal credentials returns 200 + # Revoke the API key; the gateway rejects it (401), then re-issue a valid key for the subscription checks below. + When the API key is revoked in the developer portal + Then invoking with the revoked API key is unauthorized + When a new API key is generated in the developer portal + Then invoking the secured API through the gateway with the developer portal credentials returns 200 + # --- Subscription lifecycle (the API key stays valid throughout, so a rejection = 403 isolates the subscription) --- + # Change the subscription plan, verify from the platform-api side. + When the subscription plan is changed in the developer portal + Then platform-api reports the new subscription plan + # Regenerate the subscription token; the new token works and the old one is rejected. + When the subscription token is regenerated in the developer portal + Then invoking with the new subscription token returns 200 + And invoking with the old subscription token is rejected + # Pause the subscription (then resume), verify at the gateway. + When the subscription is paused in the developer portal + Then invoking the secured API through the gateway is rejected + When the subscription is resumed in the developer portal + Then invoking the secured API through the gateway with the developer portal credentials returns 200 + # Remove the subscription (terminal); the gateway stops honoring the credentials. + When the subscription is removed in the developer portal + Then invoking the secured API through the gateway is rejected diff --git a/tests/integration-e2e/features/secured-api-invocation.feature b/tests/integration-e2e/features/secured-api-invocation.feature new file mode 100644 index 0000000000..448f9b74b5 --- /dev/null +++ b/tests/integration-e2e/features/secured-api-invocation.feature @@ -0,0 +1,20 @@ +Feature: A secured API published to platform-api is deployed and invoked through the gateway + As an API platform operator + I want a published API protected by an API key and subscription validation to be + invocable through the gateway data plane only with valid credentials + So that the control plane, subscription model and data plane work together end to end. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @secured + Scenario: A published, secured API is invocable through the gateway only with valid credentials + Given a subscription plan "e2e-gold" allowing 10000 requests per hour + And a published REST API secured with API key and subscription validation offering that plan + When I deploy the secured API to the gateway + Then an unauthenticated request to the secured API is rejected + When an application is subscribed to the API under that plan + And an API key is issued for the API + Then invoking the secured API through the gateway with valid credentials returns 200 + And invoking the secured API through the gateway without credentials is rejected diff --git a/tests/integration-e2e/init-db.sql b/tests/integration-e2e/init-db.sql index 7efbee063f..6d749553c7 100644 --- a/tests/integration-e2e/init-db.sql +++ b/tests/integration-e2e/init-db.sql @@ -5,6 +5,11 @@ CREATE DATABASE platform_api; CREATE DATABASE gateway_test; -- Second gateway-controller store for the multi-gateway scenario. CREATE DATABASE gateway_test2; +-- Developer portal store for the @devportal scenario. Postgres is used (not the +-- devportal's default SQLite) because the devportal's org-update path relies on +-- UPDATE ... RETURNING rows, which SQLite does not provide. Tables are +-- auto-created by the devportal on startup (sequelize.sync()). +CREATE DATABASE devportal; -- platform-api only auto-runs schema DDL for SQLite; against an external -- database it expects the schema to be pre-provisioned by the operator. Apply @@ -13,3 +18,8 @@ CREATE DATABASE gateway_test2; -- gateway_test / gateway_test2 schema.) \connect platform_api \i /schema/schema.postgres.sql + +-- Same for the developer portal database (it does not auto-create its schema on +-- an external postgres — its own postgres compose loads this dump at init too). +\connect devportal +\i /devportal-schema/schema.postgres.sql diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index e10b161b38..0290e2c5d5 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -19,4 +19,8 @@ region = "us" [[auth.file_based.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." +# NOTE: platform-api ignores file-based users from a mounted config (defaultConfig's +# admin wins in koanf's slice merge), so scopes here are not authoritative. The +# @devportal stack instead injects the admin (with dp:* scopes) via the +# AUTH_FILE_BASED_USERS env var, which does override the default. See suite_test.go. 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:api_key:read ap:secret:manage" diff --git a/tests/integration-e2e/steps_devportal_lifecycle_test.go b/tests/integration-e2e/steps_devportal_lifecycle_test.go new file mode 100644 index 0000000000..fff430fde1 --- /dev/null +++ b/tests/integration-e2e/steps_devportal_lifecycle_test.go @@ -0,0 +1,287 @@ +/* + * 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. + */ + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/cucumber/godog" +) + +// This file extends the @devportal scenario with credential-lifecycle changes made +// in the developer portal, each verified either at the gateway (invocation) or on +// the platform-api (control-plane) side, all via the signed-webhook propagation. +// The scenario runs them in two groups: +// +// API key lifecycle (the subscription stays ACTIVE throughout): +// - change expiry -> gateway rejects the (now-expired) key, then serves after restore +// - revoke -> gateway returns 401, then a new key is issued and serves again +// +// Subscription lifecycle (the API key stays valid throughout): +// - change plan -> verify the new plan via platform-api REST +// - regenerate token -> new token works at the gateway, old is rejected +// - pause (INACTIVE)/resume -> gateway rejects then serves +// - remove -> gateway returns 403 (terminal) +// +// Isolation: the gateway distinguishes a bad/revoked/expired key (401, api-key-auth) +// from a bad/absent/inactive subscription (403, subscription-validation). Grouping +// the checks — all key checks while the subscription is active, all subscription +// checks with a valid key — means each rejection's status code identifies the cause. +// +// Expiry note: platform-api exposes NO REST endpoint that returns a webhook-created +// key's expiry — /me/api-keys is filtered to the caller's own keys (webhook keys have +// no owner) and /applications/{appId}/api-keys requires a key→app mapping that neither +// the apikey.application_updated webhook nor the direct AddApplicationAPIKeys REST can +// establish (both fail to resolve the key). So the expiry change is verified at the +// gateway instead: setting a past expiry must make the data plane reject the key. + +const ( + keyExpiryPast = "2020-01-01T00:00:00Z" // past → the gateway must reject the key + keyExpiryFuture = "2035-01-01T00:00:00Z" // future → restores validity for later checks +) + +func (w *world) registerDevportalLifecycleSteps(sc *godog.ScenarioContext) { + sc.Step(`^a second subscription plan is synced to the developer portal$`, w.syncSecondPlan) + sc.Step(`^the API key is expired in the developer portal$`, w.expireKey) + sc.Step(`^invoking with the expired API key is rejected$`, w.invokeExpiredKeyRejected) + sc.Step(`^the API key expiry is restored in the developer portal$`, w.restoreKeyExpiry) + sc.Step(`^the subscription plan is changed in the developer portal$`, w.changeSubPlan) + sc.Step(`^platform-api reports the new subscription plan$`, w.verifySubPlan) + sc.Step(`^the subscription token is regenerated in the developer portal$`, w.regenerateSubToken) + sc.Step(`^invoking with the old subscription token is rejected$`, w.invokeOldTokenRejected) + sc.Step(`^the subscription is paused in the developer portal$`, w.pauseSubscription) + sc.Step(`^the subscription is resumed in the developer portal$`, w.resumeSubscription) + sc.Step(`^invoking the secured API through the gateway is rejected$`, w.invokeRejected) + sc.Step(`^the API key is revoked in the developer portal$`, w.revokeKey) + sc.Step(`^invoking with the revoked API key is unauthorized$`, w.invokeRevokedKeyUnauthorized) + sc.Step(`^a new API key is generated in the developer portal$`, w.generateKeyInDevportal) + sc.Step(`^the subscription is removed in the developer portal$`, w.removeSubscription) +} + +// --- 2. API key expiry -------------------------------------------------------- + +// regenerateKeyExpiry regenerates the key with a new expiry. Regenerate mints a new +// plaintext secret too, so the current key value is updated. Fires apikey.regenerated, +// which platform-api applies (new expiry + hash) and broadcasts to the gateway. +func (w *world) regenerateKeyExpiry(expiresAt string) error { + st, body, err := dpCall(http.MethodPost, "/apis/"+w.dpApiID+"/api-keys/regenerate", map[string]any{ + "keyId": w.dpKeyHandle, + "expiresAt": expiresAt, + }) + if err != nil { + return err + } + newKey := jsonField(body, "key") + if st >= 300 || newKey == "" { + return fmt.Errorf("regenerate devportal key failed (%d): %s", st, body) + } + w.apiKey = newKey // the previous secret is now invalid + return nil +} + +func (w *world) expireKey() error { return w.regenerateKeyExpiry(keyExpiryPast) } +func (w *world) restoreKeyExpiry() error { return w.regenerateKeyExpiry(keyExpiryFuture) } + +// invokeExpiredKeyRejected polls until the gateway rejects the expired key with 401. +// The subscription is still active, so the failure isolates the key's expiry. +func (w *world) invokeExpiredKeyRejected() error { + headers := map[string]string{apiKeyHeader: w.apiKey, subKeyHeader: w.subToken} + return waitIngressWithHeaders(ingressGw1, w.apiContext, headers, 401) +} + +// --- 3. subscription plan change --------------------------------------------- + +// syncSecondPlan creates a second plan in platform-api (ACTIVE) and mirrors it into +// the developer portal. It must run before the API is published so the API can offer +// it (publishAPIToDevportal includes plan2ID), which the change-plan below requires. +func (w *world) syncSecondPlan() error { + w.plan2ID = "e2e-silver-" + randHex() + // displayName is unique (= handle) to avoid the gateway's (gateway_id, plan_name) + // collision; verifySubPlan asserts against this same value. + if st, body, err := apiCall(http.MethodPost, "/subscription-plans", suite.token, map[string]any{ + "id": w.plan2ID, + "displayName": w.plan2ID, + "status": "ACTIVE", + "limits": []map[string]any{{"limitType": "REQUEST_COUNT", "timeUnit": "HOUR", "limitCount": 5000}}, + }); err != nil { + return err + } else if st >= 300 { + return fmt.Errorf("create second plan failed (%d): %s", st, body) + } + return syncDevportalPlan(w.plan2ID) // refId = platform-api plan handle +} + +// changeSubPlan switches the subscription to the second plan (already offered by the +// API). The devportal field is planId, not subscriptionPlanId. +func (w *world) changeSubPlan() error { + st, body, err := dpCall(http.MethodPost, "/subscriptions/"+w.dpSubID+"/change-plan", map[string]any{ + "planId": w.plan2ID, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("change devportal plan failed (%d): %s", st, body) + } + return nil +} + +// verifySubPlan polls platform-api until the subscription reports the new plan. +// The subscriptionPlanId in the response is the plan's UUID, so the plan is +// identified by its display name instead. +func (w *world) verifySubPlan() error { + deadline := time.Now().Add(pollTimeout) + var last string + for time.Now().Before(deadline) { + st, body, err := apiCall(http.MethodGet, "/subscriptions?apiId="+w.apiID, suite.token, nil) + if err == nil && st == http.StatusOK { + var env struct { + Subscriptions []map[string]any `json:"subscriptions"` + } + if json.Unmarshal(body, &env) == nil && len(env.Subscriptions) > 0 { + last = stringField(env.Subscriptions[0], "subscriptionPlanName") + if last == w.plan2ID { + return nil + } + } + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("platform-api did not report the new subscription plan %q (last observed %q)", w.plan2ID, last) +} + +// --- 6. subscription token regeneration -------------------------------------- + +func (w *world) regenerateSubToken() error { + w.prevSubToken = w.subToken + st, body, err := dpCall(http.MethodPost, "/subscriptions/"+w.dpSubID+"/regenerate-token", nil) + if err != nil { + return err + } + w.subToken = jsonField(body, "subscriptionToken") + if st >= 300 || w.subToken == "" || w.subToken == w.prevSubToken { + return fmt.Errorf("regenerate devportal token failed (%d): %s", st, body) + } + return nil +} + +func (w *world) invokeOldTokenRejected() error { + headers := map[string]string{apiKeyHeader: w.apiKey, subKeyHeader: w.prevSubToken} + // The new token must have propagated (asserted by the preceding 200 step), so the + // old token should now be rejected immediately. + if code := ingressStatusWithHeaders(ingressGw1, w.apiContext, headers); code != 401 && code != 403 { + return fmt.Errorf("old subscription token should be rejected, got %d", code) + } + return nil +} + +// --- 4. pause / resume subscription ------------------------------------------ + +func (w *world) pauseSubscription() error { return w.setSubStatus("INACTIVE") } +func (w *world) resumeSubscription() error { return w.setSubStatus("ACTIVE") } + +func (w *world) setSubStatus(status string) error { + st, body, err := dpCall(http.MethodPut, "/subscriptions/"+w.dpSubID, map[string]any{ + "status": status, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("set devportal subscription status %s failed (%d): %s", status, st, body) + } + return nil +} + +// invokeRejected polls until the gateway rejects the current credentials with 403 +// (subscription-level authorization failure — used for pause and delete). +func (w *world) invokeRejected() error { + headers := map[string]string{apiKeyHeader: w.apiKey, subKeyHeader: w.subToken} + return waitIngressWithHeaders(ingressGw1, w.apiContext, headers, 403) +} + +// --- 1. API key revocation --------------------------------------------------- + +func (w *world) revokeKey() error { + st, body, err := dpCall(http.MethodPost, "/apis/"+w.dpApiID+"/api-keys/revoke", map[string]any{ + "keyId": w.dpKeyHandle, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("revoke devportal key failed (%d): %s", st, body) + } + return nil +} + +// invokeRevokedKeyUnauthorized polls until the gateway rejects the revoked key with +// 401. The subscription is still active, so the failure isolates the key. +func (w *world) invokeRevokedKeyUnauthorized() error { + headers := map[string]string{apiKeyHeader: w.apiKey, subKeyHeader: w.subToken} + return waitIngressWithHeaders(ingressGw1, w.apiContext, headers, 401) +} + +// --- 5. subscription removal ------------------------------------------------- + +func (w *world) removeSubscription() error { + st, body, err := dpCall(http.MethodDelete, "/subscriptions/"+w.dpSubID, nil) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("delete devportal subscription failed (%d): %s", st, body) + } + return nil +} + +// --- helpers ----------------------------------------------------------------- + +// syncDevportalPlan upserts a devportal plan whose refId equals the platform-api +// plan handle (so subscription events carry a resolvable subscription_plan.ref_id). +func syncDevportalPlan(handle string) error { + st, body, err := dpCall(http.MethodPut, "/subscription-plans", []map[string]any{ + { + "id": handle, + "displayName": handle, + "refId": handle, + "limits": []map[string]any{ + {"limitType": "REQUEST_COUNT", "limitCount": 5000, "timeUnit": "HOUR", "timeAmount": 1}, + }, + }, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("sync devportal plan %q failed (%d): %s", handle, st, body) + } + return nil +} + +// stringField reads a string value from a decoded JSON object. +func stringField(m map[string]any, key string) string { + if s, ok := m[key].(string); ok { + return s + } + return "" +} diff --git a/tests/integration-e2e/steps_devportal_test.go b/tests/integration-e2e/steps_devportal_test.go new file mode 100644 index 0000000000..90e9c1475d --- /dev/null +++ b/tests/integration-e2e/steps_devportal_test.go @@ -0,0 +1,395 @@ +/* + * 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. + */ + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "time" + + "github.com/cucumber/godog" +) + +// This file adds the full-product-suite scenario: an API key and a subscription +// are created in the DEVELOPER PORTAL, which fires signed webhooks to +// platform-api; platform-api decrypts them, persists the credentials, and +// propagates them to the gateway over its control-plane WebSocket. The API is +// then invoked through the gateway using those developer-portal-issued +// credentials. +// +// Trust/transport model (verified from source): +// - The devportal signs each webhook "t=,v1=" over "." +// (X-Devportal-Signature) with the shared secret, and encrypts the key/token +// with the platform-api webhook RSA public key (RSA-OAEP-SHA256 + AES-256-GCM). +// - platform-api resolves the event's org by HANDLE (org.ref_id) and the API / +// plan by HANDLE (data.api.ref_id / data.subscription_plan.ref_id). So the +// devportal org's cpRefId must equal the platform-api org handle ("default"), +// the devportal API's referenceId must equal the platform-api API handle, and +// the devportal plan's refId must equal the platform-api plan handle. +// - Delivery is fire-once on a ~2s poll, and the plaintext key/token the portal +// returns to the user are exactly what the gateway validates. +// +// The devportal accepts the platform-api admin JWT directly (it verifies it with +// the shared DP_PLATFORMAPI_JWTSECRET and takes the org from the token's +// org_handle claim), so suite.token is reused for every call here. + +// webhookReceiverURL is the platform-api webhook receiver at its container-internal +// host (the devportal reaches platform-api by service name on the compose network, +// not via the host-published port). Path from webhookReceiverPath (a var, so this +// is a var too). +var webhookReceiverURL = "https://platform-api:9243" + webhookReceiverPath + +// The devportal org handle seeded via DP_DEFAULTORGNAME; must match the platform-api +// org handle so org.ref_id resolves. +const devportalOrgHandle = "default" + +func (w *world) registerDevportalSteps(sc *godog.ScenarioContext) { + sc.Step(`^the subscription plan is synced to the developer portal$`, w.syncPlanToDevportal) + sc.Step(`^the API is published to the developer portal linked to the platform API$`, w.publishAPIToDevportal) + sc.Step(`^an application subscribed to the API is created in the developer portal$`, w.subscribeInDevportal) + sc.Step(`^an API key is generated in the developer portal$`, w.generateKeyInDevportal) + // The invocation assertions are shared with the platform-api-driven scenario. + sc.Step(`^invoking the secured API through the gateway with the developer portal credentials returns 200$`, w.invokeWithCredentialsSucceeds) + sc.Step(`^invoking with the new subscription token returns 200$`, w.invokeWithCredentialsSucceeds) + + // Credential-lifecycle steps (revoke / expiry / plan / pause / delete / token regen). + w.registerDevportalLifecycleSteps(sc) +} + +// --- suite bootstrap (called from bringUpStack) ---------------------------- + +// bootstrapDevportal prepares the running developer portal so its webhooks are +// accepted by platform-api: it links the portal org to the control-plane org +// handle and registers the platform-api webhook subscriber. +func bootstrapDevportal() error { + if err := waitDevportalHealthy(); err != nil { + return err + } + if err := linkDevportalOrg(); err != nil { + return err + } + return registerWebhookSubscriber() +} + +func waitDevportalHealthy() error { + deadline := time.Now().Add(pollTimeout * 2) + var lastStatus int + var lastErr error + for time.Now().Before(deadline) { + st, _, err := dpCall(http.MethodGet, "/organizations", nil) + if err == nil && st == http.StatusOK { + return nil + } + lastStatus, lastErr = st, err + time.Sleep(2 * time.Second) + } + return fmt.Errorf("developer portal did not become healthy (last status %d, err %v)", lastStatus, lastErr) +} + +// linkDevportalOrg sets the portal org's cpRefId to the platform-api org handle +// so outbound events carry a resolvable org.ref_id. displayName/idpRefId are read +// back first so the update does not clobber them. +func linkDevportalOrg() error { + st, body, err := dpCall(http.MethodGet, "/organizations/"+devportalOrgHandle, nil) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("get devportal org failed (%d): %s", st, body) + } + displayName := jsonField(body, "displayName") + if displayName == "" { + displayName = "Default" + } + update := map[string]any{ + "id": devportalOrgHandle, + "displayName": displayName, + "cpRefId": devportalOrgHandle, // == platform-api org handle + } + if idp := jsonField(body, "idpRefId"); idp != "" { + update["idpRefId"] = idp + } + st, body, err = dpCall(http.MethodPut, "/organizations/"+devportalOrgHandle, update) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("link devportal org failed (%d): %s", st, body) + } + return nil +} + +// registerWebhookSubscriber points the portal at the platform-api receiver with +// the shared HMAC secret and the receiver's RSA public key. Idempotent: a repeat +// registration (E2E_KEEP reruns) that conflicts is treated as success. +func registerWebhookSubscriber() error { + pub, err := os.ReadFile(webhookPubKeySource) + if err != nil { + return fmt.Errorf("read webhook public key %s: %w", webhookPubKeySource, err) + } + st, body, err := dpCall(http.MethodPost, "/webhook-subscribers", map[string]any{ + "id": "platform-api", + "displayName": "Platform API", + "targetUrl": webhookReceiverURL, + "secret": webhookSecret, + "publicKey": string(pub), + "events": []string{"apikey.*", "subscription.*"}, + "enabled": true, + }) + if err != nil { + return err + } + if st == http.StatusConflict { + return nil // already registered on a kept stack + } + if st >= 300 { + return fmt.Errorf("register webhook subscriber failed (%d): %s", st, body) + } + return nil +} + +// --- scenario steps -------------------------------------------------------- + +// syncPlanToDevportal upserts a devportal plan whose refId equals the platform-api +// plan handle, so subscription.created carries a resolvable subscription_plan.ref_id. +func (w *world) syncPlanToDevportal() error { + if w.planID == "" { + return fmt.Errorf("no platform-api plan to sync") + } + st, body, err := dpCall(http.MethodPut, "/subscription-plans", []map[string]any{ + { + "id": w.planID, // devportal plan handle + "displayName": w.planID, + "refId": w.planID, // link to the platform-api plan handle + "limits": []map[string]any{ + {"limitType": "REQUEST_COUNT", "limitCount": 10000, "timeUnit": "HOUR", "timeAmount": 1}, + }, + }, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("sync devportal plan failed (%d): %s", st, body) + } + return nil +} + +// publishAPIToDevportal publishes the API into the portal with referenceId set to +// the platform-api API handle (lowercase `referenceId` — the parser ignores the +// `referenceID` spelling used by the shipped samples) so credential webhooks map +// back to the deployed API. +func (w *world) publishAPIToDevportal() error { + if w.apiID == "" { + return fmt.Errorf("no platform-api API to publish") + } + name := "dp-" + w.apiID // devportal API handle (metadata.name) + // Offer the second plan too when the lifecycle scenario has pre-created it, so a + // later change-plan (which only allows plans the API offers) succeeds. + plans := []string{w.planID} + if w.plan2ID != "" { + plans = append(plans, w.plan2ID) + } + ct, payload, err := devportalAPIMultipart(name, w.apiID, plans) + if err != nil { + return err + } + st, body, err := dpDo(http.MethodPost, "/apis", ct, payload) + if err != nil { + return err + } + w.dpApiID = jsonField(body, "id") + if st >= 300 || w.dpApiID == "" { + return fmt.Errorf("publish API to devportal failed (%d): %s", st, body) + } + return nil +} + +// devportalAPIMultipart builds the multipart body (api.yaml + a minimal OpenAPI +// definition) used to create or update a developer-portal API. referenceId is set +// to the platform-api API handle so credential webhooks map back to the deployed +// API, and subscriptionPlans lists the plan handles the API offers. +func devportalAPIMultipart(name, refID string, plans []string) (string, []byte, error) { + plansYAML := "" + for _, p := range plans { + plansYAML += "\n - " + p + } + apiYAML := fmt.Sprintf(`apiVersion: devportal.api-platform.wso2.com/v1alpha1 +kind: RestApi +metadata: + name: %s +spec: + type: REST + displayName: %s + version: v1 + status: PUBLISHED + referenceId: %s + subscriptionPlans:%s + endpoints: + sandboxUrl: http://sample-backend:9080 + productionUrl: http://sample-backend:9080 +`, name, name, refID, plansYAML) + + defYAML := fmt.Sprintf(`openapi: 3.0.1 +info: + title: %s + version: v1 +paths: + /: + get: + responses: + '200': + description: OK +`, name) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + if err := addYAMLPart(mw, "api", "api.yaml", apiYAML); err != nil { + return "", nil, err + } + if err := addYAMLPart(mw, "apiDefinition", "definition.yaml", defYAML); err != nil { + return "", nil, err + } + if err := mw.Close(); err != nil { + return "", nil, err + } + return mw.FormDataContentType(), buf.Bytes(), nil +} + +// subscribeInDevportal creates an application and subscribes it to the API under +// the synced plan. The returned subscriptionToken (plaintext) is the gateway +// Subscription-Key value; creating the subscription fires subscription.created. +func (w *world) subscribeInDevportal() error { + appName := "e2e-app-" + randHex() + st, body, err := dpCall(http.MethodPost, "/applications", map[string]any{ + "id": appName, + "displayName": appName, + "description": "e2e application", + }) + if err != nil { + return err + } + w.appID = jsonField(body, "id") + if st >= 300 || w.appID == "" { + return fmt.Errorf("create devportal application failed (%d): %s", st, body) + } + + st, body, err = dpCall(http.MethodPost, "/subscriptions", map[string]any{ + "apiId": w.dpApiID, // the devportal API handle, not the referenceId + "subscriptionPlanId": w.planID, + }) + if err != nil { + return err + } + w.subToken = jsonField(body, "subscriptionToken") + w.dpSubID = jsonField(body, "subscriptionId") + if st >= 300 || w.subToken == "" || w.dpSubID == "" { + return fmt.Errorf("create devportal subscription failed (%d): %s", st, body) + } + return nil +} + +// generateKeyInDevportal generates an API key in the portal; the one-time plaintext +// `key` is the gateway API-Key value, and generation fires apikey.generated. The +// key handle is captured for later lifecycle operations (revoke/regenerate/associate). +func (w *world) generateKeyInDevportal() error { + handle := "e2ekey-" + randHex() + st, body, err := dpCall(http.MethodPost, "/apis/"+w.dpApiID+"/api-keys/generate", map[string]any{ + "id": handle, + "displayName": "e2e key", + }) + if err != nil { + return err + } + w.apiKey = jsonField(body, "key") + w.dpKeyHandle = jsonField(body, "id") + if w.dpKeyHandle == "" { + w.dpKeyHandle = handle + } + if st >= 300 || w.apiKey == "" { + return fmt.Errorf("generate devportal API key failed (%d): %s", st, body) + } + return nil +} + +// --- devportal HTTP helpers ------------------------------------------------ + +// dpCall performs a JSON request against the developer portal with the admin +// bearer token. +func dpCall(method, path string, body any) (int, []byte, error) { + var payload []byte + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, nil, err + } + payload = b + } + return dpDo(method, path, "application/json", payload) +} + +// dpDo performs a request against the developer portal with the admin bearer token +// and an explicit content type (used for both JSON and multipart bodies). path is +// the resource path relative to devportalBase (e.g. "/apis"), which is prepended. +func dpDo(method, path, contentType string, body []byte) (int, []byte, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, devportalAPI+devportalBase+path, rdr) + if err != nil { + return 0, nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + // Bearer auth with the platform-api admin JWT: the devportal verifies it with + // the shared secret, takes the org from its org_handle claim, and (unlike + // API-key mode) resolves a user identity — required to persist created_by on + // applications/subscriptions/keys. + if suite.token != "" { + req.Header.Set("Authorization", "Bearer "+suite.token) + } + resp, err := httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, out, nil +} + +// addYAMLPart writes a named YAML file part into a multipart writer. +func addYAMLPart(mw *multipart.Writer, field, filename, content string) error { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename)) + h.Set("Content-Type", "application/yaml") + part, err := mw.CreatePart(h) + if err != nil { + return err + } + _, err = part.Write([]byte(content)) + return err +} diff --git a/tests/integration-e2e/steps_secured_test.go b/tests/integration-e2e/steps_secured_test.go new file mode 100644 index 0000000000..dc0ff5c870 --- /dev/null +++ b/tests/integration-e2e/steps_secured_test.go @@ -0,0 +1,279 @@ +/* + * 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. + */ + +package e2e + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// This file adds the "secured API" scenario: a PUBLISHED REST API protected by +// the api-key-auth and subscription-validation policies is deployed to the +// gateway and then invoked through the data plane, proving that the full chain +// (publish -> deploy -> subscription plan -> application -> subscription -> +// API key -> authenticated invocation) works end to end against the real +// platform-api and the real gateway. +// +// Credential value flow (verified from source): +// - api-key-auth validates the plaintext value of the `API-Key` header by +// hashing it and matching the hash stored for the API. The caller CHOOSES +// the plaintext when creating the key (POST /rest-apis/{id}/api-keys with +// {displayName, apiKey}); platform-api stores only the hash, so the test +// keeps the plaintext to present at the ingress. +// - subscription-validation validates the raw `Subscription-Key` header +// against the subscriptionToken minted by POST /subscriptions. +// +// Ordering rule (verified): the API must be DEPLOYED to the gateway before the +// subscription / API key are created, because platform-api only broadcasts the +// subscription.created / apikey.created events to gateways where the artifact is +// already deployed (and POST .../api-keys returns 503 when no gateway is +// connected). Unlike deployments, these events are pushed live over the +// control-plane WebSocket and applied immediately, so NO controller restart is +// needed after them — we just poll the ingress until they propagate. + +const ( + apiKeyHeader = "API-Key" // api-key-auth policy default header + subKeyHeader = "Subscription-Key" // subscription-validation policy default header +) + +func (w *world) registerSecuredSteps(sc *godog.ScenarioContext) { + sc.Step(`^a subscription plan "([^"]*)" allowing (\d+) requests per (minute|hour|day|month)$`, w.aSubscriptionPlan) + sc.Step(`^a published REST API secured with API key and subscription validation offering that plan$`, w.aSecuredRestAPI) + sc.Step(`^I deploy the secured API to the gateway$`, w.deployToGateway) + sc.Step(`^an unauthenticated request to the secured API is rejected$`, w.unauthenticatedRequestRejected) + sc.Step(`^an application is subscribed to the API under that plan$`, w.applicationSubscribed) + sc.Step(`^an API key is issued for the API$`, w.apiKeyIssued) + sc.Step(`^invoking the secured API through the gateway with valid credentials returns 200$`, w.invokeWithCredentialsSucceeds) + sc.Step(`^invoking the secured API through the gateway without credentials is rejected$`, w.invokeWithoutCredentialsRejected) +} + +// --- Given ----------------------------------------------------------------- + +// aSubscriptionPlan creates an ACTIVE, org-scoped subscription plan. The handle +// is made unique per run so a kept stack (E2E_KEEP=1) can be re-run without a +// 409; the quoted name in the feature is used as the display name. +func (w *world) aSubscriptionPlan(name string, count int, unit string) error { + handle := strings.ToLower(name) + "-" + randHex() + // displayName must be unique too: the gateway stores plans keyed by + // (gateway_id, plan_name) where plan_name is this displayName, so two plans with + // the same display name on one gateway collide (and the later one — with its + // subscription — silently fails to sync). Use the unique handle as the name. + st, body, err := apiCall(http.MethodPost, "/subscription-plans", suite.token, map[string]any{ + "id": handle, + "displayName": handle, + "status": "ACTIVE", + "limits": []map[string]any{ + {"limitType": "REQUEST_COUNT", "timeUnit": strings.ToUpper(unit), "limitCount": count}, + }, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("create subscription plan failed (%d): %s", st, body) + } + // The response echoes the plan handle as "id". + if id := jsonField(body, "id"); id != "" { + handle = id + } + w.planID = handle + return nil +} + +// aSecuredRestAPI creates a PUBLISHED REST API that offers the plan created +// above and is guarded by api-key-auth + subscription-validation. Publishing is +// done inline via lifeCycleStatus (there is no separate lifecycle endpoint). +func (w *world) aSecuredRestAPI() error { + if w.planID == "" { + return fmt.Errorf("subscription plan was not created before the secured API") + } + suffix := randHex() + w.apiContext = "/e2e-sec-" + suffix + st, body, err := apiCall(http.MethodPost, "/rest-apis", suite.token, map[string]any{ + "displayName": "e2e-secured-" + suffix, + "context": w.apiContext, + "version": "v1", + "projectId": suite.projectID, + "lifeCycleStatus": "PUBLISHED", + "subscriptionPlans": []string{w.planID}, + "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, + "policies": []map[string]any{ + {"name": "api-key-auth", "version": "v1", "params": map[string]any{"key": apiKeyHeader, "in": "header"}}, + {"name": "subscription-validation", "version": "v1", "params": map[string]any{"subscriptionKeyHeader": subKeyHeader}}, + }, + }) + if err != nil { + return err + } + w.apiID = jsonField(body, "id", "handle", "uuid") + if st >= 300 || w.apiID == "" { + return fmt.Errorf("create secured API failed (%d): %s", st, body) + } + // Keep the plaintext key for the ingress call; platform-api stores only its hash. + w.apiKey = "e2e" + randHex() + randHex() + randHex() + randHex() + return nil +} + +// --- Then / When: readiness, subscription, key, invocation ----------------- + +// unauthenticatedRequestRejected doubles as the deployment-readiness gate: it +// waits until the ingress stops returning 404 (route not yet programmed) and +// starts returning 401/403 (route active and enforcing the auth policies). +func (w *world) unauthenticatedRequestRejected() error { + return waitIngressRejected(ingressGw1, w.apiContext) +} + +func (w *world) applicationSubscribed() error { + appID, err := createApplication("e2e-app-"+randHex(), suite.projectID) + if err != nil { + return err + } + w.appID = appID + token, err := createSubscription(w.apiID, w.appID, w.planID) + if err != nil { + return err + } + w.subToken = token + return nil +} + +func (w *world) apiKeyIssued() error { + return createAPIKey(w.apiID, w.apiKey) +} + +func (w *world) invokeWithCredentialsSucceeds() error { + headers := map[string]string{apiKeyHeader: w.apiKey, subKeyHeader: w.subToken} + return waitIngressWithHeaders(ingressGw1, w.apiContext, headers, 200) +} + +func (w *world) invokeWithoutCredentialsRejected() error { + if code := ingressStatusWithHeaders(ingressGw1, w.apiContext, nil); code != 401 && code != 403 { + return fmt.Errorf("unauthenticated request should be rejected (401/403), got %d", code) + } + return nil +} + +// --- platform-api REST helpers --------------------------------------------- + +func createApplication(name, projectID string) (string, error) { + st, body, err := apiCall(http.MethodPost, "/applications", suite.token, map[string]any{ + "id": name, + "displayName": name, + "projectId": projectID, + "type": "genai", // the only ApplicationType value + }) + if err != nil { + return "", err + } + id := jsonField(body, "id", "handle", "uuid") + if st >= 300 || id == "" { + return "", fmt.Errorf("create application failed (%d): %s", st, body) + } + return id, nil +} + +func createSubscription(apiID, appID, planID string) (string, error) { + st, body, err := apiCall(http.MethodPost, "/subscriptions", suite.token, map[string]any{ + "apiId": apiID, + "kind": "RestApi", + "subscriberId": "e2e-subscriber", + "applicationId": appID, + "subscriptionPlanId": planID, + }) + if err != nil { + return "", err + } + // The raw token is returned only on creation; it is the Subscription-Key value. + token := jsonField(body, "subscriptionToken") + if st >= 300 || token == "" { + return "", fmt.Errorf("create subscription failed (%d): %s", st, body) + } + return token, nil +} + +// createAPIKey supplies the plaintext key value to platform-api, which hashes +// and broadcasts it to the gateways where the API is deployed. The response +// carries only status/keyId, never the secret, so the caller must retain the +// value it supplied. +func createAPIKey(apiID, keyValue string) error { + st, body, err := apiCall(http.MethodPost, "/rest-apis/"+apiID+"/api-keys", suite.token, map[string]any{ + "displayName": "e2e-key", + "apiKey": keyValue, + }) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("create API key failed (%d): %s", st, body) + } + return nil +} + +// --- ingress helpers ------------------------------------------------------- + +// ingressStatusWithHeaders is ingressStatus with optional request headers (the +// API-Key / Subscription-Key credentials). +func ingressStatusWithHeaders(base, context string, headers map[string]string) int { + req, err := http.NewRequest(http.MethodGet, base+context+"/", nil) + if err != nil { + return -1 + } + req.Host = ingressHost // gateway routes by vhost + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := httpClient.Do(req) + if err != nil { + return 0 + } + resp.Body.Close() + return resp.StatusCode +} + +// waitIngressWithHeaders polls the ingress with the given headers until it +// returns want (credentials propagate to the data plane asynchronously). +func waitIngressWithHeaders(base, context string, headers map[string]string, want int) error { + deadline := time.Now().Add(pollTimeout) + var last int + for time.Now().Before(deadline) { + if last = ingressStatusWithHeaders(base, context, headers); last == want { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("ingress %s%s with credentials: wanted %d, last observed %d", base, context, want, last) +} + +// waitIngressRejected polls until the route is active and enforcing auth, i.e. +// it returns 401 or 403 (not 404, which means the route is not programmed yet). +func waitIngressRejected(base, context string) error { + deadline := time.Now().Add(pollTimeout) + var last int + for time.Now().Before(deadline) { + if last = ingressStatus(base, context); last == 401 || last == 403 { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("ingress %s%s: wanted 401/403 (route active, unauthenticated), last observed %d", base, context, last) +} diff --git a/tests/integration-e2e/steps_test.go b/tests/integration-e2e/steps_test.go index 177122762b..9c9477944f 100644 --- a/tests/integration-e2e/steps_test.go +++ b/tests/integration-e2e/steps_test.go @@ -35,6 +35,22 @@ type world struct { apiContext string // e.g. /e2e-ab12cd34 depGw1 string depGw2 string + + // Secured-API scenario state (see steps_secured_test.go). + planID string // subscription plan handle offered by the secured API + appID string // application handle subscribed to the API + subToken string // subscription token (Subscription-Key value) + apiKey string // plaintext API key (API-Key value) + + // Developer-portal scenario state (see steps_devportal_test.go). In that flow + // subToken/apiKey above are the credentials issued by the portal. + dpApiID string // the API's handle inside the developer portal + + // Developer-portal credential-lifecycle state (see steps_devportal_lifecycle_test.go). + dpSubID string // the subscription id (UUID) in the developer portal + dpKeyHandle string // the API key's handle (its `id`) in the developer portal + prevSubToken string // a superseded subscription token, kept for negative checks + plan2ID string // a second subscription plan handle (for the plan-change check) } // initializeScenario is invoked by godog for each scenario; it binds a fresh @@ -58,6 +74,13 @@ func initializeScenario(sc *godog.ScenarioContext) { sc.Step(`^the second gateway serves the API$`, w.secondGatewayServes) sc.Step(`^I undeploy the API from the second gateway$`, w.undeployFromSecondGateway) sc.Step(`^the second gateway stops serving the API$`, w.secondGatewayStopsServing) + + // Secured-API scenario (API key + subscription validation), defined in + // steps_secured_test.go so the deploy/ingress helpers above can be reused. + w.registerSecuredSteps(sc) + + // Developer-portal webhook scenario, defined in steps_devportal_test.go. + w.registerDevportalSteps(sc) } // --- Background steps ------------------------------------------------------ @@ -82,12 +105,12 @@ func (w *world) aRestAPI() error { // Name/displayName must be URL-friendly (no slash); the context is the path. suffix := randHex() w.apiContext = "/e2e-" + suffix - st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis", suite.token, map[string]any{ + st, body, err := apiCall(http.MethodPost, "/rest-apis", suite.token, map[string]any{ "displayName": "e2e-api-" + suffix, - "context": w.apiContext, - "version": "v1", - "projectId": suite.projectID, - "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, + "context": w.apiContext, + "version": "v1", + "projectId": suite.projectID, + "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, }) if err != nil { return err @@ -117,13 +140,13 @@ func (w *world) deployedAndServed() error { // re-runs that one-time sync, which is the data-plane equivalent of "the // controller noticed the new deployment". Returns the deployment id. func deploy(apiID, gatewayID, controllerService string) (string, error) { - if st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis/"+apiID+"/gateways", suite.token, + if st, body, err := apiCall(http.MethodPost, "/rest-apis/"+apiID+"/gateways", suite.token, []map[string]string{{"gatewayId": gatewayID}}); err != nil { return "", err } else if st >= 300 { return "", fmt.Errorf("attach gateway failed (%d): %s", st, body) } - st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis/"+apiID+"/deployments", suite.token, + st, body, err := apiCall(http.MethodPost, "/rest-apis/"+apiID+"/deployments", suite.token, map[string]any{"base": "current", "gatewayId": gatewayID, "name": "dep-" + randHex()}) if err != nil { return "", err @@ -140,7 +163,7 @@ func deploy(apiID, gatewayID, controllerService string) (string, error) { func undeploy(apiID, deploymentID, gatewayID string) error { st, body, err := apiCall(http.MethodPost, - "/api/v0.9/rest-apis/"+apiID+"/deployments/"+deploymentID+"/undeploy?gatewayId="+gatewayID, suite.token, nil) + "/rest-apis/"+apiID+"/deployments/"+deploymentID+"/undeploy?gatewayId="+gatewayID, suite.token, nil) if err != nil { return err } @@ -173,9 +196,9 @@ func (w *world) undeployFromSecondGateway() error { return undeploy(w.apiID, w.d // --- Then (data-plane assertions) ------------------------------------------ -func (w *world) gatewayServes() error { return waitIngress(ingressGw1, w.apiContext, 200) } -func (w *world) gatewayStopsServing() error { return waitIngress(ingressGw1, w.apiContext, 404) } -func (w *world) secondGatewayServes() error { return waitIngress(ingressGw2, w.apiContext, 200) } +func (w *world) gatewayServes() error { return waitIngress(ingressGw1, w.apiContext, 200) } +func (w *world) gatewayStopsServing() error { return waitIngress(ingressGw1, w.apiContext, 404) } +func (w *world) secondGatewayServes() error { return waitIngress(ingressGw2, w.apiContext, 200) } func (w *world) secondGatewayStopsServing() error { return waitIngress(ingressGw2, w.apiContext, 404) } diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go index 58dae83b17..20d9d5267f 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -31,6 +31,8 @@ import ( "net/url" "os" "os/exec" + "path/filepath" + "strings" "testing" "time" @@ -40,16 +42,55 @@ import ( const ( composeProject = "apip-e2e-bdd" ingressHost = "localhost" - pollTimeout = 90 * time.Second + // Ingress/readiness poll budget. Generous headroom so the full multi-scenario + // run (postgres + two gateways + devportal, with controller restarts) tolerates + // slower Envoy config propagation under load on constrained hosts. + pollTimeout = 120 * time.Second + + // Webhook trust material shared with the developer portal. The secret matches + // WEBHOOK_SECRET in docker-compose.yaml; the key pair is the committed dev + // fixture (the .pub is registered on the devportal subscriber, the .pem is + // mounted into platform-api — verified to be a matching RSA pair). + webhookSecret = "5bd108b058ac9b318faf771c82a3f88bf6d3be5cc51c221e7ee213dabdbdee22" + webhookKeySource = "../../portals/developer-portal/devportal-webhook.pem" + webhookPubKeySource = "../../portals/developer-portal/devportal-webhook.pub" + + // Admin user injected via AUTH_FILE_BASED_USERS on the @devportal stack. It + // carries both the platform-api ap:* scopes and the dp:*_manage scopes the + // developer portal requires, so the same admin JWT authorizes both products. + // (A mounted config's users are ignored — the built-in default admin wins — + // but the AUTH_FILE_BASED_USERS env var does override it.) + fileBasedAdminUsers = `[{"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:api_key:read ap:secret:manage dp:org_manage dp:api_manage dp:sub_plan_manage dp:app_manage dp:subscription_manage dp:api_key_manage dp:webhook_subscriber_manage"}]` ) // Host-side endpoints. Ports are overridable so the suite can run alongside // other local stacks; container-internal wiring (controller -> platform-api:9243) // is unaffected. Defaults match the compose files and CI. var ( - platformAPI = "https://localhost:" + envOr("PA_HOST_PORT", "9243") - ingressGw1 = "http://localhost:" + envOr("GW_HTTP_PORT", "18080") - ingressGw2 = "http://localhost:" + envOr("GW2_HTTP_PORT", "18081") + platformAPI = "https://localhost:" + envOr("PA_HOST_PORT", "9243") + ingressGw1 = "http://localhost:" + envOr("GW_HTTP_PORT", "18080") + ingressGw2 = "http://localhost:" + envOr("GW2_HTTP_PORT", "18081") + devportalAPI = "http://localhost:" + envOr("DP_HOST_PORT", "3000") +) + +// REST API base paths, defined in one place so each product's API version/prefix can +// be reconfigured centrally — and independently, since platform-api and the developer +// portal version on separate release cadences and may diverge. Overridable via env +// (PA_API_BASE / DP_API_BASE) so a version bump needs no code change. platformAPIBase +// is prepended by the apiCall helper; devportalBase by dpDo — so their callers name +// only the resource path (e.g. "/rest-apis"). +var ( + platformAPIBase = envOr("PA_API_BASE", "/api/v0.9") + devportalBase = envOr("DP_API_BASE", "/api/v0.9") +) + +// Additional platform-api path prefixes, distinct from the resource API version above +// (they carry their own version segment). Overridable for the same forward-compat reason. +var ( + portalAuthPath = envOr("PA_PORTAL_BASE", "/api/portal/v0.9") // username/password login + // webhookReceiverPath is the platform-api webhook receiver, addressed by the + // devportal at the container-internal host (see webhookReceiverURL). + webhookReceiverPath = envOr("PA_WEBHOOK_BASE", "/api/internal/v0.9") + "/webhook/events" ) // suite holds state established once for the whole run (BeforeSuite): the chosen @@ -58,6 +99,7 @@ var suite struct { db string // postgres | sqlite | sqlserver composeFile string multi bool // second gateway available (postgres stack only) + devportal bool // developer portal + webhook wired (postgres stack only) token string projectID string gw1ID string @@ -73,7 +115,9 @@ var httpClient = &http.Client{ func TestFeatures(t *testing.T) { tags := os.Getenv("E2E_TAGS") if tags == "" && os.Getenv("E2E_DB") != "" && os.Getenv("E2E_DB") != "postgres" { - tags = "~@multigateway" // second gateway is only wired on the postgres stack + // The second gateway and the developer portal are only wired on the + // postgres stack, so their scenarios are skipped elsewhere. + tags = "~@multigateway && ~@devportal" } status := godog.TestSuite{ Name: "platform-api-gateway-e2e", @@ -113,7 +157,12 @@ func bringUpStack() error { suite.db = envOr("E2E_DB", "postgres") switch suite.db { case "postgres": + // The postgres stack is the one wired with the second gateway and the + // developer portal (+ webhook), so @multigateway and @devportal run here. + // The devportal service needs its own image, so only bring it up when the + // @devportal scenario is actually selected (a tag subset may exclude it). suite.composeFile, suite.multi = "docker-compose.yaml", true + suite.devportal = devportalSelected() case "sqlite": suite.composeFile = "docker-compose.sqlite.yaml" case "sqlserver": @@ -123,6 +172,20 @@ func bringUpStack() error { } fmt.Printf("E2E database backend: %s (%s)\n", suite.db, suite.composeFile) + // The postgres platform-api has the webhook receiver enabled and mounts the + // private key at startup (phase 1), so make a container-readable copy and + // export PA_WEBHOOK_KEY before bringing it up — regardless of whether the + // @devportal scenario runs this time. + if suite.db == "postgres" { + if err := prepareWebhookKey(); err != nil { + return err + } + // Give the admin JWT the dp:* scopes the developer portal enforces. + if err := os.Setenv("AUTH_FILE_BASED_USERS", fileBasedAdminUsers); err != nil { + return err + } + } + // Phase 1: control plane + backend. phase1 := []string{"platform-api", "sample-backend"} if suite.db != "sqlite" { @@ -156,11 +219,23 @@ func bringUpStack() error { env["GATEWAY_REGISTRATION_TOKEN_2"] = gw2Token dataPlane = append(dataPlane, "gateway-controller-2", "gateway-runtime-2") } + if suite.devportal { + dataPlane = append(dataPlane, "devportal") + } - // Phase 2: data plane with the minted tokens. + // Phase 2: data plane (+ devportal) with the minted tokens. if err := compose(env, append([]string{"up", "-d"}, dataPlane...)...); err != nil { return fmt.Errorf("start data plane: %w", err) } + + // Bootstrap the developer portal so it can fire webhooks that platform-api + // accepts: link its org to the control-plane org handle and register the + // platform-api webhook subscriber. + if suite.devportal { + if err := bootstrapDevportal(); err != nil { + return fmt.Errorf("bootstrap devportal: %w", err) + } + } return nil } @@ -208,13 +283,15 @@ func waitHealthy() error { // --- platform-api REST helpers -------------------------------------------- +// apiCall issues a JSON request to the platform-api resource API. path is the +// resource path relative to platformAPIBase (e.g. "/rest-apis"), which is prepended. func apiCall(method, path, token string, body any) (int, []byte, error) { var rdr io.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } - req, err := http.NewRequest(method, platformAPI+path, rdr) + req, err := http.NewRequest(method, platformAPI+platformAPIBase+path, rdr) if err != nil { return 0, nil, err } @@ -233,7 +310,7 @@ func apiCall(method, path, token string, body any) (int, []byte, error) { func login() (string, error) { form := url.Values{"username": {"admin"}, "password": {"admin"}} - req, err := http.NewRequest(http.MethodPost, platformAPI+"/api/portal/v0.9/auth/login", + req, err := http.NewRequest(http.MethodPost, platformAPI+portalAuthPath+"/auth/login", bytes.NewBufferString(form.Encode())) if err != nil { return "", err @@ -256,7 +333,7 @@ func login() (string, error) { } func createProject() (string, error) { - st, body, err := apiCall(http.MethodPost, "/api/v0.9/projects", suite.token, + st, body, err := apiCall(http.MethodPost, "/projects", suite.token, map[string]string{"id": "e2e-proj", "displayName": "e2e-proj", "description": "e2e"}) if err != nil { return "", err @@ -269,7 +346,7 @@ func createProject() (string, error) { } func createGatewayAndToken(name string) (gatewayID, token string, err error) { - st, body, err := apiCall(http.MethodPost, "/api/v0.9/gateways", suite.token, map[string]any{ + st, body, err := apiCall(http.MethodPost, "/gateways", suite.token, map[string]any{ "id": name, "displayName": name, "endpoints": []string{"http://" + ingressHost}, "functionalityType": "regular", }) if err != nil { @@ -279,7 +356,7 @@ func createGatewayAndToken(name string) (gatewayID, token string, err error) { if st >= 300 || gatewayID == "" { return "", "", fmt.Errorf("create gateway failed (%d): %s", st, body) } - st, body, err = apiCall(http.MethodPost, "/api/v0.9/gateways/"+gatewayID+"/tokens", suite.token, map[string]any{}) + st, body, err = apiCall(http.MethodPost, "/gateways/"+gatewayID+"/tokens", suite.token, map[string]any{}) if err != nil { return "", "", err } @@ -290,6 +367,43 @@ func createGatewayAndToken(name string) (gatewayID, token string, err error) { return gatewayID, token, nil } +// devportalSelected reports whether the @devportal scenario will run, so the +// devportal service (which needs its own image) is only brought up when needed. +// A default run (no E2E_TAGS) includes it; an explicit tag subset includes it +// only when it selects @devportal and does not negate it. +func devportalSelected() bool { + tags := os.Getenv("E2E_TAGS") + if tags == "" { + return true + } + return strings.Contains(tags, "@devportal") && !strings.Contains(tags, "~@devportal") +} + +// prepareWebhookKey copies the repo's devportal webhook private key (mode 0600, +// owned by the host user) to a world-readable copy and points PA_WEBHOOK_KEY at +// it, so the platform-api container (uid 10001) can read the mounted key. The +// key is a non-secret dev fixture, so a 0644 copy is fine. +// +// The copy is written into the working directory (the compose-file dir, under +// the user's home), NOT os.TempDir(): the container runtime (e.g. colima) only +// shares the home tree into its VM, so a /tmp source would fail to bind-mount +// (docker would create an empty directory at the target instead). +func prepareWebhookKey() error { + data, err := os.ReadFile(webhookKeySource) + if err != nil { + return fmt.Errorf("read webhook private key %s: %w", webhookKeySource, err) + } + cwd, err := os.Getwd() + if err != nil { + return err + } + dst := filepath.Join(cwd, ".webhook-key.it.pem") + if err := os.WriteFile(dst, data, 0o644); err != nil { + return fmt.Errorf("write webhook private key copy: %w", err) + } + return os.Setenv("PA_WEBHOOK_KEY", dst) +} + // --- small helpers --------------------------------------------------------- func envOr(k, def string) string { From 0ee98fb3a663856ab44295e588bf7a2fc7aa591f Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 15:22:38 +0530 Subject: [PATCH 2/9] Add .gitignore for devportal webhook private key --- tests/integration-e2e/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/integration-e2e/.gitignore diff --git a/tests/integration-e2e/.gitignore b/tests/integration-e2e/.gitignore new file mode 100644 index 0000000000..d6ea856f3e --- /dev/null +++ b/tests/integration-e2e/.gitignore @@ -0,0 +1,3 @@ +# World-readable copy of the devportal webhook private key, generated by the +# suite (prepareWebhookKey) so the platform-api container can read the mount. +.webhook-key.it.pem From 72d9033b1714f5fce5d24d6be675ac7fd27bc318 Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 15:28:46 +0530 Subject: [PATCH 3/9] Update E2E test configuration to exclude devportal scenarios for PostgreSQL --- .github/workflows/platform-api-gateway-e2e.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/platform-api-gateway-e2e.yml b/.github/workflows/platform-api-gateway-e2e.yml index a3ccee9f64..003c1f2fbd 100644 --- a/.github/workflows/platform-api-gateway-e2e.yml +++ b/.github/workflows/platform-api-gateway-e2e.yml @@ -53,5 +53,13 @@ jobs: working-directory: samples/sample-service - name: Run combined e2e (${{ matrix.db }}) - run: E2E_DB=${{ matrix.db }} go test -run TestFeatures -count=1 -v -timeout 25m ./... + # The developer-portal scenarios (@devportal / @lifecycle) need the devportal + # image, which only platform-api-devportal-e2e.yml builds — exclude them here. + # On the postgres stack the suite would otherwise pull them in by default; on + # the other databases they are auto-skipped, but setting E2E_TAGS bypasses that + # auto-skip, so the postgres-only @multigateway scenario must be excluded too. + env: + E2E_DB: ${{ matrix.db }} + E2E_TAGS: ${{ matrix.db == 'postgres' && '~@devportal' || '~@multigateway && ~@devportal' }} + run: go test -run TestFeatures -count=1 -v -timeout 25m ./... working-directory: tests/integration-e2e From 52ac7db33a7a09e23bebbdae5f009c8f9527c575 Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 16:04:26 +0530 Subject: [PATCH 4/9] Refactor webhook key management in E2E tests to generate RSA keys dynamically --- .../workflows/platform-api-devportal-e2e.yml | 4 +- tests/integration-e2e/README.md | 17 ++++--- tests/integration-e2e/docker-compose.yaml | 12 +++-- tests/integration-e2e/steps_devportal_test.go | 13 +++-- tests/integration-e2e/suite_test.go | 49 ++++++++++++++----- 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/.github/workflows/platform-api-devportal-e2e.yml b/.github/workflows/platform-api-devportal-e2e.yml index 4f5bd7283f..ff127d8ec6 100644 --- a/.github/workflows/platform-api-devportal-e2e.yml +++ b/.github/workflows/platform-api-devportal-e2e.yml @@ -10,9 +10,7 @@ name: Platform API + Gateway + Developer Portal E2E # The developer-portal scenarios are postgres-only (they need the shared postgres # server for the devportal store + webhook round-trip), so this runs a single # postgres job. It builds the developer-portal image on top of the platform-api, -# gateway and sample-service images, so it is heavier than the combined e2e and is -# intentionally kept off the per-PR critical path — it runs on demand and when the -# e2e itself changes. +# gateway and sample-service images. on: workflow_dispatch: pull_request: diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index a84dc32994..19b6c973c8 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -148,17 +148,18 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. - Auth: the devportal accepts the platform-api admin JWT directly (shared `DP_PLATFORMAPI_JWTSECRET`, org from the token's `org_handle` claim). The admin must carry `dp:*` scopes, which platform-api's built-in admin lacks — - so the suite injects an admin (ap:* **and** dp:*) via the + so the suite injects an admin (ap:* *and* dp:*) via the `AUTH_FILE_BASED_USERS` env var (a mounted config's users are ignored; only that env override wins). Bearer auth (not API-key mode) is used because the write paths need a resolved user for `created_by`. - - `BeforeSuite` links the portal org (`cpRefId = "default"`, the platform-api - org handle) and registers a webhook subscriber pointing at - `…/api/internal/v0.9/webhook/events` with the shared HMAC secret and the RSA - **public** key `devportal-webhook.pub`. platform-api decrypts with the paired - `devportal-webhook.pem` (mounted in; the suite copies it to a 0644 file under - the compose dir — the container runs as uid 10001 and the source is 0600, and - `/tmp` isn't shared into the container VM). + - `BeforeSuite` generates a fresh RSA key pair per run (`prepareWebhookKey`), + links the portal org (`cpRefId = "default"`, the platform-api org handle), and + registers a webhook subscriber pointing at `…/api/internal/v0.9/webhook/events` + with the shared HMAC secret and the generated **public** key. platform-api + decrypts with the matching private key, which is written 0644 under the compose + dir and mounted in via `PA_WEBHOOK_KEY` (the container runs as uid 10001, and + `/tmp` isn't shared into the container VM). The key is generated rather than read + from the repo because the private key is gitignored (and absent in CI). - The delivery worker POSTs over raw https with the default agent, so the devportal container sets `NODE_TLS_REJECT_UNAUTHORIZED=0` to accept platform-api's self-signed cert. diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index ea4078dcbd..6e71b783aa 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -71,17 +71,19 @@ services: # Developer Portal webhook receiver (only the postgres stack runs the devportal). # Enabled via env so the sqlite/sqlserver stacks — which have no devportal and no # mounted key — leave it off. The private key must be readable by the container - # user (uid 10001); the suite copies the repo key to a 0644 temp and points - # PA_WEBHOOK_KEY at it (see prepareWebhookKey in suite_test.go). + # user (uid 10001); the suite generates a fresh RSA pair, writes the 0644 private + # key and points PA_WEBHOOK_KEY at it (see prepareWebhookKey in suite_test.go). - WEBHOOK_ENABLED=true - WEBHOOK_SECRET=5bd108b058ac9b318faf771c82a3f88bf6d3be5cc51c221e7ee213dabdbdee22 - WEBHOOK_PRIVATE_KEY_PATH=/etc/platform-api/devportal-webhook.pem - WEBHOOK_GATEWAY_TYPE=wso2/api-platform volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # RSA private key paired with portals/developer-portal/devportal-webhook.pub; - # platform-api decrypts the webhook's encrypted key/token fields with it. - - ${PA_WEBHOOK_KEY:-../../portals/developer-portal/devportal-webhook.pem}:/etc/platform-api/devportal-webhook.pem:ro + # RSA private key that platform-api decrypts the webhook's encrypted key/token + # fields with; its public half is registered on the devportal subscriber. The + # suite always sets PA_WEBHOOK_KEY (the generated key); the default is only a + # fallback for manual `docker compose up` after a suite run has generated it. + - ${PA_WEBHOOK_KEY:-./.webhook-key.it.pem}:/etc/platform-api/devportal-webhook.pem:ro ports: - "${PA_HOST_PORT:-9243}:9243" depends_on: diff --git a/tests/integration-e2e/steps_devportal_test.go b/tests/integration-e2e/steps_devportal_test.go index 90e9c1475d..67f2226aba 100644 --- a/tests/integration-e2e/steps_devportal_test.go +++ b/tests/integration-e2e/steps_devportal_test.go @@ -26,7 +26,6 @@ import ( "mime/multipart" "net/http" "net/textproto" - "os" "time" "github.com/cucumber/godog" @@ -142,19 +141,19 @@ func linkDevportalOrg() error { } // registerWebhookSubscriber points the portal at the platform-api receiver with -// the shared HMAC secret and the receiver's RSA public key. Idempotent: a repeat -// registration (E2E_KEEP reruns) that conflicts is treated as success. +// the shared HMAC secret and the run's generated RSA public key (see +// prepareWebhookKey). Idempotent: a repeat registration (E2E_KEEP reruns) that +// conflicts is treated as success. func registerWebhookSubscriber() error { - pub, err := os.ReadFile(webhookPubKeySource) - if err != nil { - return fmt.Errorf("read webhook public key %s: %w", webhookPubKeySource, err) + if webhookPublicKeyPEM == "" { + return fmt.Errorf("webhook public key not generated (prepareWebhookKey must run first)") } st, body, err := dpCall(http.MethodPost, "/webhook-subscribers", map[string]any{ "id": "platform-api", "displayName": "Platform API", "targetUrl": webhookReceiverURL, "secret": webhookSecret, - "publicKey": string(pub), + "publicKey": webhookPublicKeyPEM, "events": []string{"apikey.*", "subscription.*"}, "enabled": true, }) diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go index 20d9d5267f..baf372c265 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -23,8 +23,12 @@ package e2e import ( "bytes" + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" "io" "net/http" @@ -47,13 +51,11 @@ const ( // slower Envoy config propagation under load on constrained hosts. pollTimeout = 120 * time.Second - // Webhook trust material shared with the developer portal. The secret matches - // WEBHOOK_SECRET in docker-compose.yaml; the key pair is the committed dev - // fixture (the .pub is registered on the devportal subscriber, the .pem is - // mounted into platform-api — verified to be a matching RSA pair). - webhookSecret = "5bd108b058ac9b318faf771c82a3f88bf6d3be5cc51c221e7ee213dabdbdee22" - webhookKeySource = "../../portals/developer-portal/devportal-webhook.pem" - webhookPubKeySource = "../../portals/developer-portal/devportal-webhook.pub" + // Webhook HMAC secret shared with the developer portal (matches WEBHOOK_SECRET in + // docker-compose.yaml). The RSA key pair used for the encrypted key/token fields + // is generated fresh per run by prepareWebhookKey (not read from the repo — the + // private key is intentionally gitignored and absent in CI). + webhookSecret = "5bd108b058ac9b318faf771c82a3f88bf6d3be5cc51c221e7ee213dabdbdee22" // Admin user injected via AUTH_FILE_BASED_USERS on the @devportal stack. It // carries both the platform-api ap:* scopes and the dp:*_manage scopes the @@ -388,19 +390,44 @@ func devportalSelected() bool { // the user's home), NOT os.TempDir(): the container runtime (e.g. colima) only // shares the home tree into its VM, so a /tmp source would fail to bind-mount // (docker would create an empty directory at the target instead). +// webhookPublicKeyPEM is the SPKI PEM of the RSA key pair generated by +// prepareWebhookKey; registerWebhookSubscriber gives it to the developer portal so it +// can encrypt key/token fields that platform-api decrypts with the matching private key. +var webhookPublicKeyPEM string + +// prepareWebhookKey generates the RSA key pair used for the devportal↔platform-api +// hybrid encryption of webhook secret fields, writes the private key where the +// platform-api container mounts it (PA_WEBHOOK_KEY), and stores the public key for the +// subscriber registration. The pair is generated per run rather than read from the +// repo, since the private key is gitignored (and absent in CI); any matched pair works. +// +// The private key is written under the compose directory (not os.TempDir) because the +// container runtime only shares the working tree into its VM, and 0644 so the +// container user (uid 10001) can read the read-only mount. func prepareWebhookKey() error { - data, err := os.ReadFile(webhookKeySource) + key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return fmt.Errorf("read webhook private key %s: %w", webhookKeySource, err) + return fmt.Errorf("generate webhook key pair: %w", err) } + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) cwd, err := os.Getwd() if err != nil { return err } dst := filepath.Join(cwd, ".webhook-key.it.pem") - if err := os.WriteFile(dst, data, 0o644); err != nil { - return fmt.Errorf("write webhook private key copy: %w", err) + if err := os.WriteFile(dst, privPEM, 0o644); err != nil { + return fmt.Errorf("write webhook private key: %w", err) + } + + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return fmt.Errorf("marshal webhook public key: %w", err) } + webhookPublicKeyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) + return os.Setenv("PA_WEBHOOK_KEY", dst) } From 7578fb4a5ca43a3b5eebde6994cae52990c0bd0c Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 16:13:07 +0530 Subject: [PATCH 5/9] Remove redundant comments in devportal service description in docker-compose.yaml --- tests/integration-e2e/docker-compose.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 6e71b783aa..bc40e610dc 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -202,9 +202,8 @@ services: # Developer Portal — the consumer-facing plane. Credentials (subscriptions, # API keys) generated here are fired as signed webhooks to platform-api's # receiver (…/api/internal/v0.9/webhook/events), which persists them and - # propagates to the gateway data plane over its existing WebSocket sync. Uses - # its own self-contained SQLite store (auto-migrated on boot) so it needs no - # database wiring. Started on demand by the godog suite (postgres stack only). + # propagates to the gateway data plane over its existing WebSocket sync. + # Started on demand by the godog suite (postgres stack only). devportal: image: ${DEVPORTAL_IMAGE:-developer-portal:it-e2e} depends_on: From d74b2c5e388079578e7d5b137bb5264541d17680 Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 18:53:31 +0530 Subject: [PATCH 6/9] Update subscription token encryption key in Docker Compose files to ensure it meets length requirements --- tests/integration-e2e/docker-compose.sqlite.yaml | 4 +++- tests/integration-e2e/docker-compose.sqlserver.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 98ea5d2dc4..46f596f8e3 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -9,7 +9,9 @@ services: - DATABASE_DRIVER=sqlite3 - DATABASE_PATH=/app/data/platform.db - DATABASE_EXECUTE_SCHEMA_DDL=true - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Must be 32 bytes (64 hex chars); the subscription-token encryption path + # (exercised by the @secured subscription-create handler) rejects shorter keys. + - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 772854ca49..87149e7e0c 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -52,7 +52,9 @@ services: - DATABASE_USER=sa - DATABASE_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd} - DATABASE_SSL_MODE=disable - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Must be 32 bytes (64 hex chars); the subscription-token encryption path + # (exercised by the @secured subscription-create handler) rejects shorter keys. + - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 From 340dd158a00a409cc9a220b83bed9afd763052aa Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Tue, 7 Jul 2026 10:05:14 +0530 Subject: [PATCH 7/9] Add docs path to ignore list to skip these tests --- .github/workflows/platform-api-devportal-e2e.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/platform-api-devportal-e2e.yml b/.github/workflows/platform-api-devportal-e2e.yml index ff127d8ec6..3dbe500be2 100644 --- a/.github/workflows/platform-api-devportal-e2e.yml +++ b/.github/workflows/platform-api-devportal-e2e.yml @@ -16,6 +16,9 @@ on: pull_request: branches: - main + paths-ignore: + - '**/*.md' + - 'docs/**' permissions: contents: read From 7e74fdda392dad1143969df675e7ceb80c958d80 Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Tue, 7 Jul 2026 10:05:28 +0530 Subject: [PATCH 8/9] Fix review comments --- tests/integration-e2e/steps_secured_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration-e2e/steps_secured_test.go b/tests/integration-e2e/steps_secured_test.go index dc0ff5c870..f133c180b7 100644 --- a/tests/integration-e2e/steps_secured_test.go +++ b/tests/integration-e2e/steps_secured_test.go @@ -125,7 +125,7 @@ func (w *world) aSecuredRestAPI() error { if err != nil { return err } - w.apiID = jsonField(body, "id", "handle", "uuid") + w.apiID = jsonField(body, "id") if st >= 300 || w.apiID == "" { return fmt.Errorf("create secured API failed (%d): %s", st, body) } From 0c24b1a355d6cf32741e9b1a05a70fcdb3fa8da6 Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Tue, 7 Jul 2026 12:27:52 +0530 Subject: [PATCH 9/9] Address review comments --- tests/integration-e2e/features/devportal-webhook.feature | 7 ++++--- tests/integration-e2e/steps_devportal_lifecycle_test.go | 9 +++++++-- tests/integration-e2e/steps_devportal_test.go | 1 - 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/integration-e2e/features/devportal-webhook.feature b/tests/integration-e2e/features/devportal-webhook.feature index 7bb5ef3bb4..117333abc2 100644 --- a/tests/integration-e2e/features/devportal-webhook.feature +++ b/tests/integration-e2e/features/devportal-webhook.feature @@ -46,9 +46,10 @@ Feature: Credentials issued in the developer portal authorize gateway invocation Then invoking the secured API through the gateway with the developer portal credentials returns 200 # --- Subscription lifecycle (the API key stays valid throughout, so a rejection = 403 isolates the subscription) --- # Change the subscription plan, verify from the platform-api side. - When the subscription plan is changed in the developer portal - Then platform-api reports the new subscription plan - # Regenerate the subscription token; the new token works and the old one is rejected. + When the applied subscription plan of the API is switched in the developer portal + Then platform-api receives the new subscription plan update of the API + # Regenerate the subscription token; the current token works, then the new token works and the old one is rejected. + Then invoking with the current subscription token returns 200 When the subscription token is regenerated in the developer portal Then invoking with the new subscription token returns 200 And invoking with the old subscription token is rejected diff --git a/tests/integration-e2e/steps_devportal_lifecycle_test.go b/tests/integration-e2e/steps_devportal_lifecycle_test.go index fff430fde1..50d95f2071 100644 --- a/tests/integration-e2e/steps_devportal_lifecycle_test.go +++ b/tests/integration-e2e/steps_devportal_lifecycle_test.go @@ -64,9 +64,14 @@ func (w *world) registerDevportalLifecycleSteps(sc *godog.ScenarioContext) { sc.Step(`^the API key is expired in the developer portal$`, w.expireKey) sc.Step(`^invoking with the expired API key is rejected$`, w.invokeExpiredKeyRejected) sc.Step(`^the API key expiry is restored in the developer portal$`, w.restoreKeyExpiry) - sc.Step(`^the subscription plan is changed in the developer portal$`, w.changeSubPlan) - sc.Step(`^platform-api reports the new subscription plan$`, w.verifySubPlan) + sc.Step(`^the applied subscription plan of the API is switched in the developer portal$`, w.changeSubPlan) + sc.Step(`^platform-api receives the new subscription plan update of the API$`, w.verifySubPlan) + // The token-regeneration triple: the current token works, then after regen the new + // token works and the old one is rejected. The two 200-checks reuse the shared + // invokeWithCredentialsSucceeds (invokes with the current w.apiKey + w.subToken). + sc.Step(`^invoking with the current subscription token returns 200$`, w.invokeWithCredentialsSucceeds) sc.Step(`^the subscription token is regenerated in the developer portal$`, w.regenerateSubToken) + sc.Step(`^invoking with the new subscription token returns 200$`, w.invokeWithCredentialsSucceeds) sc.Step(`^invoking with the old subscription token is rejected$`, w.invokeOldTokenRejected) sc.Step(`^the subscription is paused in the developer portal$`, w.pauseSubscription) sc.Step(`^the subscription is resumed in the developer portal$`, w.resumeSubscription) diff --git a/tests/integration-e2e/steps_devportal_test.go b/tests/integration-e2e/steps_devportal_test.go index 67f2226aba..9b369d52cf 100644 --- a/tests/integration-e2e/steps_devportal_test.go +++ b/tests/integration-e2e/steps_devportal_test.go @@ -71,7 +71,6 @@ func (w *world) registerDevportalSteps(sc *godog.ScenarioContext) { sc.Step(`^an API key is generated in the developer portal$`, w.generateKeyInDevportal) // The invocation assertions are shared with the platform-api-driven scenario. sc.Step(`^invoking the secured API through the gateway with the developer portal credentials returns 200$`, w.invokeWithCredentialsSucceeds) - sc.Step(`^invoking with the new subscription token returns 200$`, w.invokeWithCredentialsSucceeds) // Credential-lifecycle steps (revoke / expiry / plan / pause / delete / token regen). w.registerDevportalLifecycleSteps(sc)