mirror of
https://github.com/Drop-OSS/drop
synced 2026-08-27 14:23:05 -04:00
Phase 3+: Integration Seams & CI Gates (#39)
* chore: add fork-diff oracle script + update test strategy plan Creates .github/scripts/diff-to-test-prompt.sh for LLM test generation from git diffs. Marks Phase 3 tasks complete in plan (PrismaRepository blocked: 0 models in schema). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test(droplet): add cross-crate pipeline integration test (libarchive->droplet) 2 new integration tests validating directory-to-manifest pipeline: basic 5-file manifest and multi-chunk (144 MiB) manifest. 27 total tests pass. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: John Smith <you@example.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
aa8a176a15
commit
5f49b7ad78
3 changed files with 752 additions and 0 deletions
199
.github/scripts/diff-to-test-prompt.sh
vendored
Executable file
199
.github/scripts/diff-to-test-prompt.sh
vendored
Executable file
|
|
@ -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/<module>/
|
||||
# cli/ → cargo test → cli/tests/ or inline #[cfg(test)]
|
||||
# desktop/ → cargo test → desktop/src-tauri/<crate>/tests/
|
||||
# libraries/ → cargo test → inline #[cfg(test)]
|
||||
# other → vitest (generic) → <workspace>/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 <<PROMPT
|
||||
You are an expert test engineer. The following git diff represents behavioral
|
||||
claims made by code changes. Each changed line is a commitment about how the
|
||||
system should behave. Generate tests that verify these claims.
|
||||
|
||||
If a test file already exists for the changed module, update it. Otherwise,
|
||||
create a new test file.
|
||||
|
||||
Workspace(s): ${WORKSPACES}
|
||||
Suggested test location: ${TEST_LOC}
|
||||
${FRAMEWORK_HINTS}
|
||||
|
||||
Diff summary: ${FILE_COUNT} file(s), ${ADDED} additions, ${REMOVED} removals
|
||||
|
||||
For each changed function, export, API route, or component:
|
||||
1. Identify the behavioral claim (what should happen that didn't before)
|
||||
2. Write a test that passes when the claim holds and fails when it doesn't
|
||||
3. Cover: happy path, error cases, edge cases (empty input, null, boundary)
|
||||
4. Do NOT test unchanged code — only the behavioral delta
|
||||
|
||||
Write production-quality tests:
|
||||
- Descriptive test names (what + expected outcome)
|
||||
- Arrange-Act-Assert structure
|
||||
- No test interdependence
|
||||
- No mocked side effects that bypass the change's logic
|
||||
|
||||
Diff:
|
||||
${DIFF_CONTENT}
|
||||
PROMPT
|
||||
265
.omo/plans/test-strategy-goal.md
Normal file
265
.omo/plans/test-strategy-goal.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
# Test Strategy Goal Prompt — Drop Monorepo
|
||||
|
||||
**Goal:** Prove BillyOutlast/drop can merge into Drop-OSS/drop without breaking,
|
||||
then maintain 100% code coverage with automated test hooks.
|
||||
|
||||
**Generated:** 2026-07-25 via adversarial planning (hyperplan)
|
||||
**Perspectives:** codebase-realist, security-hardener, integration-architect, creative-escaper
|
||||
|
||||
---
|
||||
|
||||
## 1. Givens (Reality Constraints)
|
||||
|
||||
| Constraint | Impact |
|
||||
|---|---|
|
||||
| **Prisma schema: 0 models** | All DB-dependent tests (~40% of server backend) blocked until schema defined |
|
||||
| **Rust coverage: no tooling** | `cargo-llvm-cov` (or tarpaulin) must be added before any Rust coverage |
|
||||
| **Tailscale FFI: CGo, no trait boundary** | Cannot unit-test — requires `trait TailscaleProvider` extraction first |
|
||||
| **Current coverage: 1.17%** | 32 vitest + 10 cargo + 6 cargo + 1 Playwright = 49 tests total |
|
||||
| **No upstream remote configured** | `git remote add upstream git@github.com:Drop-OSS/drop.git` is prerequisite |
|
||||
| **4 workspaces with 0 tests** | `desktop/main/` (Nuxt 4), `sites/promo/` (Next.js), `sites/docs/` (Astro), `libraries/base/` |
|
||||
| **169 API handlers, 71 internal modules, 72 Rust source files** | Scope is large — prioritization essential |
|
||||
|
||||
**Realistic 6-month target: 25-30% project-wide coverage.** 100% requires months of
|
||||
solo-dev effort across schema definition, trait extraction, and 2000+ tests.
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat Model & Security Test Priorities (P0 First)
|
||||
|
||||
### P0 — Must test before merge
|
||||
|
||||
**T1: WebAuthn attestation not validated**
|
||||
- `parseAndValidatePasskeyCreation()` in `server/server/internal/auth/webauthn.ts`
|
||||
- Validates challenge/RPID but NOT attestation signature
|
||||
- **Test:** Crafted CBOR with arbitrary public key should be REJECTED
|
||||
- **Or:** Document explicit gap if out of scope
|
||||
|
||||
**T2: OIDC group-to-admin escalation**
|
||||
- `fetchOrCreateUser()` in `server/server/internal/auth/oidc/index.ts`
|
||||
- If OIDC provider returns `adminGroup` for non-admin user → user created as admin
|
||||
- **Test:** Mock OIDC returns adminGroup → verify user NOT created as admin
|
||||
|
||||
**T3: Session fixation**
|
||||
- `signin()` reuses existing `drop-token` cookie if present
|
||||
- **Test:** Pre-set cookie → signin → new session created, old one invalidated
|
||||
|
||||
**T4: ACL confused deputy**
|
||||
- `allowSystemACL()` in `server/server/internal/acls/index.ts`
|
||||
- Session exists but user is NOT admin + valid system token → falls through to token check
|
||||
- **Test:** Non-admin with session + stolen system token → denied
|
||||
|
||||
### P1 — High priority
|
||||
|
||||
**T5: OIDC state replay**
|
||||
- `signinStateTable` never GCs used states
|
||||
- **Test:** Same `state` value replayed → rejected
|
||||
|
||||
**T6: CA blacklist footgun**
|
||||
- `dbCertificateStore.checkBlacklistCertificate()` returns `true` for missing rows
|
||||
- Deleted cert = "blacklisted" = denial of service
|
||||
- **Test:** Missing cert ≠ blacklisted
|
||||
|
||||
**T7: TOTP code generation/verification**
|
||||
- Zero tests for the actual TOTP flow (not just base64 encode/decode)
|
||||
- **Test:** Secret → code generation → code verification round-trip
|
||||
|
||||
**T8: Notification ACL enforcement**
|
||||
- `listen()` stores user-provided ACLs but never verifies caller possesses them
|
||||
- **Test:** Register listener with `system:admin` ACL as non-admin → filtered
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture — Integration Seams That MUST Have Tests
|
||||
|
||||
### F1: API Route → Prisma (CRITICAL, affects ~100 handlers)
|
||||
- **Problem:** Every route handler calls `prisma.game.create(...)` directly
|
||||
- **Fix:** Extract `trait PrismaRepository` per domain (GameRepo, CompanyRepo, TagRepo)
|
||||
- **What to test:**
|
||||
- Handler creates correct Prisma query shape (via InMemoryGameRepo)
|
||||
- Route returns correct HTTP status for each DB outcome (created, conflict, not-found)
|
||||
- Error responses do not leak internal state
|
||||
|
||||
### F2: Metadata Provider Chain Fallthrough (HIGH)
|
||||
- **Problem:** 5 providers (IGDB, Steam, GiantBomb, PCGamingWiki, Manual) chained via PriorityListIndexed
|
||||
- **Fix:** Inject mock providers (trait-level, not MSW HTTP-level)
|
||||
- **What to test:**
|
||||
- Provider A fails → Provider B tries → Provider C succeeds → returns all successful
|
||||
- All providers fail → empty result, no crash
|
||||
- Provider timeout interleaving (Promise.allSettled + per-provider timeout)
|
||||
- Fuzzy sort correctness across multi-provider results
|
||||
|
||||
### F3: Tailscale FFI — No Trait Boundary (CRITICAL)
|
||||
- **Problem:** `desktop/src-tauri/tailscale/` is pure CGo FFI, zero mocks
|
||||
- **Fix:** `trait TailscaleProvider { fn start() -> ...; 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
|
||||
288
libraries/droplet/tests/pipeline_test.rs
Normal file
288
libraries/droplet/tests/pipeline_test.rs
Normal file
|
|
@ -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<u8> = (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<Writer = tokio::io::Sink>>,
|
||||
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<u8> = 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<Writer = tokio::io::Sink>>,
|
||||
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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue