Skip to content

Test suite audit: tests verify output/config text, not git state (fallback, destructive ops, security under-covered) #62

Description

@bashandbone

Summary

An audit of the test suite found that while it is large (~6,550 lines in tests/ plus ~398 inline unit tests) and looks thorough, it has a consistent structural weakness: most integration tests assert on printed output or on the submod.toml text, not on the actual git/filesystem state the tool exists to manipulate. This lets a large fraction of tests pass even when core behavior is broken — and has already let at least one real bug ship undetected (fetchRecurse silently dropped, see P0-3).

This issue tracks the gaps and the recommended fixes. Findings have been spot-verified against the source.


Cross-cutting theme: tests verify narration, not effects

submod's job is to mutate git state (gitlinks, .git/modules, .gitmodules, .git/config, worktrees), but the tests largely check what was printed or what landed in submod.toml:

  • add (tests/integration_tests.rs:52) checks submod.toml + that a .git exists. Never verifies the index gitlink (mode 160000), the .gitmodules entry, the submodule.<name> config section, or that the submodule HEAD is the right commit. The shallow-clone test (tests/integration_tests.rs:887) is the only add test that shells out to git rev-parse.
  • delete / nuke assert only !config.contains("[name]") (tests/integration_tests.rs:648, :854; tests/command_contract_tests.rs:244, :1331). Never check the worktree is gone, .git/modules/<name|path> is removed, the index gitlink is cleared, or the .gitmodules entry is gone. The delete code itself notes path-keyed artifacts "may linger and prevent a clean re-add" (src/git_manager.rs:1605).
  • update / sync are smoke tests (tests/integration_tests.rs:169, :295): they update against a remote that never advanced (guaranteed no-op), asserted via stdout.contains("Updated") || contains("Already up to date").
  • check/status asserts only printed substrings; is_dirty is a stub that always reports clean when HEAD resolves (src/git_manager.rs:380), and no test can catch that.

P0 — Highest priority

P0-1: Fallback architecture (the core design) is largely unverified

  • No failure-injection seam: gix_ops is a private field with no way to disable gix or force git2/CLI. The only reachable fallbacks are 8 gix methods hard-coded to Err (src/git_ops/gix_ops.rs:317,677,701,707,713,719,725,731) and an incidental 2-part-config-key quirk. For every op gix actually implements (read/write_gitmodules, init/update/delete/deinit, list, fetch), git2's fallback cannot be exercised.
  • The CLI last-resort path is dead code from the suite's perspective: the add_submodule CLI fallback + partial-state cleanup (src/git_ops/mod.rs:316-429) only runs if git2.add_submodule fails, and nothing makes it fail. apply_sparse_checkout_reaches_cli_fallback (tests/fallback_tests.rs:258) asserts nothing on the success path.
  • Of ~23 "fallback" tests, only 3 actually force a fallback and assert a correct result; several are mislabeled (e.g. manager_write_gitmodules_succeeds_despite_gix_limitations exercises a path gix handles fine).
  • reopen() is never tested for its actual hazard (delete-then-re-add same name in one process); its fatal/non-fatal asymmetry (git2 fatal, gix warning) is untested. Fallback warning logs are never asserted.

Fix: add a failure-injection seam (e.g. GitOpsManager::new taking gix: Option<...>, or a feature-gated failable gix) so git2 fallback and CLI last-resort can be tested for correct results.

P0-2: Destructive ops verified only against TOML, never git cleanup

delete_submodule_by_name (src/git_manager.rs:1581) removes config sections, deletes .git/modules/<path> and .git/modules/<name>, and calls reopen() — none of which any test checks. Note git2's delete_submodule deliberately does NOT touch .gitmodules (src/git_ops/git2_ops.rs:431, "left to higher-level logic"), so a stale .gitmodules entry after delete is a plausible undetectable bug.

Fix: after delete/nuke --kill, assert: worktree gone, .git/modules/<name and path> gone, git ls-files --stage <path> empty, .gitmodules entry gone, git config --get-regexp ^submodule\. empty.

P0-3: Real bug — fetchRecurse / [defaults] branch silently dropped (masked by tests)

The serde field is fetch_recurse (snake_case) with no rename/alias; the struct has no rename_all and no deny_unknown_fields (src/config.rs:71). So fetchRecurse in submod.toml is an unknown key, silently dropped — parsed value is always None. test_config_with_all_git_options "passes" only because it asserts the file text still contains fetchRecurse = "always" (tests/config_tests.rs:164), trivially true since save_config is append-only/verbatim. [defaults] branch is likewise dropped (SubmoduleDefaults has no branch field). Related: SerializableFetchRecurse serializes as always/never via serde but true/false via gitmodules, and from_gitmodules("always") returns Err — no bridge test.

Fix: add serde aliases (fetchRecurse) + deny_unknown_fields; add tests asserting parsed values, not file text; add a cross-system (serde↔gitmodules) round-trip test for FetchRecurse.


P1

  • update against an advanced remote is untested. Add a test that moves the remote forward, runs update, and asserts the submodule HEAD followed. The git2 update path including the rebase/merge→checkout downgrade (src/git_ops/git2_ops.rs:397) is unverified.
  • add does not verify real git state (gitlink mode 160000, .gitmodules, submodule.<name> config, HEAD == remote).
  • check/status stub: is_dirty (src/git_manager.rs:380) always reports clean; no test asserts a dirty submodule is reported dirty. get_submodule_status OIDs/flags are never asserted (tests/git_ops_tests.rs:315).
  • reopen() / stale-state: add add → delete → add same name+path in one process test.
  • CLI-over-file precedence in Config::load (src/config.rs:1048) is untested; the git2 bridge (to_git2_options / TryFrom ... Git2SubmoduleOptions) and SubmoduleUpdateOptions::from_options recursive-flag logic are untested.
  • save_config is append-only (src/git_manager.rs:210): edits to existing sections are silently not written back; comment/order "preservation" is only proven against a no-op. Add real load→modify→save→reload tests.

P2

  • Security is effectively untested. tests/security_tests.rs has 2 tests, both the same vector (path component starting with -). Null-byte handling is faked by the harnessrun_submod (tests/common/mod.rs:223) intercepts \0 args and returns a fabricated failure without launching the binary. Missing: command/flag injection via submodule URL or name (CVE-2018-17456 class), malicious .gitmodules fed to generate-config, symlink escapes, asserted path-traversal containment.
  • Tests that cannot fail meaningfully — delete or fix:
    • test_invalid_config_file_path (tests/error_handling_tests.rs:67) — zero assertions.
    • test_sparse_checkout_empty_patterns (tests/sparse_checkout_tests.rs:321) — else { /* failure also acceptable */ }.
    • test_invalid_sparse_checkout_patterns (tests/error_handling_tests.rs:196) — path-traversal guard whose only assertion is assert!(!stderr.is_empty()) behind failure; passes if traversal is silently accepted.
    • Recurring assert!(!stderr.is_empty()) (tests/error_handling_tests.rs:230,312,334,494) detects "failed somehow," not the cause.
    • permission_denied / config_file_locked silently no-op as root (zero coverage in CI containers).
  • Error-message assertions are wide || disjunctions (contains("Failed to <verb>") || contains(name)) that are near-unfailable; no test asserts a specific exit code.
  • Idempotency/partial-failure untested: add-same-submodule-twice, delete-nonexistent, and failed-add-leaves-no-partial-state (orphan lib/... dir, dangling .git/modules, half-written .gitmodules).
  • merge_from drops other.use_git_default_sparse_checkout (src/config.rs:212) — latent bug, no merge test exposes it.

P3

  • Sparse checkout: strongest area (tests/sparse_checkout_tests.rs:23 checks file contents AND that excluded dirs are absent), but several tests (:74, :231, :358) only check wanted paths exist, not that excluded paths are absent — so they don't prove sparseness. All lean on get_sparse_checkout_file_path (tests/common/mod.rs:288), which falls back to locations[0] even when nothing exists (:357), masking "file not where expected."
  • Performance tests aren't benchmarks despite the CLAUDE.md criterion claim — Instant + loose ceilings (30s/120s) that catch only hangs; test_memory_usage… measures no memory; "concurrent" tests run sequentially.
  • Concurrency is the stated reason tests are serialized (.config/nextest.toml, .git/index.lock contention), yet no test exercises concurrent ops or a held lock.
  • Zero-coverage public surface in src/config.rs: SubmoduleUpdateOptions::* (:298), from_options_and_settings (:495), update_with_settings (:577), set_sparse_paths_for (:897), loaders load_from_file/load_with_git_sync/sync_with_git_config.

Suggested first PRs (concrete, high-signal)

  1. P0-2 — git-state assertions for delete/nuke/add.
  2. P0-3 — fix fetchRecurse/defaults.branch silent-drop + parsed-value tests.
  3. P0-1 — failure-injection seam to actually test git2/CLI fallback.
  4. P1update against an advanced remote.

Audit performed via multi-agent review of tests/ and src/; claims spot-verified against source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions