package web import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "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" ) const testHubToken = "test-hub-token" // newHubTestApp builds a server with a real store and explicit Hub/Apprise // configuration. func newHubTestApp(t *testing.T, hubToken, appriseBase string) (*Server, *store.Store) { 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) } cfg := &config.Config{ AdminUsername: "sam", Version: "test", AppEnv: "test", DBPath: ":memory:", ScheduleLocation: time.UTC, HubToken: hubToken, AppriseURL: appriseBase, AppriseURLs: []string{"mailto:sam@example.com"}, } srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) if err != nil { t.Fatalf("web.New: %v", err) } return srv, st } func hubRequest(t *testing.T, h http.Handler, 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() h.ServeHTTP(rec, req) return rec } func TestCapabilitiesRequiresToken(t *testing.T) { srv, _ := newHubTestApp(t, testHubToken, "") rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "") if rec.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated status = %d, want 401", rec.Code) } rec = hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", testHubToken) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) } var body struct { Project string `json:"project"` Topics []string `json:"topics"` People []string `json:"people"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if body.Project != "internet_use_control" { t.Errorf("project = %q", body.Project) } if len(body.Topics) == 0 { t.Errorf("topics empty") } } func TestAskRequiresTokenAndAnswers(t *testing.T) { srv, st := newHubTestApp(t, testHubToken, "") ctx := context.Background() dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test") if err != nil { t.Fatalf("CreateDevice: %v", err) } today := time.Now().In(time.UTC).Format("2006-01-02") if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{ {App: "com.spotify.music", Seconds: 3600}, }); err != nil { t.Fatalf("InsertTelemetry: %v", err) } // No token: refused. rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask", `{"question":"how long have the kids been on screens today?","asker":"sam"}`, "") if rec.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated status = %d, want 401", rec.Code) } body := `{"question":"how long have the kids been on screens today?","asker":"sam"}` rec = hubRequest(t, srv.Handler(), http.MethodPost, "/ask", body, testHubToken) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) } var resp struct { Answer *string `json:"answer"` Data struct { TotalSeconds int `json:"total_seconds"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Answer == nil || !strings.Contains(*resp.Answer, "1h 00m") { t.Fatalf("answer = %v, want 1h 00m", resp.Answer) } if resp.Data.TotalSeconds != 3600 { t.Errorf("total_seconds = %d, want 3600", resp.Data.TotalSeconds) } } func TestAskWritesNothing(t *testing.T) { srv, st := newHubTestApp(t, testHubToken, "") ctx := context.Background() dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test") if err != nil { t.Fatalf("CreateDevice: %v", err) } today := time.Now().In(time.UTC).Format("2006-01-02") if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{{App: "youtube", Seconds: 600}}); err != nil { t.Fatalf("InsertTelemetry: %v", err) } countRows := func() int { var n int if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry`).Scan(&n); err != nil { t.Fatalf("count telemetry: %v", err) } return n } var auditBefore int if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditBefore); err != nil { t.Fatalf("count audit: %v", err) } questions := []string{ `{"question":"how long have the kids been on screens today?","asker":"sam"}`, `{"question":"what is blocked?","asker":"sam"}`, `{"question":"are the devices online?","asker":"sam"}`, `{"question":"what is the weather?","asker":"sam"}`, } for _, q := range questions { rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask", q, testHubToken) if rec.Code != http.StatusOK { t.Fatalf("ask status = %d (%s)", rec.Code, rec.Body.String()) } } if got := countRows(); got != 1 { t.Errorf("telemetry rows = %d, want 1 (ask must not write)", got) } var auditAfter int if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditAfter); err != nil { t.Fatalf("count audit: %v", err) } if auditAfter != auditBefore { t.Errorf("audit rows changed from %d to %d; ask must not write", auditBefore, auditAfter) } } func TestNotifyDeliversThroughApprise(t *testing.T) { var gotPath string var gotBody map[string]any stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path raw, _ := io.ReadAll(r.Body) _ = json.Unmarshal(raw, &gotBody) w.WriteHeader(http.StatusOK) })) defer stub.Close() srv, st := newHubTestApp(t, testHubToken, stub.URL) rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify", `{"from":"internet_use_control","level":"important","message":"3 emails need a decision.","person":"sam"}`, testHubToken) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) } if gotPath != "/notify" { t.Errorf("apprise path = %q, want /notify", gotPath) } if gotBody["body"] != "3 emails need a decision." { t.Errorf("apprise body = %#v", gotBody) } if gotBody["type"] != "warning" { t.Errorf("apprise type = %v, want warning", gotBody["type"]) } alerts, err := st.ListAlerts(context.Background(), 10) if err != nil { t.Fatalf("ListAlerts: %v", err) } if len(alerts) != 1 || alerts[0].Kind != store.AlertNotify || !alerts[0].Delivered { t.Fatalf("alerts = %#v, want one delivered notify", alerts) } } func TestNotifyValidatesInput(t *testing.T) { srv, _ := newHubTestApp(t, testHubToken, "") cases := []string{ `{"from":"x","level":"loud","message":"hi"}`, `{"from":"x","level":"info","message":""}`, `not json`, } for _, body := range cases { rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify", body, testHubToken) if rec.Code != http.StatusBadRequest { t.Errorf("body %q: status = %d, want 400", body, rec.Code) } } } func TestHubAPINotConfigured(t *testing.T) { srv, _ := newHubTestApp(t, "", "") rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503 when no Hub token is set", rec.Code) } }