package main import ( "bytes" "context" "crypto/ed25519" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func TestDigestCell(t *testing.T) { if digestCell(true, false) != textStateYes || digestCell(false, false) != textStateNo || digestCell(false, true) != textStateStraggler { t.Fatal("digestCell does not map the three states to their approved texts") } } // directDigestStore is a deployment dir with the root key in the env: the // shape `carlos` takes with no console. CARLOS_DEPLOYMENT_DIR selects // direct-store mode (resolveMode, apimode.go:104); HOME and // CARLOS_CREDENTIALS are masked so no real login is ever consulted. func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) { t.Helper() dir := t.TempDir() seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } t.Setenv("HOME", t.TempDir()) t.Setenv("CARLOS_CREDENTIALS", "") t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) t.Setenv("CARLOS_RELEASE_KEY", seed) t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64) priv, _ := release.ParseSigningKey(seed, "test") pub, _ := release.ParsePublicKey(pubB64, "test") s := release.NewDirStore(dir) m := release.Manifest{Schema: 1, App: "km", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {}}} if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("one")}); err != nil { t.Fatal(err) } if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil { t.Fatal(err) } // Resign needs the verified marker (Astra I3 #4); written after the // promotion, so the pointer still has no digests to resign. if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil { t.Fatal(err) } return s, pub } func runResign(t *testing.T, answer string, args ...string) string { t.Helper() var out bytes.Buffer prevOut, prevIn := stdout, resignInput stdout, resignInput = &out, strings.NewReader(answer) t.Cleanup(func() { stdout, resignInput = prevOut, prevIn }) runChannelsResign(args) return out.String() } // Direct-store resign: the plan is printed, "no" signs nothing, "yes" signs. func TestResignDirectStoreAsksBeforeSigning(t *testing.T) { s, pub := directDigestStore(t) ctx := context.Background() out := runResign(t, "no\n", "--account-id", "bab", "--app", "km", "stable") if !strings.Contains(out, "v1") || !strings.Contains(out, textResignAborted) { t.Fatalf("answering no:\n%s", out) } if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest != "" { t.Fatal("answering no signed the pointer") } out = runResign(t, "yes\n", "--account-id", "bab", "km", "stable") // the spec's positional form if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest == "" { t.Fatalf("answering yes did not sign:\n%s", out) } } // The DIGESTS column through a console: yes, plain no, and the straggler // text, one row each; an older console's rows (no keys) read as no. func TestChannelsVerbAPIModeDigestsColumn(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) base := "/api/cli/apps/" + apiTestAccount + "/titogo" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case base + "/channels": json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{ {"channel": "stable", "version": "1", "promoted_at": "2026-08-04T00:00:00Z", "digests": true}, {"channel": "beta", "version": "2", "promoted_at": "2026-08-04T00:00:00Z", "straggler": true}, {"channel": "edge", "version": "3", "promoted_at": "2026-08-04T00:00:00Z"}, {"channel": "broken", "error": "signature mismatch"}, }}) case base + "/tags": json.NewEncoder(w).Encode(map[string]any{"tags": []map[string]any{}}) default: t.Errorf("path = %s", r.URL.Path) } })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) rows := map[string]string{} header := false wantHeader := strings.Fields(strings.ReplaceAll(textChannelsHeader, "\t", " ")) for _, line := range strings.Split(buf.String(), "\n") { f := strings.Fields(line) if slices.Equal(f, wantHeader) { header = true continue } if len(f) > 0 { rows[f[0]] = strings.TrimSpace(line) } } if !header { t.Fatalf("no %q header in:\n%s", textChannelsHeader, buf.String()) } for ch, want := range map[string]string{"stable": textStateYes, "beta": textStateStraggler, "edge": textStateNo} { if !strings.HasSuffix(rows[ch], want) { t.Fatalf("%s row = %q, want it to end in %q", ch, rows[ch], want) } } if !strings.Contains(rows["broken"], "ERROR: signature mismatch") { t.Fatalf("broken row = %q", rows["broken"]) } } // `carlos channels digests` through a console: the report is read with a GET, // and on posts the named accounts. func TestChannelsDigestsAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var posted map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/system/pointer-digests" { t.Errorf("path = %s", r.URL.Path) } if r.Method == http.MethodPost { json.NewDecoder(r.Body).Decode(&posted) } json.NewEncoder(w).Encode(map[string]any{ "accounts": []map[string]any{ {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"}, {"account": "bah", "on": false}, }, "channels": []map[string]any{ {"account": "bab", "app": "km", "channel": "stable", "version": "v1", "straggler": true}, {"account": "bab", "app": "km", "channel": "beta", "version": "v2"}, }, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runChannelsDigests(nil) out := buf.String() for _, want := range []string{ fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z"), fmt.Sprintf(textStatusOff, "bah"), strings.Split(textStatusHeader, "\t")[0], textStateStraggler, "beta", } { if !strings.Contains(out, want) { t.Fatalf("listing lacks %q:\n%s", want, out) } } if posted != nil { t.Fatalf("a bare listing posted %v", posted) } buf.Reset() runChannelsDigests([]string{"on", "--account-id", "bab"}) if posted["on"] != true || len(posted["accounts"].([]any)) != 1 { t.Fatalf("posted = %v", posted) } if want := fmt.Sprintf(textTurnedOn, "bab"); !strings.Contains(buf.String(), want) { t.Fatalf("on output lacks %q:\n%s", want, buf.String()) } } // Resign through a console: the plan is posted with only the channel, then // "yes" posts the same digests back to be signed; "no" posts nothing more. func TestResignAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) path := "/api/cli/apps/" + apiTestAccount + "/titogo/channels/resign" var bodies []map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != path || r.Method != http.MethodPost { t.Errorf("request = %s %s", r.Method, r.URL.Path) } var b map[string]any json.NewDecoder(r.Body).Decode(&b) bodies = append(bodies, b) if _, signing := b["digest"]; signing { json.NewEncoder(w).Encode(map[string]string{"result": "resigned"}) return } json.NewEncoder(w).Encode(map[string]any{ "channel": "stable", "version": "v2", "digest": strings.Repeat("a", 64), "history": []string{"v1"}, "history_digests": []string{""}, "computed": []bool{true, false}, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") var out bytes.Buffer prevIn := resignInput t.Cleanup(func() { resignInput = prevIn }) captured := captureStdout(t) resignInput = strings.NewReader("no\n") runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) if len(bodies) != 1 || !strings.Contains(captured.String(), textResignAborted) { t.Fatalf("answering no: %d requests\n%s", len(bodies), captured.String()) } captured.Reset() bodies = nil resignInput = strings.NewReader("yes\n") runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) out.WriteString(captured.String()) if len(bodies) != 2 { t.Fatalf("answering yes: %d requests, want a plan and a signing\n%s", len(bodies), out.String()) } if bodies[1]["digest"] != strings.Repeat("a", 64) || bodies[1]["channel"] != "stable" { t.Fatalf("signing body = %v", bodies[1]) } for _, want := range []string{textResignComputed, textResignMissing, fmt.Sprintf(textResignDone, "titogo", "stable")} { if !strings.Contains(out.String(), want) { t.Fatalf("output lacks %q:\n%s", want, out.String()) } } } // An account whose report the console could not read is "unknown", never // "off", and never earns the none-left line. func TestChannelsDigestsAPIModeFailedAccount(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "accounts": []map[string]any{ {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"}, {"account": "bah", "on": false, "error": "marker did not verify"}, }, "channels": []map[string]any{}, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runChannelsDigests(nil) out := buf.String() if want := fmt.Sprintf(textStatusError, "bah", "marker did not verify"); !strings.Contains(out, want) { t.Fatalf("output lacks %q:\n%s", want, out) } if strings.Contains(out, fmt.Sprintf(textStatusOff, "bah")) || strings.Contains(out, textStatusNoneLeft) { t.Fatalf("a failed account read as off, or the none-left line printed:\n%s", out) } if !strings.Contains(out, fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z")) { t.Fatalf("the healthy account is missing:\n%s", out) } } func TestCheckNamedAccounts(t *testing.T) { rep := apiPointerDigests{Accounts: []apiDigestAccount{{Account: "bab"}, {Account: "bah"}}} if err := checkNamedAccounts(rep, []string{"bab", "bah"}); err != nil { t.Fatalf("known accounts: %v", err) } if err := checkNamedAccounts(rep, nil); err != nil { t.Fatalf("no names: %v", err) } err := checkNamedAccounts(rep, []string{"bab", "zzz"}) if err == nil || err.Error() != fmt.Sprintf(textUnknownAccount, "zzz") { t.Fatalf("unknown account: %v", err) } } func runDigests(t *testing.T, args ...string) string { t.Helper() buf := captureStdout(t) runChannelsDigests(args) return buf.String() } // Direct-store `channels digests`: bare lists, off removes the marker, on // writes it again (an account with no apps still reads on), and an account // whose marker cannot be verified is reported unknown. func TestChannelsDigestsDirectStore(t *testing.T) { s, pub := directDigestStore(t) // account bab: marker on, km/stable at v1 with no digest ctx := context.Background() m, err := release.ReadDigestMarker(ctx, s, pub, "bab") if err != nil || m == nil { t.Fatalf("marker: %v %v", m, err) } since := m.At.UTC().Format(time.RFC3339) out := runDigests(t, "--account-id", "bab") for _, want := range []string{fmt.Sprintf(textStatusOn, "bab", since), "km", "stable", "v1", textStateNo} { if !strings.Contains(out, want) { t.Fatalf("listing lacks %q:\n%s", want, out) } } if strings.Contains(out, textStatusNoneLeft) { t.Fatalf("none-left printed with a channel still without digests:\n%s", out) } out = runDigests(t, "off", "--account-id", "bab") if !strings.Contains(out, fmt.Sprintf(textTurnedOff, "bab")) || !strings.Contains(out, fmt.Sprintf(textStatusOff, "bab")) { t.Fatalf("off:\n%s", out) } if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m != nil { t.Fatal("off left the marker in place") } out = runDigests(t, "on", "--account-id", "bab", "--account-id", "bah") if !strings.Contains(out, fmt.Sprintf(textTurnedOn, "bab, bah")) { t.Fatalf("on:\n%s", out) } if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m == nil { t.Fatal("on did not write the marker") } // bah has no apps at all: on, straight after turning it on. bah, err := release.ReadDigestMarker(ctx, s, pub, "bah") if err != nil || bah == nil { t.Fatalf("on did not write bah's marker: %v %v", bah, err) } if want := fmt.Sprintf(textStatusOn, "bah", bah.At.UTC().Format(time.RFC3339)); !strings.Contains(out, want) { t.Fatalf("an account with no apps does not read on (want %q):\n%s", want, out) } // An unverifiable marker is unknown, and the none-left line stays away. if _, err := s.Put(ctx, release.PointerDigestsKey("bai"), []byte("junk"), ""); err != nil { t.Fatal(err) } _, merr := release.ReadDigestMarker(ctx, s, pub, "bai") if merr == nil { t.Fatal("control: the junk marker verified") } out = runDigests(t, "--account-id", "bai") if want := fmt.Sprintf(textStatusError, "bai", merr); !strings.Contains(out, want) { t.Fatalf("unverifiable marker (want %q):\n%s", want, out) } if strings.Contains(out, textStatusNoneLeft) || strings.Contains(out, fmt.Sprintf(textStatusOff, "bai")) { t.Fatalf("unverifiable marker read as off or done:\n%s", out) } } // Astra r1 #4, direct-store: every readable channel carries digests, but one // pointer cannot be verified. The account is reported unknown with that // channel's error, and the none-left line never prints. func TestChannelsDigestsDirectStoreUnreadableChannel(t *testing.T) { s, pub := directDigestStore(t) // bab: marker on, km/stable at v1 with no digest ctx := context.Background() priv, err := release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "test") if err != nil { t.Fatal(err) } if err := release.Promote(ctx, s, priv, "bab", "km", "beta", "v1"); err != nil { // under the marker: digests t.Fatal(err) } key := release.ChannelKey("bab", "km", "stable") _, etag, err := s.Get(ctx, key) if err != nil { t.Fatal(err) } if _, err := s.Put(ctx, key, []byte("junk"), etag); err != nil { t.Fatal(err) } rows, _, err := release.DigestReport(ctx, s, pub, "bab", "km") if err != nil { t.Fatal(err) } cause := "" for _, r := range rows { switch { case r.Channel == "stable": cause = r.Err case !r.HeadDigest: t.Fatalf("control: %s carries no digest, so none-left would be withheld anyway", r.Channel) } } if cause == "" { t.Fatal("control: the junk pointer read as a channel state") } out := runDigests(t, "--account-id", "bab") if want := fmt.Sprintf(textStatusError, "bab", cause); !strings.Contains(out, want) { t.Fatalf("output lacks %q:\n%s", want, out) } if strings.Contains(out, textStatusNoneLeft) { t.Fatalf("an incomplete report printed the none-left line:\n%s", out) } }