package main // Handler-level gates for the member-bot charter store (memberbots.go) — the // same ownership model as member apps, pinned where it's enforced: a plain // member can't plant a charter owned by someone else, a third party can't // overwrite one, and an admin can moderate without being able to read. import ( "bytes" "encoding/json" "net/http" "strings" "testing" ) func putBot(t *testing.T, tsURL, id string, body map[string]string) int { t.Helper() raw, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPut, tsURL+"/api/member-bots/"+id, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } func TestMemberBotOwnershipGates(t *testing.T) { ts, s := newTestServer(t) alice := provisionNamed(t, ts.URL, "Alice", false) bob := provisionNamed(t, ts.URL, "Bob", false) agent := provisionNamed(t, ts.URL, "Eleven", true) aliceUID, _ := s.db.deviceTokenUser(alice.Token) bobUID, _ := s.db.deviceTokenUser(bob.Token) // A member deploys for themselves; a foreign owner_id is quietly ignored // (they own what they store — no spoofing). if code := putBot(t, ts.URL, "bot-aaa111", map[string]string{ "token": alice.Token, "blob": "ciphertext", "owner_id": bobUID}); code != http.StatusOK { t.Fatalf("member create: %d", code) } b, _, _ := s.db.getMemberBot("bot-aaa111") if b.OwnerID != aliceUID { t.Fatalf("member-set foreign owner must be ignored: %+v", b) } // An AGENT may set the owner to the member it built for. if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{ "token": agent.Token, "blob": "ciphertext", "owner_id": bobUID}); code != http.StatusOK { t.Fatalf("agent create: %d", code) } b, _, _ = s.db.getMemberBot("bot-bbb222") if b.OwnerID != bobUID { t.Fatalf("agent-set owner should stick: %+v", b) } // Overwrite: a third party is refused; the owner may. if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{ "token": alice.Token, "blob": "evil"}); code != http.StatusForbidden { t.Fatalf("third-party overwrite: %d", code) } if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{ "token": bob.Token, "blob": "revised"}); code != http.StatusOK { t.Fatalf("owner overwrite: %d", code) } // Delete: a third party is refused; an admin moderates blind. req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/member-bots/bot-bbb222?token="+alice.Token, nil) resp, _ := http.DefaultClient.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("third-party delete: %d", resp.StatusCode) } req, _ = http.NewRequest(http.MethodDelete, ts.URL+"/api/member-bots/bot-bbb222", nil) req.Header.Set("Authorization", "Bearer admintok") resp, _ = http.DefaultClient.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("admin delete: %d", resp.StatusCode) } if _, exists, _ := s.db.getMemberBot("bot-bbb222"); exists { t.Fatal("bot should be gone") } // The list is member-token-gated, like messages. resp, err := http.Get(ts.URL + "/api/member-bots?token=nonsense") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("stranger list: %d", resp.StatusCode) } resp, err = http.Get(ts.URL + "/api/member-bots?token=" + alice.Token) if err != nil { t.Fatal(err) } defer resp.Body.Close() var list struct { Bots []MemberBot `json:"bots"` } if json.NewDecoder(resp.Body).Decode(&list) != nil || len(list.Bots) != 1 { t.Fatalf("member list: %+v", list) } // Slug rules hold (same shape as member apps). if code := putBot(t, ts.URL, "NOT-OK!", map[string]string{ "token": alice.Token, "blob": "x"}); code != http.StatusBadRequest { t.Fatalf("bad id: %d", code) } } // The charter wake-time report (postCharterNextDue): the right principal is a // bot's own device token — a person's member token and a stranger are both // turned away, and the reported value round-trips through the store. func TestCharterNextDueReport(t *testing.T) { ts, s := newTestServer(t) alice := provisionNamed(t, ts.URL, "Alice", false) agent := provisionNamed(t, ts.URL, "Eleven", true) report := func(token string, due int64) int { t.Helper() raw, _ := json.Marshal(map[string]any{"token": token, "due": due}) resp, err := http.Post(ts.URL+"/api/charter-next-due", "application/json", bytes.NewReader(raw)) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } // A bot's device token is accepted and the value lands. if code := report(agent.Token, 1234567890); code != http.StatusOK { t.Fatalf("agent report: %d", code) } if got := s.db.charterNextDue(); got != 1234567890 { t.Fatalf("stored due = %d, want 1234567890", got) } // Last write wins; zero clears. if code := report(agent.Token, 0); code != http.StatusOK { t.Fatalf("agent clear: %d", code) } if got := s.db.charterNextDue(); got != 0 { t.Fatalf("cleared due = %d, want 0", got) } // A person's member token is refused (bot-kind only), a stranger is // refused, and neither write reaches the store. if code := report(alice.Token, 42); code != http.StatusForbidden { t.Fatalf("member report: want 403, got %d", code) } if code := report("nonsense", 42); code != http.StatusForbidden { t.Fatalf("stranger report: want 403, got %d", code) } if got := s.db.charterNextDue(); got != 0 { t.Fatalf("refused writes must not land: %d", got) } // A negative due is malformed. if code := report(agent.Token, -5); code != http.StatusBadRequest { t.Fatalf("negative due: want 400, got %d", code) } } func TestMemberBotOwnerCap(t *testing.T) { ts, _ := newTestServer(t) alice := provisionNamed(t, ts.URL, "Alice", false) for i := 0; i < maxMemberBotsPerOwner; i++ { id := "bot-cap" + strings.Repeat("0", 3) + string(rune('a'+i)) if code := putBot(t, ts.URL, id, map[string]string{"token": alice.Token, "blob": "x"}); code != http.StatusOK { t.Fatalf("create %d: %d", i, code) } } if code := putBot(t, ts.URL, "bot-capzzz", map[string]string{ "token": alice.Token, "blob": "x"}); code != http.StatusForbidden { t.Fatalf("over-cap create should 403, got %d", code) } }