acl_test.mx raw
1 package acl
2
3 import (
4 "bytes"
5 "encoding/hex"
6 "os"
7 "testing"
8 "time"
9
10 "git.smesh.lol/smesh/pkg/nostr/event"
11 "git.smesh.lol/smesh/pkg/nostr/kind"
12 "git.smesh.lol/smesh/pkg/nostr/tag"
13 "git.smesh.lol/smesh/pkg/store"
14 )
15
16 // aclPk is a fixed 32-byte pubkey so the store round-trip needs no signer.
17 func aclPk(fill byte) (b []byte) {
18 b = []byte{:32}
19 for i := range b {
20 b[i] = fill
21 }
22 return
23 }
24
25 func aclSig() (b []byte) {
26 b = []byte{:64}
27 for i := range b {
28 b[i] = byte(i)
29 }
30 return
31 }
32
33 // aclTmp opens a store in a fresh directory. The caller owns both.
34 func aclTmp(t *testing.T) (eng *store.Engine, dir string) {
35 t.Helper()
36 dir, derr := os.MkdirTemp("", "moxie-acl")
37 if derr != nil {
38 t.Fatal(derr)
39 }
40 eng, oerr := store.Open(dir)
41 if oerr != nil {
42 t.Fatal(oerr)
43 }
44 return eng, dir
45 }
46
47 // aclBinTag is the 33-byte binary form grapevine.GetFollows expects
48 // (ValueBinary strips the trailing NUL).
49 func aclBinTag(pk []byte) (b []byte) {
50 b = []byte{:33}
51 copy(b, pk)
52 return
53 }
54
55 // aclFollowList builds a kind-3 event authored by admin that follows one
56 // pubkey. idFill keeps two events in one store from colliding.
57 func aclFollowList(t *testing.T, admin []byte, idFill byte, followed []byte) (ev *event.E) {
58 t.Helper()
59 kind.Ensure()
60 tags := tag.NewSWithCap(1)
61 tags.T = push(tags.T, tag.NewFromBytesSlice([]byte("p"), aclBinTag(followed)))
62 return &event.E{
63 ID: aclPk(idFill),
64 Pubkey: admin,
65 CreatedAt: 1700000000,
66 Kind: kind.FollowList.K,
67 Tags: tags,
68 Sig: aclSig(),
69 }
70 }
71
72 // --- acl.mx ---
73
74 func TestOpenAllowsEverything(t *testing.T) {
75 var o Checker = Open{}
76 if !o.AllowWrite(aclPk(0x01), 1) {
77 t.Fatal("Open must allow every write")
78 }
79 if !o.AllowRead(aclPk(0x01)) {
80 t.Fatal("Open must allow every read")
81 }
82 if !o.AllowWrite(nil, 0) {
83 t.Fatal("Open must allow an empty pubkey")
84 }
85 }
86
87 func TestWhitelist(t *testing.T) {
88 var c Checker = &Whitelist{Pubkeys: [][]byte{aclPk(0xA1), aclPk(0xB2)}}
89 if !c.AllowWrite(aclPk(0xA1), 1) {
90 t.Fatal("whitelisted pubkey 1 rejected")
91 }
92 if !c.AllowWrite(aclPk(0xB2), 7) {
93 t.Fatal("whitelisted pubkey 2 rejected")
94 }
95 if c.AllowWrite(aclPk(0xC3), 1) {
96 t.Fatal("non-whitelisted pubkey accepted")
97 }
98 if c.AllowWrite(nil, 1) {
99 t.Fatal("nil pubkey accepted")
100 }
101 if !c.AllowRead(aclPk(0xC3)) {
102 t.Fatal("Whitelist must allow every read")
103 }
104
105 empty := &Whitelist{}
106 if empty.AllowWrite(aclPk(0xA1), 1) {
107 t.Fatal("empty whitelist accepted a write")
108 }
109 }
110
111 func TestReadOnly(t *testing.T) {
112 var c Checker = ReadOnly{}
113 if c.AllowWrite(aclPk(0xA1), 1) {
114 t.Fatal("ReadOnly accepted a write")
115 }
116 if c.AllowWrite(nil, 0) {
117 t.Fatal("ReadOnly accepted an empty write")
118 }
119 if !c.AllowRead(aclPk(0xA1)) {
120 t.Fatal("ReadOnly must allow reads")
121 }
122 }
123
124 // --- follows.mx ---
125
126 func TestHexDec(t *testing.T) {
127 if empty := hexDec(""); len(empty) != 0 {
128 t.Fatalf("hexDec(\"\") length = %d", int32(len(empty)))
129 }
130 dec := hexDec("00ff10aF")
131 if len(dec) != 4 {
132 t.Fatalf("hexDec length = %d", int32(len(dec)))
133 }
134 if dec[0] != 0x00 || dec[1] != 0xff || dec[2] != 0x10 || dec[3] != 0xaf {
135 t.Fatalf("hexDec bytes = %x", dec)
136 }
137 if hexDec("0") != nil {
138 t.Fatal("odd-length hex accepted")
139 }
140 if hexDec("zz") != nil {
141 t.Fatal("non-hex accepted")
142 }
143 if hexDec("0g") != nil {
144 t.Fatal("partially non-hex accepted")
145 }
146 }
147
148 func TestUnhex(t *testing.T) {
149 if unhex('0') != 0 {
150 t.Fatal("unhex('0')")
151 }
152 if unhex('9') != 9 {
153 t.Fatal("unhex('9')")
154 }
155 if unhex('a') != 10 {
156 t.Fatal("unhex('a')")
157 }
158 if unhex('f') != 15 {
159 t.Fatal("unhex('f')")
160 }
161 if unhex('A') != 10 {
162 t.Fatal("unhex('A')")
163 }
164 if unhex('F') != 15 {
165 t.Fatal("unhex('F')")
166 }
167 if unhex('g') != 0xff {
168 t.Fatal("unhex('g') must be the invalid sentinel")
169 }
170 if unhex('/') != 0xff {
171 t.Fatal("unhex('/') must be the invalid sentinel")
172 }
173 }
174
175 // TestFollowsWithStore pins the store-backed decision: the admin can always
176 // write, a pubkey on the admin's kind-3 follow list can write, and everyone
177 // else is denied.
178 func TestFollowsWithStore(t *testing.T) {
179 eng, dir := aclTmp(t)
180 defer os.RemoveAll(dir)
181 defer eng.Close()
182
183 admin := aclPk(0xA1)
184 alice := aclPk(0xB2)
185 bob := aclPk(0xC3)
186 if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil {
187 t.Fatal(err)
188 }
189
190 f := NewFollows(eng, []string{hex.EncodeToString(admin)}, 3600)
191 if !f.AllowWrite(admin, 1) {
192 t.Fatal("admin must always write")
193 }
194 if !f.AllowWrite(alice, 1) {
195 t.Fatal("followed pubkey must write")
196 }
197 if f.AllowWrite(bob, 1) {
198 t.Fatal("unfollowed pubkey must not write")
199 }
200 if !f.AllowRead(bob) {
201 t.Fatal("Follows must allow every read")
202 }
203 if !f.IsFollowed(alice) {
204 t.Fatal("IsFollowed(alice)")
205 }
206 if f.IsFollowed(bob) {
207 t.Fatal("IsFollowed(bob)")
208 }
209 // refresh() seeds the admin set into `followed` as well, so the admin is
210 // reported as followed.
211 if !f.IsFollowed(admin) {
212 t.Fatal("the admin must be seeded into the followed map")
213 }
214 }
215
216 // TestFollowsAdminParsing pins that only well-formed 32-byte hex admins are
217 // kept, and that a valid admin entry still works alongside malformed ones.
218 func TestFollowsAdminParsing(t *testing.T) {
219 eng, dir := aclTmp(t)
220 defer os.RemoveAll(dir)
221 defer eng.Close()
222
223 admin := aclPk(0xA1)
224 alice := aclPk(0xB2)
225 if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil {
226 t.Fatal(err)
227 }
228
229 // empty, odd-length, non-hex and short-but-even entries are all dropped.
230 f := NewFollows(eng, []string{"", "abc", "zz", "abcd", hex.EncodeToString(admin)}, 3600)
231 if len(f.admins) != 1 {
232 t.Fatalf("admins kept = %d, want 1", int32(len(f.admins)))
233 }
234 if !bytes.Equal(f.admins[0], admin) {
235 t.Fatal("the surviving admin is not the valid one")
236 }
237 if !f.AllowWrite(alice, 1) {
238 t.Fatal("the valid admin's follow list was not loaded")
239 }
240 }
241
242 // TestFollowsWithoutStore drives AllowWrite/IsFollowed on a constructed value
243 // with no store: refresh is skipped, so the decision comes from the in-memory
244 // maps alone. This is the store-free half of the API.
245 func TestFollowsWithoutStore(t *testing.T) {
246 admin := aclPk(0xA1)
247 alice := aclPk(0xB2)
248 bob := aclPk(0xC3)
249 f := &Follows{
250 admins: [][]byte{admin},
251 followed: map[string]bool{string(alice): true},
252 freqSec: 3600,
253 lastRefresh: time.Now().Unix(),
254 }
255 if !f.AllowWrite(admin, 1) {
256 t.Fatal("admin must write without a store")
257 }
258 if !f.AllowWrite(alice, 1) {
259 t.Fatal("followed pubkey must write without a store")
260 }
261 if f.AllowWrite(bob, 1) {
262 t.Fatal("unknown pubkey must not write without a store")
263 }
264 if !f.AllowRead(bob) {
265 t.Fatal("AllowRead must be unconditional")
266 }
267 if !f.IsFollowed(alice) || f.IsFollowed(bob) {
268 t.Fatal("IsFollowed without a store")
269 }
270 }
271
272 // --- social.mx ---
273
274 // TestSocialWithStore pins the WoT-depth decisions at maxDepth 2:
275 // admin=0, direct follow=1, follow-of-follow=2, outsider=-1.
276 func TestSocialWithStore(t *testing.T) {
277 eng, dir := aclTmp(t)
278 defer os.RemoveAll(dir)
279 defer eng.Close()
280
281 admin := aclPk(0xA1)
282 alice := aclPk(0xB2)
283 bob := aclPk(0xC3)
284 outsider := aclPk(0xDD)
285 if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil {
286 t.Fatal(err)
287 }
288 if err := eng.SaveEvent(aclFollowList(t, alice, 0xE2, bob)); err != nil {
289 t.Fatal(err)
290 }
291
292 s := NewSocial(eng, []string{hex.EncodeToString(admin)}, 2, 3600)
293 if s.Depth(admin) != 0 {
294 t.Fatalf("Depth(admin) = %d, want 0", s.Depth(admin))
295 }
296 if s.Depth(alice) != 1 {
297 t.Fatalf("Depth(alice) = %d, want 1", s.Depth(alice))
298 }
299 if s.Depth(bob) != 2 {
300 t.Fatalf("Depth(bob) = %d, want 2", s.Depth(bob))
301 }
302 if s.Depth(outsider) != -1 {
303 t.Fatalf("Depth(outsider) = %d, want -1", s.Depth(outsider))
304 }
305 if !s.AllowWrite(admin, 1) {
306 t.Fatal("admin must write")
307 }
308 if !s.AllowWrite(alice, 1) {
309 t.Fatal("depth-1 pubkey must write")
310 }
311 if !s.AllowWrite(bob, 1) {
312 t.Fatal("depth-2 pubkey must write")
313 }
314 if s.AllowWrite(outsider, 1) {
315 t.Fatal("depth -1 pubkey must not write")
316 }
317 if !s.AllowRead(outsider) {
318 t.Fatal("Social must allow every read")
319 }
320 }
321
322 // TestSocialRespectsMaxDepth pins that maxDepth truncates the traversal: at
323 // maxDepth 1 the second hop is unknown (depth -1) and cannot write.
324 func TestSocialRespectsMaxDepth(t *testing.T) {
325 eng, dir := aclTmp(t)
326 defer os.RemoveAll(dir)
327 defer eng.Close()
328
329 admin := aclPk(0xA1)
330 alice := aclPk(0xB2)
331 bob := aclPk(0xC3)
332 if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil {
333 t.Fatal(err)
334 }
335 if err := eng.SaveEvent(aclFollowList(t, alice, 0xE2, bob)); err != nil {
336 t.Fatal(err)
337 }
338
339 s := NewSocial(eng, []string{hex.EncodeToString(admin)}, 1, 3600)
340 if s.Depth(alice) != 1 {
341 t.Fatalf("Depth(alice) = %d, want 1", s.Depth(alice))
342 }
343 if s.Depth(bob) != -1 {
344 t.Fatalf("Depth(bob) at maxDepth 1 = %d, want -1", s.Depth(bob))
345 }
346 if s.AllowWrite(bob, 1) {
347 t.Fatal("beyond maxDepth must not write")
348 }
349 }
350
351 // TestSocialWithoutStore drives the store-free half: Depth and AllowWrite read
352 // the constructed depthMap and admin list directly.
353 func TestSocialWithoutStore(t *testing.T) {
354 admin := aclPk(0xA1)
355 alice := aclPk(0xB2)
356 bob := aclPk(0xC3)
357 s := &Social{
358 admins: [][]byte{admin},
359 maxDepth: 2,
360 refreshSec: 3600,
361 lastRefresh: time.Now().Unix(),
362 depthMap: map[string]int32{string(alice): 1},
363 }
364 if s.Depth(admin) != 0 {
365 t.Fatal("admin depth must be 0 without a store")
366 }
367 if s.Depth(alice) != 1 {
368 t.Fatal("mapped depth must be returned")
369 }
370 if s.Depth(bob) != -1 {
371 t.Fatal("unmapped depth must be -1")
372 }
373 if !s.AllowWrite(admin, 1) || !s.AllowWrite(alice, 1) {
374 t.Fatal("admin and mapped pubkey must write")
375 }
376 if s.AllowWrite(bob, 1) {
377 t.Fatal("unmapped pubkey must not write")
378 }
379 if !s.AllowRead(bob) {
380 t.Fatal("AllowRead must be unconditional")
381 }
382 }
383