carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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
20func 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
33func 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
43func 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.
54func 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.
113func 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