package docs import ( "encoding/json" "net/http" "path/filepath" "testing" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/auth" "gitea.parodia.dev/drwily/petal/internal/db" ) // This file is the point of the auth plumbing: it proves that swapping the // hardcoded user for a request-scoped one actually isolates accounts. Every // handler resolves its user from the request, so mounting the same routers twice // behind two different resolvers gives us two "logged-in" users over one // database — which is exactly the situation a real login will create. // newTwoUserServer opens one database holding two users and returns a router for // each, identical but for who the auth middleware says is calling. func newTwoUserServer(t *testing.T) (alice, bob http.Handler) { t.Helper() database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) // db.Open seeds the local user; add a second so both sides have a valid FK. if _, err := database.Exec( `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`, "bob", "bob@petal.local", "Bob", ); err != nil { t.Fatalf("seed second user: %v", err) } mount := func(userID string) http.Handler { h := New(database) r := chi.NewRouter() r.Mount("/docs", h.Routes()) r.Mount("/tags", h.TagRoutes()) r.Mount("/search", h.SearchRoutes()) return auth.Middleware(auth.StaticResolver(userID))(r) } return mount(db.LocalUserID), mount("bob") } // TestDocumentIsolation walks every read and write path that takes a document id // and asserts Bob cannot reach Alice's document through any of them. A stranger's // document must be indistinguishable from a nonexistent one — 404, never 403. func TestDocumentIsolation(t *testing.T) { alice, bob := newTwoUserServer(t) docID := createDoc(t, alice, "Alice's diary", "a private sentence about my day") t.Run("not in list", func(t *testing.T) { rec := do(t, bob, http.MethodGet, "/docs", "") var out []docSummary if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode list: %v", err) } if len(out) != 0 { t.Fatalf("bob sees %d of alice's documents, want 0", len(out)) } }) t.Run("not in search", func(t *testing.T) { rec := do(t, bob, http.MethodGet, "/search?q=private", "") var out []searchResult if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode search: %v", err) } if len(out) != 0 { t.Fatalf("search leaked %d of alice's documents", len(out)) } }) // The FTS index is a separate table joined back to documents; a missing // user_id filter there would leak content even though the list query is // scoped, so assert the owner still finds her own document. t.Run("owner still finds it", func(t *testing.T) { rec := do(t, alice, http.MethodGet, "/search?q=private", "") var out []searchResult if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode search: %v", err) } if len(out) != 1 { t.Fatalf("alice found %d results for her own document, want 1", len(out)) } }) for _, tc := range []struct { name, method, path, body string }{ {"get", http.MethodGet, "/docs/" + docID, ""}, {"update", http.MethodPut, "/docs/" + docID, `{"title":"defaced"}`}, {"delete", http.MethodDelete, "/docs/" + docID, ""}, {"export", http.MethodGet, "/docs/" + docID + "/export?format=md", ""}, {"passport", http.MethodGet, "/docs/" + docID + "/passport", ""}, {"snapshot", http.MethodPost, "/docs/" + docID + "/versions", ""}, } { t.Run(tc.name, func(t *testing.T) { rec := do(t, bob, tc.method, tc.path, tc.body) if rec.Code != http.StatusNotFound { t.Fatalf("%s %s as bob = %d, want 404 (body: %s)", tc.method, tc.path, rec.Code, rec.Body) } }) } // The document must have survived every attempt above unchanged. rec := do(t, alice, http.MethodGet, "/docs/"+docID, "") if rec.Code != http.StatusOK { t.Fatalf("alice lost access to her own document: %d %s", rec.Code, rec.Body) } var doc db.Document if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatalf("decode doc: %v", err) } if doc.Title != "Alice's diary" { t.Fatalf("title = %q, want %q — bob's update went through", doc.Title, "Alice's diary") } } // TestVersionIsolation covers the history endpoints, which scope through a join // to documents rather than a direct user_id column — an easy place to forget the // filter, and one where the leak would be the full text of every draft. func TestVersionIsolation(t *testing.T) { alice, bob := newTwoUserServer(t) docID := createDoc(t, alice, "Draft", "the first version of my essay") rec := do(t, alice, http.MethodPost, "/docs/"+docID+"/versions", "") if rec.Code != http.StatusCreated { t.Fatalf("snapshot: %d %s", rec.Code, rec.Body) } var v db.DocumentVersion if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Fatalf("decode version: %v", err) } t.Run("list is empty for stranger", func(t *testing.T) { rec := do(t, bob, http.MethodGet, "/docs/"+docID+"/versions", "") var out []db.DocumentVersion if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } if len(out) != 0 { t.Fatalf("bob sees %d of alice's snapshots, want 0", len(out)) } }) for _, tc := range []struct{ name, method, path string }{ {"preview", http.MethodGet, "/docs/" + docID + "/versions/" + v.ID}, {"restore", http.MethodPost, "/docs/" + docID + "/versions/" + v.ID + "/restore"}, } { t.Run(tc.name, func(t *testing.T) { rec := do(t, bob, tc.method, tc.path, "") if rec.Code != http.StatusNotFound { t.Fatalf("%s as bob = %d, want 404 (body: %s)", tc.name, rec.Code, rec.Body) } }) } } // TestTagIsolation checks the tag roster and, more importantly, that a document // and a tag can't be cross-linked across accounts — the assignment endpoint takes // two ids from different tables and must own-check both. func TestTagIsolation(t *testing.T) { alice, bob := newTwoUserServer(t) docID := createDoc(t, alice, "Essay", "some words") rec := do(t, alice, http.MethodPost, "/tags", `{"name":"school","color":"mint"}`) if rec.Code != http.StatusCreated { t.Fatalf("create tag: %d %s", rec.Code, rec.Body) } var aliceTag db.Tag if err := json.Unmarshal(rec.Body.Bytes(), &aliceTag); err != nil { t.Fatalf("decode tag: %v", err) } rec = do(t, bob, http.MethodPost, "/tags", `{"name":"bobs","color":"sky"}`) var bobTag db.Tag if err := json.Unmarshal(rec.Body.Bytes(), &bobTag); err != nil { t.Fatalf("decode bob tag: %v", err) } t.Run("roster is per user", func(t *testing.T) { rec := do(t, bob, http.MethodGet, "/tags", "") var out []db.Tag if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } if len(out) != 1 || out[0].Name != "bobs" { t.Fatalf("bob's roster = %+v, want just his own tag", out) } }) t.Run("cannot tag a stranger's document", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"tag_id": bobTag.ID}) rec := do(t, bob, http.MethodPost, "/docs/"+docID+"/tags", string(body)) if rec.Code != http.StatusNotFound { t.Fatalf("bob tagging alice's doc = %d, want 404", rec.Code) } }) t.Run("cannot rename a stranger's tag", func(t *testing.T) { rec := do(t, bob, http.MethodPatch, "/tags/"+aliceTag.ID, `{"name":"stolen"}`) if rec.Code != http.StatusNotFound { t.Fatalf("bob renaming alice's tag = %d, want 404", rec.Code) } }) t.Run("cannot delete a stranger's tag", func(t *testing.T) { rec := do(t, bob, http.MethodDelete, "/tags/"+aliceTag.ID, "") if rec.Code != http.StatusNotFound { t.Fatalf("bob deleting alice's tag = %d, want 404", rec.Code) } }) }