diff --git a/src/specs.yaml b/src/specs.yaml index 01091d7..b0cd6d4 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -573,9 +573,8 @@ commands: description: Output file path. Defaults to stdout (for piping) alias: o - name: compression - description: Compression algorithm for the archive + description: Compression algorithm for the archive. Auto-detected from output file extension when not specified options: [none, gzip, zstd] - default: none - name: on-error description: How to handle missing objects. 'skip' omits them, 'fail' aborts the request options: [skip, fail] diff --git a/test/auth/fly.test.ts b/test/auth/fly.test.ts new file mode 100644 index 0000000..7a29289 --- /dev/null +++ b/test/auth/fly.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/auth/storage.js', () => ({ + getSelectedOrganization: vi.fn(), +})); + +import { isFlyOrganization } from '../../src/auth/fly.js'; +import { getSelectedOrganization } from '../../src/auth/storage.js'; + +describe('isFlyOrganization', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + logSpy.mockClear(); + }); + + afterEach(() => { + vi.mocked(getSelectedOrganization).mockReset(); + }); + + it('returns true when org starts with flyio_', () => { + vi.mocked(getSelectedOrganization).mockReturnValue('flyio_my-org'); + expect(isFlyOrganization('User management')).toBe(true); + }); + + it('prints message when org is Fly', () => { + vi.mocked(getSelectedOrganization).mockReturnValue('flyio_my-org'); + isFlyOrganization('User management'); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0][0]).toContain('User management'); + expect(logSpy.mock.calls[0][0]).toContain('fly.io'); + }); + + it('returns false when org does not start with flyio_', () => { + vi.mocked(getSelectedOrganization).mockReturnValue('my-regular-org'); + expect(isFlyOrganization('User management')).toBe(false); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('returns false when getSelectedOrganization returns null', () => { + vi.mocked(getSelectedOrganization).mockReturnValue(null); + expect(isFlyOrganization('User management')).toBe(false); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/auth/iam.test.ts b/test/auth/iam.test.ts new file mode 100644 index 0000000..732b0a4 --- /dev/null +++ b/test/auth/iam.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock dependencies before importing module under test +vi.mock('../../src/auth/client.js', () => ({ + getAuthClient: vi.fn(() => ({ + isAuthenticated: vi.fn(), + getAccessToken: vi.fn(), + })), + getAuth0Config: () => ({ + domain: 'test.auth0.com', + clientId: 'test-client-id', + audience: 'test-audience', + }), +})); + +vi.mock('../../src/auth/provider.js', () => ({ + resolveAuthMethod: vi.fn(), + getTigrisConfig: vi.fn(() => ({ + iamEndpoint: 'https://iam.test', + mgmtEndpoint: 'https://mgmt.test', + })), +})); + +vi.mock('../../src/auth/storage.js', () => ({ + getLoginMethod: vi.fn(), + getSelectedOrganization: vi.fn(), +})); + +vi.mock('../../src/utils/exit.js', () => ({ + failWithError: vi.fn((_ctx: unknown, msg: unknown) => { + throw new Error(String(msg)); + }), +})); + +vi.mock('../../src/utils/messages.js', () => ({ + msg: vi.fn(() => ({})), +})); + +import { getAuthClient } from '../../src/auth/client.js'; +import { getIAMConfig, getOAuthIAMConfig } from '../../src/auth/iam.js'; +import { resolveAuthMethod } from '../../src/auth/provider.js'; +import { + getLoginMethod, + getSelectedOrganization, +} from '../../src/auth/storage.js'; +import { msg } from '../../src/utils/messages.js'; + +const context = msg('test'); + +describe('getOAuthIAMConfig', () => { + it('throws when login method is not oauth', async () => { + vi.mocked(getLoginMethod).mockReturnValue('credentials'); + await expect(getOAuthIAMConfig(context)).rejects.toThrow( + 'requires OAuth login' + ); + }); + + it('throws when not authenticated', async () => { + vi.mocked(getLoginMethod).mockReturnValue('oauth'); + const mockClient = { + isAuthenticated: vi.fn().mockResolvedValue(false), + getAccessToken: vi.fn(), + }; + vi.mocked(getAuthClient).mockReturnValue( + mockClient as ReturnType + ); + + await expect(getOAuthIAMConfig(context)).rejects.toThrow( + 'Not authenticated' + ); + }); + + it('returns config on success', async () => { + vi.mocked(getLoginMethod).mockReturnValue('oauth'); + vi.mocked(getSelectedOrganization).mockReturnValue('my-org'); + const mockClient = { + isAuthenticated: vi.fn().mockResolvedValue(true), + getAccessToken: vi.fn().mockResolvedValue('tok-123'), + }; + vi.mocked(getAuthClient).mockReturnValue( + mockClient as ReturnType + ); + + const config = await getOAuthIAMConfig(context); + expect(config).toEqual({ + sessionToken: 'tok-123', + organizationId: 'my-org', + iamEndpoint: 'https://iam.test', + mgmtEndpoint: 'https://mgmt.test', + }); + }); + + it('returns undefined organizationId when no org selected', async () => { + vi.mocked(getLoginMethod).mockReturnValue('oauth'); + vi.mocked(getSelectedOrganization).mockReturnValue(null); + const mockClient = { + isAuthenticated: vi.fn().mockResolvedValue(true), + getAccessToken: vi.fn().mockResolvedValue('tok-123'), + }; + vi.mocked(getAuthClient).mockReturnValue( + mockClient as ReturnType + ); + + const config = await getOAuthIAMConfig(context); + expect(config.organizationId).toBeUndefined(); + }); +}); + +describe('getIAMConfig', () => { + it('delegates to getOAuthIAMConfig when type is oauth', async () => { + vi.mocked(resolveAuthMethod).mockResolvedValue({ + type: 'oauth', + } as Awaited>); + vi.mocked(getLoginMethod).mockReturnValue('oauth'); + const mockClient = { + isAuthenticated: vi.fn().mockResolvedValue(true), + getAccessToken: vi.fn().mockResolvedValue('tok-456'), + }; + vi.mocked(getAuthClient).mockReturnValue( + mockClient as ReturnType + ); + vi.mocked(getSelectedOrganization).mockReturnValue('org-1'); + + const config = await getIAMConfig(context); + expect(config).toHaveProperty('sessionToken', 'tok-456'); + }); + + it.each(['credentials', 'environment', 'configured', 'aws-profile'] as const)( + 'returns credential config when type is %s', + async (type) => { + vi.mocked(resolveAuthMethod).mockResolvedValue({ + type, + accessKeyId: 'ak-123', + secretAccessKey: 'sk-456', + } as Awaited>); + vi.mocked(getSelectedOrganization).mockReturnValue('org-2'); + + const config = await getIAMConfig(context); + expect(config).toEqual({ + accessKeyId: 'ak-123', + secretAccessKey: 'sk-456', + organizationId: 'org-2', + iamEndpoint: 'https://iam.test', + }); + } + ); + + it('throws when type is none', async () => { + vi.mocked(resolveAuthMethod).mockResolvedValue({ + type: 'none', + } as Awaited>); + + await expect(getIAMConfig(context)).rejects.toThrow('Not authenticated'); + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index 864db8b..a71cda5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -4,9 +4,14 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getTestPrefix, shouldSkipIntegrationTests } from './setup.js'; +import { + getTestPrefix, + shouldSkipIntegrationTests, + shouldSkipOAuthTests, +} from './setup.js'; const skipTests = shouldSkipIntegrationTests(); +const skipOAuth = shouldSkipOAuthTests(); // Helper to run CLI commands with env vars for auth function runCli(args: string): { @@ -2024,4 +2029,204 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.stdout).toContain('Access verified'); }); }); + + describe('access-keys lifecycle', () => { + let createdKeyId: string | undefined; + const keyName = `${testPrefix}-ak`; + + afterAll(() => { + if (createdKeyId) { + runCli(`access-keys delete ${createdKeyId} --yes`); + } + }); + + it('should create an access key', () => { + const result = runCli(`access-keys create ${keyName} --format json`); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + createdKeyId = parsed.id; + expect(parsed.name).toBe(keyName); + expect(parsed.secret).toBeTruthy(); + }); + + it('should get the access key', () => { + if (!createdKeyId) return; + const result = runCli(`access-keys get ${createdKeyId} --format json`); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.name).toBe(keyName); + expect(parsed.id).toBe(createdKeyId); + }); + + it('should list access keys and include the created one', () => { + if (!createdKeyId) return; + const result = runCli('access-keys list --format json'); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + const found = parsed.items.some( + (k: { id: string }) => k.id === createdKeyId + ); + expect(found).toBe(true); + }); + + it('should assign bucket-specific role', () => { + if (!createdKeyId) return; + const result = runCli( + `access-keys assign ${createdKeyId} --bucket ${testBucket} --role Editor` + ); + expect(result.exitCode).toBe(0); + }); + + it('should assign admin role', () => { + if (!createdKeyId) return; + // Revoke bucket roles first, then assign admin + runCli(`access-keys assign ${createdKeyId} --revoke-roles`); + const result = runCli(`access-keys assign ${createdKeyId} --admin`); + expect(result.exitCode).toBe(0); + }); + + it('should revoke all roles', () => { + if (!createdKeyId) return; + const result = runCli( + `access-keys assign ${createdKeyId} --revoke-roles` + ); + expect(result.exitCode).toBe(0); + }); + + it('should rotate the access key', () => { + if (!createdKeyId) return; + const result = runCli( + `access-keys rotate ${createdKeyId} --yes --format json` + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.secret).toBeTruthy(); + }); + + it('should delete the access key', () => { + if (!createdKeyId) return; + const result = runCli(`access-keys delete ${createdKeyId} --yes`); + expect(result.exitCode).toBe(0); + createdKeyId = undefined; + }); + }); + + describe('access-keys error cases', () => { + it('should error on create without name', () => { + const result = runCli('access-keys create'); + expect(result.exitCode).not.toBe(0); + }); + + it('should error on get without id', () => { + const result = runCli('access-keys get'); + expect(result.exitCode).not.toBe(0); + }); + + it('should error on delete without --yes in non-TTY', () => { + const result = runCli('access-keys delete fake-id'); + expect(result.exitCode).toBe(1); + }); + + it('should error on rotate without --yes in non-TTY', () => { + const result = runCli('access-keys rotate fake-id'); + expect(result.exitCode).toBe(1); + }); + }); + + describe('whoami command', () => { + it('should show auth info', () => { + const result = runCli('whoami'); + expect(result.exitCode).toBe(0); + }); + + it('should show auth info with --format json', () => { + const result = runCli('whoami --format json'); + expect(result.exitCode).toBe(0); + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + }); + }); +}); + +describe.skipIf(skipTests || skipOAuth)('OAuth Integration Tests', () => { + const testPrefix = getTestPrefix(); + + describe('iam policies lifecycle', () => { + let policyArn: string | undefined; + const policyName = `${testPrefix}-policy`; + + afterAll(() => { + if (policyArn) { + runCli(`iam policies delete --resource ${policyArn} --yes`); + } + }); + + it('should create a policy', () => { + const doc = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { Effect: 'Allow', Action: ['s3:GetObject'], Resource: ['*'] }, + ], + }); + const result = runCli( + `iam policies create --name ${policyName} --document '${doc}' --format json` + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + policyArn = parsed.arn; + expect(parsed.name).toBe(policyName); + }); + + it('should list policies and include the created one', () => { + if (!policyArn) return; + const result = runCli('iam policies list --format json'); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + const found = parsed.items.some( + (p: { resource: string }) => p.resource === policyArn + ); + expect(found).toBe(true); + }); + + it('should get the policy', () => { + if (!policyArn) return; + const result = runCli( + `iam policies get --resource ${policyArn} --format json` + ); + expect(result.exitCode).toBe(0); + }); + + it('should delete the policy', () => { + if (!policyArn) return; + const result = runCli( + `iam policies delete --resource ${policyArn} --yes` + ); + expect(result.exitCode).toBe(0); + policyArn = undefined; + }); + }); + + describe('iam users', () => { + it('should list users', () => { + const result = runCli('iam users list --format json'); + // May fail for Fly orgs — that's expected + if (result.exitCode === 0 && result.stdout.trim()) { + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + } + }); + }); + + describe('organizations', () => { + it('should list organizations with --format table', () => { + const result = runCli('organizations list --format table'); + expect(result.exitCode).toBe(0); + }); + + it('should list organizations with --format json', () => { + const result = runCli('organizations list --format json'); + expect(result.exitCode).toBe(0); + if (result.stdout.trim()) { + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + } + }); + }); }); diff --git a/test/setup.ts b/test/setup.ts index 68d2f19..7c30782 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -38,6 +38,20 @@ export function shouldSkipIntegrationTests(): boolean { return false; } +/** + * Check if OAuth-dependent integration tests should run. + * Set TIGRIS_OAUTH_TEST=true in the environment to enable. + */ +export function shouldSkipOAuthTests(): boolean { + if (!process.env.TIGRIS_OAUTH_TEST) { + console.warn( + 'Skipping OAuth integration tests - set TIGRIS_OAUTH_TEST=true to enable' + ); + return true; + } + return false; +} + /** * Generate a unique test prefix using nanosecond timestamp * Format: tigris-cli-test-{timestamp} diff --git a/test/utils/bucket-info.test.ts b/test/utils/bucket-info.test.ts new file mode 100644 index 0000000..c2f9ded --- /dev/null +++ b/test/utils/bucket-info.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBucketInfo } from '../../src/utils/bucket-info.js'; + +function makeResponse(overrides: Record = {}) { + return { + isSnapshotEnabled: false, + forkInfo: undefined, + sizeInfo: { + numberOfObjects: 10, + size: 1024, + numberOfObjectsAllVersions: 15, + }, + settings: { + defaultTier: 'STANDARD', + deleteProtection: false, + allowObjectAcl: false, + corsRules: [], + customDomain: undefined, + lifecycleRules: undefined, + ttlConfig: undefined, + notifications: undefined, + dataMigration: undefined, + }, + ...overrides, + } as Parameters[0]; +} + +function findValue( + info: { label: string; value: string }[], + label: string +): string | undefined { + return info.find((i) => i.label === label)?.value; +} + +describe('buildBucketInfo', () => { + describe('base fields', () => { + it('returns all base fields', () => { + const info = buildBucketInfo(makeResponse()); + const labels = info.map((i) => i.label); + expect(labels).toContain('Number of Objects'); + expect(labels).toContain('Total Size'); + expect(labels).toContain('All Versions Count'); + expect(labels).toContain('Default Tier'); + expect(labels).toContain('Snapshots Enabled'); + expect(labels).toContain('Delete Protection'); + expect(labels).toContain('Allow Object ACL'); + expect(labels).toContain('Custom Domain'); + expect(labels).toContain('Has Forks'); + }); + + it('formats number of objects', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Number of Objects')).toBe('10'); + }); + + it('uses N/A when numberOfObjects is undefined', () => { + const info = buildBucketInfo( + makeResponse({ sizeInfo: { size: 0, numberOfObjectsAllVersions: 0 } }) + ); + expect(findValue(info, 'Number of Objects')).toBe('N/A'); + }); + + it('uses N/A when size is undefined', () => { + const info = buildBucketInfo( + makeResponse({ sizeInfo: { numberOfObjects: 0 } }) + ); + expect(findValue(info, 'Total Size')).toBe('N/A'); + }); + + it('formats size when defined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Total Size')).toBe('1.0 KB'); + }); + + it('uses N/A when numberOfObjectsAllVersions is undefined', () => { + const info = buildBucketInfo( + makeResponse({ sizeInfo: { numberOfObjects: 0, size: 0 } }) + ); + expect(findValue(info, 'All Versions Count')).toBe('N/A'); + }); + + it('shows custom domain as None when undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Custom Domain')).toBe('None'); + }); + + it('shows custom domain when defined', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + customDomain: 'cdn.example.com', + }, + }) + ); + expect(findValue(info, 'Custom Domain')).toBe('cdn.example.com'); + }); + + it('shows boolean fields as Yes/No', () => { + const info = buildBucketInfo( + makeResponse({ + isSnapshotEnabled: true, + settings: { + ...makeResponse().settings, + deleteProtection: true, + allowObjectAcl: true, + }, + }) + ); + expect(findValue(info, 'Snapshots Enabled')).toBe('Yes'); + expect(findValue(info, 'Delete Protection')).toBe('Yes'); + expect(findValue(info, 'Allow Object ACL')).toBe('Yes'); + }); + }); + + describe('fork info', () => { + it('shows Has Forks as No when forkInfo is undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Has Forks')).toBe('No'); + }); + + it('shows Has Forks as Yes when hasChildren is true', () => { + const info = buildBucketInfo( + makeResponse({ forkInfo: { hasChildren: true, parents: [] } }) + ); + expect(findValue(info, 'Has Forks')).toBe('Yes'); + }); + + it('adds Forked From and Fork Snapshot when parents exist', () => { + const info = buildBucketInfo( + makeResponse({ + forkInfo: { + hasChildren: false, + parents: [{ bucketName: 'parent-bucket', snapshot: 'snap-123' }], + }, + }) + ); + expect(findValue(info, 'Forked From')).toBe('parent-bucket'); + expect(findValue(info, 'Fork Snapshot')).toBe('snap-123'); + }); + + it('does not add fork fields when parents is empty', () => { + const info = buildBucketInfo( + makeResponse({ forkInfo: { hasChildren: false, parents: [] } }) + ); + expect(findValue(info, 'Forked From')).toBeUndefined(); + }); + }); + + describe('TTL config', () => { + it('does not add TTL when ttlConfig is undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'TTL')).toBeUndefined(); + }); + + it('shows Disabled when ttlConfig.enabled is false', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + ttlConfig: { enabled: false }, + }, + }) + ); + expect(findValue(info, 'TTL')).toBe('Disabled'); + }); + + it('shows days when enabled with days', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + ttlConfig: { enabled: true, days: 30 }, + }, + }) + ); + expect(findValue(info, 'TTL')).toBe('30 days'); + }); + + it('shows date when enabled without days', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + ttlConfig: { enabled: true, date: '2025-12-31' }, + }, + }) + ); + expect(findValue(info, 'TTL')).toBe('2025-12-31'); + }); + + it('shows Enabled when enabled without days or date', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + ttlConfig: { enabled: true }, + }, + }) + ); + expect(findValue(info, 'TTL')).toBe('Enabled'); + }); + }); + + describe('lifecycle rules', () => { + it('does not add lifecycle rules when undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Lifecycle Rules')).toBeUndefined(); + }); + + it('does not add lifecycle rules when empty', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { ...makeResponse().settings, lifecycleRules: [] }, + }) + ); + expect(findValue(info, 'Lifecycle Rules')).toBeUndefined(); + }); + + it('formats rule with storage class and days', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + lifecycleRules: [ + { storageClass: 'GLACIER', days: 90, enabled: true }, + ], + }, + }) + ); + expect(findValue(info, 'Lifecycle Rules')).toBe('GLACIER after 90d'); + }); + + it('marks disabled rules', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + lifecycleRules: [ + { storageClass: 'GLACIER', days: 90, enabled: false }, + ], + }, + }) + ); + expect(findValue(info, 'Lifecycle Rules')).toBe( + 'GLACIER after 90d (disabled)' + ); + }); + + it('joins multiple rules with commas', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + lifecycleRules: [ + { storageClass: 'STANDARD_IA', days: 30, enabled: true }, + { storageClass: 'GLACIER', days: 90, enabled: true }, + ], + }, + }) + ); + expect(findValue(info, 'Lifecycle Rules')).toBe( + 'STANDARD_IA after 30d, GLACIER after 90d' + ); + }); + }); + + describe('CORS rules', () => { + it('does not add CORS when empty', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'CORS Rules')).toBeUndefined(); + }); + + it('shows rule count when corsRules has entries', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + corsRules: [{}, {}], + }, + }) + ); + expect(findValue(info, 'CORS Rules')).toBe('2 rule(s)'); + }); + }); + + describe('notifications', () => { + it('does not add notifications when undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Notifications')).toBeUndefined(); + }); + + it('shows Enabled when notifications.enabled is not false', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + notifications: { enabled: true }, + }, + }) + ); + expect(findValue(info, 'Notifications')).toBe('Enabled'); + }); + + it('shows Enabled when notifications.enabled is undefined', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { ...makeResponse().settings, notifications: {} }, + }) + ); + expect(findValue(info, 'Notifications')).toBe('Enabled'); + }); + + it('shows Disabled when notifications.enabled is false', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + notifications: { enabled: false }, + }, + }) + ); + expect(findValue(info, 'Notifications')).toBe('Disabled'); + }); + }); + + describe('data migration', () => { + it('does not add data migration when undefined', () => { + const info = buildBucketInfo(makeResponse()); + expect(findValue(info, 'Data Migration')).toBeUndefined(); + }); + + it('shows name and endpoint when both present', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + dataMigration: { name: 'aws-s3', endpoint: 's3.amazonaws.com' }, + }, + }) + ); + expect(findValue(info, 'Data Migration')).toBe( + 'aws-s3 (s3.amazonaws.com)' + ); + }); + + it('shows name when endpoint is absent', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + dataMigration: { name: 'aws-s3' }, + }, + }) + ); + expect(findValue(info, 'Data Migration')).toBe('aws-s3'); + }); + + it('shows Configured when both name and endpoint are absent', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { ...makeResponse().settings, dataMigration: {} }, + }) + ); + expect(findValue(info, 'Data Migration')).toBe('Configured'); + }); + + it('shows N/A for name when only endpoint is present', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + dataMigration: { endpoint: 's3.amazonaws.com' }, + }, + }) + ); + expect(findValue(info, 'Data Migration')).toBe('N/A (s3.amazonaws.com)'); + }); + }); +}); diff --git a/test/utils/concurrency.test.ts b/test/utils/concurrency.test.ts new file mode 100644 index 0000000..0a13225 --- /dev/null +++ b/test/utils/concurrency.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { executeWithConcurrency } from '../../src/utils/concurrency.js'; + +describe('executeWithConcurrency', () => { + it('returns empty array for empty tasks', async () => { + const results = await executeWithConcurrency([], 4); + expect(results).toEqual([]); + }); + + it('executes a single task', async () => { + const tasks = [() => Promise.resolve('a')]; + const results = await executeWithConcurrency(tasks, 1); + expect(results).toEqual(['a']); + }); + + it('preserves result order', async () => { + const tasks = [ + () => new Promise((r) => setTimeout(() => r(3), 30)), + () => new Promise((r) => setTimeout(() => r(1), 10)), + () => new Promise((r) => setTimeout(() => r(2), 20)), + ]; + const results = await executeWithConcurrency(tasks, 3); + expect(results).toEqual([3, 1, 2]); + }); + + it('floors concurrency to 1 when 0', async () => { + const tasks = [() => Promise.resolve('ok')]; + const results = await executeWithConcurrency(tasks, 0); + expect(results).toEqual(['ok']); + }); + + it('floors concurrency to 1 when negative', async () => { + const tasks = [() => Promise.resolve('ok')]; + const results = await executeWithConcurrency(tasks, -5); + expect(results).toEqual(['ok']); + }); + + it('floors fractional concurrency', async () => { + const tasks = [() => Promise.resolve('a'), () => Promise.resolve('b')]; + const results = await executeWithConcurrency(tasks, 0.5); + expect(results).toEqual(['a', 'b']); + }); + + it('works when concurrency exceeds number of tasks', async () => { + const tasks = [() => Promise.resolve(1), () => Promise.resolve(2)]; + const results = await executeWithConcurrency(tasks, 100); + expect(results).toEqual([1, 2]); + }); + + it('limits concurrent execution to the specified concurrency', async () => { + let running = 0; + let maxRunning = 0; + + const makeTask = () => async () => { + running++; + maxRunning = Math.max(maxRunning, running); + await new Promise((r) => setTimeout(r, 20)); + running--; + return true; + }; + + const tasks = Array.from({ length: 8 }, makeTask); + await executeWithConcurrency(tasks, 3); + expect(maxRunning).toBeLessThanOrEqual(3); + expect(maxRunning).toBeGreaterThan(1); + }); + + it('propagates error from a failing task', async () => { + const tasks = [ + () => Promise.resolve('ok'), + () => Promise.reject(new Error('boom')), + () => Promise.resolve('ok'), + ]; + await expect(executeWithConcurrency(tasks, 2)).rejects.toThrow('boom'); + }); +}); diff --git a/test/utils/interactive.test.ts b/test/utils/interactive.test.ts new file mode 100644 index 0000000..fa5d5f0 --- /dev/null +++ b/test/utils/interactive.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requireInteractive } from '../../src/utils/interactive.js'; + +describe('requireInteractive', () => { + const originalIsTTY = process.stdin.isTTY; + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + beforeEach(() => { + exitSpy.mockClear(); + errorSpy.mockClear(); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + writable: true, + }); + }); + + it('does nothing when stdin is a TTY', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + }); + requireInteractive('use --yes flag'); + expect(exitSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('prints error and exits when stdin is not a TTY', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + }); + requireInteractive('use --yes flag'); + expect(errorSpy).toHaveBeenCalledTimes(2); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('includes the hint in the error message', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + }); + requireInteractive('use --format json'); + const hintCall = errorSpy.mock.calls.find((call) => + String(call[0]).includes('use --format json') + ); + expect(hintCall).toBeTruthy(); + }); +}); diff --git a/test/utils/upload.test.ts b/test/utils/upload.test.ts new file mode 100644 index 0000000..102092f --- /dev/null +++ b/test/utils/upload.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateUploadParams } from '../../src/utils/upload.js'; + +const MB = 1024 * 1024; +const GB = 1024 * MB; +const DEFAULT_PART_SIZE = 5 * MB; + +describe('calculateUploadParams', () => { + it.each([ + { fileSize: undefined, label: 'undefined' }, + { fileSize: 0, label: '0' }, + { fileSize: 1, label: '1 byte' }, + { fileSize: DEFAULT_PART_SIZE, label: '5 MB (exactly)' }, + ])('returns { multipart: false } when fileSize is $label', ({ fileSize }) => { + expect(calculateUploadParams(fileSize)).toEqual({ multipart: false }); + }); + + it('returns multipart config when fileSize exceeds 5 MB', () => { + const result = calculateUploadParams(DEFAULT_PART_SIZE + 1); + expect(result).toEqual({ + multipart: true, + partSize: DEFAULT_PART_SIZE, + queueSize: 10, + }); + }); + + it('keeps default partSize for files under 50 GB', () => { + const result = calculateUploadParams(10 * GB); + expect(result).toEqual({ + multipart: true, + partSize: DEFAULT_PART_SIZE, + queueSize: 10, + }); + }); + + it('recalculates partSize when file exceeds MAX_PARTS * DEFAULT_PART_SIZE', () => { + const fileSize = DEFAULT_PART_SIZE * 10_000 + 1; // just over 50 GB + const result = calculateUploadParams(fileSize); + expect(result.multipart).toBe(true); + if (result.multipart) { + expect(result.partSize).toBe(Math.ceil(fileSize / 10_000)); + expect(result.partSize).toBeGreaterThan(DEFAULT_PART_SIZE); + expect(result.queueSize).toBe(10); + } + }); + + it('handles very large files (1 TB)', () => { + const fileSize = 1024 * GB; + const result = calculateUploadParams(fileSize); + expect(result.multipart).toBe(true); + if (result.multipart) { + expect(result.partSize).toBe(Math.ceil(fileSize / 10_000)); + // Verify we stay within S3 part limit + expect(Math.ceil(fileSize / result.partSize)).toBeLessThanOrEqual(10_000); + } + }); +});