diff --git a/portals/developer-portal/.dockerignore b/portals/developer-portal/.dockerignore index 612801a2d2..05ee1c0e0b 100644 --- a/portals/developer-portal/.dockerignore +++ b/portals/developer-portal/.dockerignore @@ -2,6 +2,13 @@ # node_modules is installed fresh inside the container by npm ci node_modules +# ── Local dev SQLite databases — must never be baked into the image. A stale +# copy here is root-owned (COPY runs before the chown to appuser) and silently +# shadows the real runtime DB at the configured database.file path, causing +# every write (sessions, seeding, queues) to fail as "readonly database". +*.db +data/ + # ── Config / secrets (mounted at runtime via volume) ────────────── config.toml configs/ diff --git a/portals/developer-portal/it/.gitignore b/portals/developer-portal/it/.gitignore index 5e79d4142a..399208f1de 100644 --- a/portals/developer-portal/it/.gitignore +++ b/portals/developer-portal/it/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.certs/ reports/videos/ reports/screenshots/ reports/*.json diff --git a/portals/developer-portal/it/Makefile b/portals/developer-portal/it/Makefile index 215f4f02cd..171cc89321 100644 --- a/portals/developer-portal/it/Makefile +++ b/portals/developer-portal/it/Makefile @@ -16,17 +16,39 @@ # under the License. # -------------------------------------------------------------------- -.PHONY: all test test-postgres test-rest-api test-rest-api-postgres open clean deps ensure-test-tag +.PHONY: all test test-postgres test-rest-api test-rest-api-postgres open clean deps ensure-test-tag ensure-certs VERSION ?= $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.1-SNAPSHOT") DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform IMAGE_NAME := developer-portal +CERT_DIR := .certs # Auto-detect host CPU so Cypress runs natively (no QEMU). # arm64 = Apple Silicon → pull linux/arm64 Cypress image. # Everything else (CI, Intel) defaults to linux/amd64. CYPRESS_PLATFORM ?= $(shell [ "$$(uname -m)" = "arm64" ] && echo "linux/arm64" || echo "linux/amd64") +# One-time, idempotent TLS cert generation for platform-api's HTTPS listener — +# mirrors ../scripts/setup.sh's openssl step, scoped to this test fixture and run +# automatically as a `make test`/`test-postgres`/`test-rest-api*` prerequisite +# (unlike setup.sh, which a person runs by hand before `docker compose up`) so CI +# and a fresh checkout need no manual pre-step. Bind-mounted read-only into +# platform-api (docker-compose.test*.yaml) instead of the old certgen init +# container + named volume. 644, not 600, so the container's non-root user can +# read a file owned by the host user (same tradeoff as setup.sh's CERT_FILE_MODE). +ensure-certs: + @mkdir -p $(CERT_DIR) + @if [ -f $(CERT_DIR)/cert.pem ] && [ -f $(CERT_DIR)/key.pem ]; then \ + echo "Using existing $(CERT_DIR)/{cert,key}.pem"; \ + else \ + echo "Generating platform-api TLS certificate at $(CERT_DIR) ..."; \ + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout $(CERT_DIR)/key.pem -out $(CERT_DIR)/cert.pem \ + -subj "/O=WSO2 API Platform/CN=platform-api" \ + -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" 2>/dev/null; \ + chmod 644 $(CERT_DIR)/key.pem $(CERT_DIR)/cert.pem; \ + fi + # Tag the pre-built developer-portal image as :test so docker-compose.test.yaml can # reference a stable :test tag regardless of whether the local build is :VERSION. ensure-test-tag: @@ -50,7 +72,7 @@ ensure-test-tag: # docker inspect`: `ps -q` lists only RUNNING containers, so once the service has # exited it returns nothing and the code is silently lost (the target then passes # even when tests fail). Keep the capture in the same shell as `up`. -test: ensure-test-tag +test: ensure-test-tag ensure-certs @CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.yaml up devportal cypress --abort-on-container-exit --exit-code-from cypress; \ EXIT=$$?; \ docker compose -f docker-compose.test.yaml down -v --remove-orphans; \ @@ -58,7 +80,7 @@ test: ensure-test-tag # Run Cypress tests headlessly against PostgreSQL. # Requires the developer-portal image to be built first: make build (from portals/developer-portal/). -test-postgres: ensure-test-tag +test-postgres: ensure-test-tag ensure-certs @CYPRESS_PLATFORM=$(CYPRESS_PLATFORM) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.postgres.yaml up postgres devportal cypress --abort-on-container-exit --exit-code-from cypress; \ EXIT=$$?; \ docker compose -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ @@ -66,14 +88,14 @@ test-postgres: ensure-test-tag # Run the REST API integration test suite (Jest + Supertest) against SQLite. # Requires the developer-portal image to be built first: make build (from portals/developer-portal/). -test-rest-api: ensure-test-tag +test-rest-api: ensure-test-tag ensure-certs @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.yaml up devportal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ docker compose -f docker-compose.test.yaml down -v --remove-orphans; \ exit $$EXIT # Run the REST API integration test suite (Jest + Supertest) against PostgreSQL. -test-rest-api-postgres: ensure-test-tag +test-rest-api-postgres: ensure-test-tag ensure-certs @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -f docker-compose.test.postgres.yaml up postgres devportal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ docker compose -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index f4bc27bfb6..4f31606b0d 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -41,25 +41,8 @@ services: networks: - it-devportal-network - # One-shot init container: generates the TLS pair the platform-api HTTPS - # listener requires (the server no longer generates a self-signed fallback). - platform-api-certgen: - image: alpine/openssl - entrypoint: ["/bin/sh", "-c"] - command: - - | - if [ ! -f /certs/cert.pem ] || [ ! -f /certs/key.pem ]; then - openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ - -keyout /certs/key.pem -out /certs/cert.pem \ - -subj "/O=WSO2 API Platform/CN=platform-api" \ - -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" - fi - chown 10001:10001 /certs/cert.pem /certs/key.pem - volumes: - - platform-api-certs:/certs - platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.12.0 command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -67,10 +50,10 @@ services: volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - - platform-api-certs:/app/data/certs - depends_on: - platform-api-certgen: - condition: service_completed_successfully + # TLS pair generated once, host-side, by `make ensure-certs` (mirrors + # ../scripts/setup.sh's approach for the production compose) — read-only, + # 644-mode so the container's non-root user can read a host-owned file. + - ./.certs:/app/data/certs:ro healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 5s @@ -168,5 +151,4 @@ networks: driver: bridge volumes: - platform-api-certs: platform-api-data: diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 7c2120415b..d3f5bc5891 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -25,25 +25,8 @@ # The devportal service uses the pre-built image tagged :test (set by make ensure-test-tag). services: - # One-shot init container: generates the TLS pair the platform-api HTTPS - # listener requires (the server no longer generates a self-signed fallback). - platform-api-certgen: - image: alpine/openssl - entrypoint: ["/bin/sh", "-c"] - command: - - | - if [ ! -f /certs/cert.pem ] || [ ! -f /certs/key.pem ]; then - openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ - -keyout /certs/key.pem -out /certs/cert.pem \ - -subj "/O=WSO2 API Platform/CN=platform-api" \ - -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" - fi - chown 10001:10001 /certs/cert.pem /certs/key.pem - volumes: - - platform-api-certs:/certs - platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.12.0 command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -51,10 +34,10 @@ services: volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - - platform-api-certs:/app/data/certs - depends_on: - platform-api-certgen: - condition: service_completed_successfully + # TLS pair generated once, host-side, by `make ensure-certs` (mirrors + # ../scripts/setup.sh's approach for the production compose) — read-only, + # 644-mode so the container's non-root user can read a host-owned file. + - ./.certs:/app/data/certs:ro healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 5s @@ -154,6 +137,5 @@ networks: driver: bridge volumes: - platform-api-certs: sqlite-data: platform-api-data: diff --git a/portals/developer-portal/it/rest-api/jest.config.js b/portals/developer-portal/it/rest-api/jest.config.js index 83448b8130..027923a336 100644 --- a/portals/developer-portal/it/rest-api/jest.config.js +++ b/portals/developer-portal/it/rest-api/jest.config.js @@ -24,7 +24,20 @@ module.exports = { // Registers a per-suite afterAll that deletes resources tracked via // support/cleanup.js, so specs don't accumulate objects in the shared org. setupFilesAfterEnv: ['/support/autoCleanup.js'], - testTimeout: 10000, + testTimeout: 20000, + // The devportal-under-test runs SQLite through a single Sequelize connection + // (pool: { max: 1 } — see src/db/sequelizeConfig.js, correct for a single-writer + // SQLite file) shared by every model AND the session store. Because + // better-sqlite3 is synchronous, that one connection processes every DB op — + // logins' session writes, fixture CRUD from every spec file, the webhook + // dispatcher/delivery-worker polling — strictly one at a time regardless of + // Jest's worker count, so parallel workers add queuing/coordination risk + // without any real throughput gain. Under load, queued ops can exceed the + // test timeout and surface as spurious "Login failed" redirects + // (authController.js's session.regenerate/logIn failure branches), not real + // auth bugs — this was flaky even at maxWorkers: 2 depending on host load. + // Fully serial removes the contention risk entirely. + maxWorkers: 1, reporters: [ 'default', ['jest-junit', { outputDirectory: 'reports', outputName: 'rest-api-results.xml' }], diff --git a/portals/developer-portal/it/test-config.toml b/portals/developer-portal/it/test-config.toml index 24fbf7496d..565e0002e6 100644 --- a/portals/developer-portal/it/test-config.toml +++ b/portals/developer-portal/it/test-config.toml @@ -4,6 +4,17 @@ # at /app/configs/config.toml in both. Settings that are identical across both DB # variants live here; settings that differ (database connection, network hostnames) # stay as APIP_DP_* env vars in each compose file's environment: block. +# +# There is no automatic APIP_DP_* environment-variable override (see +# src/config/configLoader.js / configs/config.toml) — an env var set in either +# compose file's environment: block only takes effect where a key below +# explicitly references it via a {{ env "..." "default" }} token. Every key +# that either compose file overrides MUST have a matching token here, or the +# override is silently ignored and the built-in default +# (src/config/configDefaults.js) is used instead. + +[server] +base_url = '{{ env "APIP_DP_SERVER_BASEURL" "http://devportal:3000" }}' [tls] # Use plain HTTP inside the test network so Cypress doesn't need to trust a cert. @@ -12,6 +23,19 @@ enabled = false [logging] console_only = true +[database] +# type/file cover docker-compose.test.yaml (SQLite); host/port/username/password/name +# cover docker-compose.test.postgres.yaml (PostgreSQL) — each compose file only sets +# the env vars relevant to its own variant, so the other side's tokens just resolve +# to these defaults, unused. +type = '{{ env "APIP_DP_DATABASE_TYPE" "sqlite" }}' +file = '{{ env "APIP_DP_DATABASE_FILE" "./data/devportal-it.db" }}' +host = '{{ env "APIP_DP_DATABASE_HOST" "localhost" }}' +port = '{{ env "APIP_DP_DATABASE_PORT" "5432" }}' +username = '{{ env "APIP_DP_DATABASE_USERNAME" "postgres" }}' +password = '{{ env "APIP_DP_DATABASE_PASSWORD" "" }}' +name = '{{ env "APIP_DP_DATABASE_NAME" "devportal" }}' + [security] # Required — devportal fails closed at startup without these. encryption_key = '{{ env "APIP_DP_SECURITY_ENCRYPTIONKEY" }}' diff --git a/portals/developer-portal/it/ui/cypress/support/e2e.js b/portals/developer-portal/it/ui/cypress/support/e2e.js index 4f8b854af1..79c1b45d3b 100644 --- a/portals/developer-portal/it/ui/cypress/support/e2e.js +++ b/portals/developer-portal/it/ui/cypress/support/e2e.js @@ -26,7 +26,12 @@ before(() => { const apiKey = Cypress.env('API_KEY'); cy.request({ method: 'GET', - url: '/organizations', + // Must include the API base path — the devportal API router (see + // src/routes/api/devportalApiRouter.js) only recognizes requests whose + // first path segment matches the OpenAPI spec's server basePath ('api'). + // A bare '/organizations' falls through to the page-rendering route + // tree instead, which misinterprets "organizations" as an org handle. + url: '/api/v0.9/organizations', headers: apiKey ? { 'x-wso2-api-key': apiKey } : {}, failOnStatusCode: false, }).then((resp) => { diff --git a/portals/developer-portal/src/dao/viewDao.js b/portals/developer-portal/src/dao/viewDao.js index 11706e9a3f..d4a25f6c46 100644 --- a/portals/developer-portal/src/dao/viewDao.js +++ b/portals/developer-portal/src/dao/viewDao.js @@ -18,6 +18,7 @@ const View = require('../models/view'); const ViewLabels = require('../models/viewLabel'); const Labels = require('../models/label'); +const { OrgContent } = require('../models/organization'); const { Sequelize } = require('sequelize'); const constants = require('../utils/constants'); const { CustomError } = require('../utils/errors/customErrors'); @@ -76,14 +77,34 @@ const update = async (orgId, handle, displayName, updatedBy, t) => { } } -const deleteView = async (orgId, handle) => { +const deleteView = async (orgId, handle, t) => { try { + const view = await View.findOne({ + where: { + handle: handle, + org_uuid: orgId + }, + transaction: t + }); + if (!view) { + return 0; + } + // Neither dependent's view_uuid FK actually cascades at the SQLite level — + // dp_view_label_mappings (models/viewLabel.js) has no onDelete at all, and + // dp_organization_assets (models/organization.js's OrgContent) only sets + // onDelete: 'CASCADE' on the Sequelize association, which doesn't translate + // into the generated SQLite constraint. Destroying a view with either still + // attached fails with a FOREIGN KEY constraint error — detach both first, + // same pattern organizationDao's whole-org delete already uses for OrgContent. + await ViewLabels.destroy({ where: { view_uuid: view.dataValues.uuid }, transaction: t }); + await OrgContent.destroy({ where: { view_uuid: view.dataValues.uuid }, transaction: t }); const viewResponse = await View.destroy({ where: { handle: handle, org_uuid: orgId - } + }, + transaction: t }); return viewResponse; } catch (error) { diff --git a/portals/developer-portal/src/services/apiMetadataService.js b/portals/developer-portal/src/services/apiMetadataService.js index 7715f5935a..9265ec063f 100644 --- a/portals/developer-portal/src/services/apiMetadataService.js +++ b/portals/developer-portal/src/services/apiMetadataService.js @@ -23,6 +23,7 @@ const subDao = require('../dao/subscriptionDao'); const labelDao = require('../dao/labelDao'); const tagDao = require('../dao/tagDao'); const viewDao = require('../dao/viewDao'); +const apiWorkflowDao = require('../dao/apiWorkflowDao'); const subscriptionPlanDao = require('../dao/subscriptionPlanDao'); const apiFileDao = require('../dao/apiFileDao'); const apiKeyDao = require("../dao/apiKeyDao"); @@ -1704,6 +1705,10 @@ const deleteView = async (req, res) => { throw new CustomError(400, constants.ERROR_CODE[400], "The default view cannot be deleted"); } const viewUuid = await viewDao.getId(orgId, name); + const workflows = await apiWorkflowDao.list(orgId, viewUuid); + if (workflows.length > 0) { + throw new CustomError(409, constants.ERROR_MESSAGE.ERR_WORKFLOW_EXIST, "View has API workflows."); + } const viewDelete = await viewDao.delete(orgId, name); if (viewDelete === 0) { throw new Sequelize.EmptyResultError("Resource not found to delete"); diff --git a/portals/developer-portal/src/utils/constants.js b/portals/developer-portal/src/utils/constants.js index a82f242b47..2146ec0488 100644 --- a/portals/developer-portal/src/utils/constants.js +++ b/portals/developer-portal/src/utils/constants.js @@ -330,6 +330,7 @@ module.exports = { WEBHOOK_SUBSCRIBER_NOT_FOUND: "Webhook subscriber not found", ERR_SUB_EXIST: "ERR_SUB_EXIST", ERR_KEY_EXIST: "ERR_KEY_EXIST", + ERR_WORKFLOW_EXIST: "ERR_WORKFLOW_EXIST", UNAUTHORIZED_ORG: "You are not authorized to access this organization", UNAUTHORIZED_API: "You are not authorized to access this API", API_NOT_FOUND: "Requested API not found",