diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-03-16 11:46:04 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-03-16 11:46:04 -0700 |
| commit | 2f46f87177234070044b5304ca3e0db471699ad8 (patch) | |
| tree | 6dc72ce596e95850a587001a85b3f45e394b0e34 /internal | |
| parent | 92f4b7af1073b6cdd0786ce2090d6e446e0903ad (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.go | 328 | ||||
| -rw-r--r-- | internal/mobius/api_test.go | 688 | ||||
| -rw-r--r-- | internal/mobius/threaded_news_test.go | 645 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers_test.go | 1522 |
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)) +} |