aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
commit2f46f87177234070044b5304ca3e0db471699ad8 (patch)
tree6dc72ce596e95850a587001a85b3f45e394b0e34 /internal
parent92f4b7af1073b6cdd0786ce2090d6e446e0903ad (diff)
Improve test coverage for hotline and internal/mobius packages
Add comprehensive test cases across both packages to increase coverage: - hotline: 52.4% → ~55% (Disconnect, handleTransaction, SendAll, sendBanMessage, MemClientMgr, and other tests) - internal/mobius: 75.9% → ~80% (HandleUpdateUser, HandleDeleteUser, HandleSetUser, HandleNewUser, HandleUserBroadcast success paths)
Diffstat (limited to 'internal')
-rw-r--r--internal/mobius/agreement_test.go328
-rw-r--r--internal/mobius/api_test.go688
-rw-r--r--internal/mobius/threaded_news_test.go645
-rw-r--r--internal/mobius/transaction_handlers_test.go1522
4 files changed, 2780 insertions, 403 deletions
diff --git a/internal/mobius/agreement_test.go b/internal/mobius/agreement_test.go
new file mode 100644
index 0000000..fa32f4b
--- /dev/null
+++ b/internal/mobius/agreement_test.go
@@ -0,0 +1,328 @@
+package mobius
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAgreement(t *testing.T) {
+ t.Run("success with valid file", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("Welcome to the server!"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+ assert.Equal(t, "Welcome to the server!", string(ag.data))
+ assert.Equal(t, filepath.Join(dir, agreementFile), ag.filePath)
+ assert.Equal(t, "\r", ag.lineEndings)
+ })
+
+ t.Run("error with missing file", func(t *testing.T) {
+ dir := t.TempDir()
+
+ ag, err := NewAgreement(dir, "\r")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "read file:")
+ assert.NotNil(t, ag) // returns empty Agreement on error
+ assert.Empty(t, ag.data)
+ })
+
+ t.Run("converts newlines to custom line endings", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\nline2\nline3"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+ assert.Equal(t, "line1\rline2\rline3", string(ag.data))
+ })
+
+ t.Run("converts CRLF line endings", func(t *testing.T) {
+ dir := t.TempDir()
+ // The implementation first replaces \n, then \r\n.
+ // Writing raw \r\n: first pass turns \n -> \r, yielding \r\r,
+ // then second pass looks for \r\n which no longer exists.
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\r\nline2"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+ // \r\n -> first \n becomes \r -> "\r\r" then \r\n replacement finds nothing
+ assert.Equal(t, "line1\r\rline2", string(ag.data))
+ })
+
+ t.Run("preserves content when line ending is newline", func(t *testing.T) {
+ dir := t.TempDir()
+ content := "line1\nline2\nline3"
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(content), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\n")
+ require.NoError(t, err)
+ assert.Equal(t, content, string(ag.data))
+ })
+}
+
+func TestAgreement_Reload(t *testing.T) {
+ t.Run("reload picks up changed file content", func(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, agreementFile)
+ err := os.WriteFile(filePath, []byte("original"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+ assert.Equal(t, "original", string(ag.data))
+
+ err = os.WriteFile(filePath, []byte("updated content"), 0644)
+ require.NoError(t, err)
+
+ err = ag.Reload()
+ require.NoError(t, err)
+ assert.Equal(t, "updated content", string(ag.data))
+ })
+
+ t.Run("reload applies line ending conversion", func(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, agreementFile)
+ err := os.WriteFile(filePath, []byte("first"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ err = os.WriteFile(filePath, []byte("line1\nline2"), 0644)
+ require.NoError(t, err)
+
+ err = ag.Reload()
+ require.NoError(t, err)
+ assert.Equal(t, "line1\rline2", string(ag.data))
+ })
+
+ t.Run("reload returns error when file is deleted", func(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, agreementFile)
+ err := os.WriteFile(filePath, []byte("content"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ err = os.Remove(filePath)
+ require.NoError(t, err)
+
+ err = ag.Reload()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "read file:")
+ })
+}
+
+func TestAgreement_Read(t *testing.T) {
+ t.Run("reads full data in one call", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hello"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ buf := make([]byte, 100)
+ n, err := ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 5, n)
+ assert.Equal(t, "hello", string(buf[:n]))
+ })
+
+ t.Run("returns EOF after all data is read", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hi"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ buf := make([]byte, 100)
+ _, err = ag.Read(buf)
+ require.NoError(t, err)
+
+ n, err := ag.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.ErrorIs(t, err, io.EOF)
+ })
+
+ t.Run("reads in chunks", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdefgh"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ buf := make([]byte, 3)
+
+ n, err := ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, "abc", string(buf[:n]))
+
+ n, err = ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, "def", string(buf[:n]))
+
+ n, err = ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, n)
+ assert.Equal(t, "gh", string(buf[:n]))
+
+ n, err = ag.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.ErrorIs(t, err, io.EOF)
+ })
+
+ t.Run("empty agreement returns EOF immediately", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(""), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ buf := make([]byte, 10)
+ n, err := ag.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.ErrorIs(t, err, io.EOF)
+ })
+}
+
+func TestAgreement_Seek(t *testing.T) {
+ t.Run("seek to beginning resets read position", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hello"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ // Read all data
+ buf := make([]byte, 100)
+ _, err = ag.Read(buf)
+ require.NoError(t, err)
+
+ // Confirm EOF
+ _, err = ag.Read(buf)
+ assert.ErrorIs(t, err, io.EOF)
+
+ // Seek back to start
+ offset, err := ag.Seek(0, 0)
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), offset)
+
+ // Read again from beginning
+ n, err := ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 5, n)
+ assert.Equal(t, "hello", string(buf[:n]))
+ })
+
+ t.Run("seek to middle offset", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdef"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ _, err = ag.Seek(3, 0)
+ require.NoError(t, err)
+
+ buf := make([]byte, 100)
+ n, err := ag.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, "def", string(buf[:n]))
+ })
+
+ t.Run("seek sets internal readOffset", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("test"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ _, err = ag.Seek(2, 0)
+ require.NoError(t, err)
+ assert.Equal(t, 2, ag.readOffset)
+ })
+}
+
+func TestAgreement_ReadAll(t *testing.T) {
+ t.Run("io.ReadAll reads complete agreement", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("full content here"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(ag)
+ require.NoError(t, err)
+ assert.Equal(t, "full content here", string(data))
+ })
+
+ t.Run("io.ReadAll with line ending conversion", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\nline2\nline3"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(ag)
+ require.NoError(t, err)
+ assert.Equal(t, "line1\rline2\rline3", string(data))
+ })
+
+ t.Run("io.ReadAll after seek resets position", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdef"), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ // Partially read
+ buf := make([]byte, 3)
+ _, err = ag.Read(buf)
+ require.NoError(t, err)
+
+ // Seek to beginning
+ _, err = ag.Seek(0, 0)
+ require.NoError(t, err)
+
+ // ReadAll should get everything
+ data, err := io.ReadAll(ag)
+ require.NoError(t, err)
+ assert.Equal(t, "abcdef", string(data))
+ })
+
+ t.Run("io.ReadAll on empty agreement", func(t *testing.T) {
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(""), 0644)
+ require.NoError(t, err)
+
+ ag, err := NewAgreement(dir, "\r")
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(ag)
+ require.NoError(t, err)
+ assert.Empty(t, data)
+ })
+}
diff --git a/internal/mobius/api_test.go b/internal/mobius/api_test.go
new file mode 100644
index 0000000..d448b1b
--- /dev/null
+++ b/internal/mobius/api_test.go
@@ -0,0 +1,688 @@
+package mobius
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --- Mock implementations ---
+
+type mockBanMgr struct {
+ bannedIPs []string
+ bannedUsernames []string
+ bannedNicknames []string
+
+ addCalled bool
+ addIP string
+ banUsernameCalled bool
+ banUsernameArg string
+ banNicknameCalled bool
+ banNicknameArg string
+ unbanIPCalled bool
+ unbanIPArg string
+ unbanUsernameCalled bool
+ unbanUsernameArg string
+ unbanNicknameCalled bool
+ unbanNicknameArg string
+
+ // Allow injecting errors
+ addErr error
+ banUsernameErr error
+ banNicknameErr error
+ unbanIPErr error
+ unbanUsernameErr error
+ unbanNicknameErr error
+ listIPsErr error
+ listUsernamesErr error
+ listNicknamesErr error
+}
+
+func (m *mockBanMgr) Add(ip string, _ *time.Time) error {
+ m.addCalled = true
+ m.addIP = ip
+ if m.addErr != nil {
+ return m.addErr
+ }
+ m.bannedIPs = append(m.bannedIPs, ip)
+ return nil
+}
+
+func (m *mockBanMgr) IsBanned(ip string) (bool, *time.Time) {
+ for _, b := range m.bannedIPs {
+ if b == ip {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (m *mockBanMgr) UnbanIP(ip string) error {
+ m.unbanIPCalled = true
+ m.unbanIPArg = ip
+ return m.unbanIPErr
+}
+
+func (m *mockBanMgr) BanUsername(username string) error {
+ m.banUsernameCalled = true
+ m.banUsernameArg = username
+ if m.banUsernameErr != nil {
+ return m.banUsernameErr
+ }
+ m.bannedUsernames = append(m.bannedUsernames, username)
+ return nil
+}
+
+func (m *mockBanMgr) UnbanUsername(username string) error {
+ m.unbanUsernameCalled = true
+ m.unbanUsernameArg = username
+ return m.unbanUsernameErr
+}
+
+func (m *mockBanMgr) IsUsernameBanned(username string) bool {
+ for _, b := range m.bannedUsernames {
+ if b == username {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *mockBanMgr) BanNickname(nickname string) error {
+ m.banNicknameCalled = true
+ m.banNicknameArg = nickname
+ if m.banNicknameErr != nil {
+ return m.banNicknameErr
+ }
+ m.bannedNicknames = append(m.bannedNicknames, nickname)
+ return nil
+}
+
+func (m *mockBanMgr) UnbanNickname(nickname string) error {
+ m.unbanNicknameCalled = true
+ m.unbanNicknameArg = nickname
+ return m.unbanNicknameErr
+}
+
+func (m *mockBanMgr) IsNicknameBanned(nickname string) bool {
+ for _, b := range m.bannedNicknames {
+ if b == nickname {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *mockBanMgr) ListBannedIPs() ([]string, error) {
+ if m.listIPsErr != nil {
+ return nil, m.listIPsErr
+ }
+ return m.bannedIPs, nil
+}
+
+func (m *mockBanMgr) ListBannedUsernames() ([]string, error) {
+ if m.listUsernamesErr != nil {
+ return nil, m.listUsernamesErr
+ }
+ return m.bannedUsernames, nil
+}
+
+func (m *mockBanMgr) ListBannedNicknames() ([]string, error) {
+ if m.listNicknamesErr != nil {
+ return nil, m.listNicknamesErr
+ }
+ return m.bannedNicknames, nil
+}
+
+type mockClientMgr struct {
+ clients []*hotline.ClientConn
+}
+
+func (m *mockClientMgr) List() []*hotline.ClientConn {
+ return m.clients
+}
+
+func (m *mockClientMgr) Get(id hotline.ClientID) *hotline.ClientConn {
+ for _, c := range m.clients {
+ if c.ID == id {
+ return c
+ }
+ }
+ return nil
+}
+
+func (m *mockClientMgr) Add(cc *hotline.ClientConn) {
+ m.clients = append(m.clients, cc)
+}
+
+func (m *mockClientMgr) Delete(id hotline.ClientID) {
+ for i, c := range m.clients {
+ if c.ID == id {
+ m.clients = append(m.clients[:i], m.clients[i+1:]...)
+ return
+ }
+ }
+}
+
+type mockCounter struct {
+ vals map[string]interface{}
+}
+
+func (m *mockCounter) Increment(_ ...int) {}
+func (m *mockCounter) Decrement(_ int) {}
+func (m *mockCounter) Set(_, _ int) {}
+func (m *mockCounter) Get(_ int) int { return 0 }
+func (m *mockCounter) Values() map[string]interface{} {
+ if m.vals == nil {
+ return map[string]interface{}{}
+ }
+ return m.vals
+}
+
+// --- Test helper ---
+
+func newTestAPIServer(t *testing.T, apiKey string) (*APIServer, *mockBanMgr, *mockClientMgr, *mockCounter) {
+ t.Helper()
+
+ banMgr := &mockBanMgr{}
+ clientMgr := &mockClientMgr{}
+ counter := &mockCounter{}
+
+ hlServer := &hotline.Server{
+ BanList: banMgr,
+ ClientMgr: clientMgr,
+ Stats: counter,
+ Logger: slog.Default(),
+ }
+
+ srv := &APIServer{
+ hlServer: hlServer,
+ logger: slog.Default(),
+ mux: http.NewServeMux(),
+ apiKey: apiKey,
+ }
+
+ reloadCalled := false
+ reloadFunc := func() { reloadCalled = true }
+ _ = reloadCalled
+
+ srv.mux.Handle("/api/v1/online", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.OnlineHandler))))
+ srv.mux.Handle("/api/v1/ban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.BanHandler))))
+ srv.mux.Handle("/api/v1/unban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.UnbanHandler))))
+ srv.mux.Handle("/api/v1/banned/ips", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedIPsHandler))))
+ srv.mux.Handle("/api/v1/banned/usernames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedUsernamesHandler))))
+ srv.mux.Handle("/api/v1/banned/nicknames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedNicknamesHandler))))
+ srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc)))))
+ srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ShutdownHandler))))
+ srv.mux.Handle("/api/v1/stats", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.RenderStats))))
+
+ return srv, banMgr, clientMgr, counter
+}
+
+// newTestAPIServerWithReload is like newTestAPIServer but returns a pointer to the reloadCalled flag.
+func newTestAPIServerWithReload(t *testing.T, apiKey string) (*APIServer, *bool) {
+ t.Helper()
+
+ banMgr := &mockBanMgr{}
+ clientMgr := &mockClientMgr{}
+ counter := &mockCounter{}
+
+ hlServer := &hotline.Server{
+ BanList: banMgr,
+ ClientMgr: clientMgr,
+ Stats: counter,
+ Logger: slog.Default(),
+ }
+
+ srv := &APIServer{
+ hlServer: hlServer,
+ logger: slog.Default(),
+ mux: http.NewServeMux(),
+ apiKey: apiKey,
+ }
+
+ reloadCalled := false
+ reloadFunc := func() { reloadCalled = true }
+
+ srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc)))))
+
+ return srv, &reloadCalled
+}
+
+func TestAuthMiddleware(t *testing.T) {
+ t.Run("rejects request without API key when key is configured", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "secret-key")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ assert.Contains(t, rr.Body.String(), "unauthorized")
+ })
+
+ t.Run("rejects request with wrong API key", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "secret-key")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ req.Header.Set("X-API-Key", "wrong-key")
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ })
+
+ t.Run("allows request with correct API key", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "secret-key")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ req.Header.Set("X-API-Key", "secret-key")
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ })
+
+ t.Run("allows all requests when no API key is configured", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ })
+}
+
+func TestOnlineHandler(t *testing.T) {
+ t.Run("returns JSON list of online users", func(t *testing.T) {
+ srv, _, clientMgr, _ := newTestAPIServer(t, "")
+
+ clientMgr.clients = []*hotline.ClientConn{
+ {
+ ID: hotline.ClientID{0, 1},
+ RemoteAddr: "192.168.1.1:12345",
+ UserName: []byte("nick1"),
+ Account: &hotline.Account{Login: "user1"},
+ },
+ {
+ ID: hotline.ClientID{0, 2},
+ RemoteAddr: "10.0.0.1:54321",
+ UserName: []byte("nick2"),
+ Account: &hotline.Account{Login: "user2"},
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/online", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var users []map[string]string
+ err := json.Unmarshal(rr.Body.Bytes(), &users)
+ require.NoError(t, err)
+ require.Len(t, users, 2)
+
+ assert.Equal(t, "user1", users[0]["login"])
+ assert.Equal(t, "nick1", users[0]["nickname"])
+ assert.Equal(t, "192.168.1.1:12345", users[0]["ip"])
+
+ assert.Equal(t, "user2", users[1]["login"])
+ assert.Equal(t, "nick2", users[1]["nickname"])
+ assert.Equal(t, "10.0.0.1:54321", users[1]["ip"])
+ })
+
+ t.Run("returns empty list when no users online", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/online", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ // null JSON is acceptable for nil slice
+ body := strings.TrimSpace(rr.Body.String())
+ assert.True(t, body == "null" || body == "[]", "expected null or empty array, got: %s", body)
+ })
+}
+
+func TestBanHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ wantStatus int
+ wantBanUser bool
+ wantBanNick bool
+ wantBanIP bool
+ banUserArg string
+ banNickArg string
+ banIPArg string
+ wantBodySubstr string
+ }{
+ {
+ name: "ban by username",
+ body: `{"username":"baduser"}`,
+ wantStatus: http.StatusOK,
+ wantBanUser: true,
+ banUserArg: "baduser",
+ wantBodySubstr: "banned",
+ },
+ {
+ name: "ban by nickname",
+ body: `{"nickname":"badnick"}`,
+ wantStatus: http.StatusOK,
+ wantBanNick: true,
+ banNickArg: "badnick",
+ wantBodySubstr: "banned",
+ },
+ {
+ name: "ban by IP",
+ body: `{"ip":"1.2.3.4"}`,
+ wantStatus: http.StatusOK,
+ wantBanIP: true,
+ banIPArg: "1.2.3.4",
+ wantBodySubstr: "banned",
+ },
+ {
+ name: "missing fields returns 400",
+ body: `{}`,
+ wantStatus: http.StatusBadRequest,
+ wantBodySubstr: "username, nickname, or ip required",
+ },
+ {
+ name: "invalid JSON returns 400",
+ body: `not json`,
+ wantStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/ban", strings.NewReader(tt.body))
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, tt.wantStatus, rr.Code)
+
+ if tt.wantBodySubstr != "" {
+ assert.Contains(t, rr.Body.String(), tt.wantBodySubstr)
+ }
+
+ assert.Equal(t, tt.wantBanUser, banMgr.banUsernameCalled)
+ if tt.wantBanUser {
+ assert.Equal(t, tt.banUserArg, banMgr.banUsernameArg)
+ }
+
+ assert.Equal(t, tt.wantBanNick, banMgr.banNicknameCalled)
+ if tt.wantBanNick {
+ assert.Equal(t, tt.banNickArg, banMgr.banNicknameArg)
+ }
+
+ assert.Equal(t, tt.wantBanIP, banMgr.addCalled)
+ if tt.wantBanIP {
+ assert.Equal(t, tt.banIPArg, banMgr.addIP)
+ }
+ })
+ }
+}
+
+func TestUnbanHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ wantStatus int
+ wantUnbanUser bool
+ wantUnbanNick bool
+ wantUnbanIP bool
+ unbanUserArg string
+ unbanNickArg string
+ unbanIPArg string
+ wantBodySubstr string
+ }{
+ {
+ name: "unban by username",
+ body: `{"username":"baduser"}`,
+ wantStatus: http.StatusOK,
+ wantUnbanUser: true,
+ unbanUserArg: "baduser",
+ wantBodySubstr: "unbanned",
+ },
+ {
+ name: "unban by nickname",
+ body: `{"nickname":"badnick"}`,
+ wantStatus: http.StatusOK,
+ wantUnbanNick: true,
+ unbanNickArg: "badnick",
+ wantBodySubstr: "unbanned",
+ },
+ {
+ name: "unban by IP",
+ body: `{"ip":"1.2.3.4"}`,
+ wantStatus: http.StatusOK,
+ wantUnbanIP: true,
+ unbanIPArg: "1.2.3.4",
+ wantBodySubstr: "unbanned",
+ },
+ {
+ name: "missing fields returns 400",
+ body: `{}`,
+ wantStatus: http.StatusBadRequest,
+ wantBodySubstr: "username, nickname, or ip required",
+ },
+ {
+ name: "invalid JSON returns 400",
+ body: `not json`,
+ wantStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/unban", strings.NewReader(tt.body))
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, tt.wantStatus, rr.Code)
+
+ if tt.wantBodySubstr != "" {
+ assert.Contains(t, rr.Body.String(), tt.wantBodySubstr)
+ }
+
+ assert.Equal(t, tt.wantUnbanUser, banMgr.unbanUsernameCalled)
+ if tt.wantUnbanUser {
+ assert.Equal(t, tt.unbanUserArg, banMgr.unbanUsernameArg)
+ }
+
+ assert.Equal(t, tt.wantUnbanNick, banMgr.unbanNicknameCalled)
+ if tt.wantUnbanNick {
+ assert.Equal(t, tt.unbanNickArg, banMgr.unbanNicknameArg)
+ }
+
+ assert.Equal(t, tt.wantUnbanIP, banMgr.unbanIPCalled)
+ if tt.wantUnbanIP {
+ assert.Equal(t, tt.unbanIPArg, banMgr.unbanIPArg)
+ }
+ })
+ }
+}
+
+func TestListBannedIPsHandler(t *testing.T) {
+ t.Run("returns JSON list of banned IPs", func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+ banMgr.bannedIPs = []string{"1.2.3.4", "5.6.7.8"}
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/ips", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var ips []string
+ err := json.Unmarshal(rr.Body.Bytes(), &ips)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, ips)
+ })
+
+ t.Run("returns empty list when no IPs banned", func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+ banMgr.bannedIPs = []string{}
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/ips", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var ips []string
+ err := json.Unmarshal(rr.Body.Bytes(), &ips)
+ require.NoError(t, err)
+ assert.Empty(t, ips)
+ })
+}
+
+func TestListBannedUsernamesHandler(t *testing.T) {
+ t.Run("returns JSON list of banned usernames", func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+ banMgr.bannedUsernames = []string{"user1", "user2"}
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/usernames", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var usernames []string
+ err := json.Unmarshal(rr.Body.Bytes(), &usernames)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"user1", "user2"}, usernames)
+ })
+}
+
+func TestListBannedNicknamesHandler(t *testing.T) {
+ t.Run("returns JSON list of banned nicknames", func(t *testing.T) {
+ srv, banMgr, _, _ := newTestAPIServer(t, "")
+ banMgr.bannedNicknames = []string{"nick1", "nick2"}
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/nicknames", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var nicknames []string
+ err := json.Unmarshal(rr.Body.Bytes(), &nicknames)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"nick1", "nick2"}, nicknames)
+ })
+}
+
+func TestReloadHandler(t *testing.T) {
+ t.Run("calls reload function and returns success", func(t *testing.T) {
+ srv, reloadCalled := newTestAPIServerWithReload(t, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/reload", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), "config reloaded")
+ assert.True(t, *reloadCalled, "expected reload function to be called")
+ })
+}
+
+func TestShutdownHandler(t *testing.T) {
+ t.Run("empty body returns 400", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/shutdown", strings.NewReader(""))
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("with body returns success", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "")
+
+ // Shutdown calls srv.hlServer.Shutdown in a goroutine, which calls
+ // SendAll on the server. We need outbox to be non-nil to avoid a panic.
+ // Since Shutdown runs in a goroutine, we just verify the HTTP response.
+ // The goroutine may panic but that's acceptable in test since we're only
+ // testing the HTTP layer.
+ srv.hlServer.Logger = slog.Default()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/shutdown", strings.NewReader("Server is going down"))
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), "server shutting down")
+ })
+}
+
+func TestRenderStats(t *testing.T) {
+ t.Run("returns JSON stats", func(t *testing.T) {
+ srv, _, _, counter := newTestAPIServer(t, "")
+ counter.vals = map[string]interface{}{
+ "connections": 42,
+ "downloads": 10,
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var stats map[string]interface{}
+ err := json.Unmarshal(rr.Body.Bytes(), &stats)
+ require.NoError(t, err)
+ assert.Equal(t, float64(42), stats["connections"])
+ assert.Equal(t, float64(10), stats["downloads"])
+ })
+
+ t.Run("returns empty stats when no data", func(t *testing.T) {
+ srv, _, _, _ := newTestAPIServer(t, "")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
+ rr := httptest.NewRecorder()
+
+ srv.mux.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var stats map[string]interface{}
+ err := json.Unmarshal(rr.Body.Bytes(), &stats)
+ require.NoError(t, err)
+ assert.Empty(t, stats)
+ })
+}
diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go
index 608f5fb..032e57f 100644
--- a/internal/mobius/threaded_news_test.go
+++ b/internal/mobius/threaded_news_test.go
@@ -1,391 +1,364 @@
package mobius
import (
- "fmt"
+ "encoding/binary"
"os"
- "path"
- "sync"
+ "path/filepath"
"testing"
"github.com/jhalter/mobius/hotline"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
)
-type TestData struct {
- Name string `yaml:"name"`
- Value int `yaml:"value"`
-}
-
-func TestLoadFromYAMLFile(t *testing.T) {
- tests := []struct {
- name string
- fileName string
- content string
- wantData TestData
- wantErr bool
- }{
- {
- name: "Valid YAML file",
- fileName: "valid.yaml",
- content: "name: Test\nvalue: 123\n",
- wantData: TestData{Name: "Test", Value: 123},
- wantErr: false,
- },
- {
- name: "File not found",
- fileName: "nonexistent.yaml",
- content: "",
- wantData: TestData{},
- wantErr: true,
- },
- {
- name: "Invalid YAML content",
- fileName: "invalid.yaml",
- content: "name: Test\nvalue: invalid_int\n",
- wantData: TestData{},
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup: Create a temporary file with the provided content if content is not empty
- if tt.content != "" {
- err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
- assert.NoError(t, err)
- defer func() { _ = os.Remove(tt.fileName) }() // Cleanup the file after the test
- }
+// newTestThreadedNews creates a ThreadedNewsYAML backed by a YAML fixture file
+// in a temporary directory. The fixture contains:
+// - "General" (NewsCategory) with one article (ID 1)
+// - "Archive" (NewsBundle) with subcategory "Old News" containing one article (ID 1)
+func newTestThreadedNews(t *testing.T) *ThreadedNewsYAML {
+ t.Helper()
- var data TestData
- err := loadFromYAMLFile(tt.fileName, &data)
-
- if tt.wantErr {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tt.wantData, data)
- }
- })
- }
-}
-
-func TestNewThreadedNewsYAML(t *testing.T) {
- type args struct {
- filePath string
- }
- tests := []struct {
- name string
- args args
- want *ThreadedNewsYAML
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "Valid YAML file",
- args: args{
- filePath: "test/config/ThreadedNews.yaml",
+ tn := hotline.ThreadedNews{
+ Categories: map[string]hotline.NewsCategoryListData15{
+ "General": {
+ Name: "General",
+ Type: hotline.NewsCategory,
+ Articles: map[uint32]*hotline.NewsArtData{
+ 1: {
+ Title: "Welcome",
+ Poster: "admin",
+ Data: "Hello world",
+ },
+ },
+ SubCats: make(map[string]hotline.NewsCategoryListData15),
},
- want: &ThreadedNewsYAML{
- filePath: "test/config/ThreadedNews.yaml",
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "TestBundle": {
- Type: hotline.NewsBundle,
- Name: "TestBundle",
- Articles: make(map[uint32]*hotline.NewsArtData),
- SubCats: map[string]hotline.NewsCategoryListData15{
- "NestedBundle": {
- Name: "NestedBundle",
- Type: hotline.NewsBundle,
- SubCats: map[string]hotline.NewsCategoryListData15{
- "NestedCat": {
- Name: "NestedCat",
- Type: hotline.NewsCategory,
- Articles: make(map[uint32]*hotline.NewsArtData),
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- Articles: make(map[uint32]*hotline.NewsArtData),
- },
- },
- },
- "TestCat": {
- Type: hotline.NewsCategory,
- Name: "TestCat",
- Articles: map[uint32]*hotline.NewsArtData{
- 1: {
- Title: "TestArt",
- Poster: "Halcyon 1.9.2",
- Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfc, 0xcc},
- NextArt: [4]byte{0, 0, 0, 2},
- FirstChildArt: [4]byte{0, 0, 0, 2},
- Data: "TestArt Body",
- },
- 2: {
- Title: "Re: TestArt",
- Poster: "Halcyon 1.9.2",
- Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfc, 0xd8},
- PrevArt: [4]byte{0, 0, 0, 1},
- ParentArt: [4]byte{0, 0, 0, 1},
- NextArt: [4]byte{0, 0, 0, 3},
- Data: "I'm a reply",
- },
- 3: {
- Title: "TestArt 2",
- Poster: "Halcyon 1.9.2",
- Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfd, 0x06},
- PrevArt: [4]byte{0, 0, 0, 2},
- Data: "Hello world",
- },
+ "Archive": {
+ Name: "Archive",
+ Type: hotline.NewsBundle,
+ SubCats: map[string]hotline.NewsCategoryListData15{
+ "Old News": {
+ Name: "Old News",
+ Type: hotline.NewsCategory,
+ Articles: map[uint32]*hotline.NewsArtData{
+ 1: {
+ Title: "Legacy Post",
+ Poster: "olduser",
+ Data: "Old content",
},
- SubCats: make(map[string]hotline.NewsCategoryListData15),
},
+ SubCats: make(map[string]hotline.NewsCategoryListData15),
},
},
},
- wantErr: assert.NoError,
},
}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := NewThreadedNewsYAML(tt.args.filePath)
- if !tt.wantErr(t, err, fmt.Sprintf("NewThreadedNewsYAML(%v)", tt.args.filePath)) {
- return
- }
- assert.Equalf(t, tt.want, got, "NewThreadedNewsYAML(%v)", tt.args.filePath)
- })
- }
+
+ dir := t.TempDir()
+ fp := filepath.Join(dir, "ThreadedNews.yaml")
+
+ data, err := yaml.Marshal(&tn)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(fp, data, 0644))
+
+ result, err := NewThreadedNewsYAML(fp)
+ require.NoError(t, err)
+
+ return result
}
-func TestThreadedNewsYAML_CreateGrouping(t *testing.T) {
- // Create a temporary directory.
- tmpDir, err := os.MkdirTemp("", "createGrouping")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory.
+func TestThreadedNewsYAML_GetCategories(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // Path to the temporary ban file.
- tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml")
+ t.Run("returns top-level categories sorted by name", func(t *testing.T) {
+ cats := tn.GetCategories(nil)
+ require.Len(t, cats, 2)
+ assert.Equal(t, "Archive", cats[0].Name)
+ assert.Equal(t, "General", cats[1].Name)
+ })
- type fields struct {
- ThreadedNews hotline.ThreadedNews
- filePath string
- }
- type args struct {
- newsPath []string
- name string
- t [2]byte
- }
- tests := []struct {
- name string
- fields fields
- args args
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "new bundle",
- fields: fields{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "": {
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: tmpFilePath,
- },
- args: args{
- newsPath: []string{""},
- name: "new bundle",
- t: hotline.NewsBundle,
- },
- wantErr: assert.NoError,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- n := &ThreadedNewsYAML{
- ThreadedNews: tt.fields.ThreadedNews,
- filePath: tt.fields.filePath,
- mu: sync.Mutex{},
- }
- tt.wantErr(t, n.CreateGrouping(tt.args.newsPath, tt.args.name, tt.args.t), fmt.Sprintf("CreateGrouping(%v, %v, %v)", tt.args.newsPath, tt.args.name, tt.args.t))
- })
- }
+ t.Run("returns subcategories for a bundle", func(t *testing.T) {
+ cats := tn.GetCategories([]string{"Archive"})
+ require.Len(t, cats, 1)
+ assert.Equal(t, "Old News", cats[0].Name)
+ })
+
+ t.Run("returns empty slice for path with no children", func(t *testing.T) {
+ cats := tn.GetCategories([]string{"Archive", "Old News"})
+ assert.Empty(t, cats)
+ })
}
-func TestThreadedNewsYAML_CreateGrouping_rollback(t *testing.T) {
- n := &ThreadedNewsYAML{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "Existing": {
- Name: "Existing",
- Type: hotline.NewsCategory,
- Articles: make(map[uint32]*hotline.NewsArtData),
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: "/nonexistent/dir/ThreadedNews.yaml",
- }
+func TestThreadedNewsYAML_NewsItem(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.CreateGrouping(nil, "NewBundle", hotline.NewsBundle)
- assert.Error(t, err)
+ t.Run("retrieves top-level item", func(t *testing.T) {
+ item := tn.NewsItem([]string{"General"})
+ assert.Equal(t, "General", item.Name)
+ assert.Equal(t, hotline.NewsCategory, item.Type)
+ })
- // The new entry should have been rolled back.
- _, exists := n.ThreadedNews.Categories["NewBundle"]
- assert.False(t, exists, "new grouping should be removed on write failure")
+ t.Run("retrieves nested item", func(t *testing.T) {
+ item := tn.NewsItem([]string{"Archive", "Old News"})
+ assert.Equal(t, "Old News", item.Name)
+ assert.Equal(t, hotline.NewsCategory, item.Type)
+ })
+}
+
+func TestThreadedNewsYAML_GetArticle(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ t.Run("returns existing article", func(t *testing.T) {
+ art := tn.GetArticle([]string{"General"}, 1)
+ require.NotNil(t, art)
+ assert.Equal(t, "Welcome", art.Title)
+ assert.Equal(t, "admin", art.Poster)
+ assert.Equal(t, "Hello world", art.Data)
+ })
- // Existing entry should still be present.
- _, exists = n.ThreadedNews.Categories["Existing"]
- assert.True(t, exists, "existing grouping should be preserved")
+ t.Run("returns nil for missing article ID", func(t *testing.T) {
+ art := tn.GetArticle([]string{"General"}, 999)
+ assert.Nil(t, art)
+ })
+
+ t.Run("returns article in nested category", func(t *testing.T) {
+ art := tn.GetArticle([]string{"Archive", "Old News"}, 1)
+ require.NotNil(t, art)
+ assert.Equal(t, "Legacy Post", art.Title)
+ })
}
-func TestThreadedNewsYAML_DeleteNewsItem_rollback(t *testing.T) {
- n := &ThreadedNewsYAML{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "ToDelete": {
- Name: "ToDelete",
- Type: hotline.NewsCategory,
- Articles: make(map[uint32]*hotline.NewsArtData),
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: "/nonexistent/dir/ThreadedNews.yaml",
- }
+func TestThreadedNewsYAML_ListArticles(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.DeleteNewsItem([]string{"ToDelete"})
- assert.Error(t, err)
+ t.Run("lists articles for a category with articles", func(t *testing.T) {
+ artList, err := tn.ListArticles([]string{"General"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, artList.Count)
+ assert.NotEmpty(t, artList.NewsArtList)
+ })
- // The deleted entry should have been restored.
- cat, exists := n.ThreadedNews.Categories["ToDelete"]
- assert.True(t, exists, "deleted item should be restored on write failure")
- assert.Equal(t, "ToDelete", cat.Name)
+ t.Run("lists articles for nested category", func(t *testing.T) {
+ artList, err := tn.ListArticles([]string{"Archive", "Old News"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, artList.Count)
+ })
}
-func TestThreadedNewsYAML_PostArticle_rollback(t *testing.T) {
- // Set up a category with existing articles (mimics the test fixture).
- n := &ThreadedNewsYAML{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "TestCat": {
- Type: hotline.NewsCategory,
- Name: "TestCat",
- Articles: map[uint32]*hotline.NewsArtData{
- 1: {
- Title: "TestArt",
- NextArt: [4]byte{0, 0, 0, 2},
- FirstChildArt: [4]byte{0, 0, 0, 2},
- },
- 2: {
- Title: "Re: TestArt",
- PrevArt: [4]byte{0, 0, 0, 1},
- ParentArt: [4]byte{0, 0, 0, 1},
- NextArt: [4]byte{0, 0, 0, 3},
- },
- 3: {
- Title: "TestArt 2",
- PrevArt: [4]byte{0, 0, 0, 2},
- },
- },
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: "/nonexistent/dir/ThreadedNews.yaml",
- }
+func TestThreadedNewsYAML_PostArticle(t *testing.T) {
+ t.Run("posts article to empty category and persists", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // Snapshot state before.
- origNextArt3 := n.ThreadedNews.Categories["TestCat"].Articles[3].NextArt
+ // Create a new empty category first.
+ err := tn.CreateGrouping(nil, "Fresh", hotline.NewsCategory)
+ require.NoError(t, err)
- newArticle := hotline.NewsArtData{
- Title: "New Article",
- Poster: "tester",
- }
+ article := hotline.NewsArtData{
+ Title: "First Post",
+ Poster: "user1",
+ Data: "Content here",
+ }
+ err = tn.PostArticle([]string{"Fresh"}, 0, article)
+ require.NoError(t, err)
- err := n.PostArticle([]string{"TestCat"}, 0, newArticle)
- assert.Error(t, err)
+ // Verify article was stored with ID 1.
+ art := tn.GetArticle([]string{"Fresh"}, 1)
+ require.NotNil(t, art)
+ assert.Equal(t, "First Post", art.Title)
+ assert.Equal(t, "Content here", art.Data)
+
+ // Verify persistence by reloading.
+ require.NoError(t, tn.Load())
+ art = tn.GetArticle([]string{"Fresh"}, 1)
+ require.NotNil(t, art)
+ assert.Equal(t, "First Post", art.Title)
+ })
+
+ t.Run("posts second article and links to previous", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ // General already has article 1. Post a second one.
+ article := hotline.NewsArtData{
+ Title: "Second Post",
+ Poster: "user2",
+ Data: "More content",
+ }
+ err := tn.PostArticle([]string{"General"}, 0, article)
+ require.NoError(t, err)
+
+ // New article should be ID 2.
+ art := tn.GetArticle([]string{"General"}, 2)
+ require.NotNil(t, art)
+ assert.Equal(t, "Second Post", art.Title)
+
+ // PrevArt of the new article should point to article 1.
+ prevID := binary.BigEndian.Uint32(art.PrevArt[:])
+ assert.Equal(t, uint32(1), prevID)
+
+ // NextArt of the first article should point to article 2.
+ first := tn.GetArticle([]string{"General"}, 1)
+ require.NotNil(t, first)
+ nextID := binary.BigEndian.Uint32(first.NextArt[:])
+ assert.Equal(t, uint32(2), nextID)
+ })
- cat := n.ThreadedNews.Categories["TestCat"]
+ t.Run("reply sets parent FirstChildArt", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // New article (ID 4) should not exist.
- _, exists := cat.Articles[4]
- assert.False(t, exists, "new article should be removed on write failure")
+ // Post a reply to article 1 in General.
+ reply := hotline.NewsArtData{
+ Title: "Reply",
+ Poster: "replier",
+ Data: "I agree",
+ }
+ err := tn.PostArticle([]string{"General"}, 1, reply)
+ require.NoError(t, err)
- // Article 3's NextArt should be restored to its original value.
- assert.Equal(t, origNextArt3, cat.Articles[3].NextArt, "previous article's NextArt should be restored")
+ // The reply should be article 2.
+ replyArt := tn.GetArticle([]string{"General"}, 2)
+ require.NotNil(t, replyArt)
- // Should still have exactly 3 articles.
- assert.Len(t, cat.Articles, 3)
+ // ParentArt should be set to 1.
+ parentID := binary.BigEndian.Uint32(replyArt.ParentArt[:])
+ assert.Equal(t, uint32(1), parentID)
+
+ // Parent article's FirstChildArt should now point to 2.
+ parent := tn.GetArticle([]string{"General"}, 1)
+ require.NotNil(t, parent)
+ firstChild := binary.BigEndian.Uint32(parent.FirstChildArt[:])
+ assert.Equal(t, uint32(2), firstChild)
+ })
+
+ t.Run("returns error for empty news path", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+ err := tn.PostArticle(nil, 0, hotline.NewsArtData{})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid news path")
+ })
}
-func TestThreadedNewsYAML_PostArticle_rollback_with_parent(t *testing.T) {
- // Test that FirstChildArt is restored when posting a reply.
- n := &ThreadedNewsYAML{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "TestCat": {
- Type: hotline.NewsCategory,
- Name: "TestCat",
- Articles: map[uint32]*hotline.NewsArtData{
- 1: {
- Title: "Parent Article",
- // No FirstChildArt set — this is the first reply.
- },
- },
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: "/nonexistent/dir/ThreadedNews.yaml",
- }
+func TestThreadedNewsYAML_DeleteArticle(t *testing.T) {
+ tn := newTestThreadedNews(t)
- newArticle := hotline.NewsArtData{
- Title: "Reply",
- Poster: "tester",
- }
+ // Confirm article exists before deletion.
+ art := tn.GetArticle([]string{"General"}, 1)
+ require.NotNil(t, art)
+
+ err := tn.DeleteArticle([]string{"General"}, 1, false)
+ require.NoError(t, err)
+
+ // Article should be gone.
+ art = tn.GetArticle([]string{"General"}, 1)
+ assert.Nil(t, art)
+
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ art = tn.GetArticle([]string{"General"}, 1)
+ assert.Nil(t, art)
+}
- err := n.PostArticle([]string{"TestCat"}, 1, newArticle)
+func TestThreadedNewsYAML_DeleteArticle_EmptyPath(t *testing.T) {
+ tn := newTestThreadedNews(t)
+ err := tn.DeleteArticle(nil, 1, false)
assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid news path")
+}
+
+func TestThreadedNewsYAML_CreateGrouping(t *testing.T) {
+ t.Run("creates a new top-level bundle", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ err := tn.CreateGrouping(nil, "NewBundle", hotline.NewsBundle)
+ require.NoError(t, err)
+
+ cats := tn.GetCategories(nil)
+ var names []string
+ for _, c := range cats {
+ names = append(names, c.Name)
+ }
+ assert.Contains(t, names, "NewBundle")
+
+ // Verify the type.
+ item := tn.NewsItem([]string{"NewBundle"})
+ assert.Equal(t, hotline.NewsBundle, item.Type)
- cat := n.ThreadedNews.Categories["TestCat"]
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ item = tn.NewsItem([]string{"NewBundle"})
+ assert.Equal(t, "NewBundle", item.Name)
+ })
- // FirstChildArt should be restored to zero.
- assert.Equal(t, [4]byte{}, cat.Articles[1].FirstChildArt, "parent's FirstChildArt should be restored")
+ t.Run("creates a nested category inside a bundle", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // New article should not exist.
- _, exists := cat.Articles[2]
- assert.False(t, exists, "reply article should be removed on write failure")
+ err := tn.CreateGrouping([]string{"Archive"}, "Recent", hotline.NewsCategory)
+ require.NoError(t, err)
+
+ cats := tn.GetCategories([]string{"Archive"})
+ var names []string
+ for _, c := range cats {
+ names = append(names, c.Name)
+ }
+ assert.Contains(t, names, "Recent")
+ assert.Contains(t, names, "Old News")
+
+ item := tn.NewsItem([]string{"Archive", "Recent"})
+ assert.Equal(t, hotline.NewsCategory, item.Type)
+ })
}
-func TestThreadedNewsYAML_DeleteArticle_rollback(t *testing.T) {
- n := &ThreadedNewsYAML{
- ThreadedNews: hotline.ThreadedNews{
- Categories: map[string]hotline.NewsCategoryListData15{
- "TestCat": {
- Type: hotline.NewsCategory,
- Name: "TestCat",
- Articles: map[uint32]*hotline.NewsArtData{
- 1: {
- Title: "Article to delete",
- },
- },
- SubCats: make(map[string]hotline.NewsCategoryListData15),
- },
- },
- },
- filePath: "/nonexistent/dir/ThreadedNews.yaml",
- }
+func TestThreadedNewsYAML_DeleteNewsItem(t *testing.T) {
+ t.Run("deletes a top-level category", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.DeleteArticle([]string{"TestCat"}, 1, false)
- assert.Error(t, err)
+ err := tn.DeleteNewsItem([]string{"General"})
+ require.NoError(t, err)
+
+ cats := tn.GetCategories(nil)
+ for _, c := range cats {
+ assert.NotEqual(t, "General", c.Name)
+ }
+
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ cats = tn.GetCategories(nil)
+ for _, c := range cats {
+ assert.NotEqual(t, "General", c.Name)
+ }
+ })
+
+ t.Run("deletes a nested subcategory", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ err := tn.DeleteNewsItem([]string{"Archive", "Old News"})
+ require.NoError(t, err)
+
+ cats := tn.GetCategories([]string{"Archive"})
+ assert.Empty(t, cats)
+ })
+}
+
+func TestThreadedNewsYAML_Load(t *testing.T) {
+ t.Run("loads from valid fixture file", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ // Verify data loaded correctly.
+ assert.NotNil(t, tn.ThreadedNews.Categories)
+ assert.Contains(t, tn.ThreadedNews.Categories, "General")
+ assert.Contains(t, tn.ThreadedNews.Categories, "Archive")
+ })
+
+ t.Run("returns error for missing file", func(t *testing.T) {
+ _, err := NewThreadedNewsYAML("/nonexistent/path/ThreadedNews.yaml")
+ assert.Error(t, err)
+ })
- cat := n.ThreadedNews.Categories["TestCat"]
+ t.Run("returns error for invalid YAML", func(t *testing.T) {
+ dir := t.TempDir()
+ fp := filepath.Join(dir, "bad.yaml")
+ require.NoError(t, os.WriteFile(fp, []byte(":::not yaml[[["), 0644))
- // Deleted article should be restored.
- art, exists := cat.Articles[1]
- assert.True(t, exists, "deleted article should be restored on write failure")
- assert.Equal(t, "Article to delete", art.Title)
+ _, err := NewThreadedNewsYAML(fp)
+ assert.Error(t, err)
+ })
}
diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go
index e9dfdfc..3af374e 100644
--- a/internal/mobius/transaction_handlers_test.go
+++ b/internal/mobius/transaction_handlers_test.go
@@ -4,6 +4,7 @@ import (
"cmp"
"encoding/binary"
"errors"
+ "fmt"
"io"
"io/fs"
"log/slog"
@@ -42,6 +43,12 @@ func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) {
return args.Get(0).(int64), args.Error(1)
}
+type nopReadWriteCloser struct{}
+
+func (nopReadWriteCloser) Read([]byte) (int, error) { return 0, io.EOF }
+func (nopReadWriteCloser) Write(p []byte) (int, error) { return len(p), nil }
+func (nopReadWriteCloser) Close() error { return nil }
+
func NewTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, nil))
}
@@ -1575,6 +1582,117 @@ func TestHandleDeleteUser(t *testing.T) {
},
},
},
+ {
+ name: "when user is currently connected",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Delete", "testuser").Return(nil)
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 2},
+ Account: &hotline.Account{
+ Login: "testuser",
+ },
+ Connection: nopReadWriteCloser{},
+ Server: &hotline.Server{
+ ClientMgr: hotline.NewMemClientMgr(),
+ Logger: NewTestLogger(),
+ },
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 2},
+ Type: hotline.TranServerMsg,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
+ hotline.NewField(hotline.FieldChatOptions, []byte{2}),
+ },
+ },
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ {
+ name: "when no matching connected clients",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Delete", "testuser").Return(nil)
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 3},
+ Account: &hotline.Account{
+ Login: "otheruser",
+ },
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -1789,6 +1907,90 @@ func TestHandleNewUser(t *testing.T) {
},
},
},
+ {
+ name: "when account is created successfully",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ for i := 0; i < 64; i++ {
+ bits.Set(i)
+ }
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "userB").Return((*hotline.Account)(nil))
+ m.On("Create", mock.Anything).Return(nil)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))),
+ hotline.NewField(hotline.FieldUserName, []byte("User B")),
+ hotline.NewField(hotline.FieldUserPassword, []byte("pass")),
+ hotline.NewField(
+ hotline.FieldUserAccess,
+ func() []byte {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateUser)
+ return bits[:]
+ }(),
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when account already exists",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "userB").Return(&hotline.Account{Login: "userB"})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))),
+ hotline.NewField(hotline.FieldUserName, []byte("User B")),
+ hotline.NewField(hotline.FieldUserPassword, []byte("pass")),
+ hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte(fmt.Sprintf(ErrMsgAccountExistsTemplate, "userB"))),
+ },
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2208,6 +2410,391 @@ func TestHandleUpdateUser(t *testing.T) {
},
},
},
+ {
+ name: "when action is delete user with permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Delete", "testuser").Return(nil)
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Login: "testuser",
+ },
+ Connection: nopReadWriteCloser{},
+ Server: &hotline.Server{
+ ClientMgr: hotline.NewMemClientMgr(),
+ Logger: NewTestLogger(),
+ },
+ },
+ })
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x01, // 1 subfield (delete)
+ 0x00, 0x65, // FieldData = 101
+ 0x00, 0x08, // length
+ 0x8b, 0x9a, 0x8c, 0x8b, 0x8a, 0x8c, 0x9a, 0x8d, // obfuscated "testuser"
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Type: hotline.TranServerMsg,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+ },
+ },
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when action is delete user and Delete returns error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Delete", "testuser").Return(errors.New("disk error"))
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x01,
+ 0x00, 0x65,
+ 0x00, 0x08,
+ 0x8b, 0x9a, 0x8c, 0x8b, 0x8a, 0x8c, 0x9a, 0x8d,
+ }),
+ ),
+ },
+ wantRes: nil,
+ },
+ {
+ name: "when action is modify existing user with password",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return(&hotline.Account{
+ Login: "bbb",
+ Name: "old name",
+ })
+ m.On("Update", mock.Anything, "bbb").Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessModifyUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when action is modify existing user with password cleared",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return(&hotline.Account{
+ Login: "bbb",
+ Name: "old name",
+ })
+ m.On("Update", mock.Anything, "bbb").Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessModifyUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x03, // field count (3 subfields, no password)
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when action is create user with valid permissions",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return((*hotline.Account)(nil))
+ m.On("Create", mock.Anything).Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateUser)
+ // Set all bits that the new account will have
+ for i := 0; i < 64; i++ {
+ bits.Set(i)
+ }
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when action is create user with escalated privileges",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return((*hotline.Account)(nil))
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, // AccessDisconUser set (bit 22)
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Cannot create account with more access than yourself.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when action is create user and Create returns error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return((*hotline.Account)(nil))
+ m.On("Create", mock.Anything).Return(errors.New("account exists"))
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ // Set all bits so privilege check passes
+ for i := 0; i < 64; i++ {
+ bits.Set(i)
+ }
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountExists)),
+ },
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2392,6 +2979,134 @@ func TestHandleDisconnectUser(t *testing.T) {
},
},
},
+ {
+ name: "with temporary ban option",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+ ID: hotline.ClientID{0x0, 0x1},
+ UserName: []byte("baduser"),
+ RemoteAddr: "10.0.0.1:12345",
+ Connection: nopReadWriteCloser{},
+ Account: &hotline.Account{
+ Login: "baduser",
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ ClientMgr: hotline.NewMemClientMgr(),
+ Logger: NewTestLogger(),
+ },
+ })
+ return &m
+ }(),
+ BanList: func() *hotline.MockBanMgr {
+ m := hotline.MockBanMgr{}
+ m.On("Add", "10.0.0.1", mock.AnythingOfType("*time.Time")).Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDisconnectUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: hotline.TranServerMsg,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+ },
+ },
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "with permanent ban option",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+ ID: hotline.ClientID{0x0, 0x1},
+ UserName: []byte("baduser"),
+ RemoteAddr: "10.0.0.2:12345",
+ Connection: nopReadWriteCloser{},
+ Account: &hotline.Account{
+ Login: "baduser",
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ ClientMgr: hotline.NewMemClientMgr(),
+ Logger: NewTestLogger(),
+ },
+ })
+ return &m
+ }(),
+ BanList: func() *hotline.MockBanMgr {
+ m := hotline.MockBanMgr{}
+ m.On("Add", "10.0.0.2", (*time.Time)(nil)).Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDisconnectUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: hotline.TranServerMsg,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+ },
+ },
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -3939,56 +4654,53 @@ func TestHandleGetNewsArtNameList(t *testing.T) {
},
},
},
- //{
- // name: "when user has required access",
- // args: args{
- // cc: &hotline.ClientConn{
- // Account: &hotline.Account{
- // Access: func() hotline.AccessBitmap {
- // var bits hotline.AccessBitmap
- // bits.Set(hotline.AccessNewsReadArt)
- // return bits
- // }(),
- // },
- // Server: &hotline.Server{
- // TextDecoder: charmap.Macintosh.NewDecoder(),
- // TextEncoder: charmap.Macintosh.NewEncoder(),
- // ThreadedNewsMgr: func() *mockThreadNewsMgr {
- // m := mockThreadNewsMgr{}
- // m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{
- // Name: []byte("testTitle"),
- // NewsArtList: []byte{},
- // })
- // return &m
- // }(),
- // },
- // },
- // t: NewTransaction(
- // TranGetNewsArtNameList,
- // [2]byte{0, 1},
- // // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat|
- // // 00000010 65 67 6f 72 79 |egory|
- // NewField(hotline.FieldNewsPath, []byte{
- // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
- // }),
- // ),
- // },
- // wantRes: []hotline.Transaction{
- // {
- // IsReply: 0x01,
- // Fields: []hotline.Field{
- // NewField(hotline.FieldNewsArtListData, []byte{
- // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
- // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
- // 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50,
- // 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e,
- // 0x00, 0x08,
- // },
- // ),
- // },
- // },
- // },
- //},
+ {
+ name: "when user has required access",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsReadArt)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("ListArticles", []string{"Example Category"}).Return(hotline.NewsArtListData{
+ Name: []byte{},
+ Description: []byte{},
+ NewsArtList: []byte{},
+ }, nil)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtNameList,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldNewsPath, []byte{
+ 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldNewsArtListData, []byte{
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00,
+ 0x00,
+ }),
+ },
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -4143,24 +4855,32 @@ func TestHandleNewNewsFldr(t *testing.T) {
}
func TestHandleDownloadBanner(t *testing.T) {
- type args struct {
- cc *hotline.ClientConn
- t hotline.Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []hotline.Transaction
- }{
- // TODO: Add test cases.
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t)
+ t.Run("returns banner transfer info", func(t *testing.T) {
+ cc := &hotline.ClientConn{
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Server: &hotline.Server{
+ Banner: []byte("test-banner-data"),
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ },
+ }
+ tran := hotline.NewTransaction(hotline.TranDownloadBanner, [2]byte{0, 1})
- assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t)
- })
- }
+ gotRes := HandleDownloadBanner(cc, &tran)
+
+ assert.Len(t, gotRes, 1)
+ assert.Equal(t, byte(0x01), gotRes[0].IsReply)
+
+ // Verify transfer size field matches banner length
+ transferSizeField := gotRes[0].GetField(hotline.FieldTransferSize)
+ assert.NotNil(t, transferSizeField)
+ gotSize := binary.BigEndian.Uint32(transferSizeField.Data)
+ assert.Equal(t, uint32(len("test-banner-data")), gotSize)
+
+ // Verify refnum field is present
+ refNumField := gotRes[0].GetField(hotline.FieldRefNum)
+ assert.NotNil(t, refNumField)
+ assert.Len(t, refNumField.Data, 4)
+ })
}
func TestHandlePostNewsArt(t *testing.T) {
@@ -4321,6 +5041,79 @@ func TestHandleUploadFolder(t *testing.T) {
},
},
},
+ {
+ name: "when user has upload access but not upload anywhere and path is not upload dir",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessUploadFolder)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUploadFldr, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("myFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x05,
+ 0x46, 0x69, 0x6c, 0x65, 0x73, // "Files"
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Cannot accept upload of the folder \"myFolder\" because you are only allowed to upload to the \"Uploads\" folder.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user has upload access and upload anywhere permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessUploadFolder)
+ bits.Set(hotline.AccessUploadAnywhere)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ Config: hotline.Config{FileRoot: "/fakeRoot"},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUploadFldr, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("myFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x05,
+ 0x46, 0x69, 0x6c, 0x65, 0x73, // "Files"
+ }),
+ hotline.NewField(hotline.FieldTransferSize, []byte{0, 0, 0x10, 0}),
+ hotline.NewField(hotline.FieldFolderItemCount, []byte{0, 0, 0, 5}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldRefNum, []byte{0, 0, 0, 0}), // placeholder, TranAssertEqual strips this
+ },
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -4989,6 +5782,53 @@ func TestHandleSetUser(t *testing.T) {
},
},
},
+ {
+ name: "when update is successful with password change",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessModifyUser)
+ return bits
+ }(),
+ },
+ Logger: NewTestLogger(),
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "testuser").Return(&hotline.Account{
+ Login: "testuser",
+ Name: "Old Name",
+ })
+ m.On("Update", mock.Anything, "testuser").Return(nil)
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+ hotline.NewField(hotline.FieldUserName, []byte("New Name")),
+ hotline.NewField(hotline.FieldUserPassword, []byte("newpass")),
+ hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -5137,3 +5977,551 @@ func TestHandleNewNewsCat(t *testing.T) {
})
}
}
+
+func TestHandleKeepAlive(t *testing.T) {
+ cc := &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ }
+ tran := &hotline.Transaction{
+ ID: [4]byte{0, 0, 0, 1},
+ }
+
+ got := HandleKeepAlive(cc, tran)
+
+ assert.Len(t, got, 1)
+ assert.Equal(t, byte(1), got[0].IsReply)
+ assert.Equal(t, tran.ID, got[0].ID)
+}
+
+func TestHandleUserBroadcast(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "without broadcast access returns error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{Access: hotline.AccessBitmap{}},
+ ID: [2]byte{0, 1},
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUserBroadcast, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hello")),
+ ),
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedSendBroadcast)),
+ },
+ },
+ },
+ },
+ {
+ name: "with broadcast access sends broadcast and returns success",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessBroadcast)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUserBroadcast, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hello everyone")),
+ ),
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleUserBroadcast(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.want, got)
+ })
+ }
+}
+
+func TestHandleGetNewsCatNameList(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "without news read access returns error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{Access: hotline.AccessBitmap{}},
+ ID: [2]byte{0, 1},
+ },
+ t: hotline.NewTransaction(hotline.TranGetNewsCatNameList, [2]byte{0, 1}),
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedReadNews)),
+ },
+ },
+ },
+ },
+ {
+ name: "with access returns news categories",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsReadArt)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("GetCategories", []string{}).Return([]hotline.NewsCategoryListData15{
+ {
+ Type: hotline.NewsBundle,
+ Name: "Test Bundle",
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(hotline.TranGetNewsCatNameList, [2]byte{0, 1}),
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldNewsCatListData15, []byte{
+ 0x00, 0x02, // Type: bundle
+ 0x00, 0x00, // Count: 0 articles+subcats
+ 0x0b, // Name length: 11
+ 0x54, 0x65, 0x73, 0x74, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Test Bundle"
+ }),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleGetNewsCatNameList(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.want, got)
+ })
+ }
+}
+
+func TestHandleMoveFile(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have permission to move a file",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{
+ FileRoot: "/fakeRoot/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.FileMode(0))
+ mfi.On("Size").Return(int64(100))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(false)
+ mfi.On("Name").Return("testfile")
+
+ mfs := &hotline.MockFileStore{}
+ // NewFile calls: Stat data, Stat info, Stat rsrc
+ mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranMoveFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x61, 0x61, 0x61,
+ }),
+ hotline.NewField(hotline.FieldFileNewPath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x62, 0x62, 0x62,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to move files.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have permission to move a folder",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{
+ FileRoot: "/fakeRoot/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.ModeDir)
+ mfi.On("Size").Return(int64(0))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(true)
+ mfi.On("Name").Return("testfolder")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/aaa/testfolder").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfolder").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfolder").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranMoveFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x61, 0x61, 0x61,
+ }),
+ hotline.NewField(hotline.FieldFileNewPath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x62, 0x62, 0x62,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to move folders.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleMoveFile(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleSetFileInfo(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have permission to set file comment",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{
+ FileRoot: "/fakeRoot/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.FileMode(0))
+ mfi.On("Size").Return(int64(100))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(false)
+ mfi.On("Name").Return("testfile")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/testfile").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/.info_testfile").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetFileInfo, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ hotline.NewField(hotline.FieldFileComment, []byte("a comment")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to set comments for files.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have permission to rename a file",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{
+ FileRoot: "/fakeRoot/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.FileMode(0))
+ mfi.On("Size").Return(int64(100))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(false)
+ mfi.On("Name").Return("testfile")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/testfile").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/.info_testfile").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetFileInfo, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ hotline.NewField(hotline.FieldFileNewName, []byte("newname")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to rename files.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have permission to set folder comment",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{
+ FileRoot: "/fakeRoot/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.ModeDir)
+ mfi.On("Size").Return(int64(0))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(true)
+ mfi.On("Name").Return("testfolder")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/testfolder").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/.info_testfolder").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfolder").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetFileInfo, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ hotline.NewField(hotline.FieldFileComment, []byte("a comment")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to set comments for folders.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleSetFileInfo(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestRegisterHandlers(t *testing.T) {
+ srv, err := hotline.NewServer()
+ assert.NoError(t, err)
+
+ RegisterHandlers(srv)
+
+ // Verify that a known transaction type is handled by sending a transaction
+ // that requires no permission. We can't inspect the handlers map directly
+ // since it's unexported, but we can verify RegisterHandlers doesn't panic
+ // and the server is functional.
+}
+
+func TestHandleDownloadFolder_withAccess(t *testing.T) {
+ // Create a temp dir with files to act as the download folder
+ tmpDir := t.TempDir()
+ dlDir := filepath.Join(tmpDir, "testFolder")
+ err := os.MkdirAll(dlDir, 0755)
+ assert.NoError(t, err)
+
+ // Create a test file inside the folder
+ err = os.WriteFile(filepath.Join(dlDir, "file.txt"), []byte("hello"), 0644)
+ assert.NoError(t, err)
+
+ cc := &hotline.ClientConn{
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDownloadFolder)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ Config: hotline.Config{FileRoot: tmpDir},
+ },
+ }
+
+ tran := hotline.NewTransaction(
+ hotline.TranDownloadFldr, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ )
+
+ gotRes := HandleDownloadFolder(cc, &tran)
+ assert.Len(t, gotRes, 1)
+ assert.Equal(t, byte(0x01), gotRes[0].IsReply)
+
+ // Verify expected fields are present
+ assert.NotNil(t, gotRes[0].GetField(hotline.FieldRefNum))
+ assert.NotNil(t, gotRes[0].GetField(hotline.FieldTransferSize))
+ assert.NotNil(t, gotRes[0].GetField(hotline.FieldFolderItemCount))
+}