Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)
Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11: the shared device keycode and the versioned admin HTTP API the separate Kotlin admin app will call. - store: migration 004 adds state.grant_until and a settings table. New overrides.go records every override in `overrides` and verifies the shared keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also clears an expired grant, and NextScheduleBoundary includes the grant end so a device re-locks on time. - effective state: a grant unlocks a device inside a schedule window; a manual lock clears a grant and a grant clears a manual lock. The retained config payload gains grant_until (additive, v stays 1). - web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device keycode endpoint POST /api/v1/device/override with per-address throttling. A state change is published retained before the API responds. - cmd/control: `keycode` subcommand and start-up seeding from CONTROL_DEVICE_KEYCODE_HASH. - deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done: an API lock reaches a real broker subscriber in well under a second. - Version 0.6.0-phase8 (compose, Makefile, docs). Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler 1 ms, request to retained publish accepted). Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
337
internal/web/admin_api_test.go
Normal file
337
internal/web/admin_api_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user