eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Handler-level gates for the member-bot charter store (memberbots.go) — the
4// same ownership model as member apps, pinned where it's enforced: a plain
5// member can't plant a charter owned by someone else, a third party can't
6// overwrite one, and an admin can moderate without being able to read.
7
8import (
9 "bytes"
10 "encoding/json"
11 "net/http"
12 "strings"
13 "testing"
14)
15
16func putBot(t *testing.T, tsURL, id string, body map[string]string) int {
17 t.Helper()
18 raw, _ := json.Marshal(body)
19 req, _ := http.NewRequest(http.MethodPut, tsURL+"/api/member-bots/"+id, bytes.NewReader(raw))
20 req.Header.Set("Content-Type", "application/json")
21 resp, err := http.DefaultClient.Do(req)
22 if err != nil {
23 t.Fatal(err)
24 }
25 resp.Body.Close()
26 return resp.StatusCode
27}
28
29func TestMemberBotOwnershipGates(t *testing.T) {
30 ts, s := newTestServer(t)
31 alice := provisionNamed(t, ts.URL, "Alice", false)
32 bob := provisionNamed(t, ts.URL, "Bob", false)
33 agent := provisionNamed(t, ts.URL, "Eleven", true)
34 aliceUID, _ := s.db.deviceTokenUser(alice.Token)
35 bobUID, _ := s.db.deviceTokenUser(bob.Token)
36
37 // A member deploys for themselves; a foreign owner_id is quietly ignored
38 // (they own what they store — no spoofing).
39 if code := putBot(t, ts.URL, "bot-aaa111", map[string]string{
40 "token": alice.Token, "blob": "ciphertext", "owner_id": bobUID}); code != http.StatusOK {
41 t.Fatalf("member create: %d", code)
42 }
43 b, _, _ := s.db.getMemberBot("bot-aaa111")
44 if b.OwnerID != aliceUID {
45 t.Fatalf("member-set foreign owner must be ignored: %+v", b)
46 }
47
48 // An AGENT may set the owner to the member it built for.
49 if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{
50 "token": agent.Token, "blob": "ciphertext", "owner_id": bobUID}); code != http.StatusOK {
51 t.Fatalf("agent create: %d", code)
52 }
53 b, _, _ = s.db.getMemberBot("bot-bbb222")
54 if b.OwnerID != bobUID {
55 t.Fatalf("agent-set owner should stick: %+v", b)
56 }
57
58 // Overwrite: a third party is refused; the owner may.
59 if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{
60 "token": alice.Token, "blob": "evil"}); code != http.StatusForbidden {
61 t.Fatalf("third-party overwrite: %d", code)
62 }
63 if code := putBot(t, ts.URL, "bot-bbb222", map[string]string{
64 "token": bob.Token, "blob": "revised"}); code != http.StatusOK {
65 t.Fatalf("owner overwrite: %d", code)
66 }
67
68 // Delete: a third party is refused; an admin moderates blind.
69 req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/member-bots/bot-bbb222?token="+alice.Token, nil)
70 resp, _ := http.DefaultClient.Do(req)
71 resp.Body.Close()
72 if resp.StatusCode != http.StatusForbidden {
73 t.Fatalf("third-party delete: %d", resp.StatusCode)
74 }
75 req, _ = http.NewRequest(http.MethodDelete, ts.URL+"/api/member-bots/bot-bbb222", nil)
76 req.Header.Set("Authorization", "Bearer admintok")
77 resp, _ = http.DefaultClient.Do(req)
78 resp.Body.Close()
79 if resp.StatusCode != http.StatusOK {
80 t.Fatalf("admin delete: %d", resp.StatusCode)
81 }
82 if _, exists, _ := s.db.getMemberBot("bot-bbb222"); exists {
83 t.Fatal("bot should be gone")
84 }
85
86 // The list is member-token-gated, like messages.
87 resp, err := http.Get(ts.URL + "/api/member-bots?token=nonsense")
88 if err != nil {
89 t.Fatal(err)
90 }
91 resp.Body.Close()
92 if resp.StatusCode != http.StatusForbidden {
93 t.Fatalf("stranger list: %d", resp.StatusCode)
94 }
95 resp, err = http.Get(ts.URL + "/api/member-bots?token=" + alice.Token)
96 if err != nil {
97 t.Fatal(err)
98 }
99 defer resp.Body.Close()
100 var list struct {
101 Bots []MemberBot `json:"bots"`
102 }
103 if json.NewDecoder(resp.Body).Decode(&list) != nil || len(list.Bots) != 1 {
104 t.Fatalf("member list: %+v", list)
105 }
106
107 // Slug rules hold (same shape as member apps).
108 if code := putBot(t, ts.URL, "NOT-OK!", map[string]string{
109 "token": alice.Token, "blob": "x"}); code != http.StatusBadRequest {
110 t.Fatalf("bad id: %d", code)
111 }
112}
113
114// The charter wake-time report (postCharterNextDue): the right principal is a
115// bot's own device token — a person's member token and a stranger are both
116// turned away, and the reported value round-trips through the store.
117func TestCharterNextDueReport(t *testing.T) {
118 ts, s := newTestServer(t)
119 alice := provisionNamed(t, ts.URL, "Alice", false)
120 agent := provisionNamed(t, ts.URL, "Eleven", true)
121
122 report := func(token string, due int64) int {
123 t.Helper()
124 raw, _ := json.Marshal(map[string]any{"token": token, "due": due})
125 resp, err := http.Post(ts.URL+"/api/charter-next-due", "application/json", bytes.NewReader(raw))
126 if err != nil {
127 t.Fatal(err)
128 }
129 resp.Body.Close()
130 return resp.StatusCode
131 }
132
133 // A bot's device token is accepted and the value lands.
134 if code := report(agent.Token, 1234567890); code != http.StatusOK {
135 t.Fatalf("agent report: %d", code)
136 }
137 if got := s.db.charterNextDue(); got != 1234567890 {
138 t.Fatalf("stored due = %d, want 1234567890", got)
139 }
140 // Last write wins; zero clears.
141 if code := report(agent.Token, 0); code != http.StatusOK {
142 t.Fatalf("agent clear: %d", code)
143 }
144 if got := s.db.charterNextDue(); got != 0 {
145 t.Fatalf("cleared due = %d, want 0", got)
146 }
147
148 // A person's member token is refused (bot-kind only), a stranger is
149 // refused, and neither write reaches the store.
150 if code := report(alice.Token, 42); code != http.StatusForbidden {
151 t.Fatalf("member report: want 403, got %d", code)
152 }
153 if code := report("nonsense", 42); code != http.StatusForbidden {
154 t.Fatalf("stranger report: want 403, got %d", code)
155 }
156 if got := s.db.charterNextDue(); got != 0 {
157 t.Fatalf("refused writes must not land: %d", got)
158 }
159
160 // A negative due is malformed.
161 if code := report(agent.Token, -5); code != http.StatusBadRequest {
162 t.Fatalf("negative due: want 400, got %d", code)
163 }
164}
165
166func TestMemberBotOwnerCap(t *testing.T) {
167 ts, _ := newTestServer(t)
168 alice := provisionNamed(t, ts.URL, "Alice", false)
169 for i := 0; i < maxMemberBotsPerOwner; i++ {
170 id := "bot-cap" + strings.Repeat("0", 3) + string(rune('a'+i))
171 if code := putBot(t, ts.URL, id, map[string]string{"token": alice.Token, "blob": "x"}); code != http.StatusOK {
172 t.Fatalf("create %d: %d", i, code)
173 }
174 }
175 if code := putBot(t, ts.URL, "bot-capzzz", map[string]string{
176 "token": alice.Token, "blob": "x"}); code != http.StatusForbidden {
177 t.Fatalf("over-cap create should 403, got %d", code)
178 }
179}
180