From 5cb6a3cb52e3c9e2a413eabb3b083944303955e7 Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Tue, 22 Sep 2026 21:21:53 -0700 Subject: [PATCH] test: cover registries, GoAccess, and NetData --- E2E_TEST_PLAN.md | 32 +++++----- package-lock.json | 8 +-- package.json | 2 +- src/clients/caprover.ts | 77 +++++++++++++++++++++++ src/inspectors/docker.ts | 27 +++++++++ tests/goaccess.test.ts | 87 ++++++++++++++++++++++++++ tests/netdata.test.ts | 124 ++++++++++++++++++++++++++++++++++++++ tests/registries.test.ts | 48 +++++++++++++++ tests/unit/docker.test.ts | 51 ++++++++++++++++ 9 files changed, 435 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..a2bd411 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` ([SDK PR #17](https://github.com/caprover/caprover-api/pull/17)). +- [x] Publish and consume the corrected `caprover-api@0.0.25` package. ### 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 container 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..29b5afc 100644 --- a/src/clients/caprover.ts +++ b/src/clients/caprover.ts @@ -50,6 +50,13 @@ type OneClickAppRepositories = Awaited< type OneClickDeploymentState = Awaited< ReturnType > +type RegistriesResponse = Awaited< + ReturnType +> +type GoAccessInfo = Awaited> +type GoAccessSettings = Parameters[0] +type GoAccessReport = Awaited> +type NetDataInfo = Awaited> export interface OneClickValuePair { key: string @@ -104,6 +111,72 @@ export class CapRoverClient { return this.request(() => this.api.getAllNodes(), 'listing Swarm nodes') } + 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(registryId: string): Promise { + return this.request( + () => this.api.setDefaultPushDockerRegistry(registryId), + 'selecting default push registry' + ) + } + + getGoAccessInfo(): Promise { + return this.request( + () => this.api.getGoAccessInfo(), + 'retrieving GoAccess settings' + ) + } + + updateGoAccessInfo(settings: GoAccessSettings): Promise { + return this.request( + () => this.api.updateGoAccessInfo(settings), + 'updating GoAccess settings' + ) + } + + getGoAccessReports(appName: string): Promise { + return this.request( + () => this.api.getGoAccessReports(appName), + `listing GoAccess reports for ${appName}`, + DEPLOYMENT_TIMEOUT_MS + ) + } + + getGoAccessReport(reportUrl: string): Promise { + return this.request( + () => this.api.getGoAccessReport(reportUrl), + 'retrieving GoAccess report', + DEPLOYMENT_TIMEOUT_MS + ) + } + + getNetDataInfo(): Promise { + return this.request( + () => this.api.getNetDataInfo(), + 'retrieving NetData settings' + ) + } + + updateNetDataInfo(settings: NetDataInfo): Promise { + return this.request( + () => this.api.updateNetDataInfo(settings), + 'updating NetData settings', + DEPLOYMENT_TIMEOUT_MS + ) + } + getProFeaturesState(): Promise { return this.request( () => this.api.getProFeaturesState(), @@ -489,4 +562,8 @@ export type { OneClickAppRepositories, OneClickAppsResponse, OneClickDeploymentState, + RegistriesResponse, + GoAccessInfo, + GoAccessSettings, + NetDataInfo, } diff --git a/src/inspectors/docker.ts b/src/inspectors/docker.ts index 6f1c593..f2f6031 100644 --- a/src/inspectors/docker.ts +++ b/src/inspectors/docker.ts @@ -127,6 +127,33 @@ const VOLUME_MARKER_PATH = '/e2e-volume/marker' export class DockerInspector { constructor(private readonly ssh: SshClient) {} + async getContainerState( + name: string + ): Promise<'running' | 'stopped' | 'absent'> { + if (!/^captain-(?:goaccess|netdata)-container$/.test(name)) { + throw new Error(`Unexpected standalone container: ${name}`) + } + const result = await this.ssh.exec(`docker inspect ${shellQuote(name)}`) + if (result.exitCode !== 0) { + if (/no such (?:object|container)/i.test(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 an invalid state for ${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..9395970 --- /dev/null +++ b/tests/goaccess.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from 'vitest' +import { GoAccessSettings } from '../src/clients/caprover' +import { waitForImage } 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 IMAGE = + 'nginx:1.29.8-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de' +const CONTAINER = 'captain-goaccess-container' + +test('GoAccess generates a live report and restores its original configuration', async () => { + requireEphemeral() + await withTestContext(async (context, cleanup, rootDomain) => { + const api = context.caprover + const { runId } = createTestNames() + const appName = `e2e-${runId}-goaccess` + const appUrl = `http://${appName}.${rootDomain}` + + cleanUpApp(context, cleanup, appName) + await api.createApp(appName) + await api.deployImage(appName, IMAGE) + await waitForImage(context, appName, IMAGE) + await context.http.waitUntilReachable(appUrl, 'Welcome to nginx') + + const original = await api.getGoAccessInfo() + cleanup.add(async () => { + await api.updateGoAccessInfo(original as GoAccessSettings) + await eventually( + async () => { + expect(await api.getGoAccessInfo()).toEqual(original) + expect( + await context.docker.getContainerState(CONTAINER) + ).toBe(original.isEnabled ? 'running' : 'absent') + }, + { description: 'GoAccess settings and container restoration' } + ) + }) + + const enabled: GoAccessSettings = { + 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' + ) + }, + { description: 'GoAccess container to start' } + ) + + for (let index = 0; index < 3; index++) { + 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?.url).toMatch(/^\/user\/system\/goaccess\//) + await eventually( + async () => { + const html = await api.getGoAccessReport(live!.url) + expect(html.length).toBeGreaterThan(100) + 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 () => { + const restored = await api.getNetDataInfo() + expect(restored).toEqual(original) + expect( + await context.docker.getContainerState(CONTAINER) + ).toBe(original.isEnabled ? 'running' : 'absent') + }, + { + timeoutMs: 60_000, + description: 'NetData settings and container restoration', + } + ) + }) + + const settings: NetDataInfo = { + isEnabled: true, + netDataUrl: '', + data: { + smtp: { + to: '', + hostname: '', + server: '', + port: '', + allowNonTls: '', + username: '', + password: '', + }, + slack: { hook: '', channel: '' }, + telegram: { chatId: '', botToken: '' }, + pushBullet: { apiToken: '', fallbackEmail: '' }, + }, + } + const url = `${loadConfig().caproverUrl}/net-data-monitor/` + const cookie = await loginCookie( + loadConfig().caproverUrl, + loadConfig().caproverPassword + ) + + await api.updateNetDataInfo(settings) + await eventually( + async () => { + expect((await api.getNetDataInfo()).isEnabled).toBe(true) + expect(await context.docker.getContainerState(CONTAINER)).toBe( + 'running' + ) + }, + { timeoutMs: 60_000, description: 'NetData container to start' } + ) + await eventually( + async () => { + const response = await context.http.get(url, { + headers: { cookie }, + }) + expect(response.status).toBe(200) + expect(response.body.length).toBeGreaterThan(100) + expect(response.body.toLowerCase()).toMatch(/netdata|net data/) + }, + { + timeoutMs: 60_000, + description: 'cookie-authenticated NetData proxy', + } + ) + + await api.updateNetDataInfo({ ...settings, 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(url, { + headers: { 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), + }) + if (response.status !== 200) + throw new Error(`Cookie login HTTP ${response.status}`) + const envelope = (await response.json()) as { status?: number } + if (envelope.status !== 100) + throw new Error(`Cookie login status ${envelope.status}`) + const match = response.headers + .get('set-cookie') + ?.match(/(?:^|,\s*)captainCookieAuth=([^;,]+)/) + if (!match) throw new Error('Login did not set captainCookieAuth') + return `captainCookieAuth=${match[1]}` +} diff --git a/tests/registries.test.ts b/tests/registries.test.ts new file mode 100644 index 0000000..b8bc8a0 --- /dev/null +++ b/tests/registries.test.ts @@ -0,0 +1,48 @@ +import { CapRoverModels } from 'caprover-api' +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 leaves global state intact', async () => { + requireEphemeral() + await withTestContext(async ({ caprover: api, ssh }) => { + const initial = await api.getDockerRegistries() + expect(Array.isArray(initial.registries)).toBe(true) + if (initial.defaultPushRegistryId) { + expect(initial.registries.map((registry) => registry.id)).toContain( + initial.defaultPushRegistryId + ) + } + + const { runId } = createTestNames() + await expect( + api.setDefaultPushDockerRegistry(`e2e-missing-registry-${runId}`) + ).rejects.toMatchObject({ captainStatus: 1111 }) + expect(await api.getDockerRegistries()).toEqual(initial) + + // Probe from the Docker host: an API 1112 alone also covers network failures. + const probe = await ssh.exec( + 'curl --connect-timeout 5 --max-time 10 -sS -D - -o /dev/null https://ghcr.io/v2/' + ) + expect( + probe.exitCode, + `Registry probe failed: ${probe.stderr.trim()}` + ).toBe(0) + expect(probe.stdout).toMatch(/^HTTP\/\S+ 401\b/m) + expect(probe.stdout).toMatch(/^www-authenticate:\s*\S+/im) + + const registry: CapRoverModels.IRegistryInfo = { + id: '', + registryType: 'REMOTE_REG', + registryDomain: 'ghcr.io', + registryImagePrefix: `e2e-${runId}`, + registryUser: `e2e-invalid-${runId}`, + registryPassword: `e2e-invalid-${runId}`, + } + await expect(api.addDockerRegistry(registry)).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..a73cf89 100644 --- a/tests/unit/docker.test.ts +++ b/tests/unit/docker.test.ts @@ -4,6 +4,57 @@ 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).toHaveBeenCalledWith( + "docker inspect 'captain-netdata-container'" + ) + }) + + test('treats only a missing Docker object as absent', 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') + }) + + test('rejects arbitrary names before executing Docker', async () => { + const exec = vi.fn() + const inspector = new DockerInspector({ exec } as unknown as SshClient) + await expect( + inspector.getContainerState('other-container') + ).rejects.toThrow() + expect(exec).not.toHaveBeenCalled() + }) +}) + describe('DockerInspector.imageMatches', () => { test('matches an exact image tag', () => { expect(