From 215cbb37aa346dbb4de872292a6efcd7f90b33b9 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 27 Jul 2026 09:20:40 +0530 Subject: [PATCH 1/8] Add authentication e2e tests --- .../e2e/001-basic/001-portal-access.cy.js | 89 +++++++++ .../cypress/e2e/auth/file-based-login.cy.js | 37 +++- .../it/ui/cypress/support/commands.js | 4 + .../it/ui/cypress/support/commands/auth.js | 20 +- .../it/ui/cypress/support/commands/seed.js | 184 ++++++++++++++++++ .../pages/login-page/partials/signin-card.hbs | 2 +- .../developer-portal/src/scripts/signin.js | 22 +-- 7 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 portals/developer-portal/it/ui/cypress/support/commands/seed.js diff --git a/portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js b/portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js index fb08f13706..6e736f999e 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js @@ -17,6 +17,25 @@ // -------------------------------------------------------------------- describe('Developer Portal — Portal Access', () => { + // Prereq: the default org + view are auto-seeded by the devportal on startup + // (seederService.seedDefaultOrg), but the API/MCP listings are empty until + // something is deployed — so we seed one PUBLISHED REST API and one MCP + // server into the default view via the management API, and tear them down + // afterward. The REST API's definition declares an apiKey security scheme so + // its detail page renders the auth-gated "API Keys" action used below. + let apiHandle; + let mcpHandle; + + before(() => { + cy.seedApi().then((handle) => { apiHandle = handle; }); + cy.seedMcp().then((handle) => { mcpHandle = handle; }); + }); + + after(() => { + cy.deleteApi(apiHandle); + cy.deleteMcp(mcpHandle); + }); + beforeEach(() => { cy.on('uncaught:exception', () => false); }); @@ -25,4 +44,74 @@ describe('Developer Portal — Portal Access', () => { cy.visitPortal(); cy.get('.hero').should('be.visible'); }); + + // ----------------------------------------------------------------------- + // Basic browsing as an anonymous (logged-out) visitor. The APIs / MCP + // listings and the API detail page are public — no login required. + // ----------------------------------------------------------------------- + context('basic browsing (logged out)', () => { + it('browses the APIs listing and opens an API detail page', () => { + cy.visitPortal('/apis'); + // At least the seeded API is present in the default view. + cy.get('.api-card').should('have.length.at.least', 1); + + // Click into an API — the card's [data-href] region drives navigation. + cy.get('.api-card').first().find('.api-card-top').click(); + + // The API detail page loaded. + cy.url().should('include', '/api/'); + cy.get('.aov-hero-name').should('be.visible'); + }); + + it('browses the MCP servers listing', () => { + cy.visitPortal('/mcps'); + cy.get('.apilist-results-heading').should('contain', 'MCP Servers'); + cy.get('.api-card').should('have.length.at.least', 1); + }); + + it('opens the API Workflows page', () => { + cy.visitPortal('/api-workflows'); + cy.get('body').should('be.visible'); + cy.get('body').should('not.contain.text', 'Cannot GET'); + cy.get('body').should('not.contain.text', '500'); + }); + }); + + // ----------------------------------------------------------------------- + // Auth-gated pages redirect an anonymous visitor to the login page and, + // after a successful login, send them back to the page they asked for + // (server-side `returnTo`). + // ----------------------------------------------------------------------- + context('auth redirection (logged out)', () => { + it('redirects Applications to login and returns there after signing in', () => { + cy.visitPortal(); + + // Applications is auth-gated → clicking it bounces to the login page. + cy.get('#sidebar #applications').click(); + cy.url().should('include', '/login'); + cy.get('#local-login-form').should('be.visible'); + + // After logging in, land back on the originally requested page. + cy.completeLoginForm(); + cy.url().should('include', '/applications'); + cy.get('#local-login-form').should('not.exist'); + cy.contains('.page-title', 'Applications').should('be.visible'); + }); + + it('redirects an API\'s API Keys page to login and returns there after signing in', () => { + // Open the seeded API's detail page (public). + cy.visitPortal(`/api/${apiHandle}`); + cy.get('.aov-hero-name').should('be.visible'); + + // The API Keys action is auth-gated → clicking it bounces to login. + cy.get('.aov-hero-actions a[href$="/api-keys"]').click(); + cy.url().should('include', '/login'); + cy.get('#local-login-form').should('be.visible'); + + // After logging in, land back on that API's API Keys page. + cy.completeLoginForm(); + cy.url().should('include', `/api/${apiHandle}/api-keys`); + cy.get('#local-login-form').should('not.exist'); + }); + }); }); diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js index da608f4276..cbf69254f7 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js @@ -21,6 +21,39 @@ // backend/auth/file-based-login.spec.js — this spec is for the actual form UX. describe('file-based login UI', () => { - it.skip('logs in through the local login form and lands on the portal home page'); - it.skip('shows a validation error for an incorrect password'); + beforeEach(() => { + cy.on('uncaught:exception', () => false); + }); + + it('logs in through the local login form, shows the profile, and logs back out', () => { + // Home → Log In → admin/admin → submit → lands back on the portal home + // with the profile link visible (cy.login asserts .profile-link visible). + cy.login(); + + // Redirected to the portal home, with the username shown top-right. + cy.url().should('include', '/views/default'); + cy.get('.profile-link').should('be.visible').and('contain', 'admin'); + + // Open the profile dropdown and log out. + cy.get('.profile-link').click(); + cy.get('.profile-dropdown-link').should('be.visible').click(); + + // Redirected back to the login page. + cy.url().should('include', '/login'); + cy.get('#local-login-form').should('be.visible'); + }); + + it('shows an error for an incorrect password', () => { + cy.visitPortal(); + cy.get('.login-btn').click(); + cy.get('#username').type('admin'); + cy.get('#password').type('wrong-password'); + cy.get('.ln-signin-btn').click(); + + // Login page re-renders with the error banner; no session established. + cy.get('.ln-error-banner') + .should('be.visible') + .and('contain', 'Invalid username or password'); + cy.url().should('include', '/login'); + }); }); diff --git a/portals/developer-portal/it/ui/cypress/support/commands.js b/portals/developer-portal/it/ui/cypress/support/commands.js index d1cab1c34a..eb15f7ecc1 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands.js +++ b/portals/developer-portal/it/ui/cypress/support/commands.js @@ -16,6 +16,10 @@ // under the License. // -------------------------------------------------------------------- +// Register command modules split out into ./commands/*. +import './commands/auth'; +import './commands/seed'; + // --------------------------------------------------------------------------- // cy.portalUrl(path) // Build a URL under the ACME/default view without hardcoding the base path. diff --git a/portals/developer-portal/it/ui/cypress/support/commands/auth.js b/portals/developer-portal/it/ui/cypress/support/commands/auth.js index 9e60d0aef1..905c94a61c 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/auth.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/auth.js @@ -29,12 +29,30 @@ Cypress.Commands.add('login', (username, password) => { cy.get('.login-btn').click(); cy.get('#username').type(user); cy.get('#password').type(pwd); - cy.get('#local-login-form button').click(); + cy.get('.ln-signin-btn').click(); // Wait until redirected back to the portal home and profile link is visible. cy.get('.profile-link', { timeout: 15000 }).should('be.visible'); }); +// --------------------------------------------------------------------------- +// cy.completeLoginForm(username, password) +// Fill in and submit the local login form on the CURRENT page. Unlike +// cy.login (which starts from the portal home), this assumes the browser is +// already sitting on the login page — e.g. after an auth-gated page redirected +// an anonymous visitor there — so the server-side `returnTo` set by that +// redirect survives and sends the user back to the originally requested page. +// --------------------------------------------------------------------------- +Cypress.Commands.add('completeLoginForm', (username, password) => { + const user = username || Cypress.env('ADMIN_USER'); + const pwd = password || Cypress.env('ADMIN_PASSWORD'); + + cy.get('#local-login-form').should('be.visible'); + cy.get('#username').type(user); + cy.get('#password').type(pwd); + cy.get('.ln-signin-btn').click(); +}); + // --------------------------------------------------------------------------- // cy.logout() // Log out by navigating to the logout endpoint. diff --git a/portals/developer-portal/it/ui/cypress/support/commands/seed.js b/portals/developer-portal/it/ui/cypress/support/commands/seed.js new file mode 100644 index 0000000000..8bfa8c85fd --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/support/commands/seed.js @@ -0,0 +1,184 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Seed helpers — create demo REST APIs / MCP servers through the real devportal +// management API (POST /api/v0.9/apis, /mcp-servers) so UI browse tests have +// something to render. They go through cy.apiRequest, which injects the +// service API-key header; the `organization` header selects the target org +// (authMiddleware.resolveOrgFromHeader), and `labels: ['default']` maps the +// resource into the default view so it appears on the /apis and /mcps listings +// (apiDao.list requires a label mapped to the view). +// +// These endpoints take multipart/form-data. cy.request runs in Node, not the +// browser, so a browser FormData won't serialize — instead we build the +// multipart body by hand. Every part here is UTF-8 text (a JSON metadata field +// and a JSON definition "file"), so a plain string body is sufficient. +// --------------------------------------------------------------------------- + +function buildMultipart(parts) { + const boundary = `----dpItBoundary${Date.now()}${Math.floor(Math.random() * 1e6)}`; + let body = ''; + for (const part of parts) { + body += `--${boundary}\r\n`; + if (part.filename) { + body += `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`; + body += `Content-Type: ${part.contentType || 'application/octet-stream'}\r\n\r\n`; + } else { + body += `Content-Disposition: form-data; name="${part.name}"\r\n\r\n`; + } + body += `${part.value}\r\n`; + } + body += `--${boundary}--\r\n`; + return { body, contentType: `multipart/form-data; boundary=${boundary}` }; +} + +function seedHeaders(contentType) { + return { + // authMiddleware resolves the target org from this header for API-key requests. + organization: Cypress.env('ORG_HANDLE'), + 'content-type': contentType, + }; +} + +// --------------------------------------------------------------------------- +// cy.seedApi(overrides) +// Create a PUBLISHED REST API in the default view and return its handle. +// Defaults produce a minimal API whose OpenAPI definition declares an apiKey +// security scheme (so the per-API "API Keys" action renders — showApiKeysNav → +// apiUsesApiKeySecurity). Overrides let a richer fixture be built: +// - name, version, id, endPoints +// - subscriptionPlans: [{ id }] — links org-level plans so the plans +// section renders (the plan must already exist, e.g. the default +// "Bronze"/"Silver"/"Gold"; this only links, it does not create). +// - definition: an OpenAPI object (or JSON string) to replace the default +// (e.g. more paths → more Resources rows, oauth2 scopes for the spec view). +// - docs: [{ name, content }] — markdown files stored as "Other" documents, +// which surface in the docs sidebar at /docs/Other/. +// --------------------------------------------------------------------------- +Cypress.Commands.add('seedApi', (overrides = {}) => { + const handle = overrides.id || `it-portal-access-api-${Date.now()}`; + const metadata = { + id: handle, + name: overrides.name || 'IT Portal Access API', + version: overrides.version || 'v1.0', + type: 'REST', + status: 'PUBLISHED', + // Maps the API into the default view so it appears on the /apis listing. + labels: ['default'], + endPoints: overrides.endPoints || { + productionURL: `https://backend.example.invalid/${handle}`, + sandboxURL: `https://sandbox.example.invalid/${handle}`, + }, + }; + if (overrides.subscriptionPlans) { + metadata.subscriptionPlans = overrides.subscriptionPlans; + } + + const definition = overrides.definition + ? (typeof overrides.definition === 'string' ? overrides.definition : JSON.stringify(overrides.definition)) + : JSON.stringify({ + openapi: '3.0.3', + info: { title: metadata.name, version: '1.0.0' }, + // apiKey scheme → the API Keys button shows on the API detail page. + components: { securitySchemes: { ApiKeyAuth: { type: 'apiKey', in: 'header', name: 'apikey' } } }, + security: [{ ApiKeyAuth: [] }], + paths: { '/ping': { get: { responses: { 200: { description: 'ok' } } } } }, + }); + + const parts = [ + { name: 'metadata', value: JSON.stringify(metadata) }, + { name: 'definition', value: definition, filename: 'definition.json', contentType: 'application/json' }, + ]; + // Each `docs` file is stored as an "Other" document (req.files.docs in + // apiMetadataService.createAPIMetadata). + (overrides.docs || []).forEach((doc) => { + parts.push({ name: 'docs', value: doc.content, filename: doc.name, contentType: 'text/markdown' }); + }); + + const { body, contentType } = buildMultipart(parts); + + return cy + .apiRequest('POST', '/api/v0.9/apis', { headers: seedHeaders(contentType), body }) + .then((resp) => { + expect(resp.status, 'seed REST API').to.eq(201); + return handle; + }); +}); + +// --------------------------------------------------------------------------- +// cy.seedMcp(overrides) +// Create a PUBLISHED MCP server in the default view. Its definition (the tools +// schema) is required on create. Returns the MCP handle. +// --------------------------------------------------------------------------- +Cypress.Commands.add('seedMcp', (overrides = {}) => { + const handle = overrides.id || `it-portal-access-mcp-${Date.now()}`; + const metadata = { + id: handle, + name: overrides.name || 'IT Portal Access MCP', + version: overrides.version || 'v1.0', + type: 'MCP', + status: 'PUBLISHED', + labels: ['default'], + endPoints: { + productionURL: `https://mcp.example.invalid/${handle}`, + sandboxURL: `https://mcp.example.invalid/${handle}`, + }, + }; + // MCP definition is a list of tools/resources/prompts (JSON is valid YAML). + const definition = JSON.stringify([ + { + type: 'TOOL', + name: 'ping', + description: 'Health-check tool.', + inputSchema: { type: 'object', properties: {} }, + }, + ]); + + const { body, contentType } = buildMultipart([ + { name: 'metadata', value: JSON.stringify(metadata) }, + { name: 'definition', value: definition, filename: 'definition.json', contentType: 'application/json' }, + ]); + + return cy + .apiRequest('POST', '/api/v0.9/mcp-servers', { headers: seedHeaders(contentType), body }) + .then((resp) => { + expect(resp.status, 'seed MCP server').to.eq(201); + return handle; + }); +}); + +// --------------------------------------------------------------------------- +// cy.deleteApi(handle) / cy.deleteMcp(handle) +// Best-effort teardown — never fails the suite if the resource is already gone. +// --------------------------------------------------------------------------- +Cypress.Commands.add('deleteApi', (handle) => { + if (!handle) return; + cy.apiRequest('DELETE', `/api/v0.9/apis/${handle}`, { + headers: { organization: Cypress.env('ORG_HANDLE') }, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('deleteMcp', (handle) => { + if (!handle) return; + cy.apiRequest('DELETE', `/api/v0.9/mcp-servers/${handle}`, { + headers: { organization: Cypress.env('ORG_HANDLE') }, + failOnStatusCode: false, + }); +}); diff --git a/portals/developer-portal/src/pages/login-page/partials/signin-card.hbs b/portals/developer-portal/src/pages/login-page/partials/signin-card.hbs index fc4666aae3..0c04ebec93 100644 --- a/portals/developer-portal/src/pages/login-page/partials/signin-card.hbs +++ b/portals/developer-portal/src/pages/login-page/partials/signin-card.hbs @@ -25,7 +25,7 @@ {{/if}} -
+
diff --git a/portals/developer-portal/src/scripts/signin.js b/portals/developer-portal/src/scripts/signin.js index 5acf818708..e5e993ad6e 100644 --- a/portals/developer-portal/src/scripts/signin.js +++ b/portals/developer-portal/src/scripts/signin.js @@ -19,21 +19,11 @@ document.addEventListener('DOMContentLoaded', function () { const localLoginForm = document.getElementById('local-login-form'); if (!localLoginForm) return; - const baseUrl = localLoginForm.getAttribute('data-base-url'); - localLoginForm.addEventListener('submit', function (e) { - e.preventDefault(); - const params = new URLSearchParams(); - params.append('username', document.getElementById('username').value); - params.append('password', document.getElementById('password').value); - fetch(`${baseUrl}/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - redirect: 'follow', - }).then(res => { - window.location.href = res.url; - }).catch(() => { - window.location.href = `${baseUrl}/login?error=Login+failed%2C+please+try+again`; - }); + localLoginForm.addEventListener('submit', function () { + const submitBtn = localLoginForm.querySelector('.ln-signin-btn'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Signing in…'; + } }); }); From 3b82ca3924e279bfc0bd268092feaabdc0e6fb45 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 27 Jul 2026 09:25:59 +0530 Subject: [PATCH 2/8] Add e2e tests for api pages --- .../ui/cypress/e2e/rest-apis/api-detail.cy.js | 155 ++++++++++++++++++ .../it/ui/cypress/support/commands/index.js | 28 ++++ .../{commands.js => commands/portal.js} | 4 +- 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js create mode 100644 portals/developer-portal/it/ui/cypress/support/commands/index.js rename portals/developer-portal/it/ui/cypress/support/{commands.js => commands/portal.js} (95%) diff --git a/portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js new file mode 100644 index 0000000000..5e107c97b8 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js @@ -0,0 +1,155 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Anonymous (logged-out) browse of a REST API: listing → overview → docs → +// specification/try-it → other docs. The API is seeded via the management API +// with a multi-operation OpenAPI definition, a linked subscription plan, and a +// markdown document so every overview/docs section has something to render. + +describe('REST API — overview, documentation & try-out', () => { + const API_NAME = 'IT REST Detail API'; + let apiHandle; + + before(() => { + cy.seedApi({ + name: API_NAME, + version: 'v1.0', + // "Bronze" is one of the org's auto-created default plans (seederService). + subscriptionPlans: [{ id: 'Bronze' }], + definition: { + openapi: '3.0.3', + info: { title: API_NAME, version: '1.0.0' }, + servers: [{ url: 'https://backend.example.invalid' }], + components: { + securitySchemes: { + OAuth2: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://idp.example.invalid/token', + scopes: { 'read:items': 'Read items', 'write:items': 'Write items' }, + }, + }, + }, + }, + }, + security: [{ OAuth2: ['read:items'] }], + paths: { + '/items': { + get: { summary: 'List items', responses: { 200: { description: 'ok' } } }, + post: { summary: 'Create an item', responses: { 201: { description: 'created' } } }, + }, + '/items/{id}': { + get: { summary: 'Get an item', responses: { 200: { description: 'ok' } } }, + }, + }, + }, + docs: [{ + name: 'getting-started.md', + content: '# Getting Started\n\nGuide for the IT REST Detail API.\n', + }], + }).then((handle) => { apiHandle = handle; }); + }); + + after(() => { + cy.deleteApi(apiHandle); + }); + + beforeEach(() => { + cy.on('uncaught:exception', () => false); + }); + + it('opens the API from the listing and shows the overview details', () => { + cy.visitPortal('/apis'); + // Open the seeded API from its card (the [data-href] region navigates; + // the click auto-scrolls the card into view). + cy.contains('.api-card', API_NAME).find('.api-card-top').click(); + cy.url().should('include', `/api/${apiHandle}`); + + // Banner (top of page): name, version, type. + cy.get('.aov-hero-name').should('be.visible').and('contain', API_NAME); + cy.get('.dp-badge--version').should('contain', 'v1.0'); + cy.get('.dp-badge--rest').should('be.visible'); // REST type badge + + // The sections below live in a scroll container, so assert on their + // presence/content rather than viewport visibility. + + // Endpoints: production + sandbox URLs. + cy.contains('.aov-section-title', 'Endpoints').should('exist'); + cy.get('#token_api_prod_url').should('contain', 'backend.example.invalid'); + cy.get('#token_api_dev_url').should('contain', 'sandbox.example.invalid'); + + // Resources: the operations from the OpenAPI definition. + cy.contains('.aov-section-title', 'Resources').should('exist'); + cy.get('.aov-resource-row').should('have.length.at.least', 2); + cy.get('.aov-resource-path').should('contain', '/items'); + cy.get('.aov-method-badge--GET').should('exist'); + cy.get('.aov-method-badge--POST').should('exist'); + + // Subscription plans: the linked "Bronze" plan. + cy.get('#subscriptionPlans').should('exist'); + cy.contains('.aov-plans-title', 'Subscription plans').should('exist'); + cy.contains('.aov-plan-card', 'Bronze').should('exist'); + + // Scopes section renders. NOTE: the DB-backed overview passes scopes: [] + // unconditionally (apiContentController.loadAPIContent), so this section + // shows the empty state here; the actual OAuth2 scopes surface in the + // specification view (Stoplight Elements), asserted separately below. + cy.contains('.aov-section-title', 'Scopes').should('exist'); + }); + + it('opens the documentation and renders the OpenAPI specification', () => { + cy.visitPortal(`/api/${apiHandle}`); + cy.get('a.dp-btn[href$="/docs/specification"]').click(); + + cy.contains('.page-title', 'Documentation').should('be.visible'); + // The OpenAPI spec is embedded in the Stoplight Elements web component, + // carrying the API document (server-modified but still containing paths). + cy.get('.adoc-file-badge--spec').should('contain', 'openapi'); + cy.get('elements-api').should('exist'); + cy.get('elements-api') + .invoke('attr', 'apiDescriptionDocument') + .should('include', '/items'); + }); + + it('lists the specification and the other document in the docs sidebar', () => { + cy.visitPortal(`/api/${apiHandle}/docs/specification`); + + cy.get('.adoc-nav').should('be.visible'); + cy.get('.adoc-nav').contains('.adoc-nav-item', 'API Definition').should('exist'); + + // The seeded markdown doc surfaces under the "Other" group. + cy.get('.adoc-nav .adoc-nav-group-title').should('contain', 'Other'); + cy.get('.adoc-nav a.doc-link[href*="/docs/Other/"]') + .should('contain', 'getting-started') + .click(); + + // The document body renders. + cy.get('.api-markdown-content').should('contain', 'Getting Started'); + }); + + it('exposes the Try It console on the specification page', () => { + cy.visitPortal(`/api/${apiHandle}/docs/specification`); + // For a REST API the "try out" console is Stoplight Elements' built-in + // Try-It panel inside , wired to the portal's tryout proxy. + // (Elements loads from a CDN and the seeded backend is unreachable, so a + // live request round-trip isn't exercised here — we assert the console is + // present and proxy-wired.) + cy.get('elements-api').should('exist').and('have.attr', 'tryItCorsProxy'); + }); +}); diff --git a/portals/developer-portal/it/ui/cypress/support/commands/index.js b/portals/developer-portal/it/ui/cypress/support/commands/index.js new file mode 100644 index 0000000000..2bd5b07b7f --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/support/commands/index.js @@ -0,0 +1,28 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Barrel for all custom Cypress commands. support/e2e.js imports this once +// (`import './commands'`), so every module below registers its commands before +// any spec runs. Add a new command module here to make it available globally. +import './portal'; // cy.portalUrl, cy.apiRequest, cy.visitPortal +import './auth'; // cy.login, cy.completeLoginForm, cy.logout +import './seed'; // cy.seedApi, cy.seedMcp, cy.deleteApi, cy.deleteMcp + +// Note: ./applications (cy.createApplication, cy.deleteApplication) is +// intentionally not imported — its selectors are stale and no spec uses it yet. +// Fix its selectors and add the import here before relying on it. diff --git a/portals/developer-portal/it/ui/cypress/support/commands.js b/portals/developer-portal/it/ui/cypress/support/commands/portal.js similarity index 95% rename from portals/developer-portal/it/ui/cypress/support/commands.js rename to portals/developer-portal/it/ui/cypress/support/commands/portal.js index eb15f7ecc1..7b908ebf41 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/portal.js @@ -16,9 +16,7 @@ // under the License. // -------------------------------------------------------------------- -// Register command modules split out into ./commands/*. -import './commands/auth'; -import './commands/seed'; +// Generic portal navigation and management-API helpers shared across specs. // --------------------------------------------------------------------------- // cy.portalUrl(path) From 48af0a0fe08f7b0fd8c275291d06b262e773b7c3 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 27 Jul 2026 10:45:53 +0530 Subject: [PATCH 3/8] Add app key generation related e2e tests --- .../developer-portal/it/ui/cypress.config.js | 14 +- .../e2e/applications/application-flows.cy.js | 173 ++++++++++++++++++ .../cypress/support/commands/applications.js | 46 +++-- .../it/ui/cypress/support/commands/auth.js | 21 ++- .../it/ui/cypress/support/commands/index.js | 11 +- .../it/ui/cypress/support/commands/seed.js | 36 ++++ .../it/ui/mock-token-server.js | 109 +++++++++++ 7 files changed, 383 insertions(+), 27 deletions(-) create mode 100644 portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js create mode 100644 portals/developer-portal/it/ui/mock-token-server.js diff --git a/portals/developer-portal/it/ui/cypress.config.js b/portals/developer-portal/it/ui/cypress.config.js index 8f4ab7251f..73c7840c37 100644 --- a/portals/developer-portal/it/ui/cypress.config.js +++ b/portals/developer-portal/it/ui/cypress.config.js @@ -17,6 +17,7 @@ // -------------------------------------------------------------------- const { defineConfig } = require('cypress'); +const { startMockTokenServer, stopMockTokenServer } = require('./mock-token-server'); module.exports = defineConfig({ e2e: { @@ -33,7 +34,7 @@ module.exports = defineConfig({ responseTimeout: 15000, // Accept self-signed certs from the devportal chromeWebSecurity: false, - setupNodeEvents(on) { + setupNodeEvents(on, config) { // Pass required flags to Chrome/Chromium in Docker (no sandbox, no GPU) on('before:browser:launch', (browser, launchOptions) => { if (browser.family === 'chromium') { @@ -43,6 +44,17 @@ module.exports = defineConfig({ } return launchOptions; }); + + // On-demand mock OAuth2 token endpoint for the application key/token + // round-trip test. Registered as tasks (not started here) so it only + // listens while a test that needs it is running — the test starts it in + // its before() and stops it in after(); it returns { endpoint, secret, + // accessToken } for the seeded key manager to point at. + on('task', { + startMockTokenServer: () => startMockTokenServer(), + stopMockTokenServer: () => stopMockTokenServer(), + }); + return config; }, }, env: { diff --git a/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js new file mode 100644 index 0000000000..f927023823 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js @@ -0,0 +1,173 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Applications require login. The "Manage Keys" section depends on an org-level +// key manager: with none configured (the default) it shows an unavailable +// message; once one is seeded, the key-manager card and its key controls render. + +describe('Applications', () => { + const DETAIL_APP = 'IT Detail App'; + let detailHandle; + + before(() => { + // A persistent, admin-owned application reused by the detail tests. + cy.login(); + cy.createApplication(DETAIL_APP, 'Used by the application detail tests') + .then((handle) => { detailHandle = handle; }); + }); + + after(() => { + // Delete via the management API using the logged-in admin session — apps + // are created_by-scoped, so this must run as admin (not the api-key path), + // and there is no CSRF on the applications endpoints. Robust cleanup that + // doesn't depend on the delete-modal UI. + cy.login(); + cy.request({ + method: 'DELETE', + url: `/api/v0.9/applications/${detailHandle}`, + failOnStatusCode: false, + }); + }); + + beforeEach(() => { + cy.on('uncaught:exception', () => false); + }); + + it('creates, edits, and deletes an application', () => { + const NAME = 'IT CRUD App'; + const RENAMED = 'IT CRUD App Renamed'; + cy.login(); + + // Create. + cy.createApplication(NAME, 'Created by the CRUD test'); + cy.contains('.app-card-name', NAME).should('be.visible'); + + // Open the detail page and rename it (inline contenteditable edit). + cy.contains('.app-card', NAME).click(); + cy.url().should('include', '/applications/'); + cy.get('#applicationName').should('contain', NAME); + cy.get('#editNameBtn').click(); + cy.get('#applicationName') + .should('have.attr', 'contenteditable', 'true') + .type('{selectall}' + RENAMED); + cy.get('#saveNameBtn').click(); + cy.get('#applicationName').should('contain', RENAMED); + + // Edit the description (also an inline contenteditable edit). + const NEW_DESC = 'Updated by the CRUD test'; + cy.get('#editDescriptionBtn').click(); + cy.get('#applicationDescription') + .should('have.attr', 'contenteditable', 'true') + .type('{selectall}' + NEW_DESC); + cy.get('#saveDescriptionBtn').click(); + cy.get('#applicationDescription').should('contain', NEW_DESC); + + // Delete (the card now carries the renamed name). + cy.deleteApplication(RENAMED); + cy.contains('.app-card-name', RENAMED).should('not.exist'); + }); + + it('shows the "no key manager" message and the API keys association section', () => { + cy.login(); + cy.visitPortal(`/applications/${detailHandle}`); + + // Manage Keys section — no key manager configured yet. + cy.contains('.mk-title', 'Manage Keys').should('exist'); + cy.get('.mk-unavailable').should('exist').and('contain', 'Key generation is unavailable'); + cy.get('.mk-km-card').should('not.exist'); + + // The API keys association section is independent of key managers. + cy.get('.ak-title').should('exist'); + cy.get('#btn-open-associate-key').should('exist'); + }); + + context('with a key manager configured', () => { + // The Manage Keys card labels each key manager by its handle (kmName = + // km.handle), so seed a known id and assert on that. + const KM_ID = 'it-key-manager'; + // Populated from the on-demand mock token server (started in before()). + let mockToken; + + before(() => { + // Start the mock OAuth2 token endpoint only for this context, and point + // the key manager at it so the token round-trip can actually resolve. + cy.task('startMockTokenServer').then((mock) => { + mockToken = mock; + cy.seedKeyManager({ + id: KM_ID, + displayName: 'IT Key Manager', + tokenEndpoint: mock.endpoint, + }); + }); + }); + + after(() => { + cy.deleteKeyManager(KM_ID); + cy.task('stopMockTokenServer'); + }); + + it('loads the key manager section and its key controls', () => { + cy.login(); + cy.visitPortal(`/applications/${detailHandle}`); + + // The unavailable message is gone; a key-manager card renders instead. + cy.get('.mk-unavailable').should('not.exist'); + cy.get('.mk-km-card').should('exist'); + cy.get('.mk-km-name').should('contain', KM_ID); + // With no credentials yet, the card exposes the "add client ID" control + // (OAuth apps are created in the key manager itself, then linked here). + cy.get('[id^="addClientIdBtn-"]').should('exist'); + }); + + it('adds a client ID, generates a token via the key manager, then revokes it', () => { + cy.login(); + cy.visitPortal(`/applications/${detailHandle}`); + + // 1. Add a client ID (PRODUCTION) — links the consumer key; no external call. + cy.get(`#addClientIdInput-${KM_ID}-PRODUCTION`).type('it-client-id'); + cy.get(`#addClientIdBtn-${KM_ID}-PRODUCTION`).click(); + // The page reloads showing the linked credentials. + cy.get(`#consumer-key-${KM_ID}-PRODUCTION-view`, { timeout: 15000 }) + .should('have.value', 'it-client-id'); + + // 2. Generate a token with the consumer secret (devportal → mock key manager). + cy.get(`#tab-btn-token-${KM_ID}-PRODUCTION`).click(); + cy.get('#tokenKeyBtn-PRODUCTION').click(); + cy.get('#generateTokenPromptModal').should('be.visible'); + cy.get('#generateTokenPromptSecretInput').type(mockToken.secret); + cy.get('#generateTokenPromptConfirmBtn').click(); + cy.get(`#token_${KM_ID}_PRODUCTION`, { timeout: 15000 }) + .should('contain', mockToken.accessToken); + cy.get('[data-cyid="keysTokenModal-PRODUCTION-close"]').click(); + + // 3. Revoke the keys — confirm in the shared delete-confirmation modal. + // Scope the revoke button to the Production pane — the Sandbox card + // renders its own revoke button too. + cy.get(`#tab-btn-creds-${KM_ID}-PRODUCTION`).click(); + cy.get('#production').find('.mk-btn-danger').click(); + cy.get('#deleteConfirmation').should('be.visible'); + cy.get('#deleteConfirmationBtn').click(); + // After the reload, the card is back to the empty "add client ID" state. + // (Both the credentials and empty-state blocks are always in the DOM — + // toggled by whether a consumer key exists — so assert the consumer key + // value is cleared rather than that its input is gone.) + cy.get(`#addClientIdBtn-${KM_ID}-PRODUCTION`, { timeout: 15000 }).should('exist'); + cy.get(`#consumer-key-${KM_ID}-PRODUCTION-view`).should('have.value', ''); + }); + }); +}); diff --git a/portals/developer-portal/it/ui/cypress/support/commands/applications.js b/portals/developer-portal/it/ui/cypress/support/commands/applications.js index cf54abcaad..0de10cb8b8 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/applications.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/applications.js @@ -16,24 +16,46 @@ // under the License. // -------------------------------------------------------------------- +// Applications are user-scoped (created_by), so they must be created through the +// UI as the logged-in user — a resource seeded via the service API key would be +// owned by a different actor and never appear on the user's Applications page. +// Call these only after cy.login(). + // --------------------------------------------------------------------------- -// cy.createApplication(name) -// Navigate to the Applications page and create an application by name. -// Waits until the new application card is visible before resolving. +// cy.createApplication(name, description) +// Create an application via the Applications page create modal. Waits for the +// new card and yields the created application's handle (the card's data-id). // --------------------------------------------------------------------------- -Cypress.Commands.add('createApplication', (name) => { - cy.get('#sidebar #applications').click(); - cy.get('#applicationCreateCard').click(); - cy.get('#applicationName').clear().type(name); - cy.get('#createAppButton').click(); - cy.contains('.application-name-link', name, { timeout: 15000 }).should('be.visible'); +Cypress.Commands.add('createApplication', (name, description = '') => { + cy.visitPortal('/applications'); + // Header button (apps exist) or empty-state button (zero apps) — only one renders. + cy.get('#apps-create-btn, #apps-create-btn-empty').first().click(); + cy.get('#app-create-modal').should('be.visible'); + cy.get('#app-name-input').clear().type(name); + if (description) { + cy.get('#app-desc-input').clear().type(description); + } + cy.get('#app-create-confirm').should('not.be.disabled').click(); + // The create request reloads the page; wait for the new card, then yield its handle. + cy.contains('.app-card', name, { timeout: 15000 }).should('be.visible'); + return cy.contains('.app-card', name).invoke('attr', 'data-id'); }); // --------------------------------------------------------------------------- // cy.deleteApplication(name) -// Delete the named application and confirm it is no longer listed. +// Best-effort delete via the Applications page — a no-op if the app is already +// gone, so it is safe to call from an after() hook. // --------------------------------------------------------------------------- Cypress.Commands.add('deleteApplication', (name) => { - cy.get(`div[data-name="${name}"] .delete-button`).click(); - cy.contains('.application-name-link', name, { timeout: 15000 }).should('not.exist'); + cy.visitPortal('/applications'); + cy.get('body').then(($body) => { + const card = $body.find('.app-card').filter((_, el) => el.textContent.includes(name)); + if (!card.length) { + return; // Already deleted. + } + cy.contains('.app-card', name).find('.app-delete-btn').click(); + cy.get('#app-delete-modal').should('be.visible'); + cy.get('#app-delete-confirm').click(); + cy.contains('.app-card-name', name).should('not.exist'); + }); }); diff --git a/portals/developer-portal/it/ui/cypress/support/commands/auth.js b/portals/developer-portal/it/ui/cypress/support/commands/auth.js index 905c94a61c..5282cc7238 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/auth.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/auth.js @@ -26,13 +26,20 @@ Cypress.Commands.add('login', (username, password) => { const pwd = password || Cypress.env('ADMIN_PASSWORD'); cy.visitPortal(); - cy.get('.login-btn').click(); - cy.get('#username').type(user); - cy.get('#password').type(pwd); - cy.get('.ln-signin-btn').click(); - - // Wait until redirected back to the portal home and profile link is visible. - cy.get('.profile-link', { timeout: 15000 }).should('be.visible'); + // Idempotent: if a session is already active (e.g. carried over from a + // before() hook into the first test), the home page shows the profile link + // rather than the login button — skip the form in that case. + cy.get('body').then(($body) => { + if ($body.find('.profile-link').length > 0) { + return; + } + cy.get('.login-btn').click(); + cy.get('#username').type(user); + cy.get('#password').type(pwd); + cy.get('.ln-signin-btn').click(); + // Wait until redirected back to the portal home and profile link is visible. + cy.get('.profile-link', { timeout: 15000 }).should('be.visible'); + }); }); // --------------------------------------------------------------------------- diff --git a/portals/developer-portal/it/ui/cypress/support/commands/index.js b/portals/developer-portal/it/ui/cypress/support/commands/index.js index 2bd5b07b7f..e04bc4aaa0 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/index.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/index.js @@ -19,10 +19,7 @@ // Barrel for all custom Cypress commands. support/e2e.js imports this once // (`import './commands'`), so every module below registers its commands before // any spec runs. Add a new command module here to make it available globally. -import './portal'; // cy.portalUrl, cy.apiRequest, cy.visitPortal -import './auth'; // cy.login, cy.completeLoginForm, cy.logout -import './seed'; // cy.seedApi, cy.seedMcp, cy.deleteApi, cy.deleteMcp - -// Note: ./applications (cy.createApplication, cy.deleteApplication) is -// intentionally not imported — its selectors are stale and no spec uses it yet. -// Fix its selectors and add the import here before relying on it. +import './portal'; // cy.portalUrl, cy.apiRequest, cy.visitPortal +import './auth'; // cy.login, cy.completeLoginForm, cy.logout +import './seed'; // cy.seedApi, cy.seedMcp, cy.deleteApi, cy.deleteMcp, cy.seedKeyManager, cy.deleteKeyManager +import './applications'; // cy.createApplication, cy.deleteApplication diff --git a/portals/developer-portal/it/ui/cypress/support/commands/seed.js b/portals/developer-portal/it/ui/cypress/support/commands/seed.js index 8bfa8c85fd..402639bb38 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/seed.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/seed.js @@ -182,3 +182,39 @@ Cypress.Commands.add('deleteMcp', (handle) => { failOnStatusCode: false, }); }); + +// --------------------------------------------------------------------------- +// cy.seedKeyManager(overrides) / cy.deleteKeyManager(id) +// Key managers are org-level config (not user-scoped), so — unlike +// applications — they can be created through the service-API-key path. +// Seeding one makes the application detail page's "Manage Keys" section render +// a key-manager card instead of the "no key manager" empty state. Returns the +// key manager's id. +// --------------------------------------------------------------------------- +Cypress.Commands.add('seedKeyManager', (overrides = {}) => { + const id = overrides.id || `it-km-${Date.now()}`; + return cy + .apiRequest('POST', '/api/v0.9/key-managers', { + headers: { organization: Cypress.env('ORG_HANDLE') }, + body: { + id, + displayName: overrides.displayName || 'IT Key Manager', + // Only the token endpoint is required; OAuth apps live in the key + // manager itself, so an unreachable placeholder is fine for the UI. + tokenEndpoint: overrides.tokenEndpoint || 'https://km.example.invalid/oauth2/token', + enabled: overrides.enabled !== false, + }, + }) + .then((resp) => { + expect(resp.status, 'seed key manager').to.eq(201); + return id; + }); +}); + +Cypress.Commands.add('deleteKeyManager', (id) => { + if (!id) return; + cy.apiRequest('DELETE', `/api/v0.9/key-managers/${id}`, { + headers: { organization: Cypress.env('ORG_HANDLE') }, + failOnStatusCode: false, + }); +}); diff --git a/portals/developer-portal/it/ui/mock-token-server.js b/portals/developer-portal/it/ui/mock-token-server.js new file mode 100644 index 0000000000..6760f2b500 --- /dev/null +++ b/portals/developer-portal/it/ui/mock-token-server.js @@ -0,0 +1,109 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Minimal OAuth2 client-credentials token endpoint used by the application +// key/token round-trip test. A key manager's tokenEndpoint is pointed here, and +// oauthTokenService (in the devportal container) POSTs to it server-side with +// HTTP Basic auth (clientId:clientSecret). Mirrors the in-process approach the +// REST suite uses in it/rest-api/key-managers/token-generation.spec.js. + +const http = require('node:http'); +const os = require('node:os'); + +const MOCK_TOKEN_PORT = 4599; +const MOCK_TOKEN_SECRET = 'it-mock-consumer-secret'; +const MOCK_ACCESS_TOKEN = 'it-mock-access-token'; + +// This process's docker-network IPv4, so the devportal container (which makes +// the token request server-side) can reach the mock. Using the address rather +// than a service-name alias keeps it working under both `compose up` and the +// one-off `compose run` used while iterating. +function containerIpv4() { + for (const list of Object.values(os.networkInterfaces())) { + for (const iface of list || []) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +// Singleton so the cy.task start/stop pair is idempotent — only the tests that +// need a token endpoint start it (via cy.task), and it is stopped afterwards, so +// it never listens during unrelated specs. +let server = null; +let meta = null; + +// Starts the mock token server if it isn't already running and resolves with its +// { endpoint, secret, accessToken }. Resolves only once the socket is listening, +// so a spec can seed a key manager against a reachable endpoint immediately. +function startMockTokenServer() { + if (server) { + return Promise.resolve(meta); + } + return new Promise((resolve) => { + server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(405); + return res.end(); + } + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const [, encoded] = (req.headers.authorization || '').split(' '); + const [, secret] = Buffer.from(encoded || '', 'base64').toString('utf8').split(':'); + if (secret !== MOCK_TOKEN_SECRET) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'invalid_client' })); + } + const params = new URLSearchParams(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + access_token: MOCK_ACCESS_TOKEN, + token_type: 'Bearer', + expires_in: Number(params.get('expiry_time')) || 3600, + scope: params.get('scope') || '', + })); + }); + }); + server.on('error', (err) => { + console.error('[mock-token-server] failed to start:', err.message); + resolve(meta); // The round-trip test fails loudly on its own if unreachable. + }); + server.listen(MOCK_TOKEN_PORT, '0.0.0.0', () => { + meta = { + endpoint: `http://${containerIpv4()}:${MOCK_TOKEN_PORT}/token`, + secret: MOCK_TOKEN_SECRET, + accessToken: MOCK_ACCESS_TOKEN, + }; + resolve(meta); + }); + }); +} + +function stopMockTokenServer() { + if (server) { + server.close(); + server = null; + meta = null; + } + return null; +} + +module.exports = { startMockTokenServer, stopMockTokenServer }; From fd2c27897d89c77120fca2a72c649134c307b4b8 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 27 Jul 2026 11:01:46 +0530 Subject: [PATCH 4/8] Add e2e test for sidebar --- .../cypress/e2e/001-basic/002-sidebar.cy.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 portals/developer-portal/it/ui/cypress/e2e/001-basic/002-sidebar.cy.js diff --git a/portals/developer-portal/it/ui/cypress/e2e/001-basic/002-sidebar.cy.js b/portals/developer-portal/it/ui/cypress/e2e/001-basic/002-sidebar.cy.js new file mode 100644 index 0000000000..9ed743f2df --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/001-basic/002-sidebar.cy.js @@ -0,0 +1,82 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Sidebar behaviour. The sidebar is collapsed by default; the expand/collapse +// button (#collapseBtn) pins it open (class `expanded`) or force-collapses it +// (class `force-collapse`), persisting the choice in localStorage. +// +// NOTE: hover-to-open / stay-while-inside / close-on-leave are driven purely by +// CSS `:hover` (.sidebar:hover:not(.force-collapse)), which cannot be triggered +// by Cypress without the cypress-real-events plugin. This suite covers the +// JS/localStorage-driven behaviour instead: collapsed-by-default, the nav items, +// pin-to-open via the button, persistence across navigation + reload, and +// collapse via the button. + +describe('Developer Portal — Sidebar', () => { + beforeEach(() => { + cy.on('uncaught:exception', () => false); + cy.visitPortal(); // portal home + }); + + it('is collapsed by default and lists the navigation items', () => { + cy.get('#sidebar') + .should('be.visible') + .and('not.have.class', 'expanded'); + // Collapsed width (~4.625rem). + cy.get('#sidebar').invoke('outerWidth').should('be.lessThan', 140); + cy.get('#collapseBtn .collapse-text').should('contain', 'Expand'); + + // Navigation items are present. + cy.get('#sidebar #home').should('exist'); + cy.get('#sidebar #apis').should('exist'); + cy.get('#sidebar #mcps').should('exist'); + cy.get('#sidebar #api-workflows').should('exist'); + cy.get('#sidebar #applications').should('exist'); + }); + + it('pins open via the expand button and stays open across navigation and reload', () => { + // Click expand → the sidebar is pinned open (independent of the mouse). + cy.get('#collapseBtn').click(); + cy.get('#sidebar').should('have.class', 'expanded'); + cy.get('#collapseBtn .collapse-text').should('contain', 'Collapse'); + cy.get('#sidebar').invoke('outerWidth').should('be.greaterThan', 180); + + // Navigate to APIs — the pinned state persists (localStorage) ... + cy.get('#sidebar #apis').click(); + cy.url().should('include', '/apis'); + cy.get('#sidebar').should('have.class', 'expanded'); + + // ... and survives a full reload. + cy.reload(); + cy.get('#sidebar').should('have.class', 'expanded'); + }); + + it('collapses via the collapse button', () => { + // Pin open first. + cy.get('#collapseBtn').click(); + cy.get('#sidebar').should('have.class', 'expanded'); + + // Click again → force-collapsed (stays collapsed even on hover). + cy.get('#collapseBtn').click(); + cy.get('#sidebar') + .should('not.have.class', 'expanded') + .and('have.class', 'force-collapse'); + cy.get('#collapseBtn .collapse-text').should('contain', 'Expand'); + cy.get('#sidebar').invoke('outerWidth').should('be.lessThan', 140); + }); +}); From 5abf8580e173cf0da8e3dd222f9ad11298467d84 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 27 Jul 2026 12:11:17 +0530 Subject: [PATCH 5/8] Add e2e tests for api and mcp listing --- .../e2e/000-smoke/002-api-listing.cy.js | 32 ------- .../e2e/002-apis/001-api-listing.cy.js | 95 +++++++++++++++++++ .../002-rest-api-details.cy.js} | 0 .../e2e/003-mcp-servers/001-mcp-listing.cy.js | 83 ++++++++++++++++ .../tryout.cy.js | 0 .../it/ui/cypress/support/commands/seed.js | 12 ++- 6 files changed, 188 insertions(+), 34 deletions(-) delete mode 100644 portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js create mode 100644 portals/developer-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js rename portals/developer-portal/it/ui/cypress/e2e/{rest-apis/api-detail.cy.js => 002-apis/002-rest-api-details.cy.js} (100%) create mode 100644 portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js rename portals/developer-portal/it/ui/cypress/e2e/{mcp-servers => 003-mcp-servers}/tryout.cy.js (100%) diff --git a/portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js b/portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js deleted file mode 100644 index a7715e3319..0000000000 --- a/portals/developer-portal/it/ui/cypress/e2e/000-smoke/002-api-listing.cy.js +++ /dev/null @@ -1,32 +0,0 @@ -// -------------------------------------------------------------------- -// 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. -// -------------------------------------------------------------------- - -describe('Developer Portal — API Listing', () => { - beforeEach(() => { - cy.on('uncaught:exception', () => false); - }); - - context('UI — API browse page', () => { - it('loads the API list page without errors', () => { - cy.visitPortal('/apis'); - cy.get('body').should('be.visible'); - cy.get('body').should('not.contain.text', 'Cannot GET'); - cy.get('body').should('not.contain.text', '500'); - }); - }); -}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js b/portals/developer-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js new file mode 100644 index 0000000000..14ce730bf9 --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js @@ -0,0 +1,95 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// The /apis listing page: renders a card per published API (in the default view) +// with a type badge, and supports server-side search (Enter navigates to +// ?query=... and the server filters). The listing is public — no login needed. + +describe('API listing', () => { + const REST_API = 'IT Listing REST API'; + const GQL_API = 'IT Listing GraphQL API'; + const MCP_NAME = 'IT Listing MCP Server'; + let restHandle; + let gqlHandle; + let mcpHandle; + + before(() => { + // Seed two APIs of different types so the listing shows distinct badges, + // plus an MCP server — which must NOT appear on the /apis listing (it + // belongs on /mcps). loadAPIs filters type !== MCP for the APIs page. + cy.seedApi({ name: REST_API }).then((h) => { restHandle = h; }); + cy.seedApi({ + name: GQL_API, + type: 'GRAPHQL', + definition: 'type Query { hello: String }\n', + definitionFileName: 'schema.graphql', + definitionContentType: 'application/graphql', + }).then((h) => { gqlHandle = h; }); + cy.seedMcp({ name: MCP_NAME }).then((h) => { mcpHandle = h; }); + }); + + after(() => { + cy.deleteApi(restHandle); + cy.deleteApi(gqlHandle); + cy.deleteMcp(mcpHandle); + }); + + beforeEach(() => { + cy.on('uncaught:exception', () => false); + }); + + it('lists API cards, including different API types', () => { + cy.visitPortal('/apis'); + + cy.get('.apilist-results-heading').should('contain', 'APIs'); + cy.get('.api-card').should('have.length.at.least', 2); + + // Both seeded APIs appear, each with its own type badge. + cy.contains('.api-card', REST_API).within(() => { + cy.get('.dp-badge--rest').should('exist').and('contain', 'REST'); + }); + cy.contains('.api-card', GQL_API).within(() => { + cy.get('.dp-badge--graphql').should('exist').and('contain', 'GraphQL'); + }); + + // MCP servers belong on /mcps and must not appear in the API listing. + cy.contains('.api-card', MCP_NAME).should('not.exist'); + }); + + it('narrows the listing with a search query', () => { + cy.visitPortal('/apis'); + + // Search is server-side: Enter navigates to ?query=... and the server filters. + cy.get('#query').type(`${GQL_API}{enter}`); + cy.url().should('include', 'query='); + + // Only the matching API remains; the non-matching one is filtered out. + cy.contains('.api-card', GQL_API).should('be.visible'); + cy.contains('.api-card', REST_API).should('not.exist'); + }); + + it('does not surface MCP servers via API search', () => { + cy.visitPortal('/apis'); + + // Searching an MCP server's name from the APIs page returns no card — + // MCP-typed results are filtered out of the /apis listing and search. + cy.get('#query').type(`${MCP_NAME}{enter}`); + cy.url().should('include', 'query='); + cy.contains('.api-card', MCP_NAME).should('not.exist'); + }); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js b/portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js similarity index 100% rename from portals/developer-portal/it/ui/cypress/e2e/rest-apis/api-detail.cy.js rename to portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js diff --git a/portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js b/portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js new file mode 100644 index 0000000000..24b956747b --- /dev/null +++ b/portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js @@ -0,0 +1,83 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// The /mcps listing page: a card per published MCP server (in the default view) +// with an MCP badge, and the same server-side search as /apis. The counterpart +// of 002-apis/002-api-listing.cy.js — loadAPIs filters type === MCP for this +// page, so REST/other APIs must NOT appear here. The listing is public. + +describe('MCP server listing', () => { + const MCP_ONE = 'IT Listing MCP Alpha'; + const MCP_TWO = 'IT Listing MCP Beta'; + const REST_API = 'IT Listing MCP-page REST API'; + let mcpOneHandle; + let mcpTwoHandle; + let restHandle; + + before(() => { + cy.seedMcp({ name: MCP_ONE }).then((h) => { mcpOneHandle = h; }); + cy.seedMcp({ name: MCP_TWO }).then((h) => { mcpTwoHandle = h; }); + // A REST API — must NOT appear on the /mcps listing (it belongs on /apis). + cy.seedApi({ name: REST_API }).then((h) => { restHandle = h; }); + }); + + after(() => { + cy.deleteMcp(mcpOneHandle); + cy.deleteMcp(mcpTwoHandle); + cy.deleteApi(restHandle); + }); + + beforeEach(() => { + cy.on('uncaught:exception', () => false); + }); + + it('lists MCP server cards with the MCP badge', () => { + cy.visitPortal('/mcps'); + + cy.get('.apilist-results-heading').should('contain', 'MCP Servers'); + cy.get('.api-card').should('have.length.at.least', 2); + + // Seeded MCP servers appear, with the MCP badge. + cy.contains('.api-card', MCP_ONE).within(() => { + cy.get('.dp-badge--mcp').should('exist').and('contain', 'MCP'); + }); + cy.contains('.api-card', MCP_TWO).should('exist'); + + // REST APIs belong on /apis and must not appear here. + cy.contains('.api-card', REST_API).should('not.exist'); + }); + + it('narrows the listing with a search query', () => { + cy.visitPortal('/mcps'); + + // Search is server-side: Enter navigates to ?query=... and the server filters. + cy.get('#query').type(`${MCP_ONE}{enter}`); + cy.url().should('include', 'query='); + + cy.contains('.api-card', MCP_ONE).should('be.visible'); + cy.contains('.api-card', MCP_TWO).should('not.exist'); + }); + + it('does not surface REST APIs via MCP search', () => { + cy.visitPortal('/mcps'); + + cy.get('#query').type(`${REST_API}{enter}`); + cy.url().should('include', 'query='); + cy.contains('.api-card', REST_API).should('not.exist'); + }); +}); diff --git a/portals/developer-portal/it/ui/cypress/e2e/mcp-servers/tryout.cy.js b/portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/tryout.cy.js similarity index 100% rename from portals/developer-portal/it/ui/cypress/e2e/mcp-servers/tryout.cy.js rename to portals/developer-portal/it/ui/cypress/e2e/003-mcp-servers/tryout.cy.js diff --git a/portals/developer-portal/it/ui/cypress/support/commands/seed.js b/portals/developer-portal/it/ui/cypress/support/commands/seed.js index 402639bb38..4111c35b79 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/seed.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/seed.js @@ -77,7 +77,10 @@ Cypress.Commands.add('seedApi', (overrides = {}) => { id: handle, name: overrides.name || 'IT Portal Access API', version: overrides.version || 'v1.0', - type: 'REST', + // 'REST' (default), 'GRAPHQL', 'SOAP', etc. — normalized server-side + // (e.g. 'REST' → 'RestApi'). For a non-REST type pass a matching + // `definition` (e.g. a GraphQL SDL for type 'GRAPHQL'). + type: overrides.type || 'REST', status: 'PUBLISHED', // Maps the API into the default view so it appears on the /apis listing. labels: ['default'], @@ -103,7 +106,12 @@ Cypress.Commands.add('seedApi', (overrides = {}) => { const parts = [ { name: 'metadata', value: JSON.stringify(metadata) }, - { name: 'definition', value: definition, filename: 'definition.json', contentType: 'application/json' }, + { + name: 'definition', + value: definition, + filename: overrides.definitionFileName || 'definition.json', + contentType: overrides.definitionContentType || 'application/json', + }, ]; // Each `docs` file is stored as an "Other" document (req.files.docs in // apiMetadataService.createAPIMetadata). From 70cf38cd49cc9b3b741bc50f9c8797cef72dddb0 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Tue, 28 Jul 2026 14:03:20 +0530 Subject: [PATCH 6/8] Improve tests --- .../e2e/002-apis/002-rest-api-details.cy.js | 7 ++- .../e2e/applications/application-flows.cy.js | 2 +- .../cypress/e2e/auth/file-based-login.cy.js | 4 +- .../cypress/support/commands/applications.js | 22 +++++-- .../it/ui/mock-token-server.js | 61 +++++++++++++++---- 5 files changed, 73 insertions(+), 23 deletions(-) diff --git a/portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js b/portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js index 5e107c97b8..7c77831554 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js @@ -119,12 +119,15 @@ describe('REST API — overview, documentation & try-out', () => { cy.contains('.page-title', 'Documentation').should('be.visible'); // The OpenAPI spec is embedded in the Stoplight Elements web component, - // carrying the API document (server-modified but still containing paths). + // carrying the API document (server-modified but still containing the + // seeded paths and the OAuth2 scopes from its security scheme). cy.get('.adoc-file-badge--spec').should('contain', 'openapi'); cy.get('elements-api').should('exist'); cy.get('elements-api') .invoke('attr', 'apiDescriptionDocument') - .should('include', '/items'); + .should('include', '/items') + .and('include', 'read:items') + .and('include', 'write:items'); }); it('lists the specification and the other document in the docs sidebar', () => { diff --git a/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js index f927023823..d908359367 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js @@ -41,7 +41,7 @@ describe('Applications', () => { method: 'DELETE', url: `/api/v0.9/applications/${detailHandle}`, failOnStatusCode: false, - }); + }).its('status').should('be.oneOf', [200, 404]); // 200 = deleted; 404 = already gone (idempotent). Any other status is a real failure. }); beforeEach(() => { diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js index cbf69254f7..271024901a 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js @@ -32,7 +32,7 @@ describe('file-based login UI', () => { // Redirected to the portal home, with the username shown top-right. cy.url().should('include', '/views/default'); - cy.get('.profile-link').should('be.visible').and('contain', 'admin'); + cy.get('.profile-link').should('be.visible').and('contain', Cypress.env('ADMIN_USER')); // Open the profile dropdown and log out. cy.get('.profile-link').click(); @@ -46,7 +46,7 @@ describe('file-based login UI', () => { it('shows an error for an incorrect password', () => { cy.visitPortal(); cy.get('.login-btn').click(); - cy.get('#username').type('admin'); + cy.get('#username').type(Cypress.env('ADMIN_USER')); cy.get('#password').type('wrong-password'); cy.get('.ln-signin-btn').click(); diff --git a/portals/developer-portal/it/ui/cypress/support/commands/applications.js b/portals/developer-portal/it/ui/cypress/support/commands/applications.js index 0de10cb8b8..2dce005075 100644 --- a/portals/developer-portal/it/ui/cypress/support/commands/applications.js +++ b/portals/developer-portal/it/ui/cypress/support/commands/applications.js @@ -36,9 +36,14 @@ Cypress.Commands.add('createApplication', (name, description = '') => { cy.get('#app-desc-input').clear().type(description); } cy.get('#app-create-confirm').should('not.be.disabled').click(); - // The create request reloads the page; wait for the new card, then yield its handle. - cy.contains('.app-card', name, { timeout: 15000 }).should('be.visible'); - return cy.contains('.app-card', name).invoke('attr', 'data-id'); + // The create request reloads the page; wait for the new card, then yield its + // handle. Match the name exactly against .app-card-name (not a substring of the + // whole .app-card) so e.g. "IT CRUD App" doesn't also match "IT CRUD App Renamed". + return cy + .contains('.app-card-name', new RegExp(`^${Cypress._.escapeRegExp(name)}$`), { timeout: 15000 }) + .should('be.visible') + .closest('.app-card') + .invoke('attr', 'data-id'); }); // --------------------------------------------------------------------------- @@ -49,11 +54,16 @@ Cypress.Commands.add('createApplication', (name, description = '') => { Cypress.Commands.add('deleteApplication', (name) => { cy.visitPortal('/applications'); cy.get('body').then(($body) => { - const card = $body.find('.app-card').filter((_, el) => el.textContent.includes(name)); - if (!card.length) { + // Exact-match the card name, not a substring of the whole card, so a + // similarly-named app (e.g. "IT CRUD App" vs "IT CRUD App Renamed") is + // never picked by mistake. + const nameCard = $body + .find('.app-card-name') + .filter((_, el) => el.textContent.trim() === name); + if (!nameCard.length) { return; // Already deleted. } - cy.contains('.app-card', name).find('.app-delete-btn').click(); + cy.wrap(nameCard).first().closest('.app-card').find('.app-delete-btn').click(); cy.get('#app-delete-modal').should('be.visible'); cy.get('#app-delete-confirm').click(); cy.contains('.app-card-name', name).should('not.exist'); diff --git a/portals/developer-portal/it/ui/mock-token-server.js b/portals/developer-portal/it/ui/mock-token-server.js index 6760f2b500..a572088ab5 100644 --- a/portals/developer-portal/it/ui/mock-token-server.js +++ b/portals/developer-portal/it/ui/mock-token-server.js @@ -28,6 +28,9 @@ const os = require('node:os'); const MOCK_TOKEN_PORT = 4599; const MOCK_TOKEN_SECRET = 'it-mock-consumer-secret'; const MOCK_ACCESS_TOKEN = 'it-mock-access-token'; +// A client-credentials token request body is tiny; cap it so a runaway/hostile +// caller can't force the mock to buffer an unbounded stream in memory. +const MAX_TOKEN_BODY_BYTES = 8 * 1024; // This process's docker-network IPv4, so the devportal container (which makes // the token request server-side) can reach the mock. Using the address rather @@ -55,17 +58,36 @@ let meta = null; // so a spec can seed a key manager against a reachable endpoint immediately. function startMockTokenServer() { if (server) { + // Already running — meta is the live endpoint's metadata, not stale. return Promise.resolve(meta); } - return new Promise((resolve) => { - server = http.createServer((req, res) => { + return new Promise((resolve, reject) => { + let listening = false; + const srv = http.createServer((req, res) => { if (req.method !== 'POST') { res.writeHead(405); return res.end(); } let body = ''; - req.on('data', (chunk) => { body += chunk; }); + let aborted = false; + req.on('data', (chunk) => { + if (aborted) { + return; + } + body += chunk; + if (body.length > MAX_TOKEN_BODY_BYTES) { + // Stop accumulating and reject with a generic 413 rather than + // buffering an unbounded body. + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'payload_too_large' })); + req.destroy(); + } + }); req.on('end', () => { + if (aborted) { + return; + } const [, encoded] = (req.headers.authorization || '').split(' '); const [, secret] = Buffer.from(encoded || '', 'base64').toString('utf8').split(':'); if (secret !== MOCK_TOKEN_SECRET) { @@ -82,11 +104,19 @@ function startMockTokenServer() { })); }); }); - server.on('error', (err) => { - console.error('[mock-token-server] failed to start:', err.message); - resolve(meta); // The round-trip test fails loudly on its own if unreachable. + srv.on('error', (err) => { + console.error('[mock-token-server] server error:', err.message); + // Only a pre-listen failure (e.g. EADDRINUSE) means startup failed — + // reject so the caller sees it instead of receiving stale/null metadata. + if (!listening) { + server = null; + meta = null; + reject(err); + } }); - server.listen(MOCK_TOKEN_PORT, '0.0.0.0', () => { + srv.listen(MOCK_TOKEN_PORT, '0.0.0.0', () => { + listening = true; + server = srv; meta = { endpoint: `http://${containerIpv4()}:${MOCK_TOKEN_PORT}/token`, secret: MOCK_TOKEN_SECRET, @@ -98,12 +128,19 @@ function startMockTokenServer() { } function stopMockTokenServer() { - if (server) { - server.close(); - server = null; - meta = null; + if (!server) { + return Promise.resolve(null); } - return null; + const srv = server; + // Resolve only once the socket has actually closed, and clear state after + // shutdown completes so callers can await full termination. + return new Promise((resolve) => { + srv.close(() => { + server = null; + meta = null; + resolve(null); + }); + }); } module.exports = { startMockTokenServer, stopMockTokenServer }; From 160bdff65f6ce852ef26799c6e452e6efa0b43a9 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Tue, 28 Jul 2026 14:28:43 +0530 Subject: [PATCH 7/8] Remove uncaught:exception --- .../it/ui/cypress/e2e/applications/application-flows.cy.js | 4 ---- .../it/ui/cypress/e2e/auth/file-based-login.cy.js | 3 --- 2 files changed, 7 deletions(-) diff --git a/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js index d908359367..98812bb33e 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/applications/application-flows.cy.js @@ -44,10 +44,6 @@ describe('Applications', () => { }).its('status').should('be.oneOf', [200, 404]); // 200 = deleted; 404 = already gone (idempotent). Any other status is a real failure. }); - beforeEach(() => { - cy.on('uncaught:exception', () => false); - }); - it('creates, edits, and deletes an application', () => { const NAME = 'IT CRUD App'; const RENAMED = 'IT CRUD App Renamed'; diff --git a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js index 271024901a..1cfcffe820 100644 --- a/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js +++ b/portals/developer-portal/it/ui/cypress/e2e/auth/file-based-login.cy.js @@ -21,9 +21,6 @@ // backend/auth/file-based-login.spec.js — this spec is for the actual form UX. describe('file-based login UI', () => { - beforeEach(() => { - cy.on('uncaught:exception', () => false); - }); it('logs in through the local login form, shows the profile, and logs back out', () => { // Home → Log In → admin/admin → submit → lands back on the portal home From 3db4f2350f9b63ca405f391d08ae6b6f207fc7ad Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Tue, 28 Jul 2026 15:01:00 +0530 Subject: [PATCH 8/8] Remove req.destroy in mock token server --- portals/developer-portal/it/ui/mock-token-server.js | 1 - 1 file changed, 1 deletion(-) diff --git a/portals/developer-portal/it/ui/mock-token-server.js b/portals/developer-portal/it/ui/mock-token-server.js index a572088ab5..f58add3c01 100644 --- a/portals/developer-portal/it/ui/mock-token-server.js +++ b/portals/developer-portal/it/ui/mock-token-server.js @@ -81,7 +81,6 @@ function startMockTokenServer() { aborted = true; res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'payload_too_large' })); - req.destroy(); } }); req.on('end', () => {