From 13d521feada39f3b424ff8a4377f9e9040f27e8e Mon Sep 17 00:00:00 2001 From: Dinith Herath Date: Mon, 6 Jul 2026 15:18:57 +0530 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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