main pico / pkg / apps / pgs / uploader_test.go
Antonio Mika  ·  2026-10-11
  1package pgs
  2
  3import (
  4	"encoding/pem"
  5	"fmt"
  6	"io"
  7	"io/fs"
  8	"log/slog"
  9	"net"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"strings"
 14	"testing"
 15	"time"
 16
 17	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 18	"github.com/picosh/pico/pkg/db"
 19	"github.com/picosh/pico/pkg/pssh"
 20	"github.com/picosh/pico/pkg/shared"
 21	"github.com/picosh/pico/pkg/storage"
 22	"github.com/pkg/sftp"
 23)
 24
 25// TestRsyncDeleteDirectory verifies that rsync --delete removes a directory
 26// tree on filesystem storage without failing with "directory not empty".
 27func TestRsyncDeleteDirectory(t *testing.T) {
 28	opts := &slog.HandlerOptions{
 29		AddSource: true,
 30		Level:     slog.LevelDebug,
 31	}
 32	logger := slog.New(
 33		slog.NewTextHandler(os.Stdout, opts),
 34	)
 35	slog.SetDefault(logger)
 36	dbpool := pgsdb.NewDBMemory(logger)
 37	dbpool.SetupTestData()
 38
 39	// Use filesystem storage so the "directory not empty" bug manifests
 40	tmpDir, err := os.MkdirTemp("", "pgs-test-storage-*")
 41	if err != nil {
 42		t.Fatalf("failed to create temp dir: %v", err)
 43	}
 44	defer func() {
 45		_ = os.RemoveAll(tmpDir)
 46	}()
 47
 48	st, err := storage.NewStorageFS(logger, tmpDir)
 49	if err != nil {
 50		t.Fatalf("failed to create storage: %v", err)
 51	}
 52
 53	pubsub := NewPubsubChan()
 54	defer func() {
 55		_ = pubsub.Close()
 56	}()
 57
 58	_ = os.Setenv("PGS_SSH_PORT", "0")
 59
 60	cfg := NewConfig(logger, dbpool, st, pubsub)
 61	done := make(chan error)
 62	readyCh := make(chan *pssh.SSHServer)
 63
 64	go StartSshServerForTesting(cfg, done, readyCh)
 65
 66	server := <-readyCh
 67	if server == nil {
 68		t.Fatal("failed to create ssh server")
 69	}
 70
 71	var actualAddr string
 72	for i := 0; i < 100; i++ {
 73		server.Mu.Lock()
 74		listener := server.Listener
 75		server.Mu.Unlock()
 76		if listener != nil {
 77			actualAddr = listener.Addr().String()
 78			break
 79		}
 80		time.Sleep(10 * time.Millisecond)
 81	}
 82	if actualAddr == "" {
 83		t.Fatal("server listener not ready")
 84	}
 85
 86	user := GenerateUser()
 87	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
 88		ID:     "test-pubkey-keepdir",
 89		UserID: dbpool.Users[0].ID,
 90		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
 91	})
 92
 93	conn, err := user.NewClientAddr(actualAddr)
 94	if err != nil {
 95		t.Fatalf("failed to connect: %v", err)
 96	}
 97	defer func() {
 98		_ = conn.Close()
 99	}()
100
101	client, err := sftp.NewClient(conn)
102	if err != nil {
103		t.Fatalf("failed to create sftp client: %v", err)
104	}
105	defer func() {
106		_ = client.Close()
107	}()
108
109	// Create temp directory with nested structure
110	name, err := os.MkdirTemp("", "rsync-dir-test-*")
111	if err != nil {
112		t.Fatalf("failed to create temp dir: %v", err)
113	}
114	defer func() {
115		_ = os.RemoveAll(name)
116	}()
117
118	// Create: project/subdir/nested/file.txt
119	nestedDir := filepath.Join(name, "subdir", "nested")
120	err = os.MkdirAll(nestedDir, 0755)
121	if err != nil {
122		t.Fatalf("failed to create nested dir: %v", err)
123	}
124	err = os.WriteFile(filepath.Join(nestedDir, "deep.txt"), []byte("deep content"), 0644)
125	if err != nil {
126		t.Fatalf("failed to write deep.txt: %v", err)
127	}
128
129	// Create: project/subdir/file.txt
130	err = os.WriteFile(filepath.Join(name, "subdir", "file.txt"), []byte("subdir content"), 0644)
131	if err != nil {
132		t.Fatalf("failed to write file.txt: %v", err)
133	}
134
135	// Create: project/index.html (stays after delete)
136	err = os.WriteFile(filepath.Join(name, "index.html"), []byte("index content"), 0644)
137	if err != nil {
138		t.Fatalf("failed to write index.html: %v", err)
139	}
140
141	block := &pem.Block{
142		Type:  "OPENSSH PRIVATE KEY",
143		Bytes: user.privateKey,
144	}
145	keyFile := filepath.Join(name, "id_ed25519")
146	err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
147	if err != nil {
148		t.Fatalf("failed to write key file: %v", err)
149	}
150
151	_, port, err := net.SplitHostPort(actualAddr)
152	if err != nil {
153		t.Fatalf("failed to parse server address: %v", err)
154	}
155
156	eCmd := fmt.Sprintf(
157		"ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
158		port, keyFile,
159	)
160
161	// Upload files including the subdir/ directory
162	cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/testdir")
163	result, err := cmd.CombinedOutput()
164	if err != nil {
165		t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
166	}
167
168	// Verify files exist on the server
169	_, err = client.Lstat("/testdir/subdir/file.txt")
170	if err != nil {
171		t.Fatalf("subdir/file.txt not found after upload: %v", err)
172	}
173	_, err = client.Lstat("/testdir/subdir/nested/deep.txt")
174	if err != nil {
175		t.Fatalf("subdir/nested/deep.txt not found after upload: %v", err)
176	}
177
178	// Now remove the entire subdir/ from the local directory
179	err = os.RemoveAll(filepath.Join(name, "subdir"))
180	if err != nil {
181		t.Fatalf("failed to remove local subdir: %v", err)
182	}
183
184	// Run rsync --delete - this should delete the subdir/ directory
185	// WITHOUT failing with "directory not empty"
186	delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/testdir")
187	result, err = delCmd.CombinedOutput()
188	if err != nil {
189		t.Fatalf("rsync --delete failed (this was the bug - 'directory not empty'): %v\noutput: %s", err, result)
190	}
191
192	// Verify the subdir and its contents are gone
193	_, err = client.Lstat("/testdir/subdir")
194	if err == nil {
195		t.Fatal("subdir/ should have been deleted but still exists")
196	}
197	// SFTP can return "no such file" or "file does not exist" depending on version
198	if !strings.Contains(err.Error(), "no such file") && !strings.Contains(err.Error(), "does not exist") {
199		t.Fatalf("expected 'not found' error, got: %v", err)
200	}
201
202	// Verify index.html still exists (it wasn't deleted)
203	fi, err := client.Lstat("/testdir/index.html")
204	if err != nil {
205		t.Fatalf("index.html should still exist: %v", err)
206	}
207	if fi.Size() != 13 {
208		t.Errorf("index.html has wrong size: got %d, want 13", fi.Size())
209	}
210
211	close(done)
212	time.Sleep(100 * time.Millisecond)
213}
214
215// TestRsyncDeleteNestedEmptyDirectories verifies that rsync --delete handles
216// deeply nested directory structures where intermediate directories become empty.
217func TestRsyncDeleteNestedEmptyDirectories(t *testing.T) {
218	opts := &slog.HandlerOptions{
219		AddSource: true,
220		Level:     slog.LevelDebug,
221	}
222	logger := slog.New(
223		slog.NewTextHandler(io.Discard, opts),
224	)
225	slog.SetDefault(logger)
226	dbpool := pgsdb.NewDBMemory(logger)
227	dbpool.SetupTestData()
228
229	tmpDir, err := os.MkdirTemp("", "pgs-test-nested-*")
230	if err != nil {
231		t.Fatalf("failed to create temp dir: %v", err)
232	}
233	defer func() {
234		_ = os.RemoveAll(tmpDir)
235	}()
236
237	st, err := storage.NewStorageFS(logger, tmpDir)
238	if err != nil {
239		t.Fatalf("failed to create storage: %v", err)
240	}
241
242	pubsub := NewPubsubChan()
243	defer func() {
244		_ = pubsub.Close()
245	}()
246
247	_ = os.Setenv("PGS_SSH_PORT", "0")
248
249	cfg := NewConfig(logger, dbpool, st, pubsub)
250	done := make(chan error)
251	readyCh := make(chan *pssh.SSHServer)
252
253	go StartSshServerForTesting(cfg, done, readyCh)
254
255	server := <-readyCh
256	if server == nil {
257		t.Fatal("failed to create ssh server")
258	}
259
260	var actualAddr string
261	for i := 0; i < 100; i++ {
262		server.Mu.Lock()
263		listener := server.Listener
264		server.Mu.Unlock()
265		if listener != nil {
266			actualAddr = listener.Addr().String()
267			break
268		}
269		time.Sleep(10 * time.Millisecond)
270	}
271	if actualAddr == "" {
272		t.Fatal("server listener not ready")
273	}
274
275	user := GenerateUser()
276	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
277		ID:     "test-pubkey-nested",
278		UserID: dbpool.Users[0].ID,
279		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
280	})
281
282	conn, err := user.NewClientAddr(actualAddr)
283	if err != nil {
284		t.Fatalf("failed to connect: %v", err)
285	}
286	defer func() {
287		_ = conn.Close()
288	}()
289
290	client, err := sftp.NewClient(conn)
291	if err != nil {
292		t.Fatalf("failed to create sftp client: %v", err)
293	}
294	defer func() {
295		_ = client.Close()
296	}()
297
298	// Create temp directory with deeply nested structure
299	name, err := os.MkdirTemp("", "rsync-nested-test-*")
300	if err != nil {
301		t.Fatalf("failed to create temp dir: %v", err)
302	}
303	defer func() {
304		_ = os.RemoveAll(name)
305	}()
306
307	// Create: a/b/c/d/file.txt
308	deepDir := filepath.Join(name, "a", "b", "c", "d")
309	err = os.MkdirAll(deepDir, 0755)
310	if err != nil {
311		t.Fatalf("failed to create deep dir: %v", err)
312	}
313	err = os.WriteFile(filepath.Join(deepDir, "file.txt"), []byte("deep"), 0644)
314	if err != nil {
315		t.Fatalf("failed to write file: %v", err)
316	}
317
318	block := &pem.Block{
319		Type:  "OPENSSH PRIVATE KEY",
320		Bytes: user.privateKey,
321	}
322	keyFile := filepath.Join(name, "id_ed25519")
323	err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
324	if err != nil {
325		t.Fatalf("failed to write key file: %v", err)
326	}
327
328	_, port, err := net.SplitHostPort(actualAddr)
329	if err != nil {
330		t.Fatalf("failed to parse server address: %v", err)
331	}
332
333	eCmd := fmt.Sprintf(
334		"ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
335		port, keyFile,
336	)
337
338	// Upload
339	cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/deep")
340	result, err := cmd.CombinedOutput()
341	if err != nil {
342		t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
343	}
344
345	// Remove the entire a/ directory locally
346	err = os.RemoveAll(filepath.Join(name, "a"))
347	if err != nil {
348		t.Fatalf("failed to remove local a/: %v", err)
349	}
350
351	// rsync --delete should handle the deeply nested structure
352	delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/deep")
353	result, err = delCmd.CombinedOutput()
354	if err != nil {
355		t.Fatalf("rsync --delete failed on nested dirs: %v\noutput: %s", err, result)
356	}
357
358	// Verify the entire tree is gone
359	_, err = client.Lstat("/deep/a")
360	if err == nil {
361		t.Fatal("/deep/a should have been deleted but still exists")
362	}
363
364	close(done)
365	time.Sleep(100 * time.Millisecond)
366}
367
368// setupPgsTestServer spins up an in-memory PGS SSH server for testing and
369// returns the server address and a teardown func.
370func setupPgsTestServer(t *testing.T, dbpool *pgsdb.MemoryDB) (string, func()) {
371	t.Helper()
372
373	opts := &slog.HandlerOptions{Level: slog.LevelDebug}
374	logger := slog.New(slog.NewTextHandler(io.Discard, opts))
375	slog.SetDefault(logger)
376
377	st, err := storage.NewStorageMemory(map[string]map[string]string{})
378	if err != nil {
379		t.Fatalf("failed to create storage: %v", err)
380	}
381
382	pubsub := NewPubsubChan()
383
384	_ = os.Setenv("PGS_SSH_PORT", "0")
385
386	cfg := NewConfig(logger, dbpool, st, pubsub)
387	done := make(chan error)
388	readyCh := make(chan *pssh.SSHServer)
389
390	go StartSshServerForTesting(cfg, done, readyCh)
391
392	server := <-readyCh
393	if server == nil {
394		t.Fatal("failed to create ssh server")
395	}
396
397	var actualAddr string
398	for i := 0; i < 100; i++ {
399		server.Mu.Lock()
400		listener := server.Listener
401		server.Mu.Unlock()
402		if listener != nil {
403			actualAddr = listener.Addr().String()
404			break
405		}
406		time.Sleep(10 * time.Millisecond)
407	}
408	if actualAddr == "" {
409		t.Fatal("server listener not ready")
410	}
411
412	teardown := func() {
413		_ = pubsub.Close()
414		close(done)
415		time.Sleep(100 * time.Millisecond)
416	}
417
418	return actualAddr, teardown
419}
420
421// TestPlusFlagAllowsUpload verifies that a user with a valid pico+ feature flag
422// can successfully upload files via SFTP.
423func TestPlusFlagAllowsUpload(t *testing.T) {
424	dbpool := pgsdb.NewDBMemory(slog.Default())
425	// SetupTestData creates a user with a valid (non-expired) plus feature flag.
426	dbpool.SetupTestData()
427
428	addr, teardown := setupPgsTestServer(t, dbpool)
429	defer teardown()
430
431	user := GenerateUser()
432	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
433		ID:     "plus-user-pubkey",
434		UserID: dbpool.Users[0].ID,
435		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
436	})
437
438	conn, err := user.NewClientAddr(addr)
439	if err != nil {
440		t.Fatalf("ssh dial failed: %v", err)
441	}
442	defer func() { _ = conn.Close() }()
443
444	client, err := sftp.NewClient(conn)
445	if err != nil {
446		t.Fatalf("sftp client failed: %v", err)
447	}
448	defer func() { _ = client.Close() }()
449
450	// Upload should succeed for a plus user.
451	f, err := client.Create("myproject/index.html")
452	if err != nil {
453		t.Fatalf("plus user upload was blocked unexpectedly: %v", err)
454	}
455	if _, err := f.Write([]byte("<html>hello</html>")); err != nil {
456		t.Fatalf("write failed: %v", err)
457	}
458	_ = f.Close()
459
460	// Confirm the file is accessible via SFTP stat.
461	fi, err := client.Lstat("myproject/index.html")
462	if err != nil {
463		t.Fatalf("file not found after upload: %v", err)
464	}
465	if fi.Size() == 0 {
466		t.Errorf("uploaded file has zero size")
467	}
468
469	// Confirm the project was created in the DB (no empty-project side-effect).
470	projects, err := dbpool.FindProjectsByUser(dbpool.Users[0].ID)
471	if err != nil {
472		t.Fatalf("FindProjectsByUser: %v", err)
473	}
474	found := false
475	for _, p := range projects {
476		if p.Name == "myproject" {
477			found = true
478			break
479		}
480	}
481	if !found {
482		t.Errorf("project 'myproject' was not created in the DB after upload")
483	}
484}
485
486// TestNoPlusFlagBlocksUpload verifies that a user whose pico+ subscription has
487// expired cannot upload files. The Validate() step (introduced in the last
488// commit) must reject the connection before any project is created, so we also
489// confirm that no empty project leaks into the DB.
490func TestNoPlusFlagBlocksUpload(t *testing.T) {
491	dbpool := pgsdb.NewDBMemory(slog.Default())
492	// Create a user but give them an *expired* plus feature flag.
493	dbpool.SetupTestData()
494	expired := time.Now().Add(-time.Hour)
495	dbpool.Feature.ExpiresAt = &expired // mark as expired
496
497	addr, teardown := setupPgsTestServer(t, dbpool)
498	defer teardown()
499
500	user := GenerateUser()
501	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
502		ID:     "no-plus-user-pubkey",
503		UserID: dbpool.Users[0].ID,
504		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
505	})
506
507	conn, err := user.NewClientAddr(addr)
508	if err != nil {
509		t.Fatalf("ssh dial failed: %v", err)
510	}
511	defer func() { _ = conn.Close() }()
512
513	// Validate() fires at the SFTP-subsystem session level, so the server may
514	// close the connection before the SFTP version handshake completes.
515	// Accept rejection at either sftp.NewClient or client.Create as valid.
516	client, sftpErr := sftp.NewClient(conn)
517	if sftpErr != nil {
518		// Server rejected the session during Validate() — this is the expected
519		// behavior for a non-plus user. Verify no project was leaked.
520		t.Logf("upload correctly blocked at SFTP handshake: %v", sftpErr)
521	} else {
522		defer func() { _ = client.Close() }()
523
524		// Upload must be rejected because the user's pico+ has expired.
525		_, err = client.Create("someproject/index.html")
526		if err == nil {
527			t.Fatal("expected upload to be blocked for non-plus user, but it succeeded")
528		}
529		t.Logf("upload correctly blocked at Create: %v", err)
530	}
531
532	// Most importantly: no empty project should have been created in the DB,
533	// because the refactored Validate() checks the feature flag *before*
534	// upserting the project bucket/quota.
535	projects, err := dbpool.FindProjectsByUser(dbpool.Users[0].ID)
536	if err != nil {
537		t.Fatalf("FindProjectsByUser: %v", err)
538	}
539	if len(projects) != 0 {
540		t.Errorf("expected 0 projects for non-plus user, got %d (empty project leak)", len(projects))
541	}
542}
543
544func TestFindFeatureFlag(t *testing.T) {
545	logger := slog.Default()
546	cfg := &Config{
547		MaxSize:            uint64(100 * shared.MB),
548		MaxAssetSize:       int64(10 * shared.MB),
549		MaxSpecialFileSize: int64(5 * shared.KB),
550	}
551
552	validExpires := time.Now().Add(24 * time.Hour)
553	expiredExpires := time.Now().Add(-24 * time.Hour)
554	userID := "user-1"
555
556	t.Run("plus valid returns plus", func(t *testing.T) {
557		dbpool := pgsdb.NewDBMemory(logger)
558		plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
559		plusFF.ExpiresAt = &validExpires
560		dbpool.Features = []*db.FeatureFlag{plusFF}
561
562		ff, err := findFeatureFlag(dbpool, cfg, userID)
563		if err != nil {
564			t.Fatalf("unexpected error: %v", err)
565		}
566		if ff.Name != "plus" {
567			t.Errorf("expected plus, got %s", ff.Name)
568		}
569	})
570
571	t.Run("pgs valid returns pgs when no plus", func(t *testing.T) {
572		dbpool := pgsdb.NewDBMemory(logger)
573		pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
574		pgsFF.ExpiresAt = &validExpires
575		dbpool.Features = []*db.FeatureFlag{pgsFF}
576
577		ff, err := findFeatureFlag(dbpool, cfg, userID)
578		if err != nil {
579			t.Fatalf("unexpected error: %v", err)
580		}
581		if ff.Name != "pgs" {
582			t.Errorf("expected pgs, got %s", ff.Name)
583		}
584	})
585
586	t.Run("plus preferred over pgs when both valid", func(t *testing.T) {
587		dbpool := pgsdb.NewDBMemory(logger)
588		plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
589		plusFF.ExpiresAt = &validExpires
590		pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
591		pgsFF.ExpiresAt = &validExpires
592		dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
593
594		ff, err := findFeatureFlag(dbpool, cfg, userID)
595		if err != nil {
596			t.Fatalf("unexpected error: %v", err)
597		}
598		if ff.Name != "plus" {
599			t.Errorf("expected plus to be picked first, got %s", ff.Name)
600		}
601	})
602
603	t.Run("pgs picked when plus is expired", func(t *testing.T) {
604		dbpool := pgsdb.NewDBMemory(logger)
605		plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
606		plusFF.ExpiresAt = &expiredExpires
607		pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
608		pgsFF.ExpiresAt = &validExpires
609		dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
610
611		ff, err := findFeatureFlag(dbpool, cfg, userID)
612		if err != nil {
613			t.Fatalf("unexpected error: %v", err)
614		}
615		if ff.Name != "pgs" {
616			t.Errorf("expected pgs when plus is expired, got %s", ff.Name)
617		}
618	})
619
620	t.Run("error when both plus and pgs expired", func(t *testing.T) {
621		dbpool := pgsdb.NewDBMemory(logger)
622		plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
623		plusFF.ExpiresAt = &expiredExpires
624		pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
625		pgsFF.ExpiresAt = &expiredExpires
626		dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
627
628		_, err := findFeatureFlag(dbpool, cfg, userID)
629		if err == nil {
630			t.Fatal("expected error, got nil")
631		}
632		if !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
633			t.Errorf("expected blog link in error, got %v", err)
634		}
635	})
636
637	t.Run("error when neither plus nor pgs exists", func(t *testing.T) {
638		dbpool := pgsdb.NewDBMemory(logger)
639		_, err := findFeatureFlag(dbpool, cfg, userID)
640		if err == nil {
641			t.Fatal("expected error, got nil")
642		}
643		if !strings.Contains(err.Error(), "pico+") || !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
644			t.Errorf("expected pico+ requirement and blog link in error, got %v", err)
645		}
646	})
647}
648
649func TestPgsFlagAllowsUpload(t *testing.T) {
650	dbpool := pgsdb.NewDBMemory(slog.Default())
651	dbpool.SetupTestData()
652	// Replace default plus flag with a valid pgs flag
653	dbpool.Feature = nil
654	valid := time.Now().Add(24 * time.Hour)
655	pgsFF := db.NewFeatureFlag(
656		dbpool.Users[0].ID,
657		"pgs",
658		uint64(25*shared.MB),
659		int64(10*shared.MB),
660		int64(5*shared.KB),
661	)
662	pgsFF.ExpiresAt = &valid
663	dbpool.Features = []*db.FeatureFlag{pgsFF}
664
665	addr, teardown := setupPgsTestServer(t, dbpool)
666	defer teardown()
667
668	user := GenerateUser()
669	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
670		ID:     "pgs-user-pubkey",
671		UserID: dbpool.Users[0].ID,
672		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
673	})
674
675	conn, err := user.NewClientAddr(addr)
676	if err != nil {
677		t.Fatalf("ssh dial failed: %v", err)
678	}
679	defer func() { _ = conn.Close() }()
680
681	client, err := sftp.NewClient(conn)
682	if err != nil {
683		t.Fatalf("sftp client failed: %v", err)
684	}
685	defer func() { _ = client.Close() }()
686
687	// Upload should succeed for a pgs user.
688	f, err := client.Create("myproject/index.html")
689	if err != nil {
690		t.Fatalf("pgs user upload was blocked unexpectedly: %v", err)
691	}
692	if _, err := f.Write([]byte("<html>hello from pgs</html>")); err != nil {
693		t.Fatalf("write failed: %v", err)
694	}
695	_ = f.Close()
696
697	// Confirm the file is accessible via SFTP stat.
698	fi, err := client.Lstat("myproject/index.html")
699	if err != nil {
700		t.Fatalf("file not found after upload: %v", err)
701	}
702	if fi.Size() == 0 {
703		t.Errorf("uploaded file has zero size")
704	}
705}
706
707// SFTP directories behave like a filesystem's on storage that has real
708// directories, with no marker files left behind.
709func TestSftpDirectories(t *testing.T) {
710	logger := slog.New(slog.DiscardHandler)
711	dbpool := pgsdb.NewDBMemory(logger)
712	dbpool.SetupTestData()
713	storageDir := t.TempDir()
714	st, err := storage.NewStorageFS(logger, storageDir)
715	if err != nil {
716		t.Fatal(err)
717	}
718	_ = os.Setenv("PGS_SSH_PORT", "0")
719	cfg := NewConfig(logger, dbpool, st, discardPubsub{})
720	done := make(chan error)
721	defer close(done)
722	readyCh := make(chan *pssh.SSHServer)
723	go StartSshServerForTesting(cfg, done, readyCh)
724	server := <-readyCh
725	if server == nil {
726		t.Fatal("failed to create ssh server")
727	}
728	var addr string
729	for range 100 {
730		server.Mu.Lock()
731		listener := server.Listener
732		server.Mu.Unlock()
733		if listener != nil {
734			addr = listener.Addr().String()
735			break
736		}
737		time.Sleep(10 * time.Millisecond)
738	}
739
740	user := GenerateUser()
741	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
742		ID:     "test-pubkey-dirs",
743		UserID: dbpool.Users[0].ID,
744		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
745	})
746	conn, err := user.NewClientAddr(addr)
747	if err != nil {
748		t.Fatal(err)
749	}
750	defer func() { _ = conn.Close() }()
751	client, err := sftp.NewClient(conn)
752	if err != nil {
753		t.Fatal(err)
754	}
755	defer func() { _ = client.Close() }()
756
757	if err := client.MkdirAll("/site/empty"); err != nil {
758		t.Fatal(err)
759	}
760	f, err := client.Create("/site/css/site.css")
761	if err != nil {
762		t.Fatal(err)
763	}
764	if _, err := f.Write([]byte("body{}")); err != nil {
765		t.Fatal(err)
766	}
767	_ = f.Close()
768
769	if err := client.RemoveDirectory("/site/css"); err == nil {
770		t.Fatal("removing a non-empty directory succeeded")
771	}
772	if err := client.Remove("/site/css/site.css"); err != nil {
773		t.Fatal(err)
774	}
775	for _, dir := range []string{"/site/empty", "/site/css"} {
776		fi, err := client.Stat(dir)
777		if err != nil || !fi.IsDir() {
778			t.Fatalf("%s should be an empty directory: %v", dir, err)
779		}
780		if err := client.RemoveDirectory(dir); err != nil {
781			t.Fatalf("rmdir %s: %v", dir, err)
782		}
783		if _, err := client.Stat(dir); err == nil {
784			t.Fatalf("%s still exists after rmdir", dir)
785		}
786	}
787
788	err = filepath.WalkDir(storageDir, func(p string, d fs.DirEntry, err error) error {
789		if err == nil && !d.IsDir() {
790			t.Errorf("unexpected file left in storage: %s", p)
791		}
792		return err
793	})
794	if err != nil {
795		t.Fatal(err)
796	}
797}