package suggestions 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" "gitea.parodia.dev/drwily/petal/internal/llm" ) // Suggestions are scoped indirectly: the table has no user_id of its own, only a // doc_id, so every access has to reach the owner through the parent document. A // forgotten join here is worse than it sounds — a suggestion quotes the sentence // it corrects, so listing another account's suggestions leaks their prose. // newTwoUserSuggestionServer seeds one document owned by the local user and // returns routers for its owner and for a second, unrelated user. func newTwoUserSuggestionServer(t *testing.T, client llm.LLMClient) (owner, stranger http.Handler, docID string) { 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() }) 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) } if err := database.QueryRow( `INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, db.LocalUserID, "I has two apple.", ).Scan(&docID); err != nil { t.Fatalf("seed doc: %v", err) } mount := func(userID string) http.Handler { h := New(database, client) r := chi.NewRouter() r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) }) r.Mount("/suggestions", h.Routes()) return auth.Middleware(auth.StaticResolver(userID))(r) } return mount(db.LocalUserID), mount("bob"), docID } func TestSuggestionIsolation(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"} ]}`} owner, stranger, docID := newTwoUserSuggestionServer(t, client) // The owner runs a checkpoint so there is a real pending suggestion to guard. rec := do(t, owner, http.MethodPost, "/docs/"+docID+"/check", "") if rec.Code != http.StatusOK { t.Fatalf("check: %d %s", rec.Code, rec.Body) } var pending []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &pending); err != nil { t.Fatalf("decode: %v", err) } if len(pending) != 1 { t.Fatalf("owner has %d suggestions, want 1", len(pending)) } sugID := pending[0].ID t.Run("cannot list a stranger's suggestions", func(t *testing.T) { rec := do(t, stranger, http.MethodGet, "/docs/"+docID+"/suggestions", "") var out []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } if len(out) != 0 { t.Fatalf("stranger read %d suggestions (leaking %q)", len(out), out[0].Original) } }) t.Run("cannot run a pass on a stranger's document", func(t *testing.T) { rec := do(t, stranger, http.MethodPost, "/docs/"+docID+"/check", "") if rec.Code != http.StatusNotFound { t.Fatalf("stranger check = %d, want 404", rec.Code) } }) // accept/dismiss take a bare suggestion id with no document in the path, so // the write has to scope itself through doc_id → documents.user_id. for _, action := range []string{"accept", "dismiss"} { t.Run("cannot "+action+" a stranger's suggestion", func(t *testing.T) { rec := do(t, stranger, http.MethodPost, "/suggestions/"+sugID+"/"+action, "") if rec.Code != http.StatusNotFound { t.Fatalf("stranger %s = %d, want 404 (body: %s)", action, rec.Code, rec.Body) } }) } // After every attempt the suggestion must still be pending for its owner. rec = do(t, owner, http.MethodGet, "/docs/"+docID+"/suggestions", "") var after []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &after); err != nil { t.Fatalf("decode: %v", err) } if len(after) != 1 || after[0].Status != db.SuggestionStatusPending { t.Fatalf("owner's suggestion was altered by the stranger: %+v", after) } // And the owner can still action it — the scoping guards, it doesn't block. rec = do(t, owner, http.MethodPost, "/suggestions/"+sugID+"/accept", "") if rec.Code != http.StatusNoContent { t.Fatalf("owner accept = %d, want 204 (body: %s)", rec.Code, rec.Body) } // That accept created a settled span, which is the other read of this table. // It carries originals only — but an original is a verbatim quotation of her // sentence, so it is the same leak as the pending list through a smaller hole. if got := getSettled(t, owner, docID); len(got) != 1 { t.Fatalf("owner should see their own settled span, got %v", got) } if got := getSettled(t, stranger, docID); len(got) != 0 { t.Fatalf("stranger read %d settled span(s) (leaking %q)", len(got), got[0]) } }