commit 484c79547fc721aab4727d6fdbc07ed4a0316734
parent 1adecb53033738b99c90ce5e029606f87c9edc60
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 22:58:14 -0400
Auth tests
Diffstat:
2 files changed, 148 insertions(+), 2 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -148,9 +148,9 @@ func NewHandler(db *DB) (http.Handler, error) {
// deny nested paths
if name != "" && !strings.Contains(name, "/") {
if u, err := db.GetUserByUsername(r.Context(), name); err == nil && u != nil {
- // Show private items if this is the owner's feed (session or API token)
+ // Show private items if this is the owner's feed (session, basic, header token, or query token)
var items []Bookmark
- if uid, ok := userIDFromRequest(r, db); ok && uid == u.ID {
+ if isOwnerAuthorized(r, u, db) {
items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0)
} else {
items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0)
@@ -510,6 +510,27 @@ func userIDFromRequest(r *http.Request, db *DB) (int64, bool) {
return 0, false
}
+// isOwnerAuthorized checks if the request authenticates as the given user via
+// (1) session cookie, (2) HTTP Basic (username + API token), (3) X-API-Token
+// header, or (4) token query parameter.
+func isOwnerAuthorized(r *http.Request, u *User, db *DB) bool {
+ if uid, ok := CurrentUserID(r); ok && uid == u.ID {
+ return true
+ }
+ if user, pass, ok := r.BasicAuth(); ok {
+ if user == u.Username && u.APIToken != nil && *u.APIToken == pass {
+ return true
+ }
+ }
+ if tok := r.Header.Get("X-API-Token"); tok != "" && u.APIToken != nil && *u.APIToken == tok {
+ return true
+ }
+ if tok := r.URL.Query().Get("token"); tok != "" && u.APIToken != nil && *u.APIToken == tok {
+ return true
+ }
+ return false
+}
+
type createBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
diff --git a/internal/auth_e2e_test.go b/internal/auth_e2e_test.go
@@ -0,0 +1,125 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "regexp"
+ "testing"
+)
+
+func TestFeedsAuthWithToken(t *testing.T) {
+ t.Parallel()
+
+ // Content server to satisfy archiver
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte(`<!doctype html><html><head><title>Feed Test</title></head><body><p>content</p></body></html>`))
+ }))
+ defer page.Close()
+
+ // App server
+ dir := t.TempDir()
+ db, err := OpenAtPath(t.Context(), dir+"/auth.db")
+ if err != nil { t.Fatalf("open db: %v", err) }
+ t.Cleanup(func(){ _ = db.Close() })
+ h, err := NewHandler(db)
+ if err != nil { t.Fatalf("new handler: %v", err) }
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // Authenticated client (cookie jar)
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar}
+
+ // Sign up user
+ postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/signup", map[string]string{"username":"alice","password":"pw"}, 200)
+
+ // Create one public and one private bookmark
+ postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL, "public": true, "title":"Pub"}, 201)
+ postJSONWithClientAuth[map[string]any](t, client, srv.URL+"/bookmarks", map[string]any{"url": page.URL+"/p", "public": false, "title":"Priv"}, 201)
+
+ // Unauthenticated caller sees only public entries in /alice.json
+ pubOnly := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json", 200)
+ if len(pubOnly) != 1 { t.Fatalf("expected 1 public item, got %d", len(pubOnly)) }
+
+ // Generate API token
+ tokResp := postJSONWithClientAuth[map[string]string](t, client, srv.URL+"/api/token", nil, 200)
+ token := tokResp["api_token"]
+ if token == "" { t.Fatalf("empty api token") }
+
+ // With header token, see both
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil)
+ req.Header.Set("X-API-Token", token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil { t.Fatalf("get header token: %v", err) }
+ defer res.Body.Close()
+ if res.StatusCode != 200 { t.Fatalf("status %d", res.StatusCode) }
+ var both []map[string]any
+ _ = json.NewDecoder(res.Body).Decode(&both)
+ if len(both) != 2 { t.Fatalf("expected 2 items with header token, got %d", len(both)) }
+
+ // With query token, see both
+ both2 := getJSONWithClientAuth[[]map[string]any](t, http.DefaultClient, srv.URL+"/alice.json?token="+token, 200)
+ if len(both2) != 2 { t.Fatalf("expected 2 items with query token, got %d", len(both2)) }
+
+ // With Basic auth, see both
+ req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.json", nil)
+ req2.SetBasicAuth("alice", token)
+ res2, err := http.DefaultClient.Do(req2)
+ if err != nil { t.Fatalf("get basic: %v", err) }
+ defer res2.Body.Close()
+ if res2.StatusCode != 200 { t.Fatalf("status %d", res2.StatusCode) }
+ var both3 []map[string]any
+ _ = json.NewDecoder(res2.Body).Decode(&both3)
+ if len(both3) != 2 { t.Fatalf("expected 2 items with basic, got %d", len(both3)) }
+
+ // RSS unauthenticated: only public (<item> once)
+ r1, _ := http.Get(srv.URL+"/alice.rss")
+ b1, _ := io.ReadAll(r1.Body); _ = r1.Body.Close()
+ if c := countRSSItems(b1); c != 1 { t.Fatalf("expected 1 RSS item unauth, got %d", c) }
+ // RSS with token header: both
+ rreq, _ := http.NewRequest(http.MethodGet, srv.URL+"/alice.rss", nil)
+ rreq.Header.Set("X-API-Token", token)
+ rres, _ := http.DefaultClient.Do(rreq)
+ rb, _ := io.ReadAll(rres.Body); _ = rres.Body.Close()
+ if c := countRSSItems(rb); c != 2 { t.Fatalf("expected 2 RSS items with token, got %d", c) }
+}
+
+func countRSSItems(b []byte) int {
+ re := regexp.MustCompile(`<item\b`)
+ return len(re.FindAllIndex(b, -1))
+}
+
+// Helpers copied/adapted from e2e tests
+func postJSONWithClientAuth[T any](t *testing.T, client *http.Client, url string, v any, want int) T {
+ t.Helper()
+ var body io.Reader
+ if v != nil {
+ b, _ := json.Marshal(v)
+ body = bytes.NewReader(b)
+ }
+ req, _ := http.NewRequest(http.MethodPost, url, body)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil { t.Fatalf("post json: %v", err) }
+ defer resp.Body.Close()
+ if resp.StatusCode != want { t.Fatalf("POST %s: got %d want %d", url, resp.StatusCode, want) }
+ var out T
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) }
+ return out
+}
+
+func getJSONWithClientAuth[T any](t *testing.T, client *http.Client, url string, want int) T {
+ t.Helper()
+ resp, err := client.Get(url)
+ if err != nil { t.Fatalf("get: %v", err) }
+ defer resp.Body.Close()
+ if resp.StatusCode != want { t.Fatalf("GET %s: got %d want %d", url, resp.StatusCode, want) }
+ var out T
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) }
+ return out
+}