| 1 | package provision |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // proposeFixture is a small census: one legacy unit, one authority unit, one |
| 11 | // envelope, sealed, and a scan that matches it. |
| 12 | func proposeFixture(t *testing.T) (rootReadScan, rootReadInventory, string) { |
| 13 | t.Helper() |
| 14 | legacy := classifiedRootRead{rootReadEntry: rootReadEntry{File: "internal/a/a.go", Unit: "Wake", Kinds: "registry:Host@10,registry:DBPath@20", Digest: strings.Repeat("1", 64)}, Class: "legacy", Task: "accepted-legacy", Basis: "accepted-legacy;rev3=J1;basis=root-route"} |
| 15 | auth := classifiedRootRead{rootReadEntry: rootReadEntry{File: "internal/a/b.go", Unit: "Route", Kinds: "registry:Host@5", Digest: strings.Repeat("2", 64)}, Class: "authority", Task: "E4", Basis: "root-route"} |
| 16 | fp := rootReadLegacyFingerprint(legacy) |
| 17 | inv := rootReadInventory{ |
| 18 | Entries: map[string]classifiedRootRead{"internal/a/a.go:Wake": legacy, "internal/a/b.go:Route": auth}, |
| 19 | Frozen: map[string]classifiedRootRead{"internal/a/a.go:Wake": legacy}, |
| 20 | Legacy: map[string]bool{fp: true}, |
| 21 | Coverage: map[string]string{"internal/a": strings.Repeat("c", 64)}, |
| 22 | } |
| 23 | got := rootReadScan{ |
| 24 | Entries: []rootReadEntry{legacy.rootReadEntry, auth.rootReadEntry}, |
| 25 | Declarations: map[string]string{"internal/a/a.go:Wake": legacy.Digest, "internal/a/b.go:Route": auth.Digest}, |
| 26 | Coverage: map[string]string{"internal/a": strings.Repeat("c", 64)}, |
| 27 | } |
| 28 | return got, inv, rootReadLegacySeal(inv) |
| 29 | } |
| 30 | |
| 31 | // change edits the scan as a code change would: a new digest, and the |
| 32 | // unit's read sites (kinds) as given. |
| 33 | func change(got *rootReadScan, key, kinds, digest string) { |
| 34 | for i, e := range got.Entries { |
| 35 | if e.File+":"+e.Unit == key { |
| 36 | got.Entries[i].Kinds, got.Entries[i].Digest = kinds, digest |
| 37 | } |
| 38 | } |
| 39 | got.Declarations[key] = digest |
| 40 | } |
| 41 | |
| 42 | func TestCensusClassifiesEachKindOfChange(t *testing.T) { |
| 43 | got, inv, seal := proposeFixture(t) |
| 44 | // body: the same reads at moved offsets. |
| 45 | change(&got, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| 46 | // reads: a kind of read the unit did not make. |
| 47 | change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Socket@9", strings.Repeat("4", 64)) |
| 48 | got.Coverage["internal/a"] = strings.Repeat("d", 64) |
| 49 | got.Entries = append(got.Entries, rootReadEntry{File: "internal/a/c.go", Unit: "New", Kinds: "file:Open@1", Digest: strings.Repeat("5", 64)}) |
| 50 | got.Declarations["internal/a/c.go:New"] = strings.Repeat("5", 64) |
| 51 | |
| 52 | classes := map[string]string{} |
| 53 | for _, c := range classifyRootReads(got, inv, seal) { |
| 54 | classes[c.Key] = c.Class |
| 55 | } |
| 56 | want := map[string]string{ |
| 57 | "internal/a/a.go:Wake": censusBody, |
| 58 | "internal/a/b.go:Route": censusReads, |
| 59 | "internal/a": censusEnvelope, |
| 60 | "internal/a/c.go:New": censusNew, |
| 61 | } |
| 62 | for k, v := range want { |
| 63 | if classes[k] != v { |
| 64 | t.Errorf("class of %s = %q, want %q (all: %v)", k, classes[k], v, classes) |
| 65 | } |
| 66 | } |
| 67 | if len(classes) != len(want) { |
| 68 | t.Errorf("classes = %v, want exactly %v", classes, want) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // sites: another read of a kind the unit already makes. |
| 73 | func TestCensusClassifiesMoreReadsOfAKnownKindAsSites(t *testing.T) { |
| 74 | got, inv, seal := proposeFixture(t) |
| 75 | change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Host@9", strings.Repeat("4", 64)) |
| 76 | cs := classifyRootReads(got, inv, seal) |
| 77 | if len(cs) != 1 || cs[0].Class != censusSites { |
| 78 | t.Fatalf("changes = %+v, want one sites", cs) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // The card never shows less than the census: a discrepancy no unit or |
| 83 | // envelope change explains is an "other" decision with the census's own |
| 84 | // message (Astra review, census-approvals P2). |
| 85 | func TestCensusClassifiesEveryOtherDiscrepancy(t *testing.T) { |
| 86 | got, inv, seal := proposeFixture(t) |
| 87 | for name, tc := range map[string]struct { |
| 88 | edit func(*rootReadScan, *rootReadInventory) string |
| 89 | want string |
| 90 | }{ |
| 91 | "a wrong seal": { |
| 92 | edit: func(*rootReadScan, *rootReadInventory) string { return strings.Repeat("e", 64) }, |
| 93 | want: "changed legacy freeze", |
| 94 | }, |
| 95 | "a removed declaration back in the tree": { |
| 96 | edit: func(g *rootReadScan, i *rootReadInventory) string { |
| 97 | i.Removed = map[string]string{"internal/a/z.go:Gone": "E4;census-ruling-x"} |
| 98 | g.Declarations["internal/a/z.go:Gone"] = strings.Repeat("6", 64) |
| 99 | return seal |
| 100 | }, |
| 101 | want: "internal/a/z.go:Gone", |
| 102 | }, |
| 103 | } { |
| 104 | t.Run(name, func(t *testing.T) { |
| 105 | g, i := got, cloneRootReadInventory(inv) |
| 106 | g.Declarations = cloneMap(got.Declarations) |
| 107 | s := tc.edit(&g, &i) |
| 108 | cs := classifyRootReads(g, i, s) |
| 109 | if len(cs) == 0 { |
| 110 | t.Fatal("classified nothing") |
| 111 | } |
| 112 | for _, c := range cs { |
| 113 | if c.Class != censusOther || !strings.Contains(c.Key, tc.want) { |
| 114 | t.Fatalf("changes = %+v, want an other carrying %q", cs, tc.want) |
| 115 | } |
| 116 | } |
| 117 | if card := rootReadCard("x", cs, nil, s, ""); strings.Contains(card, "Nothing to approve") || !strings.Contains(card, tc.want) { |
| 118 | t.Errorf("card:\n%s", card) |
| 119 | } |
| 120 | }) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestCensusClassifiesARemovedUnit(t *testing.T) { |
| 125 | got, inv, seal := proposeFixture(t) |
| 126 | got.Entries = got.Entries[:1] |
| 127 | delete(got.Declarations, "internal/a/b.go:Route") |
| 128 | cs := classifyRootReads(got, inv, seal) |
| 129 | if len(cs) != 1 || cs[0].Class != censusRemoved || cs[0].Key != "internal/a/b.go:Route" { |
| 130 | t.Fatalf("changes = %+v, want one removed", cs) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // The proposal is the whole job: loaded onto the inventory it leaves no |
| 135 | // discrepancy, under the seal it reports, and every row names the ruling. |
| 136 | func TestCensusProposalClosesEveryApprovableChange(t *testing.T) { |
| 137 | got, inv, seal := proposeFixture(t) |
| 138 | change(&got, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| 139 | change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Host@9", strings.Repeat("4", 64)) |
| 140 | got.Coverage["internal/a"] = strings.Repeat("d", 64) |
| 141 | if len(censusDiscrepancies(got, inv, seal)) == 0 { |
| 142 | t.Fatal("fixture change made no discrepancy") |
| 143 | } |
| 144 | claims := map[string]string{ |
| 145 | "internal/a/a.go:Wake": "logs the wake's timing; no read added", |
| 146 | "internal/a/b.go:Route": "reads the route's host again to log it", |
| 147 | } |
| 148 | p, err := proposeRootReads(got, inv, seal, "wake-timing", claims) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | path := filepath.Join(t.TempDir(), "overlay.tsv") |
| 153 | if err := os.WriteFile(path, []byte(p.Overlay), 0o644); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | after := cloneRootReadInventory(inv) |
| 157 | if err := loadRootReadInventory(path, &after, true); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | if errs := censusDiscrepancies(got, after, p.Seal); len(errs) != 0 { |
| 161 | t.Fatalf("after the proposal: %v\n%s", errs, p.Overlay) |
| 162 | } |
| 163 | if rootReadLegacySeal(inv) != seal { |
| 164 | t.Error("proposing changed the inventory it was given") |
| 165 | } |
| 166 | for _, line := range strings.Split(p.Overlay, "\n") { |
| 167 | if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "@coverage") && !strings.Contains(line, "rev3=census-ruling-wake-timing;basis=") { |
| 168 | t.Errorf("row does not name the ruling: %s", line) |
| 169 | } |
| 170 | } |
| 171 | for _, want := range []string{"census-ruling-wake-timing", "internal/a/a.go", "body", "sites", "+registry:Host", "internal/a", seal, p.Seal, "logs the wake's timing"} { |
| 172 | if !strings.Contains(p.Card, want) { |
| 173 | t.Errorf("card lacks %q:\n%s", want, p.Card) |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestCensusProposalRefuses(t *testing.T) { |
| 179 | for name, tc := range map[string]struct { |
| 180 | edit func(*rootReadScan) |
| 181 | claims map[string]string |
| 182 | want string |
| 183 | }{ |
| 184 | "a change without a claim": { |
| 185 | edit: func(g *rootReadScan) { |
| 186 | change(g, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| 187 | }, |
| 188 | claims: map[string]string{}, |
| 189 | want: "no claim", |
| 190 | }, |
| 191 | "a claim for no change": { |
| 192 | edit: func(g *rootReadScan) { |
| 193 | change(g, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| 194 | }, |
| 195 | claims: map[string]string{"internal/a/a.go:Wake": "x", "internal/a/z.go:Typo": "y"}, |
| 196 | want: "not a change", |
| 197 | }, |
| 198 | "a new kind of read": { |
| 199 | edit: func(g *rootReadScan) { |
| 200 | change(g, "internal/a/b.go:Route", "registry:Host@5,file:Open@9", strings.Repeat("4", 64)) |
| 201 | }, |
| 202 | claims: map[string]string{"internal/a/b.go:Route": "opens a file"}, |
| 203 | want: "decision", |
| 204 | }, |
| 205 | "a new unit": { |
| 206 | edit: func(g *rootReadScan) { |
| 207 | g.Entries = append(g.Entries, rootReadEntry{File: "internal/a/c.go", Unit: "New", Kinds: "file:Open@1", Digest: strings.Repeat("5", 64)}) |
| 208 | g.Declarations["internal/a/c.go:New"] = strings.Repeat("5", 64) |
| 209 | }, |
| 210 | claims: map[string]string{"internal/a/c.go:New": "opens a file"}, |
| 211 | want: "decision", |
| 212 | }, |
| 213 | "a claim with a tab": { |
| 214 | edit: func(g *rootReadScan) { |
| 215 | change(g, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| 216 | }, |
| 217 | claims: map[string]string{"internal/a/a.go:Wake": "a\tb"}, |
| 218 | want: "tab", |
| 219 | }, |
| 220 | "nothing to propose": { |
| 221 | edit: func(*rootReadScan) {}, |
| 222 | claims: map[string]string{}, |
| 223 | want: "nothing", |
| 224 | }, |
| 225 | } { |
| 226 | t.Run(name, func(t *testing.T) { |
| 227 | got, inv, seal := proposeFixture(t) |
| 228 | tc.edit(&got) |
| 229 | _, err := proposeRootReads(got, inv, seal, "x", tc.claims) |
| 230 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 231 | t.Fatalf("err = %v, want one mentioning %q", err, tc.want) |
| 232 | } |
| 233 | }) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestCensusClaimsFile(t *testing.T) { |
| 238 | path := filepath.Join(t.TempDir(), "claims") |
| 239 | if err := os.WriteFile(path, []byte("# what changed\n\ninternal/a/a.go:Wake\tlogs the timing; no read added\n"), 0o644); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | claims, err := readCensusClaims(path) |
| 243 | if err != nil || claims["internal/a/a.go:Wake"] != "logs the timing; no read added" || len(claims) != 1 { |
| 244 | t.Fatalf("claims = %v, %v", claims, err) |
| 245 | } |
| 246 | if err := os.WriteFile(path, []byte("internal/a/a.go:Wake no tab\n"), 0o644); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | if _, err := readCensusClaims(path); err == nil { |
| 250 | t.Fatal("a line without a tab was accepted") |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // writeFixtureRoot is a tree with an empty chain manifest and the notes dir. |
| 255 | func writeFixtureRoot(t *testing.T) string { |
| 256 | t.Helper() |
| 257 | root := t.TempDir() |
| 258 | for _, d := range []string{"internal/provision/testdata", "docs/superpowers/notes"} { |
| 259 | if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | } |
| 263 | if err := os.WriteFile(filepath.Join(root, rootReadChainManifest), []byte("# manifest\n"), 0o644); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | return root |
| 267 | } |
| 268 | |
| 269 | func readFile(t *testing.T, path string) string { |
| 270 | t.Helper() |
| 271 | raw, err := os.ReadFile(path) |
| 272 | if err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | return string(raw) |
| 276 | } |
| 277 | |
| 278 | func TestCensusProposalWritesAndReplacesItsOwnOverlay(t *testing.T) { |
| 279 | root := writeFixtureRoot(t) |
| 280 | first := rootReadProposal{Overlay: "# Census overlay x, " + censusProposeMark + ".\nrow1\n", Seal: strings.Repeat("a", 64), Card: "card 1"} |
| 281 | if err := writeRootReadProposal(root, "x", first); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | second := rootReadProposal{Overlay: "# Census overlay x, " + censusProposeMark + ".\nrow2\n", Seal: strings.Repeat("b", 64), Card: "card 2"} |
| 285 | if err := writeRootReadProposal(root, "x", second); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | if got, want := readFile(t, filepath.Join(root, rootReadChainManifest)), "# manifest\nplan14-root-reads-x.tsv\t"+strings.Repeat("b", 64)+"\n"; got != want { |
| 289 | t.Errorf("manifest = %q, want %q", got, want) |
| 290 | } |
| 291 | if got := readFile(t, filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")); got != second.Overlay { |
| 292 | t.Errorf("overlay = %q", got) |
| 293 | } |
| 294 | if got := readFile(t, filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")); got != "card 2" { |
| 295 | t.Errorf("card = %q", got) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestCensusProposalRefusesWhatItDidNotWrite(t *testing.T) { |
| 300 | for name, setup := range map[string]func(t *testing.T, root string){ |
| 301 | "a hand-written overlay of that name": func(t *testing.T, root string) { |
| 302 | if err := os.WriteFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv"), []byte("# Census overlay x (by hand)\n"), 0o644); err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | }, |
| 306 | "its overlay followed by another": func(t *testing.T, root string) { |
| 307 | mine := rootReadProposal{Overlay: "# " + censusProposeMark + "\n", Seal: strings.Repeat("a", 64)} |
| 308 | if err := writeRootReadProposal(root, "x", mine); err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | if err := writeRootReadProposal(root, "y", mine); err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | }, |
| 315 | } { |
| 316 | t.Run(name, func(t *testing.T) { |
| 317 | root := writeFixtureRoot(t) |
| 318 | setup(t, root) |
| 319 | manifest := readFile(t, filepath.Join(root, rootReadChainManifest)) |
| 320 | overlay, _ := os.ReadFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")) |
| 321 | card, _ := os.ReadFile(filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")) |
| 322 | p := rootReadProposal{Overlay: "# " + censusProposeMark + "\nnew\n", Seal: strings.Repeat("c", 64), Card: "card"} |
| 323 | if err := writeRootReadProposal(root, "x", p); err == nil { |
| 324 | t.Fatal("replaced an overlay it should not have") |
| 325 | } |
| 326 | if got := readFile(t, filepath.Join(root, rootReadChainManifest)); got != manifest { |
| 327 | t.Errorf("manifest changed: %q", got) |
| 328 | } |
| 329 | if got, _ := os.ReadFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")); string(got) != string(overlay) { |
| 330 | t.Errorf("overlay changed: %q", got) |
| 331 | } |
| 332 | if got, _ := os.ReadFile(filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")); string(got) != string(card) { |
| 333 | t.Errorf("card changed: %q", got) |
| 334 | } |
| 335 | }) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | // Keys may contain spaces, and a directory is a prefix of its units' keys. |
| 340 | func TestCensusNamesWholeKeys(t *testing.T) { |
| 341 | for _, tc := range []struct { |
| 342 | msg, key string |
| 343 | want bool |
| 344 | }{ |
| 345 | {"changed root read internal/h/k.go:type gen; review class", "internal/h/k.go:type gen", true}, |
| 346 | {"changed legacy declaration internal/h/k.go:type gen", "internal/h/k.go:type gen", true}, |
| 347 | {"changed root read internal/h/k.go:type general; review", "internal/h/k.go:type gen", false}, |
| 348 | {"changed root read internal/a/x.go:Foo; review", "internal/a", false}, |
| 349 | {"changed root source envelope internal/a", "internal/a", true}, |
| 350 | {"stale root read internal/a/x.go:Foo", "internal/a/x.go:Foo", true}, |
| 351 | } { |
| 352 | if got := censusNames(tc.msg, tc.key); got != tc.want { |
| 353 | t.Errorf("censusNames(%q, %q) = %v, want %v", tc.msg, tc.key, got, tc.want) |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | // A body change to a unit whose key has a space is still an approval. |
| 359 | func TestCensusClassifiesAKeyWithASpace(t *testing.T) { |
| 360 | got, inv, seal := proposeFixture(t) |
| 361 | key := "internal/a/t.go:type gen" |
| 362 | e := classifiedRootRead{rootReadEntry: rootReadEntry{File: "internal/a/t.go", Unit: "type gen", Kinds: "registry:Host@3", Digest: strings.Repeat("7", 64)}, Class: "legacy", Task: "accepted-legacy", Basis: "accepted-legacy;rev3=J1;basis=root-route"} |
| 363 | inv.Entries[key], inv.Frozen[key] = e, e |
| 364 | inv.Legacy[rootReadLegacyFingerprint(e)] = true |
| 365 | seal = rootReadLegacySeal(inv) |
| 366 | got.Entries = append(got.Entries, e.rootReadEntry) |
| 367 | got.Declarations[key] = e.Digest |
| 368 | change(&got, key, "registry:Host@4", strings.Repeat("8", 64)) |
| 369 | cs := classifyRootReads(got, inv, seal) |
| 370 | if len(cs) != 1 || cs[0].Class != censusBody || cs[0].Key != key { |
| 371 | t.Fatalf("changes = %+v, want one body for %q", cs, key) |
| 372 | } |
| 373 | } |
| 374 | |