| new file mode 100644 |
| index 000000000..f98f2726e |
| --- /dev/null |
| +++ b/internal/edge/homemap_assets_test.go |
| @@ -0,0 +1,246 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// assetsGolden is the host entry the real edges-file writer produced, read |
| +// from the file internal/host's TestPublishesAssetsForAnArmedHome asserts |
| +// against. The home map's reader is tested on it, not on a hand copy. |
| +func assetsGolden(t *testing.T) string { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join("..", "edgesfile", "testdata", "assets-host.golden.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// goldenAt is the golden entry's "at". |
| +var goldenAt = time.Unix(1792832400, 0) |
| + |
| +// putAssetsFile lands one box's file whose only host is host with the given |
| +// entry body. |
| +func putAssetsFile(t *testing.T, m *homeMap, box, eip, host, entry string) { |
| + t.Helper() |
| + putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf( |
| + `{"v":1,"box":%q,"eip":%q,"hosts":{%q:%s}}`, box, eip, host, entry)) |
| +} |
| + |
| +func TestHomeMapParsesAssets(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t)) |
| + refreshHomeMap(t, m) |
| + |
| + got, ok := m.AssetsFor("shop.example.com", goldenAt.Add(time.Minute)) |
| + if !ok { |
| + t.Fatal("AssetsFor = false for a fresh published block") |
| + } |
| + want := HostAssets{Account: "abc", App: "shop", Paths: []string{"/_app/immutable/", "/assets/"}, Gen: "0123456789abcdef", At: goldenAt} |
| + if got.Account != want.Account || got.App != want.App || got.Gen != want.Gen || |
| + !got.At.Equal(want.At) || !slices.Equal(got.Paths, want.Paths) { |
| + t.Fatalf("AssetsFor = %+v, want %+v", got, want) |
| + } |
| + // The answer is the caller's: scribbling on it leaves the map alone. |
| + got.Paths[0] = "/scribbled/" |
| + again, _ := m.AssetsFor("shop.example.com", goldenAt) |
| + if again.Paths[0] != "/_app/immutable/" { |
| + t.Fatalf("AssetsFor shares its path slice with the snapshot: %v", again.Paths) |
| + } |
| +} |
| + |
| +func TestAssetsForFalseBeforeFirstRead(t *testing.T) { |
| + inner := meshTestStore(t) |
| + s := &listFailingStore{Store: inner} |
| + m := newHomeMap(s, "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t)) |
| + now := goldenAt.Add(time.Minute) |
| + |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("answered before any read") |
| + } |
| + s.setFail(true) |
| + if err := m.Refresh(context.Background()); err == nil { |
| + t.Fatal("precondition: the failing list must be reported") |
| + } |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("answered after a hosts read that failed") |
| + } |
| + s.setFail(false) |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", now); !ok { |
| + t.Fatal("no answer after the first successful read") |
| + } |
| + var nilMap *homeMap |
| + if _, ok := nilMap.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("a nil home map answered") |
| + } |
| +} |
| + |
| +func TestAssetsForRefusesAStaleAt(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t)) |
| + refreshHomeMap(t, m) |
| + for _, tc := range []struct { |
| + age time.Duration |
| + want bool |
| + }{ |
| + {0, true}, {59 * time.Minute, true}, {time.Hour, true}, |
| + {time.Hour + time.Second, false}, {61 * time.Minute, false}, {24 * time.Hour, false}, |
| + {-5 * time.Minute, true}, // a reader clock behind the publisher's is not stale |
| + } { |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(tc.age)); ok != tc.want { |
| + t.Errorf("age %v: ok = %v, want %v", tc.age, ok, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// An unreadable file keeps its previous parse (refreshHosts), and the kept |
| +// block does not outlive its time: the at it carries still ages out. |
| +func TestAssetsForKeepsAStaleParseStale(t *testing.T) { |
| + inner := meshTestStore(t) |
| + s := &oneKeyFailingStore{Store: inner} |
| + m := newHomeMap(s, "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t)) |
| + // A second readable file, so betty's is one bad object and not a bucket |
| + // fault (refreshHosts returns an error when nothing at all can be read). |
| + putAssetsFile(t, m, "carol", "3.3.3.3", "other.example.com", `{"role":"home"}`) |
| + refreshHomeMap(t, m) |
| + |
| + s.failKey("control/edges/betty.json") |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(30*time.Minute)); !ok { |
| + t.Fatal("an unreadable file must keep serving its last parse while it is fresh") |
| + } |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(61*time.Minute)); ok { |
| + t.Fatal("a kept parse served past its hour") |
| + } |
| + // A corrupt body is the same class of fault. |
| + putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty",`) |
| + s.failKey("") |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(30*time.Minute)); !ok { |
| + t.Fatal("a corrupt file must keep serving its last parse while it is fresh") |
| + } |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt.Add(61*time.Minute)); ok { |
| + t.Fatal("a kept parse of a corrupt file served past its hour") |
| + } |
| +} |
| + |
| +func TestAssetsForNeverMatchesAWildcard(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + // A catch-all key carrying a block (no writer does this today). |
| + putJSON(t, m.s, "control/edges/betty.json", fmt.Sprintf( |
| + `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.example.com":%s}}`, assetsGolden(t))) |
| + refreshHomeMap(t, m) |
| + now := goldenAt.Add(time.Minute) |
| + |
| + if _, _, ok := m.Home("shop.example.com"); !ok { |
| + t.Fatal("precondition: Home does match one label under the catch-all") |
| + } |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("AssetsFor matched a name through a wildcard key") |
| + } |
| + if _, ok := m.AssetsFor("a.b.example.com", now); ok { |
| + t.Fatal("AssetsFor matched two labels through a wildcard key") |
| + } |
| +} |
| + |
| +func TestAssetsForNeedsADialTargetAnOwnerAndASelf(t *testing.T) { |
| + now := goldenAt.Add(time.Minute) |
| + noOwner := `{"role":"home","assets":{"paths":["/assets/"],"gen":"0123456789abcdef","at":1792832400}}` |
| + certOnly := `{"role":"wildcard","wildcard":true,"account":"abc","app":"shop","assets":{"paths":["/assets/"],"gen":"0123456789abcdef","at":1792832400}}` |
| + |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", noOwner) |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("a block with no published owner was served") |
| + } |
| + |
| + m = newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "*.example.com", certOnly) |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("*.example.com", now); ok { |
| + t.Fatal("a cert-only entry is not a dial target and carries no assets") |
| + } |
| + |
| + // Own box: the fold excludes it, so the local path (LocalAssets) answers. |
| + m = newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com", assetsGolden(t)) |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("the home map answered for this box's own host") |
| + } |
| + |
| + // No self label: fail closed, as Home does. |
| + m = newHomeMap(meshTestStore(t), "") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", assetsGolden(t)) |
| + refreshHomeMap(t, m) |
| + if _, ok := m.AssetsFor("shop.example.com", now); ok { |
| + t.Fatal("a map with no self label answered") |
| + } |
| +} |
| + |
| +// Two boxes claim a host. The dial winner's whole entry is the one: its |
| +// block, and no one else's, even when the loser's block is newer. |
| +func TestAssetsForFollowsTheWinningEntry(t *testing.T) { |
| + const old = `{"role":"home","stamp":5,"account":"abc","app":"shop","assets":{"paths":["/old/"],"gen":"aaaaaaaaaaaaaaaa","at":1792832400}}` |
| + const fresh = `{"role":"home","stamp":9,"account":"abc","app":"shop","assets":{"paths":["/new/"],"gen":"bbbbbbbbbbbbbbbb","at":1792832400}}` |
| + const bare = `{"role":"home","stamp":99,"account":"abc","app":"shop"}` |
| + now := goldenAt.Add(time.Minute) |
| + |
| + for _, tc := range []struct { |
| + name, annie, betty, wantGen string |
| + wantOK bool |
| + }{ |
| + {"higher stamp wins, from the later file", old, fresh, "bbbbbbbbbbbbbbbb", true}, |
| + {"higher stamp wins, from the earlier file", fresh, old, "bbbbbbbbbbbbbbbb", true}, |
| + {"winner without a block hides the loser's", old, bare, "", false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "carol") |
| + putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com", tc.annie) |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", tc.betty) |
| + refreshHomeMap(t, m) |
| + got, ok := m.AssetsFor("shop.example.com", now) |
| + if ok != tc.wantOK || got.Gen != tc.wantGen { |
| + t.Fatalf("AssetsFor = %+v %v, want gen %q ok %v", got, ok, tc.wantGen, tc.wantOK) |
| + } |
| + }) |
| + } |
| + |
| + // A route beats an alias whatever the order, and the block follows. |
| + m := newHomeMap(meshTestStore(t), "carol") |
| + putAssetsFile(t, m, "annie", "1.1.1.1", "shop.example.com", |
| + `{"role":"home","alias":true,"account":"abc","app":"shop","assets":{"paths":["/alias/"],"gen":"cccccccccccccccc","at":1792832400}}`) |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", fresh) |
| + refreshHomeMap(t, m) |
| + if got, ok := m.AssetsFor("shop.example.com", now); !ok || got.Gen != "bbbbbbbbbbbbbbbb" { |
| + t.Fatalf("AssetsFor = %+v %v, want the route's block", got, ok) |
| + } |
| +} |
| + |
| +// A file written before assets existed parses exactly as before and yields |
| +// no block; Home and Owner are unaffected. |
| +func TestOldFileWithoutAssetsParses(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + putAssetsFile(t, m, "betty", "2.2.2.2", "shop.example.com", |
| + `{"role":"home","platform":true,"account":"abc","app":"shop","stamp":3}`) |
| + refreshHomeMap(t, m) |
| + if eip, box, ok := m.Home("shop.example.com"); !ok || eip != "2.2.2.2" || box != "betty" { |
| + t.Fatalf("Home = %s %s %v", eip, box, ok) |
| + } |
| + if acct, app, _, _, ok := m.Owner("shop.example.com"); !ok || acct != "abc" || app != "shop" { |
| + t.Fatalf("Owner = %s %s %v", acct, app, ok) |
| + } |
| + if _, ok := m.AssetsFor("shop.example.com", goldenAt); ok { |
| + t.Fatal("a host with no block answered") |
| + } |
| +} |