package web import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "time" "golang.org/x/crypto/bcrypt" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // fakePublisher records the retained config publishes an API call makes, so a // test can prove a state change reaches the broker before the response. type fakePublisher struct { mu sync.Mutex topics []string } func (f *fakePublisher) Broker() string { return "fake" } func (f *fakePublisher) Connected() bool { return true } func (f *fakePublisher) Stats() (int64, int64, time.Time, string) { return 0, 0, time.Time{}, "" } func (f *fakePublisher) PublishDeviceConfig(_ context.Context, dev store.Device) error { f.mu.Lock() defer f.mu.Unlock() f.topics = append(f.topics, dev.Topic) return nil } func (f *fakePublisher) PublishAll(context.Context) (int, error) { return 0, nil } func (f *fakePublisher) published() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.topics...) } type apiTestApp struct { handler http.Handler store *store.Store pub *fakePublisher token string } func newAPITestApp(t *testing.T, token, keycode string) *apiTestApp { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "control.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) if err := st.Migrate(context.Background()); err != nil { t.Fatalf("Migrate: %v", err) } am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2") if err != nil { t.Fatalf("auth.New: %v", err) } pub := &fakePublisher{} cfg := &config.Config{ AdminUsername: "sam", Version: "test", AppEnv: "test", DBPath: ":memory:", AdminAPIToken: token, ScheduleLocation: time.UTC, } srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), pub) if err != nil { t.Fatalf("web.New: %v", err) } if keycode != "" { hash, err := bcrypt.GenerateFromPassword([]byte(keycode), bcrypt.MinCost) if err != nil { t.Fatalf("bcrypt: %v", err) } if err := st.SetKeycodeHash(context.Background(), string(hash), "test"); err != nil { t.Fatalf("SetKeycodeHash: %v", err) } } return &apiTestApp{handler: srv.Handler(), store: st, pub: pub, token: token} } func (a *apiTestApp) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder { t.Helper() var reader io.Reader if body != "" { reader = strings.NewReader(body) } req := httptest.NewRequest(method, path, reader) if body != "" { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) return rec } func decodeAPIResponse(t *testing.T, rec *httptest.ResponseRecorder) apiResponse { t.Helper() var resp apiResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response %q: %v", rec.Body.String(), err) } return resp } func mustCreateDevice(t *testing.T, st *store.Store, name string) store.Device { t.Helper() dev, err := st.CreateDevice(context.Background(), name, "Harry", "android", "test") if err != nil { t.Fatalf("CreateDevice: %v", err) } return *dev } func TestAdminAPIAuth(t *testing.T) { app := newAPITestApp(t, "s3cret", "") dev := mustCreateDevice(t, app.store, "Harry Phone") if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", ""); rec.Code != http.StatusUnauthorized { t.Errorf("no token = %d, want 401", rec.Code) } if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "wrong"); rec.Code != http.StatusUnauthorized { t.Errorf("wrong token = %d, want 401", rec.Code) } if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret"); rec.Code != http.StatusOK { t.Errorf("good token = %d, want 200", rec.Code) } // The rejection is audited. audit, err := app.store.ListAudit(context.Background(), 10) if err != nil { t.Fatalf("ListAudit: %v", err) } var rejected int for _, e := range audit { if e.Action == "admin.token_rejected" { rejected++ } } if rejected != 2 { t.Errorf("admin.token_rejected = %d, want 2", rejected) } // The token is separate from the session login: a session cookie does not // authorise the API and the API token does not open the dashboard. if rec := app.do(t, http.MethodGet, "/", "", ""); rec.Code != http.StatusSeeOther { t.Errorf("dashboard without session = %d, want 303", rec.Code) } _ = dev } func TestAdminAPIUnconfigured(t *testing.T) { app := newAPITestApp(t, "", "") if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "anything"); rec.Code != http.StatusServiceUnavailable { t.Errorf("unconfigured admin API = %d, want 503", rec.Code) } } func TestAdminAPILockUnlockGrant(t *testing.T) { app := newAPITestApp(t, "s3cret", "") dev := mustCreateDevice(t, app.store, "Harry Phone") // Lock for 30 minutes. rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/lock", `{"minutes":30}`, "s3cret") if rec.Code != http.StatusOK { t.Fatalf("lock = %d, want 200; body=%s", rec.Code, rec.Body.String()) } resp := decodeAPIResponse(t, rec) if !resp.OK || resp.Action != "lock" || resp.Device == nil { t.Fatalf("lock response = %+v", resp) } if !resp.Device.Locked || resp.Device.Reason != "manual" || resp.Device.Until == nil { t.Errorf("locked device = %+v", resp.Device) } if got := app.pub.published(); len(got) != 1 || got[0] != dev.Topic { t.Errorf("published = %v, want the device config topic", got) } // Grant time (unlocked, with an end). rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", `{"minutes":15}`, "s3cret") if rec.Code != http.StatusOK { t.Fatalf("grant = %d, want 200; body=%s", rec.Code, rec.Body.String()) } resp = decodeAPIResponse(t, rec) if resp.Device == nil || resp.Device.Locked || resp.Device.Reason != "grant" || resp.Device.GrantUntil == nil { t.Fatalf("granted device = %+v", resp.Device) } // Unlock clears the grant. rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/unlock", "", "s3cret") if rec.Code != http.StatusOK { t.Fatalf("unlock = %d, want 200; body=%s", rec.Code, rec.Body.String()) } resp = decodeAPIResponse(t, rec) if resp.Device == nil || resp.Device.Locked || resp.Device.GrantUntil != nil { t.Fatalf("unlocked device = %+v", resp.Device) } // Query one device and the list. if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices/1", "", "s3cret"); rec.Code != http.StatusOK { t.Errorf("state = %d, want 200", rec.Code) } rec = app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret") if rec.Code != http.StatusOK { t.Fatalf("list = %d, want 200", rec.Code) } if resp = decodeAPIResponse(t, rec); len(resp.Devices) != 1 || resp.Devices[0].Name != "Harry Phone" { t.Errorf("device list = %+v", resp.Devices) } // Every mutation is in the override history and the audit log. overrides, err := app.store.ListOverrides(context.Background(), 1, 10) if err != nil { t.Fatalf("ListOverrides: %v", err) } if len(overrides) != 3 { t.Errorf("overrides = %d, want 3 (lock, grant, unlock)", len(overrides)) } audit, _ := app.store.ListAudit(context.Background(), 20) var stateChanges int for _, e := range audit { switch e.Action { case "state.locked", "override.granted", "state.unlocked": stateChanges++ } } if stateChanges != 3 { t.Errorf("audited state changes = %d, want 3", stateChanges) } // A publish happens for every mutation. if got := app.pub.published(); len(got) != 3 { t.Errorf("publishes = %v, want 3", got) } } func TestAdminAPIBadRequests(t *testing.T) { app := newAPITestApp(t, "s3cret", "") mustCreateDevice(t, app.store, "Harry Phone") cases := []struct { name string body string code int }{ {"unknown field JSON is rejected", `not json`, http.StatusBadRequest}, {"negative minutes", `{"minutes":-5}`, http.StatusBadRequest}, {"past until", `{"until":"2000-01-01T00:00:00Z"}`, http.StatusBadRequest}, {"both minutes and until", `{"minutes":5,"until":"2999-01-01T00:00:00Z"}`, http.StatusBadRequest}, {"grant with no time", `{}`, http.StatusBadRequest}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", tc.body, "s3cret") if rec.Code != tc.code { t.Errorf("grant %q = %d, want %d; body=%s", tc.body, rec.Code, tc.code, rec.Body.String()) } }) } if rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/999/lock", `{}`, "s3cret"); rec.Code != http.StatusNotFound { t.Errorf("lock unknown device = %d, want 404", rec.Code) } } func TestDeviceKeycodeOverride(t *testing.T) { app := newAPITestApp(t, "s3cret", "246810") mustCreateDevice(t, app.store, "Harry Phone") // A wrong code is refused and audited. rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"000000","minutes":10}`, "") if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong keycode = %d, want 401; body=%s", rec.Code, rec.Body.String()) } // The right code grants time without the admin token. rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":10}`, "") if rec.Code != http.StatusOK { t.Fatalf("keycode = %d, want 200; body=%s", rec.Code, rec.Body.String()) } resp := decodeAPIResponse(t, rec) if !resp.OK || resp.Action != "keycode" || resp.Device == nil || resp.Device.Reason != "grant" { t.Fatalf("keycode response = %+v", resp) } // A keycode with no duration still grants a short, fixed exemption. rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810"}`, "") if rec.Code != http.StatusOK { t.Fatalf("keycode without duration = %d, want 200", rec.Code) } if resp = decodeAPIResponse(t, rec); resp.Device == nil || resp.Device.GrantUntil == nil { t.Fatalf("default grant = %+v", resp.Device) } } func TestDeviceKeycodeThrottle(t *testing.T) { app := newAPITestApp(t, "s3cret", "246810") mustCreateDevice(t, app.store, "Harry Phone") for i := 0; i < 5; i++ { rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"wrong"}`, "") if rec.Code != http.StatusUnauthorized { t.Fatalf("attempt %d = %d, want 401", i+1, rec.Code) } } // The sixth attempt is throttled even with the right code. rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":5}`, "") if rec.Code != http.StatusTooManyRequests { t.Fatalf("after five failures = %d, want 429; body=%s", rec.Code, rec.Body.String()) } } func TestDeviceKeycodeUnconfigured(t *testing.T) { app := newAPITestApp(t, "s3cret", "") mustCreateDevice(t, app.store, "Harry Phone") rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"1234"}`, "") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("unconfigured keycode = %d, want 503", rec.Code) } }