| index 52f958407..bd80efd44 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1685,8 +1685,17 @@ type moveAnswer struct { |
| Target string `json:"target"` |
| } `json:"claim"` |
| TargetReport *moves.TargetReport `json:"target_report"` |
| - SourceReport *moves.SourceReport `json:"source_report"` |
| - Evidence struct { |
| + SourceReport *struct { |
| + Box string `json:"box"` |
| + MoveID string `json:"move_id"` |
| + S1 moves.Check `json:"s1"` |
| + S2 moves.Check `json:"s2"` |
| + Env []struct { |
| + Name string `json:"name"` |
| + Kind string `json:"kind"` |
| + } `json:"env"` |
| + } `json:"source_report"` |
| + Evidence struct { |
| Source string `json:"source"` |
| Target string `json:"target"` |
| } `json:"evidence"` |
| @@ -1854,12 +1863,20 @@ func TestMoveDetailLeaksNothing(t *testing.T) { |
| } |
| src := moves.SourceReport{V: 1, Box: "ie-box", MoveID: mv.ID, At: time.Now().UTC(), Env: moves.ReportEnv(hk, env), |
| S1: moves.Check{OK: true}, S2: moves.Check{OK: true}, Release: "v0.25.0"} |
| - // A report that broke the HMAC rule: a raw value under a name. The |
| - // console passes HMACs only. |
| + // Malformed reports: raw values under a name, one of them a secret that |
| + // is 64 hex characters (the shape of an HMAC), a digest that is a raw |
| + // secret, and a target report whose env input is one. The console can |
| + // tell none of them from an HMAC, so it passes no env value at all. |
| + const hexSecret = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" |
| + const hexDigestSecret = "sha256:60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752" |
| + const hexTargetSecret = "fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13" |
| src.Env.Vars["PLAIN_LEAK"] = "raw-env-value-canary" |
| + src.Env.Vars["HEX_API_SECRET"] = hexSecret |
| + malformed := src |
| + malformed.Env.Digest = hexDigestSecret |
| dst := moves.TargetReport{V: 1, Box: "uk-box", Region: "uk", MoveID: mv.ID, At: time.Now().UTC(), Checks: map[string]moves.Check{ |
| moves.CheckProtocol: {OK: true}, moves.CheckEnv: {OK: false, Reason: "the launch environment differs"}, |
| - }} |
| + }, Inputs: provision.MoveInputs{EnvReport: hexTargetSecret, Release: "v0.25.0"}} |
| putJSON := func(key string, v any) { |
| t.Helper() |
| b, err := json.Marshal(v) |
| @@ -1870,7 +1887,7 @@ func TestMoveDetailLeaksNothing(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - putJSON(moves.SourceReportKey(prefix), src) |
| + putJSON(moves.SourceReportKey(prefix), malformed) |
| putJSON(moves.TargetReportKey(prefix), dst) |
| putJSON(srcKey, map[string]any{"v": 1, "role": "source", "note": "evidence-body-canary-source", "env": env["DATABASE_URL"]}) |
| putJSON(dstKey, map[string]any{"v": 1, "role": "target", "note": "evidence-body-canary-target"}) |
| @@ -1883,21 +1900,54 @@ func TestMoveDetailLeaksNothing(t *testing.T) { |
| if a.Detail == nil || a.Detail.TargetReport == nil || a.Detail.SourceReport == nil { |
| t.Fatalf("detail = %s, want both reports", out) |
| } |
| - for _, leak := range []string{ |
| + leaks := []string{ |
| env["DATABASE_URL"], "hunter2-db-canary", env["STRIPE_SECRET_KEY"], secretsKey, env["CARLOS_APP_TOKEN"], |
| - "raw-env-value-canary", "evidence-body-canary", "log-body-canary", token, |
| - } { |
| + "evidence-body-canary", "log-body-canary", token, hexDigestSecret, hexTargetSecret, |
| + strings.TrimPrefix(hexDigestSecret, "sha256:"), src.Env.Digest, |
| + } |
| + // No value of either report's env section: every one of the source's |
| + // (the HMACs, the raw canary, the hex secret) and the target's input. |
| + for _, v := range malformed.Env.Vars { |
| + leaks = append(leaks, v) |
| + } |
| + for _, leak := range leaks { |
| if strings.Contains(out, leak) { |
| t.Errorf("the detail answer holds %q:\n%s", leak, out) |
| } |
| } |
| - for _, want := range []string{moves.CheckProtocol, moves.CheckEnv, `"s1"`, `"s2"`, srcKey, dstKey, src.Env.Digest, src.Env.Vars["DATABASE_URL"]} { |
| + for _, want := range []string{moves.CheckProtocol, moves.CheckEnv, `"s1"`, `"s2"`, srcKey, dstKey, "v0.25.0"} { |
| if !strings.Contains(out, want) { |
| t.Errorf("the detail answer lacks %q:\n%s", want, out) |
| } |
| } |
| - if _, ok := a.Detail.SourceReport.Env.Vars["PLAIN_LEAK"]; ok { |
| - t.Errorf("a non-HMAC environment value was passed through: %+v", a.Detail.SourceReport.Env.Vars) |
| + // Each env entry is its name and its kind, nothing else. |
| + got := map[string]string{} |
| + for _, e := range a.Detail.SourceReport.Env { |
| + got[e.Name] = e.Kind |
| + } |
| + want := map[string]string{} |
| + for name := range malformed.Env.Vars { |
| + want[name] = moves.Class(name) |
| + } |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("source env = %v, want the names and kinds %v", got, want) |
| + } |
| + var raw struct { |
| + Detail struct { |
| + SourceReport map[string]json.RawMessage `json:"source_report"` |
| + } `json:"detail"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + var entries []map[string]any |
| + if err := json.Unmarshal(raw.Detail.SourceReport["env"], &entries); err != nil { |
| + t.Fatalf("source env is not a list of entries: %v in %s", err, raw.Detail.SourceReport["env"]) |
| + } |
| + for _, e := range entries { |
| + if len(e) != 2 { |
| + t.Errorf("an env entry carries more than its name and kind: %v", e) |
| + } |
| } |
| if a.Detail.Evidence.Source != srcKey || a.Detail.Evidence.Target != dstKey { |
| t.Errorf("evidence = %+v, want the two keys", a.Detail.Evidence) |
| @@ -1931,6 +1981,11 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| c.Op.Intent = map[string]string{"region": region, "source_box": box} |
| } |
| } |
| + // Every phase hostclaim lets run on no box (validateRelocating). |
| + noBoxPhases := []string{"starting", "releasing", "pinning"} |
| + inPhase := func(phase string) func(*hostclaim.Claim) { |
| + return func(c *hostclaim.Claim) { c.Op.Phase, c.Op.Exec = phase, "none" } |
| + } |
| t.Run("before the flip", func(t *testing.T) { |
| fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-wait-src") |
| readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| @@ -1947,9 +2002,12 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating || a.ID != mv.ID { |
| t.Fatalf("flat view = %s", out) |
| } |
| - a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| - if a.State != provision.MoveRelocating || waitingFor(a) != "ie" { |
| - t.Fatalf("detail with no box in ie = %s, want waiting_for_box ie", out) |
| + for _, phase := range noBoxPhases { |
| + rewriteClaim(t, fx, "acme.go.tito.io", inPhase(phase)) |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.State != provision.MoveRelocating || waitingFor(a) != "ie" { |
| + t.Fatalf("detail in %s/none with no box in ie = %s, want waiting_for_box ie", phase, out) |
| + } |
| } |
| // A live enrolled box joins ie: nothing is waiting. |
| armMoveFleet(t, fx, ieBox("ie-box2")) |
| @@ -1981,9 +2039,12 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating { |
| t.Fatalf("flat view of a converted landing = %s, want relocating", out) |
| } |
| - a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| - if a.State != provision.MoveRelocating || waitingFor(a) != "uk" || (a.Detail != nil && a.Detail.Move.State != provision.MoveCommitted) { |
| - t.Fatalf("detail of a converted landing with no box in uk = %s, want waiting_for_box uk", out) |
| + for _, phase := range noBoxPhases { |
| + rewriteClaim(t, fx, "acme.go.tito.io", inPhase(phase)) |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.State != provision.MoveRelocating || waitingFor(a) != "uk" || (a.Detail != nil && a.Detail.Move.State != provision.MoveCommitted) { |
| + t.Fatalf("detail of a converted landing in %s/none with no box in uk = %s, want waiting_for_box uk", phase, out) |
| + } |
| } |
| // A live enrolled box joins uk: still relocating, nothing waiting. |
| armMoveFleet(t, fx, ukBox("uk-box2")) |
| @@ -1991,7 +2052,7 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| t.Fatalf("detail with a live box in uk = %s", out) |
| } |
| - // Past pinning the relocation has its box: nothing waits. |
| + // A phase that runs on a box is not waiting for one. |
| putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}}) |
| rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { |
| c.Op.Phase, c.Op.Exec, c.Op.Intent["target_box"] = "installing", "uk-box2", "uk-box2" |
| @@ -1999,6 +2060,13 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| t.Fatalf("detail of a relocation that is installing = %s", out) |
| } |
| + rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { |
| + c.Op.Phase, c.Op.Exec = "releasing", "uk-box" |
| + delete(c.Op.Intent, "target_box") |
| + }) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| + t.Fatalf("detail of a relocation releasing on its source box = %s", out) |
| + } |
| }) |
| } |
| |