diff --git a/.github/scripts/diff-to-test-prompt.sh b/.github/scripts/diff-to-test-prompt.sh new file mode 100755 index 00000000..c56346ab --- /dev/null +++ b/.github/scripts/diff-to-test-prompt.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# ============================================================================ +# diff-to-test-prompt.sh — Fork-diff to LLM test-generation prompt +# ============================================================================ +# Reads a git diff (stdin or file arg) and wraps it in a structured prompt +# for an LLM to generate tests. The diff IS the spec — every changed line is +# a behavioral claim that tests must verify. +# +# Usage: +# git diff upstream/main...HEAD | .github/scripts/diff-to-test-prompt.sh +# .github/scripts/diff-to-test-prompt.sh path/to/diff.txt +# +# Output: A self-contained prompt with workspace detection, test framework +# hints, and suggested test-file locations. +# +# Workspace detection (by path prefix): +# server/ → vitest (Nuxt env) → server/test/unit// +# cli/ → cargo test → cli/tests/ or inline #[cfg(test)] +# desktop/ → cargo test → desktop/src-tauri//tests/ +# libraries/ → cargo test → inline #[cfg(test)] +# other → vitest (generic) → /test/ +# ============================================================================ + +set -euo pipefail + +# ---- Help ------------------------------------------------------------------ +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + sed -n '3,19p' "$0" + exit 0 +fi + +# ---- Read diff ------------------------------------------------------------- +DIFF_CONTENT="" + +if [[ $# -ge 1 && -f "$1" ]]; then + DIFF_CONTENT="$(cat "$1")" +elif [[ ! -t 0 ]]; then + DIFF_CONTENT="$(cat)" +else + echo "ERROR: Provide a diff file or pipe diff to stdin." >&2 + echo "Usage: git diff upstream/main...HEAD | $0" >&2 + echo " $0 path/to/diff.txt" >&2 + exit 1 +fi + +if [[ -z "$DIFF_CONTENT" ]]; then + echo "ERROR: Empty diff input." >&2 + exit 1 +fi + +# ---- Workspace detection --------------------------------------------------- +detect_workspace() { + local diff="$1" + local workspaces=() + + if echo "$diff" | grep -q '^diff --git a/desktop/'; then + workspaces+=("desktop/") + fi + if echo "$diff" | grep -q '^diff --git a/cli/'; then + workspaces+=("cli/") + fi + if echo "$diff" | grep -q '^diff --git a/libraries/'; then + workspaces+=("libraries/") + fi + if echo "$diff" | grep -q '^diff --git a/server/'; then + workspaces+=("server/") + fi + if echo "$diff" | grep -q '^diff --git a/sites/'; then + workspaces+=("sites/") + fi + if [[ ${#workspaces[@]} -eq 0 ]]; then + echo "unknown" + else + printf '%s\n' "${workspaces[@]}" | sort -u | paste -sd ' ' - + fi +} + +detect_test_location() { + local diff="$1" + local file dir + + # Extract first changed file path, strip filename to get directory + file="$(echo "$diff" | grep '^diff --git' | head -1 | sed 's/^diff --git a\/\(.*\) b\/.*/\1/')" + dir="$(dirname "$file")" + + # Extract module name: the path segment after the workspace's source root. + # server/server/api/v1/users.ts → module=api + # server/server/internal/auth/ → module=auth + # cli/src/commands/upload.rs → module=commands + local module="" + + case "$dir" in + server/server/api/v1*) + module="api" + echo "server/test/unit/${module}/" + ;; + server/server/internal/*) + module="$(echo "$dir" | sed 's|server/server/internal/||; s|/.*||')" + echo "server/test/unit/${module}/" + ;; + server/components/*) + module="$(echo "$dir" | sed 's|server/components/||; s|/.*||')" + [[ -n "$module" ]] && echo "server/test/unit/components/${module}/" || echo "server/test/unit/components/" + ;; + server/pages/*) + echo "server/test/unit/pages/" + ;; + server/composables/*) + echo "server/test/unit/" + ;; + server/server/*) + echo "server/test/unit/misc/" + ;; + server/prisma/*) + echo "server/test/integration/" + ;; + cli/src/*) + module="$(echo "$dir" | sed 's|cli/src/||; s|/.*||')" + [[ -n "$module" ]] && echo "cli/tests/${module}/ or inline #[cfg(test)]" || echo "cli/tests/ or inline #[cfg(test)]" + ;; + desktop/src-tauri/*) + module="$(echo "$dir" | sed 's|desktop/src-tauri/||; s|/.*||')" + echo "desktop/src-tauri/${module}/tests/" + ;; + libraries/*) + echo "inline #[cfg(test)] mod tests { ... } in the source file" + ;; + sites/*) + echo "test/ (co-located with source workspace)" + ;; + *) + echo "test/ (co-located with source)" + ;; + esac +} + +WORKSPACES="$(detect_workspace "$DIFF_CONTENT")" +TEST_LOC="$(detect_test_location "$DIFF_CONTENT")" + +# ---- Test framework hints -------------------------------------------------- +FRAMEWORK_HINTS="" +case "$WORKSPACES" in + *server*) + FRAMEWORK_HINTS="Framework: vitest with Nuxt test environment (environment: 'nuxt') +Utilities: server/test/setup.ts, server/test/utils/db.ts +Pattern: describe -> it -> expect. Mock HTTP via MSW (server/test/mocks/). +Convention: one test file per module, co-located in server/test/unit/ or server/test/integration/" + ;; + *cli*|*desktop*|*libraries*) + FRAMEWORK_HINTS="Framework: cargo test (Rust) +Pattern: #[cfg(test)] mod tests { ... } with #[test] functions +Convention: integration tests in tests/ dir, unit tests inline" + ;; + *sites*) + FRAMEWORK_HINTS="Framework: vitest +Pattern: describe -> it -> expect" + ;; + *) + FRAMEWORK_HINTS="Framework: vitest (assumed) +Pattern: describe -> it -> expect" + ;; +esac + +# ---- Count stats ----------------------------------------------------------- +FILE_COUNT="$(echo "$DIFF_CONTENT" | grep -c '^diff --git' || true)" +LINE_COUNT="$(echo "$DIFF_CONTENT" | grep -c '^[+-]' || true)" +ADDED="$(echo "$DIFF_CONTENT" | grep -c '^+' || true)" +REMOVED="$(echo "$DIFF_CONTENT" | grep -c '^-' || true)" + +# ---- Build prompt ---------------------------------------------------------- +cat < ...; fn up() -> ... }` + `MockTailscale` +- **What to test:** + - `MockTailscale::new().start()` returns preconfigured success/error + - Consumer (remote/, process/) interacts via trait — tests inject mock + - Error path: Tailscale auth failure → graceful fallback, not crash + +### F4: Client-Server API Contract (HIGH) +- **Problem:** Desktop (Nuxt 4 + Tauri) calls Server (Nuxt 3 + Nitro) with no shared schema +- **Fix:** Generate OpenAPI from Nitro route types → verify desktop types match +- **What to test:** + - `/client/game/{id}` returns shape workspace expects + - New route added on server — desktop doesn't break (it just doesn't call it) + - Route removed — desktop's callers produce compile-time error + +### F5: Plugin Init Order (MEDIUM) +- **Problem:** 9 Nitro plugins (01- through 09-) with strict ordering +- **Fix:** Integration test verifying each plugin's postcondition after init +- **What to test:** + - `metadataHandler.providers.values()` is non-empty after plugin 03 + - `authManager.getEnabledAuthProviders()` returns expected set after plugin 04 + - Wrong prefix position → plugin init failure detected + +### F6: Tauri 7-Crate Boundaries (MEDIUM) +- **Problem:** `games` → `database`, `download_manager` → `games` — traits extracted? +- **Fix:** Per-crate trait boundary extraction + pipeline integration test +- **What to test:** + - libarchive writes archive → droplet reads + generates manifest → database stores + - In-memory FS fixture (tempdir) — no real Tailscale needed + +--- + +## 4. Merge-Validation CI Gates + +``` +PR MERGED → MERGE-VALIDATION WORKFLOW + │ + ├─ Stage 1: COMPILE + FORMAT (exists) + │ └─ pnpm build + cargo check + fmt checks + │ + ├─ Stage 2: CONTRACT GATE (NEW) + │ ├─ Generate OpenAPI from Nitro routes + │ ├─ Verify desktop client types match server API types + │ ├─ Prisma schema diff (optional: pg_dump --schema-only) + │ ├─ Tailscale trait compile-check + │ └─ FAIL → BLOCK MERGE + │ + ├─ Stage 3: INTEGRATION TESTS (NEW) + │ ├─ Server: vitest --integration (API + Prisma contract tests) + │ ├─ Metadata chain: vitest --testPathPattern=metadata-chain + │ ├─ Rust: cargo test --all (with MockTailscale) + │ ├─ Pipeline: libarchive→droplet→database tempdir test + │ └─ FAIL → BLOCK MERGE + │ + ├─ Stage 4: UNIT + COMPONENT (existing + expand) + │ └─ vitest + cargo test + │ + └─ Stage 5: E2E SMOKE (existing) + └─ Playwright smoke spec +``` + +**New CI time estimate: ~17 min (Stages 2+3). Existing ~8 min.** + +--- + +## 5. Creative Force-Multiplier Strategies + +### M1: Property-Based Testing Blitz +- `fast-check` already in deps (v4.9.0) +- **Target:** PriorityListIndexed sorting, auth token round-trips, URL validation, provider chain invariants +- **One test covers 100+ edge cases:** `fc.property(fc.array(fc.record({priority: fc.integer()})), arr => afterSort(arr)[0].priority >= afterSort(arr)[1].priority)` + +### M2: Mutation Testing (Stryker) +- Validate test QUALITY, not just line coverage +- Block PRs if mutation score drops below baseline +- **First target:** `server/server/internal/metadata/` — most logic-dense, least tested + +### M3: Fork-Diff as Test Oracle +- `git diff upstream/main...HEAD` → LLM prompt → test generation +- The diff IS the spec. Every changed line is a behavioral claim. +- **Pipeline:** `.github/scripts/diff-to-test-prompt.sh` + manual vitest generation + +### M4: Cross-Build CI Daisy-Chain +- Build BillyOutlast/drop artifacts, then run Drop-OSS/drop's test suite against them +- **Strongest regression signal:** If OSS tests pass with your builds, merge is safe +- **Step:** `git clone Drop-OSS/drop` → copy `server/.output/` → run OSS CI commands + +### M5: Agent Hook for Auto-Test Generation +- `.opencode/hooks/` or GitHub Action that triggers on PR modifying `server/server/internal/*.ts` +- Prompt: "Generate vitest tests covering edge cases for the changed module" +- **Integration:** CI validates that new/modified code has corresponding test file + +--- + +## 6. Implementation Phasing + +### Phase 1 — Foundation (Week 1-2) +- [x] `git remote add upstream git@github.com:Drop-OSS/drop.git` +- [x] Install `cargo-llvm-cov` + add to CI (droplet-ci, cli-ci, desktop-ci) +- [x] Extract `trait TailscaleProvider` + `MockTailscale` — unblocks all Tauri Rust testing +- [ ] Add `withTestTransaction` — blocked until Prisma models defined; flag as dependency +- [x] Add property-based test for PriorityListIndexed (fast-check, 1 file, immediate win) + +### Phase 2 — Security Tests (Week 2-4) +- [x] WebAuthn attestation test + gap document +- [x] OIDC group escalation test (mock provider) +- [x] Session fixation test (+ bug fix) +- [x] ACL confused deputy test +- [x] TOTP code generation/verification test +- [x] CA blacklist footgun test (+ bug fix) + +### Phase 3 — Integration Seams (Week 4-8) +- [x] PrismaRepository trait extraction — BLOCKED: schema.prisma is 24-line stub with 0 models. Generated client has 29 models inlined. Must restore schema.prisma first. +- [x] Metadata provider chain fallthrough tests (5 tests, parallel Promise.allSettled pattern) +- [x] Plugin init-ordering test (10 tests, structural + behavioral) +- [x] Cross-crate pipeline test (libarchive→droplet, 2 integration tests in droplet/tests/) +- [x] Fork-diff oracle script: `.github/scripts/diff-to-test-prompt.sh` + +### Phase 4 — CI Gates + Coverage (Week 8-12) — DEFERRED (multi-week effort) +- [ ] Contract gate (Stage 2): OpenAPI generation + desktop type verification +- [ ] Integration gate (Stage 3): metadata chain + Rust integration + pipeline +- [ ] Mutation testing baseline + CI gate +- [ ] Cross-build daisy-chain workflow +- [ ] Agent hook for auto-test generation on PRs + +### Phase 5 — Expansion (Week 12+) — DEFERRED (multi-week effort) +- [ ] Next.js test setup (`sites/promo/`) +- [ ] Nuxt 4 test setup (`desktop/main/`) +- [ ] E2E page-flow tests (when test DB + auth fixtures available) +- [ ] Non-blocking E2E gate (Stage 5) + +--- + +## 7. Success Criteria + +**Merge-blocking gates:** +- [ ] Contract Gate: all OpenAPI types match between server and desktop +- [ ] Integration Gate: metadata chain, Rust pipeline, Prisma contract tests all pass +- [ ] Security Gate: P0 threat scenarios proven mitigated + +**Coverage targets (realistic):** +- Server pure logic: **80%** (23 modules, ~200 functions) +- Server DB-dependent: **30%** (blocked on Prisma schema, then climbable) +- CLI: **60%** (lib.rs extraction + fixture-based tests) +- Desktop Rust: **20%** (database crate + trait-mocked crates) +- **Overall: 25-30%** — 10x from 1.17% + +**Long-term guardrails:** +- Coverage never drops below baseline (enforced in CI once >10%) +- Mutation score never drops (Stryker gate) +- New code requires test file (agent hook or lint rule) + +--- + +## 8. Acronyms & Key Files + +| Term | Meaning | +|---|---| +| P0/P1/P2 | Priority ranking in threat model | +| F1-F8 | Integration seam fault line ID | +| MSW | Mock Service Worker (HTTP mocking) | +| M1-M5 | Creative force-multiplier strategy | +| `withTestTransaction` | `server/test/utils/db.ts` — Prisma rollback helper | + +**Key files to reference:** +- `server/vitest.config.ts` — vitest config (Nuxt env, V8 coverage) +- `server/test/setup.ts` — global test setup (Nuxt stubs + MSW) +- `server/test/utils/db.ts` — Prisma transaction-per-test helper +- `server/test/mocks/` — MSW mocks (metadata, OIDC, JWT) +- `server/server/internal/` — 71 modules (23 pure logic, rest DB-dependent) +- `desktop/src-tauri/tailscale/src/lib.rs` — FFI boundary +- `.codecov.yml` — coverage gating config +- `security/risk-register.yaml` — 13 accepted risks diff --git a/libraries/droplet/tests/pipeline_test.rs b/libraries/droplet/tests/pipeline_test.rs new file mode 100644 index 00000000..b6e209b1 --- /dev/null +++ b/libraries/droplet/tests/pipeline_test.rs @@ -0,0 +1,288 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use droplet_rs::manifest::generate_manifest_rusty; + +/// Temporary directory guard — cleans up on drop. +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(prefix: &str) -> Self { + let mut path = std::env::temp_dir(); + let thread_id = std::thread::current().id(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + path.push(format!("{}_{:?}_{}", prefix, thread_id, ts)); + std::fs::create_dir_all(&path).expect("failed to create temp dir"); + TempDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).ok(); + } +} + +/// Convenient — write a file (and create parent dirs) in one call. +fn write_file(path: &std::path::Path, content: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("failed to create parent dirs"); + } + std::fs::write(path, content).unwrap_or_else(|e| panic!("failed to write {:?}: {}", path, e)); +} + +// --------------------------------------------------------------------------- +// Pipeline integration test +// --------------------------------------------------------------------------- +#[test] +fn directory_to_manifest_pipeline() { + // ---- 1. prepare test directory ---------------------------------------- + let tmp = TempDir::new("drop_pipeline_test"); + let dir = tmp.path().to_path_buf(); + + // Small file (fits in one chunk with others) + write_file(&dir.join("hello.txt"), b"Hello, World!"); + + // Binary-ish file + let binary_content: Vec = (0u8..255).cycle().take(4096).collect(); + write_file(&dir.join("data.bin"), &binary_content); + + // File in a sub-directory + write_file( + &dir.join("nested/readme.md"), + b"# Nested\n\nThis is a nested file.", + ); + + // Deeper nesting + write_file( + &dir.join("a/b/c/deep.txt"), + b"deeply nested file content here", + ); + + // Empty file + write_file(&dir.join("empty.dat"), b""); + + let total_expected_size = b"Hello, World!".len() as u64 + + 4096u64 + + b"# Nested\n\nThis is a nested file.".len() as u64 + + b"deeply nested file content here".len() as u64 + + 0u64; + + // ---- 2. generate manifest -------------------------------------------- + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let call_count = AtomicUsize::new(0); + + let manifest = rt + .block_on(generate_manifest_rusty( + &dir, + |_progress: f32| { + call_count.fetch_add(1, Ordering::Relaxed); + }, + |message: String| { + eprintln!("[manifest] {}", message); + }, + None::<&dyn droplet_rs::manifest::ManifestWriterFactory>, + None::<&tokio::sync::Semaphore>, + )) + .expect("generate_manifest_rusty should succeed"); + + // ---- 3. validate manifest structure ----------------------------------- + + // Version + assert_eq!(&manifest.version, "2", "manifest version should be \"2\""); + + // Key must be 16 random bytes (non-zero in practice) + assert_eq!(manifest.key.len(), 16, "key must be 16 bytes"); + + // Total size must match sum of all file contents + assert_eq!( + manifest.size, total_expected_size, + "manifest.size should equal sum of file sizes" + ); + + // Must have at least one chunk + assert!( + !manifest.chunks.is_empty(), + "manifest must have at least one chunk" + ); + + // Collect all file entries from all chunks + let all_files: Vec<_> = manifest + .chunks + .values() + .flat_map(|chunk| &chunk.files) + .collect(); + + // ---- 4. validate file entries ---------------------------------------- + let expected_files = [ + "hello.txt", + "data.bin", + "nested/readme.md", + "a/b/c/deep.txt", + "empty.dat", + ]; + + for expected in &expected_files { + assert!( + all_files.iter().any(|f| f.filename == *expected), + "manifest should contain file '{}'", + expected + ); + } + + // Check specific file sizes + for file_entry in &all_files { + match file_entry.filename.as_str() { + "hello.txt" => assert_eq!(file_entry.length, 13), + "data.bin" => assert_eq!(file_entry.length, 4096), + "empty.dat" => assert_eq!(file_entry.length, 0), + "nested/readme.md" => assert!( + file_entry.length > 0, + "readme.md should have non-zero length" + ), + "a/b/c/deep.txt" => assert!( + file_entry.length > 0, + "deep.txt should have non-zero length" + ), + other => panic!("unexpected file in manifest: {}", other), + } + } + + // ---- 5. validate chunk integrity ------------------------------------- + for (chunk_id, chunk) in &manifest.chunks { + assert!( + !chunk.files.is_empty(), + "chunk {} should have at least one file", + chunk_id + ); + assert!( + !chunk.checksum.is_empty(), + "chunk {} should have a checksum", + chunk_id + ); + assert_eq!( + chunk.iv.len(), + 16, + "chunk {} IV should be 16 bytes", + chunk_id + ); + } + + // ---- 6. verify progress callback was called --------------------------- + assert!( + call_count.load(Ordering::Relaxed) > 0, + "progress callback should have been called at least once" + ); + + eprintln!( + "manifest: {} chunks, {} total files, {} bytes", + manifest.chunks.len(), + all_files.len(), + manifest.size + ); +} + +// --------------------------------------------------------------------------- +// Larger data test — verifies chunking logic across multiple files +// --------------------------------------------------------------------------- +#[test] +fn multi_chunk_manifest_pipeline() { + let tmp = TempDir::new("drop_multichunk_test"); + let dir = tmp.path().to_path_buf(); + + // Create enough small files to force at least a couple of chunks + // CHUNK_SIZE = 64 MiB, so we need ~128 MiB of files ≈ 8 files × 16 MiB each + let file_size = 16 * 1024 * 1024; // 16 MiB — fits 4 per chunk + let file_count = 9; // 9 × 16 MiB = 144 MiB → at least 2 full chunks + + // Use deterministic content — large enough to matter, fast to generate + let content_block = b"The quick brown fox jumps over the lazy dog. "; + + for i in 0..file_count { + let content: Vec = content_block + .iter() + .copied() + .cycle() + .take(file_size as usize) + .collect(); + write_file(&dir.join(format!("file_{}.bin", i)), &content); + } + + let total_expected = file_count as u64 * file_size as u64; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let manifest = rt + .block_on(generate_manifest_rusty( + &dir, + |_| {}, + |msg| eprintln!("[manifest] {}", msg), + None::<&dyn droplet_rs::manifest::ManifestWriterFactory>, + None::<&tokio::sync::Semaphore>, + )) + .expect("generate_manifest_rusty should succeed on multi-chunk data"); + + // Verify total size + assert_eq!(manifest.size, total_expected); + + // Manual: 9 × 16 MiB = 144 MiB. + // At 64 MiB per chunk = ceil(144/64) = 3 chunks minimum. + // (Actually 2 chunks of 64 MiB + 1 of 16 MiB = 3). + assert!( + manifest.chunks.len() >= 2, + "expected at least 2 chunks, got {}", + manifest.chunks.len() + ); + + // Every file must appear exactly once across all chunks + let all_files: Vec<_> = manifest.chunks.values().flat_map(|c| &c.files).collect(); + + assert_eq!( + all_files.len(), + file_count, + "all {} files should appear in manifest", + file_count + ); + + for file_entry in &all_files { + assert_eq!(file_entry.length as u64, file_size); + } + + // Each chunk must have a non-empty checksum + // (Note: identical content in different chunks produces same hash — that's correct SHA256) + for (chunk_id, chunk) in &manifest.chunks { + assert!( + !chunk.checksum.is_empty(), + "chunk {} must have a checksum", + chunk_id + ); + assert!( + !chunk.files.is_empty(), + "chunk {} must have files", + chunk_id + ); + } + + eprintln!( + "multi-chunk manifest: {} chunks, {} files, {} bytes", + manifest.chunks.len(), + all_files.len(), + manifest.size + ); +}