diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 644992278..938ec48c9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,6 +52,27 @@ jobs: - name: Run E2E tests run: npm run test:e2e + - name: Run Cypress E2E tests + run: npm run cypress:run:docker + timeout-minutes: 10 + env: + CYPRESS_BASE_URL: http://localhost:8081 + CYPRESS_API_BASE_URL: http://localhost:8081 + CYPRESS_GIT_PROXY_URL: http://localhost:8000 + CYPRESS_GIT_SERVER_TARGET: git-server:8443 + + - name: Dump git-proxy logs on failure + if: failure() + run: docker compose logs git-proxy --tail=100 + + - name: Upload Cypress screenshots on failure + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + retention-days: 7 + - name: Stop services if: always() run: docker compose down -v diff --git a/cypress.config.js b/cypress.config.js index 264a93c39..e70ea7cd3 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -19,7 +19,13 @@ const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:3000', + specPattern: 'cypress/e2e/*.cy.{js,ts}', chromeWebSecurity: false, // Required for OIDC testing + env: { + API_BASE_URL: process.env.CYPRESS_API_BASE_URL || 'http://localhost:8080', + GIT_PROXY_URL: process.env.CYPRESS_GIT_PROXY_URL || 'http://localhost:8000', + GIT_SERVER_TARGET: process.env.CYPRESS_GIT_SERVER_TARGET || 'git-server:8443', + }, setupNodeEvents(on, config) { on('task', { log(message) { diff --git a/cypress/e2e/docker/pushActions.cy.js b/cypress/e2e/docker/pushActions.cy.js new file mode 100644 index 000000000..690a2eb6e --- /dev/null +++ b/cypress/e2e/docker/pushActions.cy.js @@ -0,0 +1,270 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Push Actions (Approve, Reject, Cancel)', () => { + const testUser = { + username: 'testuser', + password: 'user123', + email: 'testuser@example.com', + gitAccount: 'testuser', + }; + + const approverUser = { + username: 'approver', + password: 'approver123', + email: 'approver@example.com', + gitAccount: 'approver', + }; + + before(() => { + // Setup: login as admin, create test users, assign permissions + cy.login('admin', 'admin'); + + cy.createUser(testUser.username, testUser.password, testUser.email, testUser.gitAccount); + cy.createUser( + approverUser.username, + approverUser.password, + approverUser.email, + approverUser.gitAccount, + ); + + cy.getTestRepoId().then((repoId) => { + cy.addUserPushPermission(repoId, testUser.username); + cy.addUserAuthorisePermission(repoId, approverUser.username); + }); + + cy.logout(); + }); + + afterEach(() => { + cy.logout(); + }); + + describe('Approve flow', () => { + beforeEach(() => { + const suffix = `approve-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should approve a pending push via attestation dialog', function () { + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Verify push is Pending + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Action buttons should be visible + cy.get('[data-testid="push-cancel-btn"]').should('be.visible'); + cy.get('[data-testid="push-reject-btn"]').should('be.visible'); + cy.get('[data-testid="attestation-open-btn"]').should('be.visible'); + + // Open attestation dialog + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + + // Confirm button should be disabled until all checkboxes are checked + cy.get('[data-testid="attestation-confirm-btn"]').should('be.disabled'); + + // Check all attestation checkboxes + cy.get('[data-testid="attestation-dialog"]') + .find('input[type="checkbox"]') + .each(($checkbox) => { + cy.wrap($checkbox).check({ force: true }); + }); + + // Confirm button should now be enabled + cy.get('[data-testid="attestation-confirm-btn"]').should('not.be.disabled'); + + // Click confirm to approve + cy.get('[data-testid="attestation-confirm-btn"]').click(); + + // Should navigate back to push list + cy.url().should('include', '/dashboard/push'); + cy.url().should('not.include', this.pushId); + + // Verify push is now Approved by revisiting its detail page + cy.visit(`/dashboard/push/${this.pushId}`); + cy.get('[data-testid="push-status"]').should('contain', 'Approved'); + + // Action buttons should no longer be visible for an approved push + cy.get('[data-testid="push-cancel-btn"]').should('not.exist'); + cy.get('[data-testid="push-reject-btn"]').should('not.exist'); + cy.get('[data-testid="attestation-open-btn"]').should('not.exist'); + }); + }); + + describe('Reject flow', () => { + beforeEach(() => { + const suffix = `reject-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should reject a pending push', function () { + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Verify push is Pending + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Open reject dialog + cy.get('[data-testid="push-reject-btn"]').click(); + + // Confirm button should be disabled until reason is provided + cy.get('[data-testid="push-reject-confirm-btn"]').should('be.disabled'); + + // Fill in rejection reason + cy.get('#reason').type('Rejecting for test purposes'); + + // Confirm button should now be enabled + cy.get('[data-testid="push-reject-confirm-btn"]').should('not.be.disabled'); + + // Confirm rejection + cy.get('[data-testid="push-reject-confirm-btn"]').click(); + + // Should navigate back to push list + cy.url().should('include', '/dashboard/push'); + cy.url().should('not.include', this.pushId); + + // Verify push is now Rejected + cy.visit(`/dashboard/push/${this.pushId}`); + cy.get('[data-testid="push-status"]').should('contain', 'Rejected'); + + // Action buttons should no longer be visible + cy.get('[data-testid="push-cancel-btn"]').should('not.exist'); + cy.get('[data-testid="push-reject-btn"]').should('not.exist'); + cy.get('[data-testid="attestation-open-btn"]').should('not.exist'); + }); + }); + + describe('Cancel flow', () => { + beforeEach(() => { + const suffix = `cancel-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should cancel a pending push', function () { + // Cancel can be done by the push author + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Verify push is Pending + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Click Cancel + cy.get('[data-testid="push-cancel-btn"]').click(); + + // Should navigate back to push list + cy.url().should('include', '/dashboard/push'); + + // Verify push is now Canceled + cy.visit(`/dashboard/push/${this.pushId}`); + cy.get('[data-testid="push-status"]').should('contain', 'Canceled'); + + // Action buttons should no longer be visible + cy.get('[data-testid="push-cancel-btn"]').should('not.exist'); + cy.get('[data-testid="push-reject-btn"]').should('not.exist'); + cy.get('[data-testid="attestation-open-btn"]').should('not.exist'); + }); + }); + + describe('Negative: unauthorized approve', () => { + beforeEach(() => { + const suffix = `neg-approve-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should not change push state when user lacks canAuthorise permission', function () { + // Login as testuser (has canPush but NOT canAuthorise) + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Open attestation dialog and attempt to approve + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + + // Check all checkboxes + cy.get('[data-testid="attestation-dialog"]') + .find('input[type="checkbox"]') + .each(($checkbox) => { + cy.wrap($checkbox).check({ force: true }); + }); + + cy.get('[data-testid="attestation-confirm-btn"]').click(); + + // TODO: The server correctly returns 403 but the UI (src/ui/services/git-push.ts) + // only handles 401 errors in authorisePush/rejectPush. The 403 is silently + // ignored and the user is navigated away without feedback. Once the UI properly + // handles 403, this test should assert a snackbar error message is shown. + cy.visit(`/dashboard/push/${this.pushId}`); + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + }); + }); + + describe('Negative: unauthorized reject', () => { + beforeEach(() => { + const suffix = `neg-reject-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should not change push state when user lacks canAuthorise permission', function () { + // Login as testuser + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Click Reject + cy.get('[data-testid="push-reject-btn"]').click(); + + // TODO: Same issue as unauthorized approve — UI ignores 403 from server. + // Once fixed, assert snackbar error message is shown. + cy.visit(`/dashboard/push/${this.pushId}`); + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + }); + }); + + describe('Attestation dialog cancel does not cancel the push', () => { + beforeEach(() => { + const suffix = `dialog-cancel-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + }); + + it('should close attestation dialog without affecting push status', function () { + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Open attestation dialog + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + + // Click the dialog's Cancel button (NOT the push cancel button) + cy.get('[data-testid="attestation-cancel-btn"]').click(); + + // Dialog should close, push should still be pending + cy.get('[data-testid="attestation-dialog"]').should('not.exist'); + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Action buttons should still be visible (push is still pending) + cy.get('[data-testid="push-cancel-btn"]').should('be.visible'); + cy.get('[data-testid="push-reject-btn"]').should('be.visible'); + cy.get('[data-testid="attestation-open-btn"]').should('be.visible'); + }); + }); +}); diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index 08432c547..a69372848 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -18,6 +18,12 @@ describe('Repo', () => { let cookies; let repoName; + before(() => { + cy.login('admin', 'admin'); + cy.cleanupTestRepos(); + cy.logout(); + }); + describe('Anonymous users', () => { beforeEach(() => { cy.visit('/dashboard/repo'); @@ -70,8 +76,6 @@ describe('Repo', () => { }); cy.contains('a', `cypress-test/${repoName}`, { timeout: 10000 }).click(); - - // cy.get('[data-testid="delete-repo-button"]').click(); }); it('Displays an error when adding an existing repo', () => { @@ -100,11 +104,13 @@ describe('Repo', () => { // Create a new repo cy.getCSRFToken().then((csrfToken) => { repoName = `${Date.now()}`; - cloneURL = `http://localhost:8000/github.com/cypress-test/${repoName}.git`; + const gitProxyUrl = Cypress.env('GIT_PROXY_URL') || 'http://localhost:8000'; + cloneURL = `${gitProxyUrl}/github.com/cypress-test/${repoName}.git`; + const apiBaseUrl = Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); cy.request({ method: 'POST', - url: 'http://localhost:8080/api/v1/repo', + url: `${apiBaseUrl}/api/v1/repo`, body: { project: 'cypress-test', name: repoName, @@ -161,7 +167,7 @@ describe('Repo', () => { cy.getCSRFToken().then((csrfToken) => { cy.request({ method: 'DELETE', - url: `http://localhost:8080/api/v1/repo/${repoName}/delete`, + url: `${Cypress.env('API_BASE_URL') || Cypress.config('baseUrl')}/api/v1/repo/${repoId}/delete`, headers: { cookie: cookies?.join('; ') || '', 'X-CSRF-TOKEN': csrfToken, diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 1624d8ad6..e852c0a28 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -40,6 +40,15 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +/** + * Helper to get the API base URL for cy.request calls. + * cy.request with relative URLs may not resolve correctly in all environments, + * so we use absolute URLs constructed from Cypress.config('baseUrl'). + */ +function getApiBaseUrl() { + return Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); +} + // start of a login command with sessions // TODO: resolve issues with the CSRF token Cypress.Commands.add('login', (username, password) => { @@ -57,11 +66,11 @@ Cypress.Commands.add('login', (username, password) => { }); Cypress.Commands.add('logout', () => { - Cypress.session.clearAllSavedSessions(); + cy.clearCookies(); }); Cypress.Commands.add('getCSRFToken', () => { - return cy.request('GET', 'http://localhost:8080/api/v1/repo').then((res) => { + return cy.request('GET', `${getApiBaseUrl()}/api/v1/repo`).then((res) => { let cookies = res.headers['set-cookie']; if (typeof cookies === 'string') { @@ -69,15 +78,161 @@ Cypress.Commands.add('getCSRFToken', () => { } if (!cookies) { - throw new Error('No cookies found in response'); + // No Set-Cookie header: CSRF protection is disabled (expected in NODE_ENV=test). + cy.log('getCSRFToken: no cookies in response, assuming CSRF is disabled'); + return cy.wrap(''); } const csrfCookie = cookies.find((c) => c.startsWith('csrf=')); if (!csrfCookie) { - throw new Error('No CSRF cookie found in response headers'); + cy.log('getCSRFToken: no csrf cookie found, assuming CSRF is disabled'); + return cy.wrap(''); } const token = csrfCookie.split('=')[1].split(';')[0]; return cy.wrap(decodeURIComponent(token)); }); }); + +Cypress.Commands.add('createUser', (username, password, email, gitAccount) => { + cy.request({ + method: 'POST', + url: `${getApiBaseUrl()}/api/auth/create-user`, + body: { username, password, email, gitAccount, admin: false }, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('addUserPushPermission', (repoId, username) => { + cy.request({ + method: 'PATCH', + url: `${getApiBaseUrl()}/api/v1/repo/${repoId}/user/push`, + body: { username }, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('addUserAuthorisePermission', (repoId, username) => { + cy.request({ + method: 'PATCH', + url: `${getApiBaseUrl()}/api/v1/repo/${repoId}/user/authorise`, + body: { username }, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('getTestRepoId', () => { + const url = `${getApiBaseUrl()}/api/v1/repo`; + cy.request({ + method: 'GET', + url, + headers: { Accept: 'application/json' }, + failOnStatusCode: false, + }).then((res) => { + if (res.status !== 200) { + throw new Error( + `GET ${url} returned status ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`, + ); + } + if (!Array.isArray(res.body)) { + throw new Error( + `GET ${url} returned non-array (${typeof res.body}): ${JSON.stringify(res.body).slice(0, 500)}`, + ); + } + const gitServerTarget = Cypress.env('GIT_SERVER_TARGET') || 'git-server:8443'; + const repo = res.body.find( + (r) => r.url === `https://${gitServerTarget}/test-owner/test-repo.git`, + ); + if (!repo) { + throw new Error( + `test-owner/test-repo not found in database. Repos: ${res.body.map((r) => r.url).join(', ')}`, + ); + } + return cy.wrap(repo._id); + }); +}); + +Cypress.Commands.add('cleanupTestRepos', () => { + cy.getCSRFToken().then((csrfToken) => { + cy.request({ + method: 'GET', + url: `${getApiBaseUrl()}/api/v1/repo`, + failOnStatusCode: false, + }).then((res) => { + if (res.status !== 200 || !Array.isArray(res.body)) return; + const testRepos = res.body.filter((r) => r.project === 'cypress-test'); + testRepos.forEach((repo) => { + cy.request({ + method: 'DELETE', + url: `${getApiBaseUrl()}/api/v1/repo/${repo._id}/delete`, + headers: { 'X-CSRF-TOKEN': csrfToken }, + failOnStatusCode: false, + }); + }); + }); + }); +}); + +Cypress.Commands.add('deleteRepo', (repoId) => { + cy.getCSRFToken().then((csrfToken) => { + cy.request({ + method: 'DELETE', + url: `${getApiBaseUrl()}/api/v1/repo/${repoId}/delete`, + headers: { + 'X-CSRF-TOKEN': csrfToken, + }, + failOnStatusCode: false, + }); + }); +}); + +Cypress.Commands.add('createPush', (gitUser, gitPassword, gitEmail, uniqueSuffix) => { + const proxyUrl = Cypress.env('GIT_PROXY_URL') || 'http://localhost:8000'; + const gitServerTarget = Cypress.env('GIT_SERVER_TARGET') || 'git-server:8443'; + const repoUrl = `${proxyUrl}/${gitServerTarget}/test-owner/test-repo.git`; + const cloneDir = `/tmp/cypress-push-${uniqueSuffix}`; + + // Pass credentials via GIT_CONFIG_* env vars to avoid exposing them in command output + const gitCredentialEnv = { + GIT_TERMINAL_PROMPT: '0', + NODE_TLS_REJECT_UNAUTHORIZED: '0', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `url.${proxyUrl.replace('://', `://${gitUser}:${gitPassword}@`)}.insteadOf`, + GIT_CONFIG_VALUE_0: proxyUrl, + }; + + cy.exec(`rm -rf ${cloneDir}`, { failOnNonZeroExit: false }); + cy.exec(`git clone ${repoUrl} ${cloneDir}`, { + timeout: 30000, + env: gitCredentialEnv, + }); + cy.exec(`git -C ${cloneDir} config user.name "${gitUser}"`); + cy.exec(`git -C ${cloneDir} config user.email "${gitEmail}"`); + + // Pull any upstream changes to avoid conflicts from previous test runs + cy.exec(`git -C ${cloneDir} pull --rebase origin main`, { + failOnNonZeroExit: false, + timeout: 30000, + env: gitCredentialEnv, + }); + + const timestamp = Date.now(); + cy.exec( + `echo "test-${uniqueSuffix}-${timestamp}" > ${cloneDir}/cypress-test-${uniqueSuffix}.txt`, + ); + cy.exec(`git -C ${cloneDir} add .`); + cy.exec(`git -C ${cloneDir} commit -m "cypress e2e test: ${uniqueSuffix}"`); + cy.exec(`git -C ${cloneDir} push origin main 2>&1`, { + failOnNonZeroExit: false, + timeout: 30000, + env: gitCredentialEnv, + }).then((result) => { + const output = result.stdout + result.stderr; + const match = output.match(/dashboard\/push\/([a-f0-9_]+)/); + if (!match) { + throw new Error(`Could not extract push ID from git output:\n${output}`); + } + cy.exec(`rm -rf ${cloneDir}`, { failOnNonZeroExit: false }); + return cy.wrap(match[1]); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index a81f25c60..b947fd688 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - git-network environment: - NODE_ENV=test + - CONFIG_FILE=/app/test-e2e.proxy.config.json - GIT_PROXY_UI_PORT=8081 - GIT_PROXY_SERVER_PORT=8000 - NODE_OPTIONS=--trace-warnings diff --git a/package.json b/package.json index ae40c5192..b42afd368 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md,yml,yaml,css,scss}\" --ignore-path .gitignore --config ./.prettierrc", "gen-schema-doc": "node ./scripts/doc-schema.js", "cypress:run": "cypress run", + "cypress:run:docker": "cypress run --config specPattern='cypress/e2e/docker/**/*.cy.{js,ts}'", "cypress:open": "cypress open", "generate-config-types": "quicktype --src-lang schema --lang typescript --out src/config/generated/config.ts --top-level GitProxyConfig config.schema.json && ts-node scripts/add-banner.ts src/config/generated/config.ts && prettier --write src/config/generated/config.ts" }, diff --git a/src/ui/components/CustomButtons/Button.tsx b/src/ui/components/CustomButtons/Button.tsx index da1ae0220..a5f5e258b 100644 --- a/src/ui/components/CustomButtons/Button.tsx +++ b/src/ui/components/CustomButtons/Button.tsx @@ -78,7 +78,7 @@ export default function RegularButton(props: RegularButtonProps) { }); return ( - ); diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 6f1441687..7060c2b2e 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -181,11 +181,11 @@ const Dashboard: React.FC = () => { {generateIcon(headerData.title)} -

{headerData.title}

+

{headerData.title}

{!(push.canceled || push.rejected || push.authorised) && (
- diff --git a/src/ui/views/PushDetails/components/Attestation.tsx b/src/ui/views/PushDetails/components/Attestation.tsx index ff72b0201..f0b245da5 100644 --- a/src/ui/views/PushDetails/components/Attestation.tsx +++ b/src/ui/views/PushDetails/components/Attestation.tsx @@ -71,7 +71,7 @@ const Attestation: React.FC = ({ approveFn }) => { return (
- = ({ approveFn }) => { onClose={handleClose} aria-labelledby='alert-dialog-title' aria-describedby='alert-dialog-description' + data-testid='attestation-dialog' style={{ margin: '0px 15px 0px 15px', padding: '20px' }} > = ({ approveFn }) => { - diff --git a/src/ui/views/PushDetails/components/Reject.tsx b/src/ui/views/PushDetails/components/Reject.tsx index e58240593..371397d3c 100644 --- a/src/ui/views/PushDetails/components/Reject.tsx +++ b/src/ui/views/PushDetails/components/Reject.tsx @@ -49,7 +49,7 @@ const Reject: React.FC = ({ rejectFn }) => { return (
- = ({ rejectFn }) => { - diff --git a/test-e2e.proxy.config.json b/test-e2e.proxy.config.json index 8258f59b6..f8fb9a32b 100644 --- a/test-e2e.proxy.config.json +++ b/test-e2e.proxy.config.json @@ -1,9 +1,10 @@ { "cookieSecret": "integration-test-cookie-secret", + "csrfProtection": false, "sessionMaxAgeHours": 12, "rateLimit": { "windowMs": 60000, - "limit": 150 + "limit": 1000 }, "tempPassword": { "sendEmail": false,