| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "os" |
| 12 | "slices" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | func TestDigestCell(t *testing.T) { |
| 21 | if digestCell(true, false) != textStateYes || digestCell(false, false) != textStateNo || digestCell(false, true) != textStateStraggler { |
| 22 | t.Fatal("digestCell does not map the three states to their approved texts") |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // directDigestStore is a deployment dir with the root key in the env: the |
| 27 | // shape `carlos` takes with no console. CARLOS_DEPLOYMENT_DIR selects |
| 28 | // direct-store mode (resolveMode, apimode.go:104); HOME and |
| 29 | // CARLOS_CREDENTIALS are masked so no real login is ever consulted. |
| 30 | func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) { |
| 31 | t.Helper() |
| 32 | dir := t.TempDir() |
| 33 | seed, pubB64, err := release.GenerateKey() |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | t.Setenv("HOME", t.TempDir()) |
| 38 | t.Setenv("CARLOS_CREDENTIALS", "") |
| 39 | t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| 40 | t.Setenv("CARLOS_RELEASE_KEY", seed) |
| 41 | t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64) |
| 42 | priv, _ := release.ParseSigningKey(seed, "test") |
| 43 | pub, _ := release.ParsePublicKey(pubB64, "test") |
| 44 | s := release.NewDirStore(dir) |
| 45 | m := release.Manifest{Schema: 1, App: "km", Version: "v1", Kind: "binary", |
| 46 | Artifacts: map[string]release.Artifact{"linux-amd64": {}}} |
| 47 | if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("one")}); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | // Resign needs the verified marker (Astra I3 #4); written after the |
| 54 | // promotion, so the pointer still has no digests to resign. |
| 55 | if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | return s, pub |
| 59 | } |
| 60 | |
| 61 | func runResign(t *testing.T, answer string, args ...string) string { |
| 62 | t.Helper() |
| 63 | var out bytes.Buffer |
| 64 | prevOut, prevIn := stdout, resignInput |
| 65 | stdout, resignInput = &out, strings.NewReader(answer) |
| 66 | t.Cleanup(func() { stdout, resignInput = prevOut, prevIn }) |
| 67 | runChannelsResign(args) |
| 68 | return out.String() |
| 69 | } |
| 70 | |
| 71 | // Direct-store resign: the plan is printed, "no" signs nothing, "yes" signs. |
| 72 | func TestResignDirectStoreAsksBeforeSigning(t *testing.T) { |
| 73 | s, pub := directDigestStore(t) |
| 74 | ctx := context.Background() |
| 75 | out := runResign(t, "no\n", "--account-id", "bab", "--app", "km", "stable") |
| 76 | if !strings.Contains(out, "v1") || !strings.Contains(out, textResignAborted) { |
| 77 | t.Fatalf("answering no:\n%s", out) |
| 78 | } |
| 79 | if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest != "" { |
| 80 | t.Fatal("answering no signed the pointer") |
| 81 | } |
| 82 | out = runResign(t, "yes\n", "--account-id", "bab", "km", "stable") // the spec's positional form |
| 83 | if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest == "" { |
| 84 | t.Fatalf("answering yes did not sign:\n%s", out) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // The DIGESTS column through a console: yes, plain no, and the straggler |
| 89 | // text, one row each; an older console's rows (no keys) read as no. |
| 90 | func TestChannelsVerbAPIModeDigestsColumn(t *testing.T) { |
| 91 | useTempCredentials(t) |
| 92 | clearDeploymentEnv(t) |
| 93 | base := "/api/cli/apps/" + apiTestAccount + "/titogo" |
| 94 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 95 | switch r.URL.Path { |
| 96 | case base + "/channels": |
| 97 | json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{ |
| 98 | {"channel": "stable", "version": "1", "promoted_at": "2026-08-04T00:00:00Z", "digests": true}, |
| 99 | {"channel": "beta", "version": "2", "promoted_at": "2026-08-04T00:00:00Z", "straggler": true}, |
| 100 | {"channel": "edge", "version": "3", "promoted_at": "2026-08-04T00:00:00Z"}, |
| 101 | {"channel": "broken", "error": "signature mismatch"}, |
| 102 | }}) |
| 103 | case base + "/tags": |
| 104 | json.NewEncoder(w).Encode(map[string]any{"tags": []map[string]any{}}) |
| 105 | default: |
| 106 | t.Errorf("path = %s", r.URL.Path) |
| 107 | } |
| 108 | })) |
| 109 | defer ts.Close() |
| 110 | loginTo(t, ts.URL, "vicky@keymail.dev") |
| 111 | |
| 112 | buf := captureStdout(t) |
| 113 | runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) |
| 114 | |
| 115 | rows := map[string]string{} |
| 116 | header := false |
| 117 | wantHeader := strings.Fields(strings.ReplaceAll(textChannelsHeader, "\t", " ")) |
| 118 | for _, line := range strings.Split(buf.String(), "\n") { |
| 119 | f := strings.Fields(line) |
| 120 | if slices.Equal(f, wantHeader) { |
| 121 | header = true |
| 122 | continue |
| 123 | } |
| 124 | if len(f) > 0 { |
| 125 | rows[f[0]] = strings.TrimSpace(line) |
| 126 | } |
| 127 | } |
| 128 | if !header { |
| 129 | t.Fatalf("no %q header in:\n%s", textChannelsHeader, buf.String()) |
| 130 | } |
| 131 | for ch, want := range map[string]string{"stable": textStateYes, "beta": textStateStraggler, "edge": textStateNo} { |
| 132 | if !strings.HasSuffix(rows[ch], want) { |
| 133 | t.Fatalf("%s row = %q, want it to end in %q", ch, rows[ch], want) |
| 134 | } |
| 135 | } |
| 136 | if !strings.Contains(rows["broken"], "ERROR: signature mismatch") { |
| 137 | t.Fatalf("broken row = %q", rows["broken"]) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // `carlos channels digests` through a console: the report is read with a GET, |
| 142 | // and on posts the named accounts. |
| 143 | func TestChannelsDigestsAPIMode(t *testing.T) { |
| 144 | useTempCredentials(t) |
| 145 | clearDeploymentEnv(t) |
| 146 | var posted map[string]any |
| 147 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 148 | if r.URL.Path != "/api/cli/system/pointer-digests" { |
| 149 | t.Errorf("path = %s", r.URL.Path) |
| 150 | } |
| 151 | if r.Method == http.MethodPost { |
| 152 | json.NewDecoder(r.Body).Decode(&posted) |
| 153 | } |
| 154 | json.NewEncoder(w).Encode(map[string]any{ |
| 155 | "accounts": []map[string]any{ |
| 156 | {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"}, |
| 157 | {"account": "bah", "on": false}, |
| 158 | }, |
| 159 | "channels": []map[string]any{ |
| 160 | {"account": "bab", "app": "km", "channel": "stable", "version": "v1", "straggler": true}, |
| 161 | {"account": "bab", "app": "km", "channel": "beta", "version": "v2"}, |
| 162 | }, |
| 163 | }) |
| 164 | })) |
| 165 | defer ts.Close() |
| 166 | loginTo(t, ts.URL, "vicky@keymail.dev") |
| 167 | |
| 168 | buf := captureStdout(t) |
| 169 | runChannelsDigests(nil) |
| 170 | out := buf.String() |
| 171 | for _, want := range []string{ |
| 172 | fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z"), |
| 173 | fmt.Sprintf(textStatusOff, "bah"), |
| 174 | strings.Split(textStatusHeader, "\t")[0], textStateStraggler, "beta", |
| 175 | } { |
| 176 | if !strings.Contains(out, want) { |
| 177 | t.Fatalf("listing lacks %q:\n%s", want, out) |
| 178 | } |
| 179 | } |
| 180 | if posted != nil { |
| 181 | t.Fatalf("a bare listing posted %v", posted) |
| 182 | } |
| 183 | |
| 184 | buf.Reset() |
| 185 | runChannelsDigests([]string{"on", "--account-id", "bab"}) |
| 186 | if posted["on"] != true || len(posted["accounts"].([]any)) != 1 { |
| 187 | t.Fatalf("posted = %v", posted) |
| 188 | } |
| 189 | if want := fmt.Sprintf(textTurnedOn, "bab"); !strings.Contains(buf.String(), want) { |
| 190 | t.Fatalf("on output lacks %q:\n%s", want, buf.String()) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // Resign through a console: the plan is posted with only the channel, then |
| 195 | // "yes" posts the same digests back to be signed; "no" posts nothing more. |
| 196 | func TestResignAPIMode(t *testing.T) { |
| 197 | useTempCredentials(t) |
| 198 | clearDeploymentEnv(t) |
| 199 | path := "/api/cli/apps/" + apiTestAccount + "/titogo/channels/resign" |
| 200 | var bodies []map[string]any |
| 201 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 202 | if r.URL.Path != path || r.Method != http.MethodPost { |
| 203 | t.Errorf("request = %s %s", r.Method, r.URL.Path) |
| 204 | } |
| 205 | var b map[string]any |
| 206 | json.NewDecoder(r.Body).Decode(&b) |
| 207 | bodies = append(bodies, b) |
| 208 | if _, signing := b["digest"]; signing { |
| 209 | json.NewEncoder(w).Encode(map[string]string{"result": "resigned"}) |
| 210 | return |
| 211 | } |
| 212 | json.NewEncoder(w).Encode(map[string]any{ |
| 213 | "channel": "stable", "version": "v2", "digest": strings.Repeat("a", 64), |
| 214 | "history": []string{"v1"}, "history_digests": []string{""}, "computed": []bool{true, false}, |
| 215 | }) |
| 216 | })) |
| 217 | defer ts.Close() |
| 218 | loginTo(t, ts.URL, "vicky@keymail.dev") |
| 219 | |
| 220 | var out bytes.Buffer |
| 221 | prevIn := resignInput |
| 222 | t.Cleanup(func() { resignInput = prevIn }) |
| 223 | captured := captureStdout(t) |
| 224 | |
| 225 | resignInput = strings.NewReader("no\n") |
| 226 | runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) |
| 227 | if len(bodies) != 1 || !strings.Contains(captured.String(), textResignAborted) { |
| 228 | t.Fatalf("answering no: %d requests\n%s", len(bodies), captured.String()) |
| 229 | } |
| 230 | |
| 231 | captured.Reset() |
| 232 | bodies = nil |
| 233 | resignInput = strings.NewReader("yes\n") |
| 234 | runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) |
| 235 | out.WriteString(captured.String()) |
| 236 | if len(bodies) != 2 { |
| 237 | t.Fatalf("answering yes: %d requests, want a plan and a signing\n%s", len(bodies), out.String()) |
| 238 | } |
| 239 | if bodies[1]["digest"] != strings.Repeat("a", 64) || bodies[1]["channel"] != "stable" { |
| 240 | t.Fatalf("signing body = %v", bodies[1]) |
| 241 | } |
| 242 | for _, want := range []string{textResignComputed, textResignMissing, fmt.Sprintf(textResignDone, "titogo", "stable")} { |
| 243 | if !strings.Contains(out.String(), want) { |
| 244 | t.Fatalf("output lacks %q:\n%s", want, out.String()) |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // An account whose report the console could not read is "unknown", never |
| 250 | // "off", and never earns the none-left line. |
| 251 | func TestChannelsDigestsAPIModeFailedAccount(t *testing.T) { |
| 252 | useTempCredentials(t) |
| 253 | clearDeploymentEnv(t) |
| 254 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 255 | json.NewEncoder(w).Encode(map[string]any{ |
| 256 | "accounts": []map[string]any{ |
| 257 | {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"}, |
| 258 | {"account": "bah", "on": false, "error": "marker did not verify"}, |
| 259 | }, |
| 260 | "channels": []map[string]any{}, |
| 261 | }) |
| 262 | })) |
| 263 | defer ts.Close() |
| 264 | loginTo(t, ts.URL, "vicky@keymail.dev") |
| 265 | |
| 266 | buf := captureStdout(t) |
| 267 | runChannelsDigests(nil) |
| 268 | out := buf.String() |
| 269 | if want := fmt.Sprintf(textStatusError, "bah", "marker did not verify"); !strings.Contains(out, want) { |
| 270 | t.Fatalf("output lacks %q:\n%s", want, out) |
| 271 | } |
| 272 | if strings.Contains(out, fmt.Sprintf(textStatusOff, "bah")) || strings.Contains(out, textStatusNoneLeft) { |
| 273 | t.Fatalf("a failed account read as off, or the none-left line printed:\n%s", out) |
| 274 | } |
| 275 | if !strings.Contains(out, fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z")) { |
| 276 | t.Fatalf("the healthy account is missing:\n%s", out) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func TestCheckNamedAccounts(t *testing.T) { |
| 281 | rep := apiPointerDigests{Accounts: []apiDigestAccount{{Account: "bab"}, {Account: "bah"}}} |
| 282 | if err := checkNamedAccounts(rep, []string{"bab", "bah"}); err != nil { |
| 283 | t.Fatalf("known accounts: %v", err) |
| 284 | } |
| 285 | if err := checkNamedAccounts(rep, nil); err != nil { |
| 286 | t.Fatalf("no names: %v", err) |
| 287 | } |
| 288 | err := checkNamedAccounts(rep, []string{"bab", "zzz"}) |
| 289 | if err == nil || err.Error() != fmt.Sprintf(textUnknownAccount, "zzz") { |
| 290 | t.Fatalf("unknown account: %v", err) |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | func runDigests(t *testing.T, args ...string) string { |
| 295 | t.Helper() |
| 296 | buf := captureStdout(t) |
| 297 | runChannelsDigests(args) |
| 298 | return buf.String() |
| 299 | } |
| 300 | |
| 301 | // Direct-store `channels digests`: bare lists, off removes the marker, on |
| 302 | // writes it again (an account with no apps still reads on), and an account |
| 303 | // whose marker cannot be verified is reported unknown. |
| 304 | func TestChannelsDigestsDirectStore(t *testing.T) { |
| 305 | s, pub := directDigestStore(t) // account bab: marker on, km/stable at v1 with no digest |
| 306 | ctx := context.Background() |
| 307 | m, err := release.ReadDigestMarker(ctx, s, pub, "bab") |
| 308 | if err != nil || m == nil { |
| 309 | t.Fatalf("marker: %v %v", m, err) |
| 310 | } |
| 311 | since := m.At.UTC().Format(time.RFC3339) |
| 312 | |
| 313 | out := runDigests(t, "--account-id", "bab") |
| 314 | for _, want := range []string{fmt.Sprintf(textStatusOn, "bab", since), "km", "stable", "v1", textStateNo} { |
| 315 | if !strings.Contains(out, want) { |
| 316 | t.Fatalf("listing lacks %q:\n%s", want, out) |
| 317 | } |
| 318 | } |
| 319 | if strings.Contains(out, textStatusNoneLeft) { |
| 320 | t.Fatalf("none-left printed with a channel still without digests:\n%s", out) |
| 321 | } |
| 322 | |
| 323 | out = runDigests(t, "off", "--account-id", "bab") |
| 324 | if !strings.Contains(out, fmt.Sprintf(textTurnedOff, "bab")) || !strings.Contains(out, fmt.Sprintf(textStatusOff, "bab")) { |
| 325 | t.Fatalf("off:\n%s", out) |
| 326 | } |
| 327 | if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m != nil { |
| 328 | t.Fatal("off left the marker in place") |
| 329 | } |
| 330 | |
| 331 | out = runDigests(t, "on", "--account-id", "bab", "--account-id", "bah") |
| 332 | if !strings.Contains(out, fmt.Sprintf(textTurnedOn, "bab, bah")) { |
| 333 | t.Fatalf("on:\n%s", out) |
| 334 | } |
| 335 | if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m == nil { |
| 336 | t.Fatal("on did not write the marker") |
| 337 | } |
| 338 | // bah has no apps at all: on, straight after turning it on. |
| 339 | bah, err := release.ReadDigestMarker(ctx, s, pub, "bah") |
| 340 | if err != nil || bah == nil { |
| 341 | t.Fatalf("on did not write bah's marker: %v %v", bah, err) |
| 342 | } |
| 343 | if want := fmt.Sprintf(textStatusOn, "bah", bah.At.UTC().Format(time.RFC3339)); !strings.Contains(out, want) { |
| 344 | t.Fatalf("an account with no apps does not read on (want %q):\n%s", want, out) |
| 345 | } |
| 346 | |
| 347 | // An unverifiable marker is unknown, and the none-left line stays away. |
| 348 | if _, err := s.Put(ctx, release.PointerDigestsKey("bai"), []byte("junk"), ""); err != nil { |
| 349 | t.Fatal(err) |
| 350 | } |
| 351 | _, merr := release.ReadDigestMarker(ctx, s, pub, "bai") |
| 352 | if merr == nil { |
| 353 | t.Fatal("control: the junk marker verified") |
| 354 | } |
| 355 | out = runDigests(t, "--account-id", "bai") |
| 356 | if want := fmt.Sprintf(textStatusError, "bai", merr); !strings.Contains(out, want) { |
| 357 | t.Fatalf("unverifiable marker (want %q):\n%s", want, out) |
| 358 | } |
| 359 | if strings.Contains(out, textStatusNoneLeft) || strings.Contains(out, fmt.Sprintf(textStatusOff, "bai")) { |
| 360 | t.Fatalf("unverifiable marker read as off or done:\n%s", out) |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // Astra r1 #4, direct-store: every readable channel carries digests, but one |
| 365 | // pointer cannot be verified. The account is reported unknown with that |
| 366 | // channel's error, and the none-left line never prints. |
| 367 | func TestChannelsDigestsDirectStoreUnreadableChannel(t *testing.T) { |
| 368 | s, pub := directDigestStore(t) // bab: marker on, km/stable at v1 with no digest |
| 369 | ctx := context.Background() |
| 370 | priv, err := release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "test") |
| 371 | if err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | if err := release.Promote(ctx, s, priv, "bab", "km", "beta", "v1"); err != nil { // under the marker: digests |
| 375 | t.Fatal(err) |
| 376 | } |
| 377 | key := release.ChannelKey("bab", "km", "stable") |
| 378 | _, etag, err := s.Get(ctx, key) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | if _, err := s.Put(ctx, key, []byte("junk"), etag); err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | rows, _, err := release.DigestReport(ctx, s, pub, "bab", "km") |
| 386 | if err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | cause := "" |
| 390 | for _, r := range rows { |
| 391 | switch { |
| 392 | case r.Channel == "stable": |
| 393 | cause = r.Err |
| 394 | case !r.HeadDigest: |
| 395 | t.Fatalf("control: %s carries no digest, so none-left would be withheld anyway", r.Channel) |
| 396 | } |
| 397 | } |
| 398 | if cause == "" { |
| 399 | t.Fatal("control: the junk pointer read as a channel state") |
| 400 | } |
| 401 | out := runDigests(t, "--account-id", "bab") |
| 402 | if want := fmt.Sprintf(textStatusError, "bab", cause); !strings.Contains(out, want) { |
| 403 | t.Fatalf("output lacks %q:\n%s", want, out) |
| 404 | } |
| 405 | if strings.Contains(out, textStatusNoneLeft) { |
| 406 | t.Fatalf("an incomplete report printed the none-left line:\n%s", out) |
| 407 | } |
| 408 | } |
| 409 | |