Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:
GET /capabilities the routing topics the Hub reads, project "compass_nhs"
POST /ask one short, read-only answer about a child's school work
POST /notify accept the contract's notification shape, deliver via Apprise
All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".
/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.
Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.
/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.
Live proof on .13 (deploy/verify-phase8.sh):
/health 200, ok true
/capabilities 200, project compass_nhs; unsigned 401
/ask 200, the sentence above, duration 1ms
/notify 400 on a bad level (auth proven, nothing sent), then 200 with
a real delivery to Sam on email and WhatsApp
The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
257
internal/web/hub_api.go
Normal file
257
internal/web/hub_api.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// The Hub contract surface (AGENTS.md §4 FR-11, §8, §10 rule 5, §11 Phase 8).
|
||||
//
|
||||
// The project side of central_ai_llm_master/docs/ask-contract.md:
|
||||
//
|
||||
// GET /capabilities -> the topics this service answers (Hub routing)
|
||||
// POST /ask -> one short, read-only answer about school work
|
||||
// POST /notify -> accept the contract's notification and deliver it
|
||||
//
|
||||
// All three require the shared Hub bearer token (SCHOOL_HUB_TOKEN). A
|
||||
// service-to-service call must never be trusted unsigned. /ask and
|
||||
// /capabilities are read-only: they call only store read methods. /notify is
|
||||
// the one write on this surface, and it only records and delivers a message;
|
||||
// it never changes school data and never touches the school (AGENTS.md §10
|
||||
// rule 1).
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/hub"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// hubMaxBody bounds a Hub request body. Questions and notifications are small.
|
||||
const hubMaxBody = 64 << 10 // 64 KiB
|
||||
|
||||
// notifyResponse is this service's own reply to POST /notify. The contract does
|
||||
// not freeze a response shape for the receiver, so this is kept minimal and
|
||||
// matches the sibling project.
|
||||
type notifyResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// hubAuthOK enforces the shared bearer token on the Hub endpoints. It writes
|
||||
// the refusal and returns false when the request may not be served. askShape
|
||||
// selects the contract-shaped body for /ask; the other endpoints get the
|
||||
// minimal error body.
|
||||
func (s *Server) hubAuthOK(w http.ResponseWriter, r *http.Request, askShape bool) bool {
|
||||
refuse := func(code int, reason string) {
|
||||
if askShape {
|
||||
s.writeJSON(w, code, hub.AskResponse{Answer: nil, Reason: reason})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, code, notifyResponse{Error: reason})
|
||||
}
|
||||
if !s.cfg.HubConfigured() {
|
||||
s.log.Warn("hub api disabled: SCHOOL_HUB_TOKEN is not set", "path", r.URL.Path)
|
||||
refuse(http.StatusServiceUnavailable, "the Hub API is not configured")
|
||||
return false
|
||||
}
|
||||
provided := bearerTokenOf(r)
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(strings.TrimSpace(s.cfg.HubToken))) != 1 {
|
||||
s.log.Warn("hub api rejected", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="compass-nhs"`)
|
||||
refuse(http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleCapabilities returns the routing topics for the Hub. It holds no
|
||||
// private data.
|
||||
func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.hubAuthOK(w, r, false) {
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, hub.DefaultCapabilities())
|
||||
}
|
||||
|
||||
// handleAsk answers one question from this project's read-only data. The reply
|
||||
// is always HTTP 200 with either an answer or a reason, so the Hub can relay a
|
||||
// "cannot answer" without treating the project as down.
|
||||
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.hubAuthOK(w, r, true) {
|
||||
return
|
||||
}
|
||||
// The contract caps the answer at 3 seconds; the timeout leaves room for the
|
||||
// write rather than cutting off mid-reply.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var req hub.AskRequest
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
s.writeJSON(w, http.StatusOK, hub.AskResponse{
|
||||
Answer: nil,
|
||||
Reason: "the request body is not valid JSON",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp := s.answerer.Answer(ctx, req)
|
||||
s.log.Info("hub ask",
|
||||
"asker", req.Asker,
|
||||
"person", req.Person,
|
||||
"source", req.Source,
|
||||
"question", req.Question,
|
||||
"answered", resp.Answer != nil,
|
||||
"reason", resp.Reason,
|
||||
"duration_ms", time.Since(started).Milliseconds(),
|
||||
)
|
||||
s.writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleNotify accepts a notification in the frozen contract's shape and
|
||||
// delivers it to the named person through Apprise. The message is recorded
|
||||
// before delivery, so a delivery failure is visible on the dashboard and in
|
||||
// `notifications` (FR-12).
|
||||
func (s *Server) handleNotify(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.hubAuthOK(w, r, false) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var req hub.NotifyRequest
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(req.From)
|
||||
if from == "" {
|
||||
from = hub.ProjectName
|
||||
}
|
||||
level := strings.ToLower(strings.TrimSpace(req.Level))
|
||||
switch level {
|
||||
case "":
|
||||
level = "info"
|
||||
case "info", "important", "urgent":
|
||||
default:
|
||||
s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: "level must be info, important or urgent"})
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(req.Message)
|
||||
if message == "" {
|
||||
s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: "message is required"})
|
||||
return
|
||||
}
|
||||
if len(message) > 500 {
|
||||
message = message[:500]
|
||||
}
|
||||
|
||||
recipient, err := s.notifyRecipient(req.Person)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !s.cfg.Apprise.Enabled() {
|
||||
s.writeJSON(w, http.StatusServiceUnavailable, notifyResponse{
|
||||
Error: "apprise is not configured: set " + strings.Join(s.cfg.Apprise.Missing(), ", "),
|
||||
})
|
||||
return
|
||||
}
|
||||
if s.apprise == nil || !s.apprise.Configured() {
|
||||
s.writeJSON(w, http.StatusServiceUnavailable, notifyResponse{Error: "apprise is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
title := "Compass / NHS — " + from
|
||||
channels := s.cfg.Apprise.Channels
|
||||
if len(channels) == 0 {
|
||||
channels = []string{config.ChannelEmail}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
failed := 0
|
||||
for _, ch := range channels {
|
||||
tag := s.cfg.Apprise.ChannelTag(recipient, ch)
|
||||
res, sendErr := s.apprise.SendTags(ctx, []string{tag}, title, message, notifyType(level))
|
||||
n := store.Notification{
|
||||
Scope: "hub",
|
||||
Recipient: recipient.Name,
|
||||
Channel: ch,
|
||||
Tag: tag,
|
||||
Text: message,
|
||||
OK: sendErr == nil,
|
||||
}
|
||||
if sendErr != nil {
|
||||
n.Error = apprise.Truncate(sendErr.Error(), 300)
|
||||
failed++
|
||||
} else {
|
||||
sent := now
|
||||
n.SentAt = &sent
|
||||
}
|
||||
if _, recErr := s.store.RecordNotification(ctx, n); recErr != nil {
|
||||
s.log.Error("hub notify record", "recipient", recipient.Name, "err", recErr)
|
||||
}
|
||||
if sendErr != nil {
|
||||
s.log.Error("hub notify NOT delivered", "recipient", recipient.Name, "channel", ch, "tag", tag,
|
||||
"status", res.Status, "err", n.Error)
|
||||
} else {
|
||||
s.log.Info("hub notify delivered", "from", from, "level", level, "recipient", recipient.Name,
|
||||
"channel", ch, "tag", tag, "status", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
s.writeJSON(w, http.StatusBadGateway, notifyResponse{Error: fmt.Sprintf("delivery failed on %d channel(s)", failed)})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, notifyResponse{OK: true})
|
||||
}
|
||||
|
||||
// notifyRecipient resolves the contract's `person` to a configured Apprise
|
||||
// recipient. An empty person defaults to Sam, the service's owner; an unknown
|
||||
// person is refused rather than delivered to the wrong inbox.
|
||||
func (s *Server) notifyRecipient(person string) (config.Recipient, error) {
|
||||
person = strings.TrimSpace(person)
|
||||
if person == "" {
|
||||
if r, ok := s.cfg.Apprise.RecipientByName("Sam"); ok {
|
||||
return r, nil
|
||||
}
|
||||
if parents := s.cfg.Apprise.ParentRecipients(); len(parents) > 0 {
|
||||
return parents[0], nil
|
||||
}
|
||||
return config.Recipient{}, fmt.Errorf("no recipient is configured")
|
||||
}
|
||||
r, ok := s.cfg.Apprise.RecipientByName(person)
|
||||
if !ok {
|
||||
return config.Recipient{}, fmt.Errorf("unknown person %q", person)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// notifyType maps the contract's level to an Apprise notification class.
|
||||
func notifyType(level string) string {
|
||||
switch level {
|
||||
case "important":
|
||||
return apprise.TypeWarning
|
||||
case "urgent":
|
||||
return apprise.TypeFailure
|
||||
default:
|
||||
return apprise.TypeInfo
|
||||
}
|
||||
}
|
||||
|
||||
// bearerTokenOf reads the bearer token from the Authorization header,
|
||||
// case-insensitively.
|
||||
func bearerTokenOf(r *http.Request) string {
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
290
internal/web/hub_api_test.go
Normal file
290
internal/web/hub_api_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
const testHubToken = "hub-test-token-0123456789abcdef"
|
||||
|
||||
// hubJSON posts a JSON body to a Hub endpoint with the given bearer token.
|
||||
func hubJSON(t *testing.T, ts *httptest.Server, method, path, token, body string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var rdr *bytes.Reader
|
||||
if body == "" {
|
||||
rdr = bytes.NewReader(nil)
|
||||
} else {
|
||||
rdr = bytes.NewReader([]byte(body))
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw := readAll(t, resp)
|
||||
return resp, raw
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// addHubItem stores one item so /ask has real rows to read.
|
||||
func addHubItem(t *testing.T, st *store.Store, childID int64, it store.Item) {
|
||||
t.Helper()
|
||||
it.ChildID = childID
|
||||
if it.Source == "" {
|
||||
it.Source = "compass"
|
||||
}
|
||||
if it.ExternalRef == "" {
|
||||
it.ExternalRef = fmt.Sprintf("hub-test-%d", time.Now().UnixNano())
|
||||
}
|
||||
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{it}); err != nil {
|
||||
t.Fatalf("upsert item %q: %v", it.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, st *store.Store, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestHubCapabilitiesAndAsk covers the two read-only endpoints: the routing
|
||||
// topics the Hub reads, and a correct answer to the phase's question (FR-11,
|
||||
// §11 Phase 8).
|
||||
func TestHubCapabilitiesAndAsk(t *testing.T) {
|
||||
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
|
||||
loc := mustLoc(t)
|
||||
ctx := context.Background()
|
||||
harry, err := st.ChildByName(ctx, "Harry")
|
||||
if err != nil || harry == nil {
|
||||
t.Fatalf("harry: %v %v", harry, err)
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
|
||||
addHubItem(t, st, harry.ID, store.Item{
|
||||
Type: "event", Subject: "11XMUSA6", Teacher: "JBR",
|
||||
Title: "1 - 11XMUSA6 - A1 - JBR", DueAt: &today,
|
||||
})
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 5, 1, true, ""); err != nil {
|
||||
t.Fatalf("record scan: %v", err)
|
||||
}
|
||||
|
||||
// Capabilities: the Hub reads this to route.
|
||||
resp, raw := hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("capabilities status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var caps struct {
|
||||
Project string `json:"project"`
|
||||
Topics []string `json:"topics"`
|
||||
People []string `json:"people"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &caps); err != nil {
|
||||
t.Fatalf("capabilities json: %v (%s)", err, raw)
|
||||
}
|
||||
if caps.Project != "compass_nhs" {
|
||||
t.Errorf("project = %q, want compass_nhs", caps.Project)
|
||||
}
|
||||
if len(caps.Topics) == 0 || len(caps.People) == 0 {
|
||||
t.Errorf("capabilities = %+v, want topics and people", caps)
|
||||
}
|
||||
|
||||
// The phase's question.
|
||||
resp, raw = hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
|
||||
`{"question":"what does Harry have on today at school?","asker":"sam","person":"harry","source":"esp32"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ask status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var ask struct {
|
||||
Answer *string `json:"answer"`
|
||||
Reason string `json:"reason"`
|
||||
Expires string `json:"expires"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ask); err != nil {
|
||||
t.Fatalf("ask json: %v (%s)", err, raw)
|
||||
}
|
||||
if ask.Answer == nil {
|
||||
t.Fatalf("answer = nil, reason = %q", ask.Reason)
|
||||
}
|
||||
if !strings.Contains(*ask.Answer, "1 school event today") {
|
||||
t.Errorf("answer = %q, want today's event", *ask.Answer)
|
||||
}
|
||||
if ask.Expires == "" {
|
||||
t.Error("expires is empty, want end of day")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubAskReadsOnly is the contract's first rule made testable: /ask and
|
||||
// /capabilities must not write anything (AGENTS.md §10 rule 5).
|
||||
func TestHubAskReadsOnly(t *testing.T) {
|
||||
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
now := time.Now()
|
||||
addHubItem(t, st, harry.ID, store.Item{Type: "task", Title: "SAC 1", DueAt: &now, Body: "Status: Pending"})
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
|
||||
t.Fatalf("record scan: %v", err)
|
||||
}
|
||||
|
||||
itemsBefore := countRows(t, st, "items")
|
||||
scansBefore := countRows(t, st, "scans")
|
||||
notifyBefore := countRows(t, st, "notifications")
|
||||
|
||||
hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
|
||||
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
|
||||
`{"question":"what does Harry have on today?","asker":"sam","person":"harry"}`)
|
||||
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
|
||||
`{"question":"what is overdue for Finn?","asker":"sam","person":"finn"}`)
|
||||
|
||||
if after := countRows(t, st, "items"); after != itemsBefore {
|
||||
t.Errorf("items changed: %d -> %d, /ask must not write", itemsBefore, after)
|
||||
}
|
||||
if after := countRows(t, st, "scans"); after != scansBefore {
|
||||
t.Errorf("scans changed: %d -> %d, /ask must not write", scansBefore, after)
|
||||
}
|
||||
if after := countRows(t, st, "notifications"); after != notifyBefore {
|
||||
t.Errorf("notifications changed: %d -> %d, /ask must not write", notifyBefore, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubEndpointsNeedToken proves an unsigned request is never trusted, and
|
||||
// that an unconfigured token turns the API off rather than opening it.
|
||||
func TestHubEndpointsNeedToken(t *testing.T) {
|
||||
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
|
||||
|
||||
cases := []struct {
|
||||
method, path, body string
|
||||
}{
|
||||
{http.MethodGet, "/capabilities", ""},
|
||||
{http.MethodPost, "/ask", `{"question":"what does Harry have on today?","asker":"sam"}`},
|
||||
{http.MethodPost, "/notify", `{"from":"hub","level":"info","message":"hello"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, raw := hubJSON(t, ts, tc.method, tc.path, "", tc.body)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a token = %d, want 401 (body %s)", tc.method, tc.path, resp.StatusCode, raw)
|
||||
}
|
||||
resp, _ = hubJSON(t, ts, tc.method, tc.path, "wrong-token", tc.body)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s with a bad token = %d, want 401", tc.method, tc.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubDisabledWithoutToken proves an empty SCHOOL_HUB_TOKEN closes the API.
|
||||
func TestHubDisabledWithoutToken(t *testing.T) {
|
||||
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), "")
|
||||
resp, raw := hubJSON(t, ts, http.MethodPost, "/ask", "anything",
|
||||
`{"question":"what does Harry have on today?","asker":"sam"}`)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 (body %s)", resp.StatusCode, raw)
|
||||
}
|
||||
var ask struct {
|
||||
Answer *string `json:"answer"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ask); err != nil {
|
||||
t.Fatalf("json: %v (%s)", err, raw)
|
||||
}
|
||||
if ask.Answer != nil || ask.Reason == "" {
|
||||
t.Errorf("body = %s, want the contract's cannot-answer shape", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubNotifyDelivers covers POST /notify: the contract's shape is accepted
|
||||
// and the message is delivered through Apprise and recorded.
|
||||
func TestHubNotifyDelivers(t *testing.T) {
|
||||
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
|
||||
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
|
||||
|
||||
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
|
||||
`{"from":"hub","level":"important","message":"3 school messages need a decision.","person":"Sam"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("notify status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"ok":true`) {
|
||||
t.Errorf("body = %s, want ok true", raw)
|
||||
}
|
||||
if len(*tags) != 2 {
|
||||
t.Errorf("apprise tags = %#v, want one per channel", *tags)
|
||||
}
|
||||
rows, err := st.RecentNotifications(context.Background(), 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows = %d, %v", len(rows), err)
|
||||
}
|
||||
for _, n := range rows {
|
||||
if n.Scope != "hub" || n.Recipient != "Sam" || !n.OK || n.SentAt == nil {
|
||||
t.Errorf("row = %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubNotifyUnknownPerson refuses to deliver to a person who is not
|
||||
// configured, rather than guessing an inbox.
|
||||
func TestHubNotifyUnknownPerson(t *testing.T) {
|
||||
ts, _ := newTestServerHub(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""), testHubToken)
|
||||
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
|
||||
`{"from":"hub","level":"info","message":"hello","person":"nobody"}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body %s)", resp.StatusCode, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubNotifyFailsLoudly proves a delivery failure is reported and recorded,
|
||||
// never returned as a success (FR-12).
|
||||
func TestHubNotifyFailsLoudly(t *testing.T) {
|
||||
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
|
||||
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
|
||||
|
||||
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
|
||||
`{"from":"hub","level":"urgent","message":"blocked","person":"Sam"}`)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502 (body %s)", resp.StatusCode, raw)
|
||||
}
|
||||
rows, _ := st.RecentNotifications(context.Background(), 10)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("no rows recorded")
|
||||
}
|
||||
for _, n := range rows {
|
||||
if n.OK || n.SentAt != nil || n.Error == "" {
|
||||
t.Errorf("row = %+v, want a recorded failure", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustLoc(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
t.Fatalf("location: %v", err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/hub"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
@@ -36,12 +37,14 @@ var staticFS embed.FS
|
||||
|
||||
// Server holds the dependencies for the HTTP handlers.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
auth *auth.Manager
|
||||
tmpl *template.Template
|
||||
log *slog.Logger
|
||||
brief *brief.Service
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
auth *auth.Manager
|
||||
tmpl *template.Template
|
||||
log *slog.Logger
|
||||
brief *brief.Service
|
||||
answerer *hub.Answerer
|
||||
apprise *apprise.Client
|
||||
}
|
||||
|
||||
// New builds a Server and parses the embedded templates.
|
||||
@@ -57,7 +60,10 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
|
||||
}
|
||||
client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout)
|
||||
svc := brief.New(st, client, cfg.Apprise, cfg.Location, log)
|
||||
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc}, nil
|
||||
answerer := hub.NewAnswerer(st, cfg.Location)
|
||||
answerer.SetParents(cfg.Apprise.Parents...)
|
||||
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc,
|
||||
answerer: answerer, apprise: client}, nil
|
||||
}
|
||||
|
||||
// Handler returns the fully wrapped HTTP handler.
|
||||
@@ -83,6 +89,13 @@ func (s *Server) Handler() http.Handler {
|
||||
// machine-to-machine: a bearer token, not the dashboard session.
|
||||
mux.HandleFunc("POST /notify/morning", s.handleMorningNotify)
|
||||
|
||||
// The project side of the frozen Hub contract (AGENTS.md §4 FR-11, §11
|
||||
// Phase 8): GET /capabilities, read-only POST /ask, POST /notify. All three
|
||||
// need the shared Hub bearer token (SCHOOL_HUB_TOKEN).
|
||||
mux.HandleFunc("GET /capabilities", s.handleCapabilities)
|
||||
mux.HandleFunc("POST /ask", s.handleAsk)
|
||||
mux.HandleFunc("POST /notify", s.handleNotify)
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("web: embedded static dir missing: " + err.Error())
|
||||
@@ -156,24 +169,31 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
// ---------- health ----------
|
||||
|
||||
type healthResponse struct {
|
||||
// OK mirrors the frozen contract's GET /health shape ({"ok": true}) while
|
||||
// keeping the richer fields this service has always returned.
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Service string `json:"service"`
|
||||
Version string `json:"version"`
|
||||
Database string `json:"database"`
|
||||
Scans store.ScanStatus `json:"scans"`
|
||||
Hub bool `json:"hub_api"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
resp := healthResponse{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
Service: "compass-teams-nhs",
|
||||
Version: s.cfg.Version,
|
||||
Database: "ok",
|
||||
Hub: s.cfg.HubConfigured(),
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
code := http.StatusOK
|
||||
if err := s.store.Ping(r.Context()); err != nil {
|
||||
resp.OK = false
|
||||
resp.Status = "error"
|
||||
resp.Database = "error"
|
||||
code = http.StatusServiceUnavailable
|
||||
|
||||
@@ -46,8 +46,18 @@ func newTestServerNotify(t *testing.T, ap config.AppriseConfig) (*httptest.Serve
|
||||
return newTestServerFull(t, config.CalendarConfig{}, ap)
|
||||
}
|
||||
|
||||
// newTestServerHub is newTestServerNotify with the Hub bearer token set.
|
||||
func newTestServerHub(t *testing.T, ap config.AppriseConfig, hubToken string) (*httptest.Server, *store.Store) {
|
||||
return newTestServerAll(t, config.CalendarConfig{}, ap, hubToken)
|
||||
}
|
||||
|
||||
// newTestServerFull builds the handler with both integrations configured.
|
||||
func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
|
||||
return newTestServerAll(t, cal, ap, "")
|
||||
}
|
||||
|
||||
// newTestServerAll builds the handler with both integrations and the Hub token.
|
||||
func newTestServerAll(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig, hubToken string) (*httptest.Server, *store.Store) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
@@ -77,6 +87,7 @@ func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.Appris
|
||||
Location: loc,
|
||||
Calendar: cal,
|
||||
Apprise: ap,
|
||||
HubToken: hubToken,
|
||||
}
|
||||
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user