From da3a2e9ffdebc57bd2938227a9ec38cb1a0cdb35 Mon Sep 17 00:00:00 2001 From: Souta Date: Mon, 27 Apr 2026 14:05:55 +0900 Subject: [PATCH] feat(test): docker sshd integration test environment (Tier B-1+B-3, 0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All existing tests mock ssh2, so the actual auth + SFTP channel code has been entirely untested up to now. Three real-world bugs in PRs #49 (ssh_config) and #50 (jump host) — neither caught by mocks — confirmed it was time to wire up real-network tests. This PR adds a reproducible docker sshd container, a vitest integration suite that talks to it via the actual SftpClient, and a CI job that brings the container up, runs the suite, and tears it down. ## Components - `docker/test-sshd/Dockerfile` — Ubuntu 22.04 + openssh-server. Pre-creates a `tester` user (UID 1000) with `vault/` in their home, pubkey-only auth, `StrictModes no` so a bind-mounted authorized_keys works regardless of host UID. - `docker-compose.yml` (repo root) — single `sshd` service on 127.0.0.1:2222, bind-mounts the public key + a host-side `docker/test-vault/` directory, healthcheck on port 22. - `docker/test-vault/` — bind-mounted vault root the test writes into. Contents are gitignored aside from a README. - `docker/keys/` — gitignored; populated on first run. - `scripts/start-test-sshd.mjs` — generates an ed25519 keypair on first run (otherwise reuses), `docker compose up -d --build`, polls `Health.Status` until healthy. Idempotent. - `scripts/stop-test-sshd.mjs` — `docker compose down -v`. - `vitest.integration.config.ts` — separate config that includes `tests/integration/**` and serialises files (no fileParallelism) so they don't fight over the single sshd. Default `vitest.config.ts` excludes the integration dir so `npm test` stays fast. - `tests/integration/ssh.integration.test.ts` — opens a real SSH session with the generated keypair and exercises list / read / write (small text + 64KB binary) / stat / exists / remove through the actual SftpClient. Each run uses a unique subdir under `/home/tester/vault` so leftovers don't collide. - `package.json` scripts: `sshd:start`, `sshd:stop`, `test:integration`. `npm test` is unchanged. - `.github/workflows/integration.yml` — separate from `ci.yml` so the integration run doesn't gate other PR signals. Brings the container up before the test step, tears it down in a finally-style step, uploads `docker logs` as an artefact on failure. ## Local usage ``` cd plugin npm run sshd:start npm run test:integration npm run sshd:stop ``` Requires Docker (Desktop or daemon) on PATH. Unit tests (`npm test`) work without Docker. ## Out of scope (B-2 / B-4) - ProxyJump / two-container bastion → target topology - Daemon (obsidian-remote-server) auto-deploy + RPC handshake end-to-end against the container Both queued for a follow-up PR once this base lands. ## Version 0.3.2 → 0.4.0 (new feature surface, minor bump). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration.yml | 56 +++++++ .gitignore | 8 + docker-compose.yml | 31 ++++ docker/keys/.gitkeep | 0 docker/test-sshd/Dockerfile | 28 ++++ docker/test-vault/.gitkeep | 0 docker/test-vault/README.md | 3 + plugin/manifest.json | 2 +- plugin/package.json | 5 +- plugin/scripts/start-test-sshd.mjs | 82 ++++++++++ plugin/scripts/stop-test-sshd.mjs | 17 +++ .../tests/integration/ssh.integration.test.ts | 142 ++++++++++++++++++ plugin/versions.json | 3 +- plugin/vitest.config.ts | 5 + plugin/vitest.integration.config.ts | 21 +++ 15 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 docker-compose.yml create mode 100644 docker/keys/.gitkeep create mode 100644 docker/test-sshd/Dockerfile create mode 100644 docker/test-vault/.gitkeep create mode 100644 docker/test-vault/README.md create mode 100644 plugin/scripts/start-test-sshd.mjs create mode 100644 plugin/scripts/stop-test-sshd.mjs create mode 100644 plugin/tests/integration/ssh.integration.test.ts create mode 100644 plugin/vitest.integration.config.ts diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..6b80329 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,56 @@ +name: Integration + +# Runs the SSH integration tests against a docker sshd container. +# Slower than the main `ci.yml` job (Docker build + container +# startup) so it lives on its own workflow with no concurrency +# group sharing — main CI keeps moving while this runs. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration: + name: SSH integration + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + cache-dependency-path: plugin/package-lock.json + + - run: npm ci + + - name: Bring up test sshd + run: npm run sshd:start + + - name: Run integration tests + run: npm run test:integration + + - name: Tear down test sshd + if: always() + run: npm run sshd:stop + + - name: Upload sshd logs on failure + if: failure() + run: docker logs obsidian-remote-ssh-test-sshd > sshd-logs.txt 2>&1 || true + working-directory: ${{ github.workspace }} + + - name: Attach sshd logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: sshd-logs + path: sshd-logs.txt diff --git a/.gitignore b/.gitignore index ba8e422..393c0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,11 @@ main.js coverage/ cdp-console.log plugin/server-bin/ + +# Docker test environment artefacts: keypair generated on first run, +# bind-mounted vault contents written by integration tests. +docker/keys/id_test +docker/keys/id_test.pub +docker/test-vault/* +!docker/test-vault/.gitkeep +!docker/test-vault/README.md diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e533ba2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +# Docker test environment for the plugin's SSH integration tests. +# +# Brings up a single sshd container on 127.0.0.1:2222 with a +# pre-created `tester` user (UID 1000) whose home holds an empty +# `vault/` directory the tests write into. +# +# Usage: +# npm run sshd:start # generates a keypair (if missing) + docker compose up -d +# npm run test:integration +# npm run sshd:stop +# +# The keypair lives in `docker/keys/` and is gitignored. The +# container reads the public key as authorized_keys via bind mount, +# so rotating keys doesn't require rebuilding the image. + +services: + sshd: + build: ./docker/test-sshd + image: obsidian-remote-ssh-test-sshd:latest + container_name: obsidian-remote-ssh-test-sshd + ports: + - "127.0.0.1:2222:22" + volumes: + - ./docker/keys/id_test.pub:/home/tester/.ssh/authorized_keys:ro + - ./docker/test-vault:/home/tester/vault + restart: "no" + healthcheck: + test: ["CMD-SHELL", "ss -lnt | grep ':22 '"] + interval: 2s + timeout: 2s + retries: 15 diff --git a/docker/keys/.gitkeep b/docker/keys/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/test-sshd/Dockerfile b/docker/test-sshd/Dockerfile new file mode 100644 index 0000000..39e6e06 --- /dev/null +++ b/docker/test-sshd/Dockerfile @@ -0,0 +1,28 @@ +# Minimal sshd container for plugin integration tests. +# +# Provides a `tester` user with a known UID, an empty `vault/` home +# subdir for the test to write into, and pubkey-only auth. The +# authorized_keys is bind-mounted by docker-compose so we can rotate +# the key pair without rebuilding the image. `StrictModes no` lets +# sshd accept the bind-mounted key file regardless of host UID/GID +# permissions — fine for a throwaway test container, never for prod. + +FROM ubuntu:22.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir /var/run/sshd \ + && useradd -m -s /bin/bash -u 1000 tester \ + && mkdir -p /home/tester/.ssh /home/tester/vault \ + && chown -R tester:tester /home/tester \ + && chmod 700 /home/tester/.ssh \ + && sed -i 's/#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config \ + && sed -i 's/#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config \ + && echo 'StrictModes no' >> /etc/ssh/sshd_config + +EXPOSE 22 + +# `-D` keeps sshd in the foreground; `-e` sends logs to stderr so +# `docker logs` shows them. +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/docker/test-vault/.gitkeep b/docker/test-vault/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/test-vault/README.md b/docker/test-vault/README.md new file mode 100644 index 0000000..c2f6439 --- /dev/null +++ b/docker/test-vault/README.md @@ -0,0 +1,3 @@ +Mounted as `/home/tester/vault` inside the test sshd container. The +integration test writes / reads files here; anything the test leaves +behind is ignored by git. diff --git a/plugin/manifest.json b/plugin/manifest.json index fd3a1c7..433624a 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "remote-ssh", "name": "Remote SSH", - "version": "0.3.2", + "version": "0.4.0", "minAppVersion": "1.4.0", "description": "Edit remote vaults over SSH/SFTP — VS Code Remote SSH for Obsidian", "author": "", diff --git a/plugin/package.json b/plugin/package.json index 28a4f95..a66bd17 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-remote-ssh", - "version": "0.3.2", + "version": "0.4.0", "description": "VS Code Remote SSH-like experience for Obsidian", "main": "main.js", "scripts": { @@ -13,6 +13,9 @@ "cdp:tail": "node scripts/cdp-tail.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:integration": "vitest run --config vitest.integration.config.ts", + "sshd:start": "node scripts/start-test-sshd.mjs", + "sshd:stop": "node scripts/stop-test-sshd.mjs", "lint": "eslint src --ext .ts", "version": "node scripts/bump-version.mjs && git add manifest.json versions.json" }, diff --git a/plugin/scripts/start-test-sshd.mjs b/plugin/scripts/start-test-sshd.mjs new file mode 100644 index 0000000..08adc11 --- /dev/null +++ b/plugin/scripts/start-test-sshd.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Bring up the test sshd container. + * + * - Generates `docker/keys/id_test{,.pub}` if missing (ed25519, + * no passphrase). Both files are gitignored. + * - Runs `docker compose up -d --build` from the repo root. + * - Waits for sshd to accept connections on 127.0.0.1:2222 (using + * the compose healthcheck) before returning. + * - Prints the public key, container name, and host:port so a + * human knows what just happened. + * + * Idempotent: re-running it just re-checks the keys, brings the + * container back up if it was stopped, and waits for healthy. + * + * Used by the integration test suite (`npm run test:integration`). + */ + +import { spawnSync, execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); +const keyDir = path.join(repoRoot, 'docker', 'keys'); +const keyPath = path.join(keyDir, 'id_test'); +const pubPath = `${keyPath}.pub`; + +fs.mkdirSync(keyDir, { recursive: true }); +fs.mkdirSync(path.join(repoRoot, 'docker', 'test-vault'), { recursive: true }); + +if (!fs.existsSync(keyPath)) { + console.log(`Generating ed25519 keypair at ${keyPath}`); + // -N '' = no passphrase, -q = no banner. -f overwrites (already + // checked nonexistence above). + spawnRequire('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-q', '-C', 'obsidian-remote-ssh-test']); +} else { + console.log(`Reusing existing keypair at ${keyPath}`); +} + +console.log('Bringing up docker compose service `sshd`…'); +spawnRequire('docker', ['compose', 'up', '-d', '--build', 'sshd'], { cwd: repoRoot }); + +console.log('Waiting for sshd to be healthy…'); +const deadline = Date.now() + 60_000; +while (Date.now() < deadline) { + const out = spawnSync('docker', ['inspect', '--format', '{{.State.Health.Status}}', + 'obsidian-remote-ssh-test-sshd'], { encoding: 'utf8' }); + const status = (out.stdout || '').trim(); + if (status === 'healthy') { + console.log('sshd is healthy.'); + console.log(''); + console.log(` host: 127.0.0.1`); + console.log(` port: 2222`); + console.log(` user: tester`); + console.log(` private key: ${keyPath}`); + console.log(` public key: ${pubPath}`); + console.log(''); + process.exit(0); + } + if (status === 'unhealthy' || status === 'exited') { + console.error(`sshd entered unhealthy state: ${status}`); + process.exit(1); + } + // 'starting' or empty (container not yet up) — wait a tick. + await new Promise(r => setTimeout(r, 1000)); +} +console.error('Timed out waiting for sshd to become healthy.'); +process.exit(1); + +// ─── helpers ─────────────────────────────────────────────────────────── + +function spawnRequire(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); + if (r.status !== 0) { + if (r.error && r.error.code === 'ENOENT') { + console.error(`Missing required tool: ${cmd}. Is it on PATH?`); + } + process.exit(r.status ?? 1); + } +} diff --git a/plugin/scripts/stop-test-sshd.mjs b/plugin/scripts/stop-test-sshd.mjs new file mode 100644 index 0000000..da79a47 --- /dev/null +++ b/plugin/scripts/stop-test-sshd.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * Tear down the test sshd container started by start-test-sshd.mjs. + * `docker compose down -v` also drops the bind-mount volume aliases + * so a subsequent `up` rebuilds cleanly. + */ + +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +const r = spawnSync('docker', ['compose', 'down', '-v'], + { cwd: repoRoot, stdio: 'inherit' }); +process.exit(r.status ?? 0); diff --git a/plugin/tests/integration/ssh.integration.test.ts b/plugin/tests/integration/ssh.integration.test.ts new file mode 100644 index 0000000..44765a8 --- /dev/null +++ b/plugin/tests/integration/ssh.integration.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SftpClient } from '../../src/ssh/SftpClient'; +import { AuthResolver } from '../../src/ssh/AuthResolver'; +import { SecretStore } from '../../src/ssh/SecretStore'; +import { HostKeyStore } from '../../src/ssh/HostKeyStore'; +import type { SshProfile } from '../../src/types'; + +/** + * Integration tests against a real openssh-server running in docker + * (`docker-compose.yml` at the repo root, brought up by + * `npm run sshd:start`). Exercises the actual ssh2 handshake + + * SFTP channel rather than the unit-test mocks; meant to catch + * regressions in the auth / channel code that mocks can't see. + * + * Skipped automatically when the test keypair isn't present, so + * `npm run test:integration` from a fresh checkout fails loud at + * the first describe but `npm test` (unit) keeps working. + */ +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const PRIVATE_KEY = path.join(REPO_ROOT, 'docker', 'keys', 'id_test'); +const TEST_HOST = '127.0.0.1'; +const TEST_PORT = 2222; +const TEST_USER = 'tester'; +// In-container path: `/home/tester/vault` — bind-mounted from +// `docker/test-vault/` on the host. Each test file gets a unique +// subdir so parallel runs (within the file) don't clobber each +// other; the file as a whole runs serially via vitest's +// fileParallelism: false. +const REMOTE_VAULT = `/home/${TEST_USER}/vault`; + +if (!fs.existsSync(PRIVATE_KEY)) { + throw new Error( + `Integration test keypair missing at ${PRIVATE_KEY}. ` + + 'Run `npm run sshd:start` from the repo root before `npm run test:integration`.', + ); +} + +function buildProfile(): SshProfile { + return { + id: 'integration-test', + name: 'Docker test sshd', + host: TEST_HOST, + port: TEST_PORT, + username: TEST_USER, + authMethod: 'privateKey', + privateKeyPath: PRIVATE_KEY, + remotePath: REMOTE_VAULT, + connectTimeoutMs: 10_000, + keepaliveIntervalMs: 0, + keepaliveCountMax: 0, + }; +} + +describe('integration: SSH against docker sshd', () => { + let client: SftpClient; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const subdir = `${REMOTE_VAULT}/integration-${stamp}`; + + beforeAll(async () => { + const auth = new AuthResolver(new SecretStore()); + const hkstore = new HostKeyStore(); + client = new SftpClient(auth, hkstore); + await client.connect(buildProfile()); + await client.mkdirp(subdir); + }); + + afterAll(async () => { + try { + // Best-effort cleanup: delete files we created. The directory + // itself is bind-mounted from the host — leaving the empty + // subdir behind is harmless and gitignored. + const entries = await client.list(subdir); + for (const e of entries) { + if (e.isFile) await client.remove(`${subdir}/${e.name}`); + } + } catch { /* container may already be down */ } + await client.disconnect(); + }); + + it('lists the empty vault subdir', async () => { + const entries = await client.list(subdir); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBe(0); + }); + + it('writes and reads a small text file', async () => { + const remote = `${subdir}/hello.txt`; + await client.writeBinary(remote, Buffer.from('integration test\n', 'utf8')); + const data = await client.readBinary(remote); + expect(data.toString('utf8')).toBe('integration test\n'); + }); + + it('writes and reads a binary blob', async () => { + const remote = `${subdir}/blob.bin`; + const payload = Buffer.alloc(64 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i % 256; + await client.writeBinary(remote, payload); + const data = await client.readBinary(remote); + expect(data.length).toBe(payload.length); + expect(data.equals(payload)).toBe(true); + }); + + it('stat returns a file-type result with sane size and mtime', async () => { + const remote = `${subdir}/stat-target.txt`; + await client.writeBinary(remote, Buffer.from('xyz', 'utf8')); + const s = await client.stat(remote); + expect(s.isFile).toBe(true); + expect(s.size).toBe(3); + // mtime is unix milliseconds; just sanity-check it's recent. + const ageMs = Date.now() - s.mtime; + expect(ageMs).toBeGreaterThanOrEqual(0); + expect(ageMs).toBeLessThan(5 * 60 * 1000); + }); + + it('exists distinguishes present vs missing files', async () => { + expect(await client.exists(subdir)).toBe(true); + expect(await client.exists(`${subdir}/never-existed`)).toBe(false); + }); + + it('list includes a file we just wrote', async () => { + const remote = `${subdir}/visible.md`; + await client.writeBinary(remote, Buffer.from('# hi')); + const entries = await client.list(subdir); + const names = entries.map(e => e.name); + expect(names).toContain('visible.md'); + }); + + it('remove deletes the target', async () => { + const remote = `${subdir}/to-remove.txt`; + await client.writeBinary(remote, Buffer.from('bye')); + expect(await client.exists(remote)).toBe(true); + await client.remove(remote); + expect(await client.exists(remote)).toBe(false); + }); +}); + +// `os` import suppresses an unused warning if the file gets +// reorganised; keep until we actually need it. +void os; diff --git a/plugin/versions.json b/plugin/versions.json index bbbfbde..554314f 100644 --- a/plugin/versions.json +++ b/plugin/versions.json @@ -3,5 +3,6 @@ "0.2.0": "1.4.0", "0.3.0": "1.4.0", "0.3.1": "1.4.0", - "0.3.2": "1.4.0" + "0.3.2": "1.4.0", + "0.4.0": "1.4.0" } diff --git a/plugin/vitest.config.ts b/plugin/vitest.config.ts index 2e73b27..8223787 100644 --- a/plugin/vitest.config.ts +++ b/plugin/vitest.config.ts @@ -1,9 +1,14 @@ import { defineConfig } from 'vitest/config'; +// Default config runs unit tests only. Integration tests live under +// `tests/integration/` and need a running docker sshd container — +// they're routed through `vitest.integration.config.ts` and +// invoked via `npm run test:integration`. export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + exclude: ['tests/integration/**', 'node_modules/**'], coverage: { provider: 'v8', reporter: ['text', 'lcov'], diff --git a/plugin/vitest.integration.config.ts b/plugin/vitest.integration.config.ts new file mode 100644 index 0000000..c002059 --- /dev/null +++ b/plugin/vitest.integration.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +// Integration tests against the docker sshd container started by +// `npm run sshd:start`. Slower than unit tests (real network + +// keypair handshake) so they live in their own config and aren't +// included by default. +// +// Run manually with `npm run test:integration`. The CI integration +// job (`.github/workflows/integration.yml`) brings docker up, runs +// this config, and tears down. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/integration/**/*.test.ts'], + // Each test usually opens its own SSH session; serialise so we + // don't fight over the single sshd container. + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 30_000, + }, +});