repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
0d69224
parent
f8ad8c5
author
Eric Bower
date
2025-12-18 20:36:11 -0500 EST
chore: add tests
2 files changed,  +238, -1
M pkg/send/protocols/rsync/rsync.go
+1, -1
1@@ -148,7 +148,7 @@ func (h *handler) Remove(willReceive []*rsyncutils.ReceiverFile) error {
2 			return rf.Name == entry.Name()
3 		})
4 
5-		if !exists && entry.Name() != "._pico_keep_dir" {
6+		if !exists && path.Base(entry.Name()) != "._pico_keep_dir" {
7 			toDelete = append(toDelete, entry.Name())
8 		}
9 	}
A pkg/send/protocols/rsync/rsync_test.go
+237, -0
  1@@ -0,0 +1,237 @@
  2+package rsync
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"io"
  8+	"io/fs"
  9+	"log/slog"
 10+	"os"
 11+	"testing"
 12+	"time"
 13+
 14+	rsyncutils "github.com/picosh/go-rsync-receiver/utils"
 15+	"github.com/picosh/pico/pkg/pssh"
 16+	"github.com/picosh/pico/pkg/send/utils"
 17+	"golang.org/x/crypto/ssh"
 18+)
 19+
 20+// mockFileInfo implements fs.FileInfo for testing.
 21+type mockFileInfo struct {
 22+	name    string
 23+	size    int64
 24+	mode    fs.FileMode
 25+	modTime time.Time
 26+	isDir   bool
 27+}
 28+
 29+func (m *mockFileInfo) Name() string       { return m.name }
 30+func (m *mockFileInfo) Size() int64        { return m.size }
 31+func (m *mockFileInfo) Mode() fs.FileMode  { return m.mode }
 32+func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
 33+func (m *mockFileInfo) IsDir() bool        { return m.isDir }
 34+func (m *mockFileInfo) Sys() any           { return nil }
 35+
 36+// mockWriteHandler implements utils.CopyFromClientHandler for testing.
 37+// It records the order of Delete calls to verify deletion order.
 38+type mockWriteHandler struct {
 39+	entries      []os.FileInfo
 40+	deleteCalls  []string
 41+	deleteErrors map[string]error
 42+}
 43+
 44+func (m *mockWriteHandler) Delete(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
 45+	m.deleteCalls = append(m.deleteCalls, entry.Filepath)
 46+	if m.deleteErrors != nil {
 47+		if err, ok := m.deleteErrors[entry.Filepath]; ok {
 48+			return err
 49+		}
 50+	}
 51+	return nil
 52+}
 53+
 54+func (m *mockWriteHandler) Write(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (string, error) {
 55+	return "", nil
 56+}
 57+
 58+func (m *mockWriteHandler) Read(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
 59+	return nil, nil, nil
 60+}
 61+
 62+func (m *mockWriteHandler) List(_ *pssh.SSHServerConnSession, _ string, _ bool, _ bool) ([]os.FileInfo, error) {
 63+	return m.entries, nil
 64+}
 65+
 66+func (m *mockWriteHandler) GetLogger(_ *pssh.SSHServerConnSession) *slog.Logger {
 67+	return slog.Default()
 68+}
 69+
 70+func (m *mockWriteHandler) Validate(_ *pssh.SSHServerConnSession) error {
 71+	return nil
 72+}
 73+
 74+// mockChannel implements ssh.Channel for testing.
 75+type mockChannel struct {
 76+	stderr *bytes.Buffer
 77+}
 78+
 79+func (m *mockChannel) Read(_ []byte) (int, error)     { return 0, io.EOF }
 80+func (m *mockChannel) Write(data []byte) (int, error) { return len(data), nil }
 81+func (m *mockChannel) Close() error                   { return nil }
 82+func (m *mockChannel) CloseWrite() error              { return nil }
 83+func (m *mockChannel) SendRequest(_ string, _ bool, _ []byte) (bool, error) {
 84+	return true, nil
 85+}
 86+func (m *mockChannel) Stderr() io.ReadWriter { return m.stderr }
 87+
 88+var _ ssh.Channel = (*mockChannel)(nil)
 89+
 90+// newMockSession creates a mock SSHServerConnSession for testing.
 91+func newMockSession() (*pssh.SSHServerConnSession, *bytes.Buffer) {
 92+	stderr := &bytes.Buffer{}
 93+	channel := &mockChannel{stderr: stderr}
 94+
 95+	ctx, cancel := context.WithCancel(context.Background())
 96+	logger := slog.Default()
 97+	server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{})
 98+	serverConn := pssh.NewSSHServerConn(ctx, logger, &ssh.ServerConn{
 99+		Permissions: &ssh.Permissions{
100+			Extensions: map[string]string{},
101+		},
102+	}, server)
103+
104+	session := &pssh.SSHServerConnSession{
105+		Channel:       channel,
106+		SSHServerConn: serverConn,
107+		Ctx:           ctx,
108+		CancelFunc:    cancel,
109+	}
110+
111+	return session, stderr
112+}
113+
114+func TestRemove_DeletesChildrenBeforeParents(t *testing.T) {
115+	session, _ := newMockSession()
116+	mockHandler := &mockWriteHandler{
117+		entries: []os.FileInfo{
118+			&mockFileInfo{name: "a", isDir: true},
119+			&mockFileInfo{name: "a/file.txt", size: 100},
120+			&mockFileInfo{name: "b/c", isDir: true},
121+			&mockFileInfo{name: "b/c/deep.txt", size: 50},
122+			&mockFileInfo{name: "b", isDir: true},
123+		},
124+	}
125+
126+	h := &handler{
127+		session:      session,
128+		writeHandler: mockHandler,
129+		root:         "test",
130+	}
131+
132+	err := h.Remove([]*rsyncutils.ReceiverFile{})
133+	if err != nil {
134+		t.Fatalf("Remove() returned error: %v", err)
135+	}
136+
137+	if len(mockHandler.deleteCalls) != 5 {
138+		t.Fatalf("expected 5 delete calls, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
139+	}
140+
141+	indexOfA := -1
142+	indexOfAFile := -1
143+	indexOfB := -1
144+	indexOfBC := -1
145+	indexOfBCDeep := -1
146+
147+	for i, path := range mockHandler.deleteCalls {
148+		switch path {
149+		case "/test/a":
150+			indexOfA = i
151+		case "/test/a/file.txt":
152+			indexOfAFile = i
153+		case "/test/b":
154+			indexOfB = i
155+		case "/test/b/c":
156+			indexOfBC = i
157+		case "/test/b/c/deep.txt":
158+			indexOfBCDeep = i
159+		}
160+	}
161+
162+	if indexOfAFile > indexOfA {
163+		t.Errorf("a/file.txt (index %d) should be deleted before a (index %d)", indexOfAFile, indexOfA)
164+	}
165+	if indexOfBCDeep > indexOfBC {
166+		t.Errorf("b/c/deep.txt (index %d) should be deleted before b/c (index %d)", indexOfBCDeep, indexOfBC)
167+	}
168+	if indexOfBC > indexOfB {
169+		t.Errorf("b/c (index %d) should be deleted before b (index %d)", indexOfBC, indexOfB)
170+	}
171+}
172+
173+func TestRemove_IgnoresPicoKeepDir(t *testing.T) {
174+	session, _ := newMockSession()
175+	mockHandler := &mockWriteHandler{
176+		entries: []os.FileInfo{
177+			&mockFileInfo{name: "dir", isDir: true},
178+			&mockFileInfo{name: "dir/._pico_keep_dir", size: 0},
179+			&mockFileInfo{name: "dir/file.txt", size: 100},
180+		},
181+	}
182+
183+	h := &handler{
184+		session:      session,
185+		writeHandler: mockHandler,
186+		root:         "test",
187+	}
188+
189+	err := h.Remove([]*rsyncutils.ReceiverFile{})
190+	if err != nil {
191+		t.Fatalf("Remove() returned error: %v", err)
192+	}
193+
194+	for _, path := range mockHandler.deleteCalls {
195+		if path == "/test/dir/._pico_keep_dir" {
196+			t.Error("._pico_keep_dir should not be in delete list")
197+		}
198+	}
199+
200+	if len(mockHandler.deleteCalls) != 2 {
201+		t.Errorf("expected 2 delete calls (dir, dir/file.txt), got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
202+	}
203+}
204+
205+func TestRemove_OnlyDeletesFilesNotInWillReceive(t *testing.T) {
206+	session, _ := newMockSession()
207+	mockHandler := &mockWriteHandler{
208+		entries: []os.FileInfo{
209+			&mockFileInfo{name: "a.txt", size: 100},
210+			&mockFileInfo{name: "b.txt", size: 100},
211+			&mockFileInfo{name: "c.txt", size: 100},
212+		},
213+	}
214+
215+	h := &handler{
216+		session:      session,
217+		writeHandler: mockHandler,
218+		root:         "test",
219+	}
220+
221+	willReceive := []*rsyncutils.ReceiverFile{
222+		{Name: "a.txt"},
223+		{Name: "c.txt"},
224+	}
225+
226+	err := h.Remove(willReceive)
227+	if err != nil {
228+		t.Fatalf("Remove() returned error: %v", err)
229+	}
230+
231+	if len(mockHandler.deleteCalls) != 1 {
232+		t.Fatalf("expected 1 delete call, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
233+	}
234+
235+	if mockHandler.deleteCalls[0] != "/test/b.txt" {
236+		t.Errorf("expected to delete /test/b.txt, got %s", mockHandler.deleteCalls[0])
237+	}
238+}