diff --git a/PLAN.md b/PLAN.md index ea34b53..fbf1689 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1463,6 +1463,7 @@ The diagram describes policy flow, not permission for bootstrap to move behavior | `cmd/cattery` | `bootstrap`, `cli`, `failure` | | `testfixture/filesystem`, `testfixture/sops`, `quality` | none | | `testfixture/database` | `state` | +| `integration` | any production package; the black-box suite is exempt from the import allowlist by definition | Non-fixture production packages never import `internal/testfixture` or `internal/quality`. Tests beside one package may import that package's narrow test fixture; the `integration` test package may import production packages explicitly because its purpose is cross-package verification. The architecture test exempts `_test.go` files from the fixture-import restriction so each package's tests may import its narrow `testfixture/` family (e.g. `repository` tests import `testfixture/filesystem`, `secrets` tests import `testfixture/sops`, `state` tests import `testfixture/database`); production files never receive that exemption. The allowlist is directional: a listed lower package never imports an application, CLI, bootstrap, or command package. diff --git a/integration/backend_fixture_test.go b/integration/backend_fixture_test.go new file mode 100644 index 0000000..a46ccb3 --- /dev/null +++ b/integration/backend_fixture_test.go @@ -0,0 +1,187 @@ +// Package integration proves the frozen Cattery contracts through real +// backends: isolated repositories, HOME trees, state stores, application +// services, and the built executable (PLAN.md Section 15). No production +// file is patched here. +package integration + +import ( + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/alyraffauf/cattery/internal/bootstrap" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/state" +) + +// BackendFixture bundles one isolated backend environment: a real +// repository tree, a real HOME, a state store, and the application +// services over a deterministic clock. +type BackendFixture struct { + Repository string + Home string + StateHome string + Store *state.Store + Applications bootstrap.Applications + Platform deployment.Layer +} + +// NewBackendFixture builds one isolated environment with real directories +// and a deterministic clock. Nothing is registered and no repository or +// managed row exists until a test explicitly registers one. +func NewBackendFixture(t *testing.T) BackendFixture { + t.Helper() + repository := t.TempDir() + home := t.TempDir() + stateHome := filepath.Join(t.TempDir(), "state") + adapters := bootstrap.NewAdapters(stateHome, func() time.Time { return fixedClock() }) + applications := bootstrap.BuildApplications(bootstrap.ApplicationsInput{ + Adapters: adapters, + Home: home, + Platform: currentPlatform(), + Protected: []string{stateHome}, + Stdin: strings.NewReader(""), + Stderr: io.Discard, + IsTerminal: func(fd int) bool { return false }, + }) + return BackendFixture{ + Repository: repository, + Home: home, + StateHome: stateHome, + Store: adapters.Store, + Applications: applications, + Platform: currentPlatform(), + } +} + +// Acquire opens the state store and closes it at cleanup. +func (fixture BackendFixture) Acquire(t *testing.T) { + t.Helper() + if err := fixture.Store.Acquire(context.Background()); err != nil { + t.Fatalf("acquire store: %v", err) + } + t.Cleanup(func() { + if err := fixture.Store.Close(); err != nil { + t.Errorf("close store: %v", err) + } + }) +} + +// RegisterRepository registers the fixture repository as the home default. +func (fixture BackendFixture) RegisterRepository(t *testing.T) { + t.Helper() + if _, err := fixture.Store.SetDefaultRepository(fixture.Repository, fixture.Home); err != nil { + t.Fatalf("register repository: %v", err) + } +} + +// RepositoryPath joins one repository-relative source path. +func (fixture BackendFixture) RepositoryPath(relative string) string { + return filepath.Join(fixture.Repository, filepath.FromSlash(relative)) +} + +// TargetPath joins one HOME-relative target path. +func (fixture BackendFixture) TargetPath(relative string) string { + return filepath.Join(fixture.Home, filepath.FromSlash(relative)) +} + +// WriteRepository writes one source file into the repository. +func (fixture BackendFixture) WriteRepository(t *testing.T, relative string, content []byte) { + t.Helper() + writeFile(t, fixture.RepositoryPath(relative), content) +} + +// WriteTarget writes one file into the HOME tree. +func (fixture BackendFixture) WriteTarget(t *testing.T, relative string, content []byte) { + t.Helper() + writeFile(t, fixture.TargetPath(relative), content) +} + +// writeFile writes one 0600 file after creating its parent directories. +func writeFile(t *testing.T, path string, content []byte) { + t.Helper() + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } +} + +// fixedClock returns one deterministic instant. +func fixedClock() time.Time { + return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) +} + +// currentPlatform derives the deployment layer from the runtime GOOS. +func currentPlatform() deployment.Layer { + platform, err := deployment.ParseLayer(runtime.GOOS) + if err != nil { + return "" + } + return platform +} + +func TestBackendFixture(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"isolation", testFixtureIsolation}, + {"deterministic clock", testFixtureClock}, + {"no hidden registration", testFixtureNoRegistration}, + {"cleanup", testFixtureCleanup}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testFixtureIsolation(t *testing.T) { + first := NewBackendFixture(t) + second := NewBackendFixture(t) + if first.Repository == second.Repository || first.Home == second.Home || first.StateHome == second.StateHome { + t.Fatal("two fixtures must share no path") + } + if first.Store == second.Store || first.Applications.Apply == second.Applications.Apply { + t.Fatal("two fixtures must share no store or service") + } +} + +func testFixtureClock(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + repository, err := fixture.Store.DefaultRepository(fixture.Home) + if err != nil { + t.Fatalf("default: %v", err) + } + want := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + if !repository.CreatedAt.Equal(want) { + t.Fatalf("created = %v, want the fixed instant", repository.CreatedAt) + } +} + +func testFixtureNoRegistration(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + if _, err := fixture.Store.DefaultRepository(fixture.Home); err == nil { + t.Fatal("a fresh fixture must register no repository") + } +} + +func testFixtureCleanup(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.WriteRepository(t, "files/a", []byte("a")) + fixture.WriteTarget(t, "a", []byte("a")) + if _, err := os.Stat(fixture.RepositoryPath("files/a")); err != nil { + t.Fatalf("fixture files must exist during the test: %v", err) + } +} diff --git a/internal/quality/architecture_test.go b/internal/quality/architecture_test.go index 5b21d3b..e620ff6 100644 --- a/internal/quality/architecture_test.go +++ b/internal/quality/architecture_test.go @@ -128,7 +128,7 @@ func fileDagViolations(root, module, path string) []violation { return nil } family := familyOfFile(root, path) - if family == "quality" || strings.HasPrefix(family, "testfixture") { + if family == "quality" || strings.HasPrefix(family, "testfixture") || family == "integration" { return nil } context := edgeContext{family: family, module: module, path: path} -- 2.51.2 From fe629cced3b9732926caf4f03e71fc73995d1cbf Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:09:32 -0400 Subject: [PATCH 2/7] test: expose fixture adapters for scripted services --- integration/backend_fixture_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration/backend_fixture_test.go b/integration/backend_fixture_test.go index a46ccb3..93f74d0 100644 --- a/integration/backend_fixture_test.go +++ b/integration/backend_fixture_test.go @@ -27,6 +27,7 @@ type BackendFixture struct { Home string StateHome string Store *state.Store + Adapters bootstrap.Adapters Applications bootstrap.Applications Platform deployment.Layer } @@ -54,6 +55,7 @@ func NewBackendFixture(t *testing.T) BackendFixture { Home: home, StateHome: stateHome, Store: adapters.Store, + Adapters: adapters, Applications: applications, Platform: currentPlatform(), } -- 2.51.2 From 0e4b1816f658a6941d99d858a2bd8bb5b80e3b72 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:17:38 -0400 Subject: [PATCH 3/7] test: prove backend apply workflows Fixes two integration findings at their owning boundaries: the apply source guard must not compare identities of absent targets, and the hook orchestration must re-sort each phase's hooks because the compiled plan carries display order. --- integration/backend_apply_test.go | 235 +++++++++++++++++++++ internal/application/apply/hooks.go | 19 +- internal/application/apply/source_guard.go | 4 +- 3 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 integration/backend_apply_test.go diff --git a/integration/backend_apply_test.go b/integration/backend_apply_test.go new file mode 100644 index 0000000..5f56d70 --- /dev/null +++ b/integration/backend_apply_test.go @@ -0,0 +1,235 @@ +package integration + +import ( + "context" + "io" + "os" + "strings" + "testing" + + "github.com/alyraffauf/cattery/internal/application/apply" + "github.com/alyraffauf/cattery/internal/bootstrap" + "github.com/alyraffauf/cattery/internal/state" +) + +func TestBackendApply(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"first apply creates the target", testApplyFirst}, + {"source update applies automatically", testApplySourceUpdate}, + {"target drift decides", testApplyDrift}, + {"target drift skips", testApplySkip}, + {"source removal retires", testApplyRetirement}, + {"hooks run around the phase", testApplyHooks}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// scriptedApply builds one apply service over the fixture adapters with a +// scripted interactive prompt. +func scriptedApply(t *testing.T, fixture BackendFixture, answers []string) *apply.Service { + t.Helper() + input := "" + if len(answers) > 0 { + input = strings.Join(answers, "\n") + "\n" + } + return bootstrap.BuildApplications(bootstrap.ApplicationsInput{ + Adapters: fixture.Adapters, + Home: fixture.Home, + Platform: fixture.Platform, + Protected: []string{fixture.StateHome}, + Stdin: strings.NewReader(input), + Stderr: io.Discard, + IsTerminal: func(fd int) bool { return true }, + }).Apply +} + +// applyRequest freezes one apply over the fixture default repository. +func applyRequest(fixture BackendFixture) apply.Request { + return apply.Request{Repository: apply.RepositoryInput{WorkingDir: fixture.Home}} +} + +// applyOutcome names one expected summary. +type applyOutcome struct { + completed int + partial int +} + +// assertApply asserts one apply outcome. +func assertApply(t *testing.T, result apply.Result, want applyOutcome) { + t.Helper() + if result.Summary.Completed != want.completed || result.Summary.Partial != want.partial { + t.Fatalf("summary = %+v, want completed=%d partial=%d", result.Summary, want.completed, want.partial) + } +} + +// readTarget reads one HOME-relative target. +func readTarget(t *testing.T, fixture BackendFixture, relative string) []byte { + t.Helper() + content, err := os.ReadFile(fixture.TargetPath(relative)) + if err != nil { + t.Fatalf("read target %s: %v", relative, err) + } + return content +} + +// fileRow reads the persisted file baseline of one target. +func fileRow(t *testing.T, fixture BackendFixture, target string) state.FileBaseline { + t.Helper() + row, err := fixture.Store.FileBaseline(fixture.Repository, fixture.Home, target) + if err != nil { + t.Fatalf("file row %s: %v", target, err) + } + return row +} + +func testApplyFirst(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("content")) + service := scriptedApply(t, fixture, nil) + result, err := service.Apply(context.Background(), applyRequest(fixture)) + if err != nil { + t.Fatalf("apply: %v", err) + } + assertApply(t, result, applyOutcome{completed: 1}) + if string(readTarget(t, fixture, ".config/app")) != "content" { + t.Fatal("the target must carry the source bytes") + } + if row := fileRow(t, fixture, ".config/app"); row.Status != state.StatusActive { + t.Fatalf("row = %+v, want an active baseline", row) + } +} + +func testApplySourceUpdate(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("v1")) + service := scriptedApply(t, fixture, nil) + if _, err := service.Apply(context.Background(), applyRequest(fixture)); err != nil { + t.Fatalf("first apply: %v", err) + } + fixture.WriteRepository(t, ".config/app", []byte("v2")) + result, err := service.Apply(context.Background(), applyRequest(fixture)) + if err != nil { + t.Fatalf("second apply: %v", err) + } + assertApply(t, result, applyOutcome{completed: 1}) + if string(readTarget(t, fixture, ".config/app")) != "v2" { + t.Fatal("a source-only change must apply automatically") + } +} + +func testApplyDrift(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("v1")) + service := scriptedApply(t, fixture, nil) + if _, err := service.Apply(context.Background(), applyRequest(fixture)); err != nil { + t.Fatalf("first apply: %v", err) + } + fixture.WriteTarget(t, ".config/app", []byte("drifted")) + service = scriptedApply(t, fixture, []string{"overwrite"}) + result, err := service.Apply(context.Background(), applyRequest(fixture)) + if err != nil { + t.Fatalf("apply: %v", err) + } + assertApply(t, result, applyOutcome{completed: 1}) + if string(readTarget(t, fixture, ".config/app")) != "v1" { + t.Fatal("an overwrite decision must restore the source bytes") + } +} + +func testApplySkip(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("v1")) + service := scriptedApply(t, fixture, nil) + if _, err := service.Apply(context.Background(), applyRequest(fixture)); err != nil { + t.Fatalf("first apply: %v", err) + } + fixture.WriteTarget(t, ".config/app", []byte("drifted")) + service = scriptedApply(t, fixture, []string{"skip"}) + result, err := service.Apply(context.Background(), applyRequest(fixture)) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.Summary.Planned != 1 { + t.Fatalf("summary = %+v, want one planned skip", result.Summary) + } + if string(readTarget(t, fixture, ".config/app")) != "drifted" { + t.Fatal("a skip must leave the drifted target") + } +} + +func testApplyRetirement(t *testing.T) { + fixture, service := appliedFixture(t, ".config/app", "v1") + if err := os.Remove(fixture.RepositoryPath(".config/app")); err != nil { + t.Fatal(err) + } + result, err := service.Apply(context.Background(), applyRequest(fixture)) + if err != nil { + t.Fatalf("apply: %v", err) + } + assertApply(t, result, applyOutcome{completed: 1}) + if _, err := os.Stat(fixture.TargetPath(".config/app")); err != nil { + t.Fatal("retirement must never delete the deployed target") + } + if row := fileRow(t, fixture, ".config/app"); row.Status != state.StatusRetired { + t.Fatalf("row = %+v, want a retired row", row) + } +} + +func testApplyHooks(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("v1")) + writeHook(t, fixture, hookSpec{relative: "_hooks/before/before.sh", content: []byte("#!/bin/sh\nprintf before >> $CATTERY_HOME/hook-order\n")}) + writeHook(t, fixture, hookSpec{relative: "_hooks/after/after.sh", content: []byte("#!/bin/sh\nprintf after >> $CATTERY_HOME/hook-order\n")}) + service := scriptedApply(t, fixture, nil) + if _, err := service.Apply(context.Background(), applyRequest(fixture)); err != nil { + t.Fatalf("apply: %v", err) + } + if string(readTarget(t, fixture, "hook-order")) != "beforeafter" { + t.Fatalf("hook order = %q, want before then after", string(readTarget(t, fixture, "hook-order"))) + } +} + +// hookSpec names one executable hook. +type hookSpec struct { + relative string + content []byte +} + +// writeHook writes and chmods one executable repository hook. +func writeHook(t *testing.T, fixture BackendFixture, spec hookSpec) { + t.Helper() + path := fixture.RepositoryPath(spec.relative) + writeFile(t, path, spec.content) + if err := os.Chmod(path, 0o755); err != nil { + t.Fatal(err) + } +} + +// appliedFixture builds a registered fixture with one applied source. +func appliedFixture(t *testing.T, relative, content string) (BackendFixture, *apply.Service) { + t.Helper() + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, relative, []byte(content)) + service := scriptedApply(t, fixture, nil) + if _, err := service.Apply(context.Background(), applyRequest(fixture)); err != nil { + t.Fatalf("first apply: %v", err) + } + return fixture, service +} diff --git a/internal/application/apply/hooks.go b/internal/application/apply/hooks.go index 1393c15..23096e4 100644 --- a/internal/application/apply/hooks.go +++ b/internal/application/apply/hooks.go @@ -102,5 +102,22 @@ func (service *Service) runHooks(ctx context.Context, input PipelineInput, reque Phase: request.phase, Result: request.result, } - return service.hooks.Execute(ctx, execution, input.Candidates.Hooks()) + return service.hooks.Execute(ctx, execution, hooksForPhase(input.Candidates.Hooks(), request.phase)) +} + +// hooksForPhase keeps the hooks of one phase in the execution order, since +// the compiled plan carries the display order. +func hooksForPhase(all []deployment.Hook, phase deployment.HookPhase) []deployment.Hook { + kept := make([]deployment.Hook, 0, len(all)) + for _, hook := range all { + if hook.Phase == phase { + kept = append(kept, hook) + } + } + if phase == deployment.HookBefore { + hooks.SortBefore(kept) + return kept + } + hooks.SortAfter(kept) + return kept } diff --git a/internal/application/apply/source_guard.go b/internal/application/apply/source_guard.go index d676ed5..5092b68 100644 --- a/internal/application/apply/source_guard.go +++ b/internal/application/apply/source_guard.go @@ -68,9 +68,9 @@ func targetStable(candidate Candidate, fresh reconcile.TargetSnapshot) error { switch { case fresh.Kind() != before.Kind(): return failure.New(failure.Operational, "apply: target type changed during apply: "+path, nil) - case !pathsafe.SameIdentity(fresh.Identity(), before.Identity()): + case before.Kind() != reconcile.KindAbsent && !pathsafe.SameIdentity(fresh.Identity(), before.Identity()): return failure.New(failure.Operational, "apply: target identity changed during apply: "+path, nil) - case !pathsafe.SameIdentity(fresh.Parent(), before.Parent()): + case before.Kind() != reconcile.KindAbsent && !pathsafe.SameIdentity(fresh.Parent(), before.Parent()): return failure.New(failure.Operational, "apply: target parent changed during apply: "+path, nil) case fresh.Kind() == reconcile.KindFile && (fresh.Digest() != before.Digest() || fresh.Mode() != before.Mode()): return failure.New(failure.Operational, "apply: target content changed during apply: "+path, nil) -- 2.51.2 From 21e27165774352e38bf70b2eb975a5b9015cf349 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:22:07 -0400 Subject: [PATCH 4/7] test: prove backend add workflows Also wires the add service's secret execution ports in the composition root, which previously left secret adoption unconfigured. --- integration/backend_add_test.go | 222 +++++++++++++++++++++++++++++ internal/bootstrap/applications.go | 7 +- 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 integration/backend_add_test.go diff --git a/integration/backend_add_test.go b/integration/backend_add_test.go new file mode 100644 index 0000000..5e4dc97 --- /dev/null +++ b/integration/backend_add_test.go @@ -0,0 +1,222 @@ +package integration + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alyraffauf/cattery/internal/application/add" + "github.com/alyraffauf/cattery/internal/bootstrap" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" + "github.com/alyraffauf/cattery/internal/selection" + "github.com/alyraffauf/cattery/internal/state" + "github.com/alyraffauf/cattery/internal/testfixture/sops" +) + +func TestBackendAdd(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"ordinary adoption", testAddOrdinary}, + {"dry run writes nothing", testAddDryRun}, + {"explicit group", testAddGroup}, + {"secret adoption", testAddSecret}, + {"partial batch", testAddPartial}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// addRequest freezes one add over the fixture default repository. +func addRequest(fixture BackendFixture, targets ...string) add.Request { + return add.Request{Repository: add.RepositoryInput{WorkingDir: fixture.Home}, Targets: targets} +} + +// readRepository reads one repository-relative source. +func readRepository(t *testing.T, fixture BackendFixture, relative string) []byte { + t.Helper() + content, err := os.ReadFile(fixture.RepositoryPath(relative)) + if err != nil { + t.Fatalf("read source %s: %v", relative, err) + } + return content +} + +func testAddOrdinary(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteTarget(t, "a.conf", []byte("content")) + result, err := fixture.Applications.Add.Add(context.Background(), addRequest(fixture, "a.conf")) + if err != nil { + t.Fatalf("add: %v", err) + } + if result.Summary.Completed != 1 { + t.Fatalf("summary = %+v, want one completed", result.Summary) + } + if string(readRepository(t, fixture, "a.conf")) != "content" { + t.Fatal("the repository must carry the exact target bytes") + } + if string(readTarget(t, fixture, "a.conf")) != "content" { + t.Fatal("the target must be preserved") + } + row, err := fixture.Store.FileBaseline(fixture.Repository, fixture.Home, "a.conf") + if err != nil { + t.Fatalf("row: %v", err) + } + if row.Status != state.StatusActive { + t.Fatalf("row = %+v, want an active baseline", row) + } +} + +func testAddDryRun(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteTarget(t, "a.conf", []byte("content")) + request := addRequest(fixture, "a.conf") + request.DryRun = true + result, err := fixture.Applications.Add.Add(context.Background(), request) + if err != nil { + t.Fatalf("add: %v", err) + } + if result.Summary.Planned != 1 { + t.Fatalf("summary = %+v, want one planned", result.Summary) + } + if _, err := os.Stat(fixture.RepositoryPath("a.conf")); !os.IsNotExist(err) { + t.Fatal("a dry run must not write the source") + } + if _, err := fixture.Store.FileBaseline(fixture.Repository, fixture.Home, "a.conf"); err == nil { + t.Fatal("a dry run must not establish a baseline") + } +} + +func testAddGroup(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteTarget(t, "app.conf", []byte("content")) + request := addRequest(fixture, "app.conf") + request.Group = "apps" + request.GroupSet = true + result, err := fixture.Applications.Add.Add(context.Background(), request) + if err != nil { + t.Fatalf("add: %v", err) + } + if result.Summary.Completed != 1 { + t.Fatalf("summary = %+v, want one completed", result.Summary) + } + if string(readRepository(t, fixture, "apps/app.conf")) != "content" { + t.Fatal("a group source must live under the group directory") + } +} + +func testAddSecret(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + envelope := []byte(`{"data":"ZmFrZS1jaXBoZXI=","sops":{"version":"3.9.0"}}`) + fixture.WriteTarget(t, "token", envelope) + adapters := fixture.Adapters + adapters.SOPS = fakeSOPSClient(t) + service := bootstrap.BuildApplications(bootstrap.ApplicationsInput{ + Adapters: adapters, Home: fixture.Home, Platform: fixture.Platform, + Protected: []string{fixture.StateHome}, + Stdin: strings.NewReader(""), Stderr: io.Discard, IsTerminal: func(int) bool { return false }, + }).Add + request := addRequest(fixture, "token") + request.Secret = true + request.SecretSet = true + result, err := service.Add(context.Background(), request) + if err != nil { + t.Fatalf("add: %v", err) + } + if result.Summary.Completed != 1 || !result.Items[0].Secret { + t.Fatalf("summary = %+v, want one completed secret", result.Summary) + } + if string(readRepository(t, fixture, "_secrets/token")) != string(envelope) { + t.Fatal("the repository must carry the ciphertext only") + } +} + +func testAddPartial(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteTarget(t, "a.conf", []byte("a")) + fixture.WriteTarget(t, "b.conf", []byte("b")) + service := add.NewService(add.Dependencies{ + RepositorySource: addSource{resolver: selection.NewRepositoryResolver(fixture.Home, fixture.Store)}, + Compiler: compileAdapter{}, + Writer: &partialWriter{inner: filesystem.NewReplacer()}, + Baselines: fixture.Store, + }) + result, err := service.Add(context.Background(), addRequest(fixture, "a.conf", "b.conf")) + if err == nil || !kindIs(err, failure.Operational) { + t.Fatalf("error = %v, want an operational failure", err) + } + if len(result.Items) != 2 || result.Items[0].Status != add.StatusCompleted || result.Items[1].Status != add.StatusPartial { + t.Fatalf("items = %+v, want completed then partial", result.Items) + } +} + +// addSource adapts one selection resolver into the add identity. +type addSource struct { + resolver *selection.RepositoryResolver +} + +func (source addSource) Resolve(request selection.RepositoryRequest) (add.RepositoryIdentity, error) { + repository, err := source.resolver.Resolve(request) + if err != nil { + return add.RepositoryIdentity{}, err + } + return add.RepositoryIdentity{Root: repository.RootPath, Home: repository.HomePath}, nil +} + +// compileAdapter runs the frozen repository compiler. +type compileAdapter struct{} + +func (compileAdapter) Compile(input repository.CompileInput) (deployment.Plan, error) { + return repository.Compile(input) +} + +// partialWriter fails the second replacement. +type partialWriter struct { + inner *filesystem.Replacer + calls int +} + +func (writer *partialWriter) ReplaceResult(ctx context.Context, precondition filesystem.Precondition, spec filesystem.ReplacementSpec) (filesystem.ReplaceResult, error) { + writer.calls++ + if writer.calls == 2 { + return filesystem.ReplaceResult{}, errors.New("injected write failure") + } + return writer.inner.ReplaceResult(ctx, precondition, spec) +} + +// fakeSOPSClient builds one client over the fake sops executable. +func fakeSOPSClient(t *testing.T) *secrets.Client { + t.Helper() + executable := sops.Build(t) + command, err := executable.Command(sops.Behavior{Stdout: []byte(`{"data":"ZmFrZS1jaXBoZXI=","sops":{"version":"3.9.0"}}`)}) + if err != nil { + t.Fatal(err) + } + return secrets.NewClient(executable.Path, filepath.Dir(executable.Path), command.Env) +} + +// kindIs reports whether err carries the given failure kind. +func kindIs(err error, want failure.Kind) bool { + kind, ok := failure.HasKind(err) + return ok && kind == want +} diff --git a/internal/bootstrap/applications.go b/internal/bootstrap/applications.go index a2912a2..f965732 100644 --- a/internal/bootstrap/applications.go +++ b/internal/bootstrap/applications.go @@ -135,13 +135,16 @@ func buildApply(input ApplicationsInput, shared shared) *apply.Service { }) } -// buildAdd wires the add service. +// buildAdd wires the add service with its secret execution ports. func buildAdd(input ApplicationsInput, shared shared) *add.Service { - return add.NewService(add.Dependencies{ + return add.NewServiceWithWrites(add.Dependencies{ RepositorySource: repositorySourceOf(shared.resolver, addIdentity), Compiler: shared.compiler, Writer: input.Adapters.Replacer, Baselines: shared.baselines, + }, add.WriteDependencies{ + Secrets: input.Adapters.SOPS, + HashKey: shared.state, }) } -- 2.51.2 From 52a4896415bf582c12936231a8d39c2ac597d847 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:23:52 -0400 Subject: [PATCH 5/7] test: prove backend inspection --- integration/backend_inspect_test.go | 159 ++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 integration/backend_inspect_test.go diff --git a/integration/backend_inspect_test.go b/integration/backend_inspect_test.go new file mode 100644 index 0000000..c3dbee7 --- /dev/null +++ b/integration/backend_inspect_test.go @@ -0,0 +1,159 @@ +package integration + +import ( + "context" + "os" + "testing" + + "github.com/alyraffauf/cattery/internal/application/inspect" + "github.com/alyraffauf/cattery/internal/bootstrap" +) + +func TestBackendInspect(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"converged status", testInspectConverged}, + {"drift reports difference", testInspectDrift}, + {"status and diff parity", testInspectParity}, + {"state-only retirement", testInspectRetired}, + {"secret records stay safe", testInspectSecret}, + {"inspection mutates nothing", testInspectImmutable}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// inspectRequest freezes one inspection over the fixture repository. +func inspectRequest(fixture BackendFixture) inspect.Request { + return inspect.Request{Repository: inspect.RepositoryInput{WorkingDir: fixture.Home}} +} + +// removeRepositoryFile removes one repository-relative path. +func removeRepositoryFile(fixture BackendFixture, relative string) error { + return os.Remove(fixture.RepositoryPath(relative)) +} + +// DiffTagNameOf returns the stable tag name of one diff record. +func DiffTagNameOf(record inspect.DiffRecord) string { + return inspect.DiffTagName(record) +} + +// buildApplications wires a fresh application over the given adapters. +func buildApplications(t *testing.T, fixture BackendFixture, adapters bootstrap.Adapters) bootstrap.Applications { + t.Helper() + return bootstrap.BuildApplications(bootstrap.ApplicationsInput{ + Adapters: adapters, + Home: fixture.Home, + Platform: fixture.Platform, + Protected: []string{fixture.StateHome}, + Stdin: os.Stdin, + Stderr: os.Stderr, + IsTerminal: func(fd int) bool { return false }, + }) +} + +func testInspectConverged(t *testing.T) { + fixture, _ := appliedFixture(t, ".config/app", "v1") + result, err := fixture.Applications.Inspect.Status(context.Background(), inspectRequest(fixture)) + if err != nil { + t.Fatalf("status: %v", err) + } + if !result.Converged() || len(result.Records()) != 0 { + t.Fatalf("result = %+v, want a converged empty status", result) + } +} + +func testInspectDrift(t *testing.T) { + fixture, _ := appliedFixture(t, ".config/app", "v1") + fixture.WriteTarget(t, ".config/app", []byte("drifted")) + result, err := fixture.Applications.Inspect.Status(context.Background(), inspectRequest(fixture)) + if err == nil { + t.Fatal("drift must report a difference failure") + } + if len(result.Records()) != 1 || result.Records()[0].TargetPath() != ".config/app" { + t.Fatalf("records = %+v, want the drifted record", result.Records()) + } + if result.Converged() { + t.Fatal("drift must not converge") + } +} + +func testInspectParity(t *testing.T) { + fixture, _ := appliedFixture(t, ".config/app", "v1") + fixture.WriteTarget(t, ".config/app", []byte("drifted")) + status, statusErr := fixture.Applications.Inspect.Status(context.Background(), inspectRequest(fixture)) + diff, diffErr := fixture.Applications.Inspect.Diff(context.Background(), inspectRequest(fixture)) + if (statusErr == nil) != (diffErr == nil) { + t.Fatalf("status error = %v, diff error = %v, want parity", statusErr, diffErr) + } + if len(status.Records()) != len(diff.Records()) { + t.Fatalf("records = %d/%d, want parity", len(status.Records()), len(diff.Records())) + } + if status.Records()[0].TargetPath() != diff.Records()[0].TargetPath() { + t.Fatalf("paths = %q/%q, want the same evaluation", status.Records()[0].TargetPath(), diff.Records()[0].TargetPath()) + } +} + +func testInspectRetired(t *testing.T) { + fixture, _ := appliedFixture(t, ".config/app", "v1") + if err := removeRepositoryFile(fixture, ".config/app"); err != nil { + t.Fatal(err) + } + result, err := fixture.Applications.Inspect.Status(context.Background(), inspectRequest(fixture)) + if err == nil { + t.Fatal("a pending retirement must report a difference") + } + if len(result.Records()) != 1 || result.Records()[0].Kind() != inspect.StatusKindRetired { + t.Fatalf("records = %+v, want one retired record", result.Records()) + } +} + +func testInspectSecret(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + envelope := []byte(`{"data":"ZmFrZS1jaXBoZXI=","sops":{"version":"3.9.0"}}`) + fixture.WriteRepository(t, "_secrets/token", envelope) + fixture.WriteTarget(t, "token", []byte("tampered")) + adapters := fixture.Adapters + adapters.SOPS = fakeSOPSClient(t) + applications := buildApplications(t, fixture, adapters) + result, err := applications.Inspect.Diff(context.Background(), inspectRequest(fixture)) + if err == nil { + t.Fatal("a drifted secret must report a difference") + } + if len(result.Records()) != 1 { + t.Fatalf("records = %+v, want one record", result.Records()) + } + record := result.Records()[0] + if DiffTagNameOf(record) != "secret" { + t.Fatalf("tag = %q, want secret", DiffTagNameOf(record)) + } + if record.Lines() != "" || record.SourceSize() != 0 { + t.Fatalf("record = %+v, a secret record must stay payload-free", record) + } +} + +func testInspectImmutable(t *testing.T) { + fixture := NewBackendFixture(t) + fixture.Acquire(t) + fixture.RegisterRepository(t) + fixture.WriteRepository(t, ".config/app", []byte("v1")) + fixture.WriteTarget(t, ".config/app", []byte("drifted")) + before := fixture.Store.Database() + if _, err := fixture.Applications.Inspect.Status(context.Background(), inspectRequest(fixture)); err == nil { + t.Fatal("drift must report a difference") + } + if _, err := fixture.Applications.Inspect.Diff(context.Background(), inspectRequest(fixture)); err == nil { + t.Fatal("drift must report a difference") + } + if fixture.Store.Database() != before { + t.Fatal("inspection must not touch the store") + } + if string(readTarget(t, fixture, ".config/app")) != "drifted" { + t.Fatal("inspection must not mutate targets") + } +} -- 2.51.2 From 1ee259a2b5be9cf688b281a3b950affbdcb5dc05 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:24:48 -0400 Subject: [PATCH 6/7] test: freeze process fixture --- integration/process_fixture_test.go | 150 ++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 integration/process_fixture_test.go diff --git a/integration/process_fixture_test.go b/integration/process_fixture_test.go new file mode 100644 index 0000000..1dcb78a --- /dev/null +++ b/integration/process_fixture_test.go @@ -0,0 +1,150 @@ +package integration + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// ProcessInput carries one isolated subprocess invocation. +type ProcessInput struct { + Args []string + Home string + Stdin string + Env []string + Timeout time.Duration +} + +// ProcessResult captures one subprocess outcome. +type ProcessResult struct { + Stdout string + Stderr string + Code int +} + +// ProcessFixture builds the cattery binary once and invokes it in isolated +// subprocesses with explicit HOME, XDG state, environment, streams, and +// timeouts. +type ProcessFixture struct { + Binary string +} + +// NewProcessFixture builds one binary for the whole test. +func NewProcessFixture(t *testing.T) ProcessFixture { + t.Helper() + binary := filepath.Join(t.TempDir(), "cattery") + command := exec.Command("go", "build", "-o", binary, "github.com/alyraffauf/cattery/cmd/cattery") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build cattery: %v\n%s", err, output) + } + return ProcessFixture{Binary: binary} +} + +// Run invokes the binary once under the given input and returns the exact +// streams and exit code. +func (fixture ProcessFixture) Run(t *testing.T, input ProcessInput) ProcessResult { + t.Helper() + ctx := context.Background() + cancel := func() {} + if input.Timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, input.Timeout) + } + defer cancel() + environment := append([]string{ + "HOME=" + input.Home, + "XDG_STATE_HOME=" + filepath.Join(input.Home, ".local", "state"), + "PATH=" + os.Getenv("PATH"), + }, input.Env...) + command := exec.CommandContext(ctx, fixture.Binary, input.Args...) + command.Env = environment + command.Stdin = bytes.NewBufferString(input.Stdin) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + code := 0 + if err != nil { + code = exitCodeOf(err) + } + return ProcessResult{Stdout: stdout.String(), Stderr: stderr.String(), Code: code} +} + +// exitCodeOf extracts the process exit code from one run error. +func exitCodeOf(err error) int { + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode() + } + return -1 +} + +// IsolateEnvironment clears every cattery-affecting variable. +func IsolateEnvironment() []string { + return []string{} +} + +func TestProcessFixture(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"binary reuse", testProcessReuse}, + {"stream separation", testProcessStreams}, + {"environment isolation", testProcessEnvironment}, + {"timeout cleanup", testProcessTimeout}, + {"exact exit capture", testProcessExit}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testProcessReuse(t *testing.T) { + fixture := NewProcessFixture(t) + home := t.TempDir() + first := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: home}) + second := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: home}) + if first.Stdout != second.Stdout { + t.Fatalf("outputs differ across runs: %q vs %q", first.Stdout, second.Stdout) + } +} + +func testProcessStreams(t *testing.T) { + fixture := NewProcessFixture(t) + home := t.TempDir() + result := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: home}) + if result.Stdout == "" || result.Stderr != "" || result.Code != 0 { + t.Fatalf("result = %+v, want clean stdout", result) + } +} + +func testProcessEnvironment(t *testing.T) { + fixture := NewProcessFixture(t) + first := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: t.TempDir()}) + second := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: t.TempDir()}) + if first.Stdout != second.Stdout { + t.Fatal("isolated homes must not leak into each other") + } +} + +func testProcessTimeout(t *testing.T) { + fixture := NewProcessFixture(t) + home := t.TempDir() + result := fixture.Run(t, ProcessInput{Args: []string{"version"}, Home: home, Timeout: time.Second}) + if result.Code != 0 { + t.Fatalf("code = %d, want 0 within the timeout", result.Code) + } +} + +func testProcessExit(t *testing.T) { + fixture := NewProcessFixture(t) + home := t.TempDir() + result := fixture.Run(t, ProcessInput{Args: []string{"--version"}, Home: home}) + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for an unknown flag", result.Code) + } +} -- 2.51.2 From a53443e2d20a3d356b717f03da91dce52ca47d4e Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:25:39 -0400 Subject: [PATCH 7/7] test: prove executable cli behavior --- integration/cli_test.go | 121 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 integration/cli_test.go diff --git a/integration/cli_test.go b/integration/cli_test.go new file mode 100644 index 0000000..9a3503c --- /dev/null +++ b/integration/cli_test.go @@ -0,0 +1,121 @@ +package integration + +import ( + "strings" + "testing" + "time" +) + +func TestExecutableCLI(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"root help", testCLIHelp}, + {"no command shows help", testCLINoCommand}, + {"unknown command fails", testCLIUnknownCommand}, + {"unknown root flag", testCLIUnknownFlag}, + {"version output", testCLIVersion}, + {"version flag rejected", testCLIVersionFlag}, + {"init arity", testCLIInitArity}, + {"validate usage", testCLIValidate}, + {"deterministic output", testCLIDeterministic}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// cliFixture builds the executable fixture once per test. +func cliFixture(t *testing.T) ProcessFixture { + t.Helper() + return NewProcessFixture(t) +} + +// cliRun runs the executable under a fresh isolated home. +func cliRun(t *testing.T, fixture ProcessFixture, args ...string) ProcessResult { + t.Helper() + return fixture.Run(t, ProcessInput{Args: args, Home: t.TempDir(), Timeout: 30 * time.Second}) +} + +func testCLIHelp(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "--help") + if result.Code != 0 || !strings.Contains(result.Stdout, "cattery") { + t.Fatalf("result = %+v, want help on stdout", result) + } +} + +func testCLINoCommand(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture) + if result.Code != 0 || !strings.Contains(result.Stdout, "cattery") { + t.Fatalf("result = %+v, want help without arguments", result) + } +} + +func testCLIUnknownCommand(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "nonsense") + if result.Code != 1 || result.Stderr == "" { + t.Fatalf("result = %+v, want a usage failure", result) + } +} + +func testCLIUnknownFlag(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "--version") + if result.Code != 1 || result.Stderr == "" { + t.Fatalf("result = %+v, want an unknown-flag failure", result) + } +} + +func testCLIVersion(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "version") + if result.Code != 0 { + t.Fatalf("result = %+v, want success", result) + } + if !strings.HasPrefix(result.Stdout, "cattery dev commit=unknown built=unknown go=") { + t.Fatalf("stdout = %q, want the development version line", result.Stdout) + } + if !strings.HasSuffix(result.Stdout, "\n") { + t.Fatal("the version line must end with a newline") + } +} + +func testCLIVersionFlag(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "--version") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for the unknown --version flag", result.Code) + } +} + +func testCLIInitArity(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "init", "a", "b") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for an arity error", result.Code) + } +} + +func testCLIValidate(t *testing.T) { + fixture := cliFixture(t) + result := cliRun(t, fixture, "validate") + if result.Code == 0 { + t.Fatal("validate without a repository must fail") + } + if result.Stdout != "" { + t.Fatalf("stdout = %q, want no count lines without a repository", result.Stdout) + } +} + +func testCLIDeterministic(t *testing.T) { + fixture := cliFixture(t) + first := cliRun(t, fixture, "version") + second := cliRun(t, fixture, "version") + if first.Stdout != second.Stdout || first.Code != second.Code { + t.Fatal("repeated runs must produce identical output") + } +}