main pico / pkg / apps / pastes / uploader_test.go
Antonio Mika  ·  2026-10-10
  1package pastes
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"strings"
 10	"testing"
 11	"testing/iotest"
 12	"time"
 13
 14	"github.com/picosh/pico/pkg/db"
 15	"github.com/picosh/pico/pkg/db/stub"
 16	"github.com/picosh/pico/pkg/pssh"
 17	sendutils "github.com/picosh/pico/pkg/send/utils"
 18	"golang.org/x/crypto/ssh"
 19)
 20
 21type mockFeatureDB struct {
 22	*stub.StubDB
 23	features map[string]*db.FeatureFlag
 24}
 25
 26func newMockFeatureDB() *mockFeatureDB {
 27	return &mockFeatureDB{
 28		StubDB:   stub.NewStubDB(slog.Default()),
 29		features: make(map[string]*db.FeatureFlag),
 30	}
 31}
 32
 33func (m *mockFeatureDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
 34	if ff, ok := m.features[userID+":"+name]; ok {
 35		return ff, nil
 36	}
 37	return nil, fmt.Errorf("feature flag %s not found for user %s", name, userID)
 38}
 39
 40func (m *mockFeatureDB) setFeature(userID, name string, expiresAt time.Time) {
 41	m.features[userID+":"+name] = &db.FeatureFlag{UserID: userID, Name: name, ExpiresAt: &expiresAt}
 42}
 43
 44func TestUploadHandlerValidate(t *testing.T) {
 45	cfg := NewConfig("pastes-test")
 46	userID := "user-1"
 47	future := time.Now().Add(24 * time.Hour)
 48	past := time.Now().Add(-24 * time.Hour)
 49
 50	createSession := func(user *db.User) *pssh.SSHServerConnSession {
 51		conn := &pssh.SSHServerConn{
 52			Conn: &ssh.ServerConn{
 53				Permissions: &ssh.Permissions{
 54					Extensions: map[string]string{},
 55				},
 56			},
 57			Logger: slog.Default(),
 58		}
 59		sesh := &pssh.SSHServerConnSession{
 60			SSHServerConn: conn,
 61			Ctx:           context.Background(),
 62		}
 63		if user != nil {
 64			pssh.SetUser(sesh, user)
 65		}
 66		return sesh
 67	}
 68
 69	tests := []struct {
 70		name    string
 71		setup   func(m *mockFeatureDB)
 72		user    *db.User
 73		wantErr string
 74	}{
 75		{
 76			name:    "nil user",
 77			setup:   func(m *mockFeatureDB) {},
 78			wantErr: "could not get user",
 79		},
 80		{
 81			name:    "no invite or pico+",
 82			setup:   func(m *mockFeatureDB) {},
 83			user:    &db.User{ID: userID, Name: "tester"},
 84			wantErr: "requires an invitation or pico+",
 85		},
 86		{
 87			name:  "pastes feature",
 88			setup: func(m *mockFeatureDB) { m.setFeature(userID, "pastes", future) },
 89			user:  &db.User{ID: userID, Name: "tester"},
 90		},
 91		{
 92			name:  "pico+ feature",
 93			setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", future) },
 94			user:  &db.User{ID: userID, Name: "tester"},
 95		},
 96		{
 97			name:  "expired pico+ with active pastes",
 98			setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", past); m.setFeature(userID, "pastes", future) },
 99			user:  &db.User{ID: userID, Name: "tester"},
100		},
101		{
102			name:    "expired pastes",
103			setup:   func(m *mockFeatureDB) { m.setFeature(userID, "pastes", past) },
104			user:    &db.User{ID: userID, Name: "tester"},
105			wantErr: "pastes access has expired",
106		},
107		{
108			name:    "expired pico+",
109			setup:   func(m *mockFeatureDB) { m.setFeature(userID, "plus", past) },
110			user:    &db.User{ID: userID, Name: "tester"},
111			wantErr: "pico+ has expired",
112		},
113	}
114
115	for _, tt := range tests {
116		t.Run(tt.name, func(t *testing.T) {
117			mockDB := newMockFeatureDB()
118			tt.setup(mockDB)
119			handler := NewUploadHandler(cfg, mockDB)
120
121			err := handler.Validate(createSession(tt.user))
122			if tt.wantErr == "" {
123				if err != nil {
124					t.Fatalf("unexpected error: %v", err)
125				}
126				return
127			}
128			if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
129				t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
130			}
131		})
132	}
133}
134
135func TestSetMetaKeepsSettings(t *testing.T) {
136	soon := time.Now().Add(time.Hour)
137	past := time.Now().Add(-time.Hour)
138	// Pastes expire after calendar days, which a daylight saving change
139	// makes an hour longer or shorter.
140	defaultExpiry := time.Until(time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)).Round(time.Minute)
141	tests := []struct {
142		name   string
143		cmd    []string
144		cur    *db.Post
145		hidden bool
146		// expires is how long until the paste expires, or 0 for never.
147		expires time.Duration
148	}{
149		{name: "new", expires: defaultExpiry},
150		{name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
151		{name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
152		{name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
153		{name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: defaultExpiry},
154		{name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: defaultExpiry},
155		{name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
156	}
157	for _, tt := range tests {
158		t.Run(tt.name, func(t *testing.T) {
159			s := &pssh.SSHServerConnSession{Ctx: context.Background()}
160			s.SetValue("command", tt.cmd)
161			data := &db.Post{Filename: "a.txt", Slug: "a"}
162			setMeta(s, tt.cur, data)
163			if data.Hidden != tt.hidden {
164				t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
165			}
166			switch {
167			case tt.expires == 0 && data.ExpiresAt != nil:
168				t.Errorf("expires at %v, want never", data.ExpiresAt)
169			case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
170				t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
171			}
172		})
173	}
174}
175
176func TestWriteReadError(t *testing.T) {
177	checksumErr := errors.New("rsync: file failed verification")
178	tests := []struct {
179		name string
180		text string
181		err  error
182	}{
183		{name: "before contents", err: io.ErrClosedPipe},
184		{name: "partial contents", text: "partial upload", err: io.ErrUnexpectedEOF},
185		{name: "failed verification", text: "complete upload", err: checksumErr},
186	}
187	for _, tt := range tests {
188		t.Run(tt.name, func(t *testing.T) {
189			logger := slog.New(slog.DiscardHandler)
190			session := &pssh.SSHServerConnSession{
191				SSHServerConn: &pssh.SSHServerConn{
192					Conn:   &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
193					Logger: logger,
194				},
195				Ctx: context.Background(),
196			}
197			user := &db.User{ID: "user-1", Name: "tester"}
198			pssh.SetUser(session, user)
199			posts := stub.NewPostsDB(logger, user)
200			handler := NewUploadHandler(&Config{Space: "pastes", MaxAssetSize: 1024}, posts)
201			reader := io.MultiReader(strings.NewReader(tt.text), iotest.ErrReader(tt.err))
202
203			message, err := handler.Write(session, &sendutils.FileEntry{Filepath: "/test.txt", Reader: reader})
204			if !errors.Is(err, tt.err) {
205				t.Fatalf("error = %v, want %v", err, tt.err)
206			}
207			if message != "" {
208				t.Fatalf("failed upload returned a success message: %q", message)
209			}
210			if all, _ := posts.FindAllPostsByUser(user.ID, "pastes"); len(all) != 0 {
211				t.Fatalf("failed upload stored %d posts", len(all))
212			}
213		})
214	}
215}