diff --git a/client/peer.go b/client/peer.go index d51e2d4..dc31b2b 100644 --- a/client/peer.go +++ b/client/peer.go @@ -142,7 +142,7 @@ func (p *Peer) BuildSingleFileMessage(filename string, message []byte) ([]meowli msg.From = p.MyIdentity.Public file.Filename = fi.Name() file.Chunk = uint32(chunk) - file.Data = b[:readTotal] + file.Data = append([]byte(nil), b[:readTotal]...) file.Size = uint64(fi.Size()) msg.Files = append(msg.Files, &file) msg.Type = "1" diff --git a/client/peer_test.go b/client/peer_test.go index d561115..f8358cd 100644 --- a/client/peer_test.go +++ b/client/peer_test.go @@ -1,13 +1,515 @@ package client import ( + "os" "strconv" "testing" + "forge.redroom.link/yves/meowlib" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" ) +// makePeerPair creates two peers with properly cross-wired keypairs, simulating +// a completed invitation. Alice's contact keys point to Bob's and vice versa. +func makePeerPair(t *testing.T) (alice *Peer, bob *Peer) { + t.Helper() + aliceIdentity, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + aliceEncryption, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + aliceLookup, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + + bobIdentity, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + bobEncryption, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + bobLookup, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + + alice = &Peer{ + Uid: "alice-uid", + Name: "bob", + MyName: "alice", + MyIdentity: aliceIdentity, + MyEncryptionKp: aliceEncryption, + MyLookupKp: aliceLookup, + ContactPublicKey: bobIdentity.Public, + ContactEncryption: bobEncryption.Public, + ContactLookupKey: bobLookup.Public, + } + bob = &Peer{ + Uid: "bob-uid", + Name: "alice", + MyName: "bob", + MyIdentity: bobIdentity, + MyEncryptionKp: bobEncryption, + MyLookupKp: bobLookup, + ContactPublicKey: aliceIdentity.Public, + ContactEncryption: aliceEncryption.Public, + ContactLookupKey: aliceLookup.Public, + } + return +} + +// --------------------------------------------------------------------------- +// Invitation state +// --------------------------------------------------------------------------- + +func TestInvitationPending_True(t *testing.T) { + p := &Peer{} // ContactPublicKey is empty + assert.True(t, p.InvitationPending()) +} + +func TestInvitationPending_False(t *testing.T) { + p := &Peer{ContactPublicKey: "some-key"} + assert.False(t, p.InvitationPending()) +} + +// --------------------------------------------------------------------------- +// BuildSimpleUserMessage +// --------------------------------------------------------------------------- + +func TestBuildSimpleUserMessage(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest-lookup-key", + MyIdentity: &meowlib.KeyPair{Public: "my-pub-key"}, + } + msg, err := p.BuildSimpleUserMessage([]byte("hello")) + assert.NoError(t, err) + assert.Equal(t, "dest-lookup-key", msg.Destination) + assert.Equal(t, "my-pub-key", msg.From) + assert.Equal(t, []byte("hello"), msg.Data) + assert.Equal(t, "1", msg.Type) + assert.NotNil(t, msg.Status) + assert.NotEmpty(t, msg.Status.Uuid) +} + +func TestBuildSimpleUserMessage_EmptyData(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest", + MyIdentity: &meowlib.KeyPair{Public: "pub"}, + } + msg, err := p.BuildSimpleUserMessage([]byte{}) + assert.NoError(t, err) + assert.Empty(t, msg.Data) + assert.NotEmpty(t, msg.Status.Uuid) +} + +func TestBuildSimpleUserMessage_UniqueUuids(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest", + MyIdentity: &meowlib.KeyPair{Public: "pub"}, + } + msg1, _ := p.BuildSimpleUserMessage([]byte("a")) + msg2, _ := p.BuildSimpleUserMessage([]byte("b")) + assert.NotEqual(t, msg1.Status.Uuid, msg2.Status.Uuid) +} + +// --------------------------------------------------------------------------- +// BuildSingleFileMessage +// --------------------------------------------------------------------------- + +func TestBuildSingleFileMessage_FileNotFound(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest", + MyIdentity: &meowlib.KeyPair{Public: "pub"}, + } + GetConfig().Chunksize = 1024 + _, err := p.BuildSingleFileMessage("/nonexistent/path/file.txt", []byte("msg")) + assert.Error(t, err) +} + +func TestBuildSingleFileMessage_SingleChunk(t *testing.T) { + content := []byte("small file content") + tmpFile, err := os.CreateTemp("", "peer_test_*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Write(content) + tmpFile.Close() + + p := &Peer{ + ContactLookupKey: "dest-lookup", + MyIdentity: &meowlib.KeyPair{Public: "my-pub"}, + } + GetConfig().Chunksize = 1024 // larger than file + + msgs, err := p.BuildSingleFileMessage(tmpFile.Name(), []byte("msg")) + assert.NoError(t, err) + assert.Len(t, msgs, 1) + assert.Equal(t, "dest-lookup", msgs[0].Destination) + assert.Equal(t, "my-pub", msgs[0].From) + assert.Equal(t, "1", msgs[0].Type) + assert.Len(t, msgs[0].Files, 1) + assert.Equal(t, content, msgs[0].Files[0].Data) + assert.Equal(t, uint32(0), msgs[0].Files[0].Chunk) + assert.Equal(t, uint64(len(content)), msgs[0].Files[0].Size) + assert.NotNil(t, msgs[0].Status) + assert.NotEmpty(t, msgs[0].Status.Uuid) +} + +func TestBuildSingleFileMessage_MultipleChunks(t *testing.T) { + // 20 bytes with chunksize 7 → chunks of [7, 7, 6], last chunk guaranteed + // to arrive with nil error on os.File before a separate (0, EOF) read. + content := []byte("abcdefghijklmnopqrst") + tmpFile, err := os.CreateTemp("", "peer_test_multi_*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Write(content) + tmpFile.Close() + + p := &Peer{ + ContactLookupKey: "dest-lookup", + MyIdentity: &meowlib.KeyPair{Public: "my-pub"}, + } + GetConfig().Chunksize = 7 + + msgs, err := p.BuildSingleFileMessage(tmpFile.Name(), []byte("msg")) + assert.NoError(t, err) + assert.Len(t, msgs, 3) + + // Verify chunk indices and reassembly + var reassembled []byte + for i, m := range msgs { + assert.Equal(t, uint32(i), m.Files[0].Chunk) + assert.Equal(t, uint64(len(content)), m.Files[0].Size) + reassembled = append(reassembled, m.Files[0].Data...) + } + assert.Equal(t, content, reassembled) + + // Only the first chunk carries a status UUID + assert.NotNil(t, msgs[0].Status) + assert.NotEmpty(t, msgs[0].Status.Uuid) + assert.Nil(t, msgs[1].Status) + assert.Nil(t, msgs[2].Status) +} + +func TestBuildSingleFileMessage_EmptyFile(t *testing.T) { + tmpFile, err := os.CreateTemp("", "peer_test_empty_*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() // 0 bytes + + p := &Peer{ + ContactLookupKey: "dest", + MyIdentity: &meowlib.KeyPair{Public: "pub"}, + } + GetConfig().Chunksize = 1024 + + msgs, err := p.BuildSingleFileMessage(tmpFile.Name(), []byte("msg")) + assert.NoError(t, err) + assert.Empty(t, msgs) +} + +// --------------------------------------------------------------------------- +// BuildInvitationAnswerMessage +// --------------------------------------------------------------------------- + +func TestBuildInvitationAnswerMessage(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest-lookup", + MyIdentity: &meowlib.KeyPair{Public: "my-pub"}, + InvitationId: "inv-uuid-123", + } + contactCard := &meowlib.ContactCard{ + Name: "Alice", + ContactPublicKey: "alice-pub", + } + + msg, err := p.BuildInvitationAnswerMessage(contactCard) + assert.NoError(t, err) + assert.Equal(t, "dest-lookup", msg.Destination) + assert.Equal(t, "my-pub", msg.From) + assert.Equal(t, "1", msg.Type) + assert.NotNil(t, msg.Invitation) + assert.Equal(t, int32(3), msg.Invitation.Step) + assert.Equal(t, "inv-uuid-123", msg.Invitation.Uuid) + + // Payload is the proto-serialized contact card + var decoded meowlib.ContactCard + err = proto.Unmarshal(msg.Invitation.Payload, &decoded) + assert.NoError(t, err) + assert.Equal(t, "Alice", decoded.Name) + assert.Equal(t, "alice-pub", decoded.ContactPublicKey) +} + +// --------------------------------------------------------------------------- +// Serialize / Deserialize +// --------------------------------------------------------------------------- + +func TestSerializeDeserializeUserMessage(t *testing.T) { + p := &Peer{} + original := &meowlib.UserMessage{ + Destination: "dest-key", + From: "from-key", + Type: "1", + Data: []byte("test payload"), + Status: &meowlib.ConversationStatus{Uuid: "uuid-1"}, + } + + serialized, err := p.SerializeUserMessage(original) + assert.NoError(t, err) + assert.NotEmpty(t, serialized) + + restored, err := p.DeserializeUserMessage(serialized) + assert.NoError(t, err) + assert.Equal(t, original.Destination, restored.Destination) + assert.Equal(t, original.From, restored.From) + assert.Equal(t, original.Type, restored.Type) + assert.Equal(t, original.Data, restored.Data) + assert.Equal(t, original.Status.Uuid, restored.Status.Uuid) +} + +func TestDeserializeUserMessage_InvalidData(t *testing.T) { + p := &Peer{} + // tag = field 1 wire type 2 (length-delimited), length = 10, but 0 bytes follow → EOF + _, err := p.DeserializeUserMessage([]byte{0x0a, 0x0a}) + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// AsymEncryptMessage / AsymDecryptMessage +// --------------------------------------------------------------------------- + +func TestAsymEncryptDecryptMessage_RoundTrip(t *testing.T) { + alice, bob := makePeerPair(t) + plaintext := []byte("secret message from alice to bob") + + enc, err := alice.AsymEncryptMessage(plaintext) + assert.NoError(t, err) + assert.NotEmpty(t, enc.Data) + assert.NotEmpty(t, enc.Signature) + + decrypted, err := bob.AsymDecryptMessage(enc.Data, enc.Signature) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestAsymEncryptDecryptMessage_Bidirectional(t *testing.T) { + alice, bob := makePeerPair(t) + + // Alice → Bob + enc1, err := alice.AsymEncryptMessage([]byte("alice says hi")) + assert.NoError(t, err) + dec1, err := bob.AsymDecryptMessage(enc1.Data, enc1.Signature) + assert.NoError(t, err) + assert.Equal(t, []byte("alice says hi"), dec1) + + // Bob → Alice + enc2, err := bob.AsymEncryptMessage([]byte("bob says hi")) + assert.NoError(t, err) + dec2, err := alice.AsymDecryptMessage(enc2.Data, enc2.Signature) + assert.NoError(t, err) + assert.Equal(t, []byte("bob says hi"), dec2) +} + +func TestAsymDecryptMessage_WrongSignatureKey(t *testing.T) { + alice, bob := makePeerPair(t) + + enc, err := alice.AsymEncryptMessage([]byte("hello")) + assert.NoError(t, err) + + // Bob verifies against a random key instead of Alice's — must fail + eve, err := meowlib.NewKeyPair() + if err != nil { + t.Fatal(err) + } + bobTampered := *bob + bobTampered.ContactPublicKey = eve.Public + + _, err = bobTampered.AsymDecryptMessage(enc.Data, enc.Signature) + assert.Error(t, err) +} + +func TestAsymEncryptMessage_InvalidKey(t *testing.T) { + p := &Peer{ + ContactEncryption: "not-a-valid-key", + MyIdentity: &meowlib.KeyPair{Private: "also-invalid"}, + } + _, err := p.AsymEncryptMessage([]byte("hello")) + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// PackUserMessage / UnPackUserMessage +// --------------------------------------------------------------------------- + +func TestPackUserMessage(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest-key", + ServerDeliveryInfo: false, + } + packed := p.PackUserMessage([]byte("payload"), []byte("sig")) + assert.Equal(t, "dest-key", packed.Destination) + assert.Equal(t, []byte("payload"), packed.Payload) + assert.Equal(t, []byte("sig"), packed.Signature) + assert.Empty(t, packed.ServerDeliveryUuid) +} + +func TestPackUserMessage_WithDeliveryTracking(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest-key", + ServerDeliveryInfo: true, + } + packed := p.PackUserMessage([]byte("payload"), []byte("sig")) + assert.NotEmpty(t, packed.ServerDeliveryUuid) + + // Two calls produce different delivery UUIDs + packed2 := p.PackUserMessage([]byte("payload"), []byte("sig")) + assert.NotEqual(t, packed.ServerDeliveryUuid, packed2.ServerDeliveryUuid) +} + +func TestUnPackUserMessage(t *testing.T) { + p := &Peer{} + // UnPackUserMessage unmarshals a PackedServerMessage (fields 2,3 = payload, signature) + original := &meowlib.PackedServerMessage{ + From: "sender", + Payload: []byte("the payload"), + Signature: []byte("the signature"), + } + data, err := proto.Marshal(original) + if err != nil { + t.Fatal(err) + } + + payload, signature, err := p.UnPackUserMessage(data) + assert.NoError(t, err) + assert.Equal(t, []byte("the payload"), payload) + assert.Equal(t, []byte("the signature"), signature) +} + +func TestUnPackUserMessage_InvalidData(t *testing.T) { + p := &Peer{} + // Truncated varint — all continuation bits set, no terminator + _, _, err := p.UnPackUserMessage([]byte{0xff, 0xff, 0xff, 0xff, 0xff}) + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// ProcessOutboundUserMessage / ProcessInboundUserMessage (full pipeline) +// --------------------------------------------------------------------------- + +func TestProcessOutboundInbound_RoundTrip(t *testing.T) { + alice, bob := makePeerPair(t) + + userMsg, err := alice.BuildSimpleUserMessage([]byte("end to end test")) + assert.NoError(t, err) + + packed, err := alice.ProcessOutboundUserMessage(userMsg) + assert.NoError(t, err) + assert.NotEmpty(t, packed.Payload) + assert.NotEmpty(t, packed.Signature) + assert.Equal(t, bob.MyLookupKp.Public, packed.Destination) + + received, err := bob.ProcessInboundUserMessage(packed.Payload, packed.Signature) + assert.NoError(t, err) + assert.Equal(t, []byte("end to end test"), received.Data) + assert.Equal(t, alice.MyIdentity.Public, received.From) +} + +func TestProcessOutboundInbound_EmptyMessage(t *testing.T) { + alice, bob := makePeerPair(t) + + userMsg, err := alice.BuildSimpleUserMessage([]byte{}) + assert.NoError(t, err) + + packed, err := alice.ProcessOutboundUserMessage(userMsg) + assert.NoError(t, err) + + received, err := bob.ProcessInboundUserMessage(packed.Payload, packed.Signature) + assert.NoError(t, err) + assert.Empty(t, received.Data) +} + +func TestProcessOutboundUserMessage_InvalidKey(t *testing.T) { + p := &Peer{ + ContactLookupKey: "dest", + ContactEncryption: "invalid-key", + MyIdentity: &meowlib.KeyPair{Public: "pub", Private: "invalid-priv"}, + } + msg, _ := p.BuildSimpleUserMessage([]byte("test")) + _, err := p.ProcessOutboundUserMessage(msg) + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// GetConversationRequest +// --------------------------------------------------------------------------- + +func TestGetConversationRequest(t *testing.T) { + p := &Peer{} + cr := p.GetConversationRequest() + assert.NotNil(t, cr) +} + +// --------------------------------------------------------------------------- +// SetDbPassword / GetDbPassword +// --------------------------------------------------------------------------- + +func TestGetDbPassword_NoPasswordSet(t *testing.T) { + p := &Peer{} // no explicit dbPassword + GetConfig().Clean() + _, err := p.GetDbPassword() + assert.Error(t, err) +} + +func TestSetGetDbPassword(t *testing.T) { + p := &Peer{} + p.SetDbPassword("my-secret-password") + pw, err := p.GetDbPassword() + assert.NoError(t, err) + assert.Equal(t, "my-secret-password", pw) +} + +func TestGetDbPassword_FallbackToMemPass(t *testing.T) { + p := &Peer{} // dbPassword not set → falls back to config + GetConfig().SetMemPass("config-password") + pw, err := p.GetDbPassword() + assert.NoError(t, err) + assert.Equal(t, "config-password", pw) +} + +// --------------------------------------------------------------------------- +// Stub / no-op functions +// --------------------------------------------------------------------------- + +func TestUpdateMessage_ReturnsNil(t *testing.T) { + p := &Peer{} + err := p.UpdateMessage(InternalUserMessage{}) + assert.NoError(t, err) +} + +func TestLoadMessage_ReturnsNil(t *testing.T) { + p := &Peer{} + msg, err := p.LoadMessage("some-uid") + assert.NoError(t, err) + assert.Nil(t, msg) +} + +// --------------------------------------------------------------------------- +// Original test (retained) +// --------------------------------------------------------------------------- + func TestGetFromPublicKey(t *testing.T) { id, err := CreateIdentity("test") if err != nil {