package main import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/carlosframework/platform/internal/awsinv" "github.com/carlosframework/platform/internal/hostfence" "github.com/carlosframework/platform/internal/hostresolve" ) func TestHostClaimFlagUsesTheFleetConvention(t *testing.T) { for _, raw := range []string{"1", "0", "on", " true "} { if !hostClaimEnabled(raw) { t.Errorf("hostClaimEnabled(%q) = false", raw) } } for _, raw := range []string{"", " ", "\t\n"} { if hostClaimEnabled(raw) { t.Errorf("hostClaimEnabled(%q) = true", raw) } } } func TestParseHostResolveAcceptsDocumentedInterspersedShape(t *testing.T) { o, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--yes", "--console", "https://console.example"}) if err != nil { t.Fatal(err) } if o.Host != "app.example.com" || o.Action != "declare-lost" || o.Box != "box-1" || !o.Yes || o.Console != "https://console.example" { t.Fatalf("options = %+v", o) } } func TestParseHostResolveRefusesAmbiguousAction(t *testing.T) { if _, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--readmit", "box-1"}); err == nil { t.Fatal("accepted both actions") } } // Task 1b (preflight L1): the declare-lost half both doors share. It inspects, // asks the door's own confirmation, reads the box through the operator's EC2 // reader in the challenge's region, and declares the inspected challenge // unchanged, with the challenge ID as the confirmation and the standing // attestation. Each stage that stops it is named, so each door can say why. func TestDeclareHostLossStages(t *testing.T) { inspection := func() hostresolve.Inspection { c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{ ID: "loss-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1", ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"}, Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e", Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"}, Consequences: hostfence.Consequences{Transition: "move-target-lost", DestroyedStorageAssertion: nil}, }) if err != nil { t.Fatal(err) } return hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":true}`), Details: c} } ec2For := func(state string) func(context.Context, string) (awsinv.ExactEC2API, error) { calls := 0 return func(_ context.Context, region string) (awsinv.ExactEC2API, error) { if region != "eu-west-1" { t.Errorf("reader asked for %q", region) } return fakeEC2{account: "111122223333", instance: "i-1", state: state, calls: &calls}, nil } } var declared *hostfence.Submission door := func(confirm bool, state string, readerErr error) hostLossDoor { declared = nil d := hostLossDoor{ inspect: func(context.Context) (hostresolve.Inspection, error) { return inspection(), nil }, confirm: func(hostresolve.Inspection) bool { return confirm }, reader: ec2For(state), declare: func(_ context.Context, s hostfence.Submission) error { declared = &s; return nil }, now: time.Now, } if readerErr != nil { d.reader = func(context.Context, string) (awsinv.ExactEC2API, error) { return nil, readerErr } } return d } ctx := context.Background() if stage, err := declareHostLoss(ctx, door(true, "stopped", nil)); err != nil || stage != 0 || declared == nil || declared.Confirmation != "loss-1" || string(declared.Challenge) != `{"signed":true}` || !declared.Attestation.MaintainComputeFence || !declared.Attestation.ColdQuarantineStartOnly || declared.RequestID != "aws-request-1b" { t.Fatalf("declared = %+v, stage %d, %v", declared, stage, err) } if stage, err := declareHostLoss(ctx, door(false, "stopped", nil)); stage != hostLossConfirm || !errors.Is(err, errHostLossNotConfirmed) || declared != nil { t.Fatalf("unconfirmed: stage %d, %v", stage, err) } if stage, err := declareHostLoss(ctx, door(true, "stopped", errors.New("no chain"))); stage != hostLossReader || err == nil || declared != nil { t.Fatalf("no reader: stage %d, %v", stage, err) } if stage, err := declareHostLoss(ctx, door(true, "running", nil)); stage != hostLossObserve || !errors.Is(err, hostfence.ErrObservation) || declared != nil { t.Fatalf("running: stage %d, %v", stage, err) } } // Task 1b fix round 1, finding 3: the ops door's adapter around the shared // declare half, against an httptest console. Its URLs, bodies, prompt and the // final answer are what carlos ops host resolve has always sent and printed, // byte for byte. func TestOpsHostResolveDeclareLostAdapter(t *testing.T) { in := hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":"by the test"}`)} c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{ ID: "loss-ops-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1", ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"}, Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e", Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"}, Consequences: hostfence.Consequences{Transition: "move-target-lost"}, }) if err != nil { t.Fatal(err) } in.Details = c inspectBody, _ := json.Marshal(in) finalBody := []byte(`{"result":"declared","host":"app.example.com"}`) var calls []string var declared map[string]json.RawMessage ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) calls = append(calls, r.Method+" "+r.URL.Path+" "+string(body)) switch r.URL.Path { case "/api/cli/ops/hosts/app.example.com/resolve/inspect": _, _ = w.Write(inspectBody) case "/api/cli/ops/hosts/app.example.com/resolve": _ = json.Unmarshal(body, &declared) _, _ = w.Write(finalBody) default: http.NotFound(w, r) } })) defer ts.Close() var out bytes.Buffer ec2Calls := 0 saved := hostResolveIO hostResolveIO = hostResolveSeams{ stdin: strings.NewReader("loss-ops-1\n"), stdout: &out, reader: func(_ context.Context, region string) (awsinv.ExactEC2API, error) { if region != "eu-west-1" { t.Errorf("reader asked for %q", region) } return fakeEC2{account: "111122223333", instance: "i-1", state: "stopped", calls: &ec2Calls}, nil }, } defer func() { hostResolveIO = saved }() sess := &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"} if err := sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"}); err != nil { t.Fatal(err) } want := string(inspectBody) + "\n" + "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" + string(finalBody) + "\n" if out.String() != want { t.Fatalf("output:\n%q\nwant\n%q", out.String(), want) } if len(calls) != 2 || calls[0] != `POST /api/cli/ops/hosts/app.example.com/resolve/inspect {"action":"declare-lost","box":"box-1"}` || !strings.HasPrefix(calls[1], `POST /api/cli/ops/hosts/app.example.com/resolve {"action":"declare-lost","submission":`) { t.Fatalf("calls %q", calls) } var sub hostfence.Submission if err := json.Unmarshal(declared["submission"], &sub); err != nil || string(sub.Challenge) != `{"signed":"by the test"}` || sub.Confirmation != "loss-ops-1" || sub.Observation.State != "stopped" || !sub.Attestation.MaintainComputeFence || ec2Calls != 1 { t.Fatalf("submission %+v (%v), %d EC2 calls", sub, err, ec2Calls) } // A wrong declaration ID refuses with the error it always has, and // declares nothing. calls, out = nil, bytes.Buffer{} hostResolveIO.stdin = strings.NewReader("not-the-id\n") hostResolveIO.stdout = &out err = sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"}) if err == nil || err.Error() != "fencing attestation not confirmed" || len(calls) != 1 { t.Fatalf("wrong ID: %v, calls %q", err, calls) } }