diff --git a/cli/package.json b/cli/package.json index 946e975873..d6025c37a3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,6 +37,7 @@ "obug": "^2.1.1", "picocolors": "^1.1.1", "srvx": "^0.10.1", + "valibot": "^1.2.0", "validate-npm-package-name": "^7.0.2" }, "devDependencies": { diff --git a/cli/src/npm-client.ts b/cli/src/npm-client.ts index 9fc4560922..f0a22c4aba 100644 --- a/cli/src/npm-client.ts +++ b/cli/src/npm-client.ts @@ -5,24 +5,21 @@ import { promisify } from 'node:util' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import validateNpmPackageName from 'validate-npm-package-name' +import * as v from 'valibot' +import { PackageNameSchema, UsernameSchema, OrgNameSchema, ScopeTeamSchema } from './schemas.ts' import { logCommand, logSuccess, logError } from './logger.ts' const execFileAsync = promisify(execFile) -// Validation pattern for npm usernames/org names -// These follow similar rules: lowercase alphanumeric with hyphens, can't start/end with hyphen -const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i - /** * Validates an npm package name using the official npm validation package * @throws Error if the name is invalid */ export function validatePackageName(name: string): void { - const result = validateNpmPackageName(name) - if (!result.validForNewPackages && !result.validForOldPackages) { - const errors = result.errors || result.warnings || ['Invalid package name'] - throw new Error(`Invalid package name "${name}": ${errors.join(', ')}`) + const result = v.safeParse(PackageNameSchema, name) + if (!result.success) { + const message = result.issues[0]?.message || 'Invalid package name' + throw new Error(`Invalid package name "${name}": ${message}`) } } @@ -31,7 +28,8 @@ export function validatePackageName(name: string): void { * @throws Error if the username is invalid */ export function validateUsername(name: string): void { - if (!name || name.length > 50 || !NPM_USERNAME_RE.test(name)) { + const result = v.safeParse(UsernameSchema, name) + if (!result.success) { throw new Error(`Invalid username: ${name}`) } } @@ -41,7 +39,8 @@ export function validateUsername(name: string): void { * @throws Error if the org name is invalid */ export function validateOrgName(name: string): void { - if (!name || name.length > 50 || !NPM_USERNAME_RE.test(name)) { + const result = v.safeParse(OrgNameSchema, name) + if (!result.success) { throw new Error(`Invalid org name: ${name}`) } } @@ -51,20 +50,9 @@ export function validateOrgName(name: string): void { * @throws Error if the scope:team is invalid */ export function validateScopeTeam(scopeTeam: string): void { - if (!scopeTeam || scopeTeam.length > 100) { - throw new Error(`Invalid scope:team: ${scopeTeam}`) - } - // Format: @scope:team - const match = scopeTeam.match(/^@([^:]+):(.+)$/) - if (!match) { - throw new Error(`Invalid scope:team format: ${scopeTeam}`) - } - const [, scope, team] = match - if (!scope || !NPM_USERNAME_RE.test(scope)) { - throw new Error(`Invalid scope in scope:team: ${scopeTeam}`) - } - if (!team || !NPM_USERNAME_RE.test(team)) { - throw new Error(`Invalid team name in scope:team: ${scopeTeam}`) + const result = v.safeParse(ScopeTeamSchema, scopeTeam) + if (!result.success) { + throw new Error(`Invalid scope:team format: ${scopeTeam}. Expected @scope:team`) } } diff --git a/cli/src/schemas.ts b/cli/src/schemas.ts new file mode 100644 index 0000000000..ce531d5740 --- /dev/null +++ b/cli/src/schemas.ts @@ -0,0 +1,344 @@ +import * as v from 'valibot' +import validateNpmPackageName from 'validate-npm-package-name' + +// Validation pattern for npm usernames/org names +// These follow similar rules: lowercase alphanumeric with hyphens, can't start/end with hyphen +const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i + +// ============================================================================ +// Base Schemas +// ============================================================================ + +/** + * Validates an npm package name using the official npm validation package + * Accepts both new and legacy package name formats + */ +export const PackageNameSchema = v.pipe( + v.string(), + v.nonEmpty('Package name is required'), + v.check(input => { + const result = validateNpmPackageName(input) + return result.validForNewPackages || result.validForOldPackages + }, 'Invalid package name format'), +) + +/** + * Validates an npm package name for new packages only + * Stricter than PackageNameSchema - rejects legacy formats (uppercase, etc.) + */ +export const NewPackageNameSchema = v.pipe( + v.string(), + v.nonEmpty('Package name is required'), + v.check(input => { + const result = validateNpmPackageName(input) + return result.validForNewPackages === true + }, 'Invalid package name format. New packages must be lowercase and follow npm naming conventions.'), +) + +/** + * Validates an npm username + * Must be alphanumeric with hyphens, max 50 chars, can't start/end with hyphen + */ +export const UsernameSchema = v.pipe( + v.string(), + v.nonEmpty('Username is required'), + v.maxLength(50, 'Username is too long'), + v.regex(NPM_USERNAME_RE, 'Invalid username format'), +) + +/** + * Validates an npm org name (without the @ prefix) + * Same rules as username + */ +export const OrgNameSchema = v.pipe( + v.string(), + v.nonEmpty('Org name is required'), + v.maxLength(50, 'Org name is too long'), + v.regex(NPM_USERNAME_RE, 'Invalid org name format'), +) + +/** + * Validates a team name (without scope prefix) + */ +export const TeamNameSchema = v.pipe( + v.string(), + v.nonEmpty('Team name is required'), + v.maxLength(50, 'Team name is too long'), + v.regex(NPM_USERNAME_RE, 'Invalid team name format'), +) + +/** + * Validates a scope:team format (e.g., @myorg:developers) + */ +export const ScopeTeamSchema = v.pipe( + v.string(), + v.nonEmpty('Scope:team is required'), + v.maxLength(100, 'Scope:team is too long'), + v.check(input => { + const match = input.match(/^@([^:]+):(.+)$/) + if (!match) return false + const [, scope, team] = match + if (!scope || !NPM_USERNAME_RE.test(scope)) return false + if (!team || !NPM_USERNAME_RE.test(team)) return false + return true + }, 'Invalid scope:team format. Expected @scope:team'), +) + +/** + * Validates org roles + */ +export const OrgRoleSchema = v.picklist( + ['developer', 'admin', 'owner'], + 'Invalid role. Must be developer, admin, or owner', +) + +/** + * Validates access permissions + */ +export const PermissionSchema = v.picklist( + ['read-only', 'read-write'], + 'Invalid permission. Must be read-only or read-write', +) + +/** + * Validates operation types + */ +export const OperationTypeSchema = v.picklist([ + 'org:add-user', + 'org:rm-user', + 'org:set-role', + 'team:create', + 'team:destroy', + 'team:add-user', + 'team:rm-user', + 'access:grant', + 'access:revoke', + 'owner:add', + 'owner:rm', + 'package:init', +]) + +/** + * Validates operation status + */ +export const OperationStatusSchema = v.picklist([ + 'pending', + 'approved', + 'running', + 'completed', + 'failed', + 'cancelled', +]) + +/** + * Validates OTP (6-digit code) + */ +export const OtpSchema = v.optional( + v.pipe(v.string(), v.regex(/^\d{6}$/, 'OTP must be a 6-digit code')), +) + +/** + * Validates a hex token (like session tokens and operation IDs) + */ +export const HexTokenSchema = v.pipe( + v.string(), + v.nonEmpty('Token is required'), + v.regex(/^[a-f0-9]+$/i, 'Invalid token format'), +) + +/** + * Validates operation ID (16-char hex) + */ +export const OperationIdSchema = v.pipe( + v.string(), + v.nonEmpty('Operation ID is required'), + v.regex(/^[a-f0-9]{16}$/i, 'Invalid operation ID format'), +) + +// ============================================================================ +// Request Body Schemas +// ============================================================================ + +/** + * Schema for /connect request body + */ +export const ConnectBodySchema = v.object({ + token: HexTokenSchema, +}) + +/** + * Schema for /execute request body + */ +export const ExecuteBodySchema = v.object({ + otp: OtpSchema, +}) + +/** + * Schema for operation params based on type + * Validates the params object for each operation type + */ +export const OperationParamsSchema = v.record(v.string(), v.string()) + +/** + * Schema for single operation request body + */ +export const CreateOperationBodySchema = v.object({ + type: OperationTypeSchema, + params: OperationParamsSchema, + description: v.pipe(v.string(), v.nonEmpty('Description is required'), v.maxLength(500)), + command: v.pipe(v.string(), v.nonEmpty('Command is required'), v.maxLength(1000)), +}) + +/** + * Schema for batch operation request body + */ +export const BatchOperationsBodySchema = v.array(CreateOperationBodySchema) + +// ============================================================================ +// Type-specific Operation Params Schemas +// ============================================================================ + +export const OrgAddUserParamsSchema = v.object({ + org: OrgNameSchema, + user: UsernameSchema, + role: OrgRoleSchema, +}) + +export const OrgRemoveUserParamsSchema = v.object({ + org: OrgNameSchema, + user: UsernameSchema, +}) + +export const TeamCreateParamsSchema = v.object({ + scopeTeam: ScopeTeamSchema, +}) + +export const TeamDestroyParamsSchema = v.object({ + scopeTeam: ScopeTeamSchema, +}) + +export const TeamAddUserParamsSchema = v.object({ + scopeTeam: ScopeTeamSchema, + user: UsernameSchema, +}) + +export const TeamRemoveUserParamsSchema = v.object({ + scopeTeam: ScopeTeamSchema, + user: UsernameSchema, +}) + +export const AccessGrantParamsSchema = v.object({ + permission: PermissionSchema, + scopeTeam: ScopeTeamSchema, + pkg: PackageNameSchema, +}) + +export const AccessRevokeParamsSchema = v.object({ + scopeTeam: ScopeTeamSchema, + pkg: PackageNameSchema, +}) + +export const OwnerAddParamsSchema = v.object({ + user: UsernameSchema, + pkg: PackageNameSchema, +}) + +export const OwnerRemoveParamsSchema = v.object({ + user: UsernameSchema, + pkg: PackageNameSchema, +}) + +export const PackageInitParamsSchema = v.object({ + name: NewPackageNameSchema, + author: v.optional(UsernameSchema), +}) + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Validates operation params based on operation type + * @throws ValiError if validation fails + */ +export function validateOperationParams( + type: v.InferOutput, + params: Record, +): void { + switch (type) { + case 'org:add-user': + v.parse(OrgAddUserParamsSchema, params) + break + case 'org:rm-user': + v.parse(OrgRemoveUserParamsSchema, params) + break + case 'org:set-role': + v.parse(OrgAddUserParamsSchema, params) // same params as add-user + break + case 'team:create': + v.parse(TeamCreateParamsSchema, params) + break + case 'team:destroy': + v.parse(TeamDestroyParamsSchema, params) + break + case 'team:add-user': + v.parse(TeamAddUserParamsSchema, params) + break + case 'team:rm-user': + v.parse(TeamRemoveUserParamsSchema, params) + break + case 'access:grant': + v.parse(AccessGrantParamsSchema, params) + break + case 'access:revoke': + v.parse(AccessRevokeParamsSchema, params) + break + case 'owner:add': + v.parse(OwnerAddParamsSchema, params) + break + case 'owner:rm': + v.parse(OwnerRemoveParamsSchema, params) + break + case 'package:init': + v.parse(PackageInitParamsSchema, params) + break + } +} + +/** + * Safe parse wrapper that returns a formatted error message + */ +export function safeParse( + schema: T, + input: unknown, +): { success: true; data: v.InferOutput } | { success: false; error: string } { + const result = v.safeParse(schema, input) + if (result.success) { + return { success: true, data: result.output } + } + // Format the first error message + const firstIssue = result.issues[0] + const path = firstIssue?.path?.map(p => p.key).join('.') || '' + const message = firstIssue?.message || 'Validation failed' + return { + success: false, + error: path ? `${path}: ${message}` : message, + } +} + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type PackageName = v.InferOutput +export type NewPackageName = v.InferOutput +export type Username = v.InferOutput +export type OrgName = v.InferOutput +export type ScopeTeam = v.InferOutput +export type OrgRole = v.InferOutput +export type Permission = v.InferOutput +export type OperationType = v.InferOutput +export type OperationStatus = v.InferOutput +export type ConnectBody = v.InferOutput +export type ExecuteBody = v.InferOutput +export type CreateOperationBody = v.InferOutput diff --git a/cli/src/server.ts b/cli/src/server.ts index 4034820b42..2ff477ec7c 100644 --- a/cli/src/server.ts +++ b/cli/src/server.ts @@ -1,8 +1,9 @@ import crypto from 'node:crypto' import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' import type { CorsOptions } from 'h3-next' +import * as v from 'valibot' -import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts' +import type { ConnectorState, PendingOperation, ApiResponse } from './types.ts' import { logDebug, logError } from './logger.ts' import { getNpmUser, @@ -22,9 +23,20 @@ import { ownerAdd, ownerRemove, packageInit, - validateScopeTeam, type NpmExecResult, } from './npm-client.ts' +import { + ConnectBodySchema, + ExecuteBodySchema, + CreateOperationBodySchema, + BatchOperationsBodySchema, + OrgNameSchema, + ScopeTeamSchema, + PackageNameSchema, + OperationIdSchema, + safeParse, + validateOperationParams, +} from './schemas.ts' // Read version from package.json import pkg from '../package.json' with { type: 'json' } @@ -73,8 +85,13 @@ export function createConnectorApp(expectedToken: string) { } app.post('/connect', async (event: H3Event) => { - const body = (await event.req.json()) as { token?: string } - if (body?.token !== expectedToken) { + const rawBody = await event.req.json() + const parsed = safeParse(ConnectBodySchema, rawBody) + if (!parsed.success) { + throw new HTTPError({ statusCode: 400, message: parsed.error }) + } + + if (parsed.data.token !== expectedToken) { throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) } @@ -115,13 +132,21 @@ export function createConnectorApp(expectedToken: string) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } - const body = (await event.req.json()) as { - type: OperationType - params: Record - description: string - command: string + const rawBody = await event.req.json() + const parsed = safeParse(CreateOperationBodySchema, rawBody) + if (!parsed.success) { + throw new HTTPError({ statusCode: 400, message: parsed.error }) + } + + const { type, params, description, command } = parsed.data + + // Validate params based on operation type + try { + validateOperationParams(type, params) + } catch (err) { + const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) + throw new HTTPError({ statusCode: 400, message: `Invalid params: ${message}` }) } - const { type, params, description, command } = body const operation: PendingOperation = { id: generateOperationId(), @@ -147,15 +172,29 @@ export function createConnectorApp(expectedToken: string) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } - const operations = (await event.req.json()) as Array<{ - type: OperationType - params: Record - description: string - command: string - }> + const rawBody = await event.req.json() + const parsed = safeParse(BatchOperationsBodySchema, rawBody) + if (!parsed.success) { + throw new HTTPError({ statusCode: 400, message: parsed.error }) + } + + // Validate each operation's params + for (let i = 0; i < parsed.data.length; i++) { + const op = parsed.data[i] + if (!op) continue + try { + validateOperationParams(op.type, op.params) + } catch (err) { + const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) + throw new HTTPError({ + statusCode: 400, + message: `Operation ${i}: Invalid params: ${message}`, + }) + } + } const created: PendingOperation[] = [] - for (const op of operations) { + for (const op of parsed.data) { const operation: PendingOperation = { id: generateOperationId(), type: op.type, @@ -184,7 +223,12 @@ export function createConnectorApp(expectedToken: string) { const url = new URL(event.req.url) const id = url.searchParams.get('id') - const operation = state.operations.find(op => op.id === id) + const idValidation = safeParse(OperationIdSchema, id) + if (!idValidation.success) { + throw new HTTPError({ statusCode: 400, message: idValidation.error }) + } + + const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } @@ -227,7 +271,12 @@ export function createConnectorApp(expectedToken: string) { const url = new URL(event.req.url) const id = url.searchParams.get('id') - const operation = state.operations.find(op => op.id === id) + const idValidation = safeParse(OperationIdSchema, id) + if (!idValidation.success) { + throw new HTTPError({ statusCode: 400, message: idValidation.error }) + } + + const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } @@ -255,10 +304,17 @@ export function createConnectorApp(expectedToken: string) { // OTP can be passed directly in the request body for this execution let otp: string | undefined try { - const body = (await event.req.json()) as { otp?: string } | null - otp = body?.otp - } catch { - // Empty body is fine - no OTP provided + const rawBody = await event.req.json() + if (rawBody) { + const parsed = safeParse(ExecuteBodySchema, rawBody) + if (!parsed.success) { + throw new HTTPError({ statusCode: 400, message: parsed.error }) + } + otp = parsed.data.otp + } + } catch (err) { + // Re-throw HTTPError, ignore JSON parse errors (empty body is fine) + if (err instanceof HTTPError) throw err } const approvedOps = state.operations.filter(op => op.status === 'approved') @@ -342,7 +398,12 @@ export function createConnectorApp(expectedToken: string) { const url = new URL(event.req.url) const id = url.searchParams.get('id') - const index = state.operations.findIndex(op => op.id === id) + const idValidation = safeParse(OperationIdSchema, id) + if (!idValidation.success) { + throw new HTTPError({ statusCode: 400, message: idValidation.error }) + } + + const index = state.operations.findIndex(op => op.id === idValidation.data) if (index === -1) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } @@ -380,12 +441,13 @@ export function createConnectorApp(expectedToken: string) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } - const org = event.context.params?.org - if (!org) { - throw new HTTPError({ statusCode: 400, message: 'Org name required' }) + const orgRaw = event.context.params?.org + const orgValidation = safeParse(OrgNameSchema, orgRaw) + if (!orgValidation.success) { + throw new HTTPError({ statusCode: 400, message: orgValidation.error }) } - const result = await orgListUsers(org) + const result = await orgListUsers(orgValidation.data) if (result.exitCode !== 0) { return { success: false, @@ -413,12 +475,13 @@ export function createConnectorApp(expectedToken: string) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } - const org = event.context.params?.org - if (!org) { - throw new HTTPError({ statusCode: 400, message: 'Org name required' }) + const orgRaw = event.context.params?.org + const orgValidation = safeParse(OrgNameSchema, orgRaw) + if (!orgValidation.success) { + throw new HTTPError({ statusCode: 400, message: orgValidation.error }) } - const result = await teamListTeams(org) + const result = await teamListTeams(orgValidation.data) if (result.exitCode !== 0) { return { success: false, @@ -454,11 +517,10 @@ export function createConnectorApp(expectedToken: string) { // Decode the team name (handles encoded colons like nuxt%3Adevelopers) const scopeTeam = decodeURIComponent(scopeTeamRaw) - try { - validateScopeTeam(scopeTeam) - } catch (err) { + const validationResult = safeParse(ScopeTeamSchema, scopeTeam) + if (!validationResult.success) { logError('scope:team validation failed') - logDebug(err, { scopeTeamRaw, scopeTeam }) + logDebug(validationResult.error, { scopeTeamRaw, scopeTeam }) throw new HTTPError({ statusCode: 400, message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`, @@ -493,15 +555,20 @@ export function createConnectorApp(expectedToken: string) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } - const pkg = event.context.params?.pkg - if (!pkg) { + const pkgRaw = event.context.params?.pkg + if (!pkgRaw) { throw new HTTPError({ statusCode: 400, message: 'Package name required' }) } // Decode the package name (handles scoped packages like @nuxt%2Fkit) - const decodedPkg = decodeURIComponent(pkg) + const decodedPkg = decodeURIComponent(pkgRaw) + + const pkgValidation = safeParse(PackageNameSchema, decodedPkg) + if (!pkgValidation.success) { + throw new HTTPError({ statusCode: 400, message: pkgValidation.error }) + } - const result = await accessListCollaborators(decodedPkg) + const result = await accessListCollaborators(pkgValidation.data) if (result.exitCode !== 0) { return { success: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 349b1b1698..e969de7362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: srvx: specifier: ^0.10.1 version: 0.10.1 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) validate-npm-package-name: specifier: ^7.0.2 version: 7.0.2 diff --git a/shared/schemas/package.ts b/shared/schemas/package.ts index 386d161066..355b138d94 100644 --- a/shared/schemas/package.ts +++ b/shared/schemas/package.ts @@ -3,6 +3,7 @@ import validateNpmPackageName from 'validate-npm-package-name' /** * Enforces only valid NPM package names + * Accepts both new and legacy package name formats * Leverages 'validate-npm-package-name' */ export const PackageNameSchema = v.pipe( diff --git a/test/unit/cli-schemas.spec.ts b/test/unit/cli-schemas.spec.ts new file mode 100644 index 0000000000..b3584723e2 --- /dev/null +++ b/test/unit/cli-schemas.spec.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest' +import * as v from 'valibot' +import { + PackageNameSchema, + NewPackageNameSchema, + UsernameSchema, + OrgNameSchema, + ScopeTeamSchema, + OrgRoleSchema, + PermissionSchema, + OperationTypeSchema, + OtpSchema, + HexTokenSchema, + OperationIdSchema, + ConnectBodySchema, + ExecuteBodySchema, + CreateOperationBodySchema, + BatchOperationsBodySchema, + OrgAddUserParamsSchema, + AccessGrantParamsSchema, + PackageInitParamsSchema, + safeParse, + validateOperationParams, +} from '../../cli/src/schemas.ts' + +describe('PackageNameSchema', () => { + it('accepts valid package names', () => { + expect(v.safeParse(PackageNameSchema, 'my-package').success).toBe(true) + expect(v.safeParse(PackageNameSchema, '@scope/package').success).toBe(true) + expect(v.safeParse(PackageNameSchema, 'package123').success).toBe(true) + expect(v.safeParse(PackageNameSchema, 'lodash').success).toBe(true) + }) + + it('accepts legacy package names (uppercase)', () => { + // Legacy packages with uppercase are valid for old packages + expect(v.safeParse(PackageNameSchema, 'UPPERCASE').success).toBe(true) + }) + + it('rejects invalid package names', () => { + expect(v.safeParse(PackageNameSchema, '').success).toBe(false) + expect(v.safeParse(PackageNameSchema, '.package').success).toBe(false) + expect(v.safeParse(PackageNameSchema, '_package').success).toBe(false) + expect(v.safeParse(PackageNameSchema, ' spaces ').success).toBe(false) + }) +}) + +describe('NewPackageNameSchema', () => { + it('accepts valid new package names', () => { + expect(v.safeParse(NewPackageNameSchema, 'my-package').success).toBe(true) + expect(v.safeParse(NewPackageNameSchema, '@scope/package').success).toBe(true) + expect(v.safeParse(NewPackageNameSchema, 'package123').success).toBe(true) + expect(v.safeParse(NewPackageNameSchema, 'lodash').success).toBe(true) + }) + + it('rejects legacy package name formats', () => { + // New packages must be lowercase + expect(v.safeParse(NewPackageNameSchema, 'UPPERCASE').success).toBe(false) + expect(v.safeParse(NewPackageNameSchema, 'MixedCase').success).toBe(false) + }) + + it('rejects invalid package names', () => { + expect(v.safeParse(NewPackageNameSchema, '').success).toBe(false) + expect(v.safeParse(NewPackageNameSchema, '.package').success).toBe(false) + expect(v.safeParse(NewPackageNameSchema, '_package').success).toBe(false) + expect(v.safeParse(NewPackageNameSchema, ' spaces ').success).toBe(false) + }) +}) + +describe('PackageInitParamsSchema', () => { + it('accepts valid new package names', () => { + expect(v.safeParse(PackageInitParamsSchema, { name: 'my-package' }).success).toBe(true) + expect( + v.safeParse(PackageInitParamsSchema, { name: '@scope/pkg', author: 'alice' }).success, + ).toBe(true) + }) + + it('rejects legacy package name formats for new packages', () => { + // Cannot create new packages with uppercase names + expect(v.safeParse(PackageInitParamsSchema, { name: 'UPPERCASE' }).success).toBe(false) + expect(v.safeParse(PackageInitParamsSchema, { name: 'MixedCase' }).success).toBe(false) + }) +}) + +describe('UsernameSchema', () => { + it('accepts valid usernames', () => { + expect(v.safeParse(UsernameSchema, 'alice').success).toBe(true) + expect(v.safeParse(UsernameSchema, 'bob123').success).toBe(true) + expect(v.safeParse(UsernameSchema, 'my-user').success).toBe(true) + }) + + it('rejects invalid usernames', () => { + expect(v.safeParse(UsernameSchema, '').success).toBe(false) + expect(v.safeParse(UsernameSchema, 'a'.repeat(51)).success).toBe(false) + expect(v.safeParse(UsernameSchema, '-user').success).toBe(false) + expect(v.safeParse(UsernameSchema, 'user-').success).toBe(false) + expect(v.safeParse(UsernameSchema, 'user name').success).toBe(false) + expect(v.safeParse(UsernameSchema, 'user;rm').success).toBe(false) + }) +}) + +describe('OrgNameSchema', () => { + it('accepts valid org names', () => { + expect(v.safeParse(OrgNameSchema, 'nuxt').success).toBe(true) + expect(v.safeParse(OrgNameSchema, 'my-org').success).toBe(true) + }) + + it('rejects invalid org names', () => { + expect(v.safeParse(OrgNameSchema, '').success).toBe(false) + expect(v.safeParse(OrgNameSchema, 'a'.repeat(51)).success).toBe(false) + }) +}) + +describe('ScopeTeamSchema', () => { + it('accepts valid scope:team format', () => { + expect(v.safeParse(ScopeTeamSchema, '@nuxt:developers').success).toBe(true) + expect(v.safeParse(ScopeTeamSchema, '@my-org:my-team').success).toBe(true) + expect(v.safeParse(ScopeTeamSchema, '@a:b').success).toBe(true) + }) + + it('rejects invalid scope:team format', () => { + expect(v.safeParse(ScopeTeamSchema, '').success).toBe(false) + expect(v.safeParse(ScopeTeamSchema, 'nuxt:developers').success).toBe(false) // missing @ + expect(v.safeParse(ScopeTeamSchema, '@nuxt').success).toBe(false) // missing :team + expect(v.safeParse(ScopeTeamSchema, '@:team').success).toBe(false) // empty scope + expect(v.safeParse(ScopeTeamSchema, '@org:').success).toBe(false) // empty team + expect(v.safeParse(ScopeTeamSchema, '@-org:team').success).toBe(false) // scope starts with hyphen + expect(v.safeParse(ScopeTeamSchema, '@org:-team').success).toBe(false) // team starts with hyphen + }) +}) + +describe('OrgRoleSchema', () => { + it('accepts valid roles', () => { + expect(v.safeParse(OrgRoleSchema, 'developer').success).toBe(true) + expect(v.safeParse(OrgRoleSchema, 'admin').success).toBe(true) + expect(v.safeParse(OrgRoleSchema, 'owner').success).toBe(true) + }) + + it('rejects invalid roles', () => { + expect(v.safeParse(OrgRoleSchema, 'user').success).toBe(false) + expect(v.safeParse(OrgRoleSchema, '').success).toBe(false) + expect(v.safeParse(OrgRoleSchema, 'ADMIN').success).toBe(false) + }) +}) + +describe('PermissionSchema', () => { + it('accepts valid permissions', () => { + expect(v.safeParse(PermissionSchema, 'read-only').success).toBe(true) + expect(v.safeParse(PermissionSchema, 'read-write').success).toBe(true) + }) + + it('rejects invalid permissions', () => { + expect(v.safeParse(PermissionSchema, 'write').success).toBe(false) + expect(v.safeParse(PermissionSchema, '').success).toBe(false) + }) +}) + +describe('OperationTypeSchema', () => { + it('accepts valid operation types', () => { + expect(v.safeParse(OperationTypeSchema, 'org:add-user').success).toBe(true) + expect(v.safeParse(OperationTypeSchema, 'team:create').success).toBe(true) + expect(v.safeParse(OperationTypeSchema, 'access:grant').success).toBe(true) + expect(v.safeParse(OperationTypeSchema, 'owner:add').success).toBe(true) + expect(v.safeParse(OperationTypeSchema, 'package:init').success).toBe(true) + }) + + it('rejects invalid operation types', () => { + expect(v.safeParse(OperationTypeSchema, 'invalid').success).toBe(false) + expect(v.safeParse(OperationTypeSchema, '').success).toBe(false) + }) +}) + +describe('OtpSchema', () => { + it('accepts valid OTP codes', () => { + const result = v.safeParse(OtpSchema, '123456') + expect(result.success).toBe(true) + expect(result.output).toBe('123456') + }) + + it('accepts undefined (optional)', () => { + const result = v.safeParse(OtpSchema, undefined) + expect(result.success).toBe(true) + expect(result.output).toBeUndefined() + }) + + it('rejects invalid OTP codes', () => { + expect(v.safeParse(OtpSchema, '12345').success).toBe(false) // too short + expect(v.safeParse(OtpSchema, '1234567').success).toBe(false) // too long + expect(v.safeParse(OtpSchema, 'abcdef').success).toBe(false) // not digits + expect(v.safeParse(OtpSchema, '').success).toBe(false) // empty + }) +}) + +describe('HexTokenSchema', () => { + it('accepts valid hex tokens', () => { + expect(v.safeParse(HexTokenSchema, 'abcd1234').success).toBe(true) + expect(v.safeParse(HexTokenSchema, 'ABCD1234').success).toBe(true) + expect(v.safeParse(HexTokenSchema, 'a1b2c3d4e5f6').success).toBe(true) + }) + + it('rejects invalid hex tokens', () => { + expect(v.safeParse(HexTokenSchema, '').success).toBe(false) + expect(v.safeParse(HexTokenSchema, 'ghij').success).toBe(false) // invalid hex chars + expect(v.safeParse(HexTokenSchema, 'abc-123').success).toBe(false) // contains hyphen + }) +}) + +describe('OperationIdSchema', () => { + it('accepts valid 16-char hex operation IDs', () => { + expect(v.safeParse(OperationIdSchema, 'abcd1234abcd1234').success).toBe(true) + expect(v.safeParse(OperationIdSchema, '0123456789abcdef').success).toBe(true) + }) + + it('rejects invalid operation IDs', () => { + expect(v.safeParse(OperationIdSchema, '').success).toBe(false) + expect(v.safeParse(OperationIdSchema, 'abcd1234').success).toBe(false) // too short + expect(v.safeParse(OperationIdSchema, 'abcd1234abcd1234abcd').success).toBe(false) // too long + expect(v.safeParse(OperationIdSchema, 'ghij1234abcd1234').success).toBe(false) // invalid hex + }) +}) + +describe('ConnectBodySchema', () => { + it('accepts valid connect body', () => { + const result = v.safeParse(ConnectBodySchema, { token: 'abcd1234' }) + expect(result.success).toBe(true) + }) + + it('rejects invalid connect body', () => { + expect(v.safeParse(ConnectBodySchema, {}).success).toBe(false) + expect(v.safeParse(ConnectBodySchema, { token: '' }).success).toBe(false) + expect(v.safeParse(ConnectBodySchema, { token: 'invalid!' }).success).toBe(false) + }) +}) + +describe('ExecuteBodySchema', () => { + it('accepts valid execute body with OTP', () => { + const result = v.safeParse(ExecuteBodySchema, { otp: '123456' }) + expect(result.success).toBe(true) + }) + + it('accepts execute body without OTP', () => { + const result = v.safeParse(ExecuteBodySchema, {}) + expect(result.success).toBe(true) + }) + + it('rejects invalid OTP', () => { + expect(v.safeParse(ExecuteBodySchema, { otp: '12345' }).success).toBe(false) + }) +}) + +describe('CreateOperationBodySchema', () => { + it('accepts valid operation body', () => { + const result = v.safeParse(CreateOperationBodySchema, { + type: 'org:add-user', + params: { org: 'nuxt', user: 'alice', role: 'developer' }, + description: 'Add alice to nuxt org', + command: 'npm org set nuxt alice developer', + }) + expect(result.success).toBe(true) + }) + + it('rejects missing required fields', () => { + expect(v.safeParse(CreateOperationBodySchema, {}).success).toBe(false) + expect(v.safeParse(CreateOperationBodySchema, { type: 'org:add-user' }).success).toBe(false) + }) + + it('rejects invalid type', () => { + expect( + v.safeParse(CreateOperationBodySchema, { + type: 'invalid', + params: {}, + description: 'test', + command: 'test', + }).success, + ).toBe(false) + }) +}) + +describe('BatchOperationsBodySchema', () => { + it('accepts array of valid operations', () => { + const result = v.safeParse(BatchOperationsBodySchema, [ + { + type: 'org:add-user', + params: { org: 'nuxt', user: 'alice', role: 'developer' }, + description: 'Add alice', + command: 'npm org set nuxt alice developer', + }, + { + type: 'org:add-user', + params: { org: 'nuxt', user: 'bob', role: 'admin' }, + description: 'Add bob', + command: 'npm org set nuxt bob admin', + }, + ]) + expect(result.success).toBe(true) + }) + + it('accepts empty array', () => { + expect(v.safeParse(BatchOperationsBodySchema, []).success).toBe(true) + }) + + it('rejects array with invalid operation', () => { + expect( + v.safeParse(BatchOperationsBodySchema, [ + { type: 'invalid', params: {}, description: 'test', command: 'test' }, + ]).success, + ).toBe(false) + }) +}) + +describe('OrgAddUserParamsSchema', () => { + it('accepts valid org:add-user params', () => { + const result = v.safeParse(OrgAddUserParamsSchema, { + org: 'nuxt', + user: 'alice', + role: 'developer', + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid params', () => { + expect(v.safeParse(OrgAddUserParamsSchema, {}).success).toBe(false) + expect(v.safeParse(OrgAddUserParamsSchema, { org: 'nuxt' }).success).toBe(false) + expect( + v.safeParse(OrgAddUserParamsSchema, { + org: 'nuxt', + user: 'alice', + role: 'invalid', + }).success, + ).toBe(false) + }) +}) + +describe('AccessGrantParamsSchema', () => { + it('accepts valid access:grant params', () => { + const result = v.safeParse(AccessGrantParamsSchema, { + permission: 'read-write', + scopeTeam: '@nuxt:developers', + pkg: '@nuxt/kit', + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid params', () => { + expect( + v.safeParse(AccessGrantParamsSchema, { + permission: 'write', // invalid permission + scopeTeam: '@nuxt:developers', + pkg: '@nuxt/kit', + }).success, + ).toBe(false) + }) +}) + +describe('safeParse helper', () => { + it('returns success with data for valid input', () => { + const result = safeParse(UsernameSchema, 'alice') + expect(result.success).toBe(true) + expect(result).toHaveProperty('data', 'alice') + }) + + it('returns error message for invalid input', () => { + const result = safeParse(UsernameSchema, '') + expect(result.success).toBe(false) + expect(result).toHaveProperty('error', 'Username is required') + }) + + it('includes path in error message for nested objects', () => { + const result = safeParse(OrgAddUserParamsSchema, { org: '', user: 'alice', role: 'developer' }) + expect(result.success).toBe(false) + expect((result as { error: string }).error).toContain('org') + }) +}) + +describe('validateOperationParams', () => { + it('validates org:add-user params', () => { + expect(() => + validateOperationParams('org:add-user', { + org: 'nuxt', + user: 'alice', + role: 'developer', + }), + ).not.toThrow() + }) + + it('throws for invalid org:add-user params', () => { + expect(() => validateOperationParams('org:add-user', { org: 'nuxt' })).toThrow('Invalid key') + }) + + it('validates team:create params', () => { + expect(() => + validateOperationParams('team:create', { + scopeTeam: '@nuxt:developers', + }), + ).not.toThrow() + }) + + it('validates access:grant params', () => { + expect(() => + validateOperationParams('access:grant', { + permission: 'read-write', + scopeTeam: '@nuxt:developers', + pkg: '@nuxt/kit', + }), + ).not.toThrow() + }) + + it('validates owner:add params', () => { + expect(() => + validateOperationParams('owner:add', { + user: 'alice', + pkg: '@nuxt/kit', + }), + ).not.toThrow() + }) + + it('validates package:init params', () => { + expect(() => + validateOperationParams('package:init', { + name: 'my-package', + }), + ).not.toThrow() + + expect(() => + validateOperationParams('package:init', { + name: 'my-package', + author: 'alice', + }), + ).not.toThrow() + }) +}) diff --git a/test/unit/cli-validation.spec.ts b/test/unit/cli-validation.spec.ts index a6baced7c2..669f0ad640 100644 --- a/test/unit/cli-validation.spec.ts +++ b/test/unit/cli-validation.spec.ts @@ -96,20 +96,20 @@ describe('validateScopeTeam', () => { }) it('rejects scope:team with shell injection in scope', () => { - expect(() => validateScopeTeam('@org;rm:team')).toThrow('Invalid scope in scope:team') - expect(() => validateScopeTeam('@$(whoami):team')).toThrow('Invalid scope in scope:team') + expect(() => validateScopeTeam('@org;rm:team')).toThrow('Invalid scope:team format') + expect(() => validateScopeTeam('@$(whoami):team')).toThrow('Invalid scope:team format') }) it('rejects scope:team with shell injection in team', () => { - expect(() => validateScopeTeam('@org:team;rm')).toThrow('Invalid team name in scope:team') - expect(() => validateScopeTeam('@org:$(whoami)')).toThrow('Invalid team name in scope:team') + expect(() => validateScopeTeam('@org:team;rm')).toThrow('Invalid scope:team format') + expect(() => validateScopeTeam('@org:$(whoami)')).toThrow('Invalid scope:team format') }) it('rejects scope or team starting/ending with hyphen', () => { - expect(() => validateScopeTeam('@-org:team')).toThrow('Invalid scope in scope:team') - expect(() => validateScopeTeam('@org-:team')).toThrow('Invalid scope in scope:team') - expect(() => validateScopeTeam('@org:-team')).toThrow('Invalid team name in scope:team') - expect(() => validateScopeTeam('@org:team-')).toThrow('Invalid team name in scope:team') + expect(() => validateScopeTeam('@-org:team')).toThrow('Invalid scope:team format') + expect(() => validateScopeTeam('@org-:team')).toThrow('Invalid scope:team format') + expect(() => validateScopeTeam('@org:-team')).toThrow('Invalid scope:team format') + expect(() => validateScopeTeam('@org:team-')).toThrow('Invalid scope:team format') }) })