From d025c6b8ecfedd60f52b0a4aab1cfdd1fff7198f Mon Sep 17 00:00:00 2001 From: Chris Behrens Date: Sun, 30 Aug 2026 05:03:25 +0000 Subject: [PATCH] Test the API handler and the server layer Both packages leaned on the apps/rotom-ng integration tests for what coverage they had: libs/handlers 55%, libs/services 79%. This takes them to 91% and 96%. APIHandler is generic over its controller and worker types, so the tests instantiate it with fakes and drive its HTTP surface against a real ConnectionManager, selector, and jobs manager. That covers the device and controller action error mapping, the jobs endpoints, and pprof gating, none of which were exercised before. For the server layer: UI serving from both a directory and an embedded FS, session routes staying reachable without a credential, /api guarded while the UI is not, listener-versus-address serving, bind failure, route-installer errors, and shutdown timing out on an in-flight request. libs/services/static is a test fixture rather than a shipped asset. The embedded UI is served from a directory of that name at the root of the FS, so covering that path needs one in the package; its embed directive lives in the test file and never reaches a real build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AXB8suZYfpYZsSaoEps67m --- libs/handlers/api_fakes_test.go | 190 ++++++++ libs/handlers/api_handler_test.go | 752 +++++++++++++++++++++++++++++ libs/handlers/base_handler_test.go | 91 ++++ libs/services/http_server_test.go | 355 ++++++++++++++ libs/services/static/asset.txt | 1 + libs/services/static/index.html | 8 + libs/services/web_server_test.go | 231 ++++++++- 7 files changed, 1621 insertions(+), 7 deletions(-) create mode 100644 libs/handlers/api_fakes_test.go create mode 100644 libs/handlers/api_handler_test.go create mode 100644 libs/handlers/base_handler_test.go create mode 100644 libs/services/http_server_test.go create mode 100644 libs/services/static/asset.txt create mode 100644 libs/services/static/index.html 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") } }