Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ec6970
test: cover backup and system contracts
githubsaturn Sep 22, 2026
120600b
test: cover backup and system contracts
githubsaturn Sep 22, 2026
107431f
test: cover backup and system contracts
githubsaturn Sep 22, 2026
132e704
test: cover backup and system contracts
githubsaturn Sep 22, 2026
d5709dd
test: cover backup and system contracts
githubsaturn Sep 22, 2026
a8e0a77
test: cover backup and system contracts
githubsaturn Sep 22, 2026
2df1214
test: cover backup and system contracts
githubsaturn Sep 22, 2026
815e1bb
test: cover backup and system contracts
githubsaturn Sep 22, 2026
27c9e43
test: cover backup and system contracts
githubsaturn Sep 22, 2026
b216fe7
test: cover backup and system contracts
githubsaturn Sep 22, 2026
2a7b985
test: cover backup and system contracts
githubsaturn Sep 22, 2026
0804b05
test: enforce fresh defaults file sequencing
githubsaturn Sep 22, 2026
c1b3758
test: configure deterministic fresh defaults ordering
githubsaturn Sep 22, 2026
801616b
test: verify actual file sequencing rule
githubsaturn Sep 22, 2026
1b36e38
fix: target Vitest 5 test specifications
githubsaturn Sep 22, 2026
609f6db
fix: test Vitest 5 specification sequencing
githubsaturn Sep 22, 2026
b0e1319
fix: handle relative and absolute test module IDs
githubsaturn Sep 22, 2026
a497bf4
fix: use CapRover backup download route
githubsaturn Sep 22, 2026
8a7708b
ci: allow headroom for expanded E2E suite
githubsaturn Sep 22, 2026
55f28e3
test: assert repaired backup download contract
githubsaturn Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e-ephemeral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:

- name: Run E2E suite
id: e2e
timeout-minutes: 11
timeout-minutes: 15
run: npm test

- name: Collect failure diagnostics
Expand Down
55 changes: 54 additions & 1 deletion src/clients/caprover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ interface ServerInfo {
type Theme = NonNullable<
Awaited<ReturnType<CapRoverAPI['getAllThemes']>>['themes']
>[number]
type VersionInfo = Awaited<ReturnType<CapRoverAPI['getVersionInfo']>>
type LoadBalancerInfo = Awaited<ReturnType<CapRoverAPI['getLoadBalancerInfo']>>
type NodeInfo = Awaited<ReturnType<CapRoverAPI['getAllNodes']>>['nodes'][number]
type ProFeaturesState = Awaited<
ReturnType<CapRoverAPI['getProFeaturesState']>
>['proFeaturesState']
type ProConfigs = Awaited<
ReturnType<CapRoverAPI['getProConfigs']>
>['proConfigs']

const API_TIMEOUT_MS = 30_000
const DEPLOYMENT_TIMEOUT_MS = 90_000
Expand Down Expand Up @@ -53,6 +62,42 @@ export class CapRoverClient {
)
}

getVersionInfo(): Promise<VersionInfo> {
return this.request(
() => this.api.getVersionInfo(),
'retrieving CapRover version information'
)
}

getLoadBalancerInfo(): Promise<LoadBalancerInfo> {
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<ProFeaturesState> {
return this.request(
() => this.api.getProFeaturesState(),
'retrieving Pro feature state'
).then((response) => response.proFeaturesState)
}

getProConfigs(): Promise<ProConfigs> {
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')
}
Expand Down Expand Up @@ -297,4 +342,12 @@ export class CapRoverClient {
}
}

export type { AppDefinition, Theme }
export type {
AppDefinition,
LoadBalancerInfo,
NodeInfo,
ProConfigs,
ProFeaturesState,
Theme,
VersionInfo,
}
25 changes: 25 additions & 0 deletions src/clients/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +40,24 @@ export class HttpClient {
}
}

async getBinary(
url: string,
options: HttpRequestOptions = {}
): Promise<BinaryHttpResult> {
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<number> {
return (await this.get(url)).status
}
Expand Down
33 changes: 33 additions & 0 deletions src/inspectors/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -241,6 +254,26 @@ export class DockerInspector {
return nodeId
}

async getNodes(): Promise<DockerNode[]> {
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<DockerNode[]>(inspected.stdout, 'Swarm nodes')
}

async getServicePlacementConstraints(appName: string): Promise<string[]> {
const service = await this.getService(appName)
return service.Spec?.TaskTemplate?.Placement?.Constraints ?? []
Expand Down
2 changes: 1 addition & 1 deletion src/test-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
31 changes: 31 additions & 0 deletions src/test-sequencer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BaseSequencer } from 'vitest/node'

const SYSTEM_DEFAULTS_FILE = 'tests/system-defaults.test.ts'

export function prioritizeSystemDefaults<T extends { moduleId: string }>(
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<BaseSequencer['sort']>[0]) {
const sorted = await super.sort(files)
return prioritizeSystemDefaults(sorted)
}
}
127 changes: 127 additions & 0 deletions tests/backup.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { description?: string }>
}
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<string[]> {
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<string> {
const { stdout } = await execFile('tar', ['-xOf', archivePath, entry])
return stdout
}
38 changes: 38 additions & 0 deletions tests/system-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading
Loading