[READ-ONLY] a fast, modern browser for the npm registry
at main 451 lines 16 kB view raw
1/** 2 * E2E tests for connector-authenticated features. 3 * 4 * These tests use a mock connector server (started in global setup) 5 * to test features that require being logged in via the connector. 6 * 7 * All tests run serially because they share a single mock connector server 8 * whose state is reset before each test via `mockConnector.reset()`. 9 */ 10 11import type { Page } from '@playwright/test' 12import { test, expect } from './helpers/fixtures' 13 14test.describe.configure({ mode: 'serial' }) 15 16/** 17 * When connected, the header shows "packages" and "orgs" links scoped to the user. 18 * This helper waits for the packages link to appear as proof of successful connection. 19 */ 20async function expectConnected(page: Page, username = 'testuser') { 21 await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({ 22 timeout: 10_000, 23 }) 24} 25 26/** 27 * Open the connector modal by clicking the account menu button, then clicking 28 * the npm CLI menu item inside the dropdown. 29 */ 30async function openConnectorModal(page: Page) { 31 // The AccountMenu button has aria-haspopup="true" 32 await page.locator('button[aria-haspopup="true"]').click() 33 34 // In the dropdown menu, click the npm CLI item (menuitem containing ~testuser) 35 await page 36 .getByRole('menuitem') 37 .filter({ hasText: /~testuser/ }) 38 .click() 39 40 // Wait for the dialog to appear 41 await expect(page.getByRole('dialog')).toBeVisible() 42} 43 44test.describe('Connector Connection', () => { 45 test('connects via URL params and shows connected state', async ({ 46 page, 47 gotoConnected, 48 mockConnector, 49 }) => { 50 await mockConnector.setUserOrgs(['@testorg']) 51 await gotoConnected('/') 52 53 // Header should show "packages" link for the connected user 54 await expectConnected(page) 55 }) 56 57 test('opens connector modal and shows connected user', async ({ page, gotoConnected }) => { 58 await gotoConnected('/') 59 await expectConnected(page) 60 61 await openConnectorModal(page) 62 63 // The modal should show the connected user 64 await expect(page.getByRole('dialog')).toContainText('testuser') 65 }) 66 67 test('can disconnect from the connector', async ({ page, gotoConnected }) => { 68 await gotoConnected('/') 69 await expectConnected(page) 70 71 await openConnectorModal(page) 72 73 const modal = page.getByRole('dialog') 74 75 // Click disconnect button 76 await modal.getByRole('button', { name: /disconnect/i }).click() 77 78 // Close the modal 79 await modal.getByRole('button', { name: /close/i }).click() 80 81 // The "packages" link should disappear since we're disconnected 82 await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({ 83 timeout: 5000, 84 }) 85 86 // The account menu button should now show "connect" text (the main button, not dropdown items) 87 await expect(page.getByRole('button', { name: 'connect', exact: true })).toBeVisible() 88 }) 89}) 90 91test.describe('Organization Management', () => { 92 test.beforeEach(async ({ mockConnector }) => { 93 await mockConnector.setOrgData('@testorg', { 94 users: { 95 testuser: 'owner', 96 member1: 'admin', 97 member2: 'developer', 98 }, 99 teams: ['core', 'docs'], 100 teamMembers: { 101 core: ['testuser', 'member1'], 102 docs: ['member2'], 103 }, 104 }) 105 await mockConnector.setUserOrgs(['@testorg']) 106 }) 107 108 test('shows org members when connected', async ({ page, gotoConnected }) => { 109 await gotoConnected('/@testorg') 110 111 // The org management region contains the members panel 112 const orgManagement = page.getByRole('region', { name: /organization management/i }) 113 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 114 115 // Should show the members list 116 const membersList = page.getByRole('list', { name: /organization members/i }) 117 await expect(membersList).toBeVisible({ timeout: 10_000 }) 118 119 // Members are shown as ~username links 120 await expect(membersList.getByRole('link', { name: '~testuser' })).toBeVisible() 121 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 122 await expect(membersList.getByRole('link', { name: '~member2' })).toBeVisible() 123 }) 124 125 test('can filter members by role', async ({ page, gotoConnected }) => { 126 await gotoConnected('/@testorg') 127 128 const orgManagement = page.getByRole('region', { name: /organization management/i }) 129 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 130 131 const membersList = page.getByRole('list', { name: /organization members/i }) 132 await expect(membersList).toBeVisible({ timeout: 10_000 }) 133 134 // Click the "admin" filter button (inside "Filter by role" group) 135 await orgManagement 136 .getByRole('group', { name: /filter by role/i }) 137 .getByRole('button', { name: /admin/i }) 138 .click() 139 140 // Should only show admin member 141 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 142 await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() 143 await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible() 144 }) 145 146 test('can search members by name', async ({ page, gotoConnected }) => { 147 await gotoConnected('/@testorg') 148 149 const orgManagement = page.getByRole('region', { name: /organization management/i }) 150 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 151 152 const membersList = page.getByRole('list', { name: /organization members/i }) 153 await expect(membersList).toBeVisible({ timeout: 10_000 }) 154 155 const searchInput = orgManagement.getByRole('searchbox', { name: /filter members/i }) 156 await searchInput.fill('member1') 157 158 // Should only show matching member 159 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 160 await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() 161 await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible() 162 }) 163 164 test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => { 165 await gotoConnected('/@testorg') 166 167 const orgManagement = page.getByRole('region', { name: /organization management/i }) 168 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 169 170 // Click "Add member" button 171 await orgManagement.getByRole('button', { name: /add member/i }).click() 172 173 // Wait for the add-member form to appear 174 const usernameInput = orgManagement.locator('#new-member-username') 175 await expect(usernameInput).toBeVisible({ timeout: 5000 }) 176 177 // Fill in the form 178 await usernameInput.fill('newuser') 179 180 // Select role (SelectField renders id on the <select>, not name) 181 await orgManagement.locator('#new-member-role').selectOption('admin') 182 183 // Submit 184 await Promise.all([ 185 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 186 orgManagement.getByRole('button', { name: /^add$/i }).click(), 187 ]) 188 189 const operations = await mockConnector.getOperations() 190 expect(operations).toHaveLength(1) 191 expect(operations[0]?.type).toBe('org:add-user') 192 expect(operations[0]?.params.user).toBe('newuser') 193 expect(operations[0]?.params.role).toBe('admin') 194 }) 195 196 test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => { 197 await gotoConnected('/@testorg') 198 199 const orgManagement = page.getByRole('region', { name: /organization management/i }) 200 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 201 202 await Promise.all([ 203 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 204 orgManagement.getByRole('button', { name: /remove member2/i }).click(), 205 ]) 206 207 const operations = await mockConnector.getOperations() 208 expect(operations).toHaveLength(1) 209 expect(operations[0]?.type).toBe('org:rm-user') 210 expect(operations[0]?.params.user).toBe('member2') 211 }) 212 213 test('can change member role (adds operation)', async ({ 214 page, 215 gotoConnected, 216 mockConnector, 217 }) => { 218 await gotoConnected('/@testorg') 219 220 const orgManagement = page.getByRole('region', { name: /organization management/i }) 221 await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 222 223 const roleSelect = orgManagement.locator('#role-member2') 224 await expect(roleSelect).toBeVisible({ timeout: 5000 }) 225 await Promise.all([ 226 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 227 roleSelect.selectOption('admin'), 228 ]) 229 230 const operations = await mockConnector.getOperations() 231 expect(operations).toHaveLength(1) 232 expect(operations[0]?.type).toBe('org:add-user') 233 expect(operations[0]?.params.user).toBe('member2') 234 expect(operations[0]?.params.role).toBe('admin') 235 }) 236}) 237 238test.describe('Package Access Controls', () => { 239 test.beforeEach(async ({ mockConnector }) => { 240 await mockConnector.setOrgData('@nuxt', { 241 users: { testuser: 'owner' }, 242 teams: ['core', 'docs', 'triage'], 243 }) 244 await mockConnector.setUserOrgs(['@nuxt']) 245 await mockConnector.setPackageData('@nuxt/kit', { 246 collaborators: { 247 'nuxt:core': 'read-write', 248 'nuxt:docs': 'read-only', 249 }, 250 }) 251 }) 252 253 /** 254 * Helper: connect on home page then navigate to the package page. 255 * Verifies connection is established before navigating. 256 */ 257 async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise<void>) { 258 await gotoConnected('/') 259 await expectConnected(page) 260 await page.goto('/package/@nuxt/kit') 261 await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 }) 262 } 263 264 test('shows team access section on scoped package when connected', async ({ 265 page, 266 gotoConnected, 267 }) => { 268 await goToPackageConnected(page, gotoConnected) 269 270 await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) 271 await expect(page.getByRole('heading', { name: /team access/i })).toBeVisible() 272 }) 273 274 test('displays collaborators with correct permissions', async ({ page, gotoConnected }) => { 275 await goToPackageConnected(page, gotoConnected) 276 277 await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) 278 279 const teamList = page.getByRole('list', { name: /team access list/i }) 280 await expect(teamList).toBeVisible({ timeout: 10_000 }) 281 282 await expect(teamList.getByText('core')).toBeVisible() 283 await expect(teamList.locator('span', { hasText: 'rw' })).toBeVisible() 284 await expect(teamList.getByText('docs')).toBeVisible() 285 await expect(teamList.locator('span', { hasText: 'ro' })).toBeVisible() 286 }) 287 288 test('can grant team access (creates operation)', async ({ 289 page, 290 gotoConnected, 291 mockConnector, 292 }) => { 293 await goToPackageConnected(page, gotoConnected) 294 295 const section = accessSection(page) 296 await expect(section).toBeVisible({ timeout: 15_000 }) 297 298 await section.getByRole('button', { name: /grant team access/i }).click() 299 300 const teamSelect = section.locator('#grant-team-select') 301 await expect(teamSelect).toBeVisible() 302 await expect(teamSelect.locator('option')).toHaveCount(4, { timeout: 10_000 }) 303 await teamSelect.selectOption({ label: 'nuxt:triage' }) 304 305 await section.locator('#grant-permission-select').selectOption('read-write') 306 307 await Promise.all([ 308 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 309 section.getByRole('button', { name: /^grant$/i }).click(), 310 ]) 311 312 const operations = await mockConnector.getOperations() 313 expect(operations).toHaveLength(1) 314 expect(operations[0]?.type).toBe('access:grant') 315 expect(operations[0]?.params.scopeTeam).toBe('@nuxt:triage') 316 expect(operations[0]?.params.pkg).toBe('@nuxt/kit') 317 expect(operations[0]?.params.permission).toBe('read-write') 318 }) 319 320 test('can revoke team access (creates operation)', async ({ 321 page, 322 gotoConnected, 323 mockConnector, 324 }) => { 325 await goToPackageConnected(page, gotoConnected) 326 327 const section = accessSection(page) 328 await expect(section).toBeVisible({ timeout: 15_000 }) 329 330 const teamList = page.getByRole('list', { name: /team access list/i }) 331 await expect(teamList).toBeVisible({ timeout: 10_000 }) 332 333 await Promise.all([ 334 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 335 section.getByRole('button', { name: /revoke docs access/i }).click(), 336 ]) 337 338 const operations = await mockConnector.getOperations() 339 expect(operations).toHaveLength(1) 340 expect(operations[0]?.type).toBe('access:revoke') 341 expect(operations[0]?.params.scopeTeam).toBe('nuxt:docs') 342 expect(operations[0]?.params.pkg).toBe('@nuxt/kit') 343 }) 344 345 test('can cancel grant access form', async ({ page, gotoConnected }) => { 346 await goToPackageConnected(page, gotoConnected) 347 348 const section = accessSection(page) 349 await expect(section).toBeVisible({ timeout: 15_000 }) 350 351 await section.getByRole('button', { name: /grant team access/i }).click() 352 const teamSelect = section.locator('#grant-team-select') 353 await expect(teamSelect).toBeVisible() 354 355 await section.getByRole('button', { name: /cancel granting access/i }).click() 356 await expect(teamSelect).not.toBeVisible() 357 await expect(section.getByRole('button', { name: /grant team access/i })).toBeVisible() 358 }) 359}) 360 361test.describe('Operations Queue', () => { 362 test('shows operations in connector modal', async ({ page, gotoConnected, mockConnector }) => { 363 await mockConnector.addOperation({ 364 type: 'org:add-user', 365 params: { org: '@testorg', user: 'newuser', role: 'developer' }, 366 description: 'Add @newuser to @testorg as developer', 367 command: 'npm org set @testorg newuser developer', 368 }) 369 await mockConnector.addOperation({ 370 type: 'org:rm-user', 371 params: { org: '@testorg', user: 'olduser' }, 372 description: 'Remove @olduser from @testorg', 373 command: 'npm org rm @testorg olduser', 374 }) 375 376 await gotoConnected('/') 377 await expectConnected(page) 378 379 await openConnectorModal(page) 380 381 const modal = page.getByRole('dialog') 382 await expect(modal).toContainText('Add @newuser') 383 await expect(modal).toContainText('Remove @olduser') 384 }) 385 386 test('can approve and execute operations', async ({ page, gotoConnected, mockConnector }) => { 387 await mockConnector.addOperation({ 388 type: 'org:add-user', 389 params: { org: '@testorg', user: 'newuser', role: 'developer' }, 390 description: 'Add @newuser to @testorg', 391 command: 'npm org set @testorg newuser developer', 392 }) 393 394 await gotoConnected('/') 395 await expectConnected(page) 396 397 await openConnectorModal(page) 398 399 const modal = page.getByRole('dialog') 400 401 // Approve all 402 const approveAllBtn = modal.getByRole('button', { name: /approve all/i }) 403 await expect(approveAllBtn).toBeVisible({ timeout: 5000 }) 404 await Promise.all([ 405 page.waitForResponse(resp => resp.url().includes('/approve-all') && resp.ok()), 406 approveAllBtn.click(), 407 ]) 408 409 let operations = await mockConnector.getOperations() 410 expect(operations[0]?.status).toBe('approved') 411 412 // Execute 413 const executeBtn = modal.getByRole('button', { name: /execute/i }) 414 await expect(executeBtn).toBeVisible({ timeout: 5000 }) 415 await Promise.all([ 416 page.waitForResponse(resp => resp.url().includes('/execute') && resp.ok()), 417 executeBtn.click(), 418 ]) 419 420 operations = await mockConnector.getOperations() 421 expect(operations[0]?.status).toBe('completed') 422 }) 423 424 test('can clear pending operations', async ({ page, gotoConnected, mockConnector }) => { 425 await mockConnector.addOperation({ 426 type: 'org:add-user', 427 params: { org: '@testorg', user: 'newuser', role: 'developer' }, 428 description: 'Add @newuser to @testorg', 429 command: 'npm org set @testorg newuser developer', 430 }) 431 432 await gotoConnected('/') 433 await expectConnected(page) 434 435 await openConnectorModal(page) 436 437 const modal = page.getByRole('dialog') 438 await Promise.all([ 439 page.waitForResponse(resp => resp.url().includes('/operations/all') && resp.ok()), 440 modal.getByRole('button', { name: /clear/i }).click(), 441 ]) 442 443 const operations = await mockConnector.getOperations() 444 expect(operations).toHaveLength(0) 445 }) 446}) 447 448/** The access section is identified by the "Team Access" heading */ 449function accessSection(page: Page) { 450 return page.locator('section:has(#access-heading)') 451}