fix(studio): register file backups on machine settings routes

Problem: local settings cannot load the file-backup catalog because both
machine settings URL forms return an untyped 404.

Root cause: file-backup handlers are registered only with account backup
routes, while the machine settings router registers a separate route set.

Fix: register the existing file-backup handlers with machine settings.
Normal runtime routes still register that same set once. Preserve host-only
checks, paired-device rejection, local-owner selection and mutation gates;
do not change payloads, encryption or persisted formats.

Verification: new regressions fail before the fix with plain 404 responses
and pass afterward for both machine prefixes. Focused machine settings,
file backup, account backup and mutation-gate tests pass. gofmt, go vet,
make lint, make check and the required builtin/boot tests pass.
This commit is contained in:
SivanCola committed 2026-10-26 01:12:34 +08:00
1 parent 4377886f88
commit 6881c9748d
3 files changed
+101 -1

No files matched your search

-1
View File
@@ -25,7 +25,6 @@ func (s *Server) registerBackupRoutes(mux *http.ServeMux) {
mux.HandleFunc("DELETE /backups/{id}", s.deleteBackup)
mux.HandleFunc("POST /backups/{id}/preview", s.previewBackup)
mux.HandleFunc("POST /backups/apply", s.applyBackup)
s.registerBackupFileRoutes(mux)
}
// backupSession is the gate every backup route shares: the host must allow
@@ -0,0 +1,100 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestMachineSettingsFileBackupRoutes(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
defer h.Shutdown()
rt := hubRuntime(t, h, testenv.TempDir(t))
handler := operatorHandler(h)
for _, base := range []string{"/host/settings", "/host/settings/runtime/" + rt.ID} {
t.Run(base, func(t *testing.T) {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, base+"/backups/file", nil))
var catalog struct {
Categories []json.RawMessage `json:"categories"`
MinPassphrase int `json:"minPassphrase"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &catalog) != nil || len(catalog.Categories) == 0 || catalog.MinPassphrase == 0 {
t.Fatalf("catalog = %d %s", w.Code, w.Body.String())
}
for _, tc := range []struct {
path, body string
status int
}{
{"/backups/file/export", `{`, http.StatusBadRequest},
{"/backups/file/preview", `{"file":"!!","passphrase":"test"}`, http.StatusUnprocessableEntity},
{"/backups/file/apply", `{"planId":"missing","items":["x"]}`, http.StatusGone},
} {
r := httptest.NewRequest(http.MethodPost, base+tc.path, strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatalf("%s = %d %s, want %d", tc.path, w.Code, w.Body.String(), tc.status)
}
unauthorized := httptest.NewRequest(http.MethodPost, "http://localhost"+base+tc.path, strings.NewReader(tc.body))
unauthorized.Header.Set("Content-Type", "application/json")
denied := httptest.NewRecorder()
h.Handler().ServeHTTP(denied, unauthorized)
if denied.Code != http.StatusForbidden {
t.Fatalf("unauthorized %s = %d %s", base+tc.path, denied.Code, denied.Body.String())
}
}
})
}
}
func TestMachineSettingsFileBackupRefusesPairedDevices(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
defer h.Shutdown()
rt := hubRuntime(t, h, testenv.TempDir(t))
for _, base := range []string{"/host/settings", "/host/settings/runtime/" + rt.ID} {
for _, tc := range []struct{ method, path string }{
{http.MethodGet, "/backups/file"},
{http.MethodPost, "/backups/file/export"},
{http.MethodPost, "/backups/file/preview"},
{http.MethodPost, "/backups/file/apply"},
} {
r := httptest.NewRequest(tc.method, base+tc.path, strings.NewReader(`{}`))
r.Header.Set("Content-Type", "application/json")
r = r.WithContext(withDeviceReach(r.Context(), "phone", 1))
w := httptest.NewRecorder()
operatorHandler(h).ServeHTTP(w, r)
var refusal struct{ Code string }
if w.Code != http.StatusNotFound || json.Unmarshal(w.Body.Bytes(), &refusal) != nil || refusal.Code != codeDeviceHostOnly {
t.Fatalf("device %s = %d %s", base+tc.path, w.Code, w.Body.String())
}
}
}
}
func TestMachineSettingsFileBackupRequiresLocalOwner(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
h := NewHub(HubOptions{})
defer h.Shutdown()
remote := remotePane(t, h, fakeRemoteKernel(t).Listener.Addr().String())
request := func(path string) {
t.Helper()
w := httptest.NewRecorder()
operatorHandler(h).ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
var refusal struct{ Code string }
if w.Code != http.StatusServiceUnavailable || json.Unmarshal(w.Body.Bytes(), &refusal) != nil || refusal.Code != "settings.local_runtime_required" {
t.Fatalf("%s = %d %s", path, w.Code, w.Body.String())
}
}
request("/host/settings/backups/file")
hubRuntime(t, h, testenv.TempDir(t))
request("/host/settings/runtime/" + remote.ID + "/backups/file")
request("/host/settings/runtime/missing/backups/file")
}
+1
View File
@@ -14,6 +14,7 @@ func (s *Server) registerMachineSettingsRoutes(mux *http.ServeMux) {
s.registerTurnNarrationRoutes(mux)
s.registerWebAccessRoutes(mux)
s.registerReceiptRoutes(mux)
s.registerBackupFileRoutes(mux)
mux.HandleFunc("GET /models", s.models)
mux.HandleFunc("GET /memory", s.memories)
mux.HandleFunc("POST /memory/forget", s.forgetMemory)