Step 26: Test cleanup.

Tightened lint config (added copyloopvar, durationcheck, makezero,
nilerr, bodyclose). Added 3 combo tests: encrypted+locked files,
dir-only+locked entries, lock/unlock toggle on encrypted entries.
Fixed stale API signatures in ARCHITECTURE.md. All tests already
used t.TempDir() and AddOptions{} consistently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 12:45:29 -07:00
parent 490db0599c
commit 3cac9a3530
5 changed files with 211 additions and 7 deletions

View File

@@ -8,6 +8,11 @@ linters:
- unused
- errorlint
- staticcheck
- copyloopvar
- durationcheck
- makezero
- nilerr
- bodyclose
linters-settings:
errcheck:

View File

@@ -687,7 +687,7 @@ type Garden struct {
}
// Local operations
func (g *Garden) Add(paths []string, encrypt ...bool) error
func (g *Garden) Add(paths []string, opts ...AddOptions) error
func (g *Garden) Remove(paths []string) error
func (g *Garden) Checkpoint(message string) error
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
@@ -698,10 +698,15 @@ func (g *Garden) Diff(path string) (string, error)
func (g *Garden) Prune() (int, error)
func (g *Garden) MirrorUp(paths []string) error
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error
func (g *Garden) Lock(paths []string) error
func (g *Garden) Unlock(paths []string) error
// Encryption
func (g *Garden) EncryptInit(passphrase string) error
func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
func (g *Garden) HasEncryption() bool
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool
func (g *Garden) RotateDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error
func (g *Garden) RemoveSlot(name string) error
func (g *Garden) ListSlots() map[string]string

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status
**Phase:** Phase 4 in progress. Steps 2125 complete, ready for Step 26.
**Phase:** Phase 4 in progress. Steps 2126 complete, ready for Step 27.
**Last updated:** 2026-03-24
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
## Up Next
Step 26: Test Cleanup.
Step 27: Phase 4 Polish + Release.
## Known Issues / Decisions Deferred
@@ -89,3 +89,4 @@ Step 26: Test Cleanup.
| 2026-03-24 | 23 | TLS transport: sgardd --tls-cert/--tls-key, sgard --tls/--tls-ca, 2 integration tests. |
| 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. |
| 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. |
| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. |

View File

@@ -262,10 +262,11 @@ Depends on Steps 17, 18.
### Step 26: Test Cleanup
- [ ] Standardize all test calls to use `AddOptions{}` struct (remove any legacy variadic patterns)
- [ ] Ensure all tests use `t.TempDir()` consistently
- [ ] Review lint config, tighten if possible
- [ ] Verify test coverage for lock/unlock, encrypted locked files, dir-only locked entries
- [x] Standardize all test calls — already use `AddOptions{}` struct consistently (no legacy variadic patterns found)
- [x] Ensure all tests use `t.TempDir()` consistently (audited, no `os.MkdirTemp`/`ioutil.Temp` usage)
- [x] Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters
- [x] Verify test coverage — added 3 tests: encrypted+locked, dir-only+locked, lock/unlock toggle on encrypted
- [x] Fix stale API signatures in ARCHITECTURE.md (Add, Lock, Unlock, RotateDEK, HasEncryption, NeedsDEK)
### Step 27: Phase 4 Polish + Release

192
garden/locked_combo_test.go Normal file
View File

@@ -0,0 +1,192 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestEncryptedLockedFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
if err := g.EncryptInit("passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
testFile := filepath.Join(root, "secret")
if err := os.WriteFile(testFile, []byte("locked secret"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
// Add as both encrypted and locked.
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true, Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if !entry.Encrypted {
t.Error("should be encrypted")
}
if !entry.Locked {
t.Error("should be locked")
}
if entry.PlaintextHash == "" {
t.Error("should have plaintext hash")
}
origHash := entry.Hash
// Modify the file — checkpoint should skip (locked).
if err := os.WriteFile(testFile, []byte("system overwrote"), 0o600); err != nil {
t.Fatalf("modifying: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != origHash {
t.Error("checkpoint should skip locked file even if encrypted")
}
// Status should report drifted.
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "drifted" {
t.Errorf("expected drifted, got %v", statuses)
}
// Restore should decrypt and overwrite without prompting.
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "locked secret" {
t.Errorf("content = %q, want %q", got, "locked secret")
}
}
func TestDirOnlyLocked(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "lockdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Add as dir-only and locked.
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true, Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if entry.Type != "directory" {
t.Errorf("type = %s, want directory", entry.Type)
}
if !entry.Locked {
t.Error("should be locked")
}
// Remove the directory.
if err := os.RemoveAll(testDir); err != nil {
t.Fatalf("removing: %v", err)
}
// Restore should recreate it.
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
info, err := os.Stat(testDir)
if err != nil {
t.Fatalf("directory not restored: %v", err)
}
if !info.IsDir() {
t.Error("should be a directory")
}
}
func TestLockUnlockEncryptedToggle(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
if err := g.EncryptInit("passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
testFile := filepath.Join(root, "secret")
if err := os.WriteFile(testFile, []byte("data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
// Add encrypted but not locked.
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if g.manifest.Files[0].Locked {
t.Fatal("should not be locked initially")
}
// Lock it.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Error("should be locked")
}
if !g.manifest.Files[0].Encrypted {
t.Error("should still be encrypted")
}
// Modify — checkpoint should skip.
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("changed"), 0o600); err != nil {
t.Fatalf("modifying: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != origHash {
t.Error("checkpoint should skip locked encrypted file")
}
// Unlock — checkpoint should now pick up changes.
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash == origHash {
t.Error("unlocked: checkpoint should update encrypted file hash")
}
}