Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/platform-api-devportal-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.
on:
workflow_dispatch:
pull_request:
branches:
- main
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we ignore docs path


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
10 changes: 9 additions & 1 deletion .github/workflows/platform-api-gateway-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions tests/integration-e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -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
121 changes: 115 additions & 6 deletions tests/integration-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -93,6 +110,98 @@ 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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `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.
- Webhooks are signed `t=<unix>,v1=<hmac>` over `"<t>.<body>"` 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

Expand Down
4 changes: 3 additions & 1 deletion tests/integration-e2e/docker-compose.sqlite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/integration-e2e/docker-compose.sqlserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading