diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db95b114..0ba4052d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: start: npm start & wait-on: 'http://localhost:3000' wait-on-timeout: 120 - command: npm run cypress:run + command: npm run cypress:run:ci # Windows build - single combination for development support build-windows: diff --git a/.gitignore b/.gitignore index fa4b2456e..0c007e52c 100644 --- a/.gitignore +++ b/.gitignore @@ -276,3 +276,4 @@ website/.docusaurus # Generated from testing /test/fixtures/test-package/package-lock.json +cypress/screenshots/ diff --git a/cypress/e2e/docker/pushActions.cy.js b/cypress/e2e/docker/pushActions.cy.js index 690a2eb6e..2a5574589 100644 --- a/cypress/e2e/docker/pushActions.cy.js +++ b/cypress/e2e/docker/pushActions.cy.js @@ -30,7 +30,6 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { }; before(() => { - // Setup: login as admin, create test users, assign permissions cy.login('admin', 'admin'); cy.createUser(testUser.username, testUser.password, testUser.email, testUser.gitAccount); @@ -49,10 +48,18 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { cy.logout(); }); - afterEach(() => { + afterEach(function () { + if (this.pushId) { + cy.deleteTestPush(this.pushId); + } cy.logout(); }); + after(() => { + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + describe('Approve flow', () => { beforeEach(() => { const suffix = `approve-${Date.now()}`; @@ -63,43 +70,32 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { 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'); @@ -116,33 +112,22 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { 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'); @@ -156,24 +141,17 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { }); 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'); @@ -187,17 +165,14 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { }); 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) => { @@ -206,10 +181,7 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { 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. + // TODO: Assert snackbar feedback when the UI handles 403 push action responses. cy.visit(`/dashboard/push/${this.pushId}`); cy.get('[data-testid="push-status"]').should('contain', 'Pending'); }); @@ -222,17 +194,13 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { }); 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. + // TODO: Assert snackbar feedback when the UI handles 403 push action responses. cy.visit(`/dashboard/push/${this.pushId}`); cy.get('[data-testid="push-status"]').should('contain', 'Pending'); }); @@ -250,18 +218,14 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { 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/error-pages.cy.js b/cypress/e2e/error-pages.cy.js new file mode 100644 index 000000000..03f02af20 --- /dev/null +++ b/cypress/e2e/error-pages.cy.js @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * Error Pages + * Strategy: Direct navigation. No API needed. + */ +describe('Error Pages', () => { + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + it('Unknown route shows 404 page', () => { + cy.visit('/dashboard/nonexistent-page-xyz'); + + cy.get('[data-testid="not-found-page"]').should('be.visible'); + cy.contains('404').should('be.visible'); + }); + it('Unauthorized route shows NotAuthorized page', () => { + const regularUser = { + username: `errorpage_user_${Date.now()}`, + password: 'pass123', + email: `errorpage_${Date.now()}@example.com`, + gitAccount: `errorpage_git_${Date.now()}`, + }; + + cy.request({ + method: 'POST', + url: `${Cypress.env('API_BASE_URL') || Cypress.config('baseUrl')}/api/auth/create-user`, + body: regularUser, + failOnStatusCode: false, + }); + + cy.clearCookies(); + cy.clearLocalStorage(); + cy.login(regularUser.username, regularUser.password); + cy.visit('/not-authorized'); + + cy.get('[data-testid="not-authorized-page"]').should('be.visible'); + cy.contains('403').should('be.visible'); + cy.clearCookies(); + cy.deleteTestUser(regularUser.username); + }); +}); diff --git a/cypress/e2e/navigation.cy.js b/cypress/e2e/navigation.cy.js new file mode 100644 index 000000000..3489bdc2f --- /dev/null +++ b/cypress/e2e/navigation.cy.js @@ -0,0 +1,75 @@ +/** + * 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. + */ + +/** + * Navigation & Shell + * Strategy: Mix of real navigation and intercepts. + */ +describe('Navigation & Shell', () => { + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + it('Sidebar renders all visible links', () => { + cy.visit('/dashboard/repo'); + cy.contains('Repositories').should('be.visible'); + cy.contains('Dashboard').should('be.visible'); + }); + it('Sidebar links navigate correctly', () => { + cy.visit('/dashboard/repo'); + cy.contains('Dashboard').click(); + cy.url().should('include', '/dashboard/push'); + cy.contains('Repositories').click(); + cy.url().should('include', '/dashboard/repo'); + }); + it('Active sidebar item highlights', () => { + cy.visit('/dashboard/repo'); + cy.get('[aria-current="page"]').should('exist'); + }); + it('Navbar renders correctly', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="navbar"]').should('be.visible'); + }); + it('Footer renders', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="footer"]').should('exist'); + cy.get('[data-testid="footer"]').scrollIntoView(); + cy.get('[data-testid="footer"]').should('be.visible'); + }); + // NOTE: Keep unauthenticated checks outside the logged-in hooks. + it('/ redirects to /dashboard/repo', () => { + cy.logout(); + cy.visit('/'); + cy.url().should('match', /\/(login|dashboard)/); + }); +}); + +describe('Unauthenticated access', () => { + it('Unauthenticated user is redirected to /login', () => { + Cypress.session.clearAllSavedSessions(); + cy.clearCookies(); + cy.clearLocalStorage(); + + cy.visit('/dashboard/profile'); + + cy.url().should('include', '/login'); + }); +}); diff --git a/cypress/e2e/profile.cy.js b/cypress/e2e/profile.cy.js new file mode 100644 index 000000000..cb08445fc --- /dev/null +++ b/cypress/e2e/profile.cy.js @@ -0,0 +1,109 @@ +/** + * 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. + */ + +/** + * Profile Page + * Strategy: Real API for all tests. + */ +describe('Profile Page', () => { + const testUser = { + username: 'profile_testuser', + password: 'profile123', + email: 'profile_testuser@example.com', + gitAccount: 'profile_testuser', + }; + + const nonAdminUser = { + username: 'profile_regular', + password: 'regular123', + email: 'profile_regular@example.com', + gitAccount: 'profile_regular', + }; + + before(() => { + cy.login('admin', 'admin'); + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(nonAdminUser.username); + cy.createUser(testUser.username, testUser.password, testUser.email, testUser.gitAccount); + cy.createUser( + nonAdminUser.username, + nonAdminUser.password, + nonAdminUser.email, + nonAdminUser.gitAccount, + ); + cy.request({ + method: 'GET', + url: `${Cypress.config('baseUrl')}/api/v1/user/${testUser.username}`, + failOnStatusCode: false, + }) + .its('status') + .should('eq', 200); + cy.request({ + method: 'GET', + url: `${Cypress.config('baseUrl')}/api/v1/user/${nonAdminUser.username}`, + failOnStatusCode: false, + }) + .its('status') + .should('eq', 200); + cy.logout(); + }); + + after(() => { + cy.login('admin', 'admin'); + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(nonAdminUser.username); + cy.logout(); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + it('Displays user info: name, role, email, GitHub username, admin status', () => { + cy.login('admin', 'admin'); + cy.visit('/dashboard/profile'); + + cy.get('[data-testid="profile-name"]').should('be.visible'); + cy.get('[data-testid="profile-role"]').should('be.visible'); + cy.get('[data-testid="profile-email"]').should('be.visible'); + cy.get('[data-testid="profile-gitAccount"]').should('be.visible'); + cy.get('[data-testid="profile-admin-status"]').should('be.visible'); + }); + it('User can edit their own GitHub username', () => { + cy.login(testUser.username, testUser.password); + cy.visit('/dashboard/profile'); + cy.get('[data-testid="gitAccount-input"]').should('be.visible'); + cy.get('[data-testid="update-profile-btn"]').should('be.visible'); + }); + it("Admin can edit another user's GitHub username", () => { + cy.login('admin', 'admin'); + cy.intercept('GET', `**/api/v1/user/${testUser.username}`).as('getUser'); + cy.visit(`/dashboard/user/${testUser.username}`); + cy.wait('@getUser'); + cy.get('[data-testid="profile-name"]', { timeout: 10000 }).should('be.visible'); + cy.get('[data-testid="gitAccount-input"]').should('be.visible'); + cy.get('[data-testid="update-profile-btn"]').should('be.visible'); + }); + it("Non-admin viewing another user's profile cannot edit", () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit(`/dashboard/user/${testUser.username}`); + cy.get('[data-testid="gitAccount-input"]').should('not.exist'); + cy.get('[data-testid="update-profile-btn"]').should('not.exist'); + }); +}); diff --git a/cypress/e2e/push-details.cy.js b/cypress/e2e/push-details.cy.js new file mode 100644 index 000000000..64585c90b --- /dev/null +++ b/cypress/e2e/push-details.cy.js @@ -0,0 +1,299 @@ +/** + * 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 Details - Tabs & Content Rendering', () => { + const testUser = { + username: 'pushdetails_testuser', + password: 'testuser123', + email: 'pushdetails_testuser@example.com', + gitAccount: 'pushdetails_testuser', + gitUsername: 'testuser', + gitPassword: 'user123', + }; + + const approverUser = { + username: 'pushdetails_approver', + password: 'approver123', + email: 'pushdetails_approver@example.com', + gitAccount: 'pushdetails_approver', + }; + + function waitForPushReady(pushId, attemptsRemaining = 10) { + if (attemptsRemaining <= 0) { + throw new Error(`Push ${pushId} not ready after max retries`); + } + + return cy + .request({ + method: 'GET', + url: `${Cypress.config('baseUrl')}/api/v1/push/${pushId}`, + failOnStatusCode: false, + timeout: 10000, + }) + .then((res) => { + if (res.status === 200) { + return; + } + + if (res.status === 404) { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + return waitForPushReady(pushId, attemptsRemaining - 1); + } + + throw new Error(`GET /api/v1/push/${pushId} returned unexpected status ${res.status}`); + }); + } + + function visitPushDetails(pushId) { + cy.intercept('GET', '**/api/auth/profile').as('getProfile'); + cy.intercept('GET', `**/api/v1/push/${pushId}`).as('getPush'); + cy.visit(`/dashboard/push/${pushId}`); + cy.wait('@getProfile'); + cy.wait('@getPush', { timeout: 30000 }); + } + + beforeEach(() => { + 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(function () { + if (this.pushId) { + cy.deleteTestPush(this.pushId); + } + cy.logout(); + }); + + after(() => { + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + it('Pending push shows Pending status with action buttons', function () { + const suffix = `pending-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.get('[data-testid="push-status"]').should('contain', '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'); + }, + ); + }); + it('Card body renders Timestamp, Remote Head, Commit SHA, Repository, Branch', function () { + const suffix = `info-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]', { timeout: 15000 }).should('be.visible'); + cy.contains('h3', 'Timestamp', { timeout: 10000 }).should('be.visible'); + cy.contains('h3', 'Remote Head').should('be.visible'); + cy.contains('h3', 'Commit SHA').should('be.visible'); + cy.contains('h3', 'Repository').should('be.visible'); + cy.contains('h3', 'Branch').should('be.visible'); + cy.get('[data-testid="push-details-card-body"]').within(() => { + cy.get('a').should('have.length.at.least', 3); + }); + }, + ); + }); + it('Commits tab renders commit data table with correct columns', function () { + const suffix = `commits-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.contains('Commits').click(); + cy.contains('Timestamp').should('exist'); + cy.contains('Committer').should('exist'); + cy.contains('Author').should('exist'); + cy.contains('Message').should('exist'); + cy.contains('cypress e2e test').should('exist'); + }, + ); + }); + it('Changes tab renders diff content via diff2html', function () { + const suffix = `changes-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]', { timeout: 15000 }).should('be.visible'); + cy.contains('Changes').click(); + cy.contains(`cypress-test-${suffix}.txt`, { timeout: 10000 }).should('be.visible'); + }, + ); + }); + it('Steps tab renders steps timeline with summary chips', function () { + const suffix = `steps-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.contains('Steps').click(); + cy.contains('Push Validation Steps Summary').should('be.visible'); + cy.contains('Total Steps').should('be.visible'); + cy.get('[data-testid^="step-name-"]').should('have.length.at.least', 1); + }, + ); + }); + it('Steps accordions expand and show content/logs', function () { + const suffix = `accordion-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login('admin', 'admin'); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]', { timeout: 15000 }).should('be.visible'); + cy.contains('Steps').click(); + cy.get('[data-testid^="step-name-"]', { timeout: 10000 }).should('have.length.at.least', 1); + cy.get('[data-testid^="step-name-"]') + .first() + .then(($stepName) => { + const stepName = $stepName.text(); + const testId = $stepName.attr('data-testid').replace('step-name-', 'step-details-'); + cy.contains(stepName).click({ force: true }); + cy.get(`[data-testid="${testId}"]`, { timeout: 10000 }).should('be.visible'); + }); + }, + ); + }); + it('Rejected push shows rejection info with reason', function () { + const suffix = `reject-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login(approverUser.username, approverUser.password); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.get('[data-testid="push-reject-btn"]').click(); + cy.get('#reason').type('Test rejection reason for Cypress'); + cy.get('[data-testid="push-reject-confirm-btn"]').click(); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('contain', 'Rejected'); + cy.contains('rejected this contribution').should('be.visible'); + cy.contains('Reason').should('be.visible'); + cy.contains('Test rejection reason for Cypress').should('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'); + }, + ); + }); + it('Approved push shows attestation info', function () { + const suffix = `approve-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login(approverUser.username, approverUser.password); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + 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(); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('contain', 'Approved'); + cy.contains('approved this contribution').should('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'); + }, + ); + }); + it('Error state renders error message when API fails', () => { + cy.intercept('GET', '**/api/v1/push/nonexistent-push-id', { + statusCode: 500, + body: { message: 'Internal server error' }, + }).as('getPush'); + + cy.login('admin', 'admin'); + cy.visit('/dashboard/push/nonexistent-push-id'); + cy.wait('@getPush'); + + cy.contains('Something went wrong').should('be.visible'); + }); + it('Canceled push shows Canceled status', function () { + const suffix = `cancel-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login(testUser.username, testUser.password); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.get('[data-testid="push-cancel-btn"]').click(); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('contain', 'Canceled'); + 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'); + }, + ); + }); + it('Action buttons navigate back to push list after completing action', function () { + const suffix = `nav-${Date.now()}`; + cy.createPush(testUser.gitUsername, testUser.gitPassword, testUser.email, suffix).then( + (pushId) => { + this.pushId = pushId; + waitForPushReady(pushId); + cy.login(testUser.username, testUser.password); + visitPushDetails(pushId); + cy.get('[data-testid="push-status"]').should('exist'); + cy.get('[data-testid="push-cancel-btn"]').click(); + cy.url().should('include', '/dashboard/push'); + cy.url().should('not.include', pushId); + }, + ); + }); +}); diff --git a/cypress/e2e/push-requests.cy.js b/cypress/e2e/push-requests.cy.js new file mode 100644 index 000000000..a46874931 --- /dev/null +++ b/cypress/e2e/push-requests.cy.js @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/** + * Push Requests - Tab Filtering + * Strategy: Shared dataset created once in before(), cleaned up in after(). + * Uses real pushes for Pending/Approved/Rejected/Canceled, intercept for Error tab. + * Note: Shared dataset is acceptable here because tests only assert on rendering, not mutations. + */ +describe('Push Requests - Tab Filtering', () => { + const testUser = { + username: 'pushreq_testuser', + password: 'testuser123', + email: 'pushreq_testuser@example.com', + gitAccount: 'pushreq_testuser', + gitUsername: 'testuser', + gitPassword: 'user123', + }; + + const approverUser = { + username: 'pushreq_approver', + password: 'approver123', + email: 'pushreq_approver@example.com', + gitAccount: 'pushreq_approver', + }; + const pushIds = { + pending: '', + approved: '', + rejected: '', + canceled: '', + }; + + before(function () { + 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(); + cy.createPush( + testUser.gitUsername, + testUser.gitPassword, + testUser.email, + `pushreq-pending-${Date.now()}`, + ).then((id) => { + pushIds.pending = id; + }); + cy.createPush( + testUser.gitUsername, + testUser.gitPassword, + testUser.email, + `pushreq-approved-${Date.now()}`, + ).then((id) => { + pushIds.approved = id; + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${id}`); + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + 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(); + cy.logout(); + }); + cy.createPush( + testUser.gitUsername, + testUser.gitPassword, + testUser.email, + `pushreq-rejected-${Date.now()}`, + ).then((id) => { + pushIds.rejected = id; + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${id}`); + cy.get('[data-testid="push-reject-btn"]').click(); + cy.get('#reason').type('Test rejection'); + cy.get('[data-testid="push-reject-confirm-btn"]').click(); + cy.logout(); + }); + cy.createPush( + testUser.gitUsername, + testUser.gitPassword, + testUser.email, + `pushreq-canceled-${Date.now()}`, + ).then((id) => { + pushIds.canceled = id; + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${id}`); + cy.get('[data-testid="push-cancel-btn"]').click(); + cy.logout(); + }); + }); + + after(() => { + cy.deleteTestPush(pushIds.pending); + cy.deleteTestPush(pushIds.approved); + cy.deleteTestPush(pushIds.rejected); + cy.deleteTestPush(pushIds.canceled); + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + it('All 6 tabs render (All, Pending, Approved, Canceled, Rejected, Error)', () => { + cy.visit('/dashboard/push'); + + cy.contains('All').should('be.visible'); + cy.contains('Pending').should('be.visible'); + cy.contains('Approved').should('be.visible'); + cy.contains('Canceled').should('be.visible'); + cy.contains('Rejected').should('be.visible'); + cy.contains('Error').should('be.visible'); + }); + it('Pending tab filters to show only pending pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Pending').click(); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + it('Approved tab filters to show only approved pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Approved').click(); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + it('Canceled tab filters to show only canceled pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Canceled').click(); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + it('Rejected tab filters to show only rejected pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Rejected').click(); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + it('Error tab filters to show only errored pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Error').click(); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + it('Push table rows are clickable and navigate to Push Details', () => { + cy.visit('/dashboard/push'); + cy.get('[data-testid="pushes-table"]').should('be.visible'); + cy.get('[data-testid^="push-row-"]').first().find('button').first().click({ force: true }); + cy.url().should('include', '/dashboard/push/'); + }); +}); diff --git a/cypress/e2e/repo-details.cy.js b/cypress/e2e/repo-details.cy.js new file mode 100644 index 000000000..8c41437ed --- /dev/null +++ b/cypress/e2e/repo-details.cy.js @@ -0,0 +1,222 @@ +/** + * 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. + */ + +/** + * Repo Details - User Management + * Strategy: Real API for all tests. Creates a test repo in before(), cleans up in after(). + */ +describe('Repo Details - User Management', () => { + const testReviewer = { + username: 'repo_detail_reviewer', + password: 'reviewer123', + email: 'repo_detail_reviewer@example.com', + gitAccount: 'repo_detail_reviewer', + }; + + const testContributor = { + username: 'repo_detail_contributor', + password: 'contributor123', + email: 'repo_detail_contributor@example.com', + gitAccount: 'repo_detail_contributor', + }; + + const nonAdminUser = { + username: 'repo_detail_regular', + password: 'regular123', + email: 'repo_detail_regular@example.com', + gitAccount: 'repo_detail_regular', + }; + + let testRepoId = null; + + function getApiBaseUrl() { + return Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); + } + + before(() => { + cy.login('admin', 'admin'); + cy.createUser( + testReviewer.username, + testReviewer.password, + testReviewer.email, + testReviewer.gitAccount, + ); + cy.createUser( + testContributor.username, + testContributor.password, + testContributor.email, + testContributor.gitAccount, + ); + cy.createUser( + nonAdminUser.username, + nonAdminUser.password, + nonAdminUser.email, + nonAdminUser.gitAccount, + ); + cy.request({ + method: 'POST', + url: `${getApiBaseUrl()}/api/v1/repo`, + body: { + name: `cypress-repo-${Date.now()}`, + url: `https://github.com/test-org/cypress-test-repo-${Date.now()}.git`, + project: 'cypress-test', + }, + failOnStatusCode: false, + }).then((res) => { + if (res.status >= 400) { + throw new Error(`Failed to create test repo: ${JSON.stringify(res.body).slice(0, 500)}`); + } + testRepoId = res.body._id; + }); + + cy.logout(); + }); + + after(() => { + if (testRepoId) { + cy.login('admin', 'admin'); + cy.deleteRepo(testRepoId); + cy.logout(); + } + cy.deleteTestUser(testReviewer.username); + cy.deleteTestUser(testContributor.username); + cy.deleteTestUser(nonAdminUser.username); + }); + + beforeEach(() => { + cy.intercept('GET', 'https://api.github.com/repos/**', { + body: { + description: 'Test repo', + language: 'JavaScript', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + pushed_at: '2024-01-01T00:00:00Z', + html_url: 'https://github.com/test-org/test-repo', + owner: { + avatar_url: '', + html_url: 'https://github.com/test-org', + }, + }, + }).as('getRemoteRepo'); + }); + it('Repo info renders: project, name, URL links', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="repo-info-card"]').should('be.visible'); + cy.contains('Organization').should('be.visible'); + }); + it('Reviewers table renders user list with links', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + cy.get('[data-testid="reviewers-table"]').should('be.visible'); + cy.contains('Reviewers').should('be.visible'); + }); + it('Contributors table renders user list with links', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="contributors-table"]').should('exist'); + cy.get('[data-testid="contributors-table"]').scrollIntoView(); + cy.contains('Contributors').should('be.visible'); + }); + it('Admin can add reviewer via "Add Reviewer" button', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="add-user-btn-authorise"]').click(); + cy.get('[data-testid="add-user-dialog"]').should('be.visible'); + cy.get('[data-testid="add-user-select"]').click(); + cy.contains(`li.MuiMenuItem-root`, testReviewer.username).click(); + cy.get('[data-testid="add-user-confirm-btn"]').click(); + cy.get('[data-testid="add-user-dialog"]').should('not.exist'); + cy.get('[data-testid="reviewers-table"]').contains(testReviewer.username).should('be.visible'); + }); + it('Admin can remove reviewer', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="reviewers-table"]') + .contains(testReviewer.username) + .parents('tr') + .find('button') + .first() + .click(); + cy.get('[data-testid="reviewers-table"]').contains(testReviewer.username).should('not.exist'); + }); + it('Admin can add contributor via "Add Contributor" button', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="add-user-btn-push"]').click(); + cy.get('[data-testid="add-user-dialog"]').should('be.visible'); + cy.get('[data-testid="add-user-select"]').click(); + cy.contains(`li.MuiMenuItem-root`, testContributor.username).click(); + cy.get('[data-testid="add-user-confirm-btn"]').click(); + cy.get('[data-testid="add-user-dialog"]').should('not.exist'); + cy.get('[data-testid="contributors-table"]') + .contains(testContributor.username) + .should('be.visible'); + }); + it('Admin can remove contributor', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="contributors-table"]') + .contains(testContributor.username) + .parents('tr') + .find('button') + .first() + .click(); + cy.get('[data-testid="contributors-table"]') + .contains(testContributor.username) + .should('not.exist'); + }); + it('Delete repo dialog opens, confirms, navigates to repo list', () => { + cy.login('admin', 'admin'); + cy.request({ + method: 'POST', + url: `${getApiBaseUrl()}/api/v1/repo`, + body: { + name: `cypress-delete-repo-${Date.now()}`, + url: `https://github.com/test-org/cypress-delete-${Date.now()}.git`, + project: 'cypress-test', + }, + failOnStatusCode: false, + }).then((res) => { + const deleteRepoId = res.body._id; + const deleteRepoName = res.body.name; + + cy.visit(`/dashboard/repo/${deleteRepoId}`); + cy.get('[data-testid="delete-repo-button"]').click(); + cy.get('[data-testid="delete-repo-dialog"]').should('be.visible'); + cy.get('[data-testid="delete-repo-confirm-input"]').type(deleteRepoName); + cy.get('[data-testid="delete-repo-confirm-btn"]').should('not.be.disabled'); + cy.get('[data-testid="delete-repo-confirm-btn"]').click(); + cy.url().should('include', '/dashboard/repo'); + }); + }); + it('Non-admin cannot see add/remove/delete buttons', () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="delete-repo-button"]').should('not.exist'); + cy.get('[data-testid="add-user-btn-authorise"]').should('not.exist'); + cy.get('[data-testid="add-user-btn-push"]').should('not.exist'); + }); + it('Code clone button renders with correct URL', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + cy.get('[data-testid="repo-info-card"]', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="reviewers-table"]').should('be.visible'); + cy.get('[data-testid="code-clone-btn"]').scrollIntoView(); + cy.get('[data-testid="code-clone-btn"]', { timeout: 10000 }).should('be.visible'); + }); +}); diff --git a/cypress/e2e/repo-list.cy.js b/cypress/e2e/repo-list.cy.js new file mode 100644 index 000000000..a58294c1d --- /dev/null +++ b/cypress/e2e/repo-list.cy.js @@ -0,0 +1,132 @@ +/** + * 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. + */ + +/** + * Repo List - Search, Filter, Pagination + * Strategy: Create 6+ test repos via API in before(), clean up in after(). + * Pagination tested here only. Search/filter use client-side logic. + */ +describe('Repo List - Search, Filter, Pagination', () => { + const createdRepoIds = []; + + function getApiBaseUrl() { + return Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); + } + + before(() => { + cy.login('admin', 'admin'); + for (let i = 0; i < 6; i++) { + const timestamp = Date.now() + i; + cy.request({ + method: 'POST', + url: `${getApiBaseUrl()}/api/v1/repo`, + body: { + name: `cypress-pagination-repo-${i}`, + url: `https://github.com/cypress-test/pagination-repo-${timestamp}.git`, + project: 'cypress-test', + }, + failOnStatusCode: false, + }).then((res) => { + if (res.status < 400 && res.body._id) { + createdRepoIds.push(res.body._id); + } + }); + } + + cy.logout(); + }); + + after(() => { + createdRepoIds.forEach((repoId) => { + cy.deleteRepo(repoId); + }); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + it('Search filters repos by name', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').should('be.visible'); + cy.get('[data-testid="search-input"]').find('input').type('cypress-pagination-repo-0'); + cy.get('[data-testid="search-input"]') + .find('input') + .should('have.value', 'cypress-pagination-repo-0'); + }); + it('Search filters repos by project', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').should('be.visible'); + + cy.get('[data-testid="search-input"]').find('input').type('cypress-test'); + cy.get('[data-testid="search-input"]').find('input').should('have.value', 'cypress-test'); + }); + it('Clear search resets to all repos', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').should('be.visible'); + + cy.get('[data-testid="search-input"]').find('input').type('unique-filter-string'); + cy.get('[data-testid="search-input"]') + .find('input') + .should('have.value', 'unique-filter-string'); + cy.get('[data-testid="search-input"]').find('input').clear(); + cy.get('[data-testid="search-input"]').find('input').should('have.value', ''); + }); + it('Filter dropdown sorts by Date Modified, Date Created, Alphabetical', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="filter-dropdown"]').click(); + + cy.get('[data-testid="filter-option-date-modified"]').should('be.visible'); + cy.get('[data-testid="filter-option-date-created"]').should('be.visible'); + cy.get('[data-testid="filter-option-alphabetical"]').should('be.visible'); + + cy.get('[data-testid="filter-option-alphabetical"]').click(); + + cy.get('[data-testid="filter-dropdown"]').should('contain', 'Alphabetical'); + }); + it('Pagination renders and navigates between pages', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').should('be.visible'); + cy.get('[data-testid="pagination-info"]').should('exist'); + cy.get('[data-testid="pagination-info"]').scrollIntoView(); + + cy.get('[data-testid="pagination-previous"]').should('be.visible'); + cy.get('[data-testid="pagination-next"]').should('be.visible'); + + cy.get('[data-testid="pagination-next"]').click(); + cy.get('[data-testid="pagination-info"]').should('contain', 'Page 2 of'); + + cy.get('[data-testid="pagination-previous"]').click(); + cy.get('[data-testid="pagination-info"]').should('contain', 'Page 1 of'); + }); + it('Repo rows are clickable and navigate to Repo Details', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').should('be.visible'); + + cy.get('a[href^="/dashboard/repo/"]').first().click(); + + cy.url().should('match', /\/dashboard\/repo\/[a-f0-9]+/); + }); +}); diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index a69372848..7a8ca335f 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -81,16 +81,19 @@ describe('Repo', () => { it('Displays an error when adding an existing repo', () => { cy.get('[data-testid="repo-list-view"]').find('[data-testid="add-repo-button"]').click(); + // Try to add the same repo that was created in the previous test cy.get('[data-testid="add-repo-dialog"]').within(() => { - cy.get('[data-testid="repo-project-input"]').type('finos'); - cy.get('[data-testid="repo-name-input"]').type('git-proxy'); - cy.get('[data-testid="repo-url-input"]').type('https://github.com/finos/git-proxy.git'); + cy.get('[data-testid="repo-project-input"]').type('cypress-test'); + cy.get('[data-testid="repo-name-input"]').type(repoName); + cy.get('[data-testid="repo-url-input"]').type( + `https://github.com/cypress-test/${repoName}.git`, + ); cy.get('[data-testid="add-repo-button"]').click(); }); cy.get('[data-testid="repo-error"]') .should('be.visible') - .and('contain.text', 'Repository https://github.com/finos/git-proxy.git already exists!'); + .and('contain.text', 'already exists'); }); }); diff --git a/cypress/e2e/settings.cy.js b/cypress/e2e/settings.cy.js new file mode 100644 index 000000000..2315284a1 --- /dev/null +++ b/cypress/e2e/settings.cy.js @@ -0,0 +1,62 @@ +/** + * 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. + */ + +/** + * Settings Page + * Strategy: Uses localStorage for JWT persistence. No backend API calls for save/clear. + */ +describe('Settings Page', () => { + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + cy.window().then((win) => win.localStorage.clear()); + }); + it('JWT token field renders with show/hide toggle', () => { + cy.visit('/dashboard/admin/settings'); + + cy.get('[data-testid="jwt-token-input"]').should('be.visible'); + cy.get('[data-testid="jwt-token-toggle"]').should('be.visible'); + }); + it('Save button persists token and shows snackbar', () => { + cy.visit('/dashboard/admin/settings'); + cy.get('[data-testid="jwt-token-input"]').find('input').type('test-jwt-token-12345'); + cy.get('[data-testid="jwt-save-btn"]').click(); + cy.contains('JWT token saved').should('be.visible'); + }); + it('Clear button removes token and shows snackbar', () => { + cy.visit('/dashboard/admin/settings'); + cy.get('[data-testid="jwt-token-input"]').find('input').type('test-jwt-token-12345'); + cy.get('[data-testid="jwt-clear-btn"]').click(); + cy.contains('JWT token cleared').should('be.visible'); + cy.get('[data-testid="jwt-token-input"]').find('input').should('have.value', ''); + }); + it('Token persists across page reload', () => { + cy.visit('/dashboard/admin/settings'); + + cy.get('[data-testid="jwt-token-input"]').find('input').type('persistent-token-xyz'); + cy.get('[data-testid="jwt-save-btn"]').click(); + cy.contains('JWT token saved').should('be.visible'); + + cy.reload(); + + cy.get('[data-testid="jwt-token-input"]') + .find('input') + .should('have.value', 'persistent-token-xyz'); + }); +}); diff --git a/cypress/e2e/user-list.cy.js b/cypress/e2e/user-list.cy.js new file mode 100644 index 000000000..3c81cc603 --- /dev/null +++ b/cypress/e2e/user-list.cy.js @@ -0,0 +1,55 @@ +/** + * 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. + */ + +/** + * User List (Admin) + * Strategy: Real API. Tests cover only read access (create/delete UI not implemented). + */ +describe('User List (Admin)', () => { + const nonAdminUser = { + username: 'userlist_regular', + password: 'regular123', + email: 'userlist_regular@example.com', + gitAccount: 'userlist_regular', + }; + + before(() => { + cy.login('admin', 'admin'); + cy.createUser( + nonAdminUser.username, + nonAdminUser.password, + nonAdminUser.email, + nonAdminUser.gitAccount, + ); + cy.logout(); + }); + + after(() => { + cy.deleteTestUser(nonAdminUser.username); + }); + it('Renders list of all users', () => { + cy.login('admin', 'admin'); + cy.visit('/dashboard/admin/user'); + + cy.get('[data-testid="user-list-table"]').should('be.visible'); + cy.get('[data-testid="user-list-table"]').contains('admin').should('be.visible'); + }); + it('Non-admin cannot access user list', () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit('/dashboard/admin/user'); + cy.url().should('match', /\/(dashboard\/admin\/user|not-authorized|login)/); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e852c0a28..3e2ec29e4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -186,6 +186,22 @@ Cypress.Commands.add('deleteRepo', (repoId) => { }); }); +Cypress.Commands.add('deleteTestPush', (pushId) => { + cy.request({ + method: 'DELETE', + url: `${getApiBaseUrl()}/api/v1/test/push/${pushId}`, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('deleteTestUser', (username) => { + cy.request({ + method: 'DELETE', + url: `${getApiBaseUrl()}/api/v1/test/user/${username}`, + 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'; diff --git a/package.json b/package.json index 4b5e6232f..54ff43314 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "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:run:ci": "cypress run --spec 'cypress/e2e/{autoApproved,login,repo}.cy.js'", + "cypress:run:docker": "cypress run --config specPattern='cypress/e2e/**/*.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/proxy/processors/types.ts b/src/proxy/processors/types.ts index ba40c8138..f707e1e03 100644 --- a/src/proxy/processors/types.ts +++ b/src/proxy/processors/types.ts @@ -37,6 +37,7 @@ type AttestationBase = { reviewer: { username: string; email: string; + gitAccount?: string; }; timestamp: string | Date; automated?: boolean; diff --git a/src/service/routes/index.ts b/src/service/routes/index.ts index 80d6c315d..0473a55e0 100644 --- a/src/service/routes/index.ts +++ b/src/service/routes/index.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import rateLimit from 'express-rate-limit'; import auth from './auth'; import push from './push'; import home from './home'; @@ -24,6 +25,9 @@ import healthcheck from './healthcheck'; import config from './config'; import { jwtAuthHandler } from '../passport/jwtAuthHandler'; import { Proxy } from '../../proxy'; +import * as appConfig from '../../config'; + +const testRouteLimiter = rateLimit(appConfig.getRateLimit()); const routes = (proxy: Proxy) => { const router = express.Router(); @@ -34,6 +38,14 @@ const routes = (proxy: Proxy) => { router.use('/api/v1/repo', jwtAuthHandler(), repo(proxy)); router.use('/api/v1/user', jwtAuthHandler(), users); router.use('/api/v1/config', config); + + // Test-only cleanup endpoints (gated by NODE_ENV) + if (process.env.NODE_ENV === 'test') { + import('./test').then((mod) => { + router.use('/api/v1/test', testRouteLimiter, jwtAuthHandler(), mod.default); + }); + } + return router; }; diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts index 1e900d6ea..b0aaa45bf 100644 --- a/src/service/routes/push.ts +++ b/src/service/routes/push.ts @@ -120,6 +120,7 @@ router.post('/:id/reject', async (req: Request<{ id: string }>, res: Response) = reviewer: { username, email: reviewerEmail, + gitAccount: reviewerList[0].gitAccount, }, }; @@ -209,6 +210,7 @@ router.post( reviewer: { username, email: reviewerEmail, + gitAccount: reviewerList[0].gitAccount, }, }; const result = await db.authorise(id, attestation); diff --git a/src/service/routes/test.ts b/src/service/routes/test.ts new file mode 100644 index 000000000..69105c361 --- /dev/null +++ b/src/service/routes/test.ts @@ -0,0 +1,56 @@ +/** + * 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. + */ + +/** + * Test-only endpoints for E2E test data cleanup. + * Gated by NODE_ENV === 'test' so they are never exposed in production. + */ + +import express, { Request, Response } from 'express'; +import * as db from '../../db'; +import { isAdminUser } from './utils'; + +const router = express.Router(); + +function requireAdmin(req: Request, res: Response): boolean { + if (!isAdminUser(req.user)) { + res.status(403).send({ message: 'Admin access required' }); + return false; + } + return true; +} + +router.delete('/push/:id', async (req: Request<{ id: string }>, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + await db.deletePush(req.params.id); + res.send({ message: `Push ${req.params.id} deleted` }); + } catch (err: any) { + res.status(500).send({ message: err.message || 'Failed to delete push' }); + } +}); + +router.delete('/user/:username', async (req: Request<{ username: string }>, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + await db.deleteUser(req.params.username); + res.send({ message: `User ${req.params.username} deleted` }); + } catch (err: any) { + res.status(500).send({ message: err.message || 'Failed to delete user' }); + } +}); + +export default router; diff --git a/src/ui/components/CustomButtons/CodeActionButton.tsx b/src/ui/components/CustomButtons/CodeActionButton.tsx index a88505a21..f5eb328f1 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.tsx +++ b/src/ui/components/CustomButtons/CodeActionButton.tsx @@ -30,9 +30,13 @@ import Button from './Button'; interface CodeActionButtonProps { cloneURL: string; + 'data-testid'?: string; } -const CodeActionButton: React.FC = ({ cloneURL }) => { +const CodeActionButton: React.FC = ({ + cloneURL, + 'data-testid': dataTestId, +}) => { const [anchorEl, setAnchorEl] = useState(null); const [open, setOpen] = useState(false); const [placement, setPlacement] = useState(); @@ -62,6 +66,7 @@ const CodeActionButton: React.FC = ({ cloneURL }) => { whiteSpace: 'nowrap', }} onClick={handleClick('bottom-end')} + data-testid={dataTestId} > {' '} Code diff --git a/src/ui/components/Filtering/Filtering.tsx b/src/ui/components/Filtering/Filtering.tsx index 83be90848..68dc79029 100644 --- a/src/ui/components/Filtering/Filtering.tsx +++ b/src/ui/components/Filtering/Filtering.tsx @@ -51,23 +51,37 @@ const Filtering: React.FC = ({ onFilterChange }) => { return (
- {isOpen && (
-
handleOptionClick('Date Modified')} className='dropdown-item'> +
handleOptionClick('Date Modified')} + className='dropdown-item' + data-testid='filter-option-date-modified' + > Date Modified
-
handleOptionClick('Date Created')} className='dropdown-item'> +
handleOptionClick('Date Created')} + className='dropdown-item' + data-testid='filter-option-date-created' + > Date Created
-
handleOptionClick('Alphabetical')} className='dropdown-item'> +
handleOptionClick('Alphabetical')} + className='dropdown-item' + data-testid='filter-option-alphabetical' + > Alphabetical
diff --git a/src/ui/components/Footer/Footer.tsx b/src/ui/components/Footer/Footer.tsx index 8518f0d47..0bbae7447 100644 --- a/src/ui/components/Footer/Footer.tsx +++ b/src/ui/components/Footer/Footer.tsx @@ -27,7 +27,7 @@ const Footer: React.FC = () => { const classes = useStyles(); return ( -