diff --git a/CHANGELOG.md b/CHANGELOG.md index 3809642..a872cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## Changelog +### v0.185.1 — E-2: the offer endpoints were mounted where nothing routed to them (2026-07-29) + +Registered as `/api/backup-target` inside `ServeStorageAPI`'s switch — which `main.go` mounts ONLY at +`/api/storage/`. Live result: `{"ok":false,"error":"endpoint not found"}` while every unit test +passed, because the tests called the handlers directly and never travelled the mount. Caught by the +first live call, which is exactly why the live call is part of the procedure. + +Moved to `/api/storage/backup-target[/assign]`. `TestBackupTargetRoutesLiveUnderTheStorageAPIMount` +now asserts the dispatcher's own source contains both paths, so a handler that nothing routes to +fails the suite — the repo's seam-wiring rule ("a feature is not shipped until its entry point is +reachable"), applied to a route rather than a button. + ### v0.185.0 — E-2 Parts 3+4: the offer, and the honest degraded state (2026-07-29) — MinAgent 0.113.0 The half that makes the rest work. A degraded backup target recorded only in config is the diff --git a/controller/internal/web/backup_target_offer.go b/controller/internal/web/backup_target_offer.go index 6934bda..ee9c239 100644 --- a/controller/internal/web/backup_target_offer.go +++ b/controller/internal/web/backup_target_offer.go @@ -136,7 +136,7 @@ func degradedMessageFor(st BackupTargetState) string { return backupTargetDegradedText } -// handleBackupTargetState serves GET /api/backup-target — the dashboard's source for the degraded +// handleBackupTargetState serves GET /api/storage/backup-target — the dashboard's source for the degraded // banner and the offer. func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) { st := s.resolveBackupTargetState(r.Context()) @@ -157,7 +157,7 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) writeDiskJSON(w, http.StatusOK, true, "", out) } -// handleBackupTargetAssign is the ACCEPTANCE of the offer — POST /api/backup-target/assign. +// handleBackupTargetAssign is the ACCEPTANCE of the offer — POST /api/storage/backup-target/assign. // // NOTHING calls this except an explicit customer action. Registration does not, the drive-gate does // not, and no scheduler does (E-2 §3: a drive never acquires a role by appearing). Declining is diff --git a/controller/internal/web/backup_target_offer_test.go b/controller/internal/web/backup_target_offer_test.go index ac6d6da..0d2c626 100644 --- a/controller/internal/web/backup_target_offer_test.go +++ b/controller/internal/web/backup_target_offer_test.go @@ -1,6 +1,7 @@ package web import ( + "os" "strings" "testing" ) @@ -59,3 +60,39 @@ func TestUnknownStateRendersNothing(t *testing.T) { "of degradation", msg) } } + +// REACHABILITY — the repo's seam-wiring rule, and it already bit once here. +// +// These handlers were first registered as /api/backup-target inside ServeStorageAPI's switch, which +// main.go mounts ONLY at "/api/storage/". The endpoints returned "endpoint not found" on a live box +// while every unit test passed, because the tests called the handlers directly and never travelled +// the mount. A feature is not shipped until its entry point is reachable. +func TestBackupTargetRoutesLiveUnderTheStorageAPIMount(t *testing.T) { + // main.go: mux.Handle("/api/storage/", ... ServeStorageAPI). Anything the switch answers MUST + // therefore begin with that prefix or it is dead on arrival. + for _, p := range []string{ + "/api/storage/backup-target", + "/api/storage/backup-target/assign", + } { + if !strings.HasPrefix(p, "/api/storage/") { + t.Fatalf("%s is outside the /api/storage/ mount and would 404 in production", p) + } + } + src := storageAPISource(t) + for _, want := range []string{`"/api/storage/backup-target"`, `"/api/storage/backup-target/assign"`} { + if !strings.Contains(src, want) { + t.Errorf("ServeStorageAPI does not dispatch %s — the handler exists but nothing routes to it", want) + } + } +} + +// storageAPISource reads the dispatcher's own source, so the assertion is about what the router +// actually contains rather than about a constant the test itself defines. +func storageAPISource(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("storage_handlers.go") + if err != nil { + t.Fatalf("read storage_handlers.go: %v", err) + } + return string(b) +} diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 94d196f..cbefe33 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -356,9 +356,9 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageRegister(w, r) // E-2 Parts 3+4. State drives the degraded banner and the OFFER; assign is the offer's // ACCEPTANCE and the ONLY writer of the role — registration above deliberately does not set it. - case r.URL.Path == "/api/backup-target" && r.Method == http.MethodGet: + case r.URL.Path == "/api/storage/backup-target" && r.Method == http.MethodGet: s.handleBackupTargetState(w, r) - case r.URL.Path == "/api/backup-target/assign" && r.Method == http.MethodPost: + case r.URL.Path == "/api/storage/backup-target/assign" && r.Method == http.MethodPost: s.handleBackupTargetAssign(w, r) case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost: s.handleStorageMigrate(w, r)