| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostfloor" |
| 16 | "github.com/carlosframework/platform/internal/hostwrite" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | "github.com/carlosframework/platform/internal/svcrole" |
| 19 | ) |
| 20 | |
| 21 | func resetRequest(t *testing.T, s *Server, url, token, body string) (int, string) { |
| 22 | t.Helper() |
| 23 | req := httptest.NewRequest(http.MethodPost, url, bytes.NewBufferString(body)) |
| 24 | if token != "" { |
| 25 | req.Header.Set("Authorization", "Bearer "+token) |
| 26 | } |
| 27 | req.Header.Set("Content-Type", "application/json") |
| 28 | rec := httptest.NewRecorder() |
| 29 | s.mux.ServeHTTP(rec, req) |
| 30 | return rec.Code, rec.Body.String() |
| 31 | } |
| 32 | func TestHostWakeResetMountedOperatorGates(t *testing.T) { |
| 33 | st := openGatedStore(t) |
| 34 | now := time.Now() |
| 35 | user, device, err := st.NewCLIAuth("reset", "127.0.0.1", now) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if err = st.ApproveCLIAuth(user, "paul@keymail.dev", now); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | _, token, _, err := st.PollCLIAuth(device, now) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | _, key, err := ed25519.GenerateKey(nil) |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | store := release.NewDirStore(t.TempDir()) |
| 51 | if err := hostfloor.Publish(t.Context(), store, release.RootSigner(key), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: true, Key: key, Bucket: "bucket", Releases: store}, st) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | url := "/api/cli/ops/hosts/shop.example.test/resolve" |
| 59 | calls := 0 |
| 60 | s.cfg.WakeRetryReset = &WakeRetryResetDoor{Claims: release.NewDirStore(t.TempDir()), Authority: func(context.Context) error { calls++; return hostwrite.ErrUnavailable }, StopAttempt: func(context.Context, hostclaim.Observed, hostclaim.WakeRetryAttempt) error { return nil }, NewID: func() string { return "new" }} |
| 61 | if status, _ := resetRequest(t, s, url, "", "{}"); status != 401 { |
| 62 | t.Fatal(status) |
| 63 | } |
| 64 | ghostStatus, ghostBody := resetRequest(t, s, "/not-found", token, "{}") |
| 65 | status, body := resetRequest(t, s, url, token, "{}") |
| 66 | if status != ghostStatus || body != ghostBody || calls != 0 { |
| 67 | t.Fatalf("nonoperator %d %q calls=%d", status, body, calls) |
| 68 | } |
| 69 | if err := st.SetOperator("paul@keymail.dev", true); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | service := serviceToken(t, st, OpsAccountID, "reset-service", "", svcrole.Publish) |
| 73 | if status, _ := resetRequest(t, s, url, service, "{}"); status == 200 || calls != 0 { |
| 74 | t.Fatalf("service status=%d calls=%d", status, calls) |
| 75 | } |
| 76 | if status, _ := resetRequest(t, s, url, token, `{"action":"reset-wake-retry","incarnation":"i","episode":"e","request_id":"r"}`); status != 409 || calls != 1 { |
| 77 | t.Fatalf("operator status=%d calls=%d", status, calls) |
| 78 | } |
| 79 | for _, bad := range []string{ |
| 80 | `{"action":"reset-wake-retry","unknown":true}`, |
| 81 | `{"action":"reset-wake-retry"} {}`, |
| 82 | `{"action":"reset-wake-retry","request_id":"` + strings.Repeat("x", 16<<10) + `"}`, |
| 83 | } { |
| 84 | if status, _ := resetRequest(t, s, url, token, bad); status != 400 || calls != 1 { |
| 85 | t.Fatalf("malformed reset status=%d calls=%d", status, calls) |
| 86 | } |
| 87 | } |
| 88 | // Other resolution actions still use main's dispatcher and authority. |
| 89 | for _, action := range []string{"declare-lost", "readmit"} { |
| 90 | if status, _ := resetRequest(t, s, url, token, `{"action":"`+action+`"}`); status != 409 || calls != 1 { |
| 91 | t.Fatalf("%s reached wake reset: status=%d calls=%d", action, status, calls) |
| 92 | } |
| 93 | } |
| 94 | savedStore := s.cfg.Releases |
| 95 | s.cfg.Releases = release.NewDirStore(t.TempDir()) |
| 96 | if status, _ := resetRequest(t, s, url, token, `{"action":"reset-wake-retry"}`); status != 503 || calls != 1 { |
| 97 | t.Fatalf("missing floor reached reset: status=%d calls=%d", status, calls) |
| 98 | } |
| 99 | s.cfg.Releases = savedStore |
| 100 | s.cfg.HostClaim = false |
| 101 | if status, _ := resetRequest(t, s, url, token, "{}"); status != 404 || calls != 1 { |
| 102 | t.Fatalf("disabled status=%d calls=%d", status, calls) |
| 103 | } |
| 104 | } |
| 105 | func TestHostWakeResetExactAttemptAndAudit(t *testing.T) { |
| 106 | ctx := context.Background() |
| 107 | st := release.NewDirStore(t.TempDir()) |
| 108 | now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| 109 | host := "shop.bab.oncarlos.com" |
| 110 | in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now} |
| 111 | absent, _ := hostclaim.Read(ctx, st, host) |
| 112 | o, err := hostclaim.Update(ctx, st, absent, hostclaim.BeginCreate(in, []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`), "create", "console", now)) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | o, err = hostclaim.Update(ctx, st, o, hostclaim.CompleteCreate("create", "box")) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | attempt := hostclaim.WakeRetryAttempt{ID: "a", Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller", InvocationID: "inv", LaunchInputs: "inputs"} |
| 121 | o, err = hostclaim.Update(ctx, st, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "episode", Attempt: attempt, Now: now})) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | request := wakeResetRequest{Action: "reset-wake-retry", Incarnation: o.Claim.Holder.Incarnation, Episode: "episode", RequestID: "request"} |
| 126 | door := WakeRetryResetDoor{Claims: st, Authority: func(context.Context) error { return nil }, StopAttempt: func(context.Context, hostclaim.Observed, hostclaim.WakeRetryAttempt) error { |
| 127 | return hostwrite.ErrUnavailable |
| 128 | }, NewID: func() string { return "replacement" }} |
| 129 | if err = door.reset(ctx, host, "operator@example.test", request); err == nil { |
| 130 | t.Fatal("unsettled attempt reset") |
| 131 | } |
| 132 | fresh, _ := hostclaim.Read(ctx, st, host) |
| 133 | if fresh.ETag != o.ETag { |
| 134 | t.Fatal("failure changed retry") |
| 135 | } |
| 136 | door.StopAttempt = func(_ context.Context, _ hostclaim.Observed, a hostclaim.WakeRetryAttempt) error { |
| 137 | if a != attempt { |
| 138 | t.Fatal("wrong attempt stopped") |
| 139 | } |
| 140 | return nil |
| 141 | } |
| 142 | if err = door.reset(ctx, host, "operator@example.test", request); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | fresh, _ = hostclaim.Read(ctx, st, host) |
| 146 | retry := fresh.Claim.Holder.WakeRetry |
| 147 | if retry.EpisodeID != "replacement" || retry.AttemptsReserved != 0 || retry.ResetRequest != `{"Actor":"operator@example.test","RequestID":"request"}` { |
| 148 | t.Fatalf("reset=%+v", retry) |
| 149 | } |
| 150 | if fresh.Claim.Holder.Incarnation != o.Claim.Holder.Incarnation || fresh.Claim.Holder.Stamp != o.Claim.Holder.Stamp || fresh.Claim.Holder.Session != o.Claim.Holder.Session || !reflect.DeepEqual(fresh.Claim.Op, o.Claim.Op) { |
| 151 | t.Fatal("reset changed execution authority") |
| 152 | } |
| 153 | if err = door.reset(ctx, host, "operator@example.test", request); err == nil { |
| 154 | t.Fatal("stale episode reset successor") |
| 155 | } |
| 156 | } |
| 157 | |