Antonio Mika
·
2026-10-11
1package auth
2
3import (
4 "bytes"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/gkampitakis/go-snaps/snaps"
17 "github.com/picosh/pico/pkg/db"
18 "github.com/picosh/pico/pkg/db/stub"
19 "github.com/picosh/pico/pkg/shared"
20 "github.com/picosh/pico/pkg/shared/router"
21)
22
23var testUserID = "user-1"
24var testUsername = "user-a"
25
26func TestPaymentWebhook(t *testing.T) {
27 apiConfig := setupTest()
28
29 event := OrderEvent{
30 Meta: &OrderEventMeta{
31 EventName: "order_created",
32 CustomData: &CustomDataMeta{
33 PicoUsername: testUsername,
34 },
35 },
36 Data: &OrderEventData{
37 Attr: &OrderEventDataAttr{
38 UserEmail: "auth@pico.test",
39 CreatedAt: time.Now(),
40 Status: "paid",
41 OrderNumber: 1337,
42 },
43 },
44 }
45 jso, err := json.Marshal(event)
46 bail(err)
47 hash := router.HmacString(apiConfig.Cfg.SecretWebhook, string(jso))
48 body := bytes.NewReader(jso)
49
50 request := httptest.NewRequest("POST", mkpath("/webhook"), body)
51 request.Header.Add("X-signature", hash)
52 responseRecorder := httptest.NewRecorder()
53
54 mux := authMux(apiConfig)
55 mux.ServeHTTP(responseRecorder, request)
56
57 testResponse(t, responseRecorder, 200, "text/plain")
58
59 posts, err := apiConfig.Dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, testUserID, "feeds")
60 if err != nil {
61 t.Error("could not find posts for user")
62 }
63 for _, post := range posts.Data {
64 if post.Filename != "pico-plus" {
65 continue
66 }
67 expectedText := `=: email auth@pico.test
68=: cron */10 * * * *
69=: inline_content true
70=> https://auth.pico.sh/rss/123
71=> https://blog.pico.sh/rss`
72 if post.Text != expectedText {
73 t.Errorf("Want pico plus feed file %s, got %s", expectedText, post.Text)
74 }
75 }
76}
77
78func TestUser(t *testing.T) {
79 apiConfig := setupTest()
80
81 data := sishData{
82 Username: testUsername,
83 }
84 jso, err := json.Marshal(data)
85 bail(err)
86 body := bytes.NewReader(jso)
87
88 request := httptest.NewRequest("POST", mkpath("/user"), body)
89 request.Header.Add("Authorization", "Bearer 123")
90 responseRecorder := httptest.NewRecorder()
91
92 mux := authMux(apiConfig)
93 mux.ServeHTTP(responseRecorder, request)
94
95 testResponse(t, responseRecorder, 200, "application/json")
96}
97
98func TestKey(t *testing.T) {
99 apiConfig := setupTest()
100
101 data := sishData{
102 Username: testUsername,
103 PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxVPgEqtWOa5l0QHZV6TQKhV+l46SAXU07c9RuHlGka test@pico",
104 }
105 jso, err := json.Marshal(data)
106 bail(err)
107 body := bytes.NewReader(jso)
108
109 request := httptest.NewRequest("POST", mkpath("/key"), body)
110 request.Header.Add("Authorization", "Bearer 123")
111 responseRecorder := httptest.NewRecorder()
112
113 mux := authMux(apiConfig)
114 mux.ServeHTTP(responseRecorder, request)
115
116 testResponse(t, responseRecorder, 200, "application/json")
117}
118
119func TestCheckout(t *testing.T) {
120 apiConfig := setupTest()
121
122 request := httptest.NewRequest("GET", mkpath("/checkout/"+testUsername), strings.NewReader(""))
123 request.Header.Add("Authorization", "Bearer 123")
124 responseRecorder := httptest.NewRecorder()
125
126 mux := authMux(apiConfig)
127 mux.ServeHTTP(responseRecorder, request)
128
129 loc := responseRecorder.Header().Get("Location")
130 if loc != "https://picosh.lemonsqueezy.com/buy/73c26cf9-3fac-44c3-b744-298b3032a96b?discount=0&checkout[custom][username]=user-a" {
131 t.Errorf("Have Location %s, want checkout", loc)
132 }
133 if responseRecorder.Code != http.StatusMovedPermanently {
134 t.Errorf("Want status '%d', got '%d'", http.StatusMovedPermanently, responseRecorder.Code)
135 return
136 }
137}
138
139func TestIntrospect(t *testing.T) {
140 apiConfig := setupTest()
141
142 request := httptest.NewRequest("POST", mkpath("/introspect?token=123"), strings.NewReader(""))
143 responseRecorder := httptest.NewRecorder()
144
145 mux := authMux(apiConfig)
146 mux.ServeHTTP(responseRecorder, request)
147
148 testResponse(t, responseRecorder, 200, "application/json")
149}
150
151func TestToken(t *testing.T) {
152 apiConfig := setupTest()
153
154 request := httptest.NewRequest("POST", mkpath("/token?code=123"), strings.NewReader(""))
155 responseRecorder := httptest.NewRecorder()
156
157 mux := authMux(apiConfig)
158 mux.ServeHTTP(responseRecorder, request)
159
160 testResponse(t, responseRecorder, 200, "application/json")
161}
162
163func TestAuthApi(t *testing.T) {
164 apiConfig := setupTest()
165 tt := []*ApiExample{
166 {
167 name: "authorize",
168 path: "/authorize?response_type=json&client_id=333&redirect_uri=pico.test&scope=admin",
169 status: http.StatusOK,
170 contentType: "text/html; charset=utf-8",
171 dbpool: apiConfig.Dbpool,
172 },
173 {
174 name: "rss",
175 path: "/rss/123",
176 status: http.StatusOK,
177 contentType: "application/atom+xml",
178 dbpool: apiConfig.Dbpool,
179 },
180 {
181 name: "fileserver",
182 path: "/robots.txt",
183 status: http.StatusOK,
184 contentType: "text/plain; charset=utf-8",
185 dbpool: apiConfig.Dbpool,
186 },
187 {
188 name: "well-known",
189 path: "/.well-known/oauth-authorization-server",
190 status: http.StatusOK,
191 contentType: "application/json",
192 dbpool: apiConfig.Dbpool,
193 },
194 }
195
196 for _, tc := range tt {
197 t.Run(tc.name, func(t *testing.T) {
198 request := httptest.NewRequest("GET", mkpath(tc.path), strings.NewReader(""))
199 responseRecorder := httptest.NewRecorder()
200
201 mux := authMux(apiConfig)
202 mux.ServeHTTP(responseRecorder, request)
203
204 testResponse(t, responseRecorder, tc.status, tc.contentType)
205 })
206 }
207}
208
209type ApiExample struct {
210 name string
211 path string
212 status int
213 contentType string
214 dbpool db.DB
215}
216
217type AuthDb struct {
218 *stub.StubDB
219 Posts []*db.Post
220}
221
222func (a *AuthDb) AddPicoPlusUser(username, email, from, txid string) error {
223 return nil
224}
225
226func (a *AuthDb) FindUserByName(username string) (*db.User, error) {
227 return &db.User{ID: testUserID, Name: username}, nil
228}
229
230func (a *AuthDb) FindUserByKey(username string, pubkey string) (*db.User, error) {
231 return &db.User{ID: testUserID, Name: username}, nil
232}
233
234func (a *AuthDb) FindUserByToken(token string) (*db.User, error) {
235 if token != "123" {
236 return nil, fmt.Errorf("invalid token")
237 }
238 return &db.User{ID: testUserID, Name: testUsername}, nil
239}
240
241func (a *AuthDb) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
242 return []*db.PublicKey{{ID: "1", UserID: user.ID, Name: "my-key", Key: "nice-pubkey", CreatedAt: &time.Time{}}}, nil
243}
244
245// FindFeature gives pico+ a fixed expiry for the feed snapshots, and every
246// other feature an active flag.
247func (a *AuthDb) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
248 now := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC)
249 expiresAt := now.AddDate(0, 0, 1)
250 if feature != "plus" {
251 expiresAt = time.Now().AddDate(0, 0, 1)
252 }
253 return &db.FeatureFlag{ID: "2", UserID: userID, Name: feature, ExpiresAt: &expiresAt, CreatedAt: &now}, nil
254}
255
256func (a *AuthDb) InsertPost(post *db.Post) (*db.Post, error) {
257 a.Posts = append(a.Posts, post)
258 return post, nil
259}
260
261func (a *AuthDb) FindPostsByUser(pager *db.Pager, userID, space string) (*db.Paginate[*db.Post], error) {
262 return &db.Paginate[*db.Post]{
263 Data: a.Posts,
264 }, nil
265}
266
267func (a *AuthDb) UpsertToken(string, string) (string, error) {
268 return "123", nil
269}
270
271func NewAuthDb(logger *slog.Logger) *AuthDb {
272 sb := stub.NewStubDB(logger)
273 return &AuthDb{
274 StubDB: sb,
275 }
276}
277
278func mkpath(path string) string {
279 return fmt.Sprintf("https://auth.pico.test%s", path)
280}
281
282func setupTest() *ApiConfig {
283 logger := shared.CreateLogger("auth-test", false)
284 cfg := &Config{
285 Issuer: "auth.pico.test",
286 Domain: "http://0.0.0.0:3000",
287 Port: "3000",
288 Secret: "",
289 SecretWebhook: "my-secret",
290 }
291 cfg.Logger = logger
292 db := NewAuthDb(cfg.Logger)
293 apiConfig := &ApiConfig{
294 Cfg: cfg,
295 Dbpool: db,
296 }
297
298 return apiConfig
299}
300
301func testResponse(t *testing.T, responseRecorder *httptest.ResponseRecorder, status int, contentType string) {
302 if responseRecorder.Code != status {
303 t.Errorf("Want status '%d', got '%d'", status, responseRecorder.Code)
304 return
305 }
306
307 ct := responseRecorder.Header().Get("content-type")
308 if ct != contentType {
309 t.Errorf("Want content type '%s', got '%s'", contentType, ct)
310 return
311 }
312
313 body := strings.TrimSpace(responseRecorder.Body.String())
314 snaps.MatchSnapshot(t, body)
315}
316
317func bail(err error) {
318 if err != nil {
319 panic(bail)
320 }
321}
322
323type stubDBWithFeatures struct {
324 *stub.StubDB
325 features map[string]bool
326}
327
328func (s *stubDBWithFeatures) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
329 if !s.features[feature] {
330 return nil, sql.ErrNoRows
331 }
332 expiresAt := time.Now().AddDate(0, 0, 1)
333 return &db.FeatureFlag{UserID: userID, Name: feature, ExpiresAt: &expiresAt}, nil
334}
335
336func newStubDBWithFeatures(features ...string) *stubDBWithFeatures {
337 featMap := make(map[string]bool)
338 for _, f := range features {
339 featMap[f] = true
340 }
341 return &stubDBWithFeatures{
342 StubDB: stub.NewStubDB(shared.CreateLogger("test", false)),
343 features: featMap,
344 }
345}
346
347func TestProsePostMetricDrainFeatureFlags(t *testing.T) {
348 newPostVisit := func() *db.AnalyticsVisits {
349 return &db.AnalyticsVisits{
350 UserID: testUserID,
351 PostID: "post-123",
352 Host: "user-a.prose.sh",
353 Path: "/my-post",
354 IpAddress: "1.1.1.1",
355 UserAgent: "Mozilla/5.0",
356 Status: 200,
357 }
358 }
359
360 newNonPostVisit := func() *db.AnalyticsVisits {
361 return &db.AnalyticsVisits{
362 UserID: testUserID,
363 PostID: "",
364 Host: "user-a.prose.sh",
365 Path: "/",
366 IpAddress: "1.1.1.1",
367 UserAgent: "Mozilla/5.0",
368 Status: 200,
369 }
370 }
371
372 // 1. User without any features: both post and non-post should fail
373 noFeaturesDB := newStubDBWithFeatures()
374 err := router.AnalyticsVisitFromVisit(newPostVisit(), noFeaturesDB, "secret")
375 if !errors.Is(err, router.ErrAnalyticsDisabled) {
376 t.Fatalf("expected ErrAnalyticsDisabled for post without prose/plus, got: %v", err)
377 }
378 err = router.AnalyticsVisitFromVisit(newNonPostVisit(), noFeaturesDB, "secret")
379 if !errors.Is(err, router.ErrAnalyticsDisabled) {
380 t.Fatalf("expected ErrAnalyticsDisabled for non-post without analytics, got: %v", err)
381 }
382
383 // 2. User with "prose" feature: post visit succeeds, non-post fails
384 proseDB := newStubDBWithFeatures("prose")
385 err = router.AnalyticsVisitFromVisit(newPostVisit(), proseDB, "secret")
386 if err != nil {
387 t.Fatalf("expected post visit to succeed with prose feature, got: %v", err)
388 }
389 err = router.AnalyticsVisitFromVisit(newNonPostVisit(), proseDB, "secret")
390 if !errors.Is(err, router.ErrAnalyticsDisabled) {
391 t.Fatalf("expected non-post visit to fail without analytics feature, got: %v", err)
392 }
393
394 // 3. User with "plus" feature: post visit succeeds, non-post fails
395 plusDB := newStubDBWithFeatures("plus")
396 err = router.AnalyticsVisitFromVisit(newPostVisit(), plusDB, "secret")
397 if err != nil {
398 t.Fatalf("expected post visit to succeed with plus feature, got: %v", err)
399 }
400 err = router.AnalyticsVisitFromVisit(newNonPostVisit(), plusDB, "secret")
401 if !errors.Is(err, router.ErrAnalyticsDisabled) {
402 t.Fatalf("expected non-post visit to fail without analytics feature, got: %v", err)
403 }
404
405 // 4. User with "analytics" only: post visit fails (requires prose/plus), non-post succeeds
406 analyticsDB := newStubDBWithFeatures("analytics")
407 err = router.AnalyticsVisitFromVisit(newPostVisit(), analyticsDB, "secret")
408 if !errors.Is(err, router.ErrAnalyticsDisabled) {
409 t.Fatalf("expected post visit to fail without prose or plus feature, got: %v", err)
410 }
411 err = router.AnalyticsVisitFromVisit(newNonPostVisit(), analyticsDB, "secret")
412 if err != nil {
413 t.Fatalf("expected non-post visit to succeed with analytics feature, got: %v", err)
414 }
415}