| index ec7c817f9..e1ad4f71b 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -3034,3 +3034,154 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { |
| t.Errorf("v1's manifest did not migrate: %v", err) |
| } |
| } |
| + |
| +// routeOf returns a whole registry row, for the assertions that need more |
| +// than one column of it. |
| +func (f *routeTestFixture) routeOf(host string) registry.Route { |
| + f.t.Helper() |
| + reg, err := registry.Open(f.regPath) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + if rt == nil { |
| + f.t.Fatalf("no route for host %q", host) |
| + } |
| + return *rt |
| +} |
| + |
| +// TestRouteBackingRefusesAnUnknownValue is the 2026-08-03 review's I1: |
| +// runRouteBacking's doc comment already claimed the value was checked "at the |
| +// flag before the registry is even opened", and nothing performed the check. |
| +// registry.SetBacking writes the column verbatim, and release.adoptable |
| +// compares against BackingUnit EXACTLY — so `-backing unti` was accepted, |
| +// stored "unti", and left the route neither unit-backed nor visibly wrong: the |
| +// migration's one write, silently a no-op that only shows up as an app that |
| +// never gets its capabilities. |
| +// |
| +// The refusal has to NAME both legal values, because the empty one is a real |
| +// value of this column and an operator cannot guess that from a rejection. |
| +func TestRouteBackingRefusesAnUnknownValue(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| + Channel: "stable", Status: "ready"}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unti") |
| + if err == nil { |
| + t.Fatalf("a misspelled -backing value was accepted:\n%s", out) |
| + } |
| + for _, want := range []string{"unti", "unit", `""`} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("refusal does not name %q:\n%s", want, out) |
| + } |
| + } |
| + if got := f.routeOf("keymail.dev").Backing; got != "" { |
| + t.Fatalf("backing column = %q after a refusal, want it untouched", got) |
| + } |
| +} |
| + |
| +// TestRouteBackingIsExclusiveWithTheOtherAxes is I2: -backing is a |
| +// single-route axis exactly like -grant/-revoke and -hibernate, and it was not |
| +// held to their exclusions. Every combination below was a SILENT PARTIAL — |
| +// runRoute's switch runs grantAxis, then hibernateSet, then -backing, and the |
| +// channel/environment branch is unreachable once any of them is set — so the |
| +// command did SOME of what was typed and reported success for all of it. And |
| +// `-app x -backing unit` fell through to a registry lookup of the empty host, |
| +// answering `no route for host ""` instead of the require-host message. |
| +func TestRouteBackingIsExclusiveWithTheOtherAxes(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "keymail", "v1") |
| + f.promote(release.OpsAccount, "keymail", "stable", "v1") |
| + f.promote(release.OpsAccount, "keymail", "canary/x", "v1") |
| + f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| + Channel: "stable", Version: "v1", Status: "ready"}) |
| + |
| + cases := []struct { |
| + name string |
| + args []string |
| + want string |
| + }{ |
| + { |
| + // The repoint would never have happened: the axis branch returns |
| + // before the channel machinery is reached. |
| + name: "-backing with -channel", |
| + args: []string{"-host", "keymail.dev", "-backing", "unit", "-channel", "canary/x"}, |
| + want: "-channel", |
| + }, |
| + { |
| + name: "-backing with -environment", |
| + args: []string{"-host", "keymail.dev", "-backing", "unit", "-environment", "eu"}, |
| + want: "-environment", |
| + }, |
| + { |
| + // The dangerous ordering: -hibernate is evaluated FIRST, so this |
| + // used to write the hibernate column and drop the backing stamp. |
| + name: "-hibernate=false with -backing", |
| + args: []string{"-host", "keymail.dev", "-hibernate=false", "-backing", "unit"}, |
| + want: "mutually exclusive", |
| + }, |
| + { |
| + name: "-grant with -backing", |
| + args: []string{"-host", "keymail.dev", "-grant", "port:25", "-backing", "unit"}, |
| + want: "mutually exclusive", |
| + }, |
| + { |
| + name: "-app with -backing gets the require-host message", |
| + args: []string{"-app", "keymail", "-backing", "unit"}, |
| + want: "require -host", |
| + }, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + args := append([]string{"route", "-registry", f.regPath}, c.args...) |
| + out, err := f.run(f.fullEnv(), args...) |
| + if err == nil { |
| + t.Fatalf("%s was accepted:\n%s", c.name, out) |
| + } |
| + if !strings.Contains(out, c.want) { |
| + t.Errorf("refusal does not contain %q:\n%s", c.want, out) |
| + } |
| + if strings.Contains(out, `no route for host ""`) { |
| + t.Errorf("refusal is the empty-host lookup, not an axis rail:\n%s", out) |
| + } |
| + // Nothing written, on any axis, by any of them. |
| + rt := f.routeOf("keymail.dev") |
| + if rt.Backing != "" || rt.Hibernate || rt.Caps != "" || rt.Channel != "stable" || rt.Environment != "" { |
| + t.Fatalf("a refused command still wrote: %+v", rt) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The other half of the same rail: -backing ALONE, on its own route, still |
| +// works — and prints what the next adopt pass will do with the new value. |
| +// Without this the exclusions above could be satisfied by a verb that refuses |
| +// everything. |
| +func TestRouteBackingStampsAndSaysWhatHappensNext(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", |
| + Channel: "stable", Status: "ready"}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unit") |
| + if err != nil { |
| + t.Fatalf("route -backing unit: %v\n%s", err, out) |
| + } |
| + if got := f.routeOf("keymail.dev").Backing; got != registry.BackingUnit { |
| + t.Fatalf("backing column = %q, want %q", got, registry.BackingUnit) |
| + } |
| + if !strings.Contains(out, "capability drop-ins") { |
| + t.Errorf("the stamp does not say what the next adopt pass will do:\n%s", out) |
| + } |
| + // And back again — the migration's reversibility depends on it. |
| + out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "") |
| + if err != nil { |
| + t.Fatalf(`route -backing "": %v\n%s`, err, out) |
| + } |
| + if got := f.routeOf("keymail.dev").Backing; got != "" { |
| + t.Fatalf("backing column = %q after clearing, want empty", got) |
| + } |
| +} |