22092834bd
Chat bots need replies, threads and reactions. Add two optional, omitempty envelope fields (ThreadID, ReplyTo) plus a REACT frame type. The fields ride the cleartext envelope (message-id references, not secret content) and are omitted when unset, so non-threaded frames are byte-for-byte identical on the wire and their signatures unchanged — a non-breaking, additive change. Client gains PublishReply (threaded reply) and React (emoji reaction). The reaction content travels in the payload, so it is sealed like any message and stays confidential in E2E rooms; receivers dispatch on Frame.Type == REACT and read Frame.ReplyTo for the target. Publish is refactored to share one publishFrame path with the new helpers; its behavior is unchanged. Tests: frame round-trip of a threaded REACT frame (golden), non-threaded wire/sig back-compat asserting thr/re keys are absent (edge), Unmarshal of garbage errors (error path), and an end-to-end reply+reaction round-trip in an encrypted ModeMatrix room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
4.3 KiB
Go
135 lines
4.3 KiB
Go
package frame
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestMarshalUnmarshalRoundTrip(t *testing.T) {
|
|
orig := Frame{
|
|
Type: PUB,
|
|
Subject: "room.general",
|
|
Sender: "abc123",
|
|
MsgID: "01J000000000000000000000",
|
|
Epoch: 3,
|
|
Nonce: []byte{1, 2, 3, 4},
|
|
Payload: []byte("ciphertext-bytes"),
|
|
Blob: &BlobRef{Hash: "deadbeef", Nonce: []byte{9, 8, 7}, Size: 42},
|
|
Sig: []byte{0xaa, 0xbb},
|
|
}
|
|
|
|
b, err := orig.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
got, err := Unmarshal(b)
|
|
if err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
|
|
if got.Type != orig.Type || got.Subject != orig.Subject || got.Sender != orig.Sender ||
|
|
got.MsgID != orig.MsgID || got.Epoch != orig.Epoch {
|
|
t.Fatalf("envelope mismatch: got %+v want %+v", got, orig)
|
|
}
|
|
if !bytes.Equal(got.Nonce, orig.Nonce) || !bytes.Equal(got.Payload, orig.Payload) || !bytes.Equal(got.Sig, orig.Sig) {
|
|
t.Fatalf("byte fields mismatch")
|
|
}
|
|
if got.Blob == nil || got.Blob.Hash != orig.Blob.Hash || got.Blob.Size != orig.Blob.Size ||
|
|
!bytes.Equal(got.Blob.Nonce, orig.Blob.Nonce) {
|
|
t.Fatalf("blob ref mismatch: %+v", got.Blob)
|
|
}
|
|
}
|
|
|
|
// TestThreadingRoundTrip (golden) verifies that the additive threading fields
|
|
// survive a marshal/unmarshal cycle and that a REACT frame keeps its target.
|
|
func TestThreadingRoundTrip(t *testing.T) {
|
|
orig := Frame{
|
|
Type: REACT,
|
|
Subject: "room.general",
|
|
Sender: "abc123",
|
|
MsgID: "01J000000000000000000002",
|
|
Epoch: 1,
|
|
ThreadID: "01J000000000000000000000",
|
|
ReplyTo: "01J000000000000000000001",
|
|
Payload: []byte("👍"),
|
|
}
|
|
b, err := orig.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
got, err := Unmarshal(b)
|
|
if err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if got.Type != REACT {
|
|
t.Fatalf("type mismatch: got %d want REACT(%d)", got.Type, REACT)
|
|
}
|
|
if got.ThreadID != orig.ThreadID || got.ReplyTo != orig.ReplyTo {
|
|
t.Fatalf("threading fields lost: got thr=%q re=%q", got.ThreadID, got.ReplyTo)
|
|
}
|
|
if !bytes.Equal(got.Payload, orig.Payload) {
|
|
t.Fatalf("reaction payload mismatch: got %q", got.Payload)
|
|
}
|
|
}
|
|
|
|
// TestNonThreadedWireBackCompat (edge) asserts that a frame without threading
|
|
// metadata serializes with NO thr/re keys at all, so its bytes — and therefore
|
|
// its signature — are identical to a pre-threading frame. This is the
|
|
// guarantee that makes the new fields a non-breaking, additive change.
|
|
func TestNonThreadedWireBackCompat(t *testing.T) {
|
|
f := Frame{Type: PUB, Subject: "room.general", Sender: "x", MsgID: "id", Epoch: 2, Payload: []byte("hi")}
|
|
b, err := f.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
s := string(b)
|
|
if strings.Contains(s, "\"thr\"") || strings.Contains(s, "\"re\"") {
|
|
t.Fatalf("threading keys leaked into a non-threaded frame: %s", s)
|
|
}
|
|
// SigningBytes of a non-threaded frame must also be free of the keys, so old
|
|
// signatures over equivalent frames still verify.
|
|
if sb := f.SigningBytes(); strings.Contains(string(sb), "\"thr\"") || strings.Contains(string(sb), "\"re\"") {
|
|
t.Fatalf("threading keys leaked into SigningBytes: %s", sb)
|
|
}
|
|
}
|
|
|
|
// TestUnmarshalRejectsGarbage (error path) verifies that malformed wire bytes
|
|
// surface as an error rather than a silently zero-valued frame.
|
|
func TestUnmarshalRejectsGarbage(t *testing.T) {
|
|
if _, err := Unmarshal([]byte("{not valid json")); err == nil {
|
|
t.Fatalf("expected error unmarshaling garbage, got nil")
|
|
}
|
|
}
|
|
|
|
func TestEndpointIDDeterministic(t *testing.T) {
|
|
pub := []byte("some-ed25519-public-key-bytes-32")
|
|
a := EndpointID(pub)
|
|
b := EndpointID(pub)
|
|
if a != b {
|
|
t.Fatalf("EndpointID not deterministic: %q != %q", a, b)
|
|
}
|
|
if a == "" {
|
|
t.Fatalf("EndpointID returned empty string")
|
|
}
|
|
// Different inputs must produce different ids.
|
|
if EndpointID([]byte("other-key")) == a {
|
|
t.Fatalf("EndpointID collision for different inputs")
|
|
}
|
|
}
|
|
|
|
func TestSigningBytesExcludesSig(t *testing.T) {
|
|
withSig := Frame{Type: PUB, Subject: "s", Sender: "x", MsgID: "id", Epoch: 1, Payload: []byte("p"), Sig: []byte{1, 2, 3}}
|
|
noSig := withSig
|
|
noSig.Sig = nil
|
|
|
|
if !bytes.Equal(withSig.SigningBytes(), noSig.SigningBytes()) {
|
|
t.Fatalf("SigningBytes should be identical regardless of Sig field")
|
|
}
|
|
// And SigningBytes must not be affected by mutating Sig afterward (value receiver).
|
|
sb := withSig.SigningBytes()
|
|
if bytes.Contains(sb, []byte{1, 2, 3}) {
|
|
t.Fatalf("SigningBytes leaked the Sig bytes")
|
|
}
|
|
}
|