diff --git a/libs/handlers/api_fakes_test.go b/libs/handlers/api_fakes_test.go new file mode 100644 index 0000000..c731ace --- /dev/null +++ b/libs/handlers/api_fakes_test.go @@ -0,0 +1,190 @@ +package handlers + +import ( + "bytes" + "context" + "sync/atomic" + "time" + + "github.com/UnownHash/RotomNG/libs/mitm" + "github.com/UnownHash/RotomNG/libs/protos" + "github.com/UnownHash/RotomNG/libs/stats" + "github.com/UnownHash/RotomNG/libs/ws" +) + +// The APIHandler is generic over its controller and worker types, so the tests +// instantiate it with fakes rather than the real websocket-backed types. That +// keeps the HTTP surface -- which is what these tests are about -- reachable +// without standing up connections, while still running against a real +// ConnectionManager, selector, and jobs manager. + +// fakeWorker satisfies connections.MITMWorker, api.MITMWorker, and the +// selector's worker constraint. +type fakeWorker struct { + id string + deviceID string + origin string + + closed atomic.Bool + closeHandler atomic.Pointer[func()] +} + +func newFakeWorker(id, deviceID, origin string) *fakeWorker { + return &fakeWorker{id: id, deviceID: deviceID, origin: origin} +} + +func (w *fakeWorker) ID() string { return w.id } +func (w *fakeWorker) DeviceID() string { return w.deviceID } +func (w *fakeWorker) Origin() string { return w.origin } +func (w *fakeWorker) IsZero() bool { return w == nil } + +func (w *fakeWorker) Close(ws.StatusCode, string) error { + w.closed.Store(true) + if handler := w.closeHandler.Load(); handler != nil { + (*handler)() + } + return nil +} + +func (w *fakeWorker) SetCloseHandler(fn func()) { w.closeHandler.Store(&fn) } +func (w *fakeWorker) SetPreviousWSConnStats(ws.ConnStats) {} +func (w *fakeWorker) GetModeInfo() mitm.WorkerModeInfo { return mitm.WorkerModeInfo{} } +func (w *fakeWorker) VersionCode() int32 { return 1 } +func (w *fakeWorker) VersionName() string { return "fake" } +func (w *fakeWorker) UserAgent() string { return "fake-worker" } +func (w *fakeWorker) Platform() mitm.WorkerPlatform { return mitm.WorkerPlatform(0) } +func (w *fakeWorker) WriteAsync(context.Context, ws.MessageType, []byte) error { + return nil +} + +func (w *fakeWorker) WebsocketStats() (session, total ws.ConnStats) { + return ws.ConnStats{}, ws.ConnStats{} +} + +func (w *fakeWorker) GetRequestStats() stats.CountDurationWindows[uint64] { + return stats.CountDurationWindows[uint64]{} +} + +func (w *fakeWorker) ProxyController(context.Context, mitm.Controller, bool, *protos.MitmRequest) { +} + +// fakeController satisfies handlers.Controller: api.Controller, +// connections.Controller, and Run. +type fakeController struct { + id string + workerID string + weight int + + uuid atomic.Pointer[string] + closed atomic.Bool + closeCode atomic.Int64 + closeHandler atomic.Pointer[func()] +} + +func newFakeController(id, workerID string, weight int) *fakeController { + return &fakeController{id: id, workerID: workerID, weight: weight} +} + +func (c *fakeController) ID() string { return c.id } +func (c *fakeController) WorkerID() string { return c.workerID } +func (c *fakeController) Weight() int { return c.weight } +func (c *fakeController) IsZero() bool { return c == nil } +func (c *fakeController) UserAgent() string { + return "fake-controller" +} +func (c *fakeController) ProtoMajorVersion() int { return 1 } +func (c *fakeController) ProtoMinorVersion() int { return 0 } + +func (c *fakeController) UUID() string { + if uuid := c.uuid.Load(); uuid != nil { + return *uuid + } + return "" +} + +func (c *fakeController) SetUUID(uuid string) { c.uuid.Store(&uuid) } + +func (c *fakeController) AccountInfo() protos.AccountInfo { return protos.AccountInfo{} } + +func (c *fakeController) Close(code ws.StatusCode, _ string) error { + c.closed.Store(true) + c.closeCode.Store(int64(code)) + if handler := c.closeHandler.Load(); handler != nil { + (*handler)() + } + return nil +} + +func (c *fakeController) SetCloseHandler(fn func()) { c.closeHandler.Store(&fn) } +func (c *fakeController) WebsocketStats() ws.ConnStats { return ws.ConnStats{} } +func (c *fakeController) Flush(context.Context) error { return nil } +func (c *fakeController) Run(context.Context) {} +func (c *fakeController) WriteAsync(context.Context, ws.MessageType, []byte) error { + return nil +} +func (c *fakeController) WriteAsyncFromReader(context.Context, ws.Reader) error { return nil } + +func (c *fakeController) Reader(ctx context.Context) (ws.Reader, error) { + // Nothing ever reads from a controller in these tests; blocking until the + // context is done is the honest answer for a connection with no traffic. + <-ctx.Done() + return nil, ctx.Err() +} + +// fakeWSReader is a ws.Reader over a fixed payload. +type fakeWSReader struct { + *bytes.Reader + + payload []byte +} + +func newFakeWSReader(payload []byte) *fakeWSReader { + return &fakeWSReader{Reader: bytes.NewReader(payload), payload: payload} +} + +func (r *fakeWSReader) Bytes() []byte { return r.payload } +func (r *fakeWSReader) MessageType() ws.MessageType { return ws.MessageBinary } +func (r *fakeWSReader) Len() int { return len(r.payload) } +func (r *fakeWSReader) Done() {} + +// fakeControllerWSConn satisfies connections.ControllerWSConn, serving one +// canned message: the registration request the manager reads on connect. +type fakeControllerWSConn struct { + firstMessage []byte + read atomic.Bool +} + +func (c *fakeControllerWSConn) GetStats() ws.ConnStats { return ws.ConnStats{} } +func (c *fakeControllerWSConn) Close(ws.StatusCode, string) error { return nil } +func (c *fakeControllerWSConn) SetReadDeadline(time.Time) error { return nil } +func (c *fakeControllerWSConn) Flush(context.Context) error { return nil } + +func (c *fakeControllerWSConn) WriteAsync(context.Context, ws.MessageType, []byte) error { + return nil +} +func (c *fakeControllerWSConn) WriteAsyncFromReader(context.Context, ws.Reader) error { return nil } + +func (c *fakeControllerWSConn) Reader(ctx context.Context) (ws.Reader, error) { + if c.read.Swap(true) { + <-ctx.Done() + return nil, ctx.Err() + } + return newFakeWSReader(c.firstMessage), nil +} + +// noopConnStats satisfies connections.StatsCollector. +type noopConnStats struct{} + +func (noopConnStats) SetDeviceMemoryFree(string, float64) {} +func (noopConnStats) SetDeviceMemoryMITM(string, float64) {} +func (noopConnStats) SetDeviceMemoryStart(string, float64) {} +func (noopConnStats) IncrDeviceCommandExecuted(string, string) {} +func (noopConnStats) IncrDeviceCommandSuccess(string, string) {} +func (noopConnStats) IncrDeviceCommandError(string, string) {} +func (noopConnStats) IncrDeviceRegistrationFails() {} +func (noopConnStats) IncrDeviceRegistrations(string) {} +func (noopConnStats) IncrDevicesConnected(string) {} +func (noopConnStats) DecrDevicesConnected(string) {} +func (noopConnStats) IncrDevicesTotal(string) {} +func (noopConnStats) DecrDevicesTotal(string, int) {} +func (noopConnStats) IncrWorkerRegistrations(string) {} diff --git a/libs/handlers/api_handler_test.go b/libs/handlers/api_handler_test.go new file mode 100644 index 0000000..577d39c --- /dev/null +++ b/libs/handlers/api_handler_test.go @@ -0,0 +1,752 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "google.golang.org/protobuf/proto" + + "github.com/UnownHash/RotomNG/libs/api" + "github.com/UnownHash/RotomNG/libs/connections" + "github.com/UnownHash/RotomNG/libs/jobs" + "github.com/UnownHash/RotomNG/libs/protos" + "github.com/UnownHash/RotomNG/libs/selector" +) + +type testAPIHandler = APIHandler[*fakeController, *fakeWorker] + +// apiTestEnv is an APIHandler wired to a real ConnectionManager, selector, and +// jobs manager, served through a real gin router on the routes the app +// registers. +type apiTestEnv struct { + handler *testAPIHandler + manager *connections.ConnectionManager[*fakeController, *fakeWorker] + jobs *jobs.Manager + router *gin.Engine + cfg *APIHandlerConfig[*fakeController, *fakeWorker] +} + +type apiEnvOption func(*apiEnvOptions) + +type apiEnvOptions struct { + settings APIHandlerSettings + // jobFiles are written into the jobs directory before the manager loads. + jobFiles map[string]string + // omitJobsManager leaves the handler with no jobs manager at all, which is + // how the app wires it when jobs are off. + omitJobsManager bool +} + +func withSettings(s APIHandlerSettings) apiEnvOption { + return func(o *apiEnvOptions) { o.settings = s } +} + +func withJobFile(name, body string) apiEnvOption { + return func(o *apiEnvOptions) { + if o.jobFiles == nil { + o.jobFiles = map[string]string{} + } + o.jobFiles[name] = body + } +} + +func withoutJobsManager() apiEnvOption { + return func(o *apiEnvOptions) { o.omitJobsManager = true } +} + +func newAPITestEnv(t *testing.T, opts ...apiEnvOption) *apiTestEnv { + t.Helper() + + options := apiEnvOptions{settings: APIHandlerSettings{JobsEnabled: true}} + for _, opt := range opts { + opt(&options) + } + + logger := slog.New(slog.DiscardHandler) + + jobsPath := t.TempDir() + for name, body := range options.jobFiles { + if err := os.WriteFile(filepath.Join(jobsPath, name), []byte(body), 0o600); err != nil { + t.Fatalf("write job file %s: %v", name, err) + } + } + + jobsManagerConfig := jobs.ManagerConfig{Logger: logger} + if err := jobsManagerConfig.Init(jobs.ManagerSettings{JobsPath: jobsPath}); err != nil { + t.Fatalf("init jobs manager config: %v", err) + } + jobsManager := jobs.NewManager(jobsManagerConfig) + if err := jobsManager.Reload(); err != nil { + t.Fatalf("load jobs: %v", err) + } + + var selectorConfig selector.Config + if err := selectorConfig.Init(selector.Settings{}); err != nil { + t.Fatalf("init selector config: %v", err) + } + + managerConfig := connections.ConnectionManagerConfig[*fakeController, *fakeWorker]{ + Logger: logger, + StatsCollector: noopConnStats{}, + JobsRunner: jobsManager, + WorkerSelector: selector.NewBalancedSelector[*fakeWorker](selectorConfig), + NewController: func( + _ connections.ControllerWSConn, + id string, + _ *protos.MitmRequest, + _ connections.MITMWorker, + weight int, + _ string, + _ bool, + _, _ int, + ) *fakeController { + return newFakeController(id, id+"-worker", weight) + }, + UserAgent: "test", + } + if err := managerConfig.Init(connections.ConnectionManagerSettings{}); err != nil { + t.Fatalf("init connection manager config: %v", err) + } + manager := connections.NewConnectionManager(managerConfig) + + handlerConfig := &APIHandlerConfig[*fakeController, *fakeWorker]{ + Logger: logger, + ConnectionManager: manager, + APIConverter: api.NewConverter[ + *connections.Device[*fakeWorker], *fakeWorker, *fakeController, + ](), + } + if !options.omitJobsManager { + handlerConfig.JobsManager = jobsManager + } + if err := handlerConfig.Init(options.settings); err != nil { + t.Fatalf("init api handler config: %v", err) + } + + handler := NewAPIHandler(t.Context(), *handlerConfig) + + gin.SetMode(gin.TestMode) + router := gin.New() + handler.SetupAPIRoutes(router.Group("/api")) + + t.Cleanup(manager.Wait) + + return &apiTestEnv{ + handler: handler, + manager: manager, + jobs: jobsManager, + router: router, + cfg: handlerConfig, + } +} + +// do issues a request against the handler's routes. +func (e *apiTestEnv) do(t *testing.T, method, path, body string) (int, string) { + t.Helper() + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + request := httptest.NewRequest(method, path, reader) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + response := httptest.NewRecorder() + e.router.ServeHTTP(response, request) + return response.Code, response.Body.String() +} + +// addWorker registers a worker, which is also what brings its device into +// existence in the manager. +func (e *apiTestEnv) addWorker(t *testing.T, workerID, deviceID string) *fakeWorker { + t.Helper() + worker := newFakeWorker(workerID, deviceID, "origin-"+deviceID) + if err := e.manager.RegisterWorker(t.Context(), worker); err != nil { + t.Fatalf("register worker: %v", err) + } + return worker +} + +// addController registers a controller through the v1 handshake, the only way +// in short of reaching into the manager's internals. +func (e *apiTestEnv) addController(t *testing.T, id string) *fakeController { + t.Helper() + + // A controller is only issued a worker the selector says is available, so + // one has to exist first. Registering a worker for an unknown device marks + // that device unselectable until its control connection arrives, and + // EnableDevice only reaches the selector on a state *change* -- so the + // device has to be toggled to stand in for the control connection this + // test does not open. + deviceID := id + "-device" + e.addWorker(t, id+"-worker", deviceID) + if _, err := e.manager.DisableDevice(deviceID); err != nil { + t.Fatalf("disable device: %v", err) + } + if _, err := e.manager.EnableDevice(deviceID); err != nil { + t.Fatalf("enable device: %v", err) + } + + loginRequest := &protos.MitmRequest{ + Id: 1, + Method: protos.MitmRequest_LOGIN, + Payload: &protos.MitmRequest_LoginRequest_{ + LoginRequest: &protos.MitmRequest_LoginRequest{WorkerId: id}, + }, + } + payload, err := proto.Marshal(loginRequest) + if err != nil { + t.Fatalf("marshal login request: %v", err) + } + + controller, err := e.manager.RegisterControllerConnectionV1( + t.Context(), &fakeControllerWSConn{firstMessage: payload}, 10, "test-agent", + ) + if err != nil { + t.Fatalf("register controller: %v", err) + } + return controller +} + +func decodeBody(t *testing.T, body string) map[string]any { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("decode response %q: %v", body, err) + } + return decoded +} + +func assertErrorBody(t *testing.T, body, wantSubstring string) { + t.Helper() + decoded := decodeBody(t, body) + if decoded["status"] != valStatusError { + t.Errorf("status = %v, want %q (body %s)", decoded["status"], valStatusError, body) + } + message, _ := decoded[fieldError].(string) + if !strings.Contains(message, wantSubstring) { + t.Errorf("error = %q, want it to contain %q", message, wantSubstring) + } +} + +// --- Device actions --- + +// TestDeviceActionOnUnknownDevice pins the error mapping: a device the manager +// has never heard of is a 404, not a 500, for every action that takes one. +func TestDeviceActionOnUnknownDevice(t *testing.T) { + env := newAPITestEnv(t) + + for _, action := range []string{"restart", "reboot", "logcat", "enable", "disable", "disconnect", "delete"} { + t.Run(action, func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/device/ghost/action/"+action, "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, "not found") + }) + } +} + +// TestDeviceActionRejectsAllDevicesWildcard covers the "_" guard: only delete +// is defined across every device, so the rest must refuse rather than silently +// act on one device or none. +func TestDeviceActionRejectsAllDevicesWildcard(t *testing.T) { + env := newAPITestEnv(t) + + for _, action := range []string{"restart", "reboot", "logcat", "enable", "disable", "disconnect"} { + t.Run(action, func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/device/_/action/"+action, "") + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, msgActionNotAllDevices) + }) + } +} + +// TestDeviceCommandsOnDisconnectedDevice covers the second error class: the +// device is known but has no control connection, which is a 400 rather than a +// 404 because the operator's request was well-formed. +func TestDeviceCommandsOnDisconnectedDevice(t *testing.T) { + env := newAPITestEnv(t) + env.addWorker(t, "worker-1", "device-1") + + for _, action := range []string{"restart", "reboot", "logcat", "disconnect"} { + t.Run(action, func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/device/device-1/action/"+action, "") + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, "not connected") + }) + } +} + +func TestDeviceEnableDisable(t *testing.T) { + env := newAPITestEnv(t) + env.addWorker(t, "worker-1", "device-1") + + status, body := env.do(t, http.MethodPut, "/api/device/device-1/action/enable", "") + if status != http.StatusOK { + t.Fatalf("enable: status = %d, want 200 (body %s)", status, body) + } + decoded := decodeBody(t, body) + device, ok := decoded[fieldDevice].(map[string]any) + if !ok { + t.Fatalf("enable reply has no device object: %s", body) + } + // The reply carries the device's new state, so a UI need not re-poll. + if device["enabled"] != true { + t.Errorf("enabled = %v, want true (body %s)", device["enabled"], body) + } + + status, body = env.do(t, http.MethodPut, "/api/device/device-1/action/disable", "") + if status != http.StatusOK { + t.Fatalf("disable: status = %d, want 200 (body %s)", status, body) + } + device, _ = decodeBody(t, body)[fieldDevice].(map[string]any) + if device["enabled"] != false { + t.Errorf("enabled = %v, want false (body %s)", device["enabled"], body) + } +} + +func TestDeviceDelete(t *testing.T) { + t.Run("device with workers is refused", func(t *testing.T) { + env := newAPITestEnv(t) + env.addWorker(t, "worker-1", "device-1") + + status, body := env.do(t, http.MethodPut, "/api/device/device-1/action/delete", "") + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, "workers") + }) + + t.Run("unconnected device is removed", func(t *testing.T) { + env := newAPITestEnv(t) + worker := env.addWorker(t, "worker-1", "device-1") + // Closing the worker deregisters it, leaving the device unreferenced. + _ = worker.Close(0, "") + + status, body := env.do(t, http.MethodPut, "/api/device/device-1/action/delete", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + if env.manager.GetDeviceByID("device-1") != nil { + t.Error("device is still known after a successful delete") + } + }) + + t.Run("wildcard removes every dead device", func(t *testing.T) { + env := newAPITestEnv(t) + first := env.addWorker(t, "worker-1", "device-1") + second := env.addWorker(t, "worker-2", "device-2") + _ = first.Close(0, "") + _ = second.Close(0, "") + + status, body := env.do(t, http.MethodPut, "/api/device/_/action/delete", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + if count := decodeBody(t, body)["devices_count"]; count != float64(2) { + t.Errorf("devices_count = %v, want 2 (body %s)", count, body) + } + }) +} + +func TestDeviceActionUnknownAction(t *testing.T) { + env := newAPITestEnv(t) + + status, body := env.do(t, http.MethodPut, "/api/device/device-1/action/explode", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, "Action not found") +} + +// --- Controller actions --- + +func TestControllerActions(t *testing.T) { + t.Run("unknown controller", func(t *testing.T) { + env := newAPITestEnv(t) + for _, action := range []string{"disconnect", "reconnect"} { + status, body := env.do(t, http.MethodPut, "/api/controller/nope/action/"+action, "") + if status != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404 (body %s)", action, status, body) + } + assertErrorBody(t, body, "Controller not found") + } + }) + + t.Run("disconnect closes the connection", func(t *testing.T) { + env := newAPITestEnv(t) + controller := env.addController(t, "ctrl-1") + + status, body := env.do(t, http.MethodPut, + "/api/controller/"+controller.UUID()+"/action/disconnect", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + // The close runs in the manager's background goroutine. + env.manager.Wait() + if !controller.closed.Load() { + t.Error("controller was not closed") + } + }) + + t.Run("reconnect closes with the restart-session code", func(t *testing.T) { + env := newAPITestEnv(t) + controller := env.addController(t, "ctrl-2") + + status, body := env.do(t, http.MethodPut, + "/api/controller/"+controller.UUID()+"/action/reconnect", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + env.manager.Wait() + if !controller.closed.Load() { + t.Error("controller was not closed") + } + // Disconnect and reconnect differ only in the close code the controller + // sees, which is what tells it whether to come back. + if code := controller.closeCode.Load(); code == 0 { + t.Error("controller was closed with no status code") + } + }) + + t.Run("unknown action", func(t *testing.T) { + env := newAPITestEnv(t) + status, body := env.do(t, http.MethodPut, "/api/controller/nope/action/explode", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, "Action not found") + }) +} + +// --- Jobs --- + +const testJobFile = `{"id": "whoami", "description": "run whoami", "exec": "whoami"}` + +func TestJobEndpointsWhenJobsDisabled(t *testing.T) { + // Both ways of turning jobs off must behave the same: the setting off, and + // the app not wiring a jobs manager at all. + for name, env := range map[string]*apiTestEnv{ + "setting disabled": newAPITestEnv(t, withSettings(APIHandlerSettings{JobsEnabled: false})), + "no jobs manager": newAPITestEnv(t, withoutJobsManager()), + } { + t.Run(name, func(t *testing.T) { + requests := []struct{ method, path string }{ + {http.MethodGet, "/api/job"}, + {http.MethodGet, "/api/job/whoami"}, + {http.MethodPut, "/api/job/-/reload"}, + {http.MethodPut, "/api/job/whoami/run"}, + {http.MethodGet, "/api/job-instance"}, + {http.MethodGet, "/api/job-instance/1"}, + {http.MethodPut, "/api/job-instance/1/clear"}, + } + for _, request := range requests { + status, body := env.do(t, request.method, request.path, "") + if status != http.StatusNotFound { + t.Errorf("%s %s: status = %d, want 404 (body %s)", + request.method, request.path, status, body) + } + assertErrorBody(t, body, msgJobsNotEnabled) + } + }) + } +} + +func TestGetJob(t *testing.T) { + env := newAPITestEnv(t, withJobFile("whoami.json", testJobFile)) + + t.Run("known job", func(t *testing.T) { + status, body := env.do(t, http.MethodGet, "/api/job/whoami", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + job, ok := decodeBody(t, body)["job"].(map[string]any) + if !ok || job["id"] != "whoami" { + t.Errorf("job = %v, want id whoami (body %s)", job, body) + } + }) + + t.Run("unknown job", func(t *testing.T) { + status, body := env.do(t, http.MethodGet, "/api/job/nope", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, "job not found") + }) +} + +func TestReloadJobs(t *testing.T) { + env := newAPITestEnv(t, withJobFile("whoami.json", testJobFile)) + + t.Run("all jobs", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job/-/reload", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + }) + + t.Run("single job is not supported", func(t *testing.T) { + // Reloading one job would need the manager to reconcile a partial view + // of the jobs directory; the endpoint says so rather than pretending. + status, body := env.do(t, http.MethodPut, "/api/job/whoami/reload", "") + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, "not implemented") + }) +} + +func TestRunJob(t *testing.T) { + env := newAPITestEnv(t, withJobFile("whoami.json", testJobFile)) + + t.Run("unknown job", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job/nope/run", `{"device_ids":["device-1"]}`) + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, "job not found") + }) + + t.Run("malformed body", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job/whoami/run", `{"device_ids":`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, "failed to decode request") + }) + + t.Run("no device ids", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job/whoami/run", `{"device_ids":[]}`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", status, body) + } + assertErrorBody(t, body, "no 'device_ids'") + }) + + t.Run("device that cannot run it still yields an instance", func(t *testing.T) { + // A job against an unreachable device is recorded as a failed instance + // rather than an error reply: the operator asked for N runs and gets N + // results, each with its own outcome. + status, body := env.do(t, http.MethodPut, "/api/job/whoami/run", + `{"device_ids":["device-1","device-2"]}`) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + instances, ok := decodeBody(t, body)["instances"].([]any) + if !ok || len(instances) != 2 { + t.Fatalf("instances = %v, want 2 (body %s)", instances, body) + } + }) +} + +func TestJobInstances(t *testing.T) { + env := newAPITestEnv(t, withJobFile("whoami.json", testJobFile)) + instance := env.jobs.AddFailedJobInstance("whoami", "device-1", "nope") + + t.Run("known instance", func(t *testing.T) { + status, body := env.do(t, http.MethodGet, + "/api/job-instance/"+strconv.FormatUint(instance.ID, 10), "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + }) + + t.Run("unparseable id", func(t *testing.T) { + status, body := env.do(t, http.MethodGet, "/api/job-instance/not-a-number", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, msgJobInstanceNotFound) + }) + + t.Run("unknown id", func(t *testing.T) { + status, body := env.do(t, http.MethodGet, "/api/job-instance/999999", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, msgJobInstanceNotFound) + }) +} + +func TestClearJobInstance(t *testing.T) { + env := newAPITestEnv(t, withJobFile("whoami.json", testJobFile)) + + t.Run("unparseable id", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job-instance/not-a-number/clear", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, msgJobInstanceNotFound) + }) + + t.Run("unknown id", func(t *testing.T) { + status, body := env.do(t, http.MethodPut, "/api/job-instance/999999/clear", "") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + assertErrorBody(t, body, msgJobInstanceNotFound) + }) + + t.Run("single instance", func(t *testing.T) { + instance := env.jobs.AddFailedJobInstance("whoami", "device-1", "nope") + status, body := env.do(t, http.MethodPut, + "/api/job-instance/"+strconv.FormatUint(instance.ID, 10)+"/clear", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + if _, found := env.jobs.GetJobInstanceByID(instance.ID); found { + t.Error("instance still present after clear") + } + }) + + t.Run("all instances", func(t *testing.T) { + env.jobs.AddFailedJobInstance("whoami", "device-1", "nope") + status, body := env.do(t, http.MethodPut, "/api/job-instance/-/clear", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", status, body) + } + if instances := env.jobs.GetJobInstances(); len(instances) != 0 { + t.Errorf("%d instances remain after clearing all", len(instances)) + } + }) +} + +// --- pprof --- + +// TestPprofRoutesRespectTheProfilingSetting matters because these endpoints +// expose runtime internals: they must stay shut unless explicitly enabled. +func TestPprofRoutesRespectTheProfilingSetting(t *testing.T) { + paths := []string{ + "/api/debug/pprof/", + "/api/debug/pprof/cmdline", + "/api/debug/pprof/symbol", + } + + t.Run("disabled", func(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{ProfilingEnabled: false})) + for _, path := range paths { + status, body := env.do(t, http.MethodGet, path, "") + if status != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404 (body %s)", path, status, body) + } + if !strings.Contains(body, msgProfilingDisabled) { + t.Errorf("%s: body = %q, want it to mention profiling being off", path, body) + } + } + }) + + t.Run("enabled", func(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{ProfilingEnabled: true})) + for _, path := range paths { + status, _ := env.do(t, http.MethodGet, path, "") + if status != http.StatusOK { + t.Errorf("%s: status = %d, want 200", path, status) + } + } + }) +} + +// TestPprofProfileAndTraceRespectTheSetting covers the two sampling handlers +// separately: enabled they block for the sample duration, so only the gated +// path is worth asserting here. +func TestPprofProfileAndTraceRespectTheSetting(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{ProfilingEnabled: false})) + + for _, path := range []string{"/api/debug/pprof/profile", "/api/debug/pprof/trace"} { + status, body := env.do(t, http.MethodGet, path, "") + if status != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404 (body %s)", path, status, body) + } + } +} + +func TestPprofProfileWhenEnabled(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{ProfilingEnabled: true})) + + // pprof parses seconds as an integer and falls back to 30 for anything <= 0, + // so 1 is the shortest sample that does not stall the suite. + status, body := env.do(t, http.MethodGet, "/api/debug/pprof/profile?seconds=1", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if len(body) == 0 { + t.Error("profile body is empty, want pprof output") + } +} + +func TestPprofTraceWhenEnabled(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{ProfilingEnabled: true})) + + done := make(chan struct{}) + go func() { + defer close(done) + status, _ := env.do(t, http.MethodGet, "/api/debug/pprof/trace?seconds=0.01", "") + if status != http.StatusOK { + t.Errorf("status = %d, want 200", status) + } + }() + + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("trace handler did not return") + } +} + +// --- Settings plumbing --- + +func TestSettingsReloadIsPickedUpLive(t *testing.T) { + env := newAPITestEnv(t, withSettings(APIHandlerSettings{JobsEnabled: false})) + + status, _ := env.do(t, http.MethodGet, "/api/job", "") + if status != http.StatusNotFound { + t.Fatalf("status = %d, want 404 before the reload", status) + } + + // A config reload swaps the settings container's contents underneath the + // running handler; no restart, and no re-registered routes. + if err := env.cfg.PutSettings(APIHandlerSettings{JobsEnabled: true}); err != nil { + t.Fatalf("put settings: %v", err) + } + + status, body := env.do(t, http.MethodGet, "/api/job", "") + if status != http.StatusOK { + t.Errorf("status = %d, want 200 after enabling jobs (body %s)", status, body) + } +} + +func TestNewAPIHandlerDefaultsGlobalRequestStats(t *testing.T) { + // A nil collector would panic on the first /api/status; the constructor + // substitutes an empty one instead. + env := newAPITestEnv(t) + if env.handler.globalRequestStats == nil { + t.Fatal("globalRequestStats is nil") + } + + status, body := env.do(t, http.MethodGet, "/api/status", "") + if status != http.StatusOK { + t.Errorf("status = %d, want 200 (body %s)", status, body) + } +} + +func TestAPIHandlerSettingsValidate(t *testing.T) { + if err := (APIHandlerSettings{}).Validate(); err != nil { + t.Errorf("Validate() = %v, want nil", err) + } +} diff --git a/libs/handlers/base_handler_test.go b/libs/handlers/base_handler_test.go new file mode 100644 index 0000000..0bd9726 --- /dev/null +++ b/libs/handlers/base_handler_test.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "sync/atomic" + "testing" + "time" +) + +// TestBaseHandlerRunInBackgroundIsWaitedOn covers the contract shutdown relies +// on: a goroutine started through the handler is tracked, so Wait cannot return +// while it is still running. +func TestBaseHandlerRunInBackgroundIsWaitedOn(t *testing.T) { + var handler BaseHandler + + var finished atomic.Bool + release := make(chan struct{}) + + handler.RunInBackground(func() { + <-release + finished.Store(true) + }) + + waited := make(chan struct{}) + go func() { + defer close(waited) + handler.Wait() + }() + + select { + case <-waited: + t.Fatal("Wait returned while a background goroutine was still running") + case <-time.After(50 * time.Millisecond): + } + + close(release) + + select { + case <-waited: + case <-time.After(5 * time.Second): + t.Fatal("Wait did not return after the background goroutine finished") + } + if !finished.Load() { + t.Error("Wait returned before the goroutine's work was visible") + } +} + +// TestBaseHandlerPreventShutdown covers the other half: a handler can hold +// shutdown open across work it does not run in a goroutine of its own. +func TestBaseHandlerPreventShutdown(t *testing.T) { + var handler BaseHandler + + done := handler.PreventShutdown() + + waited := make(chan struct{}) + go func() { + defer close(waited) + handler.Wait() + }() + + select { + case <-waited: + t.Fatal("Wait returned while shutdown was being prevented") + case <-time.After(50 * time.Millisecond): + } + + done() + + select { + case <-waited: + case <-time.After(5 * time.Second): + t.Fatal("Wait did not return after shutdown was released") + } +} + +// TestBaseHandlerWaitWithNothingRunning guards the trivial case: an idle +// handler must not block a shutdown. +func TestBaseHandlerWaitWithNothingRunning(t *testing.T) { + var handler BaseHandler + + waited := make(chan struct{}) + go func() { + defer close(waited) + handler.Wait() + }() + + select { + case <-waited: + case <-time.After(5 * time.Second): + t.Fatal("Wait blocked with no tracked goroutines") + } +} diff --git a/libs/services/http_server_test.go b/libs/services/http_server_test.go new file mode 100644 index 0000000..8f459c2 --- /dev/null +++ b/libs/services/http_server_test.go @@ -0,0 +1,355 @@ +package services + +import ( + "context" + "errors" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// staticRoutes installs one route and records how many times it was installed. +type staticRoutes struct { + err error + calls atomic.Int64 +} + +func (r *staticRoutes) SetupRoutes(engine *gin.Engine) error { + r.calls.Add(1) + if r.err != nil { + return r.err + } + engine.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) + engine.GET("/hang", func(c *gin.Context) { + <-c.Request.Context().Done() + }) + return nil +} + +// countingRegistrar records that it was given the engine. +type countingRegistrar struct { + calls atomic.Int64 +} + +func (r *countingRegistrar) RegisterGinEngine(*gin.Engine) { r.calls.Add(1) } + +// runServer starts s and returns a function that shuts it down and waits. +func runServer(t *testing.T, server *HTTPServer) func(context.Context) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + server.Run() + }() + return func(ctx context.Context) { + server.Shutdown(ctx) + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("Run did not return after Shutdown") + } + } +} + +func TestHTTPServerServesOnAProvidedListener(t *testing.T) { + gin.SetMode(gin.TestMode) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + routes := &staticRoutes{} + registrar := &countingRegistrar{} + auth := &allowingAuth{allow: true} + + server, err := NewHTTPServer(t.Context(), testLogger(), HTTPServerConfig{ + Address: listener.Addr().String(), + Listener: listener, + RoutesInstaller: routes, + StatsRegistrar: registrar, + AuthMiddleware: auth, + }) + if err != nil { + t.Fatalf("NewHTTPServer: %v", err) + } + if routes.calls.Load() != 1 { + t.Errorf("SetupRoutes called %d times, want 1", routes.calls.Load()) + } + if registrar.calls.Load() != 1 { + t.Errorf("StatsRegistrar called %d times, want 1", registrar.calls.Load()) + } + + stop := runServer(t, server) + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stop(ctx) + }() + + response, err := http.Get("http://" + listener.Addr().String() + "/ping") + if err != nil { + t.Fatalf("GET /ping: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", response.StatusCode) + } + // The engine-level auth middleware guards every route on these servers, + // unlike WebServer's, which guards only /api. + if auth.handlerCalls.Load() == 0 { + t.Error("auth middleware did not run") + } +} + +func TestHTTPServerServesOnAnAddress(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Claim an ephemeral port, then hand the address (not the listener) over, + // which is the path taken when the config supplies only an address. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + address := probe.Addr().String() + if err := probe.Close(); err != nil { + t.Fatalf("close probe listener: %v", err) + } + + server, err := NewHTTPServer(t.Context(), testLogger(), HTTPServerConfig{ + Address: address, + RoutesInstaller: &staticRoutes{}, + }) + if err != nil { + t.Fatalf("NewHTTPServer: %v", err) + } + + stop := runServer(t, server) + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stop(ctx) + }() + + // The listen is asynchronous, so retry briefly rather than racing it. + var response *http.Response + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + response, err = http.Get("http://" + address + "/ping") + if err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if err != nil { + t.Fatalf("GET /ping: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", response.StatusCode) + } +} + +// TestHTTPServerRunReportsABindFailure covers the error path in Run: a port it +// cannot claim must return rather than block, so the app's Run goroutine +// cancels the context and the process exits instead of appearing healthy. +func TestHTTPServerRunReportsABindFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer occupied.Close() + + server, err := NewHTTPServer(t.Context(), testLogger(), HTTPServerConfig{ + Address: occupied.Addr().String(), + RoutesInstaller: &staticRoutes{}, + }) + if err != nil { + t.Fatalf("NewHTTPServer: %v", err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + server.Run() + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Run blocked on an address it could not bind") + } +} + +func TestNewHTTPServerPropagatesRouteErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + wantErr := errors.New("routes are broken") + _, err := NewHTTPServer(t.Context(), testLogger(), HTTPServerConfig{ + Address: "127.0.0.1:0", + RoutesInstaller: &staticRoutes{err: wantErr}, + }) + if !errors.Is(err, wantErr) { + t.Errorf("error = %v, want %v", err, wantErr) + } +} + +// TestHTTPServerShutdownTimesOut covers Shutdown's error branch: an in-flight +// request that outlives the deadline makes Shutdown return, and the server +// logs it rather than hanging the whole shutdown sequence. +func TestHTTPServerShutdownTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + server, err := NewHTTPServer(t.Context(), testLogger(), HTTPServerConfig{ + Address: listener.Addr().String(), + Listener: listener, + RoutesInstaller: &staticRoutes{}, + }) + if err != nil { + t.Fatalf("NewHTTPServer: %v", err) + } + + runDone := make(chan struct{}) + go func() { + defer close(runDone) + server.Run() + }() + + // Hold a request open so there is something for Shutdown to wait on. + requestCtx, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() + + inFlight := make(chan struct{}) + go func() { + defer close(inFlight) + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, + "http://"+listener.Addr().String()+"/hang", nil) + if err != nil { + return + } + response, err := http.DefaultClient.Do(request) + if err == nil { + _ = response.Body.Close() + } + }() + + // Give the hanging request time to be accepted before shutting down. + time.Sleep(100 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + server.Shutdown(ctx) + + // Release the request and let the server finish. + cancelRequest() + <-inFlight + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + server.Shutdown(shutdownCtx) + + select { + case <-runDone: + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after shutdown") + } +} + +// --- Device and controller servers --- + +type stubDeviceHandler struct{ calls atomic.Int64 } + +func (h *stubDeviceHandler) HandleDeviceControl(c *gin.Context) { + h.calls.Add(1) + c.String(http.StatusOK, "control") +} + +type stubWorkerHandler struct{ calls atomic.Int64 } + +func (h *stubWorkerHandler) HandleWorker(c *gin.Context) { + h.calls.Add(1) + c.String(http.StatusOK, "worker") +} + +type stubControllerHandler struct{ v1, v2 atomic.Int64 } + +func (h *stubControllerHandler) HandleControllerV1(c *gin.Context) { + h.v1.Add(1) + c.String(http.StatusOK, "v1") +} + +func (h *stubControllerHandler) HandleControllerV2(c *gin.Context) { + h.v2.Add(1) + c.String(http.StatusOK, "v2") +} + +// TestDeviceServerRoutes pins the paths devices and workers connect on. They +// are baked into shipped device firmware, so a change here breaks fleets in +// the field rather than just a client. +func TestDeviceServerRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + + deviceHandler := &stubDeviceHandler{} + workerHandler := &stubWorkerHandler{} + + server, err := NewDeviceServer(t.Context(), testLogger(), DeviceServerConfig{ + Address: "127.0.0.1:0", + DeviceHandler: deviceHandler, + WorkerHandler: workerHandler, + }) + if err != nil { + t.Fatalf("NewDeviceServer: %v", err) + } + + engine := gin.New() + if err := server.SetupRoutes(engine); err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + if status, body := doGet(t, engine, "/control"); status != http.StatusOK || body != "control" { + t.Errorf("/control = %d %q, want the device handler", status, body) + } + if status, body := doGet(t, engine, "/"); status != http.StatusOK || body != "worker" { + t.Errorf("/ = %d %q, want the worker handler", status, body) + } +} + +func TestControllerServerRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := &stubControllerHandler{} + + server, err := NewControllerServer(t.Context(), testLogger(), ControllerServerConfig{ + Address: "127.0.0.1:0", + }, handler) + if err != nil { + t.Fatalf("NewControllerServer: %v", err) + } + + engine := gin.New() + if err := server.SetupRoutes(engine); err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + // The two protocol versions are distinguished by path, which is how an + // older controller keeps working against a newer rotom-ng. + if status, body := doGet(t, engine, "/"); status != http.StatusOK || body != "v1" { + t.Errorf("/ = %d %q, want the v1 handler", status, body) + } + if status, body := doGet(t, engine, "/controller"); status != http.StatusOK || body != "v2" { + t.Errorf("/controller = %d %q, want the v2 handler", status, body) + } +} diff --git a/libs/services/static/asset.txt b/libs/services/static/asset.txt new file mode 100644 index 0000000..18a6795 --- /dev/null +++ b/libs/services/static/asset.txt @@ -0,0 +1 @@ +fixture asset diff --git a/libs/services/static/index.html b/libs/services/static/index.html new file mode 100644 index 0000000..6adcc27 --- /dev/null +++ b/libs/services/static/index.html @@ -0,0 +1,8 @@ + +embedded ui fixture diff --git a/libs/services/web_server_test.go b/libs/services/web_server_test.go index d4b112c..da4fc53 100644 --- a/libs/services/web_server_test.go +++ b/libs/services/web_server_test.go @@ -2,11 +2,15 @@ package services import ( "context" + "embed" + "encoding/json" "io" "log/slog" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync/atomic" "testing" @@ -15,10 +19,62 @@ import ( "github.com/gin-gonic/gin" ) +// testUIFS stands in for the built UI bundle. WebServer looks for a directory +// named "static" at the root of the FS, so the fixture has to live under that +// name in this package -- see libs/services/static. +// +//go:embed static +var testUIFS embed.FS + func testLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } +// allowingAuth accepts or rejects on demand and implements RequestAuthorizer, +// as the real auth.Middleware does. +type allowingAuth struct { + allow bool + // handlerCalls counts in-chain middleware invocations, distinguishing the + // registered-route path from the NoRoute path. + handlerCalls atomic.Int64 +} + +func (a *allowingAuth) Handler(c *gin.Context) { + a.handlerCalls.Add(1) + if !a.allow { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Next() +} + +// sessionAuth additionally registers unauthenticated session routes. +type sessionAuth struct { + allowingAuth + + setupCalls atomic.Int64 +} + +func (s *sessionAuth) SetupSessionRoutes(group *gin.RouterGroup, _ *slog.Logger) { + s.setupCalls.Add(1) + group.GET("/auth/me", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) +} + +// uiDir writes a stand-in for the built UI bundle on disk. +func uiDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("ui"), 0o600); err != nil { + t.Fatalf("write index.html: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "asset.txt"), []byte("asset"), 0o600); err != nil { + t.Fatalf("write asset.txt: %v", err) + } + return dir +} + // newTestWebServer builds a WebServer and returns the gin engine its routes // were installed on, so requests can be driven without binding a port. func newTestWebServer(t *testing.T, config WebServerConfig) (*gin.Engine, error) { @@ -37,6 +93,94 @@ func newTestWebServer(t *testing.T, config WebServerConfig) (*gin.Engine, error) return engine, nil } +func doGet(t *testing.T, engine *gin.Engine, path string) (int, string) { + t.Helper() + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + return response.Code, response.Body.String() +} + +// --- API fallback --- + +// TestAPIMissWithoutFallbackIs404 pins the default: an unknown /api path is a +// JSON 404, not the SPA's index.html. Serving HTML there would make a typo in +// a client's URL look like a successful page load. +func TestAPIMissWithoutFallbackIs404(t *testing.T) { + engine, err := newTestWebServer(t, WebServerConfig{UIPath: uiDir(t)}) + if err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + status, body := doGet(t, engine, "/api/nope") + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404 (body %s)", status, body) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("body is not JSON: %v (%s)", err, body) + } + if decoded["error"] != "resource does not exist" { + t.Errorf("error = %v, want %q", decoded["error"], "resource does not exist") + } +} + +// --- UI serving --- + +func TestUIIsServedFromDisk(t *testing.T) { + engine, err := newTestWebServer(t, WebServerConfig{UIPath: uiDir(t)}) + if err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + t.Run("static asset", func(t *testing.T) { + status, body := doGet(t, engine, "/asset.txt") + if status != http.StatusOK || body != "asset" { + t.Errorf("status = %d, body = %q; want the asset", status, body) + } + }) + + t.Run("unknown path falls back to the SPA index", func(t *testing.T) { + // Client-side routes have no file behind them; serving index.html is + // what lets a deep link load rather than 404. + status, body := doGet(t, engine, "/devices/some-id") + if status != http.StatusOK || !strings.Contains(body, "ui") { + t.Errorf("status = %d, body = %q; want index.html", status, body) + } + }) +} + +func TestUIIsServedFromEmbeddedFS(t *testing.T) { + engine, err := newTestWebServer(t, WebServerConfig{UIFS: &testUIFS}) + if err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + status, body := doGet(t, engine, "/asset.txt") + if status != http.StatusOK || !strings.Contains(body, "fixture asset") { + t.Errorf("status = %d, body = %q; want the embedded asset", status, body) + } + + status, body = doGet(t, engine, "/deep/link") + if status != http.StatusOK || !strings.Contains(body, "embedded ui fixture") { + t.Errorf("status = %d, body = %q; want the embedded index", status, body) + } +} + +// TestSetupRoutesRejectsNoUISource covers the one unusable-UI case that is +// reachable. The sibling branch -- static.EmbedFolder failing -- is not: it +// only errors on a path fs.ValidPath rejects, and the path passed is the +// hardcoded literal "static", so a missing directory yields an empty FS rather +// than an error. +func TestSetupRoutesRejectsNoUISource(t *testing.T) { + _, err := newTestWebServer(t, WebServerConfig{}) + if err == nil { + t.Fatal("SetupRoutes succeeded with no UI at all, want an error") + } + if !strings.Contains(err.Error(), "no embedded UI") { + t.Errorf("error = %v, want it to name the missing UI", err) + } +} + // TestDevModeProxiesNonAPIPaths covers the branch taken by -ui-dev: everything // that is not /api goes to the vite dev server instead of to disk. // @@ -116,14 +260,87 @@ func TestDevModeProxiesNonAPIPaths(t *testing.T) { } } -// TestSetupRoutesRejectsNoUISource covers the other way SetupRoutes can fail, -// so a change to it cannot pass on the dev-mode path alone. -func TestSetupRoutesRejectsNoUISource(t *testing.T) { - _, err := newTestWebServer(t, WebServerConfig{}) +// --- Route wiring --- + +// TestSessionRoutesAreRegisteredUnauthenticated covers why session endpoints +// get their own group: they are how a browser obtains a credential, so gating +// them behind the credential would lock the UI out permanently. +func TestSessionRoutesAreRegisteredUnauthenticated(t *testing.T) { + auth := &sessionAuth{allowingAuth: allowingAuth{allow: false}} + engine, err := newTestWebServer(t, WebServerConfig{ + UIPath: uiDir(t), + AuthMiddleware: auth, + SetupAPIRoutes: func(group *gin.RouterGroup) { + group.GET("/guarded", func(c *gin.Context) { c.Status(http.StatusOK) }) + }, + }) + if err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + if auth.setupCalls.Load() != 1 { + t.Errorf("SetupSessionRoutes called %d times, want 1", auth.setupCalls.Load()) + } + + if status, body := doGet(t, engine, "/api/auth/me"); status != http.StatusOK { + t.Errorf("session route status = %d, want 200 (body %s)", status, body) + } + if status, _ := doGet(t, engine, "/api/guarded"); status != http.StatusUnauthorized { + t.Errorf("guarded route status = %d, want 401", status) + } +} + +func TestAPIRoutesAreGuardedByAuth(t *testing.T) { + auth := &allowingAuth{allow: true} + engine, err := newTestWebServer(t, WebServerConfig{ + UIPath: uiDir(t), + AuthMiddleware: auth, + SetupAPIRoutes: func(group *gin.RouterGroup) { + group.GET("/thing", func(c *gin.Context) { c.Status(http.StatusOK) }) + }, + }) + if err != nil { + t.Fatalf("SetupRoutes: %v", err) + } + + if status, _ := doGet(t, engine, "/api/thing"); status != http.StatusOK { + t.Errorf("status = %d, want 200", status) + } + if auth.handlerCalls.Load() != 1 { + t.Errorf("auth middleware ran %d times, want 1", auth.handlerCalls.Load()) + } + + // The UI is public: gating it would mean the login form itself never loads. + if status, _ := doGet(t, engine, "/"); status != http.StatusOK { + t.Errorf("ui status = %d, want 200", status) + } + if auth.handlerCalls.Load() != 1 { + t.Error("auth middleware ran for a UI request") + } +} + +func TestNewWebServerPropagatesSetupErrors(t *testing.T) { + // A WebServer with no UI source cannot install its routes, and that has to + // surface as a constructor error rather than a server that 404s everything. + _, err := NewWebServer(context.Background(), testLogger(), WebServerConfig{ + Address: "127.0.0.1:0", + SetupAPIRoutes: func(*gin.RouterGroup) {}, + }) if err == nil { - t.Fatal("SetupRoutes succeeded with no UI at all, want an error") + t.Fatal("NewWebServer succeeded with no UI source, want an error") } - if !strings.Contains(err.Error(), "no embedded UI") { - t.Errorf("error = %v, want it to name the missing UI", err) +} + +func TestNewWebServerBuildsAServer(t *testing.T) { + server, err := NewWebServer(context.Background(), testLogger(), WebServerConfig{ + Address: "127.0.0.1:0", + UIPath: uiDir(t), + SetupAPIRoutes: func(*gin.RouterGroup) {}, + }) + if err != nil { + t.Fatalf("NewWebServer: %v", err) + } + if server.HTTPServer == nil { + t.Error("WebServer has no underlying HTTPServer") } }