[READ-ONLY] a fast, modern browser for the npm registry
at main 537 lines 16 kB view raw
1/** 2 * Mock connector state management. Canonical source used by the mock server, 3 * E2E tests, and Vitest composable mocks. 4 */ 5 6import type { 7 PendingOperation, 8 OperationType, 9 OperationResult, 10 OrgRole, 11 AccessPermission, 12} from './types.ts' 13 14export interface MockConnectorConfig { 15 token: string 16 npmUser: string 17 avatar?: string | null 18 port?: number 19} 20 21export interface MockOrgData { 22 users: Record<string, OrgRole> 23 teams: string[] 24 /** team name -> member usernames */ 25 teamMembers: Record<string, string[]> 26} 27 28export interface MockPackageData { 29 collaborators: Record<string, AccessPermission> 30} 31 32export interface MockConnectorStateData { 33 config: MockConnectorConfig 34 connected: boolean 35 connectedAt: number | null 36 orgs: Record<string, MockOrgData> 37 packages: Record<string, MockPackageData> 38 userPackages: Record<string, AccessPermission> 39 userOrgs: string[] 40 operations: PendingOperation[] 41 operationIdCounter: number 42} 43 44export interface NewOperationInput { 45 type: OperationType 46 params: Record<string, string> 47 description: string 48 command: string 49 dependsOn?: string 50} 51 52export interface ExecuteOptions { 53 otp?: string 54 /** Per-operation results for testing failures. */ 55 results?: Record<string, Partial<OperationResult>> 56} 57 58export interface ExecuteResult { 59 results: Array<{ id: string; result: OperationResult }> 60 otpRequired?: boolean 61 authFailure?: boolean 62 urls?: string[] 63} 64 65export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData { 66 return { 67 config: { 68 port: 31415, 69 avatar: null, 70 ...config, 71 }, 72 connected: false, 73 connectedAt: null, 74 orgs: {}, 75 packages: {}, 76 userPackages: {}, 77 userOrgs: [], 78 operations: [], 79 operationIdCounter: 0, 80 } 81} 82 83/** 84 * Mock connector state, shared between the HTTP server and composable mock. 85 */ 86export class MockConnectorStateManager { 87 public state: MockConnectorStateData 88 89 constructor(initialState: MockConnectorStateData) { 90 this.state = initialState 91 } 92 93 // -- Configuration -- 94 95 get config(): MockConnectorConfig { 96 return this.state.config 97 } 98 99 get token(): string { 100 return this.state.config.token 101 } 102 103 get port(): number { 104 return this.state.config.port ?? 31415 105 } 106 107 // -- Connection -- 108 109 connect(token: string): boolean { 110 if (token !== this.state.config.token) { 111 return false 112 } 113 this.state.connected = true 114 this.state.connectedAt = Date.now() 115 return true 116 } 117 118 disconnect(): void { 119 this.state.connected = false 120 this.state.connectedAt = null 121 this.state.operations = [] 122 } 123 124 isConnected(): boolean { 125 return this.state.connected 126 } 127 128 // -- Org data -- 129 130 setOrgData(org: string, data: Partial<MockOrgData>): void { 131 const existing = this.state.orgs[org] ?? { users: {}, teams: [], teamMembers: {} } 132 this.state.orgs[org] = { 133 users: { ...existing.users, ...data.users }, 134 teams: data.teams ?? existing.teams, 135 teamMembers: { ...existing.teamMembers, ...data.teamMembers }, 136 } 137 } 138 139 getOrgUsers(org: string): Record<string, OrgRole> | null { 140 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 141 return this.state.orgs[normalizedOrg]?.users ?? null 142 } 143 144 getOrgTeams(org: string): string[] | null { 145 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 146 return this.state.orgs[normalizedOrg]?.teams ?? null 147 } 148 149 getTeamUsers(scope: string, team: string): string[] | null { 150 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 151 const org = this.state.orgs[normalizedScope] 152 if (!org) return null 153 return org.teamMembers[team] ?? null 154 } 155 156 // -- Package data -- 157 158 setPackageData(pkg: string, data: MockPackageData): void { 159 this.state.packages[pkg] = data 160 } 161 162 getPackageCollaborators(pkg: string): Record<string, AccessPermission> | null { 163 return this.state.packages[pkg]?.collaborators ?? null 164 } 165 166 // -- User data -- 167 168 setUserPackages(packages: Record<string, AccessPermission>): void { 169 this.state.userPackages = packages 170 } 171 172 setUserOrgs(orgs: string[]): void { 173 this.state.userOrgs = orgs 174 } 175 176 getUserPackages(): Record<string, AccessPermission> { 177 return this.state.userPackages 178 } 179 180 getUserOrgs(): string[] { 181 return this.state.userOrgs 182 } 183 184 // -- Operations queue -- 185 186 addOperation(operation: NewOperationInput): PendingOperation { 187 const id = `op-${++this.state.operationIdCounter}` 188 const newOp: PendingOperation = { 189 id, 190 type: operation.type, 191 params: operation.params, 192 description: operation.description, 193 command: operation.command, 194 status: 'pending', 195 createdAt: Date.now(), 196 dependsOn: operation.dependsOn, 197 } 198 this.state.operations.push(newOp) 199 return newOp 200 } 201 202 addOperations(operations: NewOperationInput[]): PendingOperation[] { 203 return operations.map(op => this.addOperation(op)) 204 } 205 206 getOperation(id: string): PendingOperation | undefined { 207 return this.state.operations.find(op => op.id === id) 208 } 209 210 getOperations(): PendingOperation[] { 211 return this.state.operations 212 } 213 214 removeOperation(id: string): boolean { 215 const index = this.state.operations.findIndex(op => op.id === id) 216 if (index === -1) return false 217 const op = this.state.operations[index] 218 // Can't remove running operations 219 if (op?.status === 'running') return false 220 this.state.operations.splice(index, 1) 221 return true 222 } 223 224 clearOperations(): number { 225 const removable = this.state.operations.filter(op => op.status !== 'running') 226 const count = removable.length 227 this.state.operations = this.state.operations.filter(op => op.status === 'running') 228 return count 229 } 230 231 approveOperation(id: string): PendingOperation | null { 232 const op = this.state.operations.find(op => op.id === id) 233 if (!op || op.status !== 'pending') return null 234 op.status = 'approved' 235 return op 236 } 237 238 approveAll(): number { 239 let count = 0 240 for (const op of this.state.operations) { 241 if (op.status === 'pending') { 242 op.status = 'approved' 243 count++ 244 } 245 } 246 return count 247 } 248 249 retryOperation(id: string): PendingOperation | null { 250 const op = this.state.operations.find(op => op.id === id) 251 if (!op || op.status !== 'failed') return null 252 op.status = 'approved' 253 op.result = undefined 254 return op 255 } 256 257 /** Execute all approved operations (mock: instant success unless configured otherwise). */ 258 executeOperations(options?: ExecuteOptions): ExecuteResult { 259 const results: Array<{ id: string; result: OperationResult }> = [] 260 const approved = this.state.operations.filter(op => op.status === 'approved') 261 262 // Sort by dependencies 263 const sorted = this.sortByDependencies(approved) 264 265 for (const op of sorted) { 266 // Check if dependent operation completed successfully 267 if (op.dependsOn) { 268 const dep = this.state.operations.find(d => d.id === op.dependsOn) 269 if (!dep || dep.status !== 'completed') { 270 // Skip - dependency not met 271 continue 272 } 273 } 274 275 op.status = 'running' 276 277 // Check for configured result 278 const configuredResult = options?.results?.[op.id] 279 if (configuredResult) { 280 const result: OperationResult = { 281 stdout: configuredResult.stdout ?? '', 282 stderr: configuredResult.stderr ?? '', 283 exitCode: configuredResult.exitCode ?? 1, 284 requiresOtp: configuredResult.requiresOtp, 285 authFailure: configuredResult.authFailure, 286 urls: configuredResult.urls, 287 } 288 op.result = result 289 op.status = result.exitCode === 0 ? 'completed' : 'failed' 290 results.push({ id: op.id, result }) 291 292 if (result.requiresOtp && !options?.otp) { 293 return { results, otpRequired: true } 294 } 295 } else { 296 // Default: success 297 const result: OperationResult = { 298 stdout: `Mock: ${op.command}`, 299 stderr: '', 300 exitCode: 0, 301 } 302 op.result = result 303 op.status = 'completed' 304 results.push({ id: op.id, result }) 305 306 // Apply the operation's effects to mock state 307 this.applyOperationEffect(op) 308 } 309 } 310 311 const authFailure = results.some(r => r.result.authFailure) 312 const allUrls = results.flatMap(r => r.result.urls ?? []) 313 const urls = [...new Set(allUrls)] 314 315 return { 316 results, 317 authFailure: authFailure || undefined, 318 urls: urls.length > 0 ? urls : undefined, 319 } 320 } 321 322 /** Apply side effects of a completed operation. Param keys match schemas.ts. */ 323 private applyOperationEffect(op: PendingOperation): void { 324 const { type, params } = op 325 326 switch (type) { 327 case 'org:add-user': { 328 // Params: { org, user, role } — OrgAddUserParamsSchema 329 const org = params['org'] 330 const user = params['user'] 331 const role = (params['role'] as OrgRole) ?? 'developer' 332 if (org && user) { 333 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 334 if (!this.state.orgs[normalizedOrg]) { 335 this.state.orgs[normalizedOrg] = { users: {}, teams: [], teamMembers: {} } 336 } 337 this.state.orgs[normalizedOrg].users[user] = role 338 } 339 break 340 } 341 case 'org:rm-user': { 342 // Params: { org, user } — OrgRemoveUserParamsSchema 343 const org = params['org'] 344 const user = params['user'] 345 if (org && user) { 346 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 347 if (this.state.orgs[normalizedOrg]) { 348 delete this.state.orgs[normalizedOrg].users[user] 349 } 350 } 351 break 352 } 353 case 'org:set-role': { 354 // Params: { org, user, role } — reuses OrgAddUserParamsSchema 355 const org = params['org'] 356 const user = params['user'] 357 const role = params['role'] as OrgRole 358 if (org && user && role) { 359 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 360 if (this.state.orgs[normalizedOrg]) { 361 this.state.orgs[normalizedOrg].users[user] = role 362 } 363 } 364 break 365 } 366 case 'team:create': { 367 // Params: { scopeTeam } — TeamCreateParamsSchema 368 const scopeTeam = params['scopeTeam'] 369 if (scopeTeam) { 370 const [scope, team] = scopeTeam.split(':') 371 if (scope && team) { 372 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 373 if (!this.state.orgs[normalizedScope]) { 374 this.state.orgs[normalizedScope] = { users: {}, teams: [], teamMembers: {} } 375 } 376 if (!this.state.orgs[normalizedScope].teams.includes(team)) { 377 this.state.orgs[normalizedScope].teams.push(team) 378 } 379 this.state.orgs[normalizedScope].teamMembers[team] = [] 380 } 381 } 382 break 383 } 384 case 'team:destroy': { 385 // Params: { scopeTeam } — TeamDestroyParamsSchema 386 const scopeTeam = params['scopeTeam'] 387 if (scopeTeam) { 388 const [scope, team] = scopeTeam.split(':') 389 if (scope && team) { 390 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 391 if (this.state.orgs[normalizedScope]) { 392 this.state.orgs[normalizedScope].teams = this.state.orgs[ 393 normalizedScope 394 ].teams.filter(t => t !== team) 395 delete this.state.orgs[normalizedScope].teamMembers[team] 396 } 397 } 398 } 399 break 400 } 401 case 'team:add-user': { 402 // Params: { scopeTeam, user } — TeamAddUserParamsSchema 403 const scopeTeam = params['scopeTeam'] 404 const user = params['user'] 405 if (scopeTeam && user) { 406 const [scope, team] = scopeTeam.split(':') 407 if (scope && team) { 408 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 409 if (this.state.orgs[normalizedScope]) { 410 const members = this.state.orgs[normalizedScope].teamMembers[team] ?? [] 411 if (!members.includes(user)) { 412 members.push(user) 413 } 414 this.state.orgs[normalizedScope].teamMembers[team] = members 415 } 416 } 417 } 418 break 419 } 420 case 'team:rm-user': { 421 // Params: { scopeTeam, user } — TeamRemoveUserParamsSchema 422 const scopeTeam = params['scopeTeam'] 423 const user = params['user'] 424 if (scopeTeam && user) { 425 const [scope, team] = scopeTeam.split(':') 426 if (scope && team) { 427 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 428 if (this.state.orgs[normalizedScope]) { 429 const members = this.state.orgs[normalizedScope].teamMembers[team] 430 if (members) { 431 this.state.orgs[normalizedScope].teamMembers[team] = members.filter(u => u !== user) 432 } 433 } 434 } 435 } 436 break 437 } 438 case 'access:grant': { 439 // Params: { permission, scopeTeam, pkg } — AccessGrantParamsSchema 440 const pkg = params['pkg'] 441 const scopeTeam = params['scopeTeam'] 442 const permission = (params['permission'] as AccessPermission) ?? 'read-write' 443 if (pkg && scopeTeam) { 444 if (!this.state.packages[pkg]) { 445 this.state.packages[pkg] = { collaborators: {} } 446 } 447 this.state.packages[pkg].collaborators[scopeTeam] = permission 448 } 449 break 450 } 451 case 'access:revoke': { 452 // Params: { scopeTeam, pkg } — AccessRevokeParamsSchema 453 const pkg = params['pkg'] 454 const scopeTeam = params['scopeTeam'] 455 if (pkg && scopeTeam && this.state.packages[pkg]) { 456 delete this.state.packages[pkg].collaborators[scopeTeam] 457 } 458 break 459 } 460 case 'owner:add': { 461 // Params: { user, pkg } — OwnerAddParamsSchema 462 const pkg = params['pkg'] 463 const user = params['user'] 464 if (pkg && user) { 465 if (!this.state.packages[pkg]) { 466 this.state.packages[pkg] = { collaborators: {} } 467 } 468 this.state.packages[pkg].collaborators[user] = 'read-write' 469 } 470 break 471 } 472 case 'owner:rm': { 473 // Params: { user, pkg } — OwnerRemoveParamsSchema 474 const pkg = params['pkg'] 475 const user = params['user'] 476 if (pkg && user && this.state.packages[pkg]) { 477 delete this.state.packages[pkg].collaborators[user] 478 } 479 break 480 } 481 case 'package:init': { 482 // Params: { name, author? } — PackageInitParamsSchema 483 const name = params['name'] 484 if (name) { 485 this.state.packages[name] = { 486 collaborators: { [this.state.config.npmUser]: 'read-write' }, 487 } 488 this.state.userPackages[name] = 'read-write' 489 } 490 break 491 } 492 } 493 } 494 495 /** Topological sort by dependsOn. */ 496 private sortByDependencies(operations: PendingOperation[]): PendingOperation[] { 497 const result: PendingOperation[] = [] 498 const visited = new Set<string>() 499 500 const visit = (op: PendingOperation) => { 501 if (visited.has(op.id)) return 502 visited.add(op.id) 503 504 if (op.dependsOn) { 505 const dep = operations.find(d => d.id === op.dependsOn) 506 if (dep) visit(dep) 507 } 508 509 result.push(op) 510 } 511 512 for (const op of operations) { 513 visit(op) 514 } 515 516 return result 517 } 518 519 reset(): void { 520 this.state.connected = false 521 this.state.connectedAt = null 522 this.state.orgs = {} 523 this.state.packages = {} 524 this.state.userPackages = {} 525 this.state.userOrgs = [] 526 this.state.operations = [] 527 this.state.operationIdCounter = 0 528 } 529} 530 531/** @internal */ 532export const DEFAULT_MOCK_CONFIG: MockConnectorConfig = { 533 token: 'test-token-e2e-12345', 534 npmUser: 'testuser', 535 avatar: null, 536 port: 31415, 537}