501 lines
13 KiB
Go
501 lines
13 KiB
Go
//go:build integration
|
|
|
|
package teamspeak_test
|
|
|
|
// Integration tests against a live TeamSpeak 3 server (build tag: integration).
|
|
//
|
|
// Run locally:
|
|
//
|
|
// docker compose -f docker-compose.integration.yml up -d --wait
|
|
// TEAMSPEAK_ADDR=127.0.0.1:9987 go test -tags integration ./... -v -timeout 120s
|
|
// docker compose -f docker-compose.integration.yml down
|
|
//
|
|
// In CI the server is provided by the workflow's service container and
|
|
// TEAMSPEAK_ADDR is set automatically.
|
|
//
|
|
// # Notes
|
|
//
|
|
// - A single shared client is reused across tests to avoid TS3 anti-flood
|
|
// protection, which bans IPs that establish too many connections quickly.
|
|
// - Some commands (clientlist, channellist) require elevated server group
|
|
// permissions that the default "Guest" group does not have. Those tests
|
|
// skip automatically instead of failing hard.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
teamspeak "github.com/honeybbq/teamspeak-go"
|
|
"github.com/honeybbq/teamspeak-go/crypto"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
sharedClient *teamspeak.Client
|
|
sharedOnce sync.Once
|
|
sharedErr error
|
|
)
|
|
|
|
var (
|
|
integrationServerPassword = os.Getenv("TEAMSPEAK_SERVER_PASSWORD")
|
|
integrationDefaultChannel = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL")
|
|
integrationDefaultChannelPassword = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD")
|
|
)
|
|
|
|
func integrationClientOptions(logger *slog.Logger) []teamspeak.ClientOption {
|
|
opts := make([]teamspeak.ClientOption, 0, 4)
|
|
if logger != nil {
|
|
opts = append(opts, teamspeak.WithLogger(logger))
|
|
}
|
|
if integrationServerPassword != "" {
|
|
opts = append(opts, teamspeak.WithServerPassword(integrationServerPassword))
|
|
}
|
|
if integrationDefaultChannel != "" {
|
|
opts = append(opts, teamspeak.WithDefaultChannel(integrationDefaultChannel))
|
|
}
|
|
if integrationDefaultChannelPassword != "" {
|
|
opts = append(opts, teamspeak.WithDefaultChannelPassword(integrationDefaultChannelPassword))
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
func requireTeamSpeakAddr(t *testing.T) string {
|
|
t.Helper()
|
|
addr := os.Getenv("TEAMSPEAK_ADDR")
|
|
if addr == "" {
|
|
t.Skip("TEAMSPEAK_ADDR not set — skip integration test (set TEAMSPEAK_ADDR=host:port to enable)")
|
|
}
|
|
return addr
|
|
}
|
|
|
|
func requireSharedClient(t *testing.T) *teamspeak.Client {
|
|
t.Helper()
|
|
addr := requireTeamSpeakAddr(t)
|
|
|
|
sharedOnce.Do(func() {
|
|
id, err := crypto.GenerateIdentity(8)
|
|
if err != nil {
|
|
sharedErr = err
|
|
return
|
|
}
|
|
c := teamspeak.NewClient(id, addr, "teamspeak-go-integ", integrationClientOptions(nil)...)
|
|
if err = c.Connect(); err != nil {
|
|
sharedErr = err
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err = c.WaitConnected(ctx); err != nil {
|
|
sharedErr = err
|
|
return
|
|
}
|
|
sharedClient = c
|
|
})
|
|
|
|
if sharedErr != nil {
|
|
t.Fatalf("shared client setup failed: %v", sharedErr)
|
|
}
|
|
return sharedClient
|
|
}
|
|
|
|
func skipOnPermErr(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err != nil && strings.Contains(err.Error(), "insufficient") {
|
|
t.Skipf("skipping — server returned permission error: %v", err)
|
|
}
|
|
}
|
|
|
|
func newConnectedIntegrationClient(t *testing.T, addr string, nicknamePrefix string, logger *slog.Logger) *teamspeak.Client {
|
|
t.Helper()
|
|
|
|
id, err := crypto.GenerateIdentity(8)
|
|
if err != nil {
|
|
t.Fatalf("GenerateIdentity: %v", err)
|
|
}
|
|
|
|
nickname := nicknamePrefix + strconv.FormatInt(time.Now().UTC().UnixNano()%1_000_000, 10)
|
|
|
|
// TS3 anti-flood may temporarily ban IPs that connect too frequently.
|
|
// Retry with backoff to handle transient bans in CI.
|
|
var client *teamspeak.Client
|
|
for attempt := range 3 {
|
|
if attempt > 0 {
|
|
time.Sleep(time.Duration(attempt*5) * time.Second)
|
|
}
|
|
client = teamspeak.NewClient(id, addr, nickname, integrationClientOptions(logger)...)
|
|
if err = client.Connect(); err != nil {
|
|
continue
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
err = client.WaitConnected(ctx)
|
|
cancel()
|
|
if err == nil {
|
|
break
|
|
}
|
|
_ = client.Disconnect()
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("WaitConnected(%s) after retries: %v", nickname, err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = client.Disconnect()
|
|
})
|
|
|
|
return client
|
|
}
|
|
|
|
func mapKeys(m map[string]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func extractJSONField(s string, needle string) string {
|
|
idx := strings.Index(s, needle)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
start := idx + len(needle)
|
|
end := strings.Index(s[start:], "\"")
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
return s[start : start+end]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIntegration_Connect(t *testing.T) {
|
|
c := requireSharedClient(t)
|
|
|
|
clid := c.ClientID()
|
|
if clid == 0 {
|
|
t.Error("expected non-zero client ID after connect")
|
|
}
|
|
t.Logf("connected: clid=%d", clid)
|
|
}
|
|
|
|
func TestIntegration_ConnectWithOptionalHandshakeAuth(t *testing.T) {
|
|
c := requireSharedClient(t)
|
|
|
|
t.Logf(
|
|
"connect auth enabled: serverPassword=%t defaultChannel=%t defaultChannelPassword=%t",
|
|
integrationServerPassword != "",
|
|
integrationDefaultChannel != "",
|
|
integrationDefaultChannelPassword != "",
|
|
)
|
|
if c.ClientID() == 0 {
|
|
t.Error("expected non-zero client ID after connect")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_Disconnect(t *testing.T) {
|
|
addr := requireTeamSpeakAddr(t)
|
|
|
|
id, err := crypto.GenerateIdentity(8)
|
|
if err != nil {
|
|
t.Fatalf("GenerateIdentity: %v", err)
|
|
}
|
|
c := teamspeak.NewClient(id, addr, "teamspeak-go-integ-disc", integrationClientOptions(nil)...)
|
|
|
|
if err = c.Connect(); err != nil {
|
|
t.Fatalf("Connect: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err = c.WaitConnected(ctx); err != nil {
|
|
t.Fatalf("WaitConnected: %v", err)
|
|
}
|
|
|
|
disconnected := make(chan error, 1)
|
|
c.OnDisconnected(func(e error) { disconnected <- e })
|
|
|
|
if err = c.Disconnect(); err != nil {
|
|
t.Logf("Disconnect returned (non-fatal): %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-disconnected:
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("OnDisconnected not fired after Disconnect()")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIntegration_ListClients(t *testing.T) {
|
|
c := requireSharedClient(t)
|
|
|
|
clients, err := c.ListClients()
|
|
skipOnPermErr(t, err)
|
|
if err != nil {
|
|
t.Fatalf("ListClients: %v", err)
|
|
}
|
|
if len(clients) == 0 {
|
|
t.Fatal("expected at least one client (ourselves)")
|
|
}
|
|
|
|
ownID := c.ClientID()
|
|
found := false
|
|
for _, cl := range clients {
|
|
if cl.ID == ownID {
|
|
found = true
|
|
t.Logf("self: clid=%d nick=%q cid=%d", cl.ID, cl.Nickname, cl.ChannelID)
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("own clid=%d not found in clientlist", ownID)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_ListChannels(t *testing.T) {
|
|
c := requireSharedClient(t)
|
|
|
|
channels, err := c.ListChannels()
|
|
skipOnPermErr(t, err)
|
|
if err != nil {
|
|
t.Fatalf("ListChannels: %v", err)
|
|
}
|
|
if len(channels) == 0 {
|
|
t.Fatal("expected at least one channel (default channel)")
|
|
}
|
|
t.Logf("channels: %d found, first=%q", len(channels), channels[0].Name)
|
|
}
|
|
|
|
func TestIntegration_JoinsConfiguredDefaultChannel(t *testing.T) {
|
|
if integrationDefaultChannel == "" {
|
|
t.Skip("TEAMSPEAK_DEFAULT_CHANNEL not set")
|
|
}
|
|
|
|
c := requireSharedClient(t)
|
|
|
|
channels, err := c.ListChannels()
|
|
skipOnPermErr(t, err)
|
|
if err != nil {
|
|
t.Fatalf("ListChannels: %v", err)
|
|
}
|
|
|
|
clients, err := c.ListClients()
|
|
skipOnPermErr(t, err)
|
|
if err != nil {
|
|
t.Fatalf("ListClients: %v", err)
|
|
}
|
|
|
|
var self *teamspeak.ClientInfo
|
|
for i := range clients {
|
|
if clients[i].ID == c.ClientID() {
|
|
self = &clients[i]
|
|
break
|
|
}
|
|
}
|
|
if self == nil {
|
|
t.Fatal("expected to find ourselves in client list")
|
|
}
|
|
|
|
var currentChannel *teamspeak.ChannelInfo
|
|
for i := range channels {
|
|
if channels[i].ID == self.ChannelID {
|
|
currentChannel = &channels[i]
|
|
break
|
|
}
|
|
}
|
|
if currentChannel == nil {
|
|
t.Fatalf("expected to resolve current channel for cid=%d", self.ChannelID)
|
|
}
|
|
if currentChannel.Name != integrationDefaultChannel {
|
|
t.Fatalf("expected current channel %q, got %q", integrationDefaultChannel, currentChannel.Name)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_GetClientInfo(t *testing.T) {
|
|
c := requireSharedClient(t)
|
|
|
|
info, err := c.GetClientInfo(c.ClientID())
|
|
skipOnPermErr(t, err)
|
|
if err != nil {
|
|
t.Fatalf("GetClientInfo: %v", err)
|
|
}
|
|
if len(info) == 0 {
|
|
t.Fatal("expected non-empty client info map")
|
|
}
|
|
if info["client_nickname"] == "" {
|
|
t.Errorf("expected client_nickname in clientinfo response; got keys: %v", mapKeys(info))
|
|
}
|
|
t.Logf("clientinfo keys: %v", mapKeys(info))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text messages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIntegration_TextPrivateNotifyFields(t *testing.T) {
|
|
addr := requireTeamSpeakAddr(t)
|
|
|
|
var logBuf bytes.Buffer
|
|
debugLogger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
|
|
receiver := newConnectedIntegrationClient(t, addr, "rx", debugLogger)
|
|
|
|
sender := newConnectedIntegrationClient(t, addr, "tx", slog.Default())
|
|
senderInfo, err := sender.GetClientInfo(sender.ClientID())
|
|
if err != nil {
|
|
t.Fatalf("sender GetClientInfo: %v", err)
|
|
}
|
|
senderUID := strings.TrimSpace(senderInfo["client_unique_identifier"])
|
|
t.Logf("sender clientinfo keys: %v", mapKeys(senderInfo))
|
|
|
|
received := make(chan teamspeak.TextMessage, 1)
|
|
receiver.OnTextMessage(func(msg teamspeak.TextMessage) {
|
|
select {
|
|
case received <- msg:
|
|
default:
|
|
}
|
|
})
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
probeText := fmt.Sprintf("cursor-probe-%d", time.Now().UTC().UnixNano())
|
|
if err := sender.SendTextMessage(1, uint64(receiver.ClientID()), probeText); err != nil {
|
|
t.Fatalf("sender SendTextMessage private: %v", err)
|
|
}
|
|
|
|
var msg teamspeak.TextMessage
|
|
select {
|
|
case msg = <-received:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("timeout waiting for private text notification; logs=%s", logBuf.String())
|
|
}
|
|
|
|
if msg.Message != probeText {
|
|
t.Fatalf("unexpected message text: got %q want %q", msg.Message, probeText)
|
|
}
|
|
|
|
logs := logBuf.String()
|
|
t.Logf("receiver logs: %s", logs)
|
|
|
|
if !strings.Contains(logs, "\"name\":\"notifytextmessage\"") {
|
|
t.Fatalf("expected notifytextmessage in logs, got: %s", logs)
|
|
}
|
|
|
|
targetNeedle := fmt.Sprintf("\"target\":\"%d\"", receiver.ClientID())
|
|
if !strings.Contains(logs, targetNeedle) {
|
|
t.Fatalf("expected raw notify target %s in logs, got: %s", targetNeedle, logs)
|
|
}
|
|
|
|
rawInvokerUID := extractJSONField(logs, "\"invokeruid\":\"")
|
|
if rawInvokerUID == "" {
|
|
t.Fatalf("expected raw notify invokeruid in logs, got: %s", logs)
|
|
}
|
|
|
|
if msg.TargetMode != 1 {
|
|
t.Fatalf("unexpected target mode: got %d want 1", msg.TargetMode)
|
|
}
|
|
|
|
if msg.TargetID != uint64(receiver.ClientID()) {
|
|
t.Fatalf("parsed TargetID mismatch: got %d want %d", msg.TargetID, receiver.ClientID())
|
|
}
|
|
|
|
expectedInvokerUID := rawInvokerUID
|
|
if senderUID != "" {
|
|
expectedInvokerUID = senderUID
|
|
}
|
|
if strings.TrimSpace(msg.InvokerUID) != expectedInvokerUID {
|
|
t.Fatalf("parsed InvokerUID mismatch: got %q want %q", msg.InvokerUID, expectedInvokerUID)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poke
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIntegration_PokeSendAndReceive(t *testing.T) {
|
|
addr := requireTeamSpeakAddr(t)
|
|
|
|
sender := newConnectedIntegrationClient(t, addr, "poke-tx", slog.Default())
|
|
receiver := newConnectedIntegrationClient(t, addr, "poke-rx", slog.Default())
|
|
|
|
pokeMsg := fmt.Sprintf("poke-test-%d", time.Now().UTC().UnixNano())
|
|
|
|
poked := make(chan teamspeak.PokeEvent, 1)
|
|
receiver.OnPoked(func(e teamspeak.PokeEvent) {
|
|
select {
|
|
case poked <- e:
|
|
default:
|
|
}
|
|
})
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
if err := sender.Poke(receiver.ClientID(), pokeMsg); err != nil {
|
|
t.Fatalf("Poke: %v", err)
|
|
}
|
|
t.Logf("sent poke from clid=%d to clid=%d msg=%q", sender.ClientID(), receiver.ClientID(), pokeMsg)
|
|
|
|
select {
|
|
case evt := <-poked:
|
|
t.Logf("poke received: invoker=%q uid=%q msg=%q", evt.InvokerName, evt.InvokerUID, evt.Message)
|
|
if evt.InvokerID != sender.ClientID() {
|
|
t.Errorf("InvokerID mismatch: got %d want %d", evt.InvokerID, sender.ClientID())
|
|
}
|
|
if evt.Message != pokeMsg {
|
|
t.Errorf("Message mismatch: got %q want %q", evt.Message, pokeMsg)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for poke notification")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_PokeEmptyMessage(t *testing.T) {
|
|
addr := requireTeamSpeakAddr(t)
|
|
|
|
sender := newConnectedIntegrationClient(t, addr, "poke-tx2", slog.Default())
|
|
receiver := newConnectedIntegrationClient(t, addr, "poke-rx2", slog.Default())
|
|
|
|
poked := make(chan teamspeak.PokeEvent, 1)
|
|
receiver.OnPoked(func(e teamspeak.PokeEvent) {
|
|
select {
|
|
case poked <- e:
|
|
default:
|
|
}
|
|
})
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
if err := sender.Poke(receiver.ClientID(), ""); err != nil {
|
|
t.Fatalf("Poke (empty): %v", err)
|
|
}
|
|
t.Logf("sent empty poke from clid=%d to clid=%d", sender.ClientID(), receiver.ClientID())
|
|
|
|
select {
|
|
case evt := <-poked:
|
|
t.Logf("empty poke received: invoker=%q msg=%q", evt.InvokerName, evt.Message)
|
|
if evt.Message != "" {
|
|
t.Errorf("expected empty message, got %q", evt.Message)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timeout waiting for empty poke notification")
|
|
}
|
|
}
|