main pico / pkg / apps / auth / api_test.go
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}