779 lines
21 KiB
Go
779 lines
21 KiB
Go
package xmpp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.i3omb.com/gronod/ha-n95-local-control/internal/config"
|
|
"git.i3omb.com/gronod/ha-n95-local-control/internal/session"
|
|
)
|
|
|
|
func init() {
|
|
// Keep tests quiet unless they explicitly fail.
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
}
|
|
|
|
func testConfig() config.Config {
|
|
cfg, err := config.Load([]string{
|
|
"ADVERTISE_IP=192.0.2.10",
|
|
"MQTT_HOST=mqtt.example.invalid",
|
|
"CONTROLLER_JID=n95bridge@ecouser.net/homeassistant",
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func TestTokenizerSeveralStanzasOneSegment(t *testing.T) {
|
|
tok := NewTokenizer()
|
|
// Feed the stream open first so the tokenizer is in-stream.
|
|
data := `<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>` +
|
|
`<iq from='serial@155.ecorobot.net/atom' to='155.ecorobot.net' id='1234567890' type='get'><ping xmlns='urn:xmpp:ping'/></iq>` +
|
|
`<iq type='set' id='a'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>atom</resource></bind></iq>` +
|
|
`<presence><status>hello world</status></presence>`
|
|
evs := tok.Feed([]byte(data))
|
|
|
|
var opens, stanzas int
|
|
for _, ev := range evs {
|
|
switch ev.Kind {
|
|
case StreamOpen:
|
|
opens++
|
|
case Stanza:
|
|
stanzas++
|
|
case TokenError:
|
|
t.Fatalf("tokenizer error: %v, data so far %q", tok.Err(), ev.Data)
|
|
}
|
|
}
|
|
if opens != 1 {
|
|
t.Fatalf("opens = %d, want 1", opens)
|
|
}
|
|
if stanzas != 3 {
|
|
t.Fatalf("stanzas = %d, want 3", stanzas)
|
|
}
|
|
if tok.Err() != nil {
|
|
t.Fatalf("tokenizer error after feed: %v", tok.Err())
|
|
}
|
|
// Remaining buffer should be empty after the three complete stanzas.
|
|
if len(tok.buf) != 0 {
|
|
t.Fatalf("remaining buffer = %q, want empty", tok.buf)
|
|
}
|
|
}
|
|
|
|
func TestTokenizerStanzaSpansSegments(t *testing.T) {
|
|
tok := NewTokenizer()
|
|
tok.Feed([]byte(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`))
|
|
part1 := `<iq from='serial@155.ecorobot.net/atom' to='155.ecorobot.net' id='1000000000' type='get'><ping xmlns='urn:xmpp:ping'/></iq><iq from='serial@155.ecorobot.net/atom' to='155.ecorobot.net' id='1000000001' type='get'`
|
|
part2 := `><ping xmlns='urn:xmpp:ping'/></iq>`
|
|
evs1 := tok.Feed([]byte(part1))
|
|
if len(evs1) != 1 {
|
|
t.Fatalf("first feed events = %d, want 1: %+v", len(evs1), evs1)
|
|
}
|
|
evs2 := tok.Feed([]byte(part2))
|
|
if len(evs2) != 1 {
|
|
t.Fatalf("second feed events = %d, want 1: %+v", len(evs2), evs2)
|
|
}
|
|
if evs2[0].Kind != Stanza {
|
|
t.Fatalf("second event kind = %v, want Stanza", evs2[0].Kind)
|
|
}
|
|
}
|
|
|
|
func TestStreamIDRepeatedAfterSASL(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
registry := session.NewRegistry()
|
|
bus := session.NewBus()
|
|
rec := &recorder{}
|
|
bus.Register(rec)
|
|
|
|
cfg := testConfig()
|
|
clock := newFakeClock()
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: clock,
|
|
}
|
|
go c.serve()
|
|
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
open1 := xc.recvUntil("</stream:features>")
|
|
id1 := extractAttr(open1, "id")
|
|
if id1 == "" {
|
|
t.Fatalf("first stream id missing: %q", open1)
|
|
}
|
|
|
|
xc.send(saslPlain("serial1", "secret"))
|
|
xc.recvUntil("<success")
|
|
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
open2 := xc.recvUntil("</stream:features>")
|
|
id2 := extractAttr(open2, "id")
|
|
if id1 != id2 {
|
|
t.Fatalf("stream id changed: %q -> %q", id1, id2)
|
|
}
|
|
|
|
// A second TCP connection must receive a different id.
|
|
client2, server2 := net.Pipe()
|
|
defer client2.Close()
|
|
defer server2.Close()
|
|
c2 := &Conn{
|
|
netConn: server2,
|
|
cfg: cfg,
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c2.serve()
|
|
xc2 := &xmppClient{t: t, c: client2}
|
|
xc2.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
open3 := xc2.recvUntil("</stream:features>")
|
|
id3 := extractAttr(open3, "id")
|
|
if id3 == "" || id3 == id1 {
|
|
t.Fatalf("second connection stream id not unique: %q", id3)
|
|
}
|
|
}
|
|
|
|
func TestStartTLSAdvertisedNotWaited(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: testConfig(),
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c.serve()
|
|
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
open1 := xc.recvUntil("</stream:features>")
|
|
if !strings.Contains(open1, `<starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls>`) {
|
|
t.Fatalf("missing starttls required: %q", open1)
|
|
}
|
|
|
|
// Skip STARTTLS and send SASL directly; server answers success.
|
|
xc.send(saslPlain("serial2", "secret"))
|
|
xc.recvUntil("<success")
|
|
}
|
|
|
|
func TestHandshakeToReady(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
registry := session.NewRegistry()
|
|
bus := session.NewBus()
|
|
rec := &recorder{}
|
|
bus.Register(rec)
|
|
|
|
cfg := testConfig()
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: newFakeClock(),
|
|
}
|
|
go c.serve()
|
|
|
|
jid := completeHandshake(t, client, "serial3", "atom")
|
|
wantJID := "serial3@155.ecorobot.net/atom"
|
|
if jid != wantJID {
|
|
t.Fatalf("bind JID = %q, want %q", jid, wantJID)
|
|
}
|
|
|
|
mustWaitFor(t, func() bool {
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
return len(rec.ready) == 1
|
|
})
|
|
rec.mu.Lock()
|
|
readyCount := len(rec.ready)
|
|
rec.mu.Unlock()
|
|
if readyCount != 1 {
|
|
t.Fatalf("SessionReady count = %d, want 1", readyCount)
|
|
}
|
|
|
|
currentGen, ok := registry.Current(wantJID)
|
|
if !ok || currentGen != 1 {
|
|
t.Fatalf("Current(%s) = %d %v, want 1 true", wantJID, currentGen, ok)
|
|
}
|
|
}
|
|
|
|
func TestDummyPresenceSpaces(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: testConfig(),
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c.serve()
|
|
|
|
jid, dummy := completeHandshakeEx(t, client, "serial4", "atom")
|
|
wantTo := fmt.Sprintf(`to="%s"`, jid)
|
|
if !strings.Contains(dummy, `<presence`) || !strings.Contains(dummy, wantTo) || !strings.Contains(dummy, "> dummy </presence>") {
|
|
t.Fatalf("dummy presence = %q, want to=%q with > dummy </presence>", dummy, jid)
|
|
}
|
|
}
|
|
|
|
func TestSASLAcceptedAndNotLogged(t *testing.T) {
|
|
// Capture all log records.
|
|
var logBuf bytes.Buffer
|
|
h := &captureHandler{Level: slog.LevelDebug}
|
|
slog.SetDefault(slog.New(h))
|
|
defer slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: testConfig(),
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c.serve()
|
|
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xc.recvUntil("</stream:features>")
|
|
|
|
// Valid PLAIN credential.
|
|
payload := "\x00serial5\x00any-password"
|
|
token := base64.StdEncoding.EncodeToString([]byte(payload))
|
|
xc.send(fmt.Sprintf(`<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>%s</auth>`, token))
|
|
xc.recvUntil("<success")
|
|
_ = logBuf.String()
|
|
|
|
h.mu.Lock()
|
|
records := h.records
|
|
h.mu.Unlock()
|
|
joined := strings.Join(records, "\n")
|
|
if strings.Contains(joined, token) {
|
|
t.Fatalf("log contained base64 token")
|
|
}
|
|
if strings.Contains(joined, "any-password") {
|
|
t.Fatalf("log contained password")
|
|
}
|
|
if !strings.Contains(joined, "serial5") {
|
|
t.Fatalf("log did not contain authcid: %q", joined)
|
|
}
|
|
|
|
// Malformed credential.
|
|
client2, server2 := net.Pipe()
|
|
defer client2.Close()
|
|
defer server2.Close()
|
|
h2 := &captureHandler{Level: slog.LevelDebug}
|
|
slog.SetDefault(slog.New(h2))
|
|
c2 := &Conn{
|
|
netConn: server2,
|
|
cfg: testConfig(),
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c2.serve()
|
|
xc2 := &xmppClient{t: t, c: client2}
|
|
xc2.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xc2.recvUntil("</stream:features>")
|
|
xc2.send(`<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>!!!</auth>`)
|
|
fail := xc2.recvUntil("</failure>")
|
|
if !strings.Contains(fail, "malformed-request") {
|
|
t.Fatalf("expected malformed-request failure: %q", fail)
|
|
}
|
|
h2.mu.Lock()
|
|
records2 := h2.records
|
|
h2.mu.Unlock()
|
|
joined2 := strings.Join(records2, "\n")
|
|
if strings.Contains(joined2, "!!!") {
|
|
t.Fatalf("malformed log contained raw payload")
|
|
}
|
|
if !strings.Contains(joined2, "sasl malformed") {
|
|
t.Fatalf("malformed log did not contain 'sasl malformed': %q", joined2)
|
|
}
|
|
}
|
|
|
|
func TestSessionReplaceAtBind(t *testing.T) {
|
|
registry := session.NewRegistry()
|
|
bus := session.NewBus()
|
|
rec := &recorder{}
|
|
bus.Register(rec)
|
|
cfg := testConfig()
|
|
clock := newFakeClock()
|
|
|
|
// Connection A completes handshake through session, not yet READY.
|
|
clientA, serverA := net.Pipe()
|
|
defer clientA.Close()
|
|
cA := &Conn{
|
|
netConn: serverA,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: clock,
|
|
}
|
|
go cA.serve()
|
|
jid := completeHandshakeNoReady(t, clientA, "serial6", "atom")
|
|
// Drain A so the replacement close-with-write does not block on a pipe.
|
|
go func() { _, _ = io.Copy(io.Discard, clientA) }()
|
|
|
|
// Connection B starts, finishes SASL, then sends bind for the same JID.
|
|
clientB, serverB := net.Pipe()
|
|
defer clientB.Close()
|
|
cB := &Conn{
|
|
netConn: serverB,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: clock,
|
|
}
|
|
go cB.serve()
|
|
xcB := &xmppClient{t: t, c: clientB}
|
|
xcB.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xcB.recvUntil("</stream:features>")
|
|
xcB.send(saslPlain("serial6", "pw"))
|
|
xcB.recvUntil("<success")
|
|
xcB.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xcB.recvUntil("</stream:features>")
|
|
xcB.send(`<iq type='set' id='bindB'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>atom</resource></bind></iq>`)
|
|
bindResult := xcB.recvUntil("</iq>")
|
|
if !strings.Contains(bindResult, jid) {
|
|
t.Fatalf("B bind result missing JID: %q", bindResult)
|
|
}
|
|
|
|
// A's later bytes should not be delivered; send a presence on A.
|
|
clientA.Write([]byte(`<presence><status>hello world</status></presence>`))
|
|
|
|
mustWaitFor(t, func() bool {
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
return len(rec.down) == 1
|
|
})
|
|
|
|
rec.mu.Lock()
|
|
replacedReasons := []string{}
|
|
for _, d := range rec.down {
|
|
replacedReasons = append(replacedReasons, d.Reason)
|
|
}
|
|
readyCount := len(rec.ready)
|
|
rec.mu.Unlock()
|
|
if len(replacedReasons) != 1 || replacedReasons[0] != session.ReasonReplaced {
|
|
t.Fatalf("replacement down events = %v, want one replaced", replacedReasons)
|
|
}
|
|
if readyCount != 0 {
|
|
t.Fatalf("SessionReady emitted from replaced A: %d", readyCount)
|
|
}
|
|
|
|
// Send on A's generation returns ErrStale.
|
|
if err := cA.registry.Send(jid, 1, []byte("x")); !errors.Is(err, session.ErrStale) {
|
|
t.Fatalf("Send on gen 1 error = %v, want ErrStale", err)
|
|
}
|
|
}
|
|
|
|
func TestBotPingMatchingID(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: testConfig(),
|
|
registry: session.NewRegistry(),
|
|
bus: session.NewBus(),
|
|
clock: newFakeClock(),
|
|
}
|
|
go c.serve()
|
|
|
|
completeHandshake(t, client, "serial7", "atom")
|
|
_ = xcRecvPing(t, client) // consume announce ping
|
|
|
|
xc := &xmppClient{t: t, c: client}
|
|
bigID := "12345678901234567890"
|
|
xc.send(fmt.Sprintf(`<iq from='serial7@155.ecorobot.net/atom' to='155.ecorobot.net' id='%s' type='get'><ping xmlns='urn:xmpp:ping'/></iq>`, bigID))
|
|
reply := xc.recvUntil("/>")
|
|
if !strings.Contains(reply, fmt.Sprintf(`id="%s"`, bigID)) {
|
|
t.Fatalf("reply missing id: %q", reply)
|
|
}
|
|
if !strings.Contains(reply, `type="result"`) {
|
|
t.Fatalf("reply not type=result: %q", reply)
|
|
}
|
|
if !strings.Contains(reply, `from="155.ecorobot.net"`) {
|
|
t.Fatalf("reply from wrong: %q", reply)
|
|
}
|
|
if !strings.Contains(reply, fmt.Sprintf(`to="serial7@155.ecorobot.net/atom"`)) {
|
|
t.Fatalf("reply to wrong: %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestAnnouncePingAfterReady(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
registry := session.NewRegistry()
|
|
bus := session.NewBus()
|
|
rec := &recorder{}
|
|
bus.Register(rec)
|
|
cfg := testConfig()
|
|
clock := newFakeClock()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: clock,
|
|
}
|
|
go c.serve()
|
|
|
|
completeHandshake(t, client, "serial8", "atom")
|
|
// The announce ping is sent immediately after READY.
|
|
ping := xcRecvPing(t, client)
|
|
waitForGoroutines() // let pingLoop reach its select
|
|
if ping == "" {
|
|
t.Fatalf("no announce ping received")
|
|
}
|
|
if !strings.Contains(ping, cfg.ControllerJID) {
|
|
t.Fatalf("ping from not controller JID: %q", ping)
|
|
}
|
|
|
|
// Reply to the announce ping.
|
|
id := extractAttr(ping, "id")
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(fmt.Sprintf(`<iq type='result' from='serial8@155.ecorobot.net/atom' to='%s' id='%s'/>`, cfg.ControllerJID, id))
|
|
|
|
// Wait for AnnounceOK.
|
|
mustWaitFor(t, func() bool {
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
return len(rec.announce) == 1
|
|
})
|
|
rec.mu.Lock()
|
|
gen := rec.announce[0].Generation
|
|
rec.mu.Unlock()
|
|
if gen != 1 {
|
|
t.Fatalf("AnnounceOK generation = %d, want 1", gen)
|
|
}
|
|
}
|
|
|
|
func TestControllerPingDeadlineSignalsDown(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
registry := session.NewRegistry()
|
|
bus := session.NewBus()
|
|
rec := &recorder{}
|
|
bus.Register(rec)
|
|
cfg := testConfig()
|
|
clock := newFakeClock()
|
|
|
|
c := &Conn{
|
|
netConn: server,
|
|
cfg: cfg,
|
|
registry: registry,
|
|
bus: bus,
|
|
clock: clock,
|
|
}
|
|
go c.serve()
|
|
|
|
completeHandshake(t, client, "serial9", "atom")
|
|
ping := xcRecvPing(t, client)
|
|
waitForGoroutines() // let pingLoop reach its select
|
|
if ping == "" {
|
|
t.Fatalf("no announce ping received")
|
|
}
|
|
|
|
// Advance 12 seconds with no reply.
|
|
clock.Advance(PingResultTimeout)
|
|
|
|
mustWaitFor(t, func() bool {
|
|
rec.mu.Lock()
|
|
defer rec.mu.Unlock()
|
|
return len(rec.down) == 1
|
|
})
|
|
rec.mu.Lock()
|
|
reason := rec.down[0].Reason
|
|
rec.mu.Unlock()
|
|
if reason != session.ReasonPingTimeout {
|
|
t.Fatalf("down reason = %q, want ping-timeout", reason)
|
|
}
|
|
}
|
|
|
|
// completeHandshake performs the full client handshake up to READY and returns
|
|
// the bound full JID.
|
|
func completeHandshake(t *testing.T, client net.Conn, serial, resource string) string {
|
|
jid, _ := completeHandshakeEx(t, client, serial, resource)
|
|
return jid
|
|
}
|
|
|
|
// completeHandshakeEx returns the bound JID and the raw dummy presence response.
|
|
func completeHandshakeEx(t *testing.T, client net.Conn, serial, resource string) (jid, dummy string) {
|
|
jid = completeHandshakeNoReady(t, client, serial, resource)
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(`<presence><status>hello world</status></presence>`)
|
|
dummy = xc.recvUntil("</presence>")
|
|
if !strings.Contains(dummy, "> dummy </presence>") {
|
|
t.Fatalf("dummy presence missing: %q", dummy)
|
|
}
|
|
return jid, dummy
|
|
}
|
|
|
|
func completeHandshakeNoReady(t *testing.T, client net.Conn, serial, resource string) string {
|
|
xc := &xmppClient{t: t, c: client}
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xc.recvUntil("</stream:features>")
|
|
xc.send(saslPlain(serial, "password"))
|
|
xc.recvUntil("<success")
|
|
xc.send(`<?xml version='1.0'?><stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>`)
|
|
xc.recvUntil("</stream:features>")
|
|
xc.send(fmt.Sprintf(`<iq type='set' id='bind1'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>%s</resource></bind></iq>`, resource))
|
|
bindResult := xc.recvUntil("</iq>")
|
|
jid := extractJID(bindResult)
|
|
if jid == "" {
|
|
t.Fatalf("no JID in bind result: %q", bindResult)
|
|
}
|
|
xc.send(`<iq type='set' id='sess1'><session xmlns='urn:ietf:params:xml:ns:xmpp-session'/></iq>`)
|
|
xc.recvUntil("/>")
|
|
return jid
|
|
}
|
|
|
|
func saslPlain(serial, password string) string {
|
|
payload := fmt.Sprintf("\x00%s\x00%s", serial, password)
|
|
token := base64.StdEncoding.EncodeToString([]byte(payload))
|
|
return fmt.Sprintf(`<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>%s</auth>`, token)
|
|
}
|
|
|
|
func extractJID(s string) string {
|
|
start := strings.Index(s, "<jid>")
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
start += len("<jid>")
|
|
end := strings.Index(s[start:], "</jid>")
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
return s[start : start+end]
|
|
}
|
|
|
|
func extractAttr(s, name string) string {
|
|
prefix := name + "=\""
|
|
start := strings.Index(s, prefix)
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
start += len(prefix)
|
|
end := strings.Index(s[start:], "\"")
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
return s[start : start+end]
|
|
}
|
|
|
|
func waitForGoroutines() { time.Sleep(20 * time.Millisecond) }
|
|
|
|
func xcRecvPing(t *testing.T, client net.Conn) string {
|
|
xc := &xmppClient{t: t, c: client}
|
|
return xc.recvUntil("</iq>")
|
|
}
|
|
|
|
func mustReadAll(c net.Conn) string {
|
|
c.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
|
defer c.SetReadDeadline(time.Time{})
|
|
var buf bytes.Buffer
|
|
b := make([]byte, 4096)
|
|
for {
|
|
n, err := c.Read(b)
|
|
if n > 0 {
|
|
buf.Write(b[:n])
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func mustWaitFor(t *testing.T, f func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if f() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("condition not satisfied")
|
|
}
|
|
|
|
type xmppClient struct {
|
|
t *testing.T
|
|
c net.Conn
|
|
}
|
|
|
|
func (xc *xmppClient) send(s string) {
|
|
xc.t.Helper()
|
|
_, err := xc.c.Write([]byte(s))
|
|
if err != nil {
|
|
xc.t.Fatalf("client write: %v", err)
|
|
}
|
|
}
|
|
|
|
func (xc *xmppClient) recvUntil(marker string) string {
|
|
xc.t.Helper()
|
|
var buf bytes.Buffer
|
|
b := make([]byte, 1024)
|
|
xc.c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
defer xc.c.SetReadDeadline(time.Time{})
|
|
for {
|
|
n, err := xc.c.Read(b)
|
|
if n > 0 {
|
|
buf.Write(b[:n])
|
|
}
|
|
if strings.Contains(buf.String(), marker) {
|
|
return buf.String()
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
xc.t.Fatalf("timeout waiting for %q, got %q", marker, buf.String())
|
|
}
|
|
xc.t.Fatalf("client read: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type recorder struct {
|
|
mu sync.Mutex
|
|
ready []session.ReadyEvent
|
|
announce []session.ReadyEvent
|
|
down []session.DownEvent
|
|
stanzas []session.StanzaEvent
|
|
}
|
|
|
|
func (r *recorder) SessionReady(e session.ReadyEvent) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.ready = append(r.ready, e)
|
|
}
|
|
|
|
func (r *recorder) AnnounceOK(e session.ReadyEvent) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.announce = append(r.announce, e)
|
|
}
|
|
|
|
func (r *recorder) SessionDown(e session.DownEvent) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.down = append(r.down, e)
|
|
}
|
|
|
|
func (r *recorder) Stanza(e session.StanzaEvent) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.stanzas = append(r.stanzas, e)
|
|
}
|
|
|
|
type captureHandler struct {
|
|
Level slog.Leveler
|
|
mu sync.Mutex
|
|
records []string
|
|
}
|
|
|
|
func (h *captureHandler) Enabled(_ context.Context, level slog.Level) bool {
|
|
return level >= h.Level.Level()
|
|
}
|
|
|
|
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
|
|
var b strings.Builder
|
|
b.WriteString(r.Message)
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
b.WriteString(fmt.Sprintf(" %s=%v", a.Key, a.Value))
|
|
return true
|
|
})
|
|
h.mu.Lock()
|
|
h.records = append(h.records, b.String())
|
|
h.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (h *captureHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h }
|
|
func (h *captureHandler) WithGroup(name string) slog.Handler { return h }
|
|
|
|
// fakeClock is a deterministic clock for tests. It starts at a fixed base time.
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
timers []*fakeTimer
|
|
}
|
|
|
|
type fakeTimer struct {
|
|
fire time.Time
|
|
ch chan time.Time
|
|
fired bool
|
|
}
|
|
|
|
func newFakeClock() *fakeClock {
|
|
return &fakeClock{now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
|
}
|
|
|
|
func (f *fakeClock) Now() time.Time {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.now
|
|
}
|
|
|
|
func (f *fakeClock) After(d time.Duration) <-chan time.Time {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
ch := make(chan time.Time, 1)
|
|
f.timers = append(f.timers, &fakeTimer{fire: f.now.Add(d), ch: ch})
|
|
f.fireDue()
|
|
return ch
|
|
}
|
|
|
|
func (f *fakeClock) Advance(d time.Duration) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.now = f.now.Add(d)
|
|
f.fireDue()
|
|
}
|
|
|
|
func (f *fakeClock) fireDue() {
|
|
for _, t := range f.timers {
|
|
if !t.fired && !f.now.Before(t.fire) {
|
|
t.fired = true
|
|
select {
|
|
case t.ch <- f.now:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|