aboutsummaryrefslogtreecommitdiff
path: root/internal/mobius/integration_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/mobius/integration_test.go')
-rw-r--r--internal/mobius/integration_test.go429
1 files changed, 429 insertions, 0 deletions
diff --git a/internal/mobius/integration_test.go b/internal/mobius/integration_test.go
new file mode 100644
index 0000000..f1485eb
--- /dev/null
+++ b/internal/mobius/integration_test.go
@@ -0,0 +1,429 @@
+package mobius
+
+// This file provides an in-process, protocol-level end-to-end harness: it builds a fully wired
+// Hotline server (real YAML managers on a t.TempDir copy of test/config) on ephemeral ports and
+// drives it with the in-repo hotline.Client. The individual regression tests live in e2e_test.go.
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/time/rate"
+)
+
+// e2eServer is a running in-process server plus the metadata tests need to connect to it.
+type e2eServer struct {
+ srv *hotline.Server
+ addr string // session port, host:port
+ xferAddr string // file transfer port (session port + 1), host:port
+ cfgDir string // tempdir holding the copied config + Files tree
+}
+
+// startE2EServer builds and starts a fully-wired server on a free ephemeral port pair, returning
+// once the session port accepts connections. The server is shut down via t.Cleanup.
+//
+// Binding is inherently racy: between probing for a free port pair and ListenAndServe claiming it,
+// another process (a concurrently-tested package, or anything else on the machine) can steal a
+// port. A stolen port surfaces as an early ListenAndServe error, so the serve attempt is retried
+// on a fresh pair rather than failing the test.
+func startE2EServer(t *testing.T) *e2eServer {
+ t.Helper()
+
+ cfgDir := t.TempDir()
+ copyDirRecursiveTest(t, "test/config", cfgDir)
+
+ cfg, err := LoadConfig(filepath.Join(cfgDir, "config.yaml"))
+ require.NoError(t, err)
+ // The fixture's FileRoot is a placeholder; point it at the copied Files tree.
+ cfg.FileRoot = filepath.Join(cfgDir, "Files")
+
+ // Wire the concrete managers exactly as cmd/mobius-hotline-server/main.go does.
+ messageBoard, err := NewFlatNews(path.Join(cfgDir, "MessageBoard.txt"))
+ require.NoError(t, err)
+
+ banFile, err := NewBanFile(path.Join(cfgDir, "Banlist.yaml"))
+ require.NoError(t, err)
+
+ threadedNews, err := NewThreadedNewsYAML(path.Join(cfgDir, "ThreadedNews.yaml"))
+ require.NoError(t, err)
+
+ am, err := NewYAMLAccountManager(path.Join(cfgDir, "Users/"))
+ require.NoError(t, err)
+
+ agreement, err := NewAgreement(cfgDir, "\r")
+ require.NoError(t, err)
+
+ // Create an admin account with a known password so tests that need elevated rights can log in
+ // deterministically. The client obfuscates passwords with EncodeString and the server bcrypt-
+ // compares that obfuscated form directly (see ClientConn.Authenticate), so the stored account
+ // must hash the obfuscated password — exactly what HandleNewUser does for real accounts.
+ var adminAccess hotline.AccessBitmap
+ for i := 0; i <= hotline.AccessSendPrivMsg; i++ {
+ adminAccess.Set(i)
+ }
+ obfuscatedPass := string(hotline.EncodeString([]byte(e2eAdminPass)))
+ require.NoError(t, am.Create(*hotline.NewAccount(e2eAdminLogin, "e2e admin", obfuscatedPass, adminAccess)))
+
+ for attempt := 0; attempt < 5; attempt++ {
+ port := findFreePortPairTest(t)
+
+ srv, err := hotline.NewServer(
+ hotline.WithInterface("127.0.0.1"),
+ hotline.WithPort(port),
+ hotline.WithConfig(*cfg),
+ hotline.WithLogger(NewTestLogger()),
+ // Disable per-IP connection throttling so multi-client tests don't pay 2s per connection.
+ hotline.WithConnectionRateLimit(rate.Inf, 1),
+ )
+ require.NoError(t, err)
+
+ srv.MessageBoard = messageBoard
+ srv.BanList = banFile
+ srv.ThreadedNewsMgr = threadedNews
+ srv.AccountManager = am
+ srv.Agreement = agreement
+ RegisterHandlers(srv)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ serveErr := make(chan error, 1)
+ go func() { serveErr <- srv.ListenAndServe(ctx) }()
+
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
+ if !waitForListener(t, addr, serveErr) {
+ cancel()
+ continue
+ }
+
+ t.Cleanup(func() {
+ cancel()
+ select {
+ case <-serveErr:
+ case <-time.After(10 * time.Second):
+ t.Error("server did not shut down within 10s")
+ }
+ })
+
+ return &e2eServer{
+ srv: srv,
+ addr: addr,
+ xferAddr: fmt.Sprintf("127.0.0.1:%d", port+1),
+ cfgDir: cfgDir,
+ }
+ }
+
+ t.Fatal("could not start e2e server: probed port pairs kept being claimed by other processes")
+ return nil
+}
+
+const (
+ e2eAdminLogin = "e2e-admin"
+ e2eAdminPass = "e2e-admin-pw"
+)
+
+// waitForListener retry-dials until the server accepts a TCP connection, returning true on
+// success. It returns false if ListenAndServe returned early instead — a bind failure, meaning
+// another process claimed a probed port first — which the caller treats as a retry signal. A
+// dial can even "succeed" against that other process's listener, so a successful dial only counts
+// after the bind error has had a moment to surface. A server that neither accepts nor errors
+// within the deadline fails the test.
+func waitForListener(t *testing.T, addr string, serveErr <-chan error) bool {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ select {
+ case <-serveErr:
+ return false
+ default:
+ }
+ conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
+ if err == nil {
+ _ = conn.Close()
+ select {
+ case <-serveErr:
+ return false
+ case <-time.After(20 * time.Millisecond):
+ return true
+ }
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("server at %s never became ready", addr)
+ return false
+}
+
+// findFreePortPairTest finds a port p such that both p and p+1 are free on the loopback interface.
+// Duplicated from hotline/server_test.go's findFreePortPair (that copy is in-package and unexported).
+func findFreePortPairTest(t *testing.T) int {
+ t.Helper()
+ for attempt := 0; attempt < 50; attempt++ {
+ l0, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ p := l0.Addr().(*net.TCPAddr).Port
+ l1, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p+1))
+ _ = l0.Close()
+ if err != nil {
+ continue
+ }
+ _ = l1.Close()
+ return p
+ }
+ t.Fatal("could not find a free consecutive port pair")
+ return 0
+}
+
+// copyDirRecursiveTest copies a directory tree from src into dst, creating dst subdirectories as
+// needed. It mirrors copyDirRecursive in cmd/mobius-hotline-server (not importable from here).
+func copyDirRecursiveTest(t *testing.T, src, dst string) {
+ t.Helper()
+ entries, err := os.ReadDir(src)
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(dst, 0755))
+
+ for _, entry := range entries {
+ srcPath := filepath.Join(src, entry.Name())
+ dstPath := filepath.Join(dst, entry.Name())
+ if entry.IsDir() {
+ copyDirRecursiveTest(t, srcPath, dstPath)
+ continue
+ }
+ data, err := os.ReadFile(srcPath)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(dstPath, data, 0644))
+ }
+}
+
+// --- E2E client driver -----------------------------------------------------------------------
+
+// e2eClient wraps hotline.Client with request/reply correlation and collection of unsolicited
+// server-pushed transactions, so tests can drive the protocol synchronously.
+type e2eClient struct {
+ t *testing.T
+ client *hotline.Client
+ cancel context.CancelFunc
+
+ mu sync.Mutex
+ waiters map[[4]byte]chan hotline.Transaction // reply ID -> waiter
+ incoming []hotline.Transaction // unsolicited (IsReply==0) transactions
+ incCh chan hotline.Transaction // signals a new unsolicited transaction
+}
+
+// connectE2E performs handshake + login and starts the read loop. It auto-replies to the agreement
+// prompt so the session becomes fully established. The login doubles as the display name.
+func connectE2E(t *testing.T, addr, login, pass string) *e2eClient {
+ t.Helper()
+ return connectE2ENamed(t, addr, login, login, pass)
+}
+
+// connectE2ENamed is connectE2E with a display name distinct from the login, so tests that run
+// several sessions on the same account can tell them apart in user lists and notifications.
+func connectE2ENamed(t *testing.T, addr, name, login, pass string) *e2eClient {
+ t.Helper()
+
+ c := hotline.NewClient(name, NewTestLogger())
+ ec := &e2eClient{
+ t: t,
+ client: c,
+ waiters: map[[4]byte]chan hotline.Transaction{},
+ incCh: make(chan hotline.Transaction, 64),
+ }
+
+ // Register the same dispatcher for every transaction type the tests care about. Replies are
+ // re-typed to their request type by the client before dispatch, so one handler covers both
+ // request-reply and server-push flows.
+ for _, tt2 := range dispatchTypes {
+ c.HandleFunc(tt2, ec.dispatch)
+ }
+
+ require.NoError(t, c.Connect(addr, login, pass))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ ec.cancel = cancel
+ go func() { _ = c.HandleTransactions(ctx) }()
+
+ t.Cleanup(func() {
+ cancel()
+ _ = c.Disconnect()
+ })
+
+ return ec
+}
+
+// dispatchTypes is the set of transaction types the e2e dispatcher is registered for: every request
+// type the tests send (replies arrive re-typed to these) plus server-pushed types.
+var dispatchTypes = [][2]byte{
+ hotline.TranLogin, hotline.TranAgreed, hotline.TranShowAgreement,
+ hotline.TranChatSend, hotline.TranChatMsg,
+ hotline.TranGetMsgs, hotline.TranOldPostNews,
+ hotline.TranGetNewsCatNameList, hotline.TranGetNewsArtNameList,
+ hotline.TranGetNewsArtData, hotline.TranPostNewsArt,
+ hotline.TranGetFileNameList, hotline.TranDownloadFile, hotline.TranUploadFile,
+ hotline.TranGetUser, hotline.TranNewUser, hotline.TranSetUser, hotline.TranDeleteUser,
+ hotline.TranListUsers, hotline.TranGetUserNameList,
+ hotline.TranNotifyChangeUser, hotline.TranNotifyDeleteUser,
+ hotline.TranServerMsg, hotline.TranDisconnectMsg, hotline.TranUserAccess,
+ hotline.TranInviteNewChat, hotline.TranInviteToChat, hotline.TranJoinChat,
+ hotline.TranNotifyChatChangeUser, hotline.TranNotifyChatDeleteUser, hotline.TranNotifyChatSubject,
+}
+
+func (ec *e2eClient) dispatch(_ context.Context, _ *hotline.Client, t *hotline.Transaction) ([]hotline.Transaction, error) {
+ if t.IsReply == 1 {
+ ec.mu.Lock()
+ ch := ec.waiters[t.ID]
+ delete(ec.waiters, t.ID)
+ ec.mu.Unlock()
+ if ch != nil {
+ ch <- *t
+ }
+ return nil, nil
+ }
+
+ // Unsolicited server push.
+ if t.Type == hotline.TranShowAgreement {
+ // Accept the agreement so login completes.
+ return []hotline.Transaction{
+ hotline.NewTransaction(hotline.TranAgreed, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte(ec.client.Pref.Username)),
+ hotline.NewField(hotline.FieldUserIconID, ec.client.Pref.IconBytes()),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+ ),
+ }, nil
+ }
+
+ ec.mu.Lock()
+ ec.incoming = append(ec.incoming, *t)
+ ec.mu.Unlock()
+ select {
+ case ec.incCh <- *t:
+ default:
+ }
+ return nil, nil
+}
+
+// roundTrip sends a request and waits for the matching reply (correlated by transaction ID).
+func (ec *e2eClient) roundTrip(req hotline.Transaction) hotline.Transaction {
+ ec.t.Helper()
+
+ ch := make(chan hotline.Transaction, 1)
+ ec.mu.Lock()
+ ec.waiters[req.ID] = ch
+ ec.mu.Unlock()
+
+ require.NoError(ec.t, ec.client.Send(req))
+
+ select {
+ case reply := <-ch:
+ return reply
+ case <-time.After(15 * time.Second):
+ ec.t.Fatalf("timed out waiting for reply to %v", req.Type)
+ return hotline.Transaction{}
+ }
+}
+
+// waitFor blocks until an unsolicited transaction of the given type arrives, or the test fails.
+// It polls the buffer on a short interval as well as waking on incCh, so a signal that races with
+// the buffer scan can never cause a lost wakeup.
+func (ec *e2eClient) waitFor(tranType [2]byte) hotline.Transaction {
+ ec.t.Helper()
+
+ deadline := time.After(15 * time.Second)
+ ticker := time.NewTicker(20 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ // Scan anything already buffered.
+ ec.mu.Lock()
+ for i, tr := range ec.incoming {
+ if tr.Type == tranType {
+ ec.incoming = append(ec.incoming[:i], ec.incoming[i+1:]...)
+ ec.mu.Unlock()
+ return tr
+ }
+ }
+ ec.mu.Unlock()
+
+ select {
+ case <-ec.incCh:
+ // Woke on a new arrival; loop and re-scan.
+ case <-ticker.C:
+ // Periodic re-scan guards against a dropped incCh signal.
+ case <-deadline:
+ ec.t.Fatalf("timed out waiting for unsolicited %v", tranType)
+ return hotline.Transaction{}
+ }
+ }
+}
+
+// isError reports whether a reply carries a Hotline error (non-zero ErrorCode).
+func isError(t hotline.Transaction) bool {
+ return t.ErrorCode != [4]byte{0, 0, 0, 0}
+}
+
+// --- transfer-port helpers -------------------------------------------------------------------
+
+// htxfHeader builds the 16-byte HTXF transfer header (protocol + reference number + data size).
+func htxfHeader(refNum [4]byte, dataSize uint32) []byte {
+ b := make([]byte, 16)
+ copy(b[0:4], hotline.HTXF[:])
+ copy(b[4:8], refNum[:])
+ b[8] = byte(dataSize >> 24)
+ b[9] = byte(dataSize >> 16)
+ b[10] = byte(dataSize >> 8)
+ b[11] = byte(dataSize)
+ return b
+}
+
+// downloadOverTransferPort dials the transfer port, sends the HTXF header for refNum, and returns
+// all bytes the server streams back (the flattened file object).
+func downloadOverTransferPort(t *testing.T, xferAddr string, refNum [4]byte) []byte {
+ t.Helper()
+ conn, err := net.DialTimeout("tcp", xferAddr, 5*time.Second)
+ require.NoError(t, err)
+ defer func() { _ = conn.Close() }()
+
+ _, err = conn.Write(htxfHeader(refNum, 0))
+ require.NoError(t, err)
+
+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
+ data, err := io.ReadAll(conn)
+ // A read deadline or server-initiated close both surface here; the payload collected so far is
+ // what we assert on.
+ if err != nil && !isTimeout(err) {
+ require.ErrorIs(t, err, io.EOF)
+ }
+ return data
+}
+
+// uploadOverTransferPort dials the transfer port, sends the HTXF header, then streams payload.
+func uploadOverTransferPort(t *testing.T, xferAddr string, refNum [4]byte, payload []byte) {
+ t.Helper()
+ conn, err := net.DialTimeout("tcp", xferAddr, 5*time.Second)
+ require.NoError(t, err)
+ defer func() { _ = conn.Close() }()
+
+ _, err = conn.Write(htxfHeader(refNum, uint32(len(payload))))
+ require.NoError(t, err)
+ _, err = conn.Write(payload)
+ require.NoError(t, err)
+
+ // Give the server a moment to persist before the caller asserts.
+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+ _, _ = io.ReadAll(conn)
+}
+
+func isTimeout(err error) bool {
+ var ne net.Error
+ if e, ok := err.(net.Error); ok {
+ ne = e
+ }
+ return ne != nil && ne.Timeout()
+}