| new file mode 100644 |
| index 000000000..166a9506e |
| --- /dev/null |
| +++ b/internal/domains/aliases_complete_test.go |
| @@ -0,0 +1,487 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The box advances its domain confirmed time only after a ReconcileAliases |
| +// pass that reported Complete. These tests pin every way the pass can log a |
| +// failure and carry on: each one must flip Complete to false while the pass |
| +// still returns nil (existing callers keep today's error behaviour). Every |
| +// incomplete case has a control run on the identical fixture with nothing |
| +// injected, which must report Complete, so a flip is the injection's doing |
| +// and not the fixture's. |
| + |
| +// aliasEnv is one test registry at a known path, so a second handle can be |
| +// opened on the same file, plus the bucket the pass reads claims from. |
| +type aliasEnv struct { |
| + t *testing.T |
| + root string |
| + store *release.DirStore |
| + reg *registry.Registry |
| + path string |
| +} |
| + |
| +func newAliasEnv(t *testing.T) *aliasEnv { |
| + t.Helper() |
| + root := t.TempDir() |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &aliasEnv{t: t, root: root, store: release.NewDirStore(root), reg: reg, path: path} |
| +} |
| + |
| +// failWhen makes one registry write fail and nothing else. It opens the |
| +// registry's database file a second time through database/sql (the "sqlite" |
| +// driver internal/registry registers) and creates a BEFORE trigger on table |
| +// whose body raises when cond holds (cond refers to NEW or OLD). Reads still |
| +// succeed, so ListAliases and the other listings do not fail first and the |
| +// pass runs to the write it is meant to fail at. event is INSERT, UPDATE or |
| +// DELETE. |
| +func (e *aliasEnv) failWhen(event, table, cond string) { |
| + e.t.Helper() |
| + db, err := sql.Open("sqlite", e.path) |
| + if err != nil { |
| + e.t.Fatal(err) |
| + } |
| + defer db.Close() |
| + for _, q := range []string{ |
| + "PRAGMA busy_timeout=5000", |
| + fmt.Sprintf("CREATE TRIGGER inject_%s_%s BEFORE %s ON %s WHEN %s BEGIN SELECT RAISE(FAIL, 'injected'); END", |
| + strings.ToLower(event), table, event, table, cond), |
| + } { |
| + if _, err := db.Exec(q); err != nil { |
| + e.t.Fatalf("%s: %v", q, err) |
| + } |
| + } |
| +} |
| + |
| +// failUpsert fails the INSERT a registry upsert issues, and its UPDATE arm. |
| +func (e *aliasEnv) failUpsert(table, cond string) { |
| + e.t.Helper() |
| + e.failWhen("INSERT", table, "NEW."+cond) |
| + e.failWhen("UPDATE", table, "NEW."+cond) |
| +} |
| + |
| +func (e *aliasEnv) failDelete(table, cond string) { |
| + e.t.Helper() |
| + e.failWhen("DELETE", table, "OLD."+cond) |
| +} |
| + |
| +// pass runs ReconcileAliasesReport and returns the report with the error. |
| +// The report starts at Complete=true so a pass that never writes it, or |
| +// writes it wrongly, cannot pass by luck of the zero value. |
| +func (e *aliasEnv) pass(store release.Store, deploymentRoutes map[string]bool, cache *ClaimCache) (AliasReport, error) { |
| + e.t.Helper() |
| + rep := AliasReport{Complete: true} |
| + err := ReconcileAliasesReport(context.Background(), store, e.reg, aliasNow(), deploymentRoutes, cache, &rep) |
| + return rep, err |
| +} |
| + |
| +func (e *aliasEnv) mustPass() { |
| + e.t.Helper() |
| + if rep, err := e.pass(e.store, map[string]bool{}, nil); err != nil || !rep.Complete { |
| + e.t.Fatalf("setup pass: complete=%v err=%v", rep.Complete, err) |
| + } |
| +} |
| + |
| +// known is the deployment-routes input of a pass that read it: non-nil, and |
| +// empty is a real answer. |
| +func known() map[string]bool { return map[string]bool{} } |
| + |
| +// TestAliasReportCompleteOnACleanPass is the baseline: a pass over a real |
| +// claim, with the deployment's routes known and every write landing, is |
| +// complete. It also checks the wrapper: ReconcileAliases, with no report to |
| +// fill, still returns nil. |
| +func TestAliasReportCompleteOnACleanPass(t *testing.T) { |
| + e := newAliasEnv(t) |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + rep, err := e.pass(e.store, known(), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rep.Complete { |
| + t.Fatal("a clean pass reported incomplete") |
| + } |
| + if _, ok := aliasMap(t, e.reg)["www.example.com"]; !ok { |
| + t.Fatalf("the alias was not written: %+v", aliasMap(t, e.reg)) |
| + } |
| + if err := ReconcileAliases(context.Background(), e.store, e.reg, aliasNow(), known(), nil); err != nil { |
| + t.Fatalf("ReconcileAliases (no report): %v", err) |
| + } |
| +} |
| + |
| +// TestAliasReportCompleteOnAnEmptyBucket: no claims at all is a complete pass |
| +// (a box with nothing claimed must still be able to confirm). |
| +func TestAliasReportCompleteOnAnEmptyBucket(t *testing.T) { |
| + e := newAliasEnv(t) |
| + rep, err := e.pass(e.store, known(), nil) |
| + if err != nil || !rep.Complete { |
| + t.Fatalf("empty bucket: complete=%v err=%v", rep.Complete, err) |
| + } |
| +} |
| + |
| +// TestAliasReportIncompleteOnAFailedWrite drives each registry write the pass |
| +// logs and survives. The pass still returns nil; only the report changes. |
| +func TestAliasReportIncompleteOnAFailedWrite(t *testing.T) { |
| + const wildHost = "wild.example.com" |
| + cases := []struct { |
| + name string |
| + setup func(e *aliasEnv) |
| + // prepare runs after setup on a registry that is still healthy, to |
| + // leave a row for the pass to delete; it may change the bucket. |
| + prepare func(e *aliasEnv) |
| + inject func(e *aliasEnv) |
| + // landed reports whether the write the injection targets took effect. |
| + landed func(t *testing.T, e *aliasEnv) bool |
| + }{ |
| + { |
| + name: "alias upsert", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + }, |
| + inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = 'www.example.com'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["www.example.com"]; return ok }, |
| + }, |
| + { |
| + name: "instance catch-all alias upsert", |
| + setup: func(e *aliasEnv) { |
| + putClaim(t, e.store, Claim{Host: "example.com", Account: "abc", App: "shop-site"}) |
| + putClaim(t, e.store, Claim{Host: "*.example.com", Account: "abc", App: "shop", Instances: true}) |
| + addInstance(t, e.reg, "acme.abc.carlos.example.net", "acme", "abc", "shop") |
| + }, |
| + inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = 'acme.example.com'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["acme.example.com"]; return ok }, |
| + }, |
| + { |
| + name: "wildcard catch-all alias upsert", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true, Catchall: true}) |
| + }, |
| + inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = '*."+wildHost+"'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["*."+wildHost]; return ok }, |
| + }, |
| + { |
| + name: "alias delete", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + }, |
| + prepare: func(e *aliasEnv) { |
| + e.mustPass() |
| + deleteClaim(t, e.store, "www.example.com") |
| + }, |
| + inject: func(e *aliasEnv) { e.failDelete("route_aliases", "alias = 'www.example.com'") }, |
| + // The row is meant to be gone; "landed" is the delete happening. |
| + landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["www.example.com"]; return !ok }, |
| + }, |
| + { |
| + name: "wildcard upsert", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true}) |
| + }, |
| + inject: func(e *aliasEnv) { e.failUpsert("wildcard_domains", "host = '"+wildHost+"'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { |
| + rows, err := e.reg.ListWildcardDomains() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(rows) == 1 |
| + }, |
| + }, |
| + { |
| + name: "wildcard delete", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true}) |
| + }, |
| + prepare: func(e *aliasEnv) { |
| + e.mustPass() |
| + deleteClaim(t, e.store, wildHost) |
| + }, |
| + inject: func(e *aliasEnv) { e.failDelete("wildcard_domains", "host = '"+wildHost+"'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { |
| + rows, err := e.reg.ListWildcardDomains() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(rows) == 0 |
| + }, |
| + }, |
| + { |
| + name: "path upsert", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + addRoute(t, e.reg, "api.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com", |
| + Paths: map[string]string{"api": "api.example.com"}}) |
| + }, |
| + inject: func(e *aliasEnv) { e.failUpsert("route_paths", "host = 'shop.example.com' AND NEW.prefix = 'api'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { return pathsByPrefix(t, e.reg, "shop.example.com")["api"] != "" }, |
| + }, |
| + { |
| + name: "path delete", |
| + setup: func(e *aliasEnv) { |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + addRoute(t, e.reg, "api.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com", |
| + Paths: map[string]string{"api": "api.example.com"}}) |
| + }, |
| + prepare: func(e *aliasEnv) { |
| + e.mustPass() |
| + // putClaim is create-only: drop the old body, write one with no paths. |
| + deleteClaim(t, e.store, "shop.example.com") |
| + putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + }, |
| + inject: func(e *aliasEnv) { e.failDelete("route_paths", "host = 'shop.example.com' AND OLD.prefix = 'api'") }, |
| + landed: func(t *testing.T, e *aliasEnv) bool { return pathsByPrefix(t, e.reg, "shop.example.com")["api"] == "" }, |
| + }, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + // Control: identical fixture, nothing injected. |
| + ctl := newAliasEnv(t) |
| + c.setup(ctl) |
| + if c.prepare != nil { |
| + c.prepare(ctl) |
| + } |
| + rep, err := ctl.pass(ctl.store, known(), nil) |
| + if err != nil { |
| + t.Fatalf("control: %v", err) |
| + } |
| + if !rep.Complete { |
| + t.Fatal("control pass (no injection) reported incomplete: the fixture, not the write, is the problem") |
| + } |
| + if !c.landed(t, ctl) { |
| + t.Fatal("control pass did not make the write this case targets: the fixture does not reach it") |
| + } |
| + |
| + // Injected: the one named write fails. |
| + e := newAliasEnv(t) |
| + c.setup(e) |
| + if c.prepare != nil { |
| + c.prepare(e) |
| + } |
| + c.inject(e) |
| + rep, err = e.pass(e.store, known(), nil) |
| + if err != nil { |
| + t.Fatalf("a failed write must not fail the pass: %v", err) |
| + } |
| + if c.landed(t, e) { |
| + t.Fatal("the injected write landed anyway: the trigger did not fire") |
| + } |
| + if rep.Complete { |
| + t.Fatal("the pass hid a failed registry write as complete") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAliasReportIncompleteWhenTheLabelCountFails is the instance catch-all's |
| +// listing blip: the names are kept for the pass, and the pass says it did not |
| +// see the app's instances. |
| +func TestAliasReportIncompleteWhenTheLabelCountFails(t *testing.T) { |
| + e := newAliasEnv(t) |
| + putClaim(t, e.store, Claim{Host: "example.com", Account: "abc", App: "shop-site"}) |
| + putClaim(t, e.store, Claim{Host: "*.example.com", Account: "abc", App: "shop", Instances: true}) |
| + addInstance(t, e.reg, "acme.abc.carlos.example.net", "acme", "abc", "shop") |
| + if rep, err := e.pass(e.store, known(), nil); err != nil || !rep.Complete { |
| + t.Fatalf("control: complete=%v err=%v", rep.Complete, err) |
| + } |
| + failing := listFailStore{e.store, instanceRecordsPrefix("abc", "shop")} |
| + rep, err := e.pass(failing, known(), nil) |
| + if err != nil { |
| + t.Fatalf("a listing blip must not fail the pass: %v", err) |
| + } |
| + if rep.Complete { |
| + t.Fatal("the pass hid a failed instance listing as complete") |
| + } |
| + if _, ok := aliasMap(t, e.reg)["acme.example.com"]; !ok { |
| + t.Fatal("the name was dropped on a listing failure") |
| + } |
| +} |
| + |
| +// getFailStore fails GET of one key with a non-NotExist error, the way a |
| +// transient bucket error reads. |
| +type getFailStore struct { |
| + *release.DirStore |
| + key string |
| +} |
| + |
| +func (g getFailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == g.key { |
| + return nil, "", errors.New("synthetic read failure") |
| + } |
| + return g.DirStore.Get(ctx, key) |
| +} |
| + |
| +// getCountStore counts GETs of one key. |
| +type getCountStore struct { |
| + *release.DirStore |
| + key string |
| + n *atomic.Int32 |
| +} |
| + |
| +func (g getCountStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == g.key { |
| + g.n.Add(1) |
| + } |
| + return g.DirStore.Get(ctx, key) |
| +} |
| + |
| +// TestAliasReportIncompleteWhenAClaimBodyIsUnreadable: a claim the listing |
| +// shows but whose body yields no claim cannot have been decided on, whatever |
| +// the reason, and so the pass is incomplete. Both folds (plain and cached) |
| +// and a cache entry carried forward from an earlier pass are covered. |
| +func TestAliasReportIncompleteWhenAClaimBodyIsUnreadable(t *testing.T) { |
| + const host = "www.example.com" |
| + key := ClaimKey(host) |
| + bodies := []struct { |
| + name string |
| + put func(t *testing.T, e *aliasEnv) |
| + }{ |
| + {"unparseable", func(t *testing.T, e *aliasEnv) { |
| + if _, err := e.store.Put(context.Background(), key, []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"schema newer than this binary", func(t *testing.T, e *aliasEnv) { |
| + if _, err := e.store.Put(context.Background(), key, []byte(`{"schema":99,"host":"`+host+`"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + } |
| + for _, b := range bodies { |
| + for _, cached := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("%s/cache=%v", b.name, cached), func(t *testing.T) { |
| + e := newAliasEnv(t) |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + // A readable neighbour proves the pass reads claims at all. |
| + putClaim(t, e.store, Claim{Host: "other.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + b.put(t, e) |
| + var cache *ClaimCache |
| + if cached { |
| + cache = &ClaimCache{} |
| + } |
| + rep, err := e.pass(e.store, known(), cache) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.Complete { |
| + t.Fatal("the pass hid an unreadable claim body as complete") |
| + } |
| + if _, ok := aliasMap(t, e.reg)["other.example.com"]; !ok { |
| + t.Fatal("the readable claim was not derived") |
| + } |
| + }) |
| + } |
| + } |
| + |
| + t.Run("GET fails", func(t *testing.T) { |
| + for _, cached := range []bool{false, true} { |
| + e := newAliasEnv(t) |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: host, Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + var cache *ClaimCache |
| + if cached { |
| + cache = &ClaimCache{} |
| + } |
| + if rep, err := e.pass(e.store, known(), cache); err != nil || !rep.Complete { |
| + t.Fatalf("cache=%v control: complete=%v err=%v", cached, rep.Complete, err) |
| + } |
| + // A fresh cache, so the read really happens on the failing pass. |
| + if cached { |
| + cache = &ClaimCache{} |
| + } |
| + rep, err := e.pass(getFailStore{e.store, key}, known(), cache) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.Complete { |
| + t.Fatalf("cache=%v: the pass hid a failed claim read as complete", cached) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("a cached skip is still counted when carried forward", func(t *testing.T) { |
| + e := newAliasEnv(t) |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + if _, err := e.store.Put(context.Background(), key, []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Old enough that the cache admits the entry (modSettle). |
| + old := time.Now().Add(-time.Hour) |
| + if err := os.Chtimes(filepath.Join(e.root, key), old, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + var n atomic.Int32 |
| + counting := getCountStore{e.store, key, &n} |
| + cache := &ClaimCache{} |
| + for pass := 1; pass <= 3; pass++ { |
| + rep, err := e.pass(counting, known(), cache) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.Complete { |
| + t.Fatalf("pass %d hid the undecodable claim as complete", pass) |
| + } |
| + } |
| + if n.Load() != 1 { |
| + t.Fatalf("the body was read %d times; the test never reached the carried-forward path", n.Load()) |
| + } |
| + }) |
| +} |
| + |
| +// TestAliasReportIncompleteWhenDeploymentRoutesIsNil: a failed |
| +// edgesfile.DeploymentRoutes read comes back nil and every guard reading it |
| +// allows. The pass still runs (nil stays permissive), but it cannot vouch. |
| +func TestAliasReportIncompleteWhenDeploymentRoutesIsNil(t *testing.T) { |
| + e := newAliasEnv(t) |
| + addRoute(t, e.reg, "shop.example.com", "abc", "shop") |
| + putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"}) |
| + rep, err := e.pass(e.store, nil, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.Complete { |
| + t.Fatal("a nil deploymentRoutes (read failed) was reported as complete") |
| + } |
| + if _, ok := aliasMap(t, e.reg)["www.example.com"]; !ok { |
| + t.Fatal("nil deploymentRoutes is permissive: the alias should still derive") |
| + } |
| + if rep, err = e.pass(e.store, known(), nil); err != nil || !rep.Complete { |
| + t.Fatalf("an empty, non-nil deploymentRoutes is a real answer: complete=%v err=%v", rep.Complete, err) |
| + } |
| +} |
| + |
| +// TestAliasReportIncompleteWhenThePassErrors: a pass that returns an error |
| +// reports incomplete, even into a report that was left saying otherwise. |
| +func TestAliasReportIncompleteWhenThePassErrors(t *testing.T) { |
| + e := newAliasEnv(t) |
| + failing := listFailStore{e.store, ClaimsPrefix} |
| + rep, err := e.pass(failing, known(), nil) |
| + if err == nil { |
| + t.Fatal("a claim listing failure must still be an error") |
| + } |
| + if rep.Complete { |
| + t.Fatal("an errored pass reported complete") |
| + } |
| +} |