diff --git a/.github/workflows/e2e-ephemeral.yml b/.github/workflows/e2e-ephemeral.yml index 6d269de..679f31b 100644 --- a/.github/workflows/e2e-ephemeral.yml +++ b/.github/workflows/e2e-ephemeral.yml @@ -37,7 +37,7 @@ jobs: - name: Run E2E suite id: e2e - timeout-minutes: 11 + timeout-minutes: 15 run: npm test - name: Collect failure diagnostics diff --git a/src/clients/caprover.ts b/src/clients/caprover.ts index fea177a..cf244f8 100644 --- a/src/clients/caprover.ts +++ b/src/clients/caprover.ts @@ -22,6 +22,15 @@ interface ServerInfo { type Theme = NonNullable< Awaited>['themes'] >[number] +type VersionInfo = Awaited> +type LoadBalancerInfo = Awaited> +type NodeInfo = Awaited>['nodes'][number] +type ProFeaturesState = Awaited< + ReturnType +>['proFeaturesState'] +type ProConfigs = Awaited< + ReturnType +>['proConfigs'] const API_TIMEOUT_MS = 30_000 const DEPLOYMENT_TIMEOUT_MS = 90_000 @@ -53,6 +62,42 @@ export class CapRoverClient { ) } + getVersionInfo(): Promise { + return this.request( + () => this.api.getVersionInfo(), + 'retrieving CapRover version information' + ) + } + + getLoadBalancerInfo(): Promise { + return this.request( + () => this.api.getLoadBalancerInfo(), + 'retrieving load-balancer information' + ) + } + + getAllNodes(): Promise<{ nodes: NodeInfo[] }> { + return this.request(() => this.api.getAllNodes(), 'listing Swarm nodes') + } + + getProFeaturesState(): Promise { + return this.request( + () => this.api.getProFeaturesState(), + 'retrieving Pro feature state' + ).then((response) => response.proFeaturesState) + } + + getProConfigs(): Promise { + return this.request( + () => this.api.getProConfigs(), + 'retrieving Pro configuration' + ).then((response) => response.proConfigs) + } + + createBackup(): Promise<{ downloadToken: string }> { + return this.request(() => this.api.createBackup(), 'creating backup') + } + getAllThemes(): Promise<{ themes: Theme[] | undefined }> { return this.request(() => this.api.getAllThemes(), 'listing themes') } @@ -297,4 +342,12 @@ export class CapRoverClient { } } -export type { AppDefinition, Theme } +export type { + AppDefinition, + LoadBalancerInfo, + NodeInfo, + ProConfigs, + ProFeaturesState, + Theme, + VersionInfo, +} diff --git a/src/clients/http.ts b/src/clients/http.ts index c6a890e..c6883bd 100644 --- a/src/clients/http.ts +++ b/src/clients/http.ts @@ -7,6 +7,13 @@ export interface HttpResult { headers: Headers } +export interface BinaryHttpResult { + status: number + body: Buffer + finalUrl: string + headers: Headers +} + export interface HttpRequestOptions { headers?: HeadersInit redirect?: RequestRedirect @@ -33,6 +40,24 @@ export class HttpClient { } } + async getBinary( + url: string, + options: HttpRequestOptions = {} + ): Promise { + const response = await fetch(url, { + headers: options.headers, + redirect: options.redirect ?? 'follow', + signal: AbortSignal.timeout(this.requestTimeoutMs), + }) + + return { + status: response.status, + body: Buffer.from(await response.arrayBuffer()), + finalUrl: response.url, + headers: response.headers, + } + } + async getStatus(url: string): Promise { return (await this.get(url)).status } diff --git a/src/inspectors/docker.ts b/src/inspectors/docker.ts index 86c3d9a..6f1c593 100644 --- a/src/inspectors/docker.ts +++ b/src/inspectors/docker.ts @@ -73,6 +73,19 @@ interface DockerSwarmInfo { NodeID?: string } +export interface DockerNode { + ID: string + Spec: { Role: 'manager' | 'worker'; Availability: string } + ManagerStatus?: { Leader?: boolean } + Description: { + Hostname: string + Platform: { Architecture: string; OS: string } + Resources: { NanoCPUs: number; MemoryBytes: number } + Engine: { EngineVersion: string } + } + Status: { Addr: string; State: string } +} + interface DockerUpdateConfig { Parallelism?: number Delay?: number @@ -241,6 +254,26 @@ export class DockerInspector { return nodeId } + async getNodes(): Promise { + const listed = await this.exec('docker node ls --quiet') + const nodeIds = listed.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + for (const nodeId of nodeIds) { + if (!/^[a-z0-9]{25}$/.test(nodeId)) { + throw new Error(`Docker returned an invalid node ID: ${nodeId}`) + } + } + if (!nodeIds.length) return [] + + const inspected = await this.exec( + `docker node inspect ${nodeIds.map(shellQuote).join(' ')}` + ) + return parseJson(inspected.stdout, 'Swarm nodes') + } + async getServicePlacementConstraints(appName: string): Promise { const service = await this.getService(appName) return service.Spec?.TaskTemplate?.Placement?.Constraints ?? [] diff --git a/src/test-selection.ts b/src/test-selection.ts index 267eae6..7479f7b 100644 --- a/src/test-selection.ts +++ b/src/test-selection.ts @@ -13,11 +13,11 @@ export const coreFiles = [ 'tests/one-click.test.ts', ] export const destructiveFiles = [ + 'tests/system-defaults.test.ts', 'tests/persistent-storage.test.ts', 'tests/custom-ports.test.ts', 'tests/themes.test.ts', 'tests/backup.test.ts', - 'tests/system-defaults.test.ts', 'tests/disk-cleanup.test.ts', 'tests/system-nginx.test.ts', 'tests/one-click-repositories.test.ts', diff --git a/src/test-sequencer.ts b/src/test-sequencer.ts new file mode 100644 index 0000000..2a089ae --- /dev/null +++ b/src/test-sequencer.ts @@ -0,0 +1,31 @@ +import { BaseSequencer } from 'vitest/node' + +const SYSTEM_DEFAULTS_FILE = 'tests/system-defaults.test.ts' + +export function prioritizeSystemDefaults( + files: T[] +): T[] { + const defaults: T[] = [] + const rest: T[] = [] + + for (const spec of files) { + const file = spec.moduleId.replaceAll('\\', '/') + if ( + file === SYSTEM_DEFAULTS_FILE || + file.endsWith(`/${SYSTEM_DEFAULTS_FILE}`) + ) { + defaults.push(spec) + } else { + rest.push(spec) + } + } + + return [...defaults, ...rest] +} + +export class CapRoverSequencer extends BaseSequencer { + override async sort(files: Parameters[0]) { + const sorted = await super.sort(files) + return prioritizeSystemDefaults(sorted) + } +} diff --git a/tests/backup.test.ts b/tests/backup.test.ts new file mode 100644 index 0000000..ab4f638 --- /dev/null +++ b/tests/backup.test.ts @@ -0,0 +1,127 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { expect, test } from 'vitest' +import { loadConfig } from '../src/config' +import { createTestNames } from '../src/helpers/names' +import { eventually } from '../src/helpers/retry' +import { cleanUpApp, withTestContext } from '../src/helpers/test-context' +import { requireEphemeral } from '../src/test-selection' + +const execFile = promisify(execFileCallback) + +test('backup download is inspectable, contains owned configuration, and is one-time', async () => { + requireEphemeral() + await withTestContext(async (context, cleanup) => { + const { initialAppName: appName } = createTestNames() + const marker = `backup-${randomUUID()}` + cleanUpApp(context, cleanup, appName) + await context.caprover.createApp(appName) + await context.caprover.updateApp(appName, { description: marker }) + expect((await context.caprover.getApp(appName)).description).toBe( + marker + ) + + const { downloadToken } = await context.caprover.createBackup() + expect(downloadToken.length).toBeGreaterThan(0) + const downloadUrl = backupUrl(downloadToken) + + const invalid = await context.http.getBinary( + backupUrl(`${downloadToken}x`) + ) + expect(invalid.status).toBe(200) + expect(isTarArchive(invalid)).toBe(false) + expect(parseEnvelopeStatus(invalid.body)).toBe(1106) + + const archive = await context.http.getBinary(downloadUrl) + expect(archive.status).toBeGreaterThanOrEqual(200) + expect(archive.status).toBeLessThan(300) + expect(archive.body.length).toBeGreaterThan(0) + expect(archive.headers.get('content-disposition')).toContain( + 'caprover-backup-' + ) + expect(isTarArchive(archive)).toBe(true) + + const directory = await mkdtemp(join(tmpdir(), 'caprover-e2e-backup-')) + try { + const archivePath = join(directory, 'backup.tar') + await writeFile(archivePath, archive.body) + const entries = await tarList(archivePath) + expect(entries).toContain('meta/backup.json') + expect(entries).toContain('data/config-captain.json') + expect(entries).not.toContain('data/data/config-captain.json') + + const meta = JSON.parse( + await tarRead(archivePath, 'meta/backup.json') + ) as { nodes?: Array<{ nodeId?: string }>; salt?: string } + expect(meta.nodes?.length).toBeGreaterThan(0) + expect(new Set(meta.nodes?.map((node) => node.nodeId))).toEqual( + new Set( + (await context.caprover.getAllNodes()).nodes.map( + (node) => node.nodeId + ) + ) + ) + expect(typeof meta.salt).toBe('string') + expect(meta.salt?.length).toBeGreaterThan(0) + + const config = JSON.parse( + await tarRead(archivePath, 'data/config-captain.json') + ) as { + appDefinitions?: Record + } + expect(config.appDefinitions?.[appName]?.description).toBe(marker) + } finally { + await rm(directory, { recursive: true, force: true }) + } + + await eventually( + async () => { + const repeated = await context.http.getBinary(downloadUrl) + expect(isTarArchive(repeated)).toBe(false) + expect(repeated.status).toBe(404) + }, + { description: 'one-time backup download to become unavailable' } + ) + }) +}) + +function backupUrl(downloadToken: string): string { + const url = new URL('/api/v2/downloads/', loadConfig().caproverUrl) + url.searchParams.set('namespace', 'captain') + url.searchParams.set('downloadToken', downloadToken) + return url.toString() +} + +function isTarArchive(response: { status: number; headers: Headers }): boolean { + return ( + response.status >= 200 && + response.status < 300 && + response.headers.get('content-disposition')?.includes('.tar') === true + ) +} + +function parseEnvelopeStatus(body: Buffer): number | undefined { + try { + const value = JSON.parse(body.toString('utf8')) as { status?: unknown } + return typeof value.status === 'number' ? value.status : undefined + } catch { + return undefined + } +} + +async function tarList(archivePath: string): Promise { + const { stdout } = await execFile('tar', ['-tf', archivePath]) + return stdout + .split(/\r?\n/) + .map((entry) => entry.replace(/^\.\//, '')) + .filter(Boolean) +} + +async function tarRead(archivePath: string, entry: string): Promise { + const { stdout } = await execFile('tar', ['-xOf', archivePath, entry]) + return stdout +} diff --git a/tests/system-defaults.test.ts b/tests/system-defaults.test.ts new file mode 100644 index 0000000..368a3dd --- /dev/null +++ b/tests/system-defaults.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest' +import { loadConfig } from '../src/config' +import { withTestContext } from '../src/helpers/test-context' +import { requireEphemeral } from '../src/test-selection' + +test('fresh provisioning has expected system and Pro defaults', async () => { + requireEphemeral() + await withTestContext(async (context) => { + const [server, apps, nodes, proState, proConfigs] = await Promise.all([ + context.caprover.getServerInfo(), + context.caprover.getApps(), + context.caprover.getAllNodes(), + context.caprover.getProFeaturesState(), + context.caprover.getProConfigs(), + ]) + + expect(server.rootDomain).toBe(apps.rootDomain) + expect(server.rootDomain).toMatch(/^e2e-[a-z0-9-]+\./) + expect(server.hasRootSsl).toBe(true) + expect(server.forceSsl).toBe(true) + expect(new URL(loadConfig().caproverUrl).hostname).toBe( + `${server.captainSubDomain}.${server.rootDomain}` + ) + + expect(nodes.nodes).toHaveLength(1) + expect(nodes.nodes[0]).toMatchObject({ + type: 'manager', + isLeader: true, + }) + expect(nodes.nodes[0].nodeId).toBe( + await context.docker.getLocalManagerNodeId() + ) + + expect(proState.isSubscribed).toBe(false) + expect(typeof proState.isFeatureFlagEnabled).toBe('boolean') + expect(proConfigs.alerts).toEqual([]) + }) +}) diff --git a/tests/system-info.test.ts b/tests/system-info.test.ts new file mode 100644 index 0000000..d000f20 --- /dev/null +++ b/tests/system-info.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from 'vitest' +import { loadConfig } from '../src/config' +import { DockerNode } from '../src/inspectors/docker' +import { eventually } from '../src/helpers/retry' +import { withTestContext } from '../src/helpers/test-context' + +const COUNTER_FIELDS = [ + 'activeConnections', + 'accepted', + 'handled', + 'total', + 'reading', + 'writing', + 'waiting', +] as const + +test('system information is internally consistent with Docker and public Nginx', async () => { + await withTestContext(async (context) => { + const [ + server, + apps, + version, + initialLoadBalancer, + apiNodes, + proState, + proConfigs, + ] = await Promise.all([ + context.caprover.getServerInfo(), + context.caprover.getApps(), + context.caprover.getVersionInfo(), + context.caprover.getLoadBalancerInfo(), + context.caprover.getAllNodes(), + context.caprover.getProFeaturesState(), + context.caprover.getProConfigs(), + ]) + + expect(server.rootDomain).toBe(apps.rootDomain) + expect(new URL(loadConfig().caproverUrl).hostname).toBe( + `${server.captainSubDomain}.${server.rootDomain}` + ) + expect(typeof server.hasRootSsl).toBe('boolean') + expect(typeof server.forceSsl).toBe('boolean') + if (server.forceSsl) expect(server.hasRootSsl).toBe(true) + + expect(version.currentVersion.length).toBeGreaterThan(0) + expect(version.latestVersion.length).toBeGreaterThan(0) + expect(typeof version.canUpdate).toBe('boolean') + expect(typeof version.changeLogMessage).toBe('string') + expectCounters(initialLoadBalancer) + + for (let index = 0; index < 3; index++) { + expect( + (await context.http.get(loadConfig().caproverUrl)).status + ).toBe(200) + } + await eventually( + async () => { + const current = await context.caprover.getLoadBalancerInfo() + expectCounters(current) + expect(current.total).toBeGreaterThan(initialLoadBalancer.total) + expect(current.accepted).toBeGreaterThanOrEqual( + initialLoadBalancer.accepted + ) + expect(current.handled).toBeGreaterThanOrEqual( + initialLoadBalancer.handled + ) + }, + { description: 'load-balancer request counters to increase' } + ) + + const dockerNodes = await context.docker.getNodes() + expect(new Set(apiNodes.nodes.map((node) => node.nodeId))).toEqual( + new Set(dockerNodes.map((node) => node.ID)) + ) + for (const dockerNode of dockerNodes) { + const apiNode = apiNodes.nodes.find( + (node) => node.nodeId === dockerNode.ID + ) + expect(apiNode).toEqual(nodeFromDocker(dockerNode)) + expect(apiNode!.nanoCpu).toBeGreaterThan(0) + expect(apiNode!.memoryBytes).toBeGreaterThan(0) + } + const localNodeId = await context.docker.getLocalManagerNodeId() + expect(apiNodes.nodes).toContainEqual( + expect.objectContaining({ nodeId: localNodeId, type: 'manager' }) + ) + expect( + dockerNodes.filter((node) => node.ManagerStatus?.Leader) + ).toHaveLength(1) + + expect(typeof proState.isSubscribed).toBe('boolean') + expect(typeof proState.isFeatureFlagEnabled).toBe('boolean') + expect(Array.isArray(proConfigs.alerts)).toBe(true) + for (const alert of proConfigs.alerts) { + expect(alert).toMatchObject({ + event: expect.any(String), + action: { actionType: expect.any(String) }, + }) + } + }) +}) + +function expectCounters( + value: import('../src/clients/caprover').LoadBalancerInfo +): void { + for (const field of COUNTER_FIELDS) { + expect(Number.isFinite(value[field])).toBe(true) + expect(Number.isInteger(value[field])).toBe(true) + expect(value[field]).toBeGreaterThanOrEqual(0) + } +} + +function nodeFromDocker(node: DockerNode) { + return { + nodeId: node.ID, + type: node.Spec.Role, + isLeader: + node.Spec.Role === 'manager' && node.ManagerStatus?.Leader === true, + hostname: node.Description.Hostname, + architecture: node.Description.Platform.Architecture, + operatingSystem: node.Description.Platform.OS, + nanoCpu: node.Description.Resources.NanoCPUs, + memoryBytes: node.Description.Resources.MemoryBytes, + dockerEngineVersion: node.Description.Engine.EngineVersion, + ip: node.Status.Addr, + state: node.Status.State, + status: node.Spec.Availability, + } +} diff --git a/tests/unit/caprover-client.test.ts b/tests/unit/caprover-client.test.ts index 9df00e9..fa8f9c5 100644 --- a/tests/unit/caprover-client.test.ts +++ b/tests/unit/caprover-client.test.ts @@ -154,3 +154,48 @@ test('theme operations use the SDK parameters', async () => { vi.restoreAllMocks() } }) + +test('system read and backup operations use the SDK parameters', async () => { + const version = vi + .spyOn(CapRoverAPI.prototype, 'getVersionInfo') + .mockResolvedValue({} as never) + const loadBalancer = vi + .spyOn(CapRoverAPI.prototype, 'getLoadBalancerInfo') + .mockResolvedValue({} as never) + const nodes = vi + .spyOn(CapRoverAPI.prototype, 'getAllNodes') + .mockResolvedValue({ nodes: [] } as never) + const features = vi + .spyOn(CapRoverAPI.prototype, 'getProFeaturesState') + .mockResolvedValue({ proFeaturesState: {} } as never) + const configs = vi + .spyOn(CapRoverAPI.prototype, 'getProConfigs') + .mockResolvedValue({ proConfigs: { alerts: [] } } as never) + const backup = vi + .spyOn(CapRoverAPI.prototype, 'createBackup') + .mockResolvedValue({ downloadToken: 'token' }) + const client = new CapRoverClient('https://example.test', 'password') + try { + await client.getVersionInfo() + await client.getLoadBalancerInfo() + await client.getAllNodes() + await client.getProFeaturesState() + await client.getProConfigs() + await expect(client.createBackup()).resolves.toEqual({ + downloadToken: 'token', + }) + + for (const spy of [ + version, + loadBalancer, + nodes, + features, + configs, + backup, + ]) + expect(spy).toHaveBeenCalledExactlyOnceWith() + } finally { + client.destroy() + vi.restoreAllMocks() + } +}) diff --git a/tests/unit/docker.test.ts b/tests/unit/docker.test.ts index ee8dafa..3211673 100644 --- a/tests/unit/docker.test.ts +++ b/tests/unit/docker.test.ts @@ -250,3 +250,40 @@ test('reads node placement, update configuration, task nodes, and container labe 'com.caprover.e2e.predeploy': 'marker', }) }) + +test('lists node IDs before inspecting the exact returned nodes', async () => { + const nodeId = 'abcdefghijklmnopqrstuvwxy' + const node = { ID: nodeId, Spec: { Role: 'manager' } } + const exec = vi.fn(async (command: string) => { + if (command === 'docker node ls --quiet') { + return { stdout: `${nodeId}\n`, stderr: '', exitCode: 0 } + } + if (command === `docker node inspect '${nodeId}'`) { + return { + stdout: JSON.stringify([node]), + stderr: '', + exitCode: 0, + } + } + throw new Error(`Unexpected Docker command: ${command}`) + }) + const inspector = new DockerInspector({ exec } as unknown as SshClient) + + await expect(inspector.getNodes()).resolves.toEqual([node]) + expect(exec).toHaveBeenNthCalledWith(1, 'docker node ls --quiet') + expect(exec).toHaveBeenNthCalledWith(2, `docker node inspect '${nodeId}'`) +}) + +test('rejects unexpected Docker node IDs before inspection', async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: 'not a node ID\n', + stderr: '', + exitCode: 0, + }) + const inspector = new DockerInspector({ exec } as unknown as SshClient) + + await expect(inspector.getNodes()).rejects.toThrow( + 'Docker returned an invalid node ID' + ) + expect(exec).toHaveBeenCalledExactlyOnceWith('docker node ls --quiet') +}) diff --git a/tests/unit/http.test.ts b/tests/unit/http.test.ts index e08b057..29f31f4 100644 --- a/tests/unit/http.test.ts +++ b/tests/unit/http.test.ts @@ -32,3 +32,22 @@ describe('HttpClient', () => { ) }) }) + +test('getBinary preserves response bytes', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(new Uint8Array([0, 255, 10]), { + status: 200, + headers: { 'content-type': 'application/x-tar' }, + }) + ) + try { + const response = await new HttpClient().getBinary( + 'https://example.test' + ) + expect(response.body).toEqual(Buffer.from([0, 255, 10])) + expect(response.headers.get('content-type')).toBe('application/x-tar') + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/tests/unit/test-selection.test.ts b/tests/unit/test-selection.test.ts index dbab901..d02d7e8 100644 --- a/tests/unit/test-selection.test.ts +++ b/tests/unit/test-selection.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest' +import { prioritizeSystemDefaults } from '../../src/test-sequencer' import { coreFiles, destructiveFiles, @@ -33,6 +34,22 @@ test('ephemeral default adds only ordinary destructive files', () => { expect(new Set(files).size).toBe(files.length) }) +test('fresh-system defaults are sequenced before other test files', () => { + const files = [ + { moduleId: '/repo/tests/themes.test.ts' }, + { moduleId: '/repo/tests/system-defaults.test.ts' }, + { moduleId: '/repo/tests/backup.test.ts' }, + ] + + expect( + prioritizeSystemDefaults(files).map((spec) => spec.moduleId) + ).toEqual([ + '/repo/tests/system-defaults.test.ts', + '/repo/tests/themes.test.ts', + '/repo/tests/backup.test.ts', + ]) +}) + test.each([undefined, '', 'persistent', 'true', 'Ephemeral'])( 'rejects destructive selection and direct guard with %s', (value) => { diff --git a/vitest.config.mts b/vitest.config.mts index 45758b7..8b379ca 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { defineConfig } from 'vitest/config' +import { CapRoverSequencer } from './src/test-sequencer' import { selectTests } from './src/test-selection' if (!process.env.CI && existsSync('.env')) { @@ -11,6 +12,7 @@ export default defineConfig(({ mode }: { mode: string }) => ({ fileParallelism: false, maxConcurrency: 1, include: selectTests(mode), + sequence: { sequencer: CapRoverSequencer }, hookTimeout: 120_000, testTimeout: 240_000, },