4e4c9170e3
EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>" fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57.
245 lines
7.8 KiB
Go
245 lines
7.8 KiB
Go
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",
|
|
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 <tok>" 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)
|
|
}
|
|
})
|
|
}
|