v0.185.1 — E-2: the offer endpoints were mounted where nothing routed to them

Registered as /api/backup-target inside ServeStorageAPI, which main.go mounts ONLY
at /api/storage/. Live result: endpoint not found, while every unit test passed --
the tests called the handlers directly and never travelled the mount. Caught by
the first live call, which is why the live call is part of the procedure.

Moved to /api/storage/backup-target[/assign]. A new test asserts the dispatcher
source contains both paths, so a handler nothing routes to fails the suite --
the seam-wiring rule applied to a route rather than a button.
This commit is contained in:
2026-07-29 09:13:13 +02:00
parent 3f7cf2a965
commit cdaeb36972
4 changed files with 53 additions and 4 deletions
@@ -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
@@ -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)
}
+2 -2
View File
@@ -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)