49f089d8a6
Maven does not get a work credential. A corp mail or calendar session living on the homelab ties the box's blast radius to the employer's data, which is the thing this task exists to refuse. What she reads instead is the signal: an Android notification-listener on the phone relays meeting notifications over wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become calendar events at source=ambient:notif, confidence 0.6. The provenance is the point. A notification is evidence about a meeting, not a reading of a calendar, so it is never indistinguishable from one: it is stored below full confidence, store.CalendarEvents keeps the source and confidence on every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for a relayed event, plain text for a CalDAV read. The parse is deliberately conservative (internal/calendar/ambient.go). It needs a real clock reading and a summary that is not just that clock reading; otherwise it stores nothing at all. A bare hour is not a time, an unread count is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose digits in a notification are far more often a badge or a date, and a mailbox of noise rendered as invented meetings is worse than a gap. The ingest is off unless configured: no -ambient-token, no route registered. The token is a shared secret compared in constant time, because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts one shape of write, and cannot read anything back out. Reposts of the same notification dedupe against the latest fact for that key+source, the same append-only discipline cmd/mavcaldav follows. Not shipped: the Android relay app itself, which is a separate artifact and a device, not Go in this repo.
224 lines
6.8 KiB
Go
224 lines
6.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)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|