Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
|
||||
"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/store"
|
||||
@@ -64,6 +65,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
|
||||
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
|
||||
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
|
||||
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
|
||||
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
|
||||
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
|
||||
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
|
||||
@@ -282,6 +284,63 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
|
||||
// "nothing due" instead of "unknown".
|
||||
func (v scanStatusView) OK() bool { return v.Level == "ok" }
|
||||
|
||||
// ---------- fail-loudly calendar banner (FR-8, FR-12) ----------
|
||||
|
||||
// calendarBannerView is the fail-loudly view of the Home Assistant calendar
|
||||
// target. A missing target or a failed write is shown explicitly, so the
|
||||
// family never assumes an event was written when it was not.
|
||||
type calendarBannerView struct {
|
||||
// Level is "off", "warn", "error" or "ok".
|
||||
Level string
|
||||
Message string
|
||||
Entity string
|
||||
LastAt string
|
||||
Written int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// OK reports whether the calendar target is healthy.
|
||||
func (v calendarBannerView) OK() bool { return v.Level == "ok" }
|
||||
|
||||
func (s *Server) calendarBanner(ctx context.Context) calendarBannerView {
|
||||
cc := s.cfg.Calendar
|
||||
if !cc.Enabled() {
|
||||
return calendarBannerView{
|
||||
Level: "warn",
|
||||
Message: "CALENDAR NOT CONFIGURED — important school events are not being written. Set " + strings.Join(cc.Missing(), ", ") + ".",
|
||||
}
|
||||
}
|
||||
v := calendarBannerView{Entity: cc.EntityID}
|
||||
st, err := s.store.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("calendar push summary", "err", err)
|
||||
v.Level = "error"
|
||||
v.Message = "Could not read the calendar log: " + err.Error()
|
||||
return v
|
||||
}
|
||||
v.Written = st.Total - st.Failed
|
||||
v.Failed = st.Failed
|
||||
if st.Last != nil {
|
||||
v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
|
||||
}
|
||||
if st.Last != nil && !st.Last.OK {
|
||||
v.Level = "error"
|
||||
v.Message = "CALENDAR WRITE FAILED — the last write to " + cc.EntityID + " did not complete."
|
||||
if st.Last.Error != "" {
|
||||
v.Message += " " + st.Last.Error
|
||||
}
|
||||
return v
|
||||
}
|
||||
if !st.EverRun {
|
||||
v.Level = "warn"
|
||||
v.Message = "No calendar write has run yet for " + cc.EntityID + "."
|
||||
return v
|
||||
}
|
||||
v.Level = "ok"
|
||||
v.Message = "Last calendar write completed."
|
||||
return v
|
||||
}
|
||||
|
||||
// ---------- dashboard (FR-6, FR-7) ----------
|
||||
|
||||
type itemView struct {
|
||||
@@ -335,6 +394,7 @@ type dashboardData struct {
|
||||
Version string
|
||||
AppEnv string
|
||||
Scan scanStatusView
|
||||
Calendar calendarBannerView
|
||||
Tabs []childTab
|
||||
Child childPanel
|
||||
HasChild bool
|
||||
@@ -357,6 +417,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Scan: s.scanStatus(ctx),
|
||||
Calendar: s.calendarBanner(ctx),
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
|
||||
}
|
||||
@@ -510,54 +571,163 @@ func typeLabel(t string) string {
|
||||
// ---------- item detail + delete (FR-10, §5 screen 3) ----------
|
||||
|
||||
type itemData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Item itemView
|
||||
Child store.Child
|
||||
Scan scanStatusView
|
||||
ServerUp string
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Item itemView
|
||||
Child store.Child
|
||||
Scan scanStatusView
|
||||
Calendar calendarItemView
|
||||
Flash string
|
||||
FlashLevel string
|
||||
ServerUp string
|
||||
}
|
||||
|
||||
// calendarItemView is the calendar state of one item on the item screen.
|
||||
type calendarItemView struct {
|
||||
Configured bool
|
||||
Entity string
|
||||
Eligible bool
|
||||
InCalendar bool
|
||||
UID string
|
||||
PushedAt string
|
||||
}
|
||||
|
||||
// itemCalendarView describes whether one item can go to the calendar and
|
||||
// whether it is already there. An important, future Compass event is eligible.
|
||||
func (s *Server) itemCalendarView(it store.CalendarItem, now time.Time) calendarItemView {
|
||||
v := calendarItemView{
|
||||
Configured: s.cfg.Calendar.Enabled(),
|
||||
Entity: s.cfg.Calendar.EntityID,
|
||||
InCalendar: it.HAEventUID != "",
|
||||
UID: it.HAEventUID,
|
||||
}
|
||||
v.Eligible = it.Type == "event" && it.Source == "compass" && it.Importance == "high" &&
|
||||
it.DueAt != nil && it.DueAt.After(now)
|
||||
if it.HAPushedAt != nil {
|
||||
v.PushedAt = it.HAPushedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, ok := pathID(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it, err := s.store.ItemByID(ctx, id)
|
||||
s.renderItemDetail(w, r, id, "", "")
|
||||
}
|
||||
|
||||
// renderItemDetail renders the item screen, optionally with a calendar flash.
|
||||
func (s *Server) renderItemDetail(w http.ResponseWriter, r *http.Request, id int64, flash, flashLevel string) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ci, err := s.store.CalendarItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("item", "id", id, "err", err)
|
||||
http.Error(w, "could not read the item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if it == nil {
|
||||
if ci == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
child, err := s.store.ChildByID(ctx, it.ChildID)
|
||||
child, err := s.store.ChildByID(ctx, ci.ChildID)
|
||||
if err != nil || child == nil {
|
||||
child = &store.Child{ID: it.ChildID, Name: it.Child}
|
||||
child = &store.Child{ID: ci.ChildID, Name: ci.Child}
|
||||
}
|
||||
|
||||
s.render(w, r, "item.html", itemData{
|
||||
Title: it.Title,
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Item: itemViews([]store.Item{*it}, s.cfg.Location)[0],
|
||||
Child: *child,
|
||||
Scan: s.scanStatus(ctx),
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
Title: ci.Title,
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Item: itemViews([]store.Item{ci.Item}, s.cfg.Location)[0],
|
||||
Child: *child,
|
||||
Scan: s.scanStatus(ctx),
|
||||
Calendar: s.itemCalendarView(*ci, time.Now()),
|
||||
Flash: flash,
|
||||
FlashLevel: flashLevel,
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleItemCalendarPush writes one item to Sam's Home Assistant calendar. The
|
||||
// school stays read-only: the only write is to the calendar (AGENTS.md §10
|
||||
// rule 1). A failure is shown on the page and recorded in calendar_pushes.
|
||||
func (s *Server) handleItemCalendarPush(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
s.log.Warn("calendar push rejected: bad csrf token", "path", r.URL.Path)
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, ok := pathID(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ci, err := s.store.CalendarItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("item", "id", id, "err", err)
|
||||
http.Error(w, "could not read the item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ci == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if missing := s.cfg.Calendar.Missing(); len(missing) > 0 {
|
||||
s.renderItemDetail(w, r, id,
|
||||
"Calendar is not configured. Set "+strings.Join(missing, ", ")+".", "error")
|
||||
return
|
||||
}
|
||||
client, err := calendar.New(s.cfg.Calendar, s.log)
|
||||
if err != nil {
|
||||
s.log.Error("calendar client", "err", err)
|
||||
s.renderItemDetail(w, r, id, "Calendar is not usable: "+err.Error(), "error")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := calendar.Sync(ctx, s.store, client,
|
||||
calendar.Options{ItemID: &id, Now: time.Now().In(s.cfg.Location)}, s.log)
|
||||
if err != nil {
|
||||
s.log.Error("calendar push", "item", id, "err", err)
|
||||
s.renderItemDetail(w, r, id, "Calendar write failed: "+err.Error(), "error")
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case res.Failed > 0:
|
||||
msg := "Calendar write failed."
|
||||
if len(res.Errors) > 0 {
|
||||
msg = "Calendar write failed: " + strings.Join(res.Errors, "; ")
|
||||
}
|
||||
s.log.Error("calendar push failed", "item", id, "child", ci.Child, "errors", res.Errors)
|
||||
s.renderItemDetail(w, r, id, msg, "error")
|
||||
case res.Created == 0 && res.Skipped > 0:
|
||||
s.renderItemDetail(w, r, id, "Already in the calendar ("+s.cfg.Calendar.EntityID+").", "ok")
|
||||
case res.Created == 0:
|
||||
s.renderItemDetail(w, r, id,
|
||||
"Nothing to write: only important, future school events go to the calendar.", "error")
|
||||
default:
|
||||
s.log.Info("calendar event written", "item", id, "child", ci.Child, "title", ci.Title, "entity", s.cfg.Calendar.EntityID)
|
||||
s.renderItemDetail(w, r, id, "Added to "+s.cfg.Calendar.EntityID+" on Sam's Home Assistant calendar.", "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -31,6 +32,11 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
|
||||
|
||||
// newTestServer builds the web handler against a fresh SQLite database.
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
|
||||
return newTestServerCalendar(t, config.CalendarConfig{})
|
||||
}
|
||||
|
||||
// newTestServerCalendar is newTestServer with a Home Assistant calendar target.
|
||||
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
@@ -58,6 +64,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
|
||||
AppEnv: "test",
|
||||
Version: "test",
|
||||
Location: loc,
|
||||
Calendar: cal,
|
||||
}
|
||||
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
|
||||
if err != nil {
|
||||
@@ -307,3 +314,187 @@ func snippet(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fakeHACalendar is a minimal Home Assistant calendar for the item-screen test.
|
||||
// It records created events so a read-back can prove the write.
|
||||
func fakeHACalendar(t *testing.T, failCreate bool) *httptest.Server {
|
||||
t.Helper()
|
||||
var events []map[string]any
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if failCreate {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"},
|
||||
})
|
||||
return
|
||||
}
|
||||
events = append(events, map[string]any{
|
||||
"uid": "uid-1", "summary": body["summary"], "description": body["description"],
|
||||
"start_date_time": body["start_date_time"],
|
||||
})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"service_response": map[string]any{"calendar.school": map[string]any{"uid": "uid-1"}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if events == nil {
|
||||
events = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"service_response": map[string]any{"calendar.school": map[string]any{"events": events}},
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestDashboardWarnsWhenTheCalendarIsNotConfigured proves the calendar half of
|
||||
// FR-12: an unconfigured target is stated, never silently skipped.
|
||||
func TestDashboardWarnsWhenTheCalendarIsNotConfigured(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
client := login(t, ts)
|
||||
body := getBody(t, client, ts.URL+"/")
|
||||
if !strings.Contains(body, "CALENDAR NOT CONFIGURED") {
|
||||
t.Errorf("dashboard does not warn about the calendar: %s", snippet(body))
|
||||
}
|
||||
if !strings.Contains(body, "SCHOOL_HA_URL") {
|
||||
t.Error("warning does not name the missing configuration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemCalendarPush proves the item screen writes one event and records it.
|
||||
func TestItemCalendarPush(t *testing.T) {
|
||||
ha := fakeHACalendar(t, false)
|
||||
ts, st := newTestServerCalendar(t, config.CalendarConfig{
|
||||
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
|
||||
})
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
|
||||
Source: "compass", Type: "event", Title: "End of Year Senior Music Concert - HALL",
|
||||
Body: "End of Year Senior Music Concert - HALL - KSH", DueAt: &due,
|
||||
Importance: "high", ExternalRef: "e1",
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
id := items[0].ID
|
||||
|
||||
client := login(t, ts)
|
||||
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
|
||||
if !strings.Contains(detail, "Add to calendar") {
|
||||
t.Fatalf("item screen has no calendar action: %s", snippet(detail))
|
||||
}
|
||||
|
||||
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
|
||||
url.Values{"csrf": {csrfToken(t, detail)}})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("push status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(page), "Added to calendar.school") {
|
||||
t.Errorf("push page has no confirmation: %s", snippet(string(page)))
|
||||
}
|
||||
if !strings.Contains(string(page), "in the calendar") {
|
||||
t.Errorf("item screen does not show the calendar state: %s", snippet(string(page)))
|
||||
}
|
||||
|
||||
summary, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if summary.Total != 1 || summary.Failed != 0 {
|
||||
t.Fatalf("push log = %+v", summary)
|
||||
}
|
||||
ci, _ := st.CalendarItemByID(ctx, id)
|
||||
if ci == nil || ci.HAEventUID != "uid-1" {
|
||||
t.Fatalf("item does not carry the event id: %+v", ci)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemCalendarPushFailureIsLoud proves a failed write is shown and recorded.
|
||||
func TestItemCalendarPushFailureIsLoud(t *testing.T) {
|
||||
ha := fakeHACalendar(t, true)
|
||||
ts, st := newTestServerCalendar(t, config.CalendarConfig{
|
||||
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
|
||||
})
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
|
||||
Source: "compass", Type: "event", Title: "Year 11 Assembly - HALL",
|
||||
DueAt: &due, Importance: "high", ExternalRef: "e1",
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
id := items[0].ID
|
||||
|
||||
client := login(t, ts)
|
||||
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
|
||||
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
|
||||
url.Values{"csrf": {csrfToken(t, detail)}})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(page), "Calendar write failed") {
|
||||
t.Errorf("failure is not shown on the page: %s", snippet(string(page)))
|
||||
}
|
||||
if !strings.Contains(string(page), "calendar is busy") {
|
||||
t.Errorf("failure loses the reason: %s", snippet(string(page)))
|
||||
}
|
||||
|
||||
summary, _ := st.CalendarPushSummary(ctx)
|
||||
if summary.Failed != 1 {
|
||||
t.Fatalf("failed write was not recorded: %+v", summary)
|
||||
}
|
||||
|
||||
// The dashboard repeats the failure, so it is visible without the item page.
|
||||
body := getBody(t, client, ts.URL+"/")
|
||||
if !strings.Contains(body, "CALENDAR WRITE FAILED") {
|
||||
t.Errorf("dashboard does not show the failed write: %s", snippet(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemCalendarPushNeedsCSRF proves the write is not open to a forged post.
|
||||
func TestItemCalendarPushNeedsCSRF(t *testing.T) {
|
||||
ha := fakeHACalendar(t, false)
|
||||
ts, st := newTestServerCalendar(t, config.CalendarConfig{
|
||||
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
|
||||
})
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
due := time.Now().Add(4 * time.Hour).UTC()
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
|
||||
Source: "compass", Type: "event", Title: "Concert", DueAt: &due,
|
||||
Importance: "high", ExternalRef: "e1",
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
|
||||
client := login(t, ts)
|
||||
resp, err := client.PostForm(ts.URL+"/items/"+itoa(items[0].ID)+"/calendar", url.Values{})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("tokenless push = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
summary, _ := st.CalendarPushSummary(ctx)
|
||||
if summary.Total != 0 {
|
||||
t.Errorf("a tokenless push recorded a write: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,12 @@ a:hover { text-decoration: underline; }
|
||||
color: var(--sticker-red);
|
||||
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
|
||||
}
|
||||
.flash--ok {
|
||||
border-color: var(--sticker-green);
|
||||
border-left-width: 3px;
|
||||
color: #0f7a28;
|
||||
background: color-mix(in srgb, var(--sticker-green) 8%, var(--surface));
|
||||
}
|
||||
|
||||
/* ---------- Scan status (FR-12: fail loudly) ---------- */
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
{{template "scan-status.html" .Scan}}
|
||||
</div>
|
||||
|
||||
{{template "calendar-status.html" .Calendar}}
|
||||
|
||||
{{if .Tabs}}
|
||||
<nav class="child-tabs" aria-label="Children">
|
||||
{{range .Tabs}}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p>
|
||||
|
||||
<article class="detail">
|
||||
{{if .Flash}}<p class="flash flash--{{if eq .FlashLevel "ok"}}ok{{else}}error{{end}}" role="alert">{{.Flash}}</p>{{end}}
|
||||
<header class="detail__head">
|
||||
<h1 class="heading-1">{{.Item.Title}}</h1>
|
||||
<p class="detail__meta">
|
||||
@@ -51,12 +52,23 @@
|
||||
<dt>Child</dt><dd>{{.Child.Name}}</dd>
|
||||
<dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd>
|
||||
{{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}}
|
||||
<dt>Calendar</dt>
|
||||
<dd>{{if .Calendar.InCalendar}}in the calendar{{if .Calendar.PushedAt}} · {{.Calendar.PushedAt}}{{end}}{{if .Calendar.UID}} · {{.Calendar.UID}}{{end}}
|
||||
{{else if not .Calendar.Configured}}not configured
|
||||
{{else if .Calendar.Eligible}}not written yet
|
||||
{{else}}not a calendar item{{end}}</dd>
|
||||
{{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}}
|
||||
{{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}}
|
||||
</dl>
|
||||
|
||||
<div class="detail__actions">
|
||||
{{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}}
|
||||
{{if and .Calendar.Configured .Calendar.Eligible (not .Calendar.InCalendar)}}
|
||||
<form method="post" action="/items/{{.Item.ID}}/calendar">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button-primary" type="submit">Add to calendar</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/items/{{.Item.ID}}/delete"
|
||||
hx-post="/items/{{.Item.ID}}/delete" hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
|
||||
19
internal/web/templates/partials/calendar-status.html
Normal file
19
internal/web/templates/partials/calendar-status.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "calendar-status.html"}}
|
||||
{{/* Fail-loudly calendar banner (FR-8, FR-12). A missing target or a failed
|
||||
write is never hidden: the family must not assume an event was written. */}}
|
||||
<section class="scan scan--{{.Level}}" aria-labelledby="calendar-title">
|
||||
<div class="scan__head">
|
||||
<h2 id="calendar-title" class="heading-2">Calendar</h2>
|
||||
<span class="category-pill scan__pill">
|
||||
{{if eq .Level "ok"}}healthy
|
||||
{{else if eq .Level "error"}}failed
|
||||
{{else if eq .Level "warn"}}attention
|
||||
{{else}}off{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="scan__message">{{.Message}}</p>
|
||||
{{if .Entity}}<p class="scan__meta">{{.Entity}}{{if .LastAt}} · last write {{.LastAt}}{{end}}
|
||||
{{if gt .Written 0}}· {{.Written}} event(s) written{{end}}</p>{{end}}
|
||||
{{if gt .Failed 0}}<p class="scan__meta">{{.Failed}} failed calendar write(s) on record.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user