bridge_e2e_test.go raw
1 package bridge
2
3 import (
4 "context"
5 "strings"
6 "testing"
7 "time"
8
9 "git.smesh.lol/orly/pkg/nostr/encoders/event"
10 "git.smesh.lol/orly/pkg/nostr/encoders/filter"
11 "git.smesh.lol/orly/pkg/nostr/encoders/hex"
12 "git.smesh.lol/orly/pkg/nostr/interfaces/signer/p8k"
13 "git.smesh.lol/orly/pkg/nostr/protocol/marmot"
14 )
15
16 func newTestSigner(t *testing.T) *p8k.Signer {
17 t.Helper()
18 s, err := p8k.New()
19 if err != nil {
20 t.Fatalf("create signer: %v", err)
21 }
22 if err := s.Generate(); err != nil {
23 t.Fatalf("generate key: %v", err)
24 }
25 return s
26 }
27
28 // TestE2E_RouterDispatch verifies that the router correctly dispatches
29 // subscribe, status, outbound email, and help messages.
30 func TestE2E_RouterDispatch(t *testing.T) {
31 sink := newMockDMSink()
32 store := NewMemorySubscriptionStore()
33 subHandler := NewSubscriptionHandler(store, nil, sink.send, 2100, nil, 0, "test.example.com")
34 router := NewRouter(subHandler, nil, sink.send)
35
36 ctx := context.Background()
37 alice := "aaaa1111"
38
39 // Subscribe command
40 router.RouteDM(ctx, alice, "subscribe")
41 waitForMessages(t, sink, alice, 1)
42 msgs := sink.get(alice)
43 if !strings.Contains(msgs[0], "not available") {
44 t.Errorf("subscribe response: want 'not available', got: %s", msgs[0])
45 }
46
47 // Status command
48 router.RouteDM(ctx, alice, "status")
49 waitForMessages(t, sink, alice, 2)
50 msgs = sink.get(alice)
51 if len(msgs) < 2 {
52 t.Fatalf("expected at least 2 messages, got %d", len(msgs))
53 }
54
55 // Outbound email (no processor configured)
56 bob := "bbbb2222"
57 router.RouteDM(ctx, bob, "To: alice@example.com\nSubject: Test\n\nHello")
58 waitForMessages(t, sink, bob, 1)
59 msgs = sink.get(bob)
60 if !strings.Contains(msgs[0], "not configured") {
61 t.Errorf("outbound response: want 'not configured', got: %s", msgs[0])
62 }
63
64 // Unrecognized → help
65 carol := "cccc3333"
66 router.RouteDM(ctx, carol, "hey what's up")
67 waitForMessages(t, sink, carol, 1)
68 msgs = sink.get(carol)
69 if !strings.Contains(msgs[0], "Marmot Email Bridge") {
70 t.Errorf("help response missing, got: %s", msgs[0])
71 }
72 }
73
74 // TestE2E_MLSKeyPackageEvent verifies that the bridge can produce a valid kind
75 // 443 key package event and a kind 10051 relay list event for broadcasting.
76 func TestE2E_MLSKeyPackageEvent(t *testing.T) {
77 bridgeSign := newTestSigner(t)
78 store, err := marmot.NewFileGroupStore(t.TempDir())
79 if err != nil {
80 t.Fatalf("create store: %v", err)
81 }
82
83 client, err := marmot.NewClient(&marmot.LocalCrypto{Sign: bridgeSign}, store, &nullRelay{})
84 if err != nil {
85 t.Fatalf("create client: %v", err)
86 }
87
88 // Kind 443 key package event
89 kpEv, err := client.KeyPackageEvent()
90 if err != nil {
91 t.Fatalf("KeyPackageEvent: %v", err)
92 }
93 if kpEv.Kind != 443 {
94 t.Errorf("kind: want 443, got %d", kpEv.Kind)
95 }
96 if len(kpEv.Content) == 0 {
97 t.Error("key package event has empty content")
98 }
99 kp, err := marmot.EventToKeyPackage(kpEv)
100 if err != nil {
101 t.Fatalf("parse key package from event: %v", err)
102 }
103 if kp == nil {
104 t.Fatal("parsed key package is nil")
105 }
106
107 // Kind 10051 relay list event
108 kprEv, err := client.KeyPackageRelaysEvent([]string{"wss://relay.example.com"})
109 if err != nil {
110 t.Fatalf("KeyPackageRelaysEvent: %v", err)
111 }
112 if kprEv.Kind != 10051 {
113 t.Errorf("kind: want 10051, got %d", kprEv.Kind)
114 }
115 relayTag := findTag(kprEv, "relay")
116 if relayTag != "wss://relay.example.com" {
117 t.Errorf("relay tag: want wss://relay.example.com, got %q", relayTag)
118 }
119 }
120
121 // TestE2E_MLSWelcomeRoundTrip verifies that an MLS Welcome can be gift-wrapped
122 // and unwrapped through the marmot SDK.
123 func TestE2E_MLSWelcomeRoundTrip(t *testing.T) {
124 alice := newTestSigner(t)
125 bob := newTestSigner(t)
126
127 aliceKPP, err := marmot.GenerateKeyPackage(&marmot.LocalCrypto{Sign: alice})
128 if err != nil {
129 t.Fatalf("alice key package: %v", err)
130 }
131 bobKPP, err := marmot.GenerateKeyPackage(&marmot.LocalCrypto{Sign: bob})
132 if err != nil {
133 t.Fatalf("bob key package: %v", err)
134 }
135
136 _, welcome, _, err := marmot.CreateDMGroup(aliceKPP, &bobKPP.Public, alice.Pub(), bob.Pub(), nil)
137 if err != nil {
138 t.Fatalf("create DM group: %v", err)
139 }
140
141 wrapEv, err := marmot.WelcomeToGiftWrap(welcome, bob.Pub(), &marmot.LocalCrypto{Sign: alice}, nil, nil)
142 if err != nil {
143 t.Fatalf("WelcomeToGiftWrap: %v", err)
144 }
145
146 if wrapEv.Kind != 1059 {
147 t.Errorf("wrap kind: want 1059, got %d", wrapEv.Kind)
148 }
149 pTag := findTag(wrapEv, "p")
150 if pTag != hex.Enc(bob.Pub()) {
151 t.Errorf("p tag: want bob's pubkey, got %s", pTag)
152 }
153
154 unwrapped, err := marmot.UnwrapWelcome(wrapEv, &marmot.LocalCrypto{Sign: bob})
155 if err != nil {
156 t.Fatalf("UnwrapWelcome: %v", err)
157 }
158
159 if hex.Enc(unwrapped.SenderPub) != hex.Enc(alice.Pub()) {
160 t.Errorf("sender: want alice, got %s", hex.Enc(unwrapped.SenderPub))
161 }
162
163 gs, err := marmot.JoinDMGroup(unwrapped.Welcome, bobKPP, alice.Pub())
164 if err != nil {
165 t.Fatalf("join group: %v", err)
166 }
167 if gs == nil {
168 t.Fatal("group state is nil after join")
169 }
170 }
171
172 // TestE2E_MLSGroupMessageRoundTrip tests that a message encrypted through MLS
173 // can be wrapped in a kind 445 event and decrypted by the recipient.
174 func TestE2E_MLSGroupMessageRoundTrip(t *testing.T) {
175 alice := newTestSigner(t)
176 bob := newTestSigner(t)
177
178 aliceKPP, err := marmot.GenerateKeyPackage(&marmot.LocalCrypto{Sign: alice})
179 if err != nil {
180 t.Fatalf("alice KPP: %v", err)
181 }
182 bobKPP, err := marmot.GenerateKeyPackage(&marmot.LocalCrypto{Sign: bob})
183 if err != nil {
184 t.Fatalf("bob KPP: %v", err)
185 }
186
187 aliceGS, welcome, _, err := marmot.CreateDMGroup(aliceKPP, &bobKPP.Public, alice.Pub(), bob.Pub(), nil)
188 if err != nil {
189 t.Fatalf("create group: %v", err)
190 }
191
192 bobGS, err := marmot.JoinDMGroup(welcome, bobKPP, alice.Pub())
193 if err != nil {
194 t.Fatalf("join group: %v", err)
195 }
196 bobGS.GroupID = aliceGS.GroupID
197
198 plaintext := []byte("subscribe")
199 ciphertext, err := aliceGS.Encrypt(plaintext)
200 if err != nil {
201 t.Fatalf("encrypt: %v", err)
202 }
203
204 exporterSecret, err := aliceGS.DeriveExporterSecret()
205 if err != nil {
206 t.Fatalf("exporter secret: %v", err)
207 }
208
209 ev, err := marmot.MessageToEvent(aliceGS.NostrGroupID, ciphertext, exporterSecret)
210 if err != nil {
211 t.Fatalf("MessageToEvent: %v", err)
212 }
213
214 if ev.Kind != 445 {
215 t.Errorf("kind: want 445, got %d", ev.Kind)
216 }
217 hTag := findTag(ev, "h")
218 if hTag != hex.Enc(aliceGS.NostrGroupID) {
219 t.Errorf("h tag: want nostr group ID, got %s", hTag)
220 }
221
222 bobExporter, err := bobGS.DeriveExporterSecret()
223 if err != nil {
224 t.Fatalf("bob exporter secret: %v", err)
225 }
226
227 _, mlsCiphertext, err := marmot.EventToMessage(ev, bobExporter)
228 if err != nil {
229 t.Fatalf("EventToMessage: %v", err)
230 }
231
232 decrypted, _, err := bobGS.Decrypt(mlsCiphertext)
233 if err != nil {
234 t.Fatalf("decrypt: %v", err)
235 }
236
237 if string(decrypted) != "subscribe" {
238 t.Errorf("decrypted: want 'subscribe', got %q", string(decrypted))
239 }
240 }
241
242 // nullRelay is a no-op relay for tests that don't need network.
243 type nullRelay struct{}
244
245 func (n *nullRelay) Publish(ctx context.Context, ev *event.E) error { return nil }
246 func (n *nullRelay) Subscribe(ctx context.Context, ff *filter.S) (marmot.EventStream, error) {
247 return &nullStream{}, nil
248 }
249
250 type nullStream struct{ ch chan *event.E }
251
252 func (s *nullStream) Events() <-chan *event.E {
253 if s.ch == nil {
254 s.ch = make(chan *event.E)
255 }
256 return s.ch
257 }
258 func (s *nullStream) Close() {}
259
260 // --- helpers ---
261
262 func findTag(ev *event.E, name string) string {
263 if ev.Tags == nil {
264 return ""
265 }
266 for _, tg := range *ev.Tags {
267 if len(tg.T) >= 2 && string(tg.T[0]) == name {
268 return string(tg.T[1])
269 }
270 }
271 return ""
272 }
273
274 func waitForMessages(t *testing.T, sink *mockDMSink, pubkey string, n int) {
275 t.Helper()
276 for i := 0; i < 100; i++ {
277 if len(sink.get(pubkey)) >= n {
278 return
279 }
280 time.Sleep(time.Millisecond)
281 }
282 }
283