marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 484c79547fc721aab4727d6fdbc07ed4a0316734
parent 1adecb53033738b99c90ce5e029606f87c9edc60
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 22:58:14 -0400

Auth tests

Diffstat:
Minternal/api.go | 25+++++++++++++++++++++++--
Ainternal/auth_e2e_test.go | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 +}