From 495d0a5eb23d5128a83b4ab78b868d4ebb4fa1c7 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Tue, 22 Sep 2026 21:22:09 -0700 Subject: [PATCH 1/3] test: cover registries, GoAccess, and NetData --- E2E_TEST_PLAN.md | 32 +++++----- package-lock.json | 8 +-- package.json | 2 +- src/clients/caprover.ts | 80 ++++++++++++++++++++++++ src/inspectors/docker.ts | 33 ++++++++++ tests/goaccess.test.ts | 102 ++++++++++++++++++++++++++++++ tests/netdata.test.ts | 126 ++++++++++++++++++++++++++++++++++++++ tests/registries.test.ts | 46 ++++++++++++++ tests/unit/docker.test.ts | 56 +++++++++++++++++ 9 files changed, 464 insertions(+), 21 deletions(-) create mode 100644 tests/goaccess.test.ts create mode 100644 tests/netdata.test.ts create mode 100644 tests/registries.test.ts diff --git a/E2E_TEST_PLAN.md b/E2E_TEST_PLAN.md index 4ceef4b..70735b5 100644 --- a/E2E_TEST_PLAN.md +++ b/E2E_TEST_PLAN.md @@ -491,31 +491,31 @@ Tier: destructive for all three files. Require ephemeral mode. ### SDK prerequisite -- [ ] Correct `defaultRegistryId` versus backend `defaultPushRegistryId` in `caprover-api`. -- [ ] Publish and consume the corrected package. +- [x] Correct `defaultRegistryId` versus backend `defaultPushRegistryId` in [`caprover-api` PR #17](https://github.com/caprover/caprover-api/pull/17). +- [x] Publish and consume `caprover-api@0.0.25` through the [release PR #19](https://github.com/caprover/caprover-api/pull/19). ### Lightweight registry contracts -- [ ] Read the initial registry list. -- [ ] Reject an unknown default registry ID. -- [ ] Specify a reachable registry endpoint and deliberately invalid credentials; verify the expected authentication rejection and registry error status. -- [ ] Treat DNS failures, connection failures, and timeouts as test failures. They must not satisfy the invalid-credentials assertion. +- [x] Read the initial registry list. +- [x] Reject an unknown default registry ID. +- [x] Specify a reachable registry endpoint and deliberately invalid credentials; verify the expected authentication rejection and registry error status. +- [x] Treat DNS failures, connection failures, and timeouts as test failures. They must not satisfy the invalid-credentials assertion. ### GoAccess -- [ ] Preserve settings. -- [ ] Enable GoAccess and generate traffic. -- [ ] Retrieve report listings and a live report. -- [ ] Verify missing-app and missing-report behavior. -- [ ] Restore settings. +- [x] Preserve settings. +- [x] Enable GoAccess and generate traffic. +- [x] Retrieve report listings and a live report. +- [x] Verify missing-app and missing-report behavior. +- [x] Restore settings. ### NetData -- [ ] Preserve settings. -- [ ] Enable NetData with notifications disabled. -- [ ] Verify service and proxied endpoint. -- [ ] Disable NetData and verify removal. -- [ ] Restore settings. +- [x] Preserve settings. +- [x] Enable NetData with notifications disabled. +- [x] Verify service and proxied endpoint. +- [x] Disable NetData and verify removal. +- [x] Restore settings. Full self-hosted registry build-and-push coverage belongs in the controlled SSL workflow because enabling it requests a real certificate. diff --git a/package-lock.json b/package-lock.json index 0a18b2b..c0dd1b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "caprover-api": "0.0.23", + "caprover-api": "0.0.25", "ssh2": "1.17.0" }, "devDependencies": { @@ -478,9 +478,9 @@ } }, "node_modules/caprover-api": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/caprover-api/-/caprover-api-0.0.23.tgz", - "integrity": "sha512-s6HTp+eidvFmLqgPPvT2buJwQMzKdjHj9kBQh9nLh92f7m6CIcG2SEk75mWYeFQXHPofzmzG5wubGg09eJUcYA==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/caprover-api/-/caprover-api-0.0.25.tgz", + "integrity": "sha512-cuKiKN/N9Gq9yjNB6kYXQwy9ms22VsW0evjephoyR1u1yrrE6Oy+WUh5bIehmf69jyXgS1BDTZuviGw4+FlooA==", "license": "ISC", "dependencies": { "cross-fetch": "^4.1.0" diff --git a/package.json b/package.json index 405bcb0..4738078 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:all": "vitest run --mode all" }, "dependencies": { - "caprover-api": "0.0.23", + "caprover-api": "0.0.25", "ssh2": "1.17.0" }, "devDependencies": { diff --git a/src/clients/caprover.ts b/src/clients/caprover.ts index 80e8a0d..494782d 100644 --- a/src/clients/caprover.ts +++ b/src/clients/caprover.ts @@ -50,6 +50,15 @@ type OneClickAppRepositories = Awaited< type OneClickDeploymentState = Awaited< ReturnType > +type RegistriesResponse = Awaited< + ReturnType +> +type GoAccessInfo = CapRoverModels.GoAccessInfo +type GoAccessState = Awaited> +type GoAccessReport = Awaited< + ReturnType +>[number] +type NetDataInfo = CapRoverModels.NetDataInfo export interface OneClickValuePair { key: string @@ -129,6 +138,72 @@ export class CapRoverClient { ) } + getDockerRegistries(): Promise { + return this.request( + () => this.api.getDockerRegistries(), + 'listing Docker registries' + ) + } + + addDockerRegistry(registry: CapRoverModels.IRegistryInfo): Promise { + return this.request( + () => this.api.addDockerRegistry(registry), + 'adding Docker registry' + ) + } + + setDefaultPushDockerRegistry(id: string): Promise { + return this.request( + () => this.api.setDefaultPushDockerRegistry(id), + 'setting default push registry' + ) + } + + getGoAccessInfo(): Promise { + return this.request( + () => this.api.getGoAccessInfo(), + 'retrieving GoAccess settings' + ) + } + + updateGoAccessInfo(info: GoAccessInfo): Promise { + return this.request( + () => this.api.updateGoAccessInfo(info), + 'updating GoAccess settings', + DEPLOYMENT_TIMEOUT_MS + ) + } + + getGoAccessReports(appName: string): Promise { + return this.request( + () => this.api.getGoAccessReports(appName), + 'listing GoAccess reports' + ) + } + + getGoAccessReport(url: string): Promise { + return this.request( + () => this.api.getGoAccessReport(url), + 'retrieving GoAccess report', + DEPLOYMENT_TIMEOUT_MS + ) + } + + getNetDataInfo(): Promise { + return this.request( + () => this.api.getNetDataInfo(), + 'retrieving NetData settings' + ) + } + + updateNetDataInfo(info: NetDataInfo): Promise { + return this.request( + () => this.api.updateNetDataInfo(info), + 'updating NetData settings', + DEPLOYMENT_TIMEOUT_MS + ) + } + setDiskCleanupSettings(settings: DiskCleanupSettings): Promise { return this.request( () => @@ -489,4 +564,9 @@ export type { OneClickAppRepositories, OneClickAppsResponse, OneClickDeploymentState, + RegistriesResponse, + GoAccessInfo, + GoAccessState, + GoAccessReport, + NetDataInfo, } diff --git a/src/inspectors/docker.ts b/src/inspectors/docker.ts index 6f1c593..6212280 100644 --- a/src/inspectors/docker.ts +++ b/src/inspectors/docker.ts @@ -127,6 +127,39 @@ const VOLUME_MARKER_PATH = '/e2e-volume/marker' export class DockerInspector { constructor(private readonly ssh: SshClient) {} + async getContainerState( + name: string + ): Promise<'absent' | 'running' | 'stopped'> { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { + throw new Error(`Unsafe Docker container name: ${name}`) + } + const result = await this.ssh.exec(`docker inspect ${shellQuote(name)}`) + if (result.exitCode !== 0) { + if ( + /no such (object|container)/i.test( + `${result.stdout}\n${result.stderr}` + ) + ) + return 'absent' + throw new Error( + `docker inspect failed for ${name}: ${result.stderr.trim()}` + ) + } + const containers = parseJson>( + result.stdout, + `container ${name}` + ) + if ( + containers.length !== 1 || + typeof containers[0].State?.Running !== 'boolean' + ) { + throw new Error( + `Docker returned no valid state for container ${name}` + ) + } + return containers[0].State.Running ? 'running' : 'stopped' + } + async validateEnvironment(): Promise { const result = await this.exec("docker info --format '{{json .Swarm}}'") const swarm = parseJson(result.stdout, 'Docker info') diff --git a/tests/goaccess.test.ts b/tests/goaccess.test.ts new file mode 100644 index 0000000..60c6816 --- /dev/null +++ b/tests/goaccess.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from 'vitest' +import { GoAccessInfo } from '../src/clients/caprover' +import { waitForServiceStable } from '../src/helpers/deployment' +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 NGINX_IMAGE = + 'nginx:1.29.8-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de' +const CONTAINER = 'captain-goaccess-container' + +test('GoAccess records routed traffic and serves a live report', async () => { + requireEphemeral() + await withTestContext(async (context, cleanup, rootDomain) => { + const { runId } = createTestNames() + const appName = `e2e-${runId}-goaccess` + const appUrl = `http://${appName}.${rootDomain}` + const api = context.caprover + + cleanUpApp(context, cleanup, appName) + await api.createApp(appName) + await api.deployImage(appName, NGINX_IMAGE) + await waitForServiceStable(context, appName) + await context.http.waitUntilReachable(appUrl, 'Welcome to nginx!') + + const original = await api.getGoAccessInfo() + const restore: GoAccessInfo = { + isEnabled: original.isEnabled, + data: { + rotationFrequencyCron: original.data.rotationFrequencyCron, + logRetentionDays: original.data.logRetentionDays ?? 180, + }, + } + cleanup.add(async () => { + await api.updateGoAccessInfo(restore) + await eventually( + async () => { + expect(await api.getGoAccessInfo()).toEqual(restore) + expect( + await context.docker.getContainerState(CONTAINER) + ).toBe(original.isEnabled ? 'running' : 'absent') + }, + { + timeoutMs: 60_000, + description: 'GoAccess settings and container restoration', + } + ) + }) + + const enabled: GoAccessInfo = { + isEnabled: true, + data: { rotationFrequencyCron: '0 0 1 * *', logRetentionDays: 7 }, + } + await api.updateGoAccessInfo(enabled) + await eventually( + async () => { + expect(await api.getGoAccessInfo()).toEqual(enabled) + expect(await context.docker.getContainerState(CONTAINER)).toBe( + 'running' + ) + }, + { timeoutMs: 60_000, description: 'GoAccess container startup' } + ) + + for (let request = 0; request < 3; request++) { + expect((await context.http.get(appUrl)).status).toBe(200) + } + + const reports = await api.getGoAccessReports(appName) + const live = reports.find( + (report) => + report.domainName === `${appName}.${rootDomain}` && + report.name.endsWith('--Live.html') + ) + expect(live).toBeDefined() + expect(live?.url).toMatch( + new RegExp(`^/user/system/goaccess/${appName}/files/`) + ) + + await eventually( + async () => { + const html = await api.getGoAccessReport(live!.url) + expect(html.length).toBeGreaterThan(500) + expect(html.toLowerCase()).toContain(' { + requireEphemeral() + await withTestContext(async (context, cleanup) => { + const api = context.caprover + const original = await api.getNetDataInfo() + cleanup.add(async () => { + await api.updateNetDataInfo(original) + await eventually( + async () => { + expect(await api.getNetDataInfo()).toEqual(original) + expect( + await context.docker.getContainerState(CONTAINER) + ).toBe(original.isEnabled ? 'running' : 'absent') + }, + { + timeoutMs: 60_000, + description: 'NetData settings and container restoration', + } + ) + }) + + const enabled: NetDataInfo = { + isEnabled: true, + netDataUrl: '', + data: { + smtp: { + to: '', + hostname: '', + server: '', + port: '', + allowNonTls: '', + username: '', + password: '', + }, + slack: { hook: '', channel: '' }, + telegram: { chatId: '', botToken: '' }, + pushBullet: { apiToken: '', fallbackEmail: '' }, + }, + } + await api.updateNetDataInfo(enabled) + await eventually( + async () => { + expect(await api.getNetDataInfo()).toMatchObject({ + isEnabled: true, + data: enabled.data, + }) + expect(await context.docker.getContainerState(CONTAINER)).toBe( + 'running' + ) + }, + { timeoutMs: 60_000, description: 'NetData container startup' } + ) + + const { caproverUrl, caproverPassword } = loadConfig() + const cookie = await loginCookie(caproverUrl, caproverPassword) + const proxyUrl = `${caproverUrl}/net-data-monitor/` + await eventually( + async () => { + const response = await context.http.get(proxyUrl, { + headers: { Cookie: cookie }, + }) + expect(response.status).toBe(200) + expect(response.body.length).toBeGreaterThan(100) + expect(response.body.toLowerCase()).toContain('netdata') + }, + { + timeoutMs: 60_000, + description: 'cookie-authenticated NetData proxy', + } + ) + + await api.updateNetDataInfo({ ...enabled, isEnabled: false }) + await eventually( + async () => { + expect((await api.getNetDataInfo()).isEnabled).toBe(false) + expect(await context.docker.getContainerState(CONTAINER)).toBe( + 'absent' + ) + }, + { timeoutMs: 60_000, description: 'NetData container removal' } + ) + + await eventually( + async () => { + const response = await context.http.get(proxyUrl, { + headers: { Cookie: cookie }, + }) + expect(response.status).toBe(500) + expect(response.body).toContain('NetData is not running!') + }, + { + timeoutMs: 60_000, + description: 'disabled NetData proxy response', + } + ) + }) +}) + +async function loginCookie(baseUrl: string, password: string): Promise { + const response = await fetch(`${baseUrl}/api/v2/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + signal: AbortSignal.timeout(10_000), + }) + const result = (await response.json()) as { status?: number } + if (response.status !== 200 || result.status !== 100) { + throw new Error( + `Cookie login failed with HTTP ${response.status}, status ${result.status ?? 'missing'}` + ) + } + const match = response.headers + .get('set-cookie') + ?.match(/(?:^|,\s*)captainCookieAuth=([^;,\s]+)/) + if (!match) throw new Error('Cookie login did not return captainCookieAuth') + return `captainCookieAuth=${match[1]}` +} diff --git a/tests/registries.test.ts b/tests/registries.test.ts new file mode 100644 index 0000000..82093b5 --- /dev/null +++ b/tests/registries.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest' +import { createTestNames } from '../src/helpers/names' +import { withTestContext } from '../src/helpers/test-context' +import { requireEphemeral } from '../src/test-selection' + +test('registry validation and rejected credentials preserve the registry state', async () => { + requireEphemeral() + await withTestContext(async (context) => { + const { runId } = createTestNames() + const api = context.caprover + const initial = await api.getDockerRegistries() + expect(Array.isArray(initial.registries)).toBe(true) + if (initial.defaultPushRegistryId !== undefined) { + expect(initial.registries.map((registry) => registry.id)).toContain( + initial.defaultPushRegistryId + ) + } + + await expect( + api.setDefaultPushDockerRegistry(`e2e-missing-registry-${runId}`) + ).rejects.toMatchObject({ captainStatus: 1111 }) + expect(await api.getDockerRegistries()).toEqual(initial) + + // A 1112 alone can also represent a network failure. Check the actual + // CapRover host's path to the registry immediately before authentication. + const probe = await context.ssh.exec( + 'curl --connect-timeout 5 --max-time 10 -sS -D - -o /dev/null https://ghcr.io/v2/', + 15_000 + ) + expect(probe.exitCode, `Registry probe failed: ${probe.stderr}`).toBe(0) + expect(probe.stdout).toMatch(/^HTTP\/\S+ 401\b/m) + expect(probe.stdout).toMatch(/^www-authenticate:\s*Bearer\b/im) + + await expect( + api.addDockerRegistry({ + id: '', + registryUser: `e2e-${runId}`, + registryPassword: `invalid-${runId}`, + registryDomain: 'ghcr.io', + registryImagePrefix: `e2e-${runId}`, + registryType: 'REMOTE_REG', + }) + ).rejects.toMatchObject({ captainStatus: 1112 }) + expect(await api.getDockerRegistries()).toEqual(initial) + }) +}) diff --git a/tests/unit/docker.test.ts b/tests/unit/docker.test.ts index 3211673..19d7888 100644 --- a/tests/unit/docker.test.ts +++ b/tests/unit/docker.test.ts @@ -4,6 +4,62 @@ import { DockerInspector } from '../../src/inspectors/docker' const docker = new DockerInspector({} as SshClient) +describe('DockerInspector standalone container state', () => { + test.each([ + [true, 'running'], + [false, 'stopped'], + ] as const)('reads Running=%s as %s', async (running, expected) => { + const exec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify([{ State: { Running: running } }]), + stderr: '', + exitCode: 0, + }) + const inspector = new DockerInspector({ exec } as unknown as SshClient) + await expect( + inspector.getContainerState('captain-netdata-container') + ).resolves.toBe(expected) + expect(exec).toHaveBeenCalledExactlyOnceWith( + "docker inspect 'captain-netdata-container'" + ) + }) + + test('treats a missing container as absent and propagates other failures', async () => { + const exec = vi + .fn() + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Error: No such object: captain-goaccess-container', + exitCode: 1, + }) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Cannot connect to the Docker daemon', + exitCode: 1, + }) + const inspector = new DockerInspector({ exec } as unknown as SshClient) + await expect( + inspector.getContainerState('captain-goaccess-container') + ).resolves.toBe('absent') + await expect( + inspector.getContainerState('captain-goaccess-container') + ).rejects.toThrow('Cannot connect to the Docker daemon') + }) + + test('rejects unsafe names and invalid inspect results', async () => { + const exec = vi + .fn() + .mockResolvedValue({ stdout: '[]', stderr: '', exitCode: 0 }) + const inspector = new DockerInspector({ exec } as unknown as SshClient) + await expect( + inspector.getContainerState('container; command') + ).rejects.toThrow('Unsafe Docker container name') + expect(exec).not.toHaveBeenCalled() + await expect( + inspector.getContainerState('captain-netdata-container') + ).rejects.toThrow('no valid state') + }) +}) + describe('DockerInspector.imageMatches', () => { test('matches an exact image tag', () => { expect( From e9bf75c89993f92b74b45826a46ec6e9ffe28c8e Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Tue, 22 Sep 2026 21:54:02 -0700 Subject: [PATCH 2/3] test: assert normalized NetData SMTP response --- tests/netdata.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/netdata.test.ts b/tests/netdata.test.ts index 6a03492..b8b49f0 100644 --- a/tests/netdata.test.ts +++ b/tests/netdata.test.ts @@ -49,10 +49,17 @@ test('NetData runs behind the cookie-authenticated proxy and stops when disabled await api.updateNetDataInfo(enabled) await eventually( async () => { - expect(await api.getNetDataInfo()).toMatchObject({ + const current = await api.getNetDataInfo() + expect(current).toMatchObject({ isEnabled: true, - data: enabled.data, + data: { + slack: enabled.data.slack, + telegram: enabled.data.telegram, + pushBullet: enabled.data.pushBullet, + }, }) + // The backend normalizes SMTP to {} when username is empty. + expect(current.data.smtp).toEqual({}) expect(await context.docker.getContainerState(CONTAINER)).toBe( 'running' ) From ab0195e3d6dd8532dd180a63ccb651994268c4e2 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Tue, 22 Sep 2026 22:16:51 -0700 Subject: [PATCH 3/3] docs: mark PR17 validated by fresh-server suite --- E2E_TEST_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/E2E_TEST_PLAN.md b/E2E_TEST_PLAN.md index 70735b5..2a5b8c6 100644 --- a/E2E_TEST_PLAN.md +++ b/E2E_TEST_PLAN.md @@ -628,7 +628,7 @@ This table should be updated whenever `caprover-api` adds or removes a public me - [x] PR14 merged ([caprover-e2e PR #33](https://github.com/caprover/caprover-e2e/pull/33)) - [x] PR15 merged ([caprover-e2e PR #35](https://github.com/caprover/caprover-e2e/pull/35)) - [x] PR16 merged ([caprover-e2e PR #36](https://github.com/caprover/caprover-e2e/pull/36)) -- [ ] PR17 merged +- [x] PR17 merged ([caprover-e2e PR #38](https://github.com/caprover/caprover-e2e/pull/38); [fresh-server run](https://github.com/caprover/caprover-e2e/actions/runs/35820353928): 36 files, 104 tests) - [ ] PR18a Git webhook workflow implemented or linked to a follow-up issue - [ ] PR18b SSL and self-hosted registry workflow implemented or linked to a follow-up issue - [ ] PR18c multi-node workflow implemented or linked to a follow-up issue