35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
291 lines
9.8 KiB
Go
291 lines
9.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"
|
|
|
|
func TestValidateAmbientConfig(t *testing.T) {
|
|
if err := validateAmbientConfig(false, ""); err != nil {
|
|
t.Fatalf("explicitly disabled ambient config: %v", err)
|
|
}
|
|
if err := validateAmbientConfig(true, ""); err == nil {
|
|
t.Fatal("enabled ambient ingest accepted an empty token")
|
|
}
|
|
if err := validateAmbientConfig(true, ambientTestToken); err != nil {
|
|
t.Fatalf("enabled authenticated ambient config: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 <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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|