package server import ( "testing" "time" "forge.redroom.link/yves/meowlib" "github.com/alicebob/miniredis" "github.com/go-redis/redis" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" "os" ) func init() { AddLogger(zerolog.New(os.Stderr).Level(zerolog.Disabled)) } // newTestRouter spins up a miniredis instance and returns a RedisRouter wired to it. // The caller must call mr.Close() when done. func newTestRouter(t *testing.T) (*RedisRouter, *miniredis.Miniredis) { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatal(err) } id := CreateIdentity("TestServer", "A test server") router := &RedisRouter{ Name: "TestRedis", ServerIdentity: id, Client: redis.NewClient(&redis.Options{ Addr: mr.Addr(), }), InvitationTimeout: 3600, Context: nil, } // seed the statistics:start key that NewRedisRouter normally sets router.Client.Set("statistics:start", time.Now().UTC().Format(time.RFC3339), 0) return router, mr } // --------------------------------------------------------------------------- // storeMessage / checkForMessage round-trip // --------------------------------------------------------------------------- func TestStoreAndCheckMessage(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "lookup-key-alice" msg := &meowlib.ToServerMessage{ Uuid: "msg-uuid-1", From: "sender-pub-key", Messages: []*meowlib.PackedUserMessage{ { Destination: dest, Payload: []byte("hello alice"), Signature: []byte("sig1"), }, }, } // store resp, err := router.storeMessage(msg) assert.NoError(t, err) assert.Equal(t, "msg-uuid-1", resp.UuidAck) // check: build a pull request for the same key pullMsg := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: dest}, }, } resp, err = router.checkForMessage(pullMsg) assert.NoError(t, err) assert.Len(t, resp.Chat, 1) assert.Equal(t, dest, resp.Chat[0].Destination) assert.Equal(t, []byte("hello alice"), resp.Chat[0].Payload) } func TestStoreMultipleMessagesAndCheck(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "lookup-key-bob" msg := &meowlib.ToServerMessage{ Uuid: "multi-uuid", Messages: []*meowlib.PackedUserMessage{ {Destination: dest, Payload: []byte("msg-1")}, {Destination: dest, Payload: []byte("msg-2")}, {Destination: dest, Payload: []byte("msg-3")}, }, } _, err := router.storeMessage(msg) assert.NoError(t, err) pullMsg := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: dest}, }, } resp, err := router.checkForMessage(pullMsg) assert.NoError(t, err) assert.Len(t, resp.Chat, 3) } // checkForMessage on an empty key returns an empty chat list (no error) func TestCheckForMessageEmpty(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() pullMsg := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: "nonexistent-key"}, }, } resp, err := router.checkForMessage(pullMsg) assert.NoError(t, err) assert.Empty(t, resp.Chat) } // messages are consumed (popped) — a second check returns nothing func TestCheckForMessageConsumes(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "consume-key" msg := &meowlib.ToServerMessage{ Messages: []*meowlib.PackedUserMessage{ {Destination: dest, Payload: []byte("once")}, }, } router.storeMessage(msg) pull := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{{LookupKey: dest}}, } resp, err := router.checkForMessage(pull) assert.NoError(t, err) assert.Len(t, resp.Chat, 1) // second pull — queue is drained resp, err = router.checkForMessage(pull) assert.NoError(t, err) assert.Empty(t, resp.Chat) } // --------------------------------------------------------------------------- // storeMessage with delivery tracking // --------------------------------------------------------------------------- // storeMessage calls SAdd("dvyrq:", redis.Z{...}) when ServerDeliveryUuid // is set. Passing redis.Z (a sorted-set helper struct) to SAdd is a bug in // router.go — the member never actually lands in the set. This test documents // that the code path executes without error; the Redis state assertion is // intentionally omitted until the bug is fixed. func TestStoreMessageDeliveryTracking(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "delivery-dest" msg := &meowlib.ToServerMessage{ Uuid: "store-dvy", From: "sender-pub", Messages: []*meowlib.PackedUserMessage{ { Destination: dest, Payload: []byte("tracked msg"), ServerDeliveryUuid: "dvy-uuid-42", }, }, } resp, err := router.storeMessage(msg) assert.NoError(t, err) assert.Equal(t, "store-dvy", resp.UuidAck) } // --------------------------------------------------------------------------- // storeMessage writes to multiple destinations // --------------------------------------------------------------------------- func TestStoreMessageMultipleDestinations(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() msg := &meowlib.ToServerMessage{ Uuid: "multi-dest", Messages: []*meowlib.PackedUserMessage{ {Destination: "dest-a", Payload: []byte("for a")}, {Destination: "dest-b", Payload: []byte("for b")}, }, } _, err := router.storeMessage(msg) assert.NoError(t, err) // each destination has exactly one message cntA, _ := router.Client.ZCount("msg:dest-a", "-inf", "+inf").Result() cntB, _ := router.Client.ZCount("msg:dest-b", "-inf", "+inf").Result() assert.Equal(t, int64(1), cntA) assert.Equal(t, int64(1), cntB) } // --------------------------------------------------------------------------- // Route dispatcher // --------------------------------------------------------------------------- func TestRouteDispatchesStoreAndCheck(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "route-dest" // first Route call: store a message storeReq := &meowlib.ToServerMessage{ Uuid: "route-store-uuid", Messages: []*meowlib.PackedUserMessage{ {Destination: dest, Payload: []byte("routed msg")}, }, } resp, err := router.Route(storeReq) assert.NoError(t, err) assert.Equal(t, "route-store-uuid", resp.UuidAck) // second Route call: pull that message pullReq := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: dest}, }, } resp, err = router.Route(pullReq) assert.NoError(t, err) assert.Len(t, resp.Chat, 1) assert.Equal(t, []byte("routed msg"), resp.Chat[0].Payload) } // Route with no actionable fields returns nil response and no error func TestRouteEmptyMessage(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() resp, err := router.Route(&meowlib.ToServerMessage{}) assert.NoError(t, err) assert.Nil(t, resp) } // Route updates statistics counters func TestRouteIncrementsTotalCounter(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() router.Route(&meowlib.ToServerMessage{}) router.Route(&meowlib.ToServerMessage{}) router.Route(&meowlib.ToServerMessage{}) val, err := router.Client.Get("statistics:messages:total").Int() assert.NoError(t, err) assert.Equal(t, 3, val) } // --------------------------------------------------------------------------- // handleMatriochka // --------------------------------------------------------------------------- func TestHandleMatriochka(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() msg := &meowlib.ToServerMessage{ Uuid: "matriochka-uuid", MatriochkaMessage: &meowlib.Matriochka{ LookupKey: "mtk-lookup", Data: []byte("onion layer"), Next: &meowlib.MatriochkaServer{ Url: "http://next.server/meow", PublicKey: "next-pub-key", }, }, } resp, err := router.handleMatriochka(msg) assert.NoError(t, err) assert.Equal(t, "matriochka-uuid", resp.UuidAck) // verify something was stored in the mtk sorted set cnt, _ := router.Client.ZCount("mtk", "-inf", "+inf").Result() assert.Equal(t, int64(1), cnt) // deserialize what was stored and verify it round-trips members, _ := router.Client.ZRange("mtk", 0, -1).Result() var stored meowlib.ToServerMessage err = proto.Unmarshal([]byte(members[0]), &stored) assert.NoError(t, err) assert.Equal(t, "mtk-lookup", stored.MatriochkaMessage.LookupKey) assert.Equal(t, []byte("onion layer"), stored.MatriochkaMessage.Data) } // multiple distinct matriochka messages accumulate in the sorted set func TestHandleMatriochkaMultiple(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() // each message must differ so the sorted-set members are unique payloads := []string{"layer-1", "layer-2", "layer-3"} for _, p := range payloads { router.handleMatriochka(&meowlib.ToServerMessage{ Uuid: "m-uuid-" + p, MatriochkaMessage: &meowlib.Matriochka{ Data: []byte(p), }, }) } cnt, _ := router.Client.ZCount("mtk", "-inf", "+inf").Result() assert.Equal(t, int64(3), cnt) } // --------------------------------------------------------------------------- // handleInvitation — step 1 (create) and step 2 (retrieve) // --------------------------------------------------------------------------- func TestHandleInvitationStep1And2(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() payload := []byte("invitation-data") // Step 1: create invitation step1Msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 1, Payload: payload, Timeout: 60, ShortcodeLen: 12, }, } resp, err := router.handleInvitation(step1Msg) assert.NoError(t, err) assert.NotEmpty(t, resp.Invitation.Shortcode) assert.True(t, resp.Invitation.Expiry > 0) shortcode := resp.Invitation.Shortcode // Step 2: retrieve invitation (no password) step2Msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: shortcode, }, } resp, err = router.handleInvitation(step2Msg) assert.NoError(t, err) assert.Equal(t, payload, resp.Invitation.Payload) } func TestHandleInvitationStep1And2WithPassword(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() payload := []byte("secret-invitation") password := "s3cret" // Step 1: create with password step1Msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 1, Payload: payload, Timeout: 60, ShortcodeLen: 10, Password: password, }, } resp, err := router.handleInvitation(step1Msg) assert.NoError(t, err) shortcode := resp.Invitation.Shortcode // Step 2: wrong password step2Wrong := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: shortcode, Password: "wrong", }, } resp, err = router.handleInvitation(step2Wrong) assert.NoError(t, err) assert.Equal(t, []byte("authentication failure"), resp.Invitation.Payload) // Step 2: correct password step2Correct := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: shortcode, Password: password, }, } resp, err = router.handleInvitation(step2Correct) assert.NoError(t, err) assert.Equal(t, payload, resp.Invitation.Payload) } // Step 2 on a non-existent shortcode returns "invitation expired" func TestHandleInvitationStep2NotFound(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() step2Msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: "does-not-exist", }, } resp, err := router.handleInvitation(step2Msg) assert.NoError(t, err) assert.Equal(t, []byte("invitation expired"), resp.Invitation.Payload) } // --------------------------------------------------------------------------- // handleInvitation — step 3 (store answer) + checkForMessage retrieves it // --------------------------------------------------------------------------- func TestHandleInvitationStep3AndRetrieve(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() lookupKey := "initiator-lookup-key" // Build a PackedUserMessage whose Destination is the initiator's lookup key pum := &meowlib.PackedUserMessage{ Destination: lookupKey, Payload: []byte("answer-payload"), } pumBytes, err := proto.Marshal(pum) assert.NoError(t, err) invitationMsg := &meowlib.Invitation{ Step: 3, Payload: pumBytes, Timeout: 60, } step3Msg := &meowlib.ToServerMessage{ Invitation: invitationMsg, } resp, err := router.handleInvitation(step3Msg) assert.NoError(t, err) assert.True(t, resp.Invitation.Expiry > 0) // Now simulate the initiator polling: checkForMessage with the lookup key // and an empty message queue — should fall back to invitation answer pullMsg := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: lookupKey}, }, } resp, err = router.checkForMessage(pullMsg) assert.NoError(t, err) assert.NotNil(t, resp.Invitation) // The stored invitation answer should deserialize cleanly var storedInv meowlib.Invitation err = proto.Unmarshal(resp.Invitation.Payload, &storedInv) // payload is the re-serialized Invitation protobuf from step 3 // just verify it's non-empty assert.NotEmpty(t, resp.Invitation.Payload) } // --------------------------------------------------------------------------- // handleInvitation — password brute-force lockout (3 attempts) // --------------------------------------------------------------------------- func TestHandleInvitationPasswordLockout(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() payload := []byte("locked-invitation") // create invitation with password step1Msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 1, Payload: payload, Timeout: 60, ShortcodeLen: 8, Password: "correct", }, } resp, err := router.handleInvitation(step1Msg) assert.NoError(t, err) shortcode := resp.Invitation.Shortcode // 3 wrong attempts for range 3 { step2 := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: shortcode, Password: "wrong", }, } router.handleInvitation(step2) } // invitation should now be destroyed — even with correct password step2Correct := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 2, Shortcode: shortcode, Password: "correct", }, } resp, err = router.handleInvitation(step2Correct) assert.NoError(t, err) assert.Equal(t, []byte("invitation expired"), resp.Invitation.Payload) } // --------------------------------------------------------------------------- // handleVideo // --------------------------------------------------------------------------- func TestHandleVideoNoServer(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() // VideoServer with no credentials configured — UpdateVideoData still works // (it just sets Url and returns empty credentials slice) msg := &meowlib.ToServerMessage{ Uuid: "video-uuid", VideoData: &meowlib.VideoData{ Room: "test-room", Duration: 300, }, } resp, err := router.handleVideo(msg) assert.NoError(t, err) assert.Equal(t, "video-uuid", resp.UuidAck) assert.NotNil(t, resp.VideoData) assert.Equal(t, "test-room", resp.VideoData.Room) } // --------------------------------------------------------------------------- // Route dispatches matriochka via top-level Route // --------------------------------------------------------------------------- func TestRouteMatriochka(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() msg := &meowlib.ToServerMessage{ Uuid: "route-mtk", MatriochkaMessage: &meowlib.Matriochka{ Data: []byte("wrapped"), }, } resp, err := router.Route(msg) assert.NoError(t, err) assert.Equal(t, "route-mtk", resp.UuidAck) cnt, _ := router.Client.ZCount("mtk", "-inf", "+inf").Result() assert.Equal(t, int64(1), cnt) } // --------------------------------------------------------------------------- // Route dispatches invitation via top-level Route // --------------------------------------------------------------------------- func TestRouteInvitation(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() msg := &meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 1, Payload: []byte("via-route"), Timeout: 30, ShortcodeLen: 6, }, } resp, err := router.Route(msg) assert.NoError(t, err) assert.NotEmpty(t, resp.Invitation.Shortcode) assert.Len(t, resp.Invitation.Shortcode, 6) } // --------------------------------------------------------------------------- // statistics counters // --------------------------------------------------------------------------- func TestStatisticsCountersIncrement(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() dest := "stats-dest" // one store increments usermessages router.Route(&meowlib.ToServerMessage{ Messages: []*meowlib.PackedUserMessage{ {Destination: dest, Payload: []byte("x")}, }, }) val, _ := router.Client.Get("statistics:messages:usermessages").Int() assert.Equal(t, 1, val) // one pull increments messagelookups router.Route(&meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: dest}, }, }) val, _ = router.Client.Get("statistics:messages:messagelookups").Int() assert.Equal(t, 1, val) // one matriochka increments matriochka counter router.Route(&meowlib.ToServerMessage{ MatriochkaMessage: &meowlib.Matriochka{Data: []byte("m")}, }) val, _ = router.Client.Get("statistics:messages:matriochka").Int() assert.Equal(t, 1, val) // one invitation increments invitation counter router.Route(&meowlib.ToServerMessage{ Invitation: &meowlib.Invitation{ Step: 1, Payload: []byte("i"), Timeout: 10, ShortcodeLen: 4, }, }) val, _ = router.Client.Get("statistics:messages:invitation").Int() assert.Equal(t, 1, val) } // --------------------------------------------------------------------------- // checkForMessage with multiple pull request keys // --------------------------------------------------------------------------- func TestCheckForMessageMultipleKeys(t *testing.T) { router, mr := newTestRouter(t) defer mr.Close() // store one message on each of two keys router.storeMessage(&meowlib.ToServerMessage{ Messages: []*meowlib.PackedUserMessage{ {Destination: "key-x", Payload: []byte("from-x")}, }, }) router.storeMessage(&meowlib.ToServerMessage{ Messages: []*meowlib.PackedUserMessage{ {Destination: "key-y", Payload: []byte("from-y")}, }, }) pull := &meowlib.ToServerMessage{ PullRequest: []*meowlib.ConversationRequest{ {LookupKey: "key-x"}, {LookupKey: "key-y"}, }, } resp, err := router.checkForMessage(pull) assert.NoError(t, err) assert.Len(t, resp.Chat, 2) }