package main import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) const ambientTestToken = "s3cret" // ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path // needs. type ambientCore struct { fakeCore latest map[string]ipc.Fact // "key|source" → fact readErr error } func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { if c.readErr != nil { return ipc.Fact{}, c.readErr } f, ok := c.latest[key+"|"+source] if !ok { return ipc.Fact{}, ipc.ErrNoFact } return f, nil } func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) { t.Helper() body, err := json.Marshal(n) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body))) req.Header.Set("Authorization", "Bearer "+ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, core, token) var resp ambientResp json.Unmarshal(rr.Body.Bytes(), &resp) return rr, resp } func meetingNotification() calendar.Notification { return calendar.Notification{ Package: "com.google.android.gm", Title: "Планёрка", Text: "10:00-10:30", // Local, like a phone relaying from the box's own timezone: the fact // key and value are stamped on the owner's clock, so a UTC reading // here would only be testing the offset of the test machine. Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local), } } func TestHandleAmbientStoresMeeting(t *testing.T) { core := &ambientCore{} rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body) } if !resp.Stored { t.Errorf("resp = %+v, want stored", resp) } if len(core.writeLog) != 1 { t.Fatalf("expected 1 fact write, got %d", len(core.writeLog)) } got := core.writeLog[0] if got.Source != calendar.SourceAmbient { t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient) } if got.Confidence >= 1.0 { t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence) } if got.Confidence != calendar.AmbientConfidence { t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence) } if got.Kind != "env" { t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind) } if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) { t.Errorf("key = %q, want prefix %q", got.Key, want) } if got.Value != "Планёрка @ 10:00-10:30" { t.Errorf("value = %q", got.Value) } } // A phone reposts the same notification many times. Each repost is the same // event, and the append-only log must not fill with duplicates. func TestHandleAmbientDedupesReposts(t *testing.T) { core := &ambientCore{} postAmbient(t, core, ambientTestToken, meetingNotification()) if len(core.writeLog) != 1 { t.Fatalf("first post did not write") } w := core.writeLog[0] core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}} rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code) } if resp.Stored { t.Error("a repost must not be stored again") } if len(core.writeLog) != 1 { t.Errorf("wrote %d facts, want 1", len(core.writeLog)) } } // The conservative half: noise stores nothing at all. func TestHandleAmbientIgnoresNonMeetings(t *testing.T) { core := &ambientCore{} rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{ Package: "com.google.android.gm", Title: "3 новых письма", Posted: time.Now(), }) if rr.Code != http.StatusAccepted { t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code) } if resp.Stored { t.Error("a notification with no meeting time must store nothing") } if len(core.writeLog) != 0 { t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog)) } } func TestHandleAmbientAuth(t *testing.T) { // "завтра" so the meeting is in the future in every zone: the clock in the // text is a local wall clock and posted_at is a Z instant, so a bare "10:00" // is already stale on a box east of UTC and stores nothing (Vikunja #482). body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}` newReq := func(hdr, val string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body)) if hdr != "" { r.Header.Set(hdr, val) } return r } t.Run("no token rejected", func(t *testing.T) { core := &ambientCore{} rr := httptest.NewRecorder() handleAmbient(rr, newReq("", ""), core, ambientTestToken) if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } if len(core.writeLog) != 0 { t.Error("an unauthorized post must not write") } }) t.Run("wrong token rejected", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } }) // RFC 7235 says the scheme is case-insensitive. A phone sending // "bearer " used to fall through to the X-Maven-Token branch and get a // 401 that looked, from the phone's side, like a wrong token. t.Run("lowercase bearer scheme accepted", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusCreated { t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body) } }) // A bare token with no scheme is not a bearer header. Accepting it made the // Authorization branch a second, undocumented X-Maven-Token. t.Run("bare token in Authorization rejected", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } }) t.Run("X-Maven-Token accepted", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusCreated { t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body) } }) t.Run("capability off", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "") if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when no token is configured", rr.Code) } }) t.Run("GET rejected", func(t *testing.T) { rr := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil) handleAmbient(rr, r, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code) } }) } func TestHandleAmbientBadInput(t *testing.T) { t.Run("bad json", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope")) req.Header.Set("X-Maven-Token", ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("missing posted_at", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`)) req.Header.Set("X-Maven-Token", ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("read error surfaces", func(t *testing.T) { core := &ambientCore{readErr: fmt.Errorf("socket closed")} rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) } // The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and // the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup // used to be stored at 18:30, and the size of the error was the deploy's offset. func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) { // No zone juggling: the assertion is that the stored wall clock is the one // he read, whatever zone the box is in. That is false under the old code // on every box except a UTC one. core := &ambientCore{} rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{ Package: "com.slack", Title: "Standup", Text: "созвон завтра в 14:30", Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), }) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body) } if !strings.HasSuffix(resp.Key, "_Standup") { t.Errorf("key = %q, want a key naming the meeting", resp.Key) } if len(core.writeLog) != 1 { t.Fatalf("expected 1 fact write, got %d", len(core.writeLog)) } if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" { t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00") } }