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