diff --git a/web/cypress/e2e/repository-shorthand-navigation.cy.ts b/web/cypress/e2e/repository-shorthand-navigation.cy.ts new file mode 100644 index 000000000..c3d002bf0 --- /dev/null +++ b/web/cypress/e2e/repository-shorthand-navigation.cy.ts @@ -0,0 +1,284 @@ +/// + +/** + * Factory function to create repository mock data + * @param org - Organization/namespace name + * @param name - Repository name + * @param overrides - Optional overrides for default values + * @returns Repository mock object + */ +function mockRepository( + org: string, + name: string, + overrides: Partial<{ + description: string; + is_public: boolean; + kind: string; + state: string; + can_write: boolean; + can_admin: boolean; + }> = {}, +) { + return { + namespace: org, + name: name, + description: 'Test repository', + is_public: true, + kind: 'image', + state: 'NORMAL', + can_write: true, + can_admin: true, + ...overrides, + }; +} + +describe('Repository Shorthand URL Navigation', () => { + beforeEach(() => { + cy.intercept('GET', '/config', {fixture: 'config.json'}).as('getConfig'); + cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`) + .then((response) => response.body.csrf_token) + .then((token) => { + cy.loginByCSRF(token); + }); + }); + + it('redirects shorthand URL /:org/:repo to /repository/:org/:repo', () => { + // Mock the repository API response for a valid repository + cy.intercept('GET', '/api/v1/repository/user1/hello-world*', { + statusCode: 200, + body: mockRepository('user1', 'hello-world'), + }).as('getRepo'); + + // Visit shorthand URL + cy.visit('/user1/hello-world'); + + // Wait for redirect and API call + cy.wait('@getRepo'); + + // Verify URL was redirected to full repository path + cy.location('pathname').should('eq', '/repository/user1/hello-world'); + + // Verify repository page loaded correctly + cy.get('[data-testid="repo-title"]').should('contain.text', 'hello-world'); + }); + + it('redirects multi-segment repository names correctly', () => { + // Mock the repository API response + cy.intercept('GET', '/api/v1/repository/openshift/release/installer*', { + statusCode: 200, + body: mockRepository('openshift', 'release/installer', { + description: 'OpenShift installer', + can_write: false, + can_admin: false, + }), + }).as('getRepo'); + + // Visit shorthand URL with multi-segment repo name + cy.visit('/openshift/release/installer'); + + // Wait for redirect and API call + cy.wait('@getRepo'); + + // Verify URL was redirected correctly + cy.location('pathname').should( + 'eq', + '/repository/openshift/release/installer', + ); + + // Verify repository page loaded + cy.get('[data-testid="repo-title"]').should( + 'contain.text', + 'release/installer', + ); + }); + + it('shows 404 error when repository does not exist', () => { + // Mock 404 response from API + cy.intercept('GET', '/api/v1/repository/nonexistent/repo*', { + statusCode: 404, + body: { + error_message: 'Not Found', + error_type: 'not_found', + }, + }).as('getRepoNotFound'); + + // Visit shorthand URL for non-existent repo + cy.visit('/nonexistent/repo'); + + // Wait for redirect and API call + cy.wait('@getRepoNotFound'); + + // Verify URL was redirected + cy.location('pathname').should('eq', '/repository/nonexistent/repo'); + + // Verify error message is displayed + cy.contains('Unable to get repository').should('exist'); + cy.contains('HTTP404 - Not Found').should('exist'); + }); + + it('does not redirect reserved route prefixes', () => { + // Mock organization API response + cy.intercept('GET', '/api/v1/organization/testorg', { + statusCode: 404, + body: { + error_message: 'Not Found', + }, + }).as('getOrg'); + + // Visit /user/ path (reserved prefix) + cy.visit('/user/testuser', {failOnStatusCode: false}); + + // URL should NOT redirect to /repository/user/testuser + cy.location('pathname').should('eq', '/user/testuser'); + + // Should show organization component (existing behavior) + cy.get('h1').should('contain.text', 'testuser'); + }); + + it('redirects single-segment org URL to /organization/:org', () => { + // Mock organization API response + cy.intercept('GET', '/api/v1/organization/testorg', { + statusCode: 200, + body: { + name: 'testorg', + is_org_admin: false, + }, + }).as('getOrg'); + + // Visit shorthand organization URL + cy.visit('/testorg'); + + // Wait for redirect and API call + cy.wait('@getOrg'); + + // Verify URL was redirected to full organization path + cy.location('pathname').should('eq', '/organization/testorg'); + + // Verify organization page loaded + cy.get('h1').should('contain.text', 'testorg'); + }); + + it('shows error when organization does not exist', () => { + // Mock 404 response from organization API + cy.intercept('GET', '/api/v1/organization/nonexistentorg', { + statusCode: 404, + body: { + error_message: 'Not Found', + }, + }).as('getOrgNotFound'); + + // Visit shorthand URL for non-existent organization + cy.visit('/nonexistentorg'); + + // Wait for redirect and API call + cy.wait('@getOrgNotFound'); + + // Verify URL was redirected to organization path + cy.location('pathname').should('eq', '/organization/nonexistentorg'); + + // Note: The organization page handles the 404, not the router + }); + + it('preserves query parameters for organization redirects', () => { + // Mock organization API response + cy.intercept('GET', '/api/v1/organization/testorg', { + statusCode: 200, + body: { + name: 'testorg', + is_org_admin: true, + }, + }).as('getOrg'); + + // Visit shorthand organization URL with query parameter + cy.visit('/testorg?tab=teams'); + + // Wait for redirect and API call + cy.wait('@getOrg'); + + // Verify URL was redirected with query parameter preserved + cy.location('pathname').should('eq', '/organization/testorg'); + cy.location('search').should('eq', '?tab=teams'); + }); + + it('preserves hash fragments for organization redirects', () => { + // Mock organization API response + cy.intercept('GET', '/api/v1/organization/testorg', { + statusCode: 200, + body: { + name: 'testorg', + is_org_admin: true, + }, + }).as('getOrg'); + + // Visit shorthand organization URL with hash fragment + cy.visit('/testorg#section'); + + // Wait for redirect and API call + cy.wait('@getOrg'); + + // Verify URL was redirected with hash fragment preserved + cy.location('pathname').should('eq', '/organization/testorg'); + cy.location('hash').should('eq', '#section'); + }); + + it('preserves query parameters during redirect', () => { + // Mock the repository API response + cy.intercept('GET', '/api/v1/repository/user1/hello-world*', { + statusCode: 200, + body: mockRepository('user1', 'hello-world'), + }).as('getRepo'); + + // Visit shorthand URL with query parameter + cy.visit('/user1/hello-world?tab=tags'); + + // Wait for redirect + cy.wait('@getRepo'); + + // Verify URL was redirected with query parameter preserved + cy.location('pathname').should('eq', '/repository/user1/hello-world'); + cy.location('search').should('eq', '?tab=tags'); + + // Verify the Tags tab is active + cy.get('[role="tab"][aria-selected="true"]').should('contain.text', 'Tags'); + }); + + it('preserves hash fragments during redirect', () => { + // Mock the repository API response + cy.intercept('GET', '/api/v1/repository/user1/hello-world*', { + statusCode: 200, + body: mockRepository('user1', 'hello-world'), + }).as('getRepo'); + + // Visit shorthand URL with hash fragment + cy.visit('/user1/hello-world#section'); + + // Wait for redirect + cy.wait('@getRepo'); + + // Verify URL was redirected with hash fragment preserved + cy.location('pathname').should('eq', '/repository/user1/hello-world'); + cy.location('hash').should('eq', '#section'); + }); + + it('preserves both query parameters and hash fragments during redirect', () => { + // Mock the repository API response + cy.intercept('GET', '/api/v1/repository/user1/hello-world*', { + statusCode: 200, + body: mockRepository('user1', 'hello-world'), + }).as('getRepo'); + + // Visit shorthand URL with both query parameter and hash fragment + cy.visit('/user1/hello-world?tab=tags#section'); + + // Wait for redirect + cy.wait('@getRepo'); + + // Verify URL was redirected with both query parameter and hash preserved + cy.location('pathname').should('eq', '/repository/user1/hello-world'); + cy.location('search').should('eq', '?tab=tags'); + cy.location('hash').should('eq', '#section'); + + // Verify the Tags tab is active + cy.get('[role="tab"][aria-selected="true"]').should('contain.text', 'Tags'); + }); +}); diff --git a/web/src/routes/StandaloneMain.tsx b/web/src/routes/StandaloneMain.tsx index 6944b4e4e..c85813984 100644 --- a/web/src/routes/StandaloneMain.tsx +++ b/web/src/routes/StandaloneMain.tsx @@ -8,7 +8,14 @@ import { Page, } from '@patternfly/react-core'; -import {Navigate, Outlet, Route, Routes} from 'react-router-dom'; +import { + Navigate, + Outlet, + Route, + Routes, + useParams, + useLocation, +} from 'react-router-dom'; import {QuayHeader} from 'src/components/header/QuayHeader'; import {QuaySidebar} from 'src/components/sidebar/QuaySidebar'; @@ -54,6 +61,93 @@ const UsageLogs = lazy(() => import('./Superuser/UsageLogs/UsageLogs')); const Messages = lazy(() => import('./Superuser/Messages/Messages')); const BuildLogs = lazy(() => import('./Superuser/BuildLogs/BuildLogs')); +/** + * Interface for shorthand repository route parameters + */ +interface ShorthandParams { + org: string; + '*': string; +} + +/** + * Derives reserved route prefixes from NavigationPath enum and additional static routes. + * These prefixes should NOT be treated as organization names in shorthand repository URLs. + * + * @returns Array of reserved path prefixes that won't redirect to repository pages + */ +function getReservedRoutePrefixes(): string[] { + // Extract first segment from each NavigationPath value + const navigationPrefixes = Object.values(NavigationPath) + .map((path) => { + // Remove leading slash and get first segment + const segments = path.replace(/^\//, '').split('/'); + return segments[0]; + }) + .filter((prefix) => prefix && !prefix.startsWith(':')); // Filter out empty and param placeholders + + // Additional static routes not in NavigationPath enum + const staticPrefixes = ['signin', 'createaccount', 'oauth-error']; + + // Combine and deduplicate + return [...new Set([...navigationPrefixes, ...staticPrefixes])]; +} + +/** + * Component to handle shorthand organization and repository URLs. + * + * This component provides backward compatibility with the Angular UI's shorthand URL pattern, + * allowing users to navigate directly to organizations and repositories using shorthand paths. + * + * Behavior: + * - Redirects /:org to /organization/:org (single segment) + * - Redirects /:org/:repo to /repository/:org/:repo (two or more segments) + * - Preserves query parameters and hash fragments during redirect + * - Excludes reserved route prefixes to prevent conflicts with existing routes + * + * Reserved prefixes (dynamically derived from NavigationPath): + * - All first segments from NavigationPath enum (e.g., 'user', 'organization', 'repository', 'overview') + * - Additional static routes ('signin', 'createaccount', 'oauth-error') + * + * @example + * // Valid organization redirects + * /myorg → /organization/myorg + * /projectquay?tab=teams → /organization/projectquay?tab=teams + * + * @example + * // Valid repository redirects + * /openshift/release → /repository/openshift/release + * /user1/hello-world?tab=tags#section → /repository/user1/hello-world?tab=tags#section + * + * @example + * // Returns 404 (reserved prefix - passes through to existing routes) + * /user/testuser → 404 (reserved prefix) + * /organization/myorg → 404 (reserved prefix) + * /repository/foo/bar → 404 (reserved prefix) + */ +function RepositoryShorthandRedirect() { + const params = useParams(); + const location = useLocation(); + const org = params.org; + const repo = params['*']; + + const reservedPrefixes = getReservedRoutePrefixes(); + + // Show 404 if org matches a reserved route prefix (let existing routes handle it) + if (reservedPrefixes.includes(org)) { + return ; + } + + // Single segment (/:org) - redirect to organization + if (!repo || repo.trim() === '') { + const redirectTo = `/organization/${org}${location.search}${location.hash}`; + return ; + } + + // Two or more segments (/:org/:repo) - redirect to repository + const redirectTo = `/repository/${org}/${repo}${location.search}${location.hash}`; + return ; +} + const NavigationRoutes = [ { path: NavigationPath.teamMember, @@ -190,6 +284,8 @@ export function StandaloneMain() { ))} } /> + {/* Redirect shorthand repository URLs (e.g., /openshift/release) to /repository/openshift/release */} + } /> } />