From 52267af847555edf28340c1fafd6f48a1b2774e1 Mon Sep 17 00:00:00 2001 From: Tam-Leal Date: Thu, 21 May 2026 21:49:36 -0400 Subject: [PATCH] test: add CI suite, lib extract, and contributor release docs Extract shared validation and parsers to lib/, add 42 automated tests with GitHub Actions, stricter account name sanitization, CHANGELOG, PR template, and release policy. No version bump or user-facing release. --- .github/pull_request_template.md | 28 ++++ .github/workflows/test.yml | 23 +++ CHANGELOG.md | 42 +++++ CONTRIBUTING.md | 28 +++- README.md | 5 +- lib/dormant.js | 28 ++++ lib/git-parse.js | 59 +++++++ lib/security.js | 173 ++++++++++++++++++++ package.json | 6 +- scripts/pr-test-suite-body.md | 21 +++ server.js | 263 +++++++------------------------ test/api.integration.test.js | 177 +++++++++++++++++++++ test/dormant.test.js | 46 ++++++ test/git-parse.test.js | 53 +++++++ test/helpers/http.js | 18 +++ test/security.test.js | 146 +++++++++++++++++ 16 files changed, 907 insertions(+), 209 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 lib/dormant.js create mode 100644 lib/git-parse.js create mode 100644 lib/security.js create mode 100644 scripts/pr-test-suite-body.md create mode 100644 test/api.integration.test.js create mode 100644 test/dormant.test.js create mode 100644 test/git-parse.test.js create mode 100644 test/helpers/http.js create mode 100644 test/security.test.js diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8103832 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix (user-visible) +- [ ] Feature (user-visible) +- [ ] Documentation / site only +- [ ] Tests, CI, or refactor (no user-visible change) + +## Checklist + +- [ ] I ran `npm test` locally (required for code changes) +- [ ] I smoke-tested with `npm start` if `dashboard.html` or API behavior changed +- [ ] No secrets in commits (`config.json` tokens, `.env`, PATs) +- [ ] Updated `CHANGELOG.md` under **Unreleased** when the change is notable + +## Release + +- [ ] **No GitHub Release needed** (default for tests, docs, CI-only) +- [ ] **Release needed** — describe why users should download a new build: + + + +## Test plan + + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5207690 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Run test suite + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd16e7c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Automated test suite (`npm test`) using Node.js built-in test runner and supertest. +- Shared libraries under `lib/` for security validation, git status parsing, and dormant repo logic. +- GitHub Actions workflow to run tests on push and pull requests to `main`. +- `CHANGELOG.md`, pull request template, and release policy in `CONTRIBUTING.md`. + +### Changed + +- `server.js` imports validation helpers from `lib/` (behavior preserved; easier to test). +- Account name validation now rejects names that require stripping unsafe characters (e.g. `bad name!`, `work;rm`). + +### Security + +- Stricter `sanitizeAccountName` so shell-like input cannot be silently normalized into a valid account id. + +## [1.2.0] - 2026-05-21 + +### Added + +- Five-step account setup with GitHub-aligned SSH and fine-grained PAT guidance. +- Optional token storage in the OS credential store (not in `config.json`). +- Official GitHub `known_hosts` fingerprints and SSH verify feedback in setup status. +- Dormant repo filter and terminology (replaces stale/inactive). +- Modal close on outside click and Escape; segmented repo action bar; cloned repo green border. + +### Changed + +- Activity Log removed; feedback via toasts and SSE. +- README, privacy page, and site copy aligned with real token and dormant behavior. + +[Unreleased]: https://github.com/gitdock-dev/gitdock/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/gitdock-dev/gitdock/releases/tag/v1.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fb0b85..5638d77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,32 @@ npm start Hub runs at **http://localhost:3848** by default. +## Testing + +GitDock uses the Node.js built-in test runner (`node --test`) plus [supertest](https://github.com/ladjs/supertest) for HTTP checks. + +| Command | What it runs | +|---------|----------------| +| `npm test` | Full suite (security, git parsers, dormant logic, API integration) | +| `npm run test:unit` | Pure library tests only (no server) | +| `npm run test:api` | Express API integration tests in an isolated temp workspace | + +Tests live under `test/`. Shared validation logic is in `lib/` so the server and tests stay aligned. + +Before opening a PR, run `npm test` and ensure it passes. + +## Releases and versioning + +We use [Semantic Versioning](https://semver.org/). Not every merged PR becomes a GitHub Release. + +| Change type | Version bump | GitHub Release (binaries) | +|-------------|--------------|---------------------------| +| Bug fix users depend on | Patch (e.g. 1.2.1) | Yes, when the fix matters for downloaded builds | +| New UX or API behavior | Minor (e.g. 1.3.0) | Yes, when bundling several user-visible changes | +| Tests, CI, docs, refactors | None required | No | + +Track user-facing work in [CHANGELOG.md](CHANGELOG.md) under **Unreleased**. When cutting a release, move items to a version section, bump `package.json`, tag `vX.Y.Z`, and publish the release (that triggers the build workflow). + ## Code style - **Language:** All code and comments in English. @@ -55,7 +81,7 @@ Hub runs at **http://localhost:3848** by default. ## Submitting changes 1. **Fork** the repository and create a branch from `main` (e.g. `feat/your-feature` or `fix/issue-description`). -2. **Make your changes** and test locally (`npm start` and, if relevant, the Hub). +2. **Make your changes** and run the automated suite: `npm test` (unit + API integration). Also smoke-test the dashboard when UI changes: `npm start`. 3. **Commit** with clear messages (e.g. `feat: add X`, `fix: resolve Y`). 4. **Push** to your fork and open a **Pull Request** against `gitdock-dev/gitdock` `main`. 5. Describe what you changed and why. Reference any related issues. diff --git a/README.md b/README.md index 02bb97f..57143c5 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,9 @@ npm run dev # Check if it's running curl http://127.0.0.1:3847/api/health + +# Run automated tests (contributors) +npm test ``` ### GitHub CLI @@ -526,7 +529,7 @@ In the local GitDock dashboard use the dashboard’s **Configure Hub** to set th ## Contributing -We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style (vanilla JS, no frameworks), and how to submit pull requests and report issues. +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, `npm test`, release policy, code style (vanilla JS, no frameworks), and how to submit pull requests and report issues. See [CHANGELOG.md](CHANGELOG.md) for version history. --- diff --git a/lib/dormant.js b/lib/dormant.js new file mode 100644 index 0000000..577f579 --- /dev/null +++ b/lib/dormant.js @@ -0,0 +1,28 @@ +/** + * Dormant repo detection (mirrors dashboard logic for tests and docs). + */ + +function isDormant(repo, dormantDays) { + const days = Number(dormantDays); + if (!Number.isFinite(days) || days < 1) return false; + const refDate = + repo.isCloned && repo.lastCommit && repo.lastCommit.date + ? repo.lastCommit.date + : repo.updatedAt; + if (!refDate) return false; + const elapsed = Math.floor((Date.now() - new Date(refDate)) / 86400000); + return elapsed > days; +} + +function dormantPeriodLabel(dormantDays) { + const d = Number(dormantDays); + if (d <= 30) return "1 month"; + if (d <= 90) return "3 months"; + if (d <= 180) return "6 months"; + return "1 year"; +} + +module.exports = { + isDormant, + dormantPeriodLabel, +}; diff --git a/lib/git-parse.js b/lib/git-parse.js new file mode 100644 index 0000000..cec2910 --- /dev/null +++ b/lib/git-parse.js @@ -0,0 +1,59 @@ +/** + * Pure parsers for git command output (used by server and tests). + */ + +function parseStatusPorcelain(output) { + const lines = output ? output.split("\n").filter(Boolean) : []; + const files = []; + let stagedCount = 0; + let unstagedCount = 0; + let untrackedCount = 0; + let conflictCount = 0; + for (const line of lines) { + const xy = line.slice(0, 2); + const x = xy[0]; + const y = xy[1]; + const filePath = line.slice(3).trim().replace(/^["']|["']$/g, ""); + if (filePath) { + let status = "modified"; + if (xy === "??") status = "untracked"; + else if (x === "A" || x === "M" || x === "D" || x === "R" || x === "C") status = "added"; + else if (y === "M" || y === "D") status = "modified"; + else if (x === "D" || y === "D") status = "deleted"; + else if (x === "U" || y === "U") status = "unmerged"; + files.push({ path: filePath, status }); + } + if (xy === "??") { + untrackedCount += 1; + } else { + if (x !== " " && x !== "?") stagedCount += 1; + if (y !== " " && y !== "?") unstagedCount += 1; + if (x === "U" || y === "U") conflictCount += 1; + } + } + return { files, summary: { stagedCount, unstagedCount, untrackedCount, conflictCount } }; +} + +/** Parse "git status -sb" first line for branch, upstream, ahead, behind */ +function parseStatusBranchLine(line) { + if (!line || !line.startsWith("## ")) { + return { branch: "unknown", hasUpstream: false, ahead: 0, behind: 0, upstreamRef: null }; + } + const rest = line.slice(3).trim(); + const branchMatch = rest.match(/^([^\s.]+)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?/); + const branch = branchMatch ? branchMatch[1] : "unknown"; + const upstreamRef = branchMatch && branchMatch[2] ? branchMatch[2] : null; + const bracket = branchMatch && branchMatch[3] ? branchMatch[3] : ""; + let ahead = 0; + let behind = 0; + const aheadM = bracket.match(/ahead\s+(\d+)/); + const behindM = bracket.match(/behind\s+(\d+)/); + if (aheadM) ahead = parseInt(aheadM[1], 10) || 0; + if (behindM) behind = parseInt(behindM[1], 10) || 0; + return { branch, hasUpstream: !!upstreamRef, ahead, behind, upstreamRef }; +} + +module.exports = { + parseStatusPorcelain, + parseStatusBranchLine, +}; diff --git a/lib/security.js b/lib/security.js new file mode 100644 index 0000000..0889b10 --- /dev/null +++ b/lib/security.js @@ -0,0 +1,173 @@ +/** + * Security helpers and input validation (shared by server and tests). + */ +const path = require("path"); + +const isWindows = process.platform === "win32"; + +function sanitizeAccountName(name) { + if (!name || typeof name !== "string") return null; + const trimmed = name.trim(); + const normalized = trimmed.toLowerCase(); + const clean = normalized.replace(/[^a-z0-9\-]/g, ""); + if (clean !== normalized || clean.length === 0 || clean.length > 64) return null; + return clean; +} + +function sanitizeRepoName(name) { + if (!name || typeof name !== "string") return null; + const clean = name.replace(/[^a-zA-Z0-9\-_.]/g, ""); + if (clean !== name || clean.length === 0 || clean.includes("..")) return null; + return clean; +} + +function sanitizeOwnerName(name) { + if (!name || typeof name !== "string") return null; + const clean = name.replace(/[^a-zA-Z0-9\-_.]/g, ""); + if (clean !== name || clean.length === 0 || clean.includes("..")) return null; + return clean; +} + +function sanitizeSshHostAlias(host) { + if (!host || typeof host !== "string") return null; + const trimmed = host.trim(); + if (trimmed.length === 0 || trimmed.length > 128) return null; + if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) return null; + return trimmed; +} + +function sanitizeBranchName(name) { + if (!name || typeof name !== "string") return null; + const clean = name.trim().replace(/[^a-zA-Z0-9\-_.\/]/g, ""); + if (clean.length === 0 || clean.length > 200) return null; + if (clean.includes("..")) return null; + return clean; +} + +function sanitizeCommitMessage(msg) { + if (!msg || typeof msg !== "string") return ""; + const trimmed = msg.trim().slice(0, 2048); + return trimmed.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n"); +} + +function sanitizeCommitHash(hash) { + if (!hash || typeof hash !== "string") return ""; + const trimmed = hash.trim().toLowerCase(); + if (!/^[a-f0-9]+$/.test(trimmed)) return ""; + if (trimmed.length < 7 || trimmed.length > 40) return ""; + return trimmed; +} + +function sanitizeStashRef(ref) { + if (!ref || typeof ref !== "string") return null; + const trimmed = ref.trim(); + if (!/^stash@\{\d+\}$/.test(trimmed)) return null; + return trimmed; +} + +function isPathInsideDir(baseDir, candidatePath) { + const base = path.resolve(baseDir); + const cand = path.resolve(candidatePath); + const baseNorm = isWindows ? base.toLowerCase() : base; + const candNorm = isWindows ? cand.toLowerCase() : cand; + const baseWithSep = baseNorm.endsWith(path.sep) ? baseNorm : baseNorm + path.sep; + return candNorm === baseNorm || candNorm.startsWith(baseWithSep); +} + +function parseGitHubRepoUrl(input) { + if (!input || typeof input !== "string") return null; + const raw = input.trim(); + if (!raw || raw.length > 2048) return null; + + let s = raw; + if (!/^https?:\/\//i.test(s) && /^github\.com\//i.test(s)) { + s = "https://" + s; + } + + try { + if (/^https?:\/\//i.test(s)) { + const u = new URL(s); + if (!/^github\.com$/i.test(u.hostname)) return null; + const parts = u.pathname.replace(/^\/+|\/+$/g, "").split("/"); + if (parts.length < 2) return null; + const owner = sanitizeOwnerName(parts[0]); + const repo = sanitizeRepoName(String(parts[1]).replace(/\.git$/i, "")); + if (!owner || !repo) return null; + return { owner, repo }; + } + } catch (e) { + // fall through + } + + const sshMatch = s.match(/^git@github\.com(?:-[a-zA-Z0-9_-]+)?:([^\/\s]+)\/([^\/\s]+?)(?:\.git)?$/); + if (sshMatch) { + const owner = sanitizeOwnerName(sshMatch[1]); + const repo = sanitizeRepoName(sshMatch[2]); + if (!owner || !repo) return null; + return { owner, repo }; + } + + return null; +} + +function parseGitHubOwnerRepoFromRemote(remoteUrl) { + const parsed = parseGitHubRepoUrl(remoteUrl); + if (parsed) return parsed; + if (!remoteUrl || typeof remoteUrl !== "string") return null; + const m = remoteUrl.trim().match(/^git@[^:]+:([^\/\s]+)\/([^\/\s]+?)(?:\.git)?$/); + if (!m) return null; + const owner = sanitizeOwnerName(m[1]); + const repo = sanitizeRepoName(m[2]); + if (!owner || !repo) return null; + return { owner, repo }; +} + +const GITHUB_KNOWN_HOSTS_MARKER = "# --- GitHub host keys (managed by GitDock) ---"; +const GITHUB_KNOWN_HOSTS_END = "# --- End GitHub host keys ---"; +const GITHUB_OFFICIAL_KNOWN_HOSTS_LINES = [ + "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", + "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=", + "github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=", +]; + +function githubOfficialKeysInKnownHosts(content) { + if (!content || typeof content !== "string") return false; + return GITHUB_OFFICIAL_KNOWN_HOSTS_LINES.every((line) => content.includes(line)); +} + +function checkRateLimit(buckets, bucketKey, maxRequests, windowMs) { + const now = Date.now(); + const entry = buckets.get(bucketKey); + if (!entry || now > entry.resetAt) { + buckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); + return true; + } + if (entry.count >= maxRequests) return false; + entry.count++; + return true; +} + +function configJsonHasNoTokenFields(config) { + const raw = JSON.stringify(config || {}); + return !/\btoken\b/i.test(raw) && !/\bapiKey\b/i.test(raw) && !/\bpat\b/i.test(raw); +} + +module.exports = { + sanitizeAccountName, + sanitizeRepoName, + sanitizeOwnerName, + sanitizeSshHostAlias, + sanitizeBranchName, + sanitizeCommitMessage, + sanitizeCommitHash, + sanitizeStashRef, + isPathInsideDir, + parseGitHubRepoUrl, + parseGitHubOwnerRepoFromRemote, + GITHUB_KNOWN_HOSTS_MARKER, + GITHUB_KNOWN_HOSTS_END, + GITHUB_OFFICIAL_KNOWN_HOSTS_LINES, + githubOfficialKeysInKnownHosts, + checkRateLimit, + configJsonHasNoTokenFields, +}; diff --git a/package.json b/package.json index 845b322..4dee9d7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", + "test": "node --test test/security.test.js test/git-parse.test.js test/dormant.test.js test/api.integration.test.js", + "test:unit": "node --test test/security.test.js test/git-parse.test.js test/dormant.test.js", + "test:api": "node --test test/api.integration.test.js", "build:bundle": "esbuild server.js --bundle --platform=node --outfile=dist/server.bundle.js --external:child_process --external:fs --external:path --external:os --external:https --external:http --external:crypto --external:net --external:tls --external:url --external:stream --external:zlib --external:events --external:util --external:querystring --external:buffer" }, "bin": "server.js", @@ -19,6 +22,7 @@ }, "devDependencies": { "esbuild": "^0.24.0", - "postject": "^1.0.0-alpha.4" + "postject": "^1.0.0-alpha.4", + "supertest": "^7.1.0" } } diff --git a/scripts/pr-test-suite-body.md b/scripts/pr-test-suite-body.md new file mode 100644 index 0000000..cdcdf74 --- /dev/null +++ b/scripts/pr-test-suite-body.md @@ -0,0 +1,21 @@ +## Summary + +Adds an OSS-grade automated test suite, CI on `main`, shared `lib/` modules, and contributor docs so we can merge safely without cutting a release for every PR. + +## What changed + +- **`lib/`** — `security.js`, `git-parse.js`, `dormant.js` (shared with `server.js` and tests) +- **`test/`** — 42 tests: sanitization, git parsers, dormant logic, API integration (isolated temp workspace via `GITDOCK_TEST`) +- **`.github/workflows/test.yml`** — runs `npm test` on push/PR to `main` +- **`server.js`** — imports from `lib/`; test mode; stricter account name validation (rejects names that need character stripping) +- **Docs** — `CHANGELOG.md` (Unreleased), PR template, release policy in `CONTRIBUTING.md` + +## Release + +**No GitHub Release or version bump.** This is infrastructure and a small security hardening on account names. Binaries stay at v1.2.0 until a future user-visible release (e.g. v1.3.0). + +## Test plan + +- [x] `npm install && npm test` (42 passing) +- [ ] After merge: confirm GitHub Actions **Tests** workflow is green on `main` +- [ ] Optional: enable branch protection requiring the Tests check diff --git a/server.js b/server.js index 33543dd..037ec19 100644 --- a/server.js +++ b/server.js @@ -40,9 +40,36 @@ const execBase = path.basename(process.execPath, ".exe").toLowerCase(); const isStandalone = execBase === "gitdock"; const APP_DIR = isPkg || isStandalone ? path.dirname(process.execPath) : __dirname; const workspaceModule = require("./workspace"); +const security = require("./lib/security"); +const gitParse = require("./lib/git-parse"); +const { + sanitizeAccountName, + sanitizeRepoName, + sanitizeOwnerName, + sanitizeSshHostAlias, + sanitizeBranchName, + sanitizeCommitMessage, + sanitizeCommitHash, + sanitizeStashRef, + isPathInsideDir, + parseGitHubRepoUrl, + parseGitHubOwnerRepoFromRemote, + GITHUB_KNOWN_HOSTS_MARKER, + GITHUB_KNOWN_HOSTS_END, + GITHUB_OFFICIAL_KNOWN_HOSTS_LINES, + githubOfficialKeysInKnownHosts, +} = security; + let BASE_DIR = (isPkg || isStandalone) ? (workspaceModule.loadWorkspace() || path.dirname(process.execPath)) : __dirname; let CONFIG_PATH = path.join(BASE_DIR, "config.json"); +if (process.env.GITDOCK_TEST === "1") { + const testRoot = process.env.GITDOCK_TEST_ROOT || path.join(os.tmpdir(), `gitdock-test-${process.pid}`); + BASE_DIR = testRoot; + CONFIG_PATH = path.join(BASE_DIR, "config.json"); + fs.mkdirSync(BASE_DIR, { recursive: true }); +} + function reloadBaseDirFromWorkspace() { if (!isPkg && !isStandalone) return; const ws = workspaceModule.loadWorkspace(); @@ -152,15 +179,7 @@ const MAX_SSE_CLIENTS = 50; // --- Rate limiting (in-memory, no external dependency) --- const rateLimitBuckets = new Map(); function checkRateLimit(bucketKey, maxRequests, windowMs) { - const now = Date.now(); - const entry = rateLimitBuckets.get(bucketKey); - if (!entry || now > entry.resetAt) { - rateLimitBuckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); - return true; - } - if (entry.count >= maxRequests) return false; - entry.count++; - return true; + return security.checkRateLimit(rateLimitBuckets, bucketKey, maxRequests, windowMs); } // --- Middleware --- @@ -223,6 +242,13 @@ app.get("/", (req, res) => { // Workspace setup API app.get("/api/workspace/status", (req, res) => { const workspace = require("./workspace"); + if (process.env.GITDOCK_TEST === "1") { + return res.json({ + configured: true, + path: BASE_DIR, + defaultPath: workspace.getDefaultWorkspacePath(), + }); + } const ws = workspace.loadWorkspace(); res.json({ configured: !!ws, @@ -338,55 +364,6 @@ app.get("/config.json", (req, res) => res.status(404).send("Not found")); // --- Helpers --- -function sanitizeAccountName(name) { - if (!name || typeof name !== "string") return null; - const clean = name.trim().toLowerCase().replace(/[^a-z0-9\-]/g, ""); - if (clean.length === 0 || clean.length > 64) return null; - return clean; -} - -function sanitizeRepoName(name) { - // Only allow alphanumeric, hyphens, underscores, dots - if (!name || typeof name !== "string") return null; - const clean = name.replace(/[^a-zA-Z0-9\-_.]/g, ""); - if (clean !== name || clean.length === 0 || clean.includes("..")) return null; - return clean; -} - -function sanitizeOwnerName(name) { - // GitHub owners: user/org. Keep same safety rules as repo name. - if (!name || typeof name !== "string") return null; - const clean = name.replace(/[^a-zA-Z0-9\-_.]/g, ""); - if (clean !== name || clean.length === 0 || clean.includes("..")) return null; - return clean; -} - -function sanitizeSshHostAlias(host) { - // SSH host alias is used as "git@{alias}" and as "Host {alias}" in ssh config. - // Restrict strictly to avoid surprising behavior and shell injection risk. - if (!host || typeof host !== "string") return null; - const trimmed = host.trim(); - if (trimmed.length === 0 || trimmed.length > 128) return null; - if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) return null; - return trimmed; -} - -// Published by GitHub for ~/.ssh/known_hosts (verify when GitHub rotates keys): -// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints -// https://docs.github.com/en/rest/meta/meta#get-github-meta-information -const GITHUB_KNOWN_HOSTS_MARKER = "# --- GitHub host keys (managed by GitDock) ---"; -const GITHUB_KNOWN_HOSTS_END = "# --- End GitHub host keys ---"; -const GITHUB_OFFICIAL_KNOWN_HOSTS_LINES = [ - "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", - "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=", - "github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=", -]; - -function githubOfficialKeysInKnownHosts(content) { - if (!content || typeof content !== "string") return false; - return GITHUB_OFFICIAL_KNOWN_HOSTS_LINES.every((line) => content.includes(line)); -} - /** Add GitHub's documented host key lines to ~/.ssh/known_hosts (no ssh-keyscan). */ function ensureGitHubKnownHosts() { const sshDir = ensureSSHDir(); @@ -502,63 +479,6 @@ function testAccountSshConnection(host, expectedGithubUser) { return { tested: true, ok: false, reason, message }; } -function parseGitHubRepoUrl(input) { - if (!input || typeof input !== "string") return null; - const raw = input.trim(); - if (!raw || raw.length > 2048) return null; - - // Accept common forms: - // - https://github.com/OWNER/REPO(.git) - // - git@github.com:OWNER/REPO(.git) - // - git@github.com-:OWNER/REPO(.git) - // - github.com/OWNER/REPO(.git) - let s = raw; - if (!/^https?:\/\//i.test(s) && /^github\.com\//i.test(s)) { - s = "https://" + s; - } - - // https URL - try { - if (/^https?:\/\//i.test(s)) { - const u = new URL(s); - if (!/^github\.com$/i.test(u.hostname)) return null; - const parts = u.pathname.replace(/^\/+|\/+$/g, "").split("/"); - if (parts.length < 2) return null; - const owner = sanitizeOwnerName(parts[0]); - const repo = sanitizeRepoName(String(parts[1]).replace(/\.git$/i, "")); - if (!owner || !repo) return null; - return { owner, repo }; - } - } catch (e) { - // fall through to SSH parsing - } - - // SSH forms - const sshMatch = s.match(/^git@github\.com(?:-[a-zA-Z0-9_-]+)?:([^\/\s]+)\/([^\/\s]+?)(?:\.git)?$/); - if (sshMatch) { - const owner = sanitizeOwnerName(sshMatch[1]); - const repo = sanitizeRepoName(sshMatch[2]); - if (!owner || !repo) return null; - return { owner, repo }; - } - - return null; -} - -function parseGitHubOwnerRepoFromRemote(remoteUrl) { - const parsed = parseGitHubRepoUrl(remoteUrl); - if (parsed) return parsed; - - // Also accept plain git@:OWNER/REPO.git that isn't github.com-* (rare) - if (!remoteUrl || typeof remoteUrl !== "string") return null; - const m = remoteUrl.trim().match(/^git@[^:]+:([^\/\s]+)\/([^\/\s]+?)(?:\.git)?$/); - if (!m) return null; - const owner = sanitizeOwnerName(m[1]); - const repo = sanitizeRepoName(m[2]); - if (!owner || !repo) return null; - return { owner, repo }; -} - function getRepoPath(accountName, repoName) { const account = validateAccount(accountName); if (!account) return null; @@ -570,17 +490,6 @@ function getRepoPath(accountName, repoName) { return repoPath; } -function isPathInsideDir(baseDir, candidatePath) { - // Ensure candidatePath is within baseDir (prefix-safe). - // Use case-insensitive comparison on Windows. - const base = path.resolve(baseDir); - const cand = path.resolve(candidatePath); - const baseNorm = isWindows ? base.toLowerCase() : base; - const candNorm = isWindows ? cand.toLowerCase() : cand; - const baseWithSep = baseNorm.endsWith(path.sep) ? baseNorm : baseNorm + path.sep; - return candNorm === baseNorm || candNorm.startsWith(baseWithSep); -} - function makeUniqueLocalRepoName({ accountName, desiredName, fallbackHint }) { // Ensures we don't overwrite an existing folder. // Returns a sanitized folder name that does not exist yet. @@ -614,35 +523,6 @@ function makeUniqueLocalRepoName({ accountName, desiredName, fallbackHint }) { return null; } -function sanitizeBranchName(name) { - if (!name || typeof name !== "string") return null; - const clean = name.trim().replace(/[^a-zA-Z0-9\-_.\/]/g, ""); - if (clean.length === 0 || clean.length > 200) return null; - if (clean.includes("..")) return null; - return clean; -} - -function sanitizeCommitMessage(msg) { - if (!msg || typeof msg !== "string") return ""; - const trimmed = msg.trim().slice(0, 2048); - return trimmed.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n"); -} - -function sanitizeCommitHash(hash) { - if (!hash || typeof hash !== "string") return ""; - const trimmed = hash.trim().toLowerCase(); - if (!/^[a-f0-9]+$/.test(trimmed)) return ""; - if (trimmed.length < 7 || trimmed.length > 40) return ""; - return trimmed; -} - -function sanitizeStashRef(ref) { - if (!ref || typeof ref !== "string") return null; - const trimmed = ref.trim(); - if (!/^stash@\{\d+\}$/.test(trimmed)) return null; - return trimmed; -} - function runCommand(cmd, cwd = BASE_DIR, timeoutMs = 60000) { try { const result = execSync(cmd, { @@ -1090,34 +970,7 @@ function broadcastSSE(data) { } } -// Parse git status --porcelain lines into file list + summary counts (staged/unstaged/untracked/conflict) -function parseStatusPorcelain(output) { - const lines = output ? output.split("\n").filter(Boolean) : []; - const files = []; - let stagedCount = 0, unstagedCount = 0, untrackedCount = 0, conflictCount = 0; - for (const line of lines) { - const xy = line.slice(0, 2); - const x = xy[0], y = xy[1]; - const filePath = line.slice(3).trim().replace(/^["']|["']$/g, ""); - if (filePath) { - let status = "modified"; - if (xy === "??") status = "untracked"; - else if (x === "A" || x === "M" || x === "D" || x === "R" || x === "C") status = "added"; - else if (y === "M" || y === "D") status = "modified"; - else if (x === "D" || y === "D") status = "deleted"; - else if (x === "U" || y === "U") status = "unmerged"; - files.push({ path: filePath, status }); - } - if (xy === "??") { - untrackedCount += 1; - } else { - if (x !== " " && x !== "?") stagedCount += 1; - if (y !== " " && y !== "?") unstagedCount += 1; - if (x === "U" || y === "U") conflictCount += 1; - } - } - return { files, summary: { stagedCount, unstagedCount, untrackedCount, conflictCount } }; -} +const parseStatusPorcelain = gitParse.parseStatusPorcelain; // Detect merge or rebase in progress function getRepoOperation(repoPath) { @@ -1127,21 +980,7 @@ function getRepoOperation(repoPath) { return { isMerging: !!isMerging, isRebasing: !!isRebasing }; } -// Parse "git status -sb" first line for branch, upstream, ahead, behind -function parseStatusBranchLine(line) { - if (!line || !line.startsWith("## ")) return { branch: "unknown", hasUpstream: false, ahead: 0, behind: 0, upstreamRef: null }; - const rest = line.slice(3).trim(); - const branchMatch = rest.match(/^([^\s.]+)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?/); - const branch = branchMatch ? branchMatch[1] : "unknown"; - const upstreamRef = branchMatch && branchMatch[2] ? branchMatch[2] : null; - const bracket = branchMatch && branchMatch[3] ? branchMatch[3] : ""; - let ahead = 0, behind = 0; - const aheadM = bracket.match(/ahead\s+(\d+)/); - const behindM = bracket.match(/behind\s+(\d+)/); - if (aheadM) ahead = parseInt(aheadM[1], 10) || 0; - if (behindM) behind = parseInt(behindM[1], 10) || 0; - return { branch, hasUpstream: !!upstreamRef, ahead, behind, upstreamRef }; -} +const parseStatusBranchLine = gitParse.parseStatusBranchLine; function getRepoStatus(repoPath) { if (!fs.existsSync(repoPath)) return null; @@ -3442,11 +3281,23 @@ async function startHubAgent() { } } -// Best-effort housekeeping (safe; does not delete SSH keys) -cleanupOrphanedGitconfigs(); -syncManagedSshConfigToAccounts(); -ensureGitHubKnownHosts(); -loadPersistedTokensOnStartup(); - -startServer(PORT); -startHubAgent(); +module.exports = { + app, + startServer, + security, + gitParse, + loadConfig, + saveConfig, + getAccounts, + BASE_DIR: () => BASE_DIR, + CONFIG_PATH: () => CONFIG_PATH, +}; + +if (require.main === module) { + cleanupOrphanedGitconfigs(); + syncManagedSshConfigToAccounts(); + ensureGitHubKnownHosts(); + loadPersistedTokensOnStartup(); + startServer(PORT); + startHubAgent(); +} diff --git a/test/api.integration.test.js b/test/api.integration.test.js new file mode 100644 index 0000000..58c8743 --- /dev/null +++ b/test/api.integration.test.js @@ -0,0 +1,177 @@ +/** + * HTTP integration tests (Express app in GITDOCK_TEST mode). + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { describe, it, before } = require("node:test"); +const assert = require("node:assert/strict"); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gitdock-api-")); +process.env.GITDOCK_TEST = "1"; +process.env.GITDOCK_TEST_ROOT = testRoot; + +const { app } = require("../server"); +const security = require("../lib/security"); +const { api } = require("./helpers/http"); + +describe("API security middleware", () => { + it("serves health without CSRF header", async () => { + const res = await api(app).get("/api/health"); + assert.equal(res.status, 200); + assert.equal(res.body.status, "ok"); + }); + + it("blocks POST without x-gitdock header", async () => { + const request = require("supertest"); + const res = await request(app) + .post("/api/accounts") + .set("Host", "127.0.0.1") + .send({ name: "work", githubUser: "octo" }); + assert.equal(res.status, 403); + assert.match(res.body.error, /security header/i); + }); + + it("does not expose config.json", async () => { + const res = await api(app).get("/config.json"); + assert.equal(res.status, 404); + }); +}); + +describe("API version and workspace", () => { + it("returns package version", async () => { + const res = await api(app).get("/api/version"); + assert.equal(res.status, 200); + assert.match(res.body.version, /^\d+\.\d+\.\d+$/); + }); + + it("reports workspace status for test root", async () => { + const res = await api(app).get("/api/workspace/status"); + assert.equal(res.status, 200); + assert.equal(res.body.configured, true); + assert.equal(res.body.path, testRoot); + }); +}); + +describe("Account CRUD", () => { + it("creates, lists, updates, and deletes an account", async () => { + const create = await api(app).post("/api/accounts", { + name: "work", + githubUser: "octocat", + label: "Work", + email: "dev@example.com", + }); + assert.equal(create.status, 200); + assert.equal(create.body.ok, true); + + const raw = JSON.parse(fs.readFileSync(path.join(testRoot, "config.json"), "utf8")); + assert.equal(security.configJsonHasNoTokenFields(raw), true); + assert.equal(raw.accounts.work.githubUser, "octocat"); + + const list = await api(app).get("/api/accounts"); + assert.equal(list.status, 200); + assert.ok(list.body.accounts.some((a) => a.name === "work")); + + const update = await api(app).put("/api/accounts/work", { label: "Work updated" }); + assert.equal(update.status, 200); + + const del = await api(app).del("/api/accounts/work"); + assert.equal(del.status, 200); + }); + + it("rejects invalid account names", async () => { + const res = await api(app).post("/api/accounts", { + name: "bad name!", + githubUser: "octocat", + }); + assert.equal(res.status, 400); + }); + + it("rejects duplicate account names", async () => { + const first = await api(app).post("/api/accounts", { + name: "dup", + githubUser: "octocat", + }); + assert.equal(first.status, 200); + const second = await api(app).post("/api/accounts", { + name: "dup", + githubUser: "octocat", + }); + assert.equal(second.status, 409); + await api(app).del("/api/accounts/dup"); + }); + + it("rejects invalid GitHub usernames", async () => { + const res = await api(app).post("/api/accounts", { + name: "valid", + githubUser: "bad user!", + }); + assert.equal(res.status, 400); + }); +}); + +describe("Missing account handling", () => { + it("returns 404 for unknown account routes", async () => { + const missing = "no-such-account"; + const put = await api(app).put(`/api/accounts/${missing}`, { label: "x" }); + assert.equal(put.status, 404); + + const del = await api(app).del(`/api/accounts/${missing}`); + assert.equal(del.status, 404); + + const status = await api(app).get(`/api/accounts/${missing}/status`); + assert.equal(status.status, 404); + + const auth = await api(app).get(`/api/accounts/${missing}/auth/status`); + assert.equal(auth.status, 404); + + const token = await api(app).post(`/api/accounts/${missing}/auth/token`, { + token: "ghp_1234567890", + remember: false, + }); + assert.equal(token.status, 404); + }); +}); + +describe("Repo guards", () => { + before(async () => { + await api(app).post("/api/accounts", { + name: "dev", + githubUser: "octocat", + label: "Dev", + }); + }); + + it("rejects clone with invalid repo name", async () => { + const res = await api(app).post("/api/repos/clone", { + account: "dev", + repoName: "../escape", + }); + assert.ok(res.status >= 400); + }); + + it("rejects clone-url for non-github URL", async () => { + const res = await api(app).post("/api/repos/clone-url", { + account: "dev", + url: "https://example.com/a/b", + }); + assert.ok(res.status >= 400); + }); +}); + +describe("Auth token endpoint validation", () => { + before(async () => { + const names = Object.keys(JSON.parse(fs.readFileSync(path.join(testRoot, "config.json"), "utf8")).accounts || {}); + if (!names.includes("dev")) { + await api(app).post("/api/accounts", { name: "dev", githubUser: "octocat" }); + } + }); + + it("rejects short tokens without storing", async () => { + const res = await api(app).post("/api/accounts/dev/auth/token", { + token: "short", + remember: false, + }); + assert.equal(res.status, 400); + }); +}); diff --git a/test/dormant.test.js b/test/dormant.test.js new file mode 100644 index 0000000..8958349 --- /dev/null +++ b/test/dormant.test.js @@ -0,0 +1,46 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const dormant = require("../lib/dormant"); + +describe("dormant repo detection", () => { + const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString(); + + it("uses last local commit when cloned", () => { + const repo = { + isCloned: true, + lastCommit: { date: daysAgo(120) }, + updatedAt: daysAgo(1), + }; + assert.equal(dormant.isDormant(repo, 90), true); + assert.equal(dormant.isDormant(repo, 180), false); + }); + + it("uses GitHub updatedAt when not cloned", () => { + const repo = { + isCloned: false, + updatedAt: daysAgo(40), + }; + assert.equal(dormant.isDormant(repo, 30), true); + assert.equal(dormant.isDormant(repo, 90), false); + }); + + it("treats repo as dormant only after threshold days", () => { + const tenDaysAgo = new Date(Date.now() - 10 * 86400000).toISOString(); + const repo = { isCloned: false, updatedAt: tenDaysAgo }; + assert.equal(dormant.isDormant(repo, 7), true); + assert.equal(dormant.isDormant(repo, 10), false); + assert.equal(dormant.isDormant(repo, 11), false); + }); + + it("returns false without a reference date", () => { + assert.equal(dormant.isDormant({ isCloned: false }, 90), false); + }); +}); + +describe("dormantPeriodLabel", () => { + it("maps thresholds to human labels", () => { + assert.equal(dormant.dormantPeriodLabel(30), "1 month"); + assert.equal(dormant.dormantPeriodLabel(90), "3 months"); + assert.equal(dormant.dormantPeriodLabel(365), "1 year"); + }); +}); diff --git a/test/git-parse.test.js b/test/git-parse.test.js new file mode 100644 index 0000000..d57a051 --- /dev/null +++ b/test/git-parse.test.js @@ -0,0 +1,53 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const gitParse = require("../lib/git-parse"); + +describe("parseStatusPorcelain", () => { + it("counts staged, unstaged, untracked, and conflicts", () => { + const out = [ + "M file.txt", + " M other.txt", + "?? new.txt", + "UU conflict.txt", + ].join("\n"); + const r = gitParse.parseStatusPorcelain(out); + assert.equal(r.files.length, 4); + assert.equal(r.summary.untrackedCount, 1); + assert.equal(r.summary.conflictCount, 1); + assert.ok(r.summary.stagedCount >= 1); + assert.ok(r.summary.unstagedCount >= 1); + }); + + it("parses quoted file paths from porcelain output", () => { + const out = ' M "path with spaces.txt"\n'; + const { files, summary } = gitParse.parseStatusPorcelain(out); + assert.equal(files[0].path, "path with spaces.txt"); + assert.equal(summary.unstagedCount, 1); + }); + + it("returns empty summary for clean tree", () => { + const r = gitParse.parseStatusPorcelain(""); + assert.deepEqual(r.summary, { + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + conflictCount: 0, + }); + }); +}); + +describe("parseStatusBranchLine", () => { + it("parses branch with ahead/behind", () => { + const r = gitParse.parseStatusBranchLine("## main...origin/main [ahead 2, behind 1]"); + assert.equal(r.branch, "main"); + assert.equal(r.hasUpstream, true); + assert.equal(r.ahead, 2); + assert.equal(r.behind, 1); + }); + + it("handles detached or missing upstream", () => { + const r = gitParse.parseStatusBranchLine("## feature"); + assert.equal(r.branch, "feature"); + assert.equal(r.hasUpstream, false); + }); +}); diff --git a/test/helpers/http.js b/test/helpers/http.js new file mode 100644 index 0000000..0156fc0 --- /dev/null +++ b/test/helpers/http.js @@ -0,0 +1,18 @@ +/** + * Supertest helpers for GitDock API (localhost + CSRF header). + */ +const request = require("supertest"); + +const API_HEADERS = { "x-gitdock": "1", "Content-Type": "application/json" }; + +function api(app) { + const agent = request(app); + return { + get: (url) => agent.get(url).set("Host", "127.0.0.1"), + post: (url, body) => agent.post(url).set(API_HEADERS).set("Host", "127.0.0.1").send(body), + put: (url, body) => agent.put(url).set(API_HEADERS).set("Host", "127.0.0.1").send(body), + del: (url) => agent.delete(url).set(API_HEADERS).set("Host", "127.0.0.1"), + }; +} + +module.exports = { api, API_HEADERS }; diff --git a/test/security.test.js b/test/security.test.js new file mode 100644 index 0000000..90ab8f9 --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,146 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const os = require("os"); +const security = require("../lib/security"); + +describe("security sanitization", () => { + it("accepts valid account and repo names", () => { + assert.equal(security.sanitizeAccountName("work"), "work"); + assert.equal(security.sanitizeRepoName("my-app"), "my-app"); + assert.equal(security.sanitizeOwnerName("Tam-Leal"), "Tam-Leal"); + }); + + it("rejects path traversal in repo names", () => { + assert.equal(security.sanitizeRepoName("../etc"), null); + assert.equal(security.sanitizeRepoName("foo..bar"), null); + }); + + it("rejects shell-like account names", () => { + assert.equal(security.sanitizeAccountName("work;rm"), null); + assert.equal(security.sanitizeAccountName(""), null); + assert.equal(security.sanitizeAccountName("bad name!"), null); + assert.equal(security.sanitizeAccountName("a".repeat(65)), null); + }); + + it("normalizes case but keeps hyphens", () => { + assert.equal(security.sanitizeAccountName("Work-Dev"), "work-dev"); + }); + + it("restricts SSH host aliases", () => { + assert.equal(security.sanitizeSshHostAlias("github.com-work"), "github.com-work"); + assert.equal(security.sanitizeSshHostAlias("host$(whoami)"), null); + }); + + it("validates commit hash and stash ref", () => { + assert.equal(security.sanitizeCommitHash("abc1234"), "abc1234"); + assert.equal(security.sanitizeCommitHash("not-a-hash"), ""); + assert.equal(security.sanitizeStashRef("stash@{0}"), "stash@{0}"); + assert.equal(security.sanitizeStashRef("stash@0"), null); + }); + + it("sanitizes branch names and blocks traversal", () => { + assert.equal(security.sanitizeBranchName("feature/login"), "feature/login"); + assert.equal(security.sanitizeBranchName("../../../main"), null); + assert.equal(security.sanitizeBranchName(""), null); + }); + + it("normalizes commit messages", () => { + assert.equal(security.sanitizeCommitMessage(" fix: typo "), "fix: typo"); + const long = "a".repeat(3000); + assert.equal(security.sanitizeCommitMessage(long).length, 2048); + assert.equal( + security.sanitizeCommitMessage("line1\r\n\r\n\r\n\r\nline2"), + "line1\n\nline2" + ); + }); +}); + +describe("parseGitHubRepoUrl", () => { + it("parses https and ssh github URLs", () => { + assert.deepEqual(security.parseGitHubRepoUrl("https://github.com/octo/Hello-World"), { + owner: "octo", + repo: "Hello-World", + }); + assert.deepEqual(security.parseGitHubRepoUrl("git@github.com-work:octo/Hello-World.git"), { + owner: "octo", + repo: "Hello-World", + }); + }); + + it("rejects non-github hosts", () => { + assert.equal(security.parseGitHubRepoUrl("https://gitlab.com/a/b"), null); + }); + + it("parses github.com/owner/repo without scheme", () => { + assert.deepEqual(security.parseGitHubRepoUrl("github.com/octo/Hello-World.git"), { + owner: "octo", + repo: "Hello-World", + }); + }); +}); + +describe("parseGitHubOwnerRepoFromRemote", () => { + it("parses generic git@host remotes", () => { + assert.deepEqual(security.parseGitHubOwnerRepoFromRemote("git@gitlab.com:octo/Hello-World.git"), { + owner: "octo", + repo: "Hello-World", + }); + }); + + it("prefers GitHub URL rules when host is github.com", () => { + assert.deepEqual( + security.parseGitHubOwnerRepoFromRemote("https://github.com/octo/Hello-World"), + { owner: "octo", repo: "Hello-World" } + ); + }); + + it("returns null for malformed remotes", () => { + assert.equal(security.parseGitHubOwnerRepoFromRemote("not-a-remote"), null); + }); +}); + +describe("isPathInsideDir", () => { + it("blocks escaping the workspace base", () => { + const base = path.join(os.tmpdir(), "gitdock-path-test"); + const inside = path.join(base, "work", "repo"); + const outside = path.join(base, "..", "outside"); + assert.equal(security.isPathInsideDir(base, inside), true); + assert.equal(security.isPathInsideDir(base, outside), false); + }); +}); + +describe("github known_hosts fingerprints", () => { + it("includes all official GitHub lines", () => { + const content = security.GITHUB_OFFICIAL_KNOWN_HOSTS_LINES.join("\n"); + assert.equal(security.githubOfficialKeysInKnownHosts(content), true); + assert.equal(security.githubOfficialKeysInKnownHosts("github.com ssh-ed25519 AAA"), false); + }); + + it("expects three key types per GitHub documentation", () => { + assert.equal(security.GITHUB_OFFICIAL_KNOWN_HOSTS_LINES.length, 3); + assert.ok(security.GITHUB_OFFICIAL_KNOWN_HOSTS_LINES.every((l) => l.startsWith("github.com "))); + }); +}); + +describe("checkRateLimit", () => { + it("allows burst then blocks within window", () => { + const buckets = new Map(); + assert.equal(security.checkRateLimit(buckets, "test", 2, 60_000), true); + assert.equal(security.checkRateLimit(buckets, "test", 2, 60_000), true); + assert.equal(security.checkRateLimit(buckets, "test", 2, 60_000), false); + }); +}); + +describe("configJsonHasNoTokenFields", () => { + it("flags configs that would store secrets on disk", () => { + assert.equal( + security.configJsonHasNoTokenFields({ accounts: { work: { githubUser: "u" } } }), + true + ); + assert.equal( + security.configJsonHasNoTokenFields({ accounts: { work: { token: "ghp_x" } } }), + false + ); + }); +});