diff --git a/.gitignore b/.gitignore index b6d8f8af2d..06c9df56df 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,15 @@ cypress.env.json /cypress/downloads/ /cypress/snapshots/actual -/cypress/snapshots/diff \ No newline at end of file +/cypress/snapshots/diff + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +.auth/ +.state/ +allure-results/ +allure-report/ diff --git a/auth.json b/auth.json new file mode 100644 index 0000000000..45ed451214 --- /dev/null +++ b/auth.json @@ -0,0 +1,14 @@ +{ + "cookies": [], + "origins": [ + { + "origin": "https://sherlockprivacy.qa.do.appknox.io", + "localStorage": [ + { + "name": "ember_simple_auth-session", + "value": "{\"authenticated\":{\"authenticator\":\"authenticator:irene\",\"token\":\"653b5bbdfffd105d36fce72207778e3b060f379bc87316ca18334e62988fa11f\",\"user_id\":1,\"b64token\":\"MTo2NTNiNWJiZGZmZmQxMDVkMzZmY2U3MjIwNzc3OGUzYjA2MGYzNzliYzg3MzE2Y2ExODMzNGU2Mjk4OGZhMTFm\"}}" + } + ] + } + ] +} \ No newline at end of file diff --git a/cypress/support/Actions/auth/LoginActions.ts b/cypress/support/Actions/auth/LoginActions.ts index f2778975a3..faed2a655d 100644 --- a/cypress/support/Actions/auth/LoginActions.ts +++ b/cypress/support/Actions/auth/LoginActions.ts @@ -98,7 +98,6 @@ export default class LoginActions { ).should('not.exist'); // Username/Email field cy.findByLabelText('login-next-button').should('not.exist'); // User check buttons - // Validate presence of access token in localStorage. cy.window() .its('localStorage') diff --git a/mirage/factories/sbom-component.ts b/mirage/factories/sbom-component.ts index 330278c46b..721b8e17c3 100644 --- a/mirage/factories/sbom-component.ts +++ b/mirage/factories/sbom-component.ts @@ -58,3 +58,5 @@ export default Factory.extend({ remediation: () => faker.lorem.paragraphs(), }); + + diff --git a/package-lock.json b/package-lock.json index 2d1070c346..98c08745d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@novnc/novnc": "^1.5.0", "@popperjs/core": "^2.11.6", "@storybook/types": "^8.4.6", + "@types/uuid": "^10.0.0", "clipboard": "^2.0.6", "cypress": "^13.6.3", "echarts": "^5.4.2", @@ -21,7 +22,8 @@ "iconify-icon": "^2.3.0", "posthog-js": "^1.284.0", "qrious": "^4.0.2", - "swagger-ui": "^5.30.3" + "swagger-ui": "^5.30.3", + "uuid": "^13.0.0" }, "devDependencies": { "@appknox/ember-pace": "^1.0.1", @@ -52,6 +54,7 @@ "@iconify-json/ph": "^1.2.2", "@iconify-json/solar": "^1.2.4", "@iconify/utils": "^3.0.1", + "@playwright/test": "^1.58.2", "@storybook/addon-actions": "8.4.6", "@storybook/addon-docs": "8.4.6", "@storybook/addon-essentials": "8.4.6", @@ -63,6 +66,7 @@ "@storybook/manager-webpack5": "6.5.16", "@testing-library/cypress": "^10.0.1", "@tsconfig/ember": "^3.0.4", + "@types/dotenv": "^6.1.1", "@types/ember": "^4.0.11", "@types/ember__application": "^4.0.11", "@types/ember__array": "^4.0.10", @@ -91,10 +95,13 @@ "@types/ember-qunit": "^6.1.1", "@types/ember-resolver": "^9.0.0", "@types/jquery": "^3.5.25", + "@types/node": "^25.6.0", "@types/qunit": "^2.19.10", "@types/rsvp": "^4.0.9", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", + "allure-commandline": "^2.37.0", + "allure-playwright": "^3.6.0", "broccoli-asset-rev": "^3.0.0", "buffer": "^6.0.3", "concurrently": "^8.2.2", @@ -1464,6 +1471,7 @@ "version": "4.47.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", "@webassemblyjs/helper-module-context": "1.9.0", @@ -1584,6 +1592,7 @@ "node_modules/@babel/core": { "version": "7.26.0", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -3402,6 +3411,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -3423,6 +3433,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -3489,6 +3500,15 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "license": "MIT", @@ -3562,6 +3582,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/graph/-/graph-5.3.9.tgz", "integrity": "sha512-mbwt7dDta7maKTsquBXpcU8hDf0Ukq9mtQNd2CPhkFgQ9YBt28NumBuiAviMr4HloOcJ8WA/GtX/RXIGP6yvEw==", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -3579,6 +3600,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/json-api/-/json-api-5.3.9.tgz", "integrity": "sha512-q+x+EFAKLT0WmrDe+7J1Yx9bn/KUDLU/QwJ2Vapnve05qXOOyXgjMF6ckSXBaulDIZksGGgTegNRzPHLB4o1fg==", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -3598,6 +3620,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/legacy-compat/-/legacy-compat-5.3.9.tgz", "integrity": "sha512-PGh9t+1DOwPQFJuWxuBlVFRxT7Q63pzoEvC0HXKVfL6pgvEZYPNQ6WNJ3H4MkD+zcdd6rWpCwjsdMq/VQRWfAQ==", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -3628,6 +3651,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/model/-/model-5.3.9.tgz", "integrity": "sha512-cYNkxiAvTCO67FMuPDagvvs/iYr68l9Dg40qB7em1Jhy0QmwpajvxkyEaj6q/fOrGaH69KmzHJhOvu4eKGTz8Q==", "license": "MIT", + "peer": true, "dependencies": { "@ember/edition-utils": "^1.2.0", "@embroider/macros": "^1.16.6", @@ -3671,6 +3695,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/request/-/request-5.3.9.tgz", "integrity": "sha512-odTe3B7eLt9HsrExkeIr6PwLP+uiS4chqu4JVxqDnCk8KpnOnbKgVnQQbIEFx8jTuv0y1vS+Jc9o6vynSV5YjQ==", "license": "MIT", + "peer": true, "dependencies": { "@ember/test-waiters": "^3.1.0", "@embroider/macros": "^1.16.6", @@ -3686,6 +3711,7 @@ "node_modules/@ember-data/request-utils": { "version": "5.3.9", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -4266,6 +4292,7 @@ "resolved": "https://registry.npmjs.org/@ember-data/store/-/store-5.3.9.tgz", "integrity": "sha512-3G24GtpgRKGlvTbF6Q6Uz/YQqxFrSfgvXpdqbSjMRu5UDd/EI7qkB+MOgD1rV6EBJ2xpic5+6a0q154lTL0dUg==", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -4283,6 +4310,7 @@ "node_modules/@ember-data/tracking": { "version": "5.3.9", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -5240,12 +5268,14 @@ }, "node_modules/@ember/string": { "version": "4.0.0", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@ember/test-helpers": { "version": "3.3.1", "devOptional": true, "license": "(MIT OR Apache-2.0)", + "peer": true, "dependencies": { "@ember/test-waiters": "^3.0.2", "@embroider/macros": "^1.10.0", @@ -5352,6 +5382,7 @@ "node_modules/@ember/test-waiters": { "version": "3.1.0", "license": "MIT", + "peer": true, "dependencies": { "calculate-cache-key-for-tree": "^2.0.0", "ember-cli-babel": "^7.26.6", @@ -7731,6 +7762,7 @@ "node_modules/@glimmer/component": { "version": "1.1.2", "license": "MIT", + "peer": true, "dependencies": { "@glimmer/di": "^0.1.9", "@glimmer/env": "^0.1.7", @@ -8569,6 +8601,7 @@ "node_modules/@glimmer/tracking": { "version": "1.1.2", "license": "MIT", + "peer": true, "dependencies": { "@glimmer/env": "^0.1.7", "@glimmer/validator": "^0.44.0" @@ -8661,10 +8694,21 @@ "typescript": ">=4.8.0" } }, + "node_modules/@glint/core/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@glint/environment-ember-loose": { "version": "1.5.0", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@glimmer/component": "^1.1.2", "@glint/template": "^1.5.0", @@ -8702,7 +8746,8 @@ }, "node_modules/@glint/template": { "version": "1.5.0", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@handlebars/parser": { "version": "2.0.0", @@ -10344,6 +10389,23 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/constants": { "version": "7.1.1", "dev": true, @@ -10456,6 +10518,13 @@ "storybook": "^8.4.6" } }, + "node_modules/@storybook/addon-actions/node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@storybook/addon-actions/node_modules/uuid": { "version": "9.0.1", "dev": true, @@ -10789,6 +10858,23 @@ } } }, + "node_modules/@storybook/builder-webpack5/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@storybook/channel-postmessage": { "version": "6.5.16", "dev": true, @@ -11813,6 +11899,7 @@ "version": "4.47.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", "@webassemblyjs/helper-module-context": "1.9.0", @@ -11940,6 +12027,23 @@ "storybook": "^8.4.6" } }, + "node_modules/@storybook/core-webpack/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@storybook/core-webpack/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@storybook/core/node_modules/ast-types": { "version": "0.16.1", "license": "MIT", @@ -13434,6 +13538,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13477,6 +13582,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13536,17 +13642,6 @@ "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-api-design-systems/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-ns-api-design-systems/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -13580,17 +13675,6 @@ "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-arazzo-1/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-ns-arazzo-1/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -13624,17 +13708,6 @@ "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-asyncapi-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-ns-asyncapi-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -13668,17 +13741,6 @@ "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-asyncapi-3/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-ns-asyncapi-3/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -13717,6 +13779,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13759,6 +13822,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13800,6 +13864,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13842,6 +13907,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13884,6 +13950,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -13922,17 +13989,6 @@ "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-ns-openapi-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-ns-openapi-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -13971,6 +14027,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -14015,6 +14072,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -14052,17 +14110,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14096,17 +14143,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14140,17 +14176,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14184,17 +14209,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14228,17 +14242,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14272,17 +14275,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14316,17 +14308,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14360,17 +14341,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14407,17 +14377,6 @@ "web-tree-sitter": "=0.24.5" } }, - "node_modules/@swagger-api/apidom-parser-adapter-json/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-json/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14451,17 +14410,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14495,17 +14443,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14539,17 +14476,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14583,17 +14509,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14627,17 +14542,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14671,17 +14575,6 @@ "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14748,17 +14641,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/ramda-adjunct": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", @@ -14776,18 +14658,6 @@ "ramda": ">= 0.30.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.1.0.tgz", @@ -14858,6 +14728,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -15019,6 +14890,16 @@ "@types/node": "*" } }, + "node_modules/@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ember": { "version": "4.0.11", "dev": true, @@ -15378,10 +15259,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.5", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -15518,8 +15401,9 @@ "license": "MIT" }, "node_modules/@types/uuid": { - "version": "9.0.8", - "dev": true, + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "license": "MIT" }, "node_modules/@types/webpack-env": { @@ -15571,6 +15455,7 @@ "version": "7.18.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -15846,6 +15731,7 @@ "node_modules/@warp-drive/core-types": { "version": "0.0.0-beta.12", "license": "MIT", + "peer": true, "dependencies": { "@embroider/macros": "^1.16.6", "@warp-drive/build-config": "0.0.0-beta.7" @@ -16120,6 +16006,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -16214,6 +16101,7 @@ "node_modules/ajv": { "version": "6.12.6", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16273,6 +16161,48 @@ "ajv": "^6.9.1" } }, + "node_modules/allure-commandline": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/allure-commandline/-/allure-commandline-2.37.0.tgz", + "integrity": "sha512-s3zZ8zjqo2U3i5Lb3iLOCjwWQCtGK58GVpScTnZddOpgTXBDXAbXn+pT7QXN4NiY7pho6xw+UgyREyCRnx/9ug==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "allure": "bin/allure" + } + }, + "node_modules/allure-js-commons": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.6.0.tgz", + "integrity": "sha512-RquIzMlh2l+ZLtq+Uxt+ZpOptxLzyPvLfPGbwU7dtgXo0QfUvhLFFkCljIR7Gjxo7jZ+WVqoto1yOPpXqU/i4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "md5": "^2.3.0" + }, + "peerDependencies": { + "allure-playwright": "3.6.0" + }, + "peerDependenciesMeta": { + "allure-playwright": { + "optional": true + } + } + }, + "node_modules/allure-playwright": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/allure-playwright/-/allure-playwright-3.6.0.tgz", + "integrity": "sha512-p/m41kdrgkKeJP0dgn/7yEQggxRBQEYdwRKUSfg2VEIfCP1va/6ph7dZNsYWKq+Hyj4Nd5DLs33DTS9VwEHe8w==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "allure-js-commons": "3.6.0" + }, + "peerDependencies": { + "@playwright/test": ">=1.53.0" + } + }, "node_modules/amd-name-resolver": { "version": "1.3.1", "license": "MIT", @@ -17481,6 +17411,7 @@ "node_modules/babel-plugin-ember-modules-api-polyfill": { "version": "3.5.0", "license": "MIT", + "peer": true, "dependencies": { "ember-rfc176-data": "^0.3.17" }, @@ -17509,6 +17440,7 @@ "node_modules/babel-plugin-htmlbars-inline-precompile": { "version": "5.3.1", "license": "MIT", + "peer": true, "dependencies": { "babel-plugin-ember-modules-api-polyfill": "^3.5.0", "line-column": "^1.0.2", @@ -20626,6 +20558,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -21037,6 +20970,16 @@ "dev": true, "license": "MIT" }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/charm": { "version": "1.0.2", "dev": true, @@ -22359,6 +22302,16 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cryptiles": { "version": "0.2.2", "dev": true, @@ -22524,6 +22477,7 @@ "version": "13.17.0", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", @@ -23689,6 +23643,7 @@ "version": "8.4.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.26.0", "@embroider/addon-shim": "^1.9.0", @@ -27955,6 +27910,16 @@ "node": "6.* || >= 7.*" } }, + "node_modules/ember-cli-deploy-cloudfront/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/ember-cli-deploy-display-revisions": { "version": "2.1.2", "dev": true, @@ -30603,6 +30568,7 @@ "version": "4.0.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13", "@babel/types": "^7.12.13", @@ -31432,6 +31398,7 @@ "node_modules/ember-data": { "version": "5.3.9", "license": "MIT", + "peer": true, "dependencies": { "@ember-data/adapter": "5.3.9", "@ember-data/debug": "5.3.9", @@ -35467,6 +35434,7 @@ "version": "4.0.3", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "ember-cli-babel": "^7.26.11" }, @@ -37076,6 +37044,7 @@ "version": "4.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@embroider/addon-shim": "^1.8.7", "decorator-transforms": "^2.0.0", @@ -38081,6 +38050,7 @@ "version": "8.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@embroider/addon-shim": "^1.8.6", "@embroider/macros": "^1.13.1", @@ -39048,6 +39018,7 @@ "node_modules/ember-source": { "version": "5.12.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.24.4", "@ember/edition-utils": "^1.2.0", @@ -44134,6 +44105,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -44202,6 +44174,7 @@ "version": "9.1.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -47402,7 +47375,8 @@ }, "node_modules/immutable": { "version": "5.0.3", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -49258,6 +49232,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/md5.js": { "version": "1.3.5", "dev": true, @@ -49699,6 +49685,7 @@ "version": "0.1.48", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@miragejs/pretender-node-polyfill": "^0.1.0", "inflected": "^2.0.4", @@ -54144,6 +54131,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -54299,6 +54287,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==", "dev": true, + "peer": true, "engines": { "node": ">=8.0.0" } @@ -54901,8 +54890,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-android-arm64": { "version": "4.52.2", @@ -54915,8 +54903,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-darwin-arm64": { "version": "4.52.2", @@ -54929,8 +54916,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-darwin-x64": { "version": "4.52.2", @@ -54943,8 +54929,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.52.2", @@ -54957,8 +54942,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-freebsd-x64": { "version": "4.52.2", @@ -54971,8 +54955,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.52.2", @@ -54985,8 +54968,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.52.2", @@ -54999,8 +54981,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.52.2", @@ -55013,8 +54994,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.52.2", @@ -55027,8 +55007,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.52.2", @@ -55041,8 +55020,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.52.2", @@ -55055,8 +55033,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.52.2", @@ -55069,8 +55046,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.52.2", @@ -55083,8 +55059,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.52.2", @@ -55097,8 +55072,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.52.2", @@ -55111,8 +55085,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.52.2", @@ -55125,8 +55098,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.52.2", @@ -55139,8 +55111,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.52.2", @@ -55153,8 +55124,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.52.2", @@ -55167,8 +55137,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.52.2", @@ -55181,8 +55150,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.52.2", @@ -55195,8 +55163,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/netlify-cli/node_modules/@sec-ant/readable-stream": { "version": "0.4.1", @@ -55312,6 +55279,7 @@ "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -55854,6 +55822,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -55896,6 +55865,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -59272,7 +59242,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -62519,6 +62488,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -62568,6 +62538,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -64568,6 +64539,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -65884,6 +65856,16 @@ "which": "^2.0.2" } }, + "node_modules/node-notifier/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-releases": { "version": "2.0.19", "license": "MIT" @@ -67084,6 +67066,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "6.0.0", "dev": true, @@ -67167,6 +67196,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -67346,6 +67376,7 @@ "version": "3.4.2", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -67757,6 +67788,7 @@ "version": "2.23.1", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "commander": "7.2.0", "node-watch": "0.7.3", @@ -67851,6 +67883,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -67897,6 +67930,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -68182,7 +68216,8 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -68893,7 +68928,8 @@ }, "node_modules/route-recognizer": { "version": "0.3.4", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/rsvp": { "version": "3.6.2", @@ -69155,6 +69191,7 @@ "node_modules/schema-utils/node_modules/ajv": { "version": "8.17.1", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -70227,6 +70264,7 @@ "node_modules/storybook": { "version": "8.4.6", "license": "MIT", + "peer": true, "dependencies": { "@storybook/core": "8.4.6" }, @@ -70644,6 +70682,7 @@ "version": "15.11.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.3.1", "@csstools/css-tokenizer": "^2.2.0", @@ -70881,6 +70920,7 @@ "version": "6.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -71022,6 +71062,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -71096,6 +71137,7 @@ "node_modules/swagger-ui/node_modules/immutable": { "version": "3.8.2", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -71953,6 +71995,7 @@ "version": "3.4.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@embroider/addon-shim": "^1.8.7", "decorator-transforms": "^2.0.0", @@ -72348,6 +72391,7 @@ "version": "5.7.2", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -72428,7 +72472,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unfetch": { @@ -72748,10 +72794,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache": { @@ -73296,6 +73348,7 @@ "node_modules/webpack": { "version": "5.97.1", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", diff --git a/package.json b/package.json index 5fbae752b2..b4c811b34d 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,14 @@ "storybook-dev": "concurrently \"npm:start\" \"npm:storybook\" --names \"dev-server,storybook\"", "prebuild-storybook": "ember build", "build-storybook": "storybook build", - "netlify:deploy": "netlify deploy --dir dist" + "netlify:deploy": "netlify deploy --dir dist", + "playwright:qa": "cross-env TEST_ENV=qa npx playwright test", + "playwright:prod": "cross-env TEST_ENV=prod npx playwright test", + "playwright:report": "npx allure generate allure-results --clean -o allure-report && npx allure open allure-report", + "playwright:qa:api": "cross-env TEST_ENV=qa npx playwright test --project=api", + "playwright:qa:ui": "cross-env TEST_ENV=qa npx playwright test --project=ui", + "playwright:prod:api": "cross-env TEST_ENV=prod npx playwright test --project=api", + "playwright:prod:ui": "cross-env TEST_ENV=prod npx playwright test --project=ui" }, "devDependencies": { "@appknox/ember-pace": "^1.0.1", @@ -66,6 +73,7 @@ "@iconify-json/ph": "^1.2.2", "@iconify-json/solar": "^1.2.4", "@iconify/utils": "^3.0.1", + "@playwright/test": "^1.58.2", "@storybook/addon-actions": "8.4.6", "@storybook/addon-docs": "8.4.6", "@storybook/addon-essentials": "8.4.6", @@ -77,6 +85,7 @@ "@storybook/manager-webpack5": "6.5.16", "@testing-library/cypress": "^10.0.1", "@tsconfig/ember": "^3.0.4", + "@types/dotenv": "^6.1.1", "@types/ember": "^4.0.11", "@types/ember__application": "^4.0.11", "@types/ember__array": "^4.0.10", @@ -105,10 +114,13 @@ "@types/ember-qunit": "^6.1.1", "@types/ember-resolver": "^9.0.0", "@types/jquery": "^3.5.25", + "@types/node": "^25.6.0", "@types/qunit": "^2.19.10", "@types/rsvp": "^4.0.9", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", + "allure-commandline": "^2.37.0", + "allure-playwright": "^3.6.0", "broccoli-asset-rev": "^3.0.0", "buffer": "^6.0.3", "concurrently": "^8.2.2", @@ -209,6 +221,7 @@ "@novnc/novnc": "^1.5.0", "@popperjs/core": "^2.11.6", "@storybook/types": "^8.4.6", + "@types/uuid": "^10.0.0", "clipboard": "^2.0.6", "cypress": "^13.6.3", "echarts": "^5.4.2", @@ -217,7 +230,8 @@ "iconify-icon": "^2.3.0", "posthog-js": "^1.284.0", "qrious": "^4.0.2", - "swagger-ui": "^5.30.3" + "swagger-ui": "^5.30.3", + "uuid": "^13.0.0" }, "overrides": { "@storybook/ember": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..ad7dccaec4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,117 @@ +// import { defineConfig } from '@playwright/test'; +// import dotenv from 'dotenv'; +// import path from 'path'; + +// const env = process.env.TEST_ENV || 'qa' || 'prod'; + +// dotenv.config({ +// path: path.resolve(__dirname, `.env.${env}`), +// }); + +// export default defineConfig({ +// testDir: './playwright/specs', +// fullyParallel: true, +// retries: process.env.CI ? 1 : 1, +// workers: process.env.CI ? 4 : 2, +// globalSetup: './playwright/global.setup.ts', +// reporter: [ +// ['html', { open: 'never' }], +// ['line'], +// [ +// 'allure-playwright', +// { +// detail: true, +// outputFolder: 'allure-results', +// suiteTitle: true, +// environmentInfo: { +// Environment: process.env.ENVIRONMENT || env.toUpperCase(), +// BaseURL: process.env.BASE_URL, +// Platform: process.platform, +// }, +// }, +// ], +// ], + +// use: { +// baseURL: process.env.BASE_URL, +// viewport: { width: 1450, height: 1650 }, +// testIdAttribute: 'data-test-cy', +// // storageState: '.auth/user.json', +// trace: 'on-first-retry', +// screenshot: 'only-on-failure', +// video: 'retain-on-failure', +// actionTimeout: 10000, +// navigationTimeout: 60000, +// }, +// }); +import { defineConfig } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +const env = process.env.TEST_ENV || 'qa'; + +dotenv.config({ + path: path.resolve(__dirname, `.env.${env}`), +}); + +export default defineConfig({ + testDir: './playwright/specs', + fullyParallel: true, + retries: process.env.CI ? 1 : 1, + workers: process.env.CI ? 4 : 2, + globalSetup: './playwright/global.setup.ts', + reporter: [ + ['html', { open: 'never' }], + ['line'], + [ + 'allure-playwright', + { + detail: true, + outputFolder: 'allure-results', + suiteTitle: true, + environmentInfo: { + Environment: process.env.ENVIRONMENT || env.toUpperCase(), + BaseURL: process.env.BASE_URL, + Platform: process.platform, + }, + }, + ], + ], + + projects: [ + { + name: 'api', + testMatch: '**/api/*.spec.ts', + use: { + baseURL: process.env.BASE_URL, + viewport: { width: 1450, height: 1650 }, + testIdAttribute: 'data-test-cy', + actionTimeout: 10000, + navigationTimeout: 60000, + }, + }, + { + name: 'ui-setup', + testMatch: '**/ui.setup.spec.ts', + use: { + baseURL: process.env.BASE_URL, + }, + }, + { + name: 'ui', + testMatch: '**/specs/*.spec.ts', + dependencies: ['ui-setup'], + use: { + baseURL: process.env.BASE_URL, + viewport: { width: 1450, height: 1650 }, + testIdAttribute: 'data-test-cy', + storageState: '.auth/user.json', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 10000, + navigationTimeout: 60000, + }, + }, + ], +}); diff --git a/playwright/Actions/api/api-client.ts b/playwright/Actions/api/api-client.ts new file mode 100644 index 0000000000..babf857af5 --- /dev/null +++ b/playwright/Actions/api/api-client.ts @@ -0,0 +1,64 @@ +import { APIRequestContext, request } from '@playwright/test'; + +export default class ApiClient { + private context!: APIRequestContext; + private baseURL: string; + private token: string | null = null; + private userId: string | number | null = null; + + constructor() { + this.baseURL = process.env.BASE_URL!; + } + + async init(): Promise { + this.context = await request.newContext({ + baseURL: this.baseURL, + extraHTTPHeaders: { + 'Content-Type': 'application/json', + }, + }); + } + + setToken(token: string, userId: string | number): void { + this.token = token; + this.userId = userId; + } + + private getAuthHeaders(): Record { + if (!this.token) return {}; + const b64 = Buffer.from(`${this.userId}:${this.token}`).toString('base64'); + return { + Authorization: `Basic ${b64}`, + }; + } + async get(endpoint: string) { + return this.context.get(endpoint, { + headers: this.getAuthHeaders(), + }); + } + + async post(endpoint: string, body?: object) { + return this.context.post(endpoint, { + headers: this.getAuthHeaders(), + ...(body && Object.keys(body).length > 0 ? { data: body } : {}), // only send data if body has content + }); + } + + async put(endpoint: string, body?: object) { + return this.context.put(endpoint, { + headers: this.getAuthHeaders(), + data: body, + }); + } + + async delete(endpoint: string, body?: object) { + return this.context.delete(endpoint, { + headers: this.getAuthHeaders(), + data: body, + }); + } + + async dispose(): Promise { + await this.context.dispose(); + } +} diff --git a/playwright/Actions/api/request.wrapper.ts b/playwright/Actions/api/request.wrapper.ts new file mode 100644 index 0000000000..bed0abf44b --- /dev/null +++ b/playwright/Actions/api/request.wrapper.ts @@ -0,0 +1,86 @@ +import { APIResponse } from '@playwright/test'; +import ApiClient from './api-client'; +import TokenManager from './token.manager'; + +export interface RequestOptions { + endpoint: string; + body?: object; + requiresAuth?: boolean; +} + +export default class RequestWrapper { + private client: ApiClient; + + constructor() { + this.client = new ApiClient(); + } + + /** + * Initialize client and attach auth token + */ + async init(): Promise { + await this.client.init(); + + const { token, user_id } = await TokenManager.getTokens(); + this.client.setToken(token, user_id); + } + + /** + * GET request with logging + */ + async get(opts: RequestOptions): Promise { + console.log(`[GET] ${opts.endpoint}`); + + const response = await this.client.get(opts.endpoint); + + console.log(`[GET] ${opts.endpoint} → ${response.status()}`); + + return response; + } + + /** + * POST request with logging + */ + async post(opts: RequestOptions): Promise { + console.log(`[POST] ${opts.endpoint}`); + + const response = await this.client.post(opts.endpoint, opts.body); + + console.log(`[POST] ${opts.endpoint} → ${response.status()}`); + + return response; + } + + /** + * PUT request with logging + */ + async put(opts: RequestOptions): Promise { + console.log(`[PUT] ${opts.endpoint}`); + + const response = await this.client.put(opts.endpoint, opts.body); + + console.log(`[PUT] ${opts.endpoint} → ${response.status()}`); + + return response; + } + + /** + * DELETE request with logging + */ + async delete(opts: RequestOptions): Promise { + console.log(`[DELETE] ${opts.endpoint}`); + + const response = await this.client.delete(opts.endpoint, opts.body); + + console.log(`[DELETE] ${opts.endpoint} → ${response.status()}`); + + return response; + } + + /** + * Dispose client after tests + */ + async dispose(): Promise { + await this.client.dispose(); + } +} diff --git a/playwright/Actions/api/token.manager.ts b/playwright/Actions/api/token.manager.ts new file mode 100644 index 0000000000..2e3e3426c0 --- /dev/null +++ b/playwright/Actions/api/token.manager.ts @@ -0,0 +1,52 @@ +import { request } from '@playwright/test'; +import { API_ROUTES } from '../../support/api.routes'; + +export interface AuthTokens { + token: string; + user_id: number; +} + +export default class TokenManager { + private static tokens: AuthTokens | null = null; + + /** + * Login and get tokens from real QA backend + * Stores tokens for reuse across all API tests + * Returns cached tokens if already fetched once + */ + static async getTokens(): Promise { + if (this.tokens) return this.tokens; + + const context = await request.newContext({ + baseURL: process.env.BASE_URL!, + }); + + const response = await context.post(API_ROUTES.login.route, { + data: { + username: process.env.TEST_USERNAME!, + password: process.env.TEST_PASSWORD!, + }, + }); + + if (!response.ok()) { + throw new Error(`Login failed with status ${response.status()}`); + } + + const body = await response.json(); + this.tokens = { + token: body.token, + user_id: body.user_id, + }; + + await context.dispose(); + + return this.tokens; + } + + /** + * Clear cached tokens + */ + static clearTokens(): void { + this.tokens = null; + } +} diff --git a/playwright/Actions/auth/loginActions.ts b/playwright/Actions/auth/loginActions.ts new file mode 100644 index 0000000000..91d804ee0b --- /dev/null +++ b/playwright/Actions/auth/loginActions.ts @@ -0,0 +1,32 @@ +import { Page, expect } from '@playwright/test'; +import pwTranslate from '../../support/translations'; + +export default class LoginActions { + constructor(private page: Page) {} + + async login(username: string, password: string) { + await this.page.goto('/login'); + + await this.page + .getByPlaceholder(pwTranslate('usernameEmailIdTextPlaceholder')) + .fill(username); + + const nextButton = this.page.getByRole('button', { name: 'Next' }); + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + + const passwordInput = this.page.getByPlaceholder( + pwTranslate('passwordPlaceholder') + ); + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(password); + + const loginButton = this.page.locator( + 'button[aria-label="login-submit-button"]' + ); + await expect(loginButton).toBeEnabled(); + await loginButton.click(); + + await expect(this.page).toHaveURL(/dashboard/, { timeout: 15000 }); + } +} diff --git a/playwright/Actions/network.actions.ts b/playwright/Actions/network.actions.ts new file mode 100644 index 0000000000..e63fc84dfd --- /dev/null +++ b/playwright/Actions/network.actions.ts @@ -0,0 +1,89 @@ +import { Page, Route } from '@playwright/test'; + +export interface PaginatedResDataOverrideProps { + count?: number; + next?: string | null; + previous?: string | null; + results?: Array; +} + +export interface MockRequestOptions { + method?: string; + route: string; + status?: number; + dataOverride?: object; +} + +export default class NetworkActions { + constructor(private page: Page) {} + + /** + * Mock any network request with full response override + * Equivalent to Cypress networkActions.mockNetworkReq + */ + async mockNetworkReq(opts: MockRequestOptions): Promise { + const { method = 'GET', route, dataOverride = {}, status = 200 } = opts; + + await this.page.route(`**${route}`, (pwRoute: Route) => { + if (pwRoute.request().method() !== method) { + return pwRoute.continue(); + } + + pwRoute.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(dataOverride), + }); + }); + } + + /** + * Mock paginated response + * Equivalent to Cypress networkActions.mockPaginatedNetworkReq + */ + async mockPaginatedNetworkReq(opts: { + method?: string; + route: string; + resDataOverride?: PaginatedResDataOverrideProps; + }): Promise { + const { method = 'GET', route, resDataOverride } = opts; + + await this.page.route(`**${route}`, (pwRoute: Route) => { + if (pwRoute.request().method() !== method) { + return pwRoute.continue(); + } + + pwRoute.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + count: resDataOverride?.results?.length ?? 0, + next: null, + previous: null, + ...resDataOverride, + }), + }); + }); + } + + /** + * Wait for a specific response + * Equivalent to cy.wait('@alias') + */ + async waitForResponse( + urlPattern: string, + expectedStatus = 200 + ): Promise { + await this.page.waitForResponse( + (res) => res.url().includes(urlPattern) && res.status() === expectedStatus + ); + } + + /** + * Clear all route mocks + * Call in afterEach to prevent bleed between tests + */ + async clearAll(): Promise { + await this.page.unrouteAll(); + } +} diff --git a/playwright/fixtures/DVIA.ipa b/playwright/fixtures/DVIA.ipa new file mode 100644 index 0000000000..4b094c7bc5 Binary files /dev/null and b/playwright/fixtures/DVIA.ipa differ diff --git a/playwright/fixtures/MFVA.aab b/playwright/fixtures/MFVA.aab new file mode 100644 index 0000000000..22889ba78b Binary files /dev/null and b/playwright/fixtures/MFVA.aab differ diff --git a/playwright/fixtures/MFVA.apk b/playwright/fixtures/MFVA.apk new file mode 100644 index 0000000000..3f101242b6 Binary files /dev/null and b/playwright/fixtures/MFVA.apk differ diff --git a/playwright/global.setup.ts b/playwright/global.setup.ts new file mode 100644 index 0000000000..2b931c7d76 --- /dev/null +++ b/playwright/global.setup.ts @@ -0,0 +1,355 @@ +import TokenManager from './Actions/api/token.manager'; +import RequestWrapper from './Actions/api/request.wrapper'; +import { API_ROUTES, resolveRoute } from './support/api.routes'; +import dotenv from 'dotenv'; +import path from 'path'; +import fs from 'fs'; + +dotenv.config({ path: path.resolve(__dirname, '../.env.qa') }); + +const STATE_DIR = path.resolve(__dirname, '../.state'); +const STATE_FILE = path.join( + STATE_DIR, + `${process.env.ENVIRONMENT || 'qa'}-state.json` +); +const POLL_INTERVAL = 15000; //poll every 15 seconds means we are giving the backend enough time to process the file and generate reports without overwhelming it with too many requests. +const POLL_TIMEOUT = 600000; //10 minutes timeout is a reasonable upper limit for file processing and report generation. + +async function globalSetup() { + /** + 0. Clean allure-results + Before we start the setup, we want to clean up any existing allure-results from previous test runs. + This ensures that our test reports will only include data from the current test run and prevents confusion caused by stale results. + */ + const allureResultsDir = path.resolve(__dirname, '../allure-results'); + if (fs.existsSync(allureResultsDir)) { + fs.rmSync(allureResultsDir, { recursive: true, force: true }); + console.log('[Setup] allure-results cleaned'); + } else { + console.log('[Setup] allure-results not found — nothing to clean'); + } + + /** + 1. API setup + we initialize our API request wrapper, which will handle making authenticated API requests using the tokens obtained from the UI login. + This wrapper will read the saved auth state to include the necessary authentication headers in each request. + With the API wrapper ready, we can start making API calls to set up our test data. + */ + const wrapper = new RequestWrapper(); + await wrapper.init(); + /** + 2. Get orgId + Many API routes require an orgId, so we make an authenticated request to the /api/organizations endpoint to retrieve + the list of organizations and extract the orgId we need for subsequent API calls. + orgId is a fundamental piece of data that links all our test entities together (projects, files, reports), so we need to fetch it first before we can do anything else. + */ + const orgResponse = await wrapper.get({ + endpoint: API_ROUTES.organizations.route, + }); + const orgBody = await orgResponse.json(); + const orgId = orgBody.results[0].id as number; + console.log(`[Setup] orgId: ${orgId}`); + + /** + 3. Get org features + We also want to check which features are enabled for our organization, as this can impact which tests we can run and how we should run them. + By hitting the organization details endpoint with our orgId, we can get the list of features that are enabled for our org and save that information in our state file. + This way, our tests can conditionally skip or modify their behavior based on whether certain features are available in the org. + */ + const orgFeatures = orgBody.results[0].features; + console.log('[Setup] Features:', JSON.stringify(orgFeatures)); + + /** + 5. Upload MFVA.apk fresh + To ensure we have a consistent test file to work with, we upload the MFVA.apk file at the start of our setup. + fresh upload ensures that we have a known fileId and reportId that we can use in our tests, + rather than relying on potentially stale data from previous test runs. + 1.The upload process involves first requesting a signed URL from the backend, + 2.then uploading the file directly to S3 using that URL, + 3.finally confirming the upload with the backend so it can start processing the file. + */ + console.log('[Setup] Uploading MFVA.apk...'); + + const initResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, orgId), + }); + const initBody = await initResponse.json(); + const { + url: s3Url, + file_key: fileKey, + file_key_signed: fileKeySigned, + } = initBody; + + const filePath = path.resolve(__dirname, 'fixtures/MFVA.apk'); + const fileBuffer = fs.readFileSync(filePath); + const s3Response = await fetch(s3Url, { + method: 'PUT', + body: fileBuffer, + headers: { 'Content-Type': 'application/octet-stream' }, + }); + if (!s3Response.ok) + throw new Error(`[Setup] S3 upload failed: ${s3Response.status}`); + + const confirmResponse = await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, orgId), + body: { file_key: fileKey, file_key_signed: fileKeySigned, url: s3Url }, + }); + const confirmBody = await confirmResponse.json(); + const submissionId = confirmBody.submission_id as number; + console.log(`[Setup] Upload confirmed, submissionId: ${submissionId}`); + /** + 6.get fileId by polling submission details + After confirming the upload, the backend will start processing the file, which includes creating a file record and associating it with our submission. + By hiiting the submission details endpoint, we can check if the file has been created and get its fileId. We need to poll this endpoint until we see that the file has been created, + which indicates that the upload and initial processing steps are complete and we can move on to generating reports. + */ + console.log('[Setup] Waiting for file to be processed...'); + const startTime = Date.now(); + let fileId: number = 0; + + while (true) { + const submissionResponse = await wrapper.get({ + endpoint: `/api/submissions/${submissionId}`, + }); + const submissionBody = await submissionResponse.json(); + + if (submissionBody.file) { + fileId = submissionBody.file as number; + console.log(`[Setup] fileId: ${fileId}`); + break; + } + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('[Setup] File processing timed out'); + } + + console.log('[Setup] Waiting for file... 15s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + + /** + 4. Get projectId and fileId + Before we can generate reports, we need to have a project with an uploaded file. We will upload the MFVA.apk file, which is a known test app in our system. When we upload this app, + it will be associated with a project (if it doesn't already exist) and a file record will be created for that upload. + We need the projectId to upload our app and the fileId to generate reports, so we have to find these IDs before we can proceed with the rest of the setup. + */ + + const projectListResponse = await wrapper.get({ + endpoint: `${API_ROUTES.v3ProjectList.route.replace('*', '')}?limit=1&q=com.appknox.mfva`, + }); + const projectListBody = await projectListResponse.json(); + const mfvaProject = projectListBody.results[0]; + + if (!mfvaProject) throw new Error('[Setup] MFVA project not found'); + + const projectId = mfvaProject.id as number; + const profileId = mfvaProject.active_profile_id as number; + console.log(`[Setup] projectId: ${projectId}, profileId: ${profileId}`); + + /** + 7. Poll until static scan done + After the file is processed, the backend will start running static analysis on it. This can take some time, so we need to poll the file details until + we see that the static scan is complete (is_static_done = true) before we can proceed with generating reports. + If we try to generate a report before the static scan is done, it will fail, so this is a necessary step to ensure our setup is correct and our tests will run reliably. + The polling mechanism checks the file details every 15 seconds and will timeout after 10 minutes if the static scan hasn't completed, + which helps prevent our tests from hanging indefinitely if something goes wrong with the file processing. + */ + console.log('[Setup] Waiting for static scan to complete...'); + while (true) { + const fileResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileById.route, fileId), + }); + const fileBody = await fileResponse.json(); + + if (fileBody.is_static_done === true) { + console.log('[Setup] Static scan complete'); + break; + } + + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('[Setup] Static scan timed out after 10 minutes'); + } + + console.log('[Setup] Static scan in progress... waiting 15s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + + /** + 8. Get analysisId + With the static scan complete, we can now get the analysisId for our file, which we will need for some of our tests that require an existing analysis. + We make a request to the file analyses endpoint with our fileId to get the list of analyses for that file, and we extract the ID of the first analysis in the list. + This analysisId will be saved in our state file along with the other IDs for use in our tests. + */ + + const analysisResponse = await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileAnalyses.route, fileId)}?limit=10`, + }); + const analysisBody = await analysisResponse.json(); + + const riskyAnalysis = analysisBody.results.find( + (a: Record) => (a.computed_risk as number) > 0 + ); + + const analysisId = riskyAnalysis.id as number; + const vulnerabilityId = riskyAnalysis.vulnerability as number; + console.log( + `[Setup] analysisId: ${analysisId}, vulnerabilityId: ${vulnerabilityId}` + ); + + /** + 9. Generate report + With the file uploaded and static scan complete, we can now generate a report for our file. We make a POST request to the reports endpoint with our fileId, + which tells the backend to start generating a report for that file. + The response will include a reportId, which we will need to poll for the PDF generation in the next step. + */ + const generateResponse = await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.reports.route, fileId), + }); + const generateBody = await generateResponse.json(); + const reportId = generateBody.id as number; + console.log(`[Setup] Report generated, reportId: ${reportId}`); + + /** + 10. Poll until PDF ready + After requesting report generation, the backend will start processing the report, which includes generating a PDF version of the report. This can take some time, + so we need to poll the report PDF endpoint until it returns a successful response, which indicates that the PDF is ready to be downloaded. + */ + console.log('[Setup] Waiting for report PDF to be ready...'); + while (true) { + const pdfResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.reportPdf.route, reportId), + }); + + if (pdfResponse.ok()) { + console.log('[Setup] Report PDF ready'); + break; + } + + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('[Setup] Report PDF timed out'); + } + + console.log('[Setup] Report not ready yet... waiting 15s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + /** + 11. Get privacy report ID + In addition to the main report, we also want to generate a privacy report for our file. + We make a GET request to the privacy report endpoint with our fileId, which will return the details of the privacy report, including its ID. + We save this privacyReportId along with the other IDs in our state file, so that our tests can use it to make API requests related to the privacy report. + */ + + let privacyReportId: number = 0; + + if (orgFeatures.privacy) { + console.log('[Setup] Waiting for privacy shield analysis...'); + while (true) { + const privacyResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.privacyReport.route, fileId), + }); + const privacyBody = await privacyResponse.json(); + + if (privacyBody.privacy_analysis_status === '2') { + privacyReportId = privacyBody.id as number; + console.log( + `[Setup] Privacy shield complete privacyReportId: ${privacyReportId}` + ); + break; + } + + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('[Setup] Privacy shield analysis timed out'); + } + + console.log('[Setup] Privacy shield in progress... waiting 15s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + } else { + console.log('[Setup] Privacy Shield disabled — skipping'); + } + // 12. Wait for SBOM scan to complete for our uploaded file + + let sbFileId: number = 0; + let sbReportId: number = 0; + + if (orgFeatures.sbom) { + console.log('[Setup] Waiting for SBOM scan...'); + while (true) { + const sbProjectsResponse = await wrapper.get({ + endpoint: `${API_ROUTES.sbProjects.route}?limit=100`, + }); + const sbProjectsBody = await sbProjectsResponse.json(); + const mfvaSbProject = sbProjectsBody.results.find( + (p: Record) => p.project === projectId + ); + + if (mfvaSbProject) { + const sbFileResponse = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.sbFileById.route, + mfvaSbProject.latest_sb_file + ), + }); + const sbFileBody = await sbFileResponse.json(); + + if (sbFileBody.file === fileId && sbFileBody.status === 3) { + sbFileId = mfvaSbProject.latest_sb_file as number; + console.log(`[Setup] SBOM scan complete sbFileId: ${sbFileId}`); + break; + } + } + + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('[Setup] SBOM scan timed out'); + } + + console.log('[Setup] Waiting for SBOM scan... 15s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + + const sbReportsResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.sbReports.route, sbFileId), + }); + const sbReportsBody = await sbReportsResponse.json(); + sbReportId = sbReportsBody.results[0].id as number; + console.log(`[Setup] sbReportId: ${sbReportId}`); + } else { + console.log('[Setup] SBOM disabled — skipping'); + } + + /** + 13. Save state + Finally, we save all the important IDs we obtained during the setup process (orgId, projectId, fileId, reportId, privacyReportId) to a JSON file in the .state directory. + This state file will be read by our tests to get the necessary IDs for making API requests and validating test scenarios. By saving this state at the end of the setup, + we ensure that all our tests have access to the correct and up-to-date information they need to run successfully. + We also clear the tokens from the TokenManager and dispose of the API wrapper to clean up any resources used during the setup process. + */ + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + const state = { + orgId, + projectId, + fileId, + reportId, + privacyReportId, + sbFileId, + sbReportId, + analysisId, + vulnerabilityId, + profileId, + features: { + privacy: orgFeatures.privacy as boolean, + sbom: orgFeatures.sbom as boolean, + manualscan: orgFeatures.manualscan as boolean, + dynamicscan_automation: orgFeatures.dynamicscan_automation as boolean, + upload_via_url: orgFeatures.upload_via_url as boolean, + }, + }; + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + console.log(`[Setup] State saved to ${STATE_FILE}`); + console.log('[Setup] Done', state); + + TokenManager.clearTokens(); + await wrapper.dispose(); +} + +export default globalSetup; diff --git a/playwright/specs/api/auth.api.spec.ts b/playwright/specs/api/auth.api.spec.ts new file mode 100644 index 0000000000..a27bfb59f4 --- /dev/null +++ b/playwright/specs/api/auth.api.spec.ts @@ -0,0 +1,306 @@ +import { expect, test } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import { API_ROUTES } from '../../support/api.routes'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; + +let wrapper: RequestWrapper; +let userId: string | number; + +test.describe('Auth API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + const tokens = await TokenManager.getTokens(); + userId = tokens.user_id; + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('POST /api/login — valid credentials returns token', async () => { + await allure.epic('Authentication'); + await allure.feature('Login'); + await allure.story('User logs in with valid credentials'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'login', 'smoke', 'positive'); + await allure.description(` + Verifies that a valid username and password returns: + - HTTP 200 + - token (string) + - user_id (number) + `); + + const response = await allure.step( + 'POST login with valid credentials', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: process.env.TEST_USERNAME!, + password: process.env.TEST_PASSWORD!, + }, + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and required fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['token', 'user_id'], + }); + } + ); + + await allure.step('Validate response schema', async () => { + SchemaValidator.validate(body, { + token: { type: 'string', required: true }, + user_id: { type: 'number', required: true }, + }); + }); + }); + + test('POST /api/login — invalid credentials returns 401 Or 403', async () => { + await allure.epic('Authentication'); + await allure.feature('Login'); + await allure.story('User logs in with invalid credentials'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'login', 'negative', 'regression'); + await allure.description(` + Verifies that wrong username and password returns: + - HTTP 403 Forbidden + Backend must reject invalid credentials correctly. + `); + + const response = await allure.step( + 'POST login with invalid credentials', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: 'invalid_user', + password: 'wrong_password', + }, + }); + } + ); + + await allure.step('Validate status 401 or 403', async () => { + expect([401, 403]).toContain(response.status()); + }); + }); + + test('GET /api/users — authenticated user returns correct schema', async () => { + await allure.epic('Authentication'); + await allure.feature('User Profile'); + await allure.story('Authenticated user fetches their profile'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('auth', 'user', 'profile', 'smoke'); + await allure.description(` + Verifies that an authenticated user can fetch their profile and response contains: + - HTTP 200 + - data.attributes.username (string) + - data.attributes.email (string) + - data.attributes.lang (string) + `); + + const tokens = await TokenManager.getTokens(); + + const testWrapper = new RequestWrapper(); + await testWrapper.init(); + + const response = await allure.step( + `GET /api/users/${tokens.user_id}`, + async () => { + return await testWrapper.get({ + endpoint: `/api/users/${tokens.user_id}`, + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and required fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['data'], + }); + } + ); + + await allure.step('Validate user attributes schema', async () => { + const data = body.data as Record; + const attributes = data.attributes as Record; + + SchemaValidator.validate(attributes, { + username: { type: 'string', required: true }, + email: { type: 'string', required: true }, + lang: { type: 'string', required: true }, + }); + }); + }); + + test('POST /api/login — Empty Username returns 400', async () => { + allure.epic('Authentication'); + allure.feature('Login'); + allure.story('User attempts login with empty username'); + allure.severity('normal'); + allure.owner('Pranav'); + allure.tags('auth', 'login', 'negative', 'regression'); + + const respone = await allure.step( + 'POST login with empty username', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: '', + password: process.env.TEST_PASSWORD!, + }, + }); + } + ); + + await allure.step('Validate status 400 Bad Request', async () => { + await ResponseValidator.validate(respone, { status: 400 }); + }); + }); + + test('POST /api/login — Empty Password returns 400', async () => { + allure.epic('Authentication'); + allure.feature('Login'); + allure.story('User attempts login with empty password'); + allure.severity('normal'); + allure.owner('Pranav'); + allure.tags('auth', 'login', 'negative', 'regression'); + + const respone = await allure.step( + 'POST login with empty password', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: process.env.TEST_USERNAME!, + password: '', + }, + }); + } + ); + + await allure.step('Validate status 400 Bad Request', async () => { + await ResponseValidator.validate(respone, { status: 400 }); + }); + }); + + test('GET /api/users/:id — invalid user id returns 200', async () => { + await allure.epic('Authentication'); + await allure.feature('User Profile'); + await allure.severity('normal'); + await allure.tags('auth', 'user', 'negative'); + + const response = await allure.step('GET user with invalid id', async () => { + return await wrapper.get({ endpoint: '/api/users/999999' }); + }); + + const body = await allure.step( + 'Validate status 200 and verify returns authenticated user data', + async () => { + const body = await ResponseValidator.validate(response, { + status: 200, + }); + const data = body.data as Record; + expect(data.id).toBe(userId); + } + ); + }); + + test('GET /api/users/:id — no auth token returns 401', async () => { + await allure.epic('Authentication'); + await allure.feature('User Profile'); + await allure.severity('critical'); + await allure.tags('auth', 'user', 'negative', 'security'); + + // create fresh context with NO storage state + const { request } = await import('@playwright/test'); + const context = await request.newContext({ + baseURL: process.env.BASE_URL, + // no storageState, no headers + }); + + const response = await allure.step( + 'GET user without auth token', + async () => { + return await context.get(`/api/users/${userId}`); + } + ); + + await allure.step('Validate status 403', async () => { + expect(response.status()).toBe(403); + }); + + await context.dispose(); + }); + + test('POST /api/login — SQL Injection attempt returns 401 or 403', async () => { + await allure.epic('Authentication'); + await allure.feature('Login Security'); + await allure.story('User attempts SQL injection in login'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'login', 'negative', 'security'); + + const response = await allure.step( + 'POST login with SQL injection attempt', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: "' OR '1'='1", + password: "' OR '1'='1", + }, + }); + } + ); + + await allure.step('Validate status 403 or 401', async () => { + const status = response.status(); + + expect([401, 403]).toContain(status); + + await ResponseValidator.validate(response, { status }); + }); + }); + + test.skip('POST /api/login — XSS attempt returns 403', async () => { + await allure.epic('Authentication'); + await allure.feature('Login Security'); + await allure.severity('critical'); + await allure.tags('auth', 'login', 'negative', 'security'); + + const response = await allure.step( + 'POST login with XSS attempt', + async () => { + return await wrapper.post({ + endpoint: API_ROUTES.login.route, + body: { + username: '', + password: '', + }, + }); + } + ); + + await allure.step('Validate status 403', async () => { + await ResponseValidator.validate(response, { status: 401 }); + }); + }); +}); diff --git a/playwright/specs/api/dynamic-scan.api.spec.ts b/playwright/specs/api/dynamic-scan.api.spec.ts new file mode 100644 index 0000000000..b0369b1d06 --- /dev/null +++ b/playwright/specs/api/dynamic-scan.api.spec.ts @@ -0,0 +1,704 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; +import { + waitForDeviceAvailable, + waitForScanRunning, + waitForScanStopped, +} from '../../utils/dynamic-scan.utils'; + +let wrapper: RequestWrapper; + +test.describe.serial('Dynamic Scan API', () => { + test.setTimeout(120000); + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('GET ds_manual_device_preference — returns current preference', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Device Preference'); + await allure.story('User views current device preference'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('GET device preference', async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + }); + }); + + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id', 'ds_manual_device_selection'], + }); + } + ); + + await allure.step('Validate preference schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + ds_manual_device_selection: { type: 'number', required: true }, + ds_manual_device_selection_display: { type: 'string', required: true }, + }); + }); + }); + + test('GET available_manual_devices — returns device list', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Device List'); + await allure.story('User views available devices'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('GET available devices', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.availableManualDevices.route, state.projectId)}?limit=5&offset=0&platform_version_min=4.4`, + }); + }); + + const body = await allure.step('Validate paginated response', async () => { + return await ResponseValidator.validatePaginated(response); + }); + + await allure.step('Validate device schema', async () => { + const device = (body.results as Record[])[0]; + SchemaValidator.validate(device, { + id: { type: 'number', required: true }, + state: { type: 'string', required: true }, + device_identifier: { type: 'string', required: true }, + model: { type: 'string', required: true }, + platform_version: { type: 'string', required: true }, + }); + }); + }); + + test('GET api_scan_options — returns API scan settings', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('API Scan'); + await allure.story('User views API scan options'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'api-scan', 'smoke'); + + const response = await allure.step('GET API scan options', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.apiScanOptions.route, state.profileId)}?id=${state.profileId}`, + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id'], + }); + }); + + await allure.step('Validate schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + }); + }); + }); + + test.describe.serial('Flow 1 — Any available device with API scan', () => { + let scanId: number; + + test('PUT preference — use any available device', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User sets device preference to any available'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('PUT device preference', async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 0, + ds_manual_device_identifier: '', + }, + }); + }); + + await allure.step('Validate status 200', async () => { + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.ds_manual_device_selection as number).toBe(0); + }); + }); + + test('POST dynamicscans — start scan with API capture', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User starts dynamic scan on any available device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'POST start dynamic scan', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.dynamicScans.route, state.fileId), + body: { mode: 0, enable_api_capture: true }, + }); + } + ); + + const body = await allure.step( + 'Validate status 201 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 201, + requiredFields: ['id', 'file', 'mode', 'status'], + }); + } + ); + + await allure.step('Verify scan started and save scanId', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + mode: { type: 'number', required: true }, + status: { type: 'number', required: true }, + }); + expect(body.file as number).toBe(state.fileId); + scanId = body.id as number; + }); + await waitForScanRunning(wrapper, state.fileId); + }); + + test('GET last_manual_dynamic_scan — verify scan is active', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Dynamic scan status is active after start'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'regression'); + + const response = await allure.step( + 'GET last manual dynamic scan', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.lastManualDynamicScan.route, + state.fileId + ), + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify scan is active', async () => { + expect(body.id as number).toBe(scanId); + expect(body.file as number).toBe(state.fileId); + }); + }); + + test('DELETE dynamicscans — stop scan', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User stops dynamic scan'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('DELETE stop scan', async () => { + return await wrapper.delete({ + endpoint: resolveRoute(API_ROUTES.dynamicScanById.route, scanId), + }); + }); + + await allure.step('Validate status 204', async () => { + expect(response.status()).toBe(204); + }); + + await waitForDeviceAvailable(wrapper, state.projectId); + + await waitForScanStopped(wrapper, state.fileId); + }); + }); + + test.describe.serial('Flow 2 — Specific device with API scan', () => { + let scanId: number; + let deviceIdentifier: string; + + test('GET available devices — pick first available', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User picks a specific device'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('GET available devices', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.availableManualDevices.route, state.projectId)}?limit=5&offset=0&platform_version_min=4.4`, + }); + }); + + const body = await allure.step( + 'Validate and get first device', + async () => { + return await ResponseValidator.validatePaginated(response); + } + ); + + await allure.step('Save first available device identifier', async () => { + const devices = body.results as Record[]; + const availableDevice = devices.find((d) => d.state === 'available'); + expect(availableDevice).toBeDefined(); + deviceIdentifier = availableDevice!.device_identifier as string; + console.log(`[Test] Using device: ${deviceIdentifier}`); + }); + }); + + test('PUT preference — use specific device', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User sets preference to specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'PUT specific device preference', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 1, + ds_manual_device_identifier: deviceIdentifier, + }, + }); + } + ); + + await allure.step('Validate status 200', async () => { + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.ds_manual_device_selection as number).toBe(1); + expect(body.ds_manual_device_identifier as string).toBe( + deviceIdentifier + ); + }); + }); + + test('POST dynamicscans — start scan on specific device', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User starts dynamic scan on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'POST start dynamic scan', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.dynamicScans.route, state.fileId), + body: { mode: 0, enable_api_capture: true }, + }); + } + ); + + const body = await allure.step('Validate status 201', async () => { + return await ResponseValidator.validate(response, { + status: 201, + requiredFields: ['id', 'file', 'mode', 'status'], + }); + }); + + await allure.step('Verify scan started and save scanId', async () => { + expect(body.file as number).toBe(state.fileId); + scanId = body.id as number; + }); + await waitForScanRunning(wrapper, state.fileId); + }); + + test('GET last_manual_dynamic_scan — verify scan active', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Dynamic scan is active on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'regression'); + + const response = await allure.step( + 'GET last manual dynamic scan', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.lastManualDynamicScan.route, + state.fileId + ), + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify scan is active', async () => { + expect(body.id as number).toBe(scanId); + expect(body.file as number).toBe(state.fileId); + }); + }); + + test('DELETE dynamicscans — stop scan', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User stops dynamic scan on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('DELETE stop scan', async () => { + return await wrapper.delete({ + endpoint: resolveRoute(API_ROUTES.dynamicScanById.route, scanId), + }); + }); + + await allure.step('Validate status 204', async () => { + expect(response.status()).toBe(204); + }); + await waitForDeviceAvailable(wrapper, state.projectId); + await waitForScanStopped(wrapper, state.fileId); + }); + + test('PUT preference — reset to any available device', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Reset device preference after test'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'regression'); + + await allure.step('PUT reset preference', async () => { + await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 0, + ds_manual_device_identifier: '', + }, + }); + }); + }); + }); + + test.describe + .serial('Flow 3 — Any available device with API scan OFF', () => { + let scanId: number; + + test('PUT preference — use any available device with API scan OFF', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User sets device preference to any available'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('PUT device preference', async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 0, + ds_manual_device_identifier: '', + }, + }); + }); + + await allure.step('Validate status 200', async () => { + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.ds_manual_device_selection as number).toBe(0); + }); + }); + + test('POST dynamicscans — start scan with API capture', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User starts dynamic scan on any available device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'POST start dynamic scan', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.dynamicScans.route, state.fileId), + body: { mode: 0, enable_api_capture: false }, + }); + } + ); + + const body = await allure.step( + 'Validate status 201 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 201, + requiredFields: ['id', 'file', 'mode', 'status'], + }); + } + ); + + await allure.step('Verify scan started and save scanId', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + mode: { type: 'number', required: true }, + status: { type: 'number', required: true }, + }); + expect(body.file as number).toBe(state.fileId); + scanId = body.id as number; + }); + await waitForScanRunning(wrapper, state.fileId); + }); + + test('GET last_manual_dynamic_scan — verify scan is active', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Dynamic scan status is active after start'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'regression'); + + const response = await allure.step( + 'GET last manual dynamic scan', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.lastManualDynamicScan.route, + state.fileId + ), + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify scan is active', async () => { + expect(body.id as number).toBe(scanId); + expect(body.file as number).toBe(state.fileId); + }); + }); + + test('DELETE dynamicscans — stop scan', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User stops dynamic scan'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('DELETE stop scan', async () => { + return await wrapper.delete({ + endpoint: resolveRoute(API_ROUTES.dynamicScanById.route, scanId), + }); + }); + + await allure.step('Validate status 204', async () => { + expect(response.status()).toBe(204); + }); + + await waitForDeviceAvailable(wrapper, state.projectId); + + await waitForScanStopped(wrapper, state.fileId); + }); + }); + + test.describe.serial('Flow 4 — Specific device with API scan Off', () => { + let scanId: number; + let deviceIdentifier: string; + + test('GET available devices — pick first available', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User picks a specific device'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('GET available devices', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.availableManualDevices.route, state.projectId)}?limit=5&offset=0&platform_version_min=4.4`, + }); + }); + + const body = await allure.step( + 'Validate and get first device', + async () => { + return await ResponseValidator.validatePaginated(response); + } + ); + + await allure.step('Save first available device identifier', async () => { + const devices = body.results as Record[]; + const availableDevice = devices.find((d) => d.state === 'available'); + expect(availableDevice).toBeDefined(); + deviceIdentifier = availableDevice!.device_identifier as string; + console.log(`[Test] Using device: ${deviceIdentifier}`); + }); + }); + + test('PUT preference — use specific device with API scan OFF', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User sets preference to specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'PUT specific device preference', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 1, + ds_manual_device_identifier: deviceIdentifier, + }, + }); + } + ); + + await allure.step('Validate status 200', async () => { + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.ds_manual_device_selection as number).toBe(1); + expect(body.ds_manual_device_identifier as string).toBe( + deviceIdentifier + ); + }); + }); + + test('POST dynamicscans — start scan on specific device with API scan OFF', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User starts dynamic scan on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step( + 'POST start dynamic scan', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.dynamicScans.route, state.fileId), + body: { mode: 0, enable_api_capture: false }, + }); + } + ); + + const body = await allure.step('Validate status 201', async () => { + return await ResponseValidator.validate(response, { + status: 201, + requiredFields: ['id', 'file', 'mode', 'status'], + }); + }); + + await allure.step('Verify scan started and save scanId', async () => { + expect(body.file as number).toBe(state.fileId); + scanId = body.id as number; + }); + await waitForScanRunning(wrapper, state.fileId); + }); + + test('GET last_manual_dynamic_scan — verify scan active', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Dynamic scan is active on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'regression'); + + const response = await allure.step( + 'GET last manual dynamic scan', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.lastManualDynamicScan.route, + state.fileId + ), + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify scan is active', async () => { + expect(body.id as number).toBe(scanId); + expect(body.file as number).toBe(state.fileId); + }); + }); + + test('DELETE dynamicscans — stop scan', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('User stops dynamic scan on specific device'); + await allure.severity('critical'); + await allure.tags('dynamic-scan', 'smoke'); + + const response = await allure.step('DELETE stop scan', async () => { + return await wrapper.delete({ + endpoint: resolveRoute(API_ROUTES.dynamicScanById.route, scanId), + }); + }); + + await allure.step('Validate status 204', async () => { + expect(response.status()).toBe(204); + }); + await waitForDeviceAvailable(wrapper, state.projectId); + await waitForScanStopped(wrapper, state.fileId); + }); + + test('PUT preference — reset to any available device', async () => { + await allure.epic('Dynamic Scan'); + await allure.feature('Manual DAST'); + await allure.story('Reset device preference after test'); + await allure.severity('normal'); + await allure.tags('dynamic-scan', 'regression'); + + await allure.step('PUT reset preference', async () => { + await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.dsManualDevicePreference.route, + state.profileId + ), + body: { + ds_manual_device_selection: 0, + ds_manual_device_identifier: '', + }, + }); + }); + }); + }); +}); diff --git a/playwright/specs/api/edit-analysis.api.spec.ts b/playwright/specs/api/edit-analysis.api.spec.ts new file mode 100644 index 0000000000..231302f56a --- /dev/null +++ b/playwright/specs/api/edit-analysis.api.spec.ts @@ -0,0 +1,499 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +/**Risk Analysis Levels: +0 = Passed +1 = Low +2 = Medium +3 = High +4 = Critical +*/ + +test.describe.serial('Edit Analysis API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test.describe.serial('Edit Analysis Risk Flow', () => { + test('PUT vulnerability_preferences — override risk to Medium', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User overrides vulnerability risk'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + const response = await allure.step( + 'PUT override risk to Medium', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + risk: 2, + comment: 'automated test override', + }, + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify override applied', async () => { + expect(body.risk as number).toBe(2); + }); + }); + + test('GET analysis — verify overridden_risk is Medium', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('Override is reflected in analysis'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'regression'); + + const response = await allure.step('GET analysis details', async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify overridden_risk === 2 (Medium)', async () => { + expect(body.overridden_risk as number).toBe(2); + expect(body.computed_risk as number).toBe(2); + expect(body.override_criteria as string).toBe('current_file'); + }); + }); + + test('DELETE vulnerability_preferences — reset override', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User resets vulnerability risk override'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + const response = await allure.step('DELETE override', async () => { + return await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + }); + }); + + await allure.step('Validate status 200', async () => { + await ResponseValidator.validate(response, { status: 200 }); + }); + + const verifyResponse = await allure.step( + 'GET analysis — verify reset', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + } + ); + + const verifyBody = await allure.step( + 'Validate overridden_risk is null', + async () => { + return await ResponseValidator.validate(verifyResponse, { + status: 200, + }); + } + ); + + await allure.step('Verify override removed', async () => { + expect(verifyBody.overridden_risk).toBeNull(); + }); + }); + }); + + test.describe.serial('Edit Analysis -- Ignore vulnerability', () => { + test('POST vulnerability_preferences/ignore — ignore vulnerability', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User ignores a vulnerability'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + const response = await allure.step( + 'POST ignore vulnerability', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + risk: 0, + comment: 'automated test ignore', + }, + }); + } + ); + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify vulnerability ignored', async () => { + expect(body.risk as number).toBe(0); + }); + }); + + test('GET analysis — verify vulnerability is ignored', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('Ignored vulnerability is reflected in analysis'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'regression'); + + await allure.step('GET analysis details', async () => { + const response = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.overridden_risk as number).toBe(0); + expect(body.computed_risk as number).toBe(0); + expect(body.override_criteria as string).toBe('current_file'); + }); + }); + + test('DELETE vulnerability_preferences — reset ignore', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User resets vulnerability ignore'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + await allure.step('DELETE ignore', async () => { + const response = await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + }); + + await ResponseValidator.validate(response, { status: 200 }); + }); + await allure.step('GET analysis — verify reset', async () => { + const response = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.overridden_risk).toBeNull(); + }); + }); + }); + + test.describe.serial('Edit Analysis -- Override All future uploads', () => { + test('PUT -- override risk to high for all future uploads', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User overrides risk for all future uploads'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + const response = await allure.step( + 'PUT override risk to High for all future uploads', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + risk: 3, + comment: 'automated all future uploads override', + all: true, + }, + }); + } + ); + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify override applied', async () => { + expect(body.risk as number).toBe(3); + }); + }); + + test('GET analysis — verify overridden_risk is High for all future uploads', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story( + 'Override for all future uploads is reflected in analysis' + ); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'regression'); + + const response = await allure.step('GET analysis details', async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + await allure.step('Verify overridden_risk is High', async () => { + expect(body.overridden_risk as number).toBe(3); + }); + await allure.step( + 'Verify override_criteria is "all_future_uploads"', + async () => { + expect(body.override_criteria as string).toBe('all_future_upload'); + } + ); + + await allure.step('Verify computed_risk is High', async () => { + expect(body.computed_risk as number).toBe(3); + }); + }); + + test('DELETE vulnerability_preferences — reset all future uploads override', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User resets all future uploads override'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + const response = await allure.step( + 'DELETE override for all future uploads', + async () => { + return await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + all: true, + }, + }); + } + ); + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + await allure.step('GET analysis — verify reset', async () => { + const response = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + + const body = await ResponseValidator.validate(response, { + status: 200, + }); + expect(body.overridden_risk).toBeNull(); + }); + }); + }); + + test.describe.serial('Edit Analysis -- Ignore All future uploads', () => { + test('PUT — ignore vulnerability for all future uploads', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User ignores all future uploads of a vulnerability'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + const response = await allure.step( + 'PUT ignore for all future uploads', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + risk: 0, + comment: 'automated ignore for all future uploads', + all: true, + }, + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify vulnerability ignored', async () => { + expect(body.risk as number).toBe(0); + }); + }); + + test('GET analysis — verify ignored for all future uploads', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story( + 'Ignored vulnerability for all future uploads is reflected' + ); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'regression'); + + const response = await allure.step('GET analysis details', async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify override applied', async () => { + expect(body.overridden_risk as number).toBe(0); + expect(body.computed_risk as number).toBe(0); + expect(body.override_criteria as string).toBe('all_future_upload'); + }); + }); + + test('DELETE — reset ignore for all future uploads', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('User resets ignore for all future uploads'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'edit-analysis', 'smoke'); + + const response = await allure.step( + 'DELETE ignore for all future uploads', + async () => { + return await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { all: true }, + }); + } + ); + + await allure.step('Validate status 200', async () => { + await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('GET analysis — verify reset', async () => { + const verifyResponse = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.analysisById.route, + state.analysisId + ), + }); + const verifyBody = await ResponseValidator.validate(verifyResponse, { + status: 200, + }); + expect(verifyBody.overridden_risk).toBeNull(); + }); + }); + }); + + test('PUT — override with missing risk field returns 400', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('Override fails when risk field is missing'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'edit-analysis', 'negative'); + + const response = await allure.step('PUT without risk field', async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + comment: 'missing risk field', + }, + }); + }); + + await allure.step('Validate status 400', async () => { + await ResponseValidator.validate(response, { status: 400 }); + }); + }); + test('PUT — override with invalid risk value returns 400', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Edit Analysis'); + await allure.story('Override fails with invalid risk value'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'edit-analysis', 'negative'); + + const response = await allure.step( + 'PUT with invalid risk value', + async () => { + return await wrapper.put({ + endpoint: resolveRoute( + API_ROUTES.editAnalysisRisk.route, + state.fileId, + state.vulnerabilityId + ), + body: { + id: state.vulnerabilityId, + risk: 99, + comment: 'invalid risk value', + }, + }); + } + ); + + await allure.step('Validate status 400', async () => { + await ResponseValidator.validate(response, { status: 400 }); + }); + }); +}); diff --git a/playwright/specs/api/projects.api.spec.ts b/playwright/specs/api/projects.api.spec.ts new file mode 100644 index 0000000000..37191fabbd --- /dev/null +++ b/playwright/specs/api/projects.api.spec.ts @@ -0,0 +1,261 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe('Projects API for organization', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + // ── Organization Tests ──────────────────────────────────────────── + + test('GET /api/organizations — returns organization list', async () => { + await allure.epic('Organizations'); + await allure.feature('Organization List'); + await allure.story('User fetches list of organizations'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('organizations', 'smoke', 'positive'); + await allure.description(` + Verifies GET /api/organizations returns: + - HTTP 200 + - Paginated response with count and results + - Each org has id (number), name (string), projects_count (number) + `); + + const response = await allure.step('GET /api/organizations', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.organization.route), + }); + }); + + const body = await allure.step( + 'Validate status 200 and required fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['count', 'results'], + }); + } + ); + + await allure.step('Validate paginated response structure', async () => { + ResponseValidator.validatePaginated(response, { status: 200 }); + }); + + await allure.step('Validate organization schema', async () => { + const org = (body.results as Record[])[0]; + SchemaValidator.validate(org, { + id: { type: 'number', required: true }, + name: { type: 'string', required: true }, + projects_count: { type: 'number', required: true }, + }); + }); + }); + + // ── Project Tests ───────────────────────────────────────────────── + + test('GET /api/organizations/:id/projects — returns project list', async () => { + await allure.epic('Organizations'); + await allure.feature('Project List'); + await allure.story('User fetches projects for an organization'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('projects', 'smoke', 'positive'); + await allure.description(` + Verifies GET /api/organizations/:id/projects returns: + - HTTP 200 + - Paginated response with count and results + `); + + const response = await allure.step( + `GET /api/organizations/${state.orgId}/projects`, + async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.projectList.route, state.orgId), + }); + } + ); + + await allure.step('Validate status 200 and required fields', async () => { + await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['count', 'results'], + }); + }); + + await allure.step('Validate paginated response structure', async () => { + ResponseValidator.validatePaginated(response, { status: 200 }); + }); + }); + + test('GET /api/organizations/:id/projects — project schema is correct', async () => { + await allure.epic('Organizations'); + await allure.feature('Project List'); + await allure.story('Project response matches expected schema'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('projects', 'schema', 'regression'); + await allure.description(` + Verifies a single project contains: + - id (number), uuid (string), package_name (string) + - platform (number), file_count (number), organization (number) + `); + + const response = await allure.step( + `GET /api/organizations/${state.orgId}/projects?limit=1`, + async () => { + return await wrapper.get({ + endpoint: + resolveRoute(API_ROUTES.projectList.route, state.orgId) + + '?limit=1', + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Validate project schema', async () => { + const project = (body.results as Record[])[0]; + SchemaValidator.validate(project, { + id: { type: 'number', required: true }, + uuid: { type: 'string', required: true }, + package_name: { type: 'string', required: true }, + platform: { type: 'number', required: true }, + file_count: { type: 'number', required: true }, + organization: { type: 'number', required: true }, + }); + }); + }); + + test('GET /api/organizations/:id/projects — returns 15 projects', async () => { + await allure.epic('Organizations'); + await allure.feature('Project List'); + await allure.story('Project list returns expected count'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('projects', 'count', 'regression'); + await allure.description(` + Verifies GET with limit=15 returns count > 0. + Exact count is not hardcoded — works across any QA environment. + `); + + const response = await allure.step( + `GET /api/organizations/${state.orgId}/projects?limit=15`, + async () => { + return await wrapper.get({ + endpoint: + resolveRoute(API_ROUTES.projectList.route, state.orgId) + + '?limit=15', + }); + } + ); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify count is greater than 0', async () => { + const count = body.count as number; + expect(count).toBeGreaterThan(0); + }); + }); + + test('GET /api/organizations/:id/projects — pagination works', async () => { + await allure.epic('Organizations'); + await allure.feature('Project List'); + await allure.story('Project list pagination works correctly'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('projects', 'pagination', 'regression'); + await allure.description(` + Verifies GET with limit=5&offset=0: + - HTTP 200 + - next field exists (more pages available) + - results length is <= 5 + `); + + const response = await allure.step( + `GET /api/organizations/${state.orgId}/projects?limit=5&offset=0`, + async () => { + return await wrapper.get({ + endpoint: + resolveRoute(API_ROUTES.projectList.route, state.orgId) + + '?limit=5&offset=0', + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and pagination fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['count', 'next', 'results'], + }); + } + ); + + await allure.step('Verify results length is <= 5', async () => { + const results = body.results as unknown[]; + expect(results.length).toBeLessThanOrEqual(5); + }); + }); + + test('GET /api/organizations/:id/me — returns member role', async () => { + await allure.epic('Organizations'); + await allure.feature('Organization Member'); + await allure.story('Authenticated user fetches their org role'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('organizations', 'member', 'smoke', 'positive'); + await allure.description(` + Verifies GET /api/organizations/:id/me returns: + - HTTP 200 + - id (number), is_admin (boolean), is_owner (boolean) + `); + + const response = await allure.step( + `GET /api/organizations/${state.orgId}/me`, + async () => { + return await wrapper.get({ + endpoint: + resolveRoute(API_ROUTES.organization.route, state.orgId) + '/me', + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and required fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id', 'is_admin', 'is_owner'], + }); + } + ); + + await allure.step('Validate member role schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + is_admin: { type: 'boolean', required: true }, + is_owner: { type: 'boolean', required: true }, + }); + }); + }); +}); diff --git a/playwright/specs/api/report.api.spec.ts b/playwright/specs/api/report.api.spec.ts new file mode 100644 index 0000000000..01a5e2ab21 --- /dev/null +++ b/playwright/specs/api/report.api.spec.ts @@ -0,0 +1,276 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe('Report API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + await wrapper.dispose(); + }); + + test('GET can_generate_report — returns boolean', async () => { + await allure.epic('Reports'); + await allure.feature('Report Generation'); + await allure.story('User checks if report can be generated'); + await allure.severity('critical'); + await allure.tags('report', 'smoke'); + + const response = await allure.step('GET can_generate_report', async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.canGenerateReport.route, + state.fileId + ), + }); + }); + + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['can_generate_report'], + }); + } + ); + + await allure.step('Validate schema', async () => { + SchemaValidator.validate(body, { + can_generate_report: { type: 'boolean', required: true }, + }); + }); + }); + + test('GET reports — returns report list', async () => { + await allure.epic('Reports'); + await allure.feature('VA Report'); + await allure.story('User views generated reports'); + await allure.severity('normal'); + await allure.tags('report', 'regression'); + + const response = await allure.step('GET reports list', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.reports.route, state.fileId)}?fileId=${state.fileId}&limit=2`, + }); + }); + + const body = await allure.step('Validate paginated response', async () => { + return await ResponseValidator.validatePaginated(response); + }); + + await allure.step('Verify at least one report exists', async () => { + expect(body.count as number).toBeGreaterThan(0); + }); + }); + + test('GET report PDF — returns S3 download URL', async () => { + await allure.epic('Reports'); + await allure.feature('Report Download'); + await allure.story('User downloads PDF report'); + await allure.severity('critical'); + await allure.tags('report', 'pdf', 'smoke'); + + const response = await allure.step('GET report PDF URL', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.reportPdf.route, state.reportId), + }); + }); + + const body = await allure.step('Validate status 200 and URL', async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + }); + + await allure.step('Verify S3 URL', async () => { + expect(body.url as string).toContain('s3.amazonaws.com'); + }); + }); + + test('GET report Excel — returns S3 download URL', async () => { + await allure.epic('Reports'); + await allure.feature('Report Download'); + await allure.story('User downloads Excel report'); + await allure.severity('normal'); + await allure.tags('report', 'excel', 'regression'); + + const response = await allure.step('GET report Excel URL', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.reportExcel.route, state.reportId), + }); + }); + + const body = await allure.step('Validate status 200 and URL', async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + }); + + await allure.step('Verify S3 URL', async () => { + expect(body.url as string).toContain('summary_excel_download'); + }); + }); + + test('GET report CSV — returns S3 download URL', async () => { + await allure.epic('Reports'); + await allure.feature('Report Download'); + await allure.story('User downloads CSV report'); + await allure.severity('normal'); + await allure.tags('report', 'csv', 'regression'); + + const response = await allure.step('GET report CSV URL', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.reportCsv.route, state.reportId), + }); + }); + + const body = await allure.step('Validate status 200 and URL', async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + }); + + await allure.step('Verify S3 URL', async () => { + expect(body.url as string).toContain('summary_csv_download'); + }); + }); + + test.describe('Privacy Report', () => { + test.beforeAll(async () => { + if (!wrapper) { + wrapper = new RequestWrapper(); + await wrapper.init(); + } + }); + + test.skip( + !state.features.privacy, + 'PRIVACY SHEILD NOT ENABLED ON THIS ENVIRONMENT' + ); + test('POST privacy report — generate PDF', async () => { + await allure.epic('Reports'); + await allure.feature('Privacy Shield Report'); + await allure.story('User generates privacy report PDF'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('report', 'privacy', 'smoke'); + + const response = await allure.step( + 'POST generate privacy PDF', + async () => { + return await wrapper.post({ + endpoint: resolveRoute( + API_ROUTES.privacyReportGenerate.route, + state.privacyReportId + ), + }); + } + ); + + await allure.step('Validate success response', async () => { + const body = await ResponseValidator.validate(response, { + status: 201, + }); + expect(body.success as boolean).toBe(true); + }); + }); + + test('GET privacy report status — pdf_status is generated', async () => { + await allure.epic('Reports'); + await allure.feature('Privacy Shield Report'); + await allure.story('Privacy report PDF generation completes'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('report', 'privacy', 'regression'); + + let body: Record = {}; + const startTime = Date.now(); + const TIMEOUT = 300000; // 5 minutes + + await allure.step('Poll until pdf_status > 0', async () => { + while (true) { + const response = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.privacyReportById.route, + state.privacyReportId + ), + }); + body = await response.json(); + + if ((body.pdf_status as number) === 4) break; + + if (Date.now() - startTime > TIMEOUT) { + throw new Error('Privacy report PDF timed out after 5 minutes'); + } + + console.log('[Test] Privacy PDF not ready yet... waiting 10s'); + await new Promise((r) => setTimeout(r, 10000)); + } + }); + + await allure.step('Validate privacy report schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + language: { type: 'string', required: true }, + pdf_status: { type: 'number', required: true }, + pdf_progress: { type: 'number', required: true }, + report_password: { type: 'string', required: true }, + }); + }); + + await allure.step('Verify pdf_status is greater than 0', async () => { + expect(body.pdf_status as number).toBe(4); + }); + }); + + test('GET privacy report download URL — returns S3 URL', async () => { + await allure.epic('Reports'); + await allure.feature('Privacy Shield Report'); + await allure.story('User downloads privacy report PDF'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('report', 'privacy', 'smoke'); + + const response = await allure.step( + 'GET privacy report download URL', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.privacyReportDownload.route, + state.privacyReportId + ), + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and URL', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + } + ); + + await allure.step('Verify S3 URL', async () => { + expect(body.url as string).toContain('s3.amazonaws.com'); + }); + }); + }); +}); diff --git a/playwright/specs/api/sbom.api.spec.ts b/playwright/specs/api/sbom.api.spec.ts new file mode 100644 index 0000000000..290ddcf7f2 --- /dev/null +++ b/playwright/specs/api/sbom.api.spec.ts @@ -0,0 +1,269 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe('SBOM API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.skip(!state.features.sbom, 'SBOM NOT ENABLED ON THIS ENVIROMENT'); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('GET sb_projects — returns SBOM project list', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Projects'); + await allure.story('User views SBOM project list'); + await allure.severity('critical'); + await allure.tags('sbom', 'smoke'); + + const response = await allure.step('GET sb_projects', async () => { + return await wrapper.get({ + endpoint: `${API_ROUTES.sbProjects.route}?limit=10`, + }); + }); + + const body = await allure.step( + 'Validate status 200 and paginated response', + async () => { + return await ResponseValidator.validatePaginated(response); + } + ); + + await allure.step('Validate schema', async () => { + const project = (body.results as Record[])[0]; + SchemaValidator.validate(project, { + id: { type: 'number', required: true }, + project: { type: 'number', required: true }, + latest_sb_file: { type: 'number', required: true }, + }); + }); + }); + + test('GET sb_file — returns SBOM file details', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM File'); + await allure.story('User views SBOM file details'); + await allure.severity('critical'); + await allure.tags('sbom', 'smoke'); + + const response = await allure.step('GET sb_file', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.sbFileById.route, state.sbFileId), + }); + }); + + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id', 'file', 'status', 'sb_project'], + }); + } + ); + + await allure.step('Validate sb_file schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + status: { type: 'number', required: true }, + sb_project: { type: 'number', required: true }, + }); + }); + + await allure.step('Verify SBOM scan is complete', async () => { + expect(body.status as number).toBe(3); + }); + + await allure.step( + 'Verify sb_file belongs to our uploaded file', + async () => { + expect(body.file as number).toBe(state.fileId); + } + ); + }); + + test('GET sb_reports — returns SBOM report list', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Report'); + await allure.story('User views SBOM report list'); + await allure.severity('normal'); + await allure.tags('sbom', 'regression'); + + const response = await allure.step('GET sb_reports', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.sbReports.route, state.sbFileId), + }); + }); + + const body = await allure.step('Validate paginated response', async () => { + return await ResponseValidator.validatePaginated(response); + }); + + await allure.step('Verify at least one report exists', async () => { + expect(body.count as number).toBeGreaterThan(0); + }); + }); + + test.describe.serial('SBOM Report PDF Flow', () => { + test.beforeAll(async () => { + if (!wrapper) { + wrapper = new RequestWrapper(); + await wrapper.init(); + } + }); + + test('POST sb_report — generate PDF', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Report'); + await allure.story('User generates SBOM report PDF'); + await allure.severity('critical'); + await allure.tags('sbom', 'smoke'); + + const response = await allure.step('POST generate SBOM PDF', async () => { + return await wrapper.post({ + endpoint: resolveRoute( + API_ROUTES.sbReportGenerate.route, + state.sbReportId + ), + }); + }); + + await allure.step('Validate success response', async () => { + const body = await ResponseValidator.validate(response, { + status: 201, + }); + expect(body.success as boolean).toBe(true); + }); + }); + + test('GET sb_report status — pdf_status is generated', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Report'); + await allure.story('SBOM report PDF generation completes'); + await allure.severity('critical'); + await allure.tags('sbom', 'regression'); + + let body: Record = {}; + const startTime = Date.now(); + const TIMEOUT = 300000; + + await allure.step('Poll until pdf_status === 4', async () => { + while (true) { + const response = await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.sbReportById.route, + state.sbReportId + ), + }); + body = await response.json(); + + if ((body.pdf_status as number) === 4) break; + + if (Date.now() - startTime > TIMEOUT) { + throw new Error('SBOM report PDF timed out after 5 minutes'); + } + + console.log('[Test] SBOM PDF not ready yet... waiting 10s'); + await new Promise((r) => setTimeout(r, 10000)); + } + }); + + await allure.step('Validate sb_report schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + sb_file: { type: 'number', required: true }, + language: { type: 'string', required: true }, + pdf_status: { type: 'number', required: true }, + pdf_progress: { type: 'number', required: true }, + report_password: { type: 'string', required: true }, + }); + }); + + await allure.step('Verify pdf_status === 4', async () => { + expect(body.pdf_status as number).toBe(4); + }); + }); + + test('GET sb_report download URL — returns S3 URL', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Report'); + await allure.story('User downloads SBOM report PDF'); + await allure.severity('critical'); + await allure.tags('sbom', 'smoke'); + + const response = await allure.step( + 'GET SBOM report download URL', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.sbReportDownload.route, + state.sbReportId + ), + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and URL', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + } + ); + + await allure.step('Verify S3 URL', async () => { + expect(body.url as string).toContain('s3.amazonaws.com'); + }); + }); + + test('GET sb_report CycloneDX JSON download URL — returns download URL', async () => { + await allure.epic('SBOM'); + await allure.feature('SBOM Report'); + await allure.story('User downloads SBOM report CycloneDX JSON'); + await allure.severity('critical'); + await allure.tags('sbom', 'smoke'); + + const response = await allure.step( + 'GET SBOM report CycloneDX JSON download URL', + async () => { + return await wrapper.get({ + endpoint: resolveRoute( + API_ROUTES.sbReortCyclonedx.route, + state.sbReportId + ), + }); + } + ); + + const body = await allure.step( + 'Validate status 200 and URL', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['url'], + }); + } + ); + + await allure.step('Verify download url', async () => { + expect(body.url as string).toContain('cyclonedx_json_file'); + }); + }); + }); +}); diff --git a/playwright/specs/api/scan.api.spec.ts b/playwright/specs/api/scan.api.spec.ts new file mode 100644 index 0000000000..8cc486467b --- /dev/null +++ b/playwright/specs/api/scan.api.spec.ts @@ -0,0 +1,161 @@ +import { test } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe('Scan API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('GET /api/v3/files/:id — returns file details', async () => { + await allure.epic('Scanning'); + await allure.feature('File Details'); + await allure.story('User views file scan details'); + await allure.severity('critical'); + await allure.tags('scan', 'file', 'smoke'); + + const response = await allure.step('GET file details', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileById.route, state.fileId), + }); + }); + + const body = await allure.step( + 'Validate status 200 and required fields', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: [ + 'id', + 'project', + 'static_scan_progress', + 'is_static_done', + ], + }); + } + ); + + await allure.step('Validate file schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + project: { type: 'number', required: true }, + version: { type: 'string', required: true }, + static_scan_progress: { type: 'number', required: true }, + is_static_done: { type: 'boolean', required: true }, + is_dynamic_done: { type: 'boolean', required: true }, + is_manual_done: { type: 'boolean', required: true }, + }); + }); + }); + + test('GET /api/v3/files/:id — static scan is completed', async () => { + await allure.epic('Scanning'); + await allure.feature('Static Scan'); + await allure.story('Static scan completes after upload'); + await allure.severity('critical'); + await allure.tags('scan', 'static', 'smoke'); + + const response = await allure.step('GET file details', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileById.route, state.fileId), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify static scan is done', async () => { + const { expect } = await import('@playwright/test'); + expect(body.static_scan_progress as number).toBe(100); + expect(body.is_static_done as boolean).toBe(true); + }); + }); + + test('GET /api/v3/files/:id/risk — returns risk data', async () => { + await allure.epic('Scanning'); + await allure.feature('Risk Analysis'); + await allure.story('User views risk analysis for a file'); + await allure.severity('normal'); + await allure.tags('scan', 'risk', 'regression'); + + const response = await allure.step('GET file risk', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileRisk.route, state.fileId), + }); + }); + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: [ + 'file', + 'rating', + 'risk_count_critical', + 'risk_count_high', + 'risk_count_medium', + 'risk_count_low', + ], + }); + } + ); + await allure.step('Validate risk schema', async () => { + SchemaValidator.validate(body, { + file: { type: 'number', required: true }, + rating: { type: 'string', required: true }, + risk_count_critical: { type: 'number', required: true }, + risk_count_high: { type: 'number', required: true }, + risk_count_medium: { type: 'number', required: true }, + risk_count_low: { type: 'number', required: true }, + risk_count_passed: { type: 'number', required: true }, + }); + }); + }); + + test('GET /api/v3/files/:id/previous_file — returns previous file', async () => { + await allure.epic('Scanning'); + await allure.feature('File Details'); + await allure.story('User views previous file version'); + await allure.severity('normal'); + await allure.tags('scan', 'file', 'regression'); + + const response = await allure.step('GET previous file', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.previousFile.route, state.fileId), + }); + }); + + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id', 'project', 'version', 'static_scan_progress'], + }); + } + ); + + await allure.step('Validate previous file schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + project: { type: 'number', required: true }, + version: { type: 'string', required: true }, + is_static_done: { type: 'boolean', required: true }, + }); + }); + }); +}); diff --git a/playwright/specs/api/tags.api.spec.ts b/playwright/specs/api/tags.api.spec.ts new file mode 100644 index 0000000000..d85d7fab1f --- /dev/null +++ b/playwright/specs/api/tags.api.spec.ts @@ -0,0 +1,191 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; +let tagId: number; + +test.describe.serial('Tags API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('POST /api/v2/files/:id/tags — creates a tag', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('User adds a tag to a file'); + await allure.severity('normal'); + await allure.tags('tags', 'smoke'); + + const response = await allure.step('POST create tag', async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.fileTags.route, state.fileId), + body: { name: 'automated-test-tag' }, + }); + }); + + const body = await allure.step( + 'Validate status 201 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 201, + requiredFields: ['id', 'name', 'color'], + }); + } + ); + + await allure.step('Validate tag schema and save tagId', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + name: { type: 'string', required: true }, + color: { type: 'string', required: true }, + }); + tagId = body.id as number; + expect(body.name as string).toBe('automated-test-tag'); + }); + }); + + test('GET /api/v2/files/:id/tags — returns tag list', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('User views tags for a file'); + await allure.severity('normal'); + await allure.tags('tags', 'regression'); + + const response = await allure.step('GET tags', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileTags.route, state.fileId), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify tag exists in list', async () => { + const results = body.results as Record[]; + const tag = results.find((t) => t.id === tagId); + expect(tag).toBeDefined(); + }); + }); + + test('DELETE /api/v2/files/:id/tags/:tagId — removes tag', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('User removes a tag from a file'); + await allure.severity('normal'); + await allure.tags('tags', 'smoke'); + + const response = await allure.step('DELETE tag', async () => { + return await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.fileTagById.route, + state.fileId, + tagId + ), + }); + }); + + await allure.step('Validate status 204', async () => { + expect(response.status()).toBe(204); + }); + }); + + test('GET /api/v2/files/:id/tags — verify tag removed', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('Tag is removed from file'); + await allure.severity('normal'); + await allure.tags('tags', 'regression'); + + const response = await allure.step('GET tags after delete', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileTags.route, state.fileId), + }); + }); + + const body = await allure.step('Validate status 200', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + const results = body.results as Record[]; + const tag = results.find((t) => t.id === tagId); + expect(tag).toBeUndefined(); + }); + + test('POST — create tag with empty name returns 400', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('Tag creation fails with empty name'); + await allure.severity('normal'); + await allure.tags('tags', 'negative'); + + const response = await allure.step('POST empty name', async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.fileTags.route, state.fileId), + body: { name: '' }, + }); + }); + + await allure.step('Validate status 400', async () => { + await ResponseValidator.validate(response, { status: 400 }); + }); + }); + + test('DELETE — non-existent tag returns 404', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('Delete fails for non-existent tag'); + await allure.severity('normal'); + await allure.tags('tags', 'negative'); + + const response = await allure.step( + 'DELETE non-existent tagId', + async () => { + return await wrapper.delete({ + endpoint: resolveRoute( + API_ROUTES.fileTagById.route, + state.fileId, + 999999 + ), + }); + } + ); + + await allure.step('Validate status 404', async () => { + expect(response.status()).toBe(404); + }); + }); + + test('GET — tags for non-existent file returns 404', async () => { + await allure.epic('Tags'); + await allure.feature('File Tags'); + await allure.story('Get tags fails for non-existent file'); + await allure.severity('normal'); + await allure.tags('tags', 'negative'); + + const response = await allure.step( + 'GET tags for invalid fileId', + async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.fileTags.route, 999999), + }); + } + ); + + await allure.step('Validate status 404', async () => { + expect(response.status()).toBe(404); + }); + }); +}); diff --git a/playwright/specs/api/upload.api.spec.ts b/playwright/specs/api/upload.api.spec.ts new file mode 100644 index 0000000000..e764c306a4 --- /dev/null +++ b/playwright/specs/api/upload.api.spec.ts @@ -0,0 +1,230 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import * as fs from 'fs'; +import * as path from 'path'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe.serial('Upload API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('Full upload flow — returns submission_id', async () => { + await allure.epic('App Upload'); + await allure.feature('Upload Flow'); + await allure.story('User uploads an APK and receives a submission ID'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('upload', 'smoke', 'positive', 'e2e'); + await allure.description(` + Tests the full 3-step upload flow: + 1. GET presigned URL from backend + 2. PUT APK directly to S3 + 3. POST confirmation to backend + Expects a valid submission_id greater than 0 in return. + `); + + const initBody = await allure.step( + 'Step 1 — GET presigned S3 URL', + async () => { + const initResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + }); + + const body = await ResponseValidator.validate(initResponse, { + status: 200, + requiredFields: ['url', 'file_key', 'file_key_signed'], + }); + + SchemaValidator.validate(body, { + url: { type: 'string', required: true }, + file_key: { type: 'string', required: true }, + file_key_signed: { type: 'string', required: true }, + }); + + return body; + } + ); + + const s3Url = initBody.url as string; + const fileKey = initBody.file_key as string; + const fileKeySigned = initBody.file_key_signed as string; + + expect(s3Url).toContain('s3.amazonaws.com'); + + await allure.step('Step 2 — PUT APK file to S3', async () => { + const filePath = path.resolve(__dirname, '../../fixtures/DVIA.ipa'); + const fileBuffer = fs.readFileSync(filePath); + + const s3Response = await fetch(s3Url, { + method: 'PUT', + body: fileBuffer, + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + + if (!s3Response.ok) { + throw new Error( + `S3 upload failed with status ${s3Response.status} — stopping test` + ); + } + + expect(s3Response.status).toBe(200); + }); + + const confirmBody = await allure.step( + 'Step 3 — POST confirm upload to backend', + async () => { + const confirmResponse = await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + body: { + file_key: fileKey, + file_key_signed: fileKeySigned, + url: s3Url, + }, + }); + + const body = await ResponseValidator.validate(confirmResponse, { + status: 202, + requiredFields: ['submission_id'], + }); + + console.log('Confirm response body:', body.submission_id); + + SchemaValidator.validate(body, { + submission_id: { type: 'number', required: true }, + }); + + return body; + } + ); + + await allure.step('Verify submission_id is greater than 0', async () => { + expect(confirmBody.submission_id as number).toBeGreaterThan(0); + }); + }); + + test('Post confirm with invalid file key signed — returns 400', async () => { + await allure.epic('App Upload'); + await allure.feature('Upload Flow'); + await allure.story('Upload confirm fails with invalid file_key_signed'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('upload', 'negative', 'regression'); + await allure.description(` + Verifies that POST confirm with an invalid file_key_signed returns: + - HTTP 400 Bad Request + `); + + const initBody = await allure.step('GET presigned URL', async () => { + const initResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + }); + return await initResponse.json(); + }); + + const response = await allure.step( + 'POST confirm with invalid file_key_signed', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + body: { + file_key: initBody.file_key, + file_key_signed: 'invalid_signature', + url: initBody.url, + }, + }); + } + ); + + await allure.step('Validate status 400', async () => { + await ResponseValidator.validate(response, { status: 400 }); + }); + }); + + test('Post confirm with invalid file_key — returns 400', async () => { + await allure.epic('App Upload'); + await allure.feature('Upload Flow'); + await allure.story('Upload confirm fails with invalid file_key'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('upload', 'negative', 'regression'); + await allure.description(` + Verifies that POST confirm with an invalid file_key returns: + - HTTP 400 Bad Request + `); + + const initBody = await allure.step('GET presigned URL', async () => { + const initResponse = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + }); + return await initResponse.json(); + }); + + const response = await allure.step( + 'POST confirm with invalid file_key', + async () => { + return await wrapper.post({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, state.orgId), + body: { + file_key: 'invalid_file_key', + file_key_signed: initBody.file_key_signed, + url: initBody.url, + }, + }); + } + ); + + await allure.step('Validate status 400', async () => { + await ResponseValidator.validate(response, { status: 400 }); + }); + }); + + test('GET upload URL with invalid org ID — returns 404 //orgId not valid uses token', async () => { + await allure.epic('App Upload'); + await allure.feature('Upload Flow'); + await allure.story('Upload URL fetch fails with invalid org ID'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('upload', 'negative', 'regression', 'bug'); + await allure.description(` + Verifies GET upload URL with a non-existent org ID. + BUG: Server currently returns 200 instead of 404. + Backend fix needed + `); + + const response = await allure.step( + 'GET upload URL with invalid org ID 7787878787', + async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.uploadApp.route, 7787878787), + }); + } + ); + + await allure.step('Log raw response body', async () => { + const text = await response.text(); + console.log('invalid org response:', text); + }); + + await allure.step('Validate status 200 (BUG — should be 404)', async () => { + await ResponseValidator.validate(response, { + status: 200, + }); + }); + }); +}); diff --git a/playwright/specs/api/vulnerability.api.spec.ts b/playwright/specs/api/vulnerability.api.spec.ts new file mode 100644 index 0000000000..4ef7f8d429 --- /dev/null +++ b/playwright/specs/api/vulnerability.api.spec.ts @@ -0,0 +1,244 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import RequestWrapper from '../../Actions/api/request.wrapper'; +import ResponseValidator from '../../utils/response.validator'; +import SchemaValidator from '../../utils/schema.validator'; +import TokenManager from '../../Actions/api/token.manager'; +import { API_ROUTES, resolveRoute } from '../../support/api.routes'; +import state from '../../support/test-state'; + +let wrapper: RequestWrapper; + +test.describe('Vulnerability API', () => { + test.beforeAll(async () => { + wrapper = new RequestWrapper(); + await wrapper.init(); + }); + + test.afterAll(async () => { + TokenManager.clearTokens(); + await wrapper.dispose(); + }); + + test('GET /api/v3/files/:id/analyses — returns vulnerability list', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Analysis'); + await allure.story('User views vulnerability list for a file'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'analysis', 'smoke'); + + const response = await allure.step('GET analyses', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileAnalyses.route, state.fileId)}?limit=200`, + }); + }); + + const body = await allure.step( + 'Validate status 200 and paginated response', + async () => { + return await ResponseValidator.validatePaginated(response); + } + ); + + await allure.step('Validate analysis schema', async () => { + const analysis = (body.results as Record[])[0]; + + SchemaValidator.validate(analysis, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + risk: { type: 'number', required: true }, + computed_risk: { type: 'number', required: true }, + status: { type: 'number', required: true }, + vulnerability: { type: 'number', required: true }, + created_on: { type: 'string', required: true }, + updated_on: { type: 'string', required: true }, + }); + }); + }); + + test('GET /api/v3/files/:id/analyses — count is greater than 0', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Analysis'); + await allure.story('File has vulnerabilities after static scan'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'analysis', 'regression'); + + const response = await allure.step('GET analyses', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileAnalyses.route, state.fileId)}?limit=1`, + }); + }); + + const body = await allure.step('Validate count > 0', async () => { + return await ResponseValidator.validate(response, { status: 200 }); + }); + + await allure.step('Verify vulnerabilities exist', async () => { + expect(body.count as number).toBeGreaterThan(0); + }); + }); + + test('GET /api/v3/files/:id/analyses/owasp — returns OWASP analysis list', async () => { + await allure.epic('Vulnerability'); + await allure.feature('OWASP Analysis'); + await allure.story('User views OWASP analysis for a file'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'owasp', 'regression'); + + const response = await allure.step('GET OWASP analyses', async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileOwaspAnalyses.route, state.fileId)}?limit=200&fileId=${state.fileId}`, + }); + }); + + const body = await allure.step('Validate paginated response', async () => { + return await ResponseValidator.validatePaginated(response); + }); + + await allure.step('Validate OWASP analysis schema', async () => { + const analysis = (body.results as Record[])[0]; + SchemaValidator.validate(analysis, { + id: { type: 'number', required: true }, + file: { type: 'number', required: true }, + }); + }); + + await allure.step('Verify count > 0', async () => { + expect(body.count as number).toBeGreaterThan(0); + }); + }); + + test('GET /api/v2/owaspmobile2024s — returns OWASP categories', async () => { + await allure.epic('Vulnerability'); + await allure.feature('OWASP Categories'); + await allure.story('User views OWASP Mobile 2024 categories'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'owasp', 'regression'); + + const response = await allure.step('GET OWASP categories', async () => { + return await wrapper.get({ + endpoint: API_ROUTES.owaspMobile.route, + }); + }); + + const body = await allure.step('Validate paginated response', async () => { + return await ResponseValidator.validatePaginated(response); + }); + + await allure.step('Validate OWASP category schema', async () => { + const category = (body.results as Record[])[0]; + SchemaValidator.validate(category, { + id: { type: 'string', required: true }, + code: { type: 'string', required: true }, + title: { type: 'string', required: true }, + year: { type: 'number', required: true }, + }); + }); + + await allure.step('Verify exactly 10 OWASP categories', async () => { + expect(body.count as number).toBe(10); + }); + }); + + test('GET /api/v2/analyses/:id — returns analysis details', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Analysis Detail'); + await allure.story('User views individual analysis details'); + await allure.severity('critical'); + await allure.tags('vulnerability', 'analysis', 'smoke'); + + const response = await allure.step('GET analysis by ID', async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.analysisById.route, state.analysisId), + }); + }); + + const body = await allure.step( + 'Validate status 200 and schema', + async () => { + return await ResponseValidator.validate(response, { + status: 200, + requiredFields: ['id', 'risk', 'status', 'vulnerability', 'file'], + }); + } + ); + + await allure.step('Validate analysis detail schema', async () => { + SchemaValidator.validate(body, { + id: { type: 'number', required: true }, + risk: { type: 'number', required: true }, + computed_risk: { type: 'number', required: true }, + status: { type: 'number', required: true }, + vulnerability: { type: 'number', required: true }, + file: { type: 'number', required: true }, + cvss_base: { type: 'number', required: true }, + created_on: { type: 'string', required: true }, + updated_on: { type: 'string', required: true }, + }); + }); + + await allure.step('Verify analysis belongs to our file', async () => { + expect(body.file as number).toBe(state.fileId); + }); + }); + + test('GET /api/v3/files/:id/analyses — invalid file id returns 404', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Analysis'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'analysis', 'negative'); + + const response = await allure.step( + 'GET analyses with invalid fileId', + async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileAnalyses.route, 999999)}?limit=1`, + }); + } + ); + + await allure.step('Validate status 404', async () => { + await ResponseValidator.validate(response, { status: 404 }); + }); + }); + + test('GET /api/v2/analyses/:id — invalid analysis id returns 404', async () => { + await allure.epic('Vulnerability'); + await allure.feature('Analysis Detail'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'analysis', 'negative'); + + const response = await allure.step( + 'GET analysis with invalid id', + async () => { + return await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.analysisById.route, 999999), + }); + } + ); + + await allure.step('Validate status 404', async () => { + await ResponseValidator.validate(response, { status: 404 }); + }); + }); + + test('GET /api/v3/files/:id/analyses/owasp — invalid file id returns 404', async () => { + await allure.epic('Vulnerability'); + await allure.feature('OWASP Analysis'); + await allure.severity('normal'); + await allure.tags('vulnerability', 'owasp', 'negative'); + + const response = await allure.step( + 'GET OWASP analyses with invalid fileId', + async () => { + return await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.fileOwaspAnalyses.route, 999999)}?limit=10`, + }); + } + ); + + await allure.step('Validate status 404', async () => { + await ResponseValidator.validate(response, { status: 404 }); + }); + }); +}); diff --git a/playwright/specs/auth.spec.ts b/playwright/specs/auth.spec.ts new file mode 100644 index 0000000000..3d3315ae7b --- /dev/null +++ b/playwright/specs/auth.spec.ts @@ -0,0 +1,327 @@ +import { test, expect } from '@playwright/test'; +import * as allure from 'allure-js-commons'; +import { APPLICATION_ROUTES } from '../support/application.routes'; +import pwTranslate from '../support/translations'; +import { API_ROUTES } from '../support/api.routes'; +import NetworkActions from '../Actions/network.actions'; + +test('dashboard loads when already authenticated', async ({ page }) => { + await allure.epic('Authentication'); + await allure.feature('Dashboard Access'); + await allure.story('Authenticated user lands on dashboard'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('dashboard', 'auth', 'smoke', 'positive'); + await allure.description(` + Verifies that an already authenticated user: + - Can navigate to /dashboard/home + - URL contains 'dashboard' + `); + + await allure.step('Navigate to /dashboard/home', async () => { + await page.goto('/dashboard/home'); + }); + + await allure.step('Verify URL contains dashboard', async () => { + await expect(page).toHaveURL(/dashboard/); + }); +}); + +test('should redirect unauthenticated user to login page', async ({ + browser, +}) => { + await allure.epic('Authentication'); + await allure.feature('Login'); + await allure.story('Unauthenticated user is redirected to login'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'redirect', 'smoke', 'negative'); + await allure.description(` + Verifies that a user with no session: + - Is redirected to /login when visiting /projects + - Login page title is visible + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + + await allure.step('Navigate to projects page without auth', async () => { + await page.goto(APPLICATION_ROUTES.projects); + }); + + await allure.step('Wait for redirect to login', async () => { + await page.waitForURL(/login/, { timeout: 15000 }); + }); + + await allure.step('Verify URL is login and title is visible', async () => { + await expect(page).toHaveURL(/login/); + await expect(page.getByText(pwTranslate('loginTitle'))).toBeVisible(); + }); + + await context.close(); +}); + +test('valid login via UI redirects to dashboard', async ({ browser }) => { + await allure.epic('Authentication'); + await allure.feature('Login'); + await allure.story('User logs in via UI and lands on dashboard'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'login', 'smoke', 'positive', 'e2e'); + await allure.description(` + Verifies full UI login flow: + - Fill username → click Next + - Fill password → click Login + - Redirected to dashboard + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + + await allure.step('Navigate to login page', async () => { + await page.goto('/login'); + }); + + await allure.step('Fill username and click Next', async () => { + await page + .getByPlaceholder(pwTranslate('usernameEmailIdTextPlaceholder')) + .fill(process.env.TEST_USERNAME!); + await page.getByRole('button', { name: 'Next' }).click(); + }); + + await allure.step('Fill password and click Login', async () => { + await page + .getByPlaceholder(pwTranslate('passwordPlaceholder')) + .fill(process.env.TEST_PASSWORD!); + await page.locator('button[aria-label="login-submit-button"]').click(); + }); + + await allure.step('Verify redirect to dashboard', async () => { + await page.waitForURL(/dashboard/, { timeout: 15000 }); + await expect(page).toHaveURL(/dashboard/); + }); + + await context.close(); +}); + +test('should show error message on failed login', async ({ browser }) => { + await allure.epic('Authentication'); + await allure.feature('Login'); + await allure.story('User sees error message on failed login'); + await allure.severity('critical'); + await allure.owner('Pranav'); + await allure.tags('auth', 'login', 'negative', 'regression', 'mock'); + await allure.description(` + Mocks POST /login with 401 response. + Verifies that wrong credentials show error message: + - "The credentials you entered are incorrect" + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + const networkActions = new NetworkActions(page); + + await allure.step('Mock login API to return 401', async () => { + await networkActions.mockNetworkReq({ + method: 'POST', + route: API_ROUTES.login.route, + status: 401, + dataOverride: { + message: 'The credentials you entered are incorrect', + attempt_left: '4', + failure_limit: '5', + }, + }); + }); + + await allure.step('Navigate to login page', async () => { + await page.goto(APPLICATION_ROUTES.login); + }); + + await allure.step('Fill username and click Next', async () => { + await page + .getByPlaceholder(pwTranslate('usernameEmailIdTextPlaceholder')) + .fill(process.env.TEST_USERNAME!); + await page.getByRole('button', { name: 'Next' }).click(); + }); + + await allure.step('Fill wrong password and click Login', async () => { + await page + .getByPlaceholder(pwTranslate('passwordPlaceholder')) + .fill('wrong_password'); + await page.locator('button[aria-label="login-submit-button"]').click(); + }); + + await allure.step('Verify error message is visible', async () => { + await expect( + page.getByText(pwTranslate('credentialsIncorrect')) + ).toBeVisible(); + }); + + await networkActions.clearAll(); + await context.close(); +}); + +test('dashboard shows main UI elements', async ({ page }) => { + await allure.epic('Dashboard'); + await allure.feature('Dashboard UI'); + await allure.story('Dashboard displays key UI elements'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('dashboard', 'ui', 'smoke', 'positive'); + await allure.description(` + Verifies /dashboard/projects page shows: + - Upload App button + - All Projects heading + - All Projects description text + `); + + await allure.step('Navigate to /dashboard/projects', async () => { + await page.goto('/dashboard/projects'); + }); + + await allure.step('Verify All Projects description is visible', async () => { + await expect( + page.getByText(pwTranslate('allProjectsDescription')) + ).toBeVisible(); + }); +}); + +test('reset button is disabled when email is empty', async ({ browser }) => { + await allure.epic('Authentication'); + await allure.feature('Forgot Password'); + await allure.story('Reset button is disabled with no email entered'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('forgot-password', 'ui', 'negative', 'regression'); + await allure.description(` + Verifies on /recover page: + - Reset Password button is disabled when email input is empty + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + + await allure.step('Navigate to recover page', async () => { + await page.goto(APPLICATION_ROUTES.recover); + }); + + await allure.step('Verify reset button is disabled', async () => { + const resetButton = page.getByRole('button', { + name: pwTranslate('resetPassword'), + }); + await expect(resetButton).toBeDisabled(); + }); + + await context.close(); +}); + +test('reset button enables when email is entered', async ({ browser }) => { + await allure.epic('Authentication'); + await allure.feature('Forgot Password'); + await allure.story('Reset button enables after email is entered'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('forgot-password', 'ui', 'positive', 'regression'); + await allure.description(` + Verifies on /recover page: + - Reset Password button enables after a valid email is typed + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + + await allure.step('Navigate to recover page', async () => { + await page.goto(APPLICATION_ROUTES.recover); + }); + + await allure.step('Fill email input', async () => { + await page + .getByPlaceholder(pwTranslate('usernameEmailIdTextPlaceholder')) + .fill('test@test.com'); + }); + + await allure.step('Verify reset button is enabled', async () => { + const resetButton = page.getByRole('button', { + name: pwTranslate('resetPassword'), + }); + await expect(resetButton).toBeEnabled(); + }); + + await context.close(); +}); + +test('forgot password API returns 204 and shows confirmation message', async ({ + browser, +}) => { + await allure.epic('Authentication'); + await allure.feature('Forgot Password'); + await allure.story('User submits forgot password and sees confirmation'); + await allure.severity('normal'); + await allure.owner('Pranav'); + await allure.tags('forgot-password', 'api', 'smoke', 'positive'); + await allure.description(` + Verifies full forgot password flow: + - Fill email and click Reset Password + - API returns 204 + - Confirmation messages are visible + `); + + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + + const page = await context.newPage(); + + await allure.step('Navigate to recover page', async () => { + await page.goto(APPLICATION_ROUTES.recover); + }); + + await allure.step('Fill email and click Reset Password', async () => { + await page + .getByPlaceholder(pwTranslate('usernameEmailIdTextPlaceholder')) + .fill('randomletters123@test.com'); + }); + + const responsePromise = page.waitForResponse((res) => + res.url().includes(API_ROUTES.forgotPassword.route) + ); + + await allure.step('Click Reset Password button', async () => { + await page + .getByRole('button', { name: pwTranslate('resetPassword') }) + .click(); + }); + + await allure.step('Verify API returns 204', async () => { + const response = await responsePromise; + expect(response.status()).toBe(204); + }); + + await allure.step('Verify confirmation messages are visible', async () => { + await expect( + page.getByText(pwTranslate('resetPasswordMessageToCheck')) + ).toBeVisible(); + + await expect( + page.getByText(pwTranslate('resetPasswordMessageToRetry')) + ).toBeVisible(); + }); + + await context.close(); +}); diff --git a/playwright/support/api.routes.ts b/playwright/support/api.routes.ts new file mode 100644 index 0000000000..642613c8e2 --- /dev/null +++ b/playwright/support/api.routes.ts @@ -0,0 +1,316 @@ +export const API_ROUTES = { + // General + check: { + route: '/api/v2/sso/check', + alias: 'checkUser', + }, + frontendConfig: { + route: '/api/v2/frontend_configuration', + alias: 'frontendConfig', + }, + serverConfig: { + route: '/api/v2/server_configuration', + alias: 'serverConfig', + }, + websockets: { + route: '/websocket/**', + alias: 'websockets', + }, + uploadApp: { + route: '/api/organizations/*/upload_app', //done + alias: 'uploadAppReq', + }, + uploadAppViaLink: { + route: '/api/organizations/*/upload_app_url', + alias: 'uploadAppLinkReq', + }, + organizations: { + route: '/api/organizations', + alias: 'organizations', + }, + + // Auth + login: { route: '/api/login' }, //dpne + + forgotPassword: { + route: '/api/v2/forgot_password', + alias: 'forgotPassword', + }, //done + + // Listing Routes + sbomProjectList: { + route: '/api/v2/sb_projects*', + alias: 'sbomProjectList', + }, + + previousFile: { + route: '/api/v3/files/*/previous_file', + alias: 'previousFile', + }, + submissionList: { + route: '/api/submissions*', + alias: 'submissionList', + }, + + submission: { + route: '/api/submissions/*', + alias: 'submission', + }, + v3ProjectList: { + route: '/api/v3/projects*', + alias: 'projectList', + }, + projectList: { + route: '/api/organizations/*/projects*', + alias: 'projectList', + }, + vulnerabilityList: { + route: '/api/vulnerabilities', + alias: 'vulnerabilityList', + }, + hudsonProjectList: { + route: '/api/hudson-api/projects', + alias: 'hudsonProjectList', + }, + vulnerabilityPreferenceList: { + route: '/api/profiles/*/vulnerability_preferences', + alias: 'vulnerabilityPreferenceList', + }, + serviceAccountList: { + route: '/api/service_accounts*', + alias: 'serviceAccountList', + }, + //reports + canGenerateReport: { + route: '/api/v3/files/*/can_generate_report', + alias: 'canGenerateReport', + }, + reports: { + route: '/api/v2/files/*/reports', + alias: 'reports', + }, + privacyReport: { + route: '/api/v2/files/*/privacy_report', + alias: 'privacyReport', + }, + reportPdf: { + route: '/api/v2/reports/*/pdf', + alias: 'reportPdf', + }, + reportExcel: { + route: '/api/v2/reports/*/summary_excel', + alias: 'reportExcel', + }, + reportCsv: { + route: '/api/v2/reports/*/summary_csv', + alias: 'reportCsv', + }, + privacyReportGenerate: { + route: '/api/v2/privacy_reports/*/pdf/generate', + alias: 'privacyReportGenerate', + }, + privacyReportById: { + route: '/api/v2/privacy_reports/*', + alias: 'privacyReportById', + }, + privacyReportDownload: { + route: '/api/v2/privacy_reports/*/pdf/download_url', + alias: 'privacyReportDownload', + }, + + sbProjects: { + route: '/api/v2/sb_projects', + alias: 'sbProjects', + }, + sbFileById: { + route: '/api/v2/sb_files/*', + alias: 'sbFileById', + }, + sbReports: { + route: '/api/v2/sb_files/*/sb_reports', + alias: 'sbReports', + }, + sbReportById: { + route: '/api/v2/sb_reports/*', + alias: 'sbReportById', + }, + sbReportGenerate: { + route: '/api/v2/sb_reports/*/pdf/generate', + alias: 'sbReportGenerate', + }, + sbReportDownload: { + route: '/api/v2/sb_reports/*/pdf/download_url', + alias: 'sbReportDownload', + }, + sbReortCyclonedx: { + route: '/api/v2/sb_reports/*/cyclonedx_json_file/download_url', + alias: 'sbReportCyclonedx', + }, + fileOwaspAnalyses: { + route: '/api/v3/files/*/analyses/owasp', + alias: 'fileOwaspAnalyses', + }, + owaspMobile: { + route: '/api/v2/owaspmobile2024s', + alias: 'owaspMobile2024', + }, + // Single Record routes + file: { route: '/api/v3/files', alias: 'file' }, + + fileRisk: { route: '/api/v3/files/*/risk', alias: 'fileRisk' }, + sbom: { route: '/api/v2/sb_files', alias: 'sbomFile' }, + unknownAnalysisStatus: { + route: '/api/profiles/*/unknown_analysis_status*', + alias: 'unknownAnalysisStatus', + }, + fileById: { + route: '/api/v3/files/*', + alias: 'fileById', + }, + fileAnalyses: { + route: '/api/v3/files/*/analyses', + alias: 'fileAnalyses', + }, + analysisById: { + route: '/api/v2/analyses/*', + alias: 'analysisById', + }, + userInfo: { + route: '/api/users/**', + alias: 'userInfo', + }, + analysis: { + route: '/api/v2/analyses', + alias: 'analysisItem', + }, + projectById: { + route: '/api/v3/projects/*', + alias: 'projectById', + }, + + submissionItem: { + route: '/api/submissions', + alias: 'submission', + }, + // dsManualDevicePreference: { + // route: '/api/v2/profiles/*/ds_manual_device_preference', + // alias: 'dsManualDevicePreference', + // }, + // availableManualDevices: { + // route: '/api/v2/projects/*/available_manual_devices*', + // alias: 'availableManualDevices', + // }, + editAnalysisRisk: { + route: '/api/files/*/vulnerability_preferences/*/risk', + alias: 'editAnalysisRisk', + }, + + fileTags: { + route: '/api/v2/files/*/tags', + alias: 'fileTags', + }, + fileTagById: { + route: '/api/v2/files/*/tags/*', + alias: 'fileTagById', + }, + + dsManualDevicePreference: { + route: '/api/v2/profiles/*/ds_manual_device_preference', + alias: 'dsManualDevicePreference', + }, + availableManualDevices: { + route: '/api/v2/projects/*/available_manual_devices', + alias: 'availableManualDevices', + }, + apiScanOptions: { + route: '/api/profiles/*/api_scan_options', + alias: 'apiScanOptions', + }, + dynamicScans: { + route: '/api/v2/files/*/dynamicscans', + alias: 'dynamicScans', + }, + dynamicScanById: { + route: '/api/v2/dynamicscans/*', + alias: 'dynamicScanById', + }, + lastManualDynamicScan: { + route: '/api/v3/files/*/last_manual_dynamic_scan', + alias: 'lastManualDynamicScan', + }, + serviceAccount: { + route: '/api/service_accounts/*', + alias: 'serviceAccount', + }, + teamsList: { + route: '/api/organizations/*/teams*', + alias: 'teamsList', + }, + organizationTeams: { + route: '/api/organizations/*/teams/*', + alias: 'organizationTeams', + }, + teamMembers: { + route: '/api/organizations/*/teams/*/members/*', + alias: 'teamMembers', + }, + teamMembersList: { + route: '/api/organizations/*/teams/*/members*', + alias: 'teamMembersList', + }, + teamProject: { + route: '/api/organizations/*/teams/*/projects/*', + alias: 'teamProject', + }, + organization: { + route: '/api/organizations/*', + alias: 'organization', + }, + editUserInfo: { + route: '/api/organizations/*/users/*', + alias: 'editUser', + }, + membersList: { + route: '/api/organizations/*/members*', + alias: 'membersList', + }, + member: { + route: '/api/organizations/*/members/*', + alias: 'member', + }, + teamExcludesUser: { + route: '/api/organizations/*/teams?exclude_user=*', + alias: 'teamExcludesUser', + }, + teamIncludesUser: { + route: '/api/organizations/*/teams?include_user=*', + alias: 'teamIncludesUser', + }, + inviteUser: { + route: '/api/organizations/*/invitations', + alias: 'inviteUser', + }, + usersList: { + route: '/api/organizations/*/users*', + alias: 'usersList', + }, + inviteTeam: { + route: '/api/organizations/*/teams/*/invitations*', + alias: 'inviteTeam', + }, + dynamicscan: { + route: '/api/v3/files/*/last_manual_dynamic_scan', + alias: 'dynamicscan', + }, +} as const; + +export function resolveRoute( + route: string, + ...params: (string | number)[] +): string { + let resolved = route; + for (const param of params) { + resolved = resolved.replace('*', String(param)); + } + return resolved.replace(/\*/g, ''); // remove any remaining wildcards +} diff --git a/playwright/support/application.routes.ts b/playwright/support/application.routes.ts new file mode 100644 index 0000000000..5f26fe6c82 --- /dev/null +++ b/playwright/support/application.routes.ts @@ -0,0 +1,11 @@ +export const APPLICATION_ROUTES = { + login: '/login', + recover: '/recover', + projects: '/projects', + file: '/dashboard/file', + sbom: '/dashboard/sbom/apps', + serviceAccount: '/dashboard/organization/settings/service-account', + register: '/register', + organizationUsers: '/dashboard/organization/users', + organizationTeams: '/dashboard/organization/teams', +} as const; diff --git a/playwright/support/constants.ts b/playwright/support/constants.ts new file mode 100644 index 0000000000..58350c2e45 --- /dev/null +++ b/playwright/support/constants.ts @@ -0,0 +1,58 @@ +export const DYNAMIC_SCAN_STATUS = { + NOT_STARTED: 0, + PREPROCESSING: 1, + PROCESSING_SCAN_REQUEST: 2, + IN_QUEUE: 3, + DEVICE_ALLOCATED: 4, + CONNECTING_TO_DEVICE: 5, + PREPARING_DEVICE: 6, + INSTALLING: 7, + CONFIGURING_API_CAPTURE: 8, + HOOKING: 9, + LAUNCHING: 10, + READY_FOR_INTERACTION: 11, + DOWNLOADING_AUTOPILOT_SCRIPT: 12, + CONFIGURING_AUTOPILOT: 13, + AUTOPILOT_RUNNING: 14, + AUTOPILOT_COMPLETED: 15, + STOP_SCAN_REQUESTED: 16, + SCAN_TIME_LIMIT_EXCEEDED: 17, + SHUTTING_DOWN: 18, + CLEANING_DEVICE: 19, + RUNTIME_DETECTION_COMPLETED: 20, + ANALYZING: 21, + ANALYSIS_COMPLETED: 22, + TIMED_OUT: 23, + ERROR: 24, + CANCELLED: 25, + TERMINATED: 26, +} as const; + +export const SCAN_RUNNING_STATUSES: number[] = [ + DYNAMIC_SCAN_STATUS.DEVICE_ALLOCATED, + DYNAMIC_SCAN_STATUS.CONNECTING_TO_DEVICE, + DYNAMIC_SCAN_STATUS.PREPARING_DEVICE, + DYNAMIC_SCAN_STATUS.INSTALLING, + DYNAMIC_SCAN_STATUS.CONFIGURING_API_CAPTURE, + DYNAMIC_SCAN_STATUS.HOOKING, + DYNAMIC_SCAN_STATUS.LAUNCHING, + DYNAMIC_SCAN_STATUS.READY_FOR_INTERACTION, +]; + +export const SCAN_STOPPED_STATUSES: number[] = [ + DYNAMIC_SCAN_STATUS.RUNTIME_DETECTION_COMPLETED, + DYNAMIC_SCAN_STATUS.ANALYZING, + DYNAMIC_SCAN_STATUS.ANALYSIS_COMPLETED, + DYNAMIC_SCAN_STATUS.TIMED_OUT, + DYNAMIC_SCAN_STATUS.ERROR, + DYNAMIC_SCAN_STATUS.CANCELLED, + DYNAMIC_SCAN_STATUS.TERMINATED, +]; + +export const RISK_LEVEL = { + PASSED: 0, + LOW: 1, + MEDIUM: 2, + HIGH: 3, + CRITICAL: 4, +} as const; diff --git a/playwright/support/test-state.ts b/playwright/support/test-state.ts new file mode 100644 index 0000000000..f0c81ff503 --- /dev/null +++ b/playwright/support/test-state.ts @@ -0,0 +1,28 @@ +import path from 'path'; +import fs from 'fs'; + +const env = process.env.ENVIRONMENT || 'qa'; +const statePath = path.resolve(__dirname, `../../.state/${env}-state.json`); + +export interface TestState { + orgId: number; + projectId: number; + fileId: number; + reportId: number; + privacyReportId: number; + sbFileId: number; + sbReportId: number; + analysisId: number; + vulnerabilityId: number; + profileId: number; + features: { + privacy: boolean; + sbom: boolean; + manualscan: boolean; + dynamicscan_automation: boolean; + upload_via_url: boolean; + }; +} + +const state: TestState = JSON.parse(fs.readFileSync(statePath, 'utf-8')); +export default state; diff --git a/playwright/support/translations.ts b/playwright/support/translations.ts new file mode 100644 index 0000000000..ee200a1477 --- /dev/null +++ b/playwright/support/translations.ts @@ -0,0 +1,31 @@ +import IntlMessageFormat from 'intl-messageformat'; +import EN_TRANSLATIONS from '../../translations/en.json'; + +type NestedKeyOf = { + [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object + ? `${Key}` | `${Key}.${NestedKeyOf}` + : Key; +}[keyof ObjectType & (string | number)]; + +function getMessageFromTranslations( + key: NestedKeyOf +): string { + return key + .split('.') + .reduce((p: Record | string, c) => { + if (typeof p === 'object' && c in p) { + return p[c] as string; + } + return c; + }, EN_TRANSLATIONS) as string; +} + +function pwTranslate( + key: NestedKeyOf, + variables?: Record +): string { + const message = getMessageFromTranslations(key); + return new IntlMessageFormat(message).format(variables) as string; +} + +export default pwTranslate; diff --git a/playwright/support/utils.ts b/playwright/support/utils.ts new file mode 100644 index 0000000000..920f367e05 --- /dev/null +++ b/playwright/support/utils.ts @@ -0,0 +1,90 @@ +/** + * @function getAliasName + * @returns String with correct alias structure - "@{aliasName}" + */ +export const getAliasName = (alias: string): `@${string}` => `@${alias}`; + +/** + * Extracts dynamic parameters from an actual route based on a route template. + * + * @param {string} routeTemplate - The route template with placeholders for dynamic parameters. + * @param {string} actualRoute - The actual route containing dynamic parameter values. + * @returns {Record } An object representing dynamic parameters with their keys and values. + * + * @example + * const routeTemplate = "/path/:id/:slug"; + * const actualRoute = "/path/123/example-slug"; + * const dynamicParams = extractDynamicParams(routeTemplate, actualRoute); + */ + +export function extractDynamicSlugs( + routeTemplate: string, + actualRoute: string +): ParamObj { + const templateRegex = new RegExp( + routeTemplate.replace( + /:(\w+)/g, + (_, paramName) => `(?<${paramName}>[^\\/]+)` + ) + ); + + const match = actualRoute.match(templateRegex); + const params = match?.groups || {}; + + return Object.keys(params).reduce( + (prev, curr) => ({ + ...prev, + [curr]: params[curr], + }), + {} as ParamObj + ); +} + +/** + * Replaces all slugs in a route with an asterisk. + * + * @param {string} route - The route containing slugs. + * @returns {string} The route with slugs replaced by asterisks. + * + */ + +export function replaceSlugsWithAsterisks(route: string): string { + const slugRegex = /:[^\\/]+/g; + const routeWithAsterisks = route.replace(slugRegex, '*'); + + return routeWithAsterisks; +} + +/** + * Removes the host from a given URL. + * + * @param {string} urlString - The URL to process. + * @returns {string} The URL without the host. + * + * @example + * const originalUrl = "https://example.com/path/to/resource"; + * const urlWithoutHost = removeHostFromUrl(originalUrl); + * + * console.log("URL without Host:", urlWithoutHost); + * // Output: "/path/to/resource" + */ + +export function removeHostFromUrl(urlString: string): string { + const url = new URL(urlString); + const urlWithoutHost = url.pathname + url.search + url.hash; + + return urlWithoutHost; +} + +/** + * Returns the corresponding text description for a given vulnerability type. + * + * @param {number} vulnType - The numeric code representing the type of vulnerability. + * @returns {string | undefined} The text description of the vulnerability type + * (e.g., 'static', 'dynamic', 'manual', 'api'), or `undefined` if the code is not recognized. + */ +export function getVulnerabilityTypeText(vulnType: number): string { + const typeTextMap = { 1: 'static', 2: 'dynamic', 3: 'manual', 4: 'api' }; + + return typeTextMap[vulnType as keyof typeof typeTextMap]; +} diff --git a/playwright/ui.setup.spec.ts b/playwright/ui.setup.spec.ts new file mode 100644 index 0000000000..397b83943c --- /dev/null +++ b/playwright/ui.setup.spec.ts @@ -0,0 +1,9 @@ +import { test as setup } from '@playwright/test'; +import LoginActions from './Actions/auth/loginActions'; + +setup('UI Login Setup', async ({ page }) => { + const login = new LoginActions(page); + await login.login(process.env.TEST_USERNAME!, process.env.TEST_PASSWORD!); + await page.context().storageState({ path: '.auth/user.json' }); + console.log('[UI Setup] Login complete '); +}); diff --git a/playwright/utils/dynamic-scan.utils.ts b/playwright/utils/dynamic-scan.utils.ts new file mode 100644 index 0000000000..f184080b8e --- /dev/null +++ b/playwright/utils/dynamic-scan.utils.ts @@ -0,0 +1,99 @@ +import RequestWrapper from '../Actions/api/request.wrapper'; +import { API_ROUTES, resolveRoute } from '../support/api.routes'; +import { + SCAN_RUNNING_STATUSES, + SCAN_STOPPED_STATUSES, +} from '../support/constants'; +const POLL_INTERVAL = 5000; +const POLL_TIMEOUT = 120000; // 2 minutes + +export async function waitForDeviceAvailable( + wrapper: RequestWrapper, + projectId: number +): Promise { + const startTime = Date.now(); + + while (true) { + const response = await wrapper.get({ + endpoint: `${resolveRoute(API_ROUTES.availableManualDevices.route, projectId)}?limit=10&offset=0&platform_version_min=4.4`, + }); + const body = await response.json(); + const freeDevice = body.results.find( + (d: Record) => + d.state === 'available' && d.is_reserved === false + ); + + if (freeDevice) { + console.log(`[Utils] Device ${freeDevice.device_identifier} is free `); + return; + } + + if (Date.now() - startTime > POLL_TIMEOUT) + throw new Error('[Utils] Timeout: No device available after 2 minutes'); + + console.log('[Utils] Waiting for device to free up... 5s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } +} + +export async function waitForScanRunning( + wrapper: RequestWrapper, + fileId: number +): Promise { + const startTime = Date.now(); + + while (true) { + const response = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.lastManualDynamicScan.route, fileId), + }); + const body = await response.json(); + + const status = body.status as number; + if (SCAN_RUNNING_STATUSES.includes(status)) { + console.log(`[Utils] Scan running ${body.status_display}`); + return; + } + + if (Date.now() - startTime > POLL_TIMEOUT) + throw new Error('[Utils] Timeout: Scan did not start after 2 minutes'); + + console.log('[Utils] Scan still processing... 5s'); + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } +} + +export async function waitForScanStopped( + wrapper: RequestWrapper, + fileId: number +): Promise { + const startTime = Date.now(); + + while (true) { + const response = await wrapper.get({ + endpoint: resolveRoute(API_ROUTES.lastManualDynamicScan.route, fileId), + }); + + // 404 means no active scan → safe to start new one + if (response.status() === 404) { + console.log('[Utils] No active scan — safe to start new one'); + return; + } + + const body = await response.json(); + const status = body.status as number; + + if (SCAN_STOPPED_STATUSES.includes(status)) { + console.log(`[Utils] Scan stopped ${body.status_display}`); + await new Promise((r) => setTimeout(r, 5000)); + return; + } + if (Date.now() - startTime > POLL_TIMEOUT) + throw new Error('[Utils] Timeout: Scan did not stop after 2 minutes'); + + console.log('[Utils] Scan still stopping... 5s'); + await new Promise((r) => setTimeout(r, 5000)); + console.log( + `[Utils] Current scan status: ${body.status} - ${body.status_display}` + ); + } +} diff --git a/playwright/utils/response.validator.ts b/playwright/utils/response.validator.ts new file mode 100644 index 0000000000..828250e68e --- /dev/null +++ b/playwright/utils/response.validator.ts @@ -0,0 +1,64 @@ +import { APIResponse } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export interface ValidationOptions { + status: number; + requiredFields?: string[]; +} + +export default class ResponseValidator { + /** + * Validate response status and required fields + * Reusable across all API tests + * + * @example + * await ResponseValidator.validate(response, { + * status: 200, + * requiredFields: ['token', 'b64token'] + * }); + */ + static async validate( + response: APIResponse, + opts: ValidationOptions + ): Promise> { + // Validate status code + expect( + response.status(), + `Expected status ${opts.status} but got ${response.status()} for ${response.url()}` + ).toBe(opts.status); + + // Parse response body + const body = await response.json(); + + // Validate required fields exist in response + if (opts.requiredFields) { + for (const field of opts.requiredFields) { + expect( + body, + `Expected field '${field}' in response from ${response.url()}` + ).toHaveProperty(field); + } + } + + return body; + } + + /** + * Validate paginated response structure + * Checks for 'count' and 'results' fields, and that 'results' is an array + * Can be extended to validate pagination links (next, previous) if needed + */ + static async validatePaginated( + response: APIResponse, + opts: { status?: number } = {} + ): Promise> { + const body = await this.validate(response, { + status: opts.status ?? 200, + requiredFields: ['count', 'results'], + }); + + expect(Array.isArray(body.results)).toBe(true); + + return body; + } +} diff --git a/playwright/utils/schema.validator.ts b/playwright/utils/schema.validator.ts new file mode 100644 index 0000000000..eb27dbf252 --- /dev/null +++ b/playwright/utils/schema.validator.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; + +type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +export interface SchemaField { + type: SchemaType; + required?: boolean; +} + +export interface Schema { + [field: string]: SchemaField; +} + +export default class SchemaValidator { + /** + * Validates response body matches expected schema + * Catches API contract breaks immediately + * + * @example + * SchemaValidator.validate(body, { + * token: { type: 'string', required: true }, + * b64token: { type: 'string', required: true }, + * }); + */ + static validate(body: Record, schema: Schema): void { + for (const [field, rules] of Object.entries(schema)) { + // Check required fields exist + if (rules.required) { + expect( + body, + `Schema violation: required field '${field}' is missing` + ).toHaveProperty(field); + } + + // Skip type check if field not present and not required + if (!(field in body)) continue; + + const value = body[field]; + + // Check correct type + if (rules.type === 'array') { + expect( + Array.isArray(value), + `Schema violation: '${field}' should be array but got ${typeof value}` + ).toBe(true); + } else { + expect( + typeof value, + `Schema violation: '${field}' should be ${rules.type} but got ${typeof value}` + ).toBe(rules.type); + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 83fcda3de7..b71f0af871 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,8 @@ "declarationMap": false, "resolveJsonModule": true, "baseUrl": ".", + "ignoreDeprecations": "6.0", + "verbatimModuleSyntax": false, // TODO: remove this later "paths": { "fetch": ["node_modules/ember-fetch"],