| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/awsinv" |
| 16 | "github.com/carlosframework/platform/internal/hostfence" |
| 17 | "github.com/carlosframework/platform/internal/hostresolve" |
| 18 | ) |
| 19 | |
| 20 | func TestHostClaimFlagUsesTheFleetConvention(t *testing.T) { |
| 21 | for _, raw := range []string{"1", "0", "on", " true "} { |
| 22 | if !hostClaimEnabled(raw) { |
| 23 | t.Errorf("hostClaimEnabled(%q) = false", raw) |
| 24 | } |
| 25 | } |
| 26 | for _, raw := range []string{"", " ", "\t\n"} { |
| 27 | if hostClaimEnabled(raw) { |
| 28 | t.Errorf("hostClaimEnabled(%q) = true", raw) |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestParseHostResolveAcceptsDocumentedInterspersedShape(t *testing.T) { |
| 34 | o, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--yes", "--console", "https://console.example"}) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if o.Host != "app.example.com" || o.Action != "declare-lost" || o.Box != "box-1" || !o.Yes || o.Console != "https://console.example" { |
| 39 | t.Fatalf("options = %+v", o) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestParseHostResolveRefusesAmbiguousAction(t *testing.T) { |
| 44 | if _, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--readmit", "box-1"}); err == nil { |
| 45 | t.Fatal("accepted both actions") |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // Task 1b (preflight L1): the declare-lost half both doors share. It inspects, |
| 50 | // asks the door's own confirmation, reads the box through the operator's EC2 |
| 51 | // reader in the challenge's region, and declares the inspected challenge |
| 52 | // unchanged, with the challenge ID as the confirmation and the standing |
| 53 | // attestation. Each stage that stops it is named, so each door can say why. |
| 54 | func TestDeclareHostLossStages(t *testing.T) { |
| 55 | inspection := func() hostresolve.Inspection { |
| 56 | c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{ |
| 57 | ID: "loss-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1", |
| 58 | ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"}, |
| 59 | Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e", |
| 60 | Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"}, |
| 61 | Consequences: hostfence.Consequences{Transition: "move-target-lost", DestroyedStorageAssertion: nil}, |
| 62 | }) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | return hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":true}`), Details: c} |
| 67 | } |
| 68 | ec2For := func(state string) func(context.Context, string) (awsinv.ExactEC2API, error) { |
| 69 | calls := 0 |
| 70 | return func(_ context.Context, region string) (awsinv.ExactEC2API, error) { |
| 71 | if region != "eu-west-1" { |
| 72 | t.Errorf("reader asked for %q", region) |
| 73 | } |
| 74 | return fakeEC2{account: "111122223333", instance: "i-1", state: state, calls: &calls}, nil |
| 75 | } |
| 76 | } |
| 77 | var declared *hostfence.Submission |
| 78 | door := func(confirm bool, state string, readerErr error) hostLossDoor { |
| 79 | declared = nil |
| 80 | d := hostLossDoor{ |
| 81 | inspect: func(context.Context) (hostresolve.Inspection, error) { return inspection(), nil }, |
| 82 | confirm: func(hostresolve.Inspection) bool { return confirm }, |
| 83 | reader: ec2For(state), |
| 84 | declare: func(_ context.Context, s hostfence.Submission) error { declared = &s; return nil }, |
| 85 | now: time.Now, |
| 86 | } |
| 87 | if readerErr != nil { |
| 88 | d.reader = func(context.Context, string) (awsinv.ExactEC2API, error) { return nil, readerErr } |
| 89 | } |
| 90 | return d |
| 91 | } |
| 92 | ctx := context.Background() |
| 93 | if stage, err := declareHostLoss(ctx, door(true, "stopped", nil)); err != nil || stage != 0 || declared == nil || |
| 94 | declared.Confirmation != "loss-1" || string(declared.Challenge) != `{"signed":true}` || !declared.Attestation.MaintainComputeFence || |
| 95 | !declared.Attestation.ColdQuarantineStartOnly || declared.RequestID != "aws-request-1b" { |
| 96 | t.Fatalf("declared = %+v, stage %d, %v", declared, stage, err) |
| 97 | } |
| 98 | if stage, err := declareHostLoss(ctx, door(false, "stopped", nil)); stage != hostLossConfirm || !errors.Is(err, errHostLossNotConfirmed) || declared != nil { |
| 99 | t.Fatalf("unconfirmed: stage %d, %v", stage, err) |
| 100 | } |
| 101 | if stage, err := declareHostLoss(ctx, door(true, "stopped", errors.New("no chain"))); stage != hostLossReader || err == nil || declared != nil { |
| 102 | t.Fatalf("no reader: stage %d, %v", stage, err) |
| 103 | } |
| 104 | if stage, err := declareHostLoss(ctx, door(true, "running", nil)); stage != hostLossObserve || !errors.Is(err, hostfence.ErrObservation) || declared != nil { |
| 105 | t.Fatalf("running: stage %d, %v", stage, err) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // Task 1b fix round 1, finding 3: the ops door's adapter around the shared |
| 110 | // declare half, against an httptest console. Its URLs, bodies, prompt and the |
| 111 | // final answer are what carlos ops host resolve has always sent and printed, |
| 112 | // byte for byte. |
| 113 | func TestOpsHostResolveDeclareLostAdapter(t *testing.T) { |
| 114 | in := hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":"by the test"}`)} |
| 115 | c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{ |
| 116 | ID: "loss-ops-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1", |
| 117 | ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"}, |
| 118 | Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e", |
| 119 | Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"}, |
| 120 | Consequences: hostfence.Consequences{Transition: "move-target-lost"}, |
| 121 | }) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | in.Details = c |
| 126 | inspectBody, _ := json.Marshal(in) |
| 127 | finalBody := []byte(`{"result":"declared","host":"app.example.com"}`) |
| 128 | var calls []string |
| 129 | var declared map[string]json.RawMessage |
| 130 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 131 | body, _ := io.ReadAll(r.Body) |
| 132 | calls = append(calls, r.Method+" "+r.URL.Path+" "+string(body)) |
| 133 | switch r.URL.Path { |
| 134 | case "/api/cli/ops/hosts/app.example.com/resolve/inspect": |
| 135 | _, _ = w.Write(inspectBody) |
| 136 | case "/api/cli/ops/hosts/app.example.com/resolve": |
| 137 | _ = json.Unmarshal(body, &declared) |
| 138 | _, _ = w.Write(finalBody) |
| 139 | default: |
| 140 | http.NotFound(w, r) |
| 141 | } |
| 142 | })) |
| 143 | defer ts.Close() |
| 144 | var out bytes.Buffer |
| 145 | ec2Calls := 0 |
| 146 | saved := hostResolveIO |
| 147 | hostResolveIO = hostResolveSeams{ |
| 148 | stdin: strings.NewReader("loss-ops-1\n"), |
| 149 | stdout: &out, |
| 150 | reader: func(_ context.Context, region string) (awsinv.ExactEC2API, error) { |
| 151 | if region != "eu-west-1" { |
| 152 | t.Errorf("reader asked for %q", region) |
| 153 | } |
| 154 | return fakeEC2{account: "111122223333", instance: "i-1", state: "stopped", calls: &ec2Calls}, nil |
| 155 | }, |
| 156 | } |
| 157 | defer func() { hostResolveIO = saved }() |
| 158 | sess := &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"} |
| 159 | if err := sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"}); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | want := string(inspectBody) + "\n" + |
| 163 | "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:\n" + |
| 164 | string(finalBody) + "\n" |
| 165 | if out.String() != want { |
| 166 | t.Fatalf("output:\n%q\nwant\n%q", out.String(), want) |
| 167 | } |
| 168 | if len(calls) != 2 || calls[0] != `POST /api/cli/ops/hosts/app.example.com/resolve/inspect {"action":"declare-lost","box":"box-1"}` || |
| 169 | !strings.HasPrefix(calls[1], `POST /api/cli/ops/hosts/app.example.com/resolve {"action":"declare-lost","submission":`) { |
| 170 | t.Fatalf("calls %q", calls) |
| 171 | } |
| 172 | var sub hostfence.Submission |
| 173 | if err := json.Unmarshal(declared["submission"], &sub); err != nil || string(sub.Challenge) != `{"signed":"by the test"}` || |
| 174 | sub.Confirmation != "loss-ops-1" || sub.Observation.State != "stopped" || !sub.Attestation.MaintainComputeFence || ec2Calls != 1 { |
| 175 | t.Fatalf("submission %+v (%v), %d EC2 calls", sub, err, ec2Calls) |
| 176 | } |
| 177 | |
| 178 | // A wrong declaration ID refuses with the error it always has, and |
| 179 | // declares nothing. |
| 180 | calls, out = nil, bytes.Buffer{} |
| 181 | hostResolveIO.stdin = strings.NewReader("not-the-id\n") |
| 182 | hostResolveIO.stdout = &out |
| 183 | err = sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"}) |
| 184 | if err == nil || err.Error() != "fencing attestation not confirmed" || len(calls) != 1 { |
| 185 | t.Fatalf("wrong ID: %v, calls %q", err, calls) |
| 186 | } |
| 187 | } |
| 188 | |