package host import ( "bytes" "context" "encoding/json" "errors" "os" "testing" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" ) func TestPlan14HistoryRollbackLink(t *testing.T) { t.Parallel() for _, scenario := range []string{"success", "rollback", "failed rollback", "intent crash", "link crash", "restart crash"} { t.Run(scenario, func(t *testing.T) { e, rt, u, c, pub := plan14AdoptionFixture(t) h := seedHostRootHistory(t, rt) deps := boundRootHistory(h, c.resolve, e.root.obs) c.history = func() (*release.RootHistoryPass, error) { return deps, nil } linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) u.crashNew = scenario != "success" u.badRollback = scenario == "failed rollback" stopped := errors.New("crash") prepare := deps.Prepare deps.Prepare = func(ctx context.Context, b release.HistoryBinding, a release.HistoryAttempt, id hostwrite.Identity) error { if scenario == "intent crash" { panic(stopped) } if err := prepare(ctx, b, a, id); err != nil { return err } live, _ := release.LiveVersion(u.opt, rt.Host) if live != "v1" { t.Fatal("rollback captured after live effect") } s, _ := c.attempts() journal, err := readRollbackAttempt(s, rt.Host) if err != nil || journal.History == nil || journal.History.ID != a.ID { t.Fatalf("unlinked intent: %+v %v", journal, err) } if scenario == "link crash" { panic(stopped) } return nil } if scenario == "restart crash" { u.afterEffect = func() { panic(stopped) } } forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) var passErr error func() { defer func() { if p := recover(); p != nil && p != stopped { panic(p) } }() passErr = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps), release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) }() u.afterEffect = nil if scenario == "success" && passErr != nil { t.Fatal(passErr) } b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} if scenario == "intent crash" || scenario == "failed rollback" { if _, err := h.Refusals(b); err == nil { t.Fatal("unresolved attempt granted permission") } return } if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { t.Fatal(err) } refs, err := h.Refusals(b) if err != nil { t.Fatal(err) } if scenario == "success" { if len(refs) != 0 { t.Fatalf("successful target refused: %+v", refs) } } else if len(refs) == 0 || refs[0].Version != "v2" || !refs[0].Recovered || refs[0].RestoredVersion != "v1" { t.Fatalf("rollback lost exact deny: %+v", refs) } before := len(u.effects) if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil || len(u.effects) != before { t.Fatalf("recovery replay: %v", err) } }) } } type historyVisibleFailure struct { rootstate.Store armed bool visible []byte } func (s *historyVisibleFailure) Write(k string, raw []byte) error { if err := s.Store.Write(k, raw); err != nil { return err } var record struct { Binding release.HistoryBinding `json:"binding"` Attempt *release.HistoryAttempt `json:"attempt"` } if s.armed && json.Unmarshal(raw, &record) == nil && record.Binding.Host != "" && record.Attempt == nil { s.visible = append([]byte(nil), raw...) return os.ErrPermission } return nil } func TestPlan14HistorySettlementRetriesVisibleSync(t *testing.T) { t.Parallel() e, rt, u, c, pub := plan14AdoptionFixture(t) h := seedHostRootHistory(t, rt) fault := &historyVisibleFailure{Store: h.Store} h.Store = fault deps := boundRootHistory(h, c.resolve, e.root.obs) c.history = func() (*release.RootHistoryPass, error) { return deps, nil } linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) u.afterEffect = func() { fault.armed = true } forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) if err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)); err == nil { t.Fatal("failed settlement sync reported success") } if len(fault.visible) == 0 { t.Fatal("settlement did not become visible") } binding := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} before := len(u.effects) if _, err := h.Refusals(binding); err == nil { t.Fatal("unsynced settlement granted mint") } if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err == nil { t.Fatal("recovery ignored failed sync") } if len(u.effects) != before { t.Fatal("sync recovery repeated restart") } want := append([]byte(nil), fault.visible...) fault.armed = false if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { t.Fatal(err) } if refs, err := h.Refusals(binding); err != nil || len(refs) != 0 { t.Fatalf("durable settlement: %+v %v", refs, err) } if len(u.effects) != before { t.Fatal("durable recovery repeated restart") } found := false for _, raw := range fault.Store.(*rollbackMemory).files { if bytes.Equal(raw, want) { found = true } } if !found { t.Fatal("sync retry refreshed settlement evidence") } } func TestPlan14HistoryRecoveryRefusesChanges(t *testing.T) { t.Parallel() for _, attack := range []string{"record", "unit", "attempt", "missing capability", "stopped", "target"} { t.Run(attack, func(t *testing.T) { e, rt, u, c, pub := plan14AdoptionFixture(t) h := seedHostRootHistory(t, rt) deps := boundRootHistory(h, c.resolve, e.root.obs) c.history = func() (*release.RootHistoryPass, error) { return deps, nil } linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) stopped := errors.New("crash") u.crashNew = true u.afterEffect = func() { panic(stopped) } forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) func() { defer func() { if p := recover(); p != stopped { t.Fatalf("crash: %v", p) } }() _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)) }() u.afterEffect = nil s, _ := c.attempts() a, err := readRollbackAttempt(s, rt.Host) if err != nil { t.Fatal(err) } switch attack { case "record": rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) if err != nil { t.Fatal(err) } rec.RecordSeq++ plan14PutRecord(t, e.store, *rec) case "unit": if err := os.Rename(u.props.FragmentPath, u.props.FragmentPath+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(u.props.FragmentPath, []byte("changed"), 0644); err != nil { t.Fatal(err) } case "attempt": a.History.ID = "another-attempt" if err := writeRollbackState(s, rollbackJournalKey(rt.Host), a); err != nil { t.Fatal(err) } case "missing capability": if err := s.Remove(rollbackCapabilityKey(rt.Host, a.ID)); err != nil { t.Fatal(err) } case "stopped": u.props.ActiveState, u.props.SubState = "inactive", "dead" case "target": if err := os.Remove(release.LiveLink(u.opt, rt.Host)); err != nil { t.Fatal(err) } } before := len(u.effects) if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err == nil || len(u.effects) != before { t.Fatalf("unsafe recovery: %v %v", err, u.effects) } h.Store.(*rollbackMemory).failWrite = false if _, err := h.Refusals(a.HistoryBinding); err == nil { t.Fatal("failed recovery cleared pending history") } }) } }