package acl import ( "bytes" "encoding/hex" "os" "testing" "time" "git.smesh.lol/nostr/pkg/event" "git.smesh.lol/nostr/pkg/kind" "git.smesh.lol/nostr/pkg/tag" "git.smesh.lol/morly/pkg/store" ) // aclPk is a fixed 32-byte pubkey so the store round-trip needs no signer. func aclPk(fill byte) (b []byte) { b = []byte{:32} for i := range b { b[i] = fill } return } func aclSig() (b []byte) { b = []byte{:64} for i := range b { b[i] = byte(i) } return } // aclTmp opens a store in a fresh directory. The caller owns both. func aclTmp(t *testing.T) (eng *store.Engine, dir string) { t.Helper() dir, derr := os.MkdirTemp("", "moxie-acl") if derr != nil { t.Fatal(derr) } eng, oerr := store.Open(dir) if oerr != nil { t.Fatal(oerr) } return eng, dir } // aclBinTag is the 33-byte binary form grapevine.GetFollows expects // (ValueBinary strips the trailing NUL). func aclBinTag(pk []byte) (b []byte) { b = []byte{:33} copy(b, pk) return } // aclFollowList builds a kind-3 event authored by admin that follows one // pubkey. idFill keeps two events in one store from colliding. func aclFollowList(t *testing.T, admin []byte, idFill byte, followed []byte) (ev *event.E) { t.Helper() kind.Ensure() tags := tag.NewSWithCap(1) tags.T = push(tags.T, tag.NewFromBytesSlice([]byte("p"), aclBinTag(followed))) return &event.E{ ID: aclPk(idFill), Pubkey: admin, CreatedAt: 1700000000, Kind: kind.FollowList.K, Tags: tags, Sig: aclSig(), } } // --- acl.mx --- func TestOpenAllowsEverything(t *testing.T) { var o Checker = &Open{} if !o.AllowWrite(aclPk(0x01), 1) { t.Fatal("Open must allow every write") } if !o.AllowRead(aclPk(0x01)) { t.Fatal("Open must allow every read") } if !o.AllowWrite(nil, 0) { t.Fatal("Open must allow an empty pubkey") } } func TestWhitelist(t *testing.T) { var c Checker = &Whitelist{Pubkeys: [][]byte{aclPk(0xA1), aclPk(0xB2)}} if !c.AllowWrite(aclPk(0xA1), 1) { t.Fatal("whitelisted pubkey 1 rejected") } if !c.AllowWrite(aclPk(0xB2), 7) { t.Fatal("whitelisted pubkey 2 rejected") } if c.AllowWrite(aclPk(0xC3), 1) { t.Fatal("non-whitelisted pubkey accepted") } if c.AllowWrite(nil, 1) { t.Fatal("nil pubkey accepted") } if !c.AllowRead(aclPk(0xC3)) { t.Fatal("Whitelist must allow every read") } empty := &Whitelist{} if empty.AllowWrite(aclPk(0xA1), 1) { t.Fatal("empty whitelist accepted a write") } } func TestReadOnly(t *testing.T) { var c Checker = &ReadOnly{} if c.AllowWrite(aclPk(0xA1), 1) { t.Fatal("ReadOnly accepted a write") } if c.AllowWrite(nil, 0) { t.Fatal("ReadOnly accepted an empty write") } if !c.AllowRead(aclPk(0xA1)) { t.Fatal("ReadOnly must allow reads") } } // --- follows.mx --- func TestHexDec(t *testing.T) { if empty := hexDec(""); len(empty) != 0 { t.Fatalf("hexDec(\"\") length = %d", int32(len(empty))) } dec := hexDec("00ff10aF") if len(dec) != 4 { t.Fatalf("hexDec length = %d", int32(len(dec))) } if dec[0] != 0x00 || dec[1] != 0xff || dec[2] != 0x10 || dec[3] != 0xaf { t.Fatalf("hexDec bytes = %x", dec) } if hexDec("0") != nil { t.Fatal("odd-length hex accepted") } if hexDec("zz") != nil { t.Fatal("non-hex accepted") } if hexDec("0g") != nil { t.Fatal("partially non-hex accepted") } } func TestUnhex(t *testing.T) { if unhex('0') != 0 { t.Fatal("unhex('0')") } if unhex('9') != 9 { t.Fatal("unhex('9')") } if unhex('a') != 10 { t.Fatal("unhex('a')") } if unhex('f') != 15 { t.Fatal("unhex('f')") } if unhex('A') != 10 { t.Fatal("unhex('A')") } if unhex('F') != 15 { t.Fatal("unhex('F')") } if unhex('g') != 0xff { t.Fatal("unhex('g') must be the invalid sentinel") } if unhex('/') != 0xff { t.Fatal("unhex('/') must be the invalid sentinel") } } // TestFollowsWithStore pins the store-backed decision: the admin can always // write, a pubkey on the admin's kind-3 follow list can write, and everyone // else is denied. func TestFollowsWithStore(t *testing.T) { eng, dir := aclTmp(t) defer os.RemoveAll(dir) defer eng.Close() admin := aclPk(0xA1) alice := aclPk(0xB2) bob := aclPk(0xC3) if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil { t.Fatal(err) } f := NewFollows(eng, []string{hex.EncodeToString(admin)}, 3600) if !f.AllowWrite(admin, 1) { t.Fatal("admin must always write") } if !f.AllowWrite(alice, 1) { t.Fatal("followed pubkey must write") } if f.AllowWrite(bob, 1) { t.Fatal("unfollowed pubkey must not write") } if !f.AllowRead(bob) { t.Fatal("Follows must allow every read") } if !f.IsFollowed(alice) { t.Fatal("IsFollowed(alice)") } if f.IsFollowed(bob) { t.Fatal("IsFollowed(bob)") } // refresh() seeds the admin set into `followed` as well, so the admin is // reported as followed. if !f.IsFollowed(admin) { t.Fatal("the admin must be seeded into the followed map") } } // TestFollowsAdminParsing pins that only well-formed 32-byte hex admins are // kept, and that a valid admin entry still works alongside malformed ones. func TestFollowsAdminParsing(t *testing.T) { eng, dir := aclTmp(t) defer os.RemoveAll(dir) defer eng.Close() admin := aclPk(0xA1) alice := aclPk(0xB2) if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil { t.Fatal(err) } // empty, odd-length, non-hex and short-but-even entries are all dropped. f := NewFollows(eng, []string{"", "abc", "zz", "abcd", hex.EncodeToString(admin)}, 3600) if len(f.admins) != 1 { t.Fatalf("admins kept = %d, want 1", int32(len(f.admins))) } if !bytes.Equal(f.admins[0], admin) { t.Fatal("the surviving admin is not the valid one") } if !f.AllowWrite(alice, 1) { t.Fatal("the valid admin's follow list was not loaded") } } // TestFollowsWithoutStore drives AllowWrite/IsFollowed on a constructed value // with no store: refresh is skipped, so the decision comes from the in-memory // maps alone. This is the store-free half of the API. func TestFollowsWithoutStore(t *testing.T) { admin := aclPk(0xA1) alice := aclPk(0xB2) bob := aclPk(0xC3) f := &Follows{ admins: [][]byte{admin}, followed: map[string]bool{string(alice): true}, freqSec: 3600, lastRefresh: time.Now().Unix(), } if !f.AllowWrite(admin, 1) { t.Fatal("admin must write without a store") } if !f.AllowWrite(alice, 1) { t.Fatal("followed pubkey must write without a store") } if f.AllowWrite(bob, 1) { t.Fatal("unknown pubkey must not write without a store") } if !f.AllowRead(bob) { t.Fatal("AllowRead must be unconditional") } if !f.IsFollowed(alice) || f.IsFollowed(bob) { t.Fatal("IsFollowed without a store") } } // --- social.mx --- // TestSocialWithStore pins the WoT-depth decisions at maxDepth 2: // admin=0, direct follow=1, follow-of-follow=2, outsider=-1. func TestSocialWithStore(t *testing.T) { eng, dir := aclTmp(t) defer os.RemoveAll(dir) defer eng.Close() admin := aclPk(0xA1) alice := aclPk(0xB2) bob := aclPk(0xC3) outsider := aclPk(0xDD) if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil { t.Fatal(err) } if err := eng.SaveEvent(aclFollowList(t, alice, 0xE2, bob)); err != nil { t.Fatal(err) } s := NewSocial(eng, []string{hex.EncodeToString(admin)}, 2, 3600) if s.Depth(admin) != 0 { t.Fatalf("Depth(admin) = %d, want 0", s.Depth(admin)) } if s.Depth(alice) != 1 { t.Fatalf("Depth(alice) = %d, want 1", s.Depth(alice)) } if s.Depth(bob) != 2 { t.Fatalf("Depth(bob) = %d, want 2", s.Depth(bob)) } if s.Depth(outsider) != -1 { t.Fatalf("Depth(outsider) = %d, want -1", s.Depth(outsider)) } if !s.AllowWrite(admin, 1) { t.Fatal("admin must write") } if !s.AllowWrite(alice, 1) { t.Fatal("depth-1 pubkey must write") } if !s.AllowWrite(bob, 1) { t.Fatal("depth-2 pubkey must write") } if s.AllowWrite(outsider, 1) { t.Fatal("depth -1 pubkey must not write") } if !s.AllowRead(outsider) { t.Fatal("Social must allow every read") } } // TestSocialRespectsMaxDepth pins that maxDepth truncates the traversal: at // maxDepth 1 the second hop is unknown (depth -1) and cannot write. func TestSocialRespectsMaxDepth(t *testing.T) { eng, dir := aclTmp(t) defer os.RemoveAll(dir) defer eng.Close() admin := aclPk(0xA1) alice := aclPk(0xB2) bob := aclPk(0xC3) if err := eng.SaveEvent(aclFollowList(t, admin, 0xE1, alice)); err != nil { t.Fatal(err) } if err := eng.SaveEvent(aclFollowList(t, alice, 0xE2, bob)); err != nil { t.Fatal(err) } s := NewSocial(eng, []string{hex.EncodeToString(admin)}, 1, 3600) if s.Depth(alice) != 1 { t.Fatalf("Depth(alice) = %d, want 1", s.Depth(alice)) } if s.Depth(bob) != -1 { t.Fatalf("Depth(bob) at maxDepth 1 = %d, want -1", s.Depth(bob)) } if s.AllowWrite(bob, 1) { t.Fatal("beyond maxDepth must not write") } } // TestSocialWithoutStore drives the store-free half: Depth and AllowWrite read // the constructed depthMap and admin list directly. func TestSocialWithoutStore(t *testing.T) { admin := aclPk(0xA1) alice := aclPk(0xB2) bob := aclPk(0xC3) s := &Social{ admins: [][]byte{admin}, maxDepth: 2, refreshSec: 3600, lastRefresh: time.Now().Unix(), depthMap: map[string]int32{string(alice): 1}, } if s.Depth(admin) != 0 { t.Fatal("admin depth must be 0 without a store") } if s.Depth(alice) != 1 { t.Fatal("mapped depth must be returned") } if s.Depth(bob) != -1 { t.Fatal("unmapped depth must be -1") } if !s.AllowWrite(admin, 1) || !s.AllowWrite(alice, 1) { t.Fatal("admin and mapped pubkey must write") } if s.AllowWrite(bob, 1) { t.Fatal("unmapped pubkey must not write") } if !s.AllowRead(bob) { t.Fatal("AllowRead must be unconditional") } }