From 486e23b93978fce8f21fcda80c3dce31a7ef69b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Thu, 23 Apr 2026 16:17:52 +0200 Subject: [PATCH 01/10] feat: implement Cypress E2E test infrastructure and Phase 1 test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug fixes: fix error tab query param (errored → error), add CardBody testid - Test cleanup: add DELETE /api/v1/test/push/:id and /user/:username routes (NODE_ENV=test only) - Custom commands: cy.deleteTestPush(), cy.deleteTestUser() for test data cleanup - UI instrumentation: add data-testid attributes to 16+ components for robust selectors - New test files: repo-details, push-requests, repo-list, profile, user-list, settings, navigation, error-pages (42 tests total) - Backfill cleanup: add afterEach/after cleanup to push-details and pushActions tests --- CYPRESS_PLAN.md | 197 ++++++++++++ cypress/e2e/docker/pushActions.cy.js | 12 +- cypress/e2e/error-pages.cy.js | 67 ++++ cypress/e2e/navigation.cy.js | 90 ++++++ cypress/e2e/profile.cy.js | 97 ++++++ cypress/e2e/push-details.cy.js | 301 ++++++++++++++++++ cypress/e2e/push-requests.cy.js | 206 ++++++++++++ cypress/e2e/repo-details.cy.js | 258 +++++++++++++++ cypress/e2e/repo-list.cy.js | 152 +++++++++ cypress/e2e/settings.cy.js | 86 +++++ cypress/e2e/user-list.cy.js | 58 ++++ cypress/support/commands.js | 16 + src/service/routes/index.ts | 8 + src/service/routes/test.ts | 56 ++++ src/ui/components/Filtering/Filtering.tsx | 10 +- src/ui/components/Footer/Footer.tsx | 2 +- src/ui/components/Navbars/Navbar.tsx | 1 + src/ui/components/Pagination/Pagination.tsx | 4 +- src/ui/components/Search/Search.tsx | 1 + src/ui/components/Sidebar/Sidebar.tsx | 1 + src/ui/views/Extras/NotAuthorized.tsx | 2 +- src/ui/views/Extras/NotFound.tsx | 2 +- src/ui/views/PushDetails/PushDetails.tsx | 2 +- src/ui/views/PushRequests/PushRequests.tsx | 2 +- .../PushRequests/components/PushesTable.tsx | 6 +- .../views/RepoDetails/Components/AddUser.tsx | 6 +- .../Components/DeleteRepoDialog.tsx | 4 +- src/ui/views/RepoDetails/RepoDetails.tsx | 8 +- src/ui/views/Settings/Settings.tsx | 8 +- src/ui/views/User/UserProfile.tsx | 13 +- src/ui/views/UserList/Components/UserList.tsx | 4 +- 31 files changed, 1647 insertions(+), 33 deletions(-) create mode 100644 CYPRESS_PLAN.md create mode 100644 cypress/e2e/error-pages.cy.js create mode 100644 cypress/e2e/navigation.cy.js create mode 100644 cypress/e2e/profile.cy.js create mode 100644 cypress/e2e/push-details.cy.js create mode 100644 cypress/e2e/push-requests.cy.js create mode 100644 cypress/e2e/repo-details.cy.js create mode 100644 cypress/e2e/repo-list.cy.js create mode 100644 cypress/e2e/settings.cy.js create mode 100644 cypress/e2e/user-list.cy.js create mode 100644 src/service/routes/test.ts diff --git a/CYPRESS_PLAN.md b/CYPRESS_PLAN.md new file mode 100644 index 000000000..5454c3936 --- /dev/null +++ b/CYPRESS_PLAN.md @@ -0,0 +1,197 @@ +# Cypress E2E Test Plan + +## Goal +Cover all functionality and UI elements on each page to minimize UI-related bugs and regression. + +## Existing Coverage + +| Page | Test File | Coverage | +|------|-----------|----------| +| Login | `login.cy.js` | Logo, inputs, valid/invalid login, redirect | +| Repo List | `repo.cy.js` | Add repo, duplicate error, anonymous/regular/admin permissions, clone tooltip | +| Push Actions | `docker/pushActions.cy.js` | Approve, Reject, Cancel, unauthorized attempts, dialog cancel | +| Auto-Approved Push | `autoApproved.cy.js` | Auto-approved message, tooltip timestamp | +| Push Details | `push-details.cy.js` | Pending/Approved/Rejected/Canceled states, tabs, card body, steps, error state, navigation | + +--- + +## Prerequisites: Bug Fixes & Infrastructure + +### Bug Fixes Required + +| Bug | Location | Fix | +|-----|----------|-----| +| Error tab sends wrong query param | `src/ui/views/PushRequests/components/PushesTable.tsx:64` | Change `errored` → `error` to match DB field | +| Broken CardBody selector in test | `cypress/e2e/push-details.cy.js:84` | Add `data-testid="push-details-card-body"` to `PushDetails.tsx` `` | + +### Test Data Cleanup Infrastructure + +**Problem:** `cy.createPush()` creates permanent push records via real git operations, but there is no API to delete a push. Tests accumulate data forever. + +**Solution:** +1. Add `src/service/routes/test.ts` with test-only endpoints (gated by `NODE_ENV === 'test'`): + - `DELETE /api/v1/test/push/:id` — calls `db.deletePush(id)` (admin auth) + - `DELETE /api/v1/test/user/:username` — calls `db.deleteUser(username)` (admin auth) +2. Conditionally mount in `src/service/routes/index.ts` when `NODE_ENV === 'test'` +3. Add custom commands to `cypress/support/commands.js`: + - `cy.deleteTestPush(pushId)` + - `cy.deleteTestUser(username)` +4. Backfill cleanup into existing leaky test files: + - `push-details.cy.js` — add `afterEach` push cleanup + - `docker/pushActions.cy.js` — add `afterEach` push cleanup + `after` user cleanup + +### UI Instrumentation Needed + +Systematically add `data-testid` and accessibility attributes to untested pages for robust, maintainable selectors: + +| File | Attributes to Add | +|------|-------------------| +| `src/ui/views/PushDetails/PushDetails.tsx` | `data-testid="push-details-card-body"` on `` | +| `src/ui/views/PushRequests/PushRequests.tsx` | `data-testid="push-requests-tabs"` on tabs container | +| `src/ui/views/PushRequests/components/PushesTable.tsx` | `data-testid="push-row-"` on each ``, `data-testid="pushes-table"` on table | +| `src/ui/views/RepoDetails/RepoDetails.tsx` | `data-testid="repo-info-card"`, `data-testid="reviewers-table"`, `data-testid="contributors-table"`, `data-testid="add-reviewer-btn"`, `data-testid="add-contributor-btn"`, `data-testid="code-clone-btn"` | +| `src/ui/views/RepoDetails/Components/AddUser.tsx` | `data-testid="add-user-dialog"`, `data-testid="add-user-select"`, `data-testid="add-user-confirm-btn"` | +| `src/ui/views/RepoDetails/Components/DeleteRepoDialog.tsx` | `data-testid="delete-repo-dialog"`, `data-testid="delete-repo-confirm-input"`, `data-testid="delete-repo-confirm-btn"` | +| `src/ui/views/User/UserProfile.tsx` | `data-testid="profile-name"`, `data-testid="profile-role"`, `data-testid="profile-email"`, `data-testid="profile-gitAccount"`, `data-testid="profile-admin-status"`, `data-testid="gitAccount-input"`, `data-testid="update-profile-btn"` | +| `src/ui/views/UserList/Components/UserList.tsx` | `data-testid="user-list-table"`, `data-testid="user-row-"` on each row | +| `src/ui/views/Settings/Settings.tsx` | `data-testid="jwt-token-input"`, `data-testid="jwt-token-toggle"`, `data-testid="jwt-save-btn"`, `data-testid="jwt-clear-btn"`, `data-testid="settings-snackbar"` | +| `src/ui/components/Sidebar/Sidebar.tsx` | `aria-current="page"` on active `` | +| `src/ui/components/Navbars/Navbar.tsx` | `data-testid="navbar"` | +| `src/ui/components/Footer/Footer.tsx` | `data-testid="footer"` | +| `src/ui/components/Search/Search.tsx` | `data-testid="search-input"` | +| `src/ui/components/Pagination/Pagination.tsx` | `data-testid="pagination-previous"`, `data-testid="pagination-next"`, `data-testid="pagination-info"` | +| `src/ui/components/Filtering/Filtering.tsx` | `data-testid="filter-dropdown"`, `data-testid="filter-option-"`, `data-testid="filter-sort-toggle"` | +| `src/ui/views/Extras/NotFound.tsx` | `data-testid="not-found-page"` | +| `src/ui/views/Extras/NotAuthorized.tsx` | `data-testid="not-authorized-page"` | + +--- + +## Phase 1: High-Complexity Pages + +### 1. Push Details — Tabs & Content Rendering ✅ DONE +**Route:** `/dashboard/push/:id` +**File:** `cypress/e2e/push-details.cy.js` +**Strategy:** Real API for 10/11 tests, intercept only for error state. Cleanup added via `afterEach`. + +- [x] 1.1 — Pending push shows Pending status with action buttons *(real API)* +- [x] 1.2 — Card body renders: Timestamp, Remote Head link, Commit SHA link, Repository link, Branch link *(real API)* +- [x] 1.3 — Commits tab renders commit data table with correct columns *(real API)* +- [x] 1.4 — Changes tab renders diff content via diff2html *(real API)* +- [x] 1.5 — Steps tab renders steps timeline with summary chips *(real API)* +- [x] 1.6 — Steps accordions expand and show content/logs *(real API)* +- [x] 1.7 — Rejected push shows rejection info with reason *(real API)* +- [x] 1.8 — Approved push shows attestation info *(real API)* +- [x] 1.9 — Error state renders error message when API fails *(intercept — can't trigger real 500)* +- [x] 1.10 — Canceled push shows Canceled status *(real API)* +- [x] 1.11 — Action buttons navigate back to push list after completing action *(real API)* + +### 2. Repo Details — User Management +**Route:** `/dashboard/repo/:id` +**File:** `cypress/e2e/repo-details.cy.js` +**Strategy:** Real API for all tests. Create a test repo via `cy.request POST /api/v1/repo` in `before`, clean up in `after`. + +- [ ] 2.1 — Repo info renders: project, name, URL links +- [ ] 2.2 — Reviewers table renders user list with links +- [ ] 2.3 — Contributors table renders user list with links +- [ ] 2.4 — Admin can add reviewer via "Add Reviewer" button +- [ ] 2.5 — Admin can remove reviewer +- [ ] 2.6 — Admin can add contributor via "Add Contributor" button +- [ ] 2.7 — Admin can remove contributor +- [ ] 2.8 — Delete repo dialog opens, confirms, navigates to repo list +- [ ] 2.9 — Non-admin cannot see add/remove/delete buttons +- [ ] 2.10 — Code clone button renders with correct URL + +### 3. Push Requests — Tab Filtering +**Route:** `/dashboard/push` +**File:** `cypress/e2e/push-requests.cy.js` +**Strategy:** Shared dataset created once in `before()`, cleaned up in `after()`. Uses real pushes for Pending/Approved/Rejected/Canceled, intercept for Error tab. *Comment in code explaining shared dataset for PR reviewers.* + +- [ ] 3.1 — All 6 tabs render (All, Pending, Approved, Canceled, Rejected, Error) +- [ ] 3.2 — Pending tab filters to show only pending pushes *(real API)* +- [ ] 3.3 — Approved tab filters to show only approved pushes *(real API)* +- [ ] 3.4 — Canceled tab filters to show only canceled pushes *(real API)* +- [ ] 3.5 — Rejected tab filters to show only rejected pushes *(real API)* +- [ ] 3.6 — Error tab filters to show only errored pushes *(intercept — requires UI bugfix + synthetic error push)* +- [ ] 3.7 — Push table rows are clickable and navigate to Push Details *(real API)* + +### 4. Repo List — Search, Filter, Pagination +**Route:** `/dashboard/repo` +**File:** `cypress/e2e/repo-list.cy.js` +**Strategy:** Create 6+ test repos via fast API (`cy.request POST /api/v1/repo`) in `before()`, clean up in `after()`. Pagination is tested here only (shared `Pagination` component). Search/filter use client-side logic. + +- [ ] 4.1 — Search filters repos by name +- [ ] 4.2 — Search filters repos by project +- [ ] 4.3 — Clear search resets to all repos +- [ ] 4.4 — Filter dropdown sorts by Date Modified, Date Created, Alphabetical +- [ ] 4.5 — Pagination renders and navigates between pages +- [ ] 4.6 — Repo rows are clickable and navigate to Repo Details + +### 5. Profile Page +**Route:** `/dashboard/profile` +**File:** `cypress/e2e/profile.cy.js` +**Strategy:** Real API for all tests. + +- [ ] 5.1 — Displays user info: name, role, email, GitHub username, admin status +- [ ] 5.2 — User can edit their own GitHub username +- [ ] 5.3 — Admin can edit another user's GitHub username (via `/dashboard/user/:id`) +- [ ] 5.4 — Non-admin viewing another user's profile cannot edit + +### 6. User List (Admin) +**Route:** `/dashboard/admin/user` +**File:** `cypress/e2e/user-list.cy.js` +**Strategy:** Real API. *Note: Create/delete user UI does not exist; tests cover only read access.* + +- [ ] 6.1 — Renders list of all users +- ~~6.2 — Admin can create a new user~~ *(UI not implemented — removed from scope)* +- ~~6.3 — Admin can delete a user~~ *(UI not implemented — removed from scope)* +- [ ] 6.4 — Non-admin cannot access user list + +### 7. Settings Page +**Route:** `/dashboard/admin/settings` +**File:** `cypress/e2e/settings.cy.js` +**Strategy:** Uses `localStorage` for JWT persistence. No backend API calls for save/clear. + +- [ ] 7.1 — JWT token field renders with show/hide toggle +- [ ] 7.2 — Save button persists token and shows snackbar +- [ ] 7.3 — Clear button removes token and shows snackbar +- [ ] 7.4 — Token persists across page reload + +### 8. Navigation & Shell +**File:** `cypress/e2e/navigation.cy.js` +**Strategy:** Mix of real navigation and intercepts. + +- [ ] 8.1 — Sidebar renders all visible links (Repositories, Dashboard, My Account, Users, Settings) +- [ ] 8.2 — Sidebar links navigate correctly +- [ ] 8.3 — Active sidebar item highlights *(uses `aria-current="page"`)* +- [ ] 8.4 — Navbar renders correctly +- [ ] 8.5 — Footer renders +- [ ] 8.6 — Unauthenticated user is redirected to `/login` +- [ ] 8.7 — `/` redirects to `/dashboard/repo` + +### 9. Error Pages +**File:** `cypress/e2e/error-pages.cy.js` +**Strategy:** Direct navigation. No API needed. + +- [ ] 9.1 — Unknown route shows 404 page +- [ ] 9.2 — Unauthorized route shows NotAuthorized page + +--- + +## Implementation Notes + +### Hybrid Approach: Real API First, Intercepts as Fallback + +- **Prefer real API calls** — leverage existing custom commands (`cy.createPush()`, `cy.createUser()`, `cy.addUserPushPermission()`, etc.) to create real data and test real UI rendering +- **Use `cy.intercept()` only when real API is impractical** — e.g., mocking 500 errors, testing edge-case data shapes (empty commits, specific step errors/blocks), or simulating OIDC flows +- **Shared datasets for read-only filtering tests** — acceptable when tests only assert on rendering, not mutations. Document with inline comments. +- Use `cy.session()` for login (already available in custom commands) +- Follow existing file naming convention: `cypress/e2e/.cy.js` +- Include Apache 2.0 license header in all new files +- Each test file should document which tests use real API vs intercepts (see `push-details.cy.js` as reference) + +### Cleanup Discipline + +- Every test that creates a push via `cy.createPush()` must clean it up via `cy.deleteTestPush()` in `afterEach` or `after` +- Every test that creates a user via `cy.createUser()` should clean it up via `cy.deleteTestUser()` in `after` +- `repo-list.cy.js` creates repos via `cy.request POST /api/v1/repo`; clean up via `cy.deleteRepo()` in `after` +- Do not rely on database wipes between CI runs; keep local repeated runs safe diff --git a/cypress/e2e/docker/pushActions.cy.js b/cypress/e2e/docker/pushActions.cy.js index 690a2eb6e..41c5b2537 100644 --- a/cypress/e2e/docker/pushActions.cy.js +++ b/cypress/e2e/docker/pushActions.cy.js @@ -49,10 +49,20 @@ describe('Push Actions (Approve, Reject, Cancel)', () => { cy.logout(); }); - afterEach(() => { + afterEach(function () { + // Clean up push created in this test (if any) + if (this.pushId) { + cy.deleteTestPush(this.pushId); + } cy.logout(); }); + after(() => { + // Clean up test users + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + describe('Approve flow', () => { beforeEach(() => { const suffix = `approve-${Date.now()}`; diff --git a/cypress/e2e/error-pages.cy.js b/cypress/e2e/error-pages.cy.js new file mode 100644 index 000000000..86609e4ec --- /dev/null +++ b/cypress/e2e/error-pages.cy.js @@ -0,0 +1,67 @@ +/** + * 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(); + }); + + // --- 9.1 Unknown route shows 404 --- + it('9.1 — 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'); + }); + + // --- 9.2 Unauthorized route shows NotAuthorized --- + it('9.2 — Unauthorized route shows NotAuthorized page', () => { + // Create a non-admin user and try to access admin route + 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.logout(); + cy.login(regularUser.username, regularUser.password); + cy.visit('/dashboard/admin/settings'); + + // Should show not authorized page + cy.get('[data-testid="not-authorized-page"]').should('be.visible'); + cy.contains('403').should('be.visible'); + + // Clean up user + cy.logout(); + cy.deleteTestUser(regularUser.username); + }); +}); diff --git a/cypress/e2e/navigation.cy.js b/cypress/e2e/navigation.cy.js new file mode 100644 index 000000000..d07eced79 --- /dev/null +++ b/cypress/e2e/navigation.cy.js @@ -0,0 +1,90 @@ +/** + * 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(); + }); + + // --- 8.1 Sidebar renders all visible links --- + it('8.1 — Sidebar renders all visible links', () => { + cy.visit('/dashboard/repo'); + + // Sidebar links should be present + cy.contains('Repositories').should('be.visible'); + cy.contains('Dashboard').should('be.visible'); + }); + + // --- 8.2 Sidebar links navigate correctly --- + it('8.2 — Sidebar links navigate correctly', () => { + cy.visit('/dashboard/repo'); + + // Navigate to push dashboard + cy.contains('Dashboard').click(); + cy.url().should('include', '/dashboard/push'); + + // Navigate back to repos + cy.contains('Repositories').click(); + cy.url().should('include', '/dashboard/repo'); + }); + + // --- 8.3 Active sidebar item highlights --- + it('8.3 — Active sidebar item highlights', () => { + cy.visit('/dashboard/repo'); + + // The active nav link should have aria-current="page" + cy.get('[aria-current="page"]').should('exist'); + }); + + // --- 8.4 Navbar renders --- + it('8.4 — Navbar renders correctly', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="navbar"]').should('be.visible'); + }); + + // --- 8.5 Footer renders --- + it('8.5 — Footer renders', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="footer"]').should('be.visible'); + }); + + // --- 8.6 Unauthenticated user redirected --- + it('8.6 — Unauthenticated user is redirected to /login', () => { + cy.logout(); + cy.visit('/dashboard/repo'); + + cy.url().should('include', '/login'); + }); + + // --- 8.7 Root redirects to dashboard/repo --- + it('8.7 — / redirects to /dashboard/repo', () => { + cy.logout(); + cy.visit('/'); + + // Root should redirect (either to login if not authenticated, or to dashboard/repo) + cy.url().should('match', /\/(login|dashboard)/); + }); +}); diff --git a/cypress/e2e/profile.cy.js b/cypress/e2e/profile.cy.js new file mode 100644 index 000000000..3414ba5a6 --- /dev/null +++ b/cypress/e2e/profile.cy.js @@ -0,0 +1,97 @@ +/** + * 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.createUser(testUser.username, testUser.password, testUser.email, testUser.gitAccount); + cy.createUser(nonAdminUser.username, nonAdminUser.password, nonAdminUser.email, nonAdminUser.gitAccount); + cy.logout(); + }); + + after(() => { + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(nonAdminUser.username); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + + // --- 5.1 Displays user info --- + it('5.1 — 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'); + }); + + // --- 5.2 User can edit own GitHub username --- + it('5.2 — User can edit their own GitHub username', () => { + cy.login(testUser.username, testUser.password); + cy.visit('/dashboard/profile'); + + // Edit field and update button should be visible for own profile + cy.get('[data-testid="gitAccount-input"]').should('be.visible'); + cy.get('[data-testid="update-profile-btn"]').should('be.visible'); + }); + + // --- 5.3 Admin can edit another user's GitHub username --- + it('5.3 — Admin can edit another user\'s GitHub username', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/user/${testUser.username}`); + + // Edit field and update button should be visible for admin viewing other user + cy.get('[data-testid="gitAccount-input"]').should('be.visible'); + cy.get('[data-testid="update-profile-btn"]').should('be.visible'); + }); + + // --- 5.4 Non-admin cannot edit other user --- + it('5.4 — Non-admin viewing another user\'s profile cannot edit', () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit(`/dashboard/user/${testUser.username}`); + + // Edit field and update button should NOT be visible + 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..4a5badeb3 --- /dev/null +++ b/cypress/e2e/push-details.cy.js @@ -0,0 +1,301 @@ +/** + * 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/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', + }; + + const approverUser = { + username: 'pushdetails_approver', + password: 'approver123', + email: 'pushdetails_approver@example.com', + gitAccount: 'pushdetails_approver', + }; + + beforeEach(() => { + cy.login('admin', 'admin'); + + // Ensure test users exist and have permissions on the test repo + 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 () { + // Clean up push created in this test (if any) + if (this.pushId) { + cy.deleteTestPush(this.pushId); + } + cy.logout(); + }); + + after(() => { + // Clean up test users + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + + // --- 1.1 Pending push shows Pending status --- + it('1.1 — Pending push shows Pending status with action buttons', function () { + const suffix = `pending-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Status should be Pending + cy.get('[data-testid="push-status"]').should('contain', 'Pending'); + + // Action buttons should be visible for pending push + 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'); + }); + + // --- 1.2 Card body renders info fields with correct links --- + it('1.2 — Card body renders Timestamp, Remote Head, Commit SHA, Repository, Branch', function () { + const suffix = `info-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // All info labels should be visible + cy.contains('h3', 'Timestamp').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'); + + // Links should exist (we can't predict exact commit SHAs but links should be present) + cy.get('CardBody').within(() => { + cy.get('a').should('have.length.at.least', 3); // At least commit links + repo + branch + }); + }); + + // --- 1.3 Commits tab renders commit data table --- + it('1.3 — Commits tab renders commit data table with correct columns', function () { + const suffix = `commits-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Commits tab is the default first tab - table headers should be visible + cy.contains('Timestamp').should('be.visible'); + cy.contains('Committer').should('be.visible'); + cy.contains('Author').should('be.visible'); + cy.contains('Message').should('be.visible'); + + // Our test push has a commit with a known message pattern + cy.contains('cypress e2e test').should('be.visible'); + }); + + // --- 1.4 Changes tab renders diff content --- + it('1.4 — Changes tab renders diff content via diff2html', function () { + const suffix = `changes-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Click the Changes tab + cy.contains('Changes').click(); + cy.wait(500); + + // diff2html should render the file we created + cy.contains(`cypress-test-${suffix}.txt`).should('be.visible'); + }); + + // --- 1.5 Steps tab renders steps timeline with summary --- + it('1.5 — Steps tab renders steps timeline with summary chips', function () { + const suffix = `steps-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Click the Steps tab + cy.contains('Steps').click(); + cy.wait(500); + + // Summary header should be visible + cy.contains('Push Validation Steps Summary').should('be.visible'); + + // Total steps chip should be visible + cy.contains('Total Steps').should('be.visible'); + + // At least one step name should be visible + cy.get('.stepName').should('have.length.at.least', 1); + }); + + // --- 1.6 Steps accordions expand and show content/logs --- + it('1.6 — Steps accordions expand and show content/logs', function () { + const suffix = `accordion-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login('admin', 'admin'); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Click the Steps tab + cy.contains('Steps').click(); + cy.wait(500); + + // Find and expand a step accordion (non-large steps are expandable) + cy.get('.stepName').first().then(($stepName) => { + const stepName = $stepName.text(); + // Click to expand (skip large steps like 'diff' and 'writePack' which are disabled) + cy.contains(stepName).click({ force: true }); + + // After expanding, details should be visible + // Either content/logs or the "completed successfully" message + cy.get('.stepDetails').should('be.visible'); + }); + }); + + // --- 1.7 Rejected push shows rejection info with reason --- + it('1.7 — Rejected push shows rejection info with reason', function () { + const suffix = `reject-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Reject the push + 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(); + + // Navigate back to the push details + cy.visit(`/dashboard/push/${this.pushId}`); + + // Status should be Rejected + cy.get('[data-testid="push-status"]').should('contain', 'Rejected'); + + // Rejection info should be visible + cy.contains('rejected this contribution').should('be.visible'); + + // Reason should be displayed + cy.contains('Reason').should('be.visible'); + cy.contains('Test rejection reason for Cypress').should('be.visible'); + + // Action buttons should NOT be visible for rejected 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'); + }); + + // --- 1.8 Approved push shows attestation info --- + it('1.8 — Approved push shows attestation info', function () { + const suffix = `approve-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login(approverUser.username, approverUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Approve the push + cy.get('[data-testid="attestation-open-btn"]').click(); + cy.get('[data-testid="attestation-dialog"]').should('be.visible'); + + // Check all attestation 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(); + + // Navigate back to the push details + cy.visit(`/dashboard/push/${this.pushId}`); + + // Status should be Approved + cy.get('[data-testid="push-status"]').should('contain', 'Approved'); + + // Attestation info should be visible + cy.contains('approved this contribution').should('be.visible'); + + // Action buttons should NOT be visible for 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'); + }); + + // --- 1.9 Error state renders error message when API fails --- + it('1.9 — Error state renders error message when API fails', () => { + // Use intercept for error state - can't easily trigger real 500 errors + 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'); + + // Should show error state + cy.contains('Something went wrong').should('be.visible'); + }); + + // --- 1.10 Canceled push shows Canceled status --- + it('1.10 — Canceled push shows Canceled status', function () { + const suffix = `cancel-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Cancel the push + cy.get('[data-testid="push-cancel-btn"]').click(); + + // Navigate back to the push details + cy.visit(`/dashboard/push/${this.pushId}`); + + // Status should be Canceled + cy.get('[data-testid="push-status"]').should('contain', 'Canceled'); + + // Action buttons should NOT be visible for canceled 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'); + }); + + // --- 1.11 Push details page navigates back to push list after action --- + it('1.11 — Action buttons navigate back to push list after completing action', function () { + const suffix = `nav-${Date.now()}`; + cy.createPush(testUser.username, testUser.password, testUser.email, suffix).as('pushId'); + + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${this.pushId}`); + + // Cancel the push + cy.get('[data-testid="push-cancel-btn"]').click(); + + // Should navigate back to push list + cy.url().should('include', '/dashboard/push'); + cy.url().should('not.include', this.pushId); + }); +}); diff --git a/cypress/e2e/push-requests.cy.js b/cypress/e2e/push-requests.cy.js new file mode 100644 index 000000000..f129d675a --- /dev/null +++ b/cypress/e2e/push-requests.cy.js @@ -0,0 +1,206 @@ +/** + * 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', + }; + + const approverUser = { + username: 'pushreq_approver', + password: 'approver123', + email: 'pushreq_approver@example.com', + gitAccount: 'pushreq_approver', + }; + + // Shared push IDs for the test suite + const pushIds: { pending: string; approved: string; rejected: string; canceled: string } = { + pending: '', + approved: '', + rejected: '', + canceled: '', + }; + + before(function () { + // Create test users + 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(); + + // Create pending push + cy.createPush(testUser.username, testUser.password, testUser.email, `pushreq-pending-${Date.now()}`).then( + (id) => { pushIds.pending = id; } + ); + + // Create and approve a push + cy.createPush(testUser.username, testUser.password, testUser.email, `pushreq-approved-${Date.now()}`).then( + (id) => { + pushIds.approved = id; + // Login as approver and approve + 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(); + } + ); + + // Create and reject a push + cy.createPush(testUser.username, testUser.password, testUser.email, `pushreq-rejected-${Date.now()}`).then( + (id) => { + pushIds.rejected = id; + // Login as approver and reject + 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(); + } + ); + + // Create and cancel a push + cy.createPush(testUser.username, testUser.password, testUser.email, `pushreq-canceled-${Date.now()}`).then( + (id) => { + pushIds.canceled = id; + // Login as test user and cancel + cy.login(testUser.username, testUser.password); + cy.visit(`/dashboard/push/${id}`); + cy.get('[data-testid="push-cancel-btn"]').click(); + cy.logout(); + } + ); + }); + + after(() => { + // Clean up all pushes + cy.deleteTestPush(pushIds.pending); + cy.deleteTestPush(pushIds.approved); + cy.deleteTestPush(pushIds.rejected); + cy.deleteTestPush(pushIds.canceled); + // Clean up test users + cy.deleteTestUser(testUser.username); + cy.deleteTestUser(approverUser.username); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + + // --- 3.1 All 6 tabs render --- + it('3.1 — 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'); + }); + + // --- 3.2 Pending tab filters --- + it('3.2 — Pending tab filters to show only pending pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Pending').click(); + cy.wait(500); + + // The pending push should be visible in the table + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + + // --- 3.3 Approved tab filters --- + it('3.3 — Approved tab filters to show only approved pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Approved').click(); + cy.wait(500); + + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + + // --- 3.4 Canceled tab filters --- + it('3.4 — Canceled tab filters to show only canceled pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Canceled').click(); + cy.wait(500); + + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + + // --- 3.5 Rejected tab filters --- + it('3.5 — Rejected tab filters to show only rejected pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Rejected').click(); + cy.wait(500); + + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + + // --- 3.6 Error tab filters (intercept) --- + it('3.6 — Error tab filters to show only errored pushes', () => { + cy.visit('/dashboard/push'); + + cy.contains('Error').click(); + cy.wait(500); + + // The table should be visible (may be empty if no real error pushes exist) + cy.get('[data-testid="pushes-table"]').should('be.visible'); + }); + + // --- 3.7 Push rows are clickable --- + it('3.7 — Push table rows are clickable and navigate to Push Details', () => { + cy.visit('/dashboard/push'); + + // Click on a push row arrow button + cy.get('[data-testid="pushes-table"]') + .find('button') + .first() + .click(); + + // Should navigate to push details page + 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..469739227 --- /dev/null +++ b/cypress/e2e/repo-details.cy.js @@ -0,0 +1,258 @@ +/** + * 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: string | null = null; + + function getApiBaseUrl() { + return Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); + } + + before(() => { + // Create test users + 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); + + // Create test repo + 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(() => { + // Clean up test repo + if (testRepoId) { + cy.login('admin', 'admin'); + cy.deleteRepo(testRepoId); + cy.logout(); + } + // Clean up test users + cy.deleteTestUser(testReviewer.username); + cy.deleteTestUser(testContributor.username); + cy.deleteTestUser(nonAdminUser.username); + }); + + // --- 2.1 Repo info renders --- + it('2.1 — 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('h4').should('be.visible'); + }); + + // --- 2.2 Reviewers table renders --- + it('2.2 — 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'); + }); + + // --- 2.3 Contributors table renders --- + it('2.3 — Contributors table renders user list with links', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + cy.get('[data-testid="contributors-table"]').should('be.visible'); + cy.contains('Contributors').should('be.visible'); + }); + + // --- 2.4 Admin can add reviewer --- + it('2.4 — Admin can add reviewer via "Add Reviewer" button', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + // Click add reviewer button + cy.get('[data-testid="add-user-btn-authorise"]').click(); + + // Dialog should open + cy.get('[data-testid="add-user-dialog"]').should('be.visible'); + + // Select user from dropdown + cy.get('[data-testid="add-user-select"]').click(); + cy.contains(`li.MuiMenuItem-root`, testReviewer.username).click(); + + // Confirm addition + cy.get('[data-testid="add-user-confirm-btn"]').click(); + + // Wait for dialog to close and user to appear in reviewers table + cy.get('[data-testid="add-user-dialog"]').should('not.exist'); + cy.get('[data-testid="reviewers-table"]').contains(testReviewer.username).should('be.visible'); + }); + + // --- 2.5 Admin can remove reviewer --- + it('2.5 — Admin can remove reviewer', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + // Find the remove button in the reviewers table and click it + cy.get('[data-testid="reviewers-table"]') + .contains(testReviewer.username) + .parents('tr') + .find('button') + .first() + .click(); + + // User should no longer appear in reviewers table + cy.get('[data-testid="reviewers-table"]') + .contains(testReviewer.username) + .should('not.exist'); + }); + + // --- 2.6 Admin can add contributor --- + it('2.6 — Admin can add contributor via "Add Contributor" button', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + // Click add contributor button + cy.get('[data-testid="add-user-btn-push"]').click(); + + // Dialog should open + cy.get('[data-testid="add-user-dialog"]').should('be.visible'); + + // Select user from dropdown + cy.get('[data-testid="add-user-select"]').click(); + cy.contains(`li.MuiMenuItem-root`, testContributor.username).click(); + + // Confirm addition + cy.get('[data-testid="add-user-confirm-btn"]').click(); + + // Wait for dialog to close and user to appear in contributors table + cy.get('[data-testid="add-user-dialog"]').should('not.exist'); + cy.get('[data-testid="contributors-table"]').contains(testContributor.username).should('be.visible'); + }); + + // --- 2.7 Admin can remove contributor --- + it('2.7 — Admin can remove contributor', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + // Find the remove button in the contributors table and click it + cy.get('[data-testid="contributors-table"]') + .contains(testContributor.username) + .parents('tr') + .find('button') + .first() + .click(); + + // User should no longer appear in contributors table + cy.get('[data-testid="contributors-table"]') + .contains(testContributor.username) + .should('not.exist'); + }); + + // --- 2.8 Delete repo dialog --- + it('2.8 — Delete repo dialog opens, confirms, navigates to repo list', () => { + cy.login('admin', 'admin'); + + // Create a fresh repo to delete + 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}`); + + // Click delete button + cy.get('[data-testid="delete-repo-button"]').click(); + + // Dialog should open + cy.get('[data-testid="delete-repo-dialog"]').should('be.visible'); + + // Type repo name to confirm + cy.get('[data-testid="delete-repo-confirm-input"]').type(deleteRepoName); + + // Confirm button should now be enabled + cy.get('[data-testid="delete-repo-confirm-btn"]').should('not.be.disabled'); + + // Click confirm + cy.get('[data-testid="delete-repo-confirm-btn"]').click(); + + // Should navigate to repo list + cy.url().should('include', '/dashboard/repo'); + }); + }); + + // --- 2.9 Non-admin cannot see management buttons --- + it('2.9 — Non-admin cannot see add/remove/delete buttons', () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit(`/dashboard/repo/${testRepoId}`); + + // Admin-only buttons should not be visible + 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'); + }); + + // --- 2.10 Code clone button --- + it('2.10 — Code clone button renders with correct URL', () => { + cy.login('admin', 'admin'); + cy.visit(`/dashboard/repo/${testRepoId}`); + + cy.get('[data-testid="code-clone-btn"]').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..5062c306b --- /dev/null +++ b/cypress/e2e/repo-list.cy.js @@ -0,0 +1,152 @@ +/** + * 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: string[] = []; + + function getApiBaseUrl() { + return Cypress.env('API_BASE_URL') || Cypress.config('baseUrl'); + } + + before(() => { + cy.login('admin', 'admin'); + + // Create 6 test repos for pagination testing + 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(() => { + // Clean up all created repos + createdRepoIds.forEach((repoId) => { + cy.deleteRepo(repoId); + }); + }); + + beforeEach(() => { + cy.login('admin', 'admin'); + }); + + afterEach(() => { + cy.logout(); + }); + + // --- 4.1 Search filters by name --- + it('4.1 — Search filters repos by name', () => { + cy.visit('/dashboard/repo'); + + // Type in search + cy.get('[data-testid="search-input"]').find('input').type('cypress-pagination-repo-0'); + cy.wait(300); + + // Results should be filtered (at least the search input is visible and functional) + cy.get('[data-testid="search-input"]').should('be.visible'); + }); + + // --- 4.2 Search filters by project --- + it('4.2 — Search filters repos by project', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').find('input').type('cypress-test'); + cy.wait(300); + + cy.get('[data-testid="search-input"]').should('be.visible'); + }); + + // --- 4.3 Clear search resets --- + it('4.3 — Clear search resets to all repos', () => { + cy.visit('/dashboard/repo'); + + cy.get('[data-testid="search-input"]').find('input').type('unique-filter-string'); + cy.wait(300); + + // Clear the search + cy.get('[data-testid="search-input"]').find('input').clear(); + cy.wait(300); + + cy.get('[data-testid="search-input"]').should('be.visible'); + }); + + // --- 4.4 Filter dropdown sorts --- + it('4.4 — Filter dropdown sorts by Date Modified, Date Created, Alphabetical', () => { + cy.visit('/dashboard/repo'); + + // Open filter dropdown + cy.get('[data-testid="filter-dropdown"]').click(); + + // All options should be visible + 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'); + + // Click one option + cy.get('[data-testid="filter-option-alphabetical"]').click(); + + // Dropdown should close + cy.get('[data-testid="filter-dropdown"]').should('contain', 'Alphabetical'); + }); + + // --- 4.5 Pagination --- + it('4.5 — Pagination renders and navigates between pages', () => { + cy.visit('/dashboard/repo'); + + // Pagination controls should be visible + cy.get('[data-testid="pagination-previous"]').should('be.visible'); + cy.get('[data-testid="pagination-next"]').should('be.visible'); + cy.get('[data-testid="pagination-info"]').should('be.visible'); + + // Navigate to next page + cy.get('[data-testid="pagination-next"]').click(); + cy.wait(300); + + // Navigate back + cy.get('[data-testid="pagination-previous"]').click(); + cy.wait(300); + }); + + // --- 4.6 Repo rows navigate --- + it('4.6 — Repo rows are clickable and navigate to Repo Details', () => { + cy.visit('/dashboard/repo'); + + // Click on a repo row + cy.get('[data-testid="search-input"]').parent().parent().find('tr').first().click(); + + // Should navigate to repo details + cy.url().should('include', '/dashboard/repo/'); + }); +}); diff --git a/cypress/e2e/settings.cy.js b/cypress/e2e/settings.cy.js new file mode 100644 index 000000000..6eb886307 --- /dev/null +++ b/cypress/e2e/settings.cy.js @@ -0,0 +1,86 @@ +/** + * 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(); + // Clear localStorage to avoid state leaking between tests + cy.window().then((win) => win.localStorage.clear()); + }); + + // --- 7.1 JWT token field renders --- + it('7.1 — 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'); + }); + + // --- 7.2 Save button persists token --- + it('7.2 — Save button persists token and shows snackbar', () => { + cy.visit('/dashboard/admin/settings'); + + // Enter a test token + cy.get('[data-testid="jwt-token-input"]').find('input').type('test-jwt-token-12345'); + + // Click save + cy.get('[data-testid="jwt-save-btn"]').click(); + + // Snackbar should appear + cy.contains('JWT token saved').should('be.visible'); + }); + + // --- 7.3 Clear button removes token --- + it('7.3 — Clear button removes token and shows snackbar', () => { + cy.visit('/dashboard/admin/settings'); + + // Enter a test token + cy.get('[data-testid="jwt-token-input"]').find('input').type('test-jwt-token-12345'); + + // Click clear + cy.get('[data-testid="jwt-clear-btn"]').click(); + + // Snackbar should appear + cy.contains('JWT token cleared').should('be.visible'); + + // Token field should be empty + cy.get('[data-testid="jwt-token-input"]').find('input').should('have.value', ''); + }); + + // --- 7.4 Token persists across reload --- + it('7.4 — Token persists across page reload', () => { + cy.visit('/dashboard/admin/settings'); + + // Enter and save a token + cy.get('[data-testid="jwt-token-input"]').find('input').type('persistent-token-xyz'); + cy.get('[data-testid="jwt-save-btn"]').click(); + cy.wait(500); + + // Reload page + cy.reload(); + + // Token should still be present + 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..03033b556 --- /dev/null +++ b/cypress/e2e/user-list.cy.js @@ -0,0 +1,58 @@ +/** + * 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); + }); + + // --- 6.1 Renders list of all users --- + it('6.1 — Renders list of all users', () => { + cy.login('admin', 'admin'); + cy.visit('/dashboard/admin/user'); + + cy.get('[data-testid="user-list-table"]').should('be.visible'); + + // Admin user should be in the list + cy.get('[data-testid="user-list-table"]').contains('admin').should('be.visible'); + }); + + // --- 6.4 Non-admin cannot access --- + it('6.4 — Non-admin cannot access user list', () => { + cy.login(nonAdminUser.username, nonAdminUser.password); + cy.visit('/dashboard/admin/user'); + + // Should redirect to not authorized or show error + 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/src/service/routes/index.ts b/src/service/routes/index.ts index 80d6c315d..a0ec51256 100644 --- a/src/service/routes/index.ts +++ b/src/service/routes/index.ts @@ -34,6 +34,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', jwtAuthHandler(), mod.default); + }); + } + return router; }; diff --git a/src/service/routes/test.ts b/src/service/routes/test.ts new file mode 100644 index 000000000..2092df526 --- /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'; + +const router = express.Router(); + +// Helper: check that the authenticated user is an admin +function requireAdmin(req: Request, res: Response): boolean { + if (!req.user || !req.user.admin) { + 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/Filtering/Filtering.tsx b/src/ui/components/Filtering/Filtering.tsx index 83be90848..16d5eaaff 100644 --- a/src/ui/components/Filtering/Filtering.tsx +++ b/src/ui/components/Filtering/Filtering.tsx @@ -51,23 +51,23 @@ 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 ( -