Antonio Mika
·
2026-10-11
1package postfs_test
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "encoding/pem"
8 "log/slog"
9 "net"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "slices"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/picosh/pico/pkg/apps/pastes"
19 "github.com/picosh/pico/pkg/apps/prose"
20 "github.com/picosh/pico/pkg/db"
21 "github.com/picosh/pico/pkg/db/stub"
22 "github.com/picosh/pico/pkg/pssh"
23 "github.com/picosh/pico/pkg/send/list"
24 "github.com/picosh/pico/pkg/send/protocols/sftp"
25 "github.com/picosh/pico/pkg/send/utils"
26 "github.com/picosh/pico/pkg/shared"
27 "github.com/picosh/pico/pkg/storage"
28 "golang.org/x/crypto/ssh"
29)
30
31type postsEnv struct {
32 t *testing.T
33 db *stub.PostsDB
34 space string
35 port string
36 key string
37}
38
39func newPostsEnv(t *testing.T, app string) *postsEnv {
40 t.Helper()
41 logger := slog.New(slog.DiscardHandler)
42 _, userKey, err := ed25519.GenerateKey(rand.Reader)
43 if err != nil {
44 t.Fatal(err)
45 }
46 signer, err := ssh.NewSignerFromKey(userKey)
47 if err != nil {
48 t.Fatal(err)
49 }
50 posts := stub.NewPostsDB(logger, &db.User{ID: "user-1", Name: "tester"})
51 posts.Pubkey = shared.KeyForKeyText(signer.PublicKey())
52
53 var space string
54 var handler interface {
55 utils.CopyFromClientHandler
56 pssh.GetLoggerInterface
57 }
58 switch app {
59 case "prose":
60 cfg := prose.NewConfig("prose-test")
61 cfg.Logger = logger
62 st, err := storage.NewStorageMemory(map[string]map[string]string{})
63 if err != nil {
64 t.Fatal(err)
65 }
66 handler = prose.NewFileHandler(cfg, posts, st)
67 space = cfg.Space
68 case "pastes":
69 cfg := pastes.NewConfig("pastes-test")
70 cfg.Logger = logger
71 handler = pastes.NewUploadHandler(cfg, posts)
72 space = cfg.Space
73 }
74
75 ctx, cancel := context.WithCancel(context.Background())
76 t.Cleanup(cancel)
77 auth := shared.NewSshAuthHandler(posts, logger, app)
78 server, err := pssh.NewSSHServerWithConfig(ctx, logger, app, "127.0.0.1", "0", "", filepath.Join(t.TempDir(), "host_key"),
79 auth.PubkeyAuthHandler,
80 []pssh.SSHServerMiddleware{list.Middleware(handler), pssh.LogMiddleware(handler, posts)},
81 []pssh.SSHServerMiddleware{sftp.Middleware(handler), pssh.LogMiddleware(handler, posts)},
82 nil,
83 )
84 if err != nil {
85 t.Fatal(err)
86 }
87 go func() { _ = server.ListenAndServe() }()
88 var addr string
89 for range 100 {
90 server.Mu.Lock()
91 if server.Listener != nil {
92 addr = server.Listener.Addr().String()
93 }
94 server.Mu.Unlock()
95 if addr != "" {
96 break
97 }
98 time.Sleep(10 * time.Millisecond)
99 }
100 if addr == "" {
101 t.Fatal("server listener not ready")
102 }
103 _, port, _ := net.SplitHostPort(addr)
104
105 b, err := ssh.MarshalPrivateKey(userKey, "")
106 if err != nil {
107 t.Fatal(err)
108 }
109 key := filepath.Join(t.TempDir(), "id_ed25519")
110 if err := os.WriteFile(key, pem.EncodeToMemory(b), 0o600); err != nil {
111 t.Fatal(err)
112 }
113 return &postsEnv{t: t, db: posts, space: space, port: port, key: key}
114}
115
116func (e *postsEnv) sshOpts() []string {
117 return []string{"-o", "IdentityFile=" + e.key, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=no",
118 "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"}
119}
120
121func (e *postsEnv) run(name string, args ...string) string {
122 e.t.Helper()
123 out, err := exec.Command(name, args...).CombinedOutput()
124 if err != nil {
125 e.t.Fatalf("%s %v: %v\n%s", name, args, err, out)
126 }
127 return string(out)
128}
129
130// seed stores a hidden post, made hidden the way each service does it.
131func (e *postsEnv) seed(filename string) *db.Post {
132 e.t.Helper()
133 published := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
134 post := &db.Post{
135 UserID: "user-1", Space: e.space, Filename: filename, Slug: strings.TrimSuffix(filename, filepath.Ext(filename)),
136 Text: seedText(e.space), FileSize: len(seedText(e.space)), Hidden: true, PublishAt: &published, UpdatedAt: &published,
137 }
138 if e.space == "pastes" {
139 expires := time.Now().Add(time.Hour).Truncate(time.Second)
140 post.ExpiresAt = &expires
141 }
142 p, err := e.db.InsertPost(post)
143 if err != nil {
144 e.t.Fatal(err)
145 }
146 return p
147}
148
149func seedText(space string) string {
150 if space == "prose" {
151 return "---\ndraft: true\n---\nseeded\n"
152 }
153 return "seeded\n"
154}
155
156// assertKept waits for the post at filename to have text and checks it is
157// the seeded one with its settings. sshfs sends a close after the command
158// that made it returns.
159func (e *postsEnv) assertKept(filename, text string, seeded *db.Post) {
160 e.t.Helper()
161 e.assertMoved(filename, text, seeded, seeded.Hidden)
162}
163
164// assertMoved is assertKept for a post whose new name changes whether it
165// is hidden.
166func (e *postsEnv) assertMoved(filename, text string, seeded *db.Post, hidden bool) {
167 e.t.Helper()
168 var got *db.Post
169 for range 100 {
170 var err error
171 if got, err = e.db.FindPostWithFilename(filename, "user-1", e.space); err == nil && got.Text == text {
172 break
173 }
174 time.Sleep(20 * time.Millisecond)
175 }
176 if got == nil || got.Text != text {
177 e.t.Fatalf("%s = %+v, want text %q", filename, got, text)
178 }
179 if got.ID != seeded.ID || !got.PublishAt.Equal(*seeded.PublishAt) || got.Hidden != hidden ||
180 (got.ExpiresAt == nil) != (seeded.ExpiresAt == nil) || (got.ExpiresAt != nil && !got.ExpiresAt.Equal(*seeded.ExpiresAt)) {
181 e.t.Errorf("%s = id %s published %v hidden %v expires %v, want %s %v %v %v", filename,
182 got.ID, got.PublishAt, got.Hidden, got.ExpiresAt, seeded.ID, seeded.PublishAt, hidden, seeded.ExpiresAt)
183 }
184}
185
186func assertListed(t *testing.T, out string, want ...string) {
187 t.Helper()
188 var got []string
189 for _, line := range strings.Split(out, "\n") {
190 f := filepath.Base(strings.TrimSpace(line))
191 if !strings.HasPrefix(line, "sftp>") && f != "." && f != ".." && f != "/" {
192 got = append(got, f)
193 }
194 }
195 slices.Sort(got)
196 slices.Sort(want)
197 if !slices.Equal(got, want) {
198 t.Errorf("listed %q, want %q", got, want)
199 }
200}
201
202func TestPostsSftp(t *testing.T) {
203 if _, err := exec.LookPath("sftp"); err != nil {
204 t.Skip("sftp not installed")
205 }
206 for app, ext := range map[string]string{"prose": ".md", "pastes": ".txt"} {
207 t.Run(app, func(t *testing.T) {
208 e := newPostsEnv(t, app)
209 a := e.seed("a" + ext)
210 e.seed("c" + ext)
211
212 sftpCmd := func(cmds string) string {
213 cmd := exec.Command("sftp", append(append([]string{"-b", "-", "-P", e.port}, e.sshOpts()...), "localhost")...)
214 cmd.Stdin = strings.NewReader(cmds)
215 out, err := cmd.CombinedOutput()
216 if err != nil {
217 t.Fatalf("sftp: %v\n%s", err, out)
218 }
219 return string(out)
220 }
221 assertListed(t, sftpCmd("ls -1\n"), "a"+ext, "c"+ext)
222 assertListed(t, e.run("ssh", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost", "command", "ls")...), "a"+ext, "c"+ext)
223
224 sftpCmd("rename a" + ext + " b" + ext + "\n")
225 e.assertKept("b"+ext, a.Text, a)
226 sftpCmd("rename b" + ext + " c" + ext + "\n")
227 e.assertKept("c"+ext, a.Text, a)
228 assertListed(t, sftpCmd("ls -1\n"), "c"+ext)
229 })
230 }
231}
232
233func TestPostsSshfs(t *testing.T) {
234 if _, err := exec.LookPath("sshfs"); err != nil {
235 t.Skip("sshfs not installed")
236 }
237 tests := map[string][]struct {
238 cmd string
239 name string
240 text string
241 // public is set when the new name drops the draft setting, which
242 // .lxt files don't read from front matter.
243 public bool
244 }{
245 "prose": {
246 {cmd: "echo more >> a.md", name: "a.md", text: seedText("prose") + "more\n"},
247 {cmd: "mv a.md b.md", name: "b.md", text: seedText("prose") + "more\n"},
248 {cmd: "mv b.md b.lxt", name: "b.lxt", text: seedText("prose") + "more\n", public: true},
249 {cmd: "mv b.lxt c.md", name: "c.md", text: seedText("prose") + "more\n"},
250 },
251 "pastes": {
252 {cmd: "echo more >> a.txt", name: "a.txt", text: "seeded\nmore\n"},
253 {cmd: "vim -u DEFAULTS -i NONE -N -es -c '%s/more/less/' -c wq a.txt", name: "a.txt", text: "seeded\nless\n"},
254 {cmd: "mv a.txt b.txt", name: "b.txt", text: "seeded\nless\n"},
255 {cmd: "mv b.txt c.txt", name: "c.txt", text: "seeded\nless\n"},
256 },
257 }
258 for app, steps := range tests {
259 t.Run(app, func(t *testing.T) {
260 e := newPostsEnv(t, app)
261 ext := filepath.Ext(steps[0].name)
262 a := e.seed("a" + ext)
263 e.seed("c" + ext)
264
265 mnt := t.TempDir()
266 if out, err := exec.Command("sshfs", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost:/", mnt)...).CombinedOutput(); err != nil {
267 t.Skipf("sshfs mount failed: %v\n%s", err, out)
268 }
269 t.Cleanup(func() { _ = exec.Command("fusermount3", "-u", "-z", mnt).Run() })
270 assertListed(t, e.run("ls", "-1a", mnt), "a"+ext, "c"+ext)
271
272 home := t.TempDir()
273 for _, step := range steps {
274 cmd := exec.Command("bash", "-c", step.cmd)
275 cmd.Dir = mnt
276 cmd.Env = append(os.Environ(), "HOME="+home)
277 if out, err := cmd.CombinedOutput(); err != nil {
278 t.Fatalf("%s: %v\n%s", step.cmd, err, out)
279 }
280 e.assertMoved(step.name, step.text, a, !step.public)
281 }
282 assertListed(t, e.run("ls", "-1a", mnt), "c"+ext)
283 })
284 }
285}