mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
5035168e5c
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
477 lines
19 KiB
Go
477 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// BUG-3334: a store error on an access decision fails CLOSED. Faults are real:
|
|
// a renamed table makes the store query genuinely fail, except where an earlier
|
|
// check runs the same query (see visibleCollectionIDsFault).
|
|
|
|
// breakTable renames table so every query reading it errors, restoring it at
|
|
// cleanup.
|
|
func breakTable(t *testing.T, srv *Server, table string) {
|
|
t.Helper()
|
|
if _, err := srv.store.DB().Exec(`ALTER TABLE ` + table + ` RENAME TO ` + table + `_bug3334`); err != nil {
|
|
t.Fatalf("rename %s: %v", table, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = srv.store.DB().Exec(`ALTER TABLE ` + table + `_bug3334 RENAME TO ` + table)
|
|
})
|
|
}
|
|
|
|
// initializedWorkspace is a workspace on an instance that has a user, so the
|
|
// fresh-install bypass does not apply.
|
|
func initializedWorkspace(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
if _, err := srv.store.CreateUser(models.UserCreate{Email: "owner-3334@example.com", Name: "Owner", Password: "pw-test-12345"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return srv, slug
|
|
}
|
|
|
|
// failUserCount makes every userCount call fail, and counts them: a leg
|
|
// asserts its site was reached through the seam, so a site that stops
|
|
// calling userCount cannot pass by never meeting the fault.
|
|
func failUserCount(t *testing.T, srv *Server) *int {
|
|
t.Helper()
|
|
calls := 0
|
|
srv.userCountFault = func() error {
|
|
calls++
|
|
return errors.New("bug3334: injected user-count fault")
|
|
}
|
|
t.Cleanup(func() { srv.userCountFault = nil })
|
|
return &calls
|
|
}
|
|
|
|
func reachedSeam(t *testing.T, calls *int) {
|
|
t.Helper()
|
|
if *calls == 0 {
|
|
t.Fatal("the site never called userCount, so the fault was never met")
|
|
}
|
|
}
|
|
|
|
var reachedNext = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusTeapot)
|
|
})
|
|
|
|
// (A1) RequireAuth: an anonymous API request during a user-count fault is
|
|
// refused, not let through as if the instance had no users.
|
|
func TestBUG3334_RequireAuthFailsClosedOnUserCountError(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
breakTable(t, srv, "users")
|
|
rr := httptest.NewRecorder()
|
|
srv.RequireAuth(reachedNext).ServeHTTP(rr, httptest.NewRequest("GET", "/api/v1/workspaces/"+slug+"/collections", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous request during a user-count fault: %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// (A2) RequireWorkspaceAccess: the same fault never grants the fresh-install
|
|
// owner role.
|
|
// The workspace lookup ahead of the check reads the users table too, so the
|
|
// fault is on the count alone (userCountFault).
|
|
func TestBUG3334_RequireWorkspaceAccessFailsClosedOnUserCountError(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
calls := failUserCount(t, srv)
|
|
req := httptest.NewRequest("GET", "/api/v1/workspaces/"+slug+"/collections", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("slug", slug)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
var role string
|
|
rr := httptest.NewRecorder()
|
|
srv.RequireWorkspaceAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
role = workspaceRole(r)
|
|
w.WriteHeader(http.StatusTeapot)
|
|
})).ServeHTTP(rr, req)
|
|
reachedSeam(t, calls)
|
|
if rr.Code == http.StatusTeapot {
|
|
t.Fatalf("anonymous request during a user-count fault reached the handler with role %q", role)
|
|
}
|
|
}
|
|
|
|
// (A1+A2) Through the real router: the anonymous read that both holes
|
|
// together turned into owner access.
|
|
func TestBUG3334_AnonymousReadDuringUserCountFaultIsRefused(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
calls := failUserCount(t, srv)
|
|
if rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections", nil); rr.Code == http.StatusOK {
|
|
t.Fatalf("anonymous collections read during a user-count fault: 200 %s", rr.Body.String())
|
|
}
|
|
reachedSeam(t, calls)
|
|
}
|
|
|
|
// (A3) CSRFProtect: a cookie-borne mutation during a user-count fault is held
|
|
// to the CSRF check rather than waved through as a fresh install.
|
|
func TestBUG3334_CSRFFailsClosedOnUserCountError(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
breakTable(t, srv, "users")
|
|
req := httptest.NewRequest("POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", strings.NewReader(`{}`))
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName(srv.secureCookies), Value: "some-session"})
|
|
rr := httptest.NewRecorder()
|
|
srv.CSRFProtect(reachedNext).ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "csrf_error") {
|
|
t.Fatalf("cookie mutation during a user-count fault: %d %s, want 403 csrf_error", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// (A4) SSE revalidation: a user-count fault does not short-circuit the
|
|
// recheck into "everyone has access"; an anonymous, tokenless stream ends.
|
|
func TestBUG3334_SSERecheckFailsClosedOnUserCountError(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
ws, err := srv.store.GetWorkspaceBySlug(slug)
|
|
if err != nil || ws == nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The count alone: a fault on the users table would first fail the
|
|
// workspace lookup, which deliberately keeps the stream (BUG-3273).
|
|
calls := failUserCount(t, srv)
|
|
if srv.sseSubscriberStillHasAccess(httptest.NewRequest("GET", "/api/v1/events", nil), ws.ID) {
|
|
t.Fatal("an anonymous stream kept access through a user-count fault")
|
|
}
|
|
reachedSeam(t, calls)
|
|
}
|
|
|
|
// (A5) Collab: the same fault does not grant an anonymous socket write access.
|
|
func TestBUG3334_CollabFailsClosedOnUserCountError(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
ws, _ := srv.store.GetWorkspaceBySlug(slug)
|
|
item, err := srv.store.CreateItem(ws.ID, mustCollectionID(t, srv, ws.ID, "tasks"), models.ItemCreate{Title: "Doc", Fields: `{"status":"open"}`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := failUserCount(t, srv)
|
|
access, err := srv.authorizeCollabAccess(httptest.NewRequest("GET", "/api/v1/collab/"+item.ID, nil), item)
|
|
if err == nil {
|
|
t.Fatalf("anonymous collab access during a user-count fault: granted (canWrite=%v)", access.canWrite)
|
|
}
|
|
reachedSeam(t, calls)
|
|
}
|
|
|
|
func mustCollectionID(t *testing.T, srv *Server, wsID, slug string) string {
|
|
t.Helper()
|
|
c, err := srv.store.GetCollectionBySlug(wsID, slug)
|
|
if err != nil || c == nil {
|
|
t.Fatalf("collection %s: %v", slug, err)
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
// --- B: visibleCollectionIDs errors ---
|
|
|
|
type restrictedFixture struct {
|
|
srv *Server
|
|
slug, wsID string
|
|
pat string
|
|
hiddenParent models.Item // in ideas, which the member cannot see
|
|
child models.Item // in tasks, parented to hiddenParent
|
|
visibleTask models.Item // in tasks, with a child in ideas
|
|
}
|
|
|
|
// restrictedMember builds a member whose access is tasks only, a task whose
|
|
// parent sits in ideas (its title is the leak signal), and a task with a
|
|
// hidden child (its progress count is the leak signal).
|
|
func restrictedMember(t *testing.T) restrictedFixture {
|
|
t.Helper()
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
ws, _ := srv.store.GetWorkspaceBySlug(slug)
|
|
post := func(coll, title, fields string) models.Item {
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/"+coll+"/items",
|
|
map[string]any{"title": title, "fields": fields})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create %s: %d %s", title, rr.Code, rr.Body.String())
|
|
}
|
|
var it models.Item
|
|
parseJSON(t, rr, &it)
|
|
return it
|
|
}
|
|
hidden := post("ideas", "Hidden parent title", `{"status":"new"}`)
|
|
child := post("tasks", "Child task", `{"status":"open","parent":"`+hidden.ID+`"}`)
|
|
visible := post("tasks", "Visible task", `{"status":"open"}`)
|
|
post("ideas", "Hidden child", `{"status":"new","parent":"`+visible.ID+`"}`)
|
|
|
|
member, err := srv.store.CreateUser(models.UserCreate{Email: "member-3334@example.com", Name: "Member", Password: "pw-test-12345"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, member.ID, "editor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.store.SetMemberCollectionAccess(ws.ID, member.ID, "specific", []string{mustCollectionID(t, srv, ws.ID, "tasks")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := srv.store.CreateAPIToken(member.ID, models.APITokenCreate{Name: "bug3334", WorkspaceID: ws.ID}, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return restrictedFixture{srv: srv, slug: slug, wsID: ws.ID, pat: tok.Token, hiddenParent: hidden, child: child, visibleTask: visible}
|
|
}
|
|
|
|
func (f restrictedFixture) do(method, path string, body any) *httptest.ResponseRecorder {
|
|
return doRequestWithHeaders(f.srv, method, "/api/v1/workspaces/"+f.slug+path, body,
|
|
map[string]string{"Authorization": "Bearer " + f.pat})
|
|
}
|
|
|
|
var errVisibilityFault = errors.New("bug3334: injected visibility fault")
|
|
|
|
// faultFrom makes visibleCollectionIDs fail from its nth call on (1-based),
|
|
// and counts calls.
|
|
func (f restrictedFixture) faultFrom(t *testing.T, n int) *int {
|
|
t.Helper()
|
|
calls := 0
|
|
f.srv.visibleCollectionIDsFault = func() error {
|
|
calls++
|
|
if calls >= n {
|
|
return errVisibilityFault
|
|
}
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { f.srv.visibleCollectionIDsFault = nil })
|
|
return &calls
|
|
}
|
|
|
|
func noLeak(t *testing.T, what string, rr *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if strings.Contains(rr.Body.String(), "Hidden parent title") || strings.Contains(rr.Body.String(), "Hidden child") {
|
|
t.Fatalf("%s leaked a hidden item through a visibility fault: %d %s", what, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The fixture itself: without a fault, the member is shown neither hidden
|
|
// item, so a leak below is the fault's doing.
|
|
func TestBUG3334_FixtureHidesBothSignals(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
rr := f.do("GET", "/items/"+f.child.Slug, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("member get: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
noLeak(t, "get without a fault", rr)
|
|
rr = f.do("GET", "/items/"+f.visibleTask.Slug+"/progress", nil)
|
|
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"total":0`) {
|
|
t.Fatalf("member progress without a fault: %d %s, want total 0", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBUG3334_GetItemFailsClosedOnVisibilityError(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
f.faultFrom(t, 1)
|
|
rr := f.do("GET", "/items/"+f.child.Slug, nil)
|
|
noLeak(t, "get", rr)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("get during a visibility fault: %d, want 500", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestBUG3334_ProgressFailsClosedOnVisibilityError(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
f.faultFrom(t, 1)
|
|
rr := f.do("GET", "/items/"+f.visibleTask.Slug+"/progress", nil)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("progress during a visibility fault: %d %s, want 500 (an unfiltered count counts the hidden child)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Update resolves visibility BEFORE writing: a fault is a 500 with nothing
|
|
// written, never a committed write answered with an unfiltered response.
|
|
func TestBUG3334_UpdateFailsClosedBeforeWriting(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
f.faultFrom(t, 1)
|
|
rr := f.do("PATCH", "/items/"+f.child.Slug, map[string]any{"title": "Renamed"})
|
|
noLeak(t, "update", rr)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("update during a visibility fault: %d, want 500", rr.Code)
|
|
}
|
|
got, _ := f.srv.store.GetItem(f.child.ID)
|
|
if got == nil || got.Title != "Child task" {
|
|
t.Fatalf("update wrote through a visibility fault: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBUG3334_RestoreFailsClosedBeforeWriting(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
if err := f.srv.store.DeleteItem(f.child.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.faultFrom(t, 1)
|
|
rr := f.do("POST", "/items/"+f.child.Slug+"/restore", nil)
|
|
noLeak(t, "restore", rr)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("restore during a visibility fault: %d, want 500", rr.Code)
|
|
}
|
|
got, _ := f.srv.store.ResolveItemIncludeDeleted(f.wsID, f.child.Slug)
|
|
if got == nil || got.DeletedAt == nil {
|
|
t.Fatalf("restore wrote through a visibility fault: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Create and move already resolve visibility before their write, with the
|
|
// error checked; the response must be built from THAT result, not from a
|
|
// second, unchecked resolution.
|
|
func TestBUG3334_CreateResolvesVisibilityOnce(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
calls := f.faultFrom(t, 2)
|
|
rr := f.do("POST", "/collections/tasks/items", map[string]any{"title": "New", "fields": `{"status":"open"}`})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if *calls != 1 {
|
|
t.Fatalf("create resolved visibility %d times; a second, unchecked resolution is the BUG-3334 site", *calls)
|
|
}
|
|
}
|
|
|
|
func TestBUG3334_MoveBuildsItsResponseFromTheCheckedResolution(t *testing.T) {
|
|
f := restrictedMember(t)
|
|
if err := f.srv.store.SetMemberCollectionAccess(f.wsID, mustUserID(t, f.srv, "member-3334@example.com"), "specific",
|
|
[]string{mustCollectionID(t, f.srv, f.wsID, "tasks"), mustCollectionID(t, f.srv, f.wsID, "docs")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The first two resolutions (the target check and the open-children
|
|
// guard) are checked already; the third built the response unchecked.
|
|
calls := f.faultFrom(t, 3)
|
|
rr := f.do("POST", "/items/"+f.child.Slug+"/move", map[string]any{"target_collection": "docs"})
|
|
noLeak(t, "move", rr)
|
|
if rr.Code != http.StatusOK && rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("move: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Code == http.StatusOK && *calls > 2 {
|
|
t.Fatalf("move answered 200 after %d visibility resolutions; the response came from an unchecked one", *calls)
|
|
}
|
|
}
|
|
|
|
func mustUserID(t *testing.T, srv *Server, email string) string {
|
|
t.Helper()
|
|
u, err := srv.store.GetUserByEmail(email)
|
|
if err != nil || u == nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
func TestVisibleCollectionIDsFaultIsNilInProduction(t *testing.T) {
|
|
t.Parallel()
|
|
// testServer copies the once-built template (storetest), where store.New(":memory:")
|
|
// ran the whole migration chain, about 17s under -race (TASK-3493).
|
|
if testServer(t).visibleCollectionIDsFault != nil {
|
|
t.Fatal("New left the BUG-3334 test-only fault seam set")
|
|
}
|
|
}
|
|
|
|
func TestUserCountFaultIsNilInProduction(t *testing.T) {
|
|
t.Parallel()
|
|
// testServer copies the once-built template (storetest), where store.New(":memory:")
|
|
// ran the whole migration chain, about 17s under -race (TASK-3493).
|
|
if testServer(t).userCountFault != nil {
|
|
t.Fatal("New left the BUG-3334 test-only user-count seam set")
|
|
}
|
|
}
|
|
|
|
// (C) /audit-log is a platform-wide admin surface. An admin's PAT is refused
|
|
// with session_required (the BUG-2890 line); the admin's own browser session
|
|
// and CLI session are not.
|
|
func TestBUG3334_AuditLogRefusesAnAdminPAT(t *testing.T) {
|
|
srv := testServer(t)
|
|
admin, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "admin-3334@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Audit WS", OwnerID: admin.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pat, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{Name: "admin-pat"}, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sess := loginUser(t, srv, "admin-3334@example.com", "correct-horse-battery-staple")
|
|
|
|
rr := doRequestWithBearer(srv, "GET", "/api/v1/audit-log", pat.Token, nil)
|
|
if rr.Code != http.StatusForbidden || errorCode(t, rr) != "session_required" {
|
|
t.Fatalf("admin PAT on /audit-log: %d %s, want 403 session_required", rr.Code, rr.Body.String())
|
|
}
|
|
if rr := doRequestWithBearer(srv, "GET", "/api/v1/audit-log", sess, nil); rr.Code != http.StatusOK {
|
|
t.Fatalf("admin CLI session on /audit-log: %d %s, want 200", rr.Code, rr.Body.String())
|
|
}
|
|
if rr := doRequestWithCookie(srv, "GET", "/api/v1/audit-log", nil, sess); rr.Code != http.StatusOK {
|
|
t.Fatalf("admin browser session on /audit-log: %d %s, want 200", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// A signed-in member keeps working through a user-count fault at every site
|
|
// the fault no longer short-circuits: the fall-through, not a 500 (the
|
|
// lead's A1 ruling).
|
|
func TestBUG3334_SignedInMemberWorksThroughUserCountFault(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
ws, _ := srv.store.GetWorkspaceBySlug(slug)
|
|
item, err := srv.store.CreateItem(ws.ID, mustCollectionID(t, srv, ws.ID, "tasks"), models.ItemCreate{Title: "Doc", Fields: `{"status":"open"}`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, tok := loginTestUser(t, srv)
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, user.ID, "editor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := failUserCount(t, srv)
|
|
if rr := doAuthedRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections", nil, tok); rr.Code != http.StatusOK {
|
|
t.Fatalf("signed-in member during a user-count fault: %d %s, want 200", rr.Code, rr.Body.String())
|
|
}
|
|
reachedSeam(t, calls)
|
|
req := httptest.NewRequest("GET", "/api/v1/events", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), ctxCurrentUser, user))
|
|
if !srv.sseSubscriberStillHasAccess(req, ws.ID) {
|
|
t.Error("a member's stream was ended by a user-count fault")
|
|
}
|
|
if access, err := srv.authorizeCollabAccess(req, item); err != nil || !access.canWrite {
|
|
t.Errorf("a member's collab access during a user-count fault: %+v, %v", access, err)
|
|
}
|
|
}
|
|
|
|
// SSE visibility: when the fresh user row cannot be read, the cached identity
|
|
// (as old as the connection) grants nothing: neither a cookie admin's bypass
|
|
// nor a full-access member's unfiltered view (a demotion or a disable would
|
|
// not show). Every event is denied until a recompute reads the row.
|
|
func TestBUG3334_SSEVisibilityDeniesWithoutAFreshUser(t *testing.T) {
|
|
srv, slug := initializedWorkspace(t)
|
|
ws, _ := srv.store.GetWorkspaceBySlug(slug)
|
|
admin, err := srv.store.CreateUser(models.UserCreate{Email: "sse-admin-3334@example.com", Name: "Admin", Password: "pw-test-12345", Role: "admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
member, err := srv.store.CreateUser(models.UserCreate{Email: "sse-member-3334@example.com", Name: "Member", Password: "pw-test-12345"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, member.ID, "editor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
breakTable(t, srv, "users")
|
|
for name, u := range map[string]*models.User{"cookie admin": admin, "full-access member": member} {
|
|
req := httptest.NewRequest("GET", "/api/v1/events", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), ctxCurrentUser, u))
|
|
v := srv.computeSSEVisibility(req, ws.ID)
|
|
if v.visibleSlugSet == nil {
|
|
t.Errorf("%s: unfiltered SSE visibility through a user-row fault", name)
|
|
continue
|
|
}
|
|
for _, ev := range []events.Event{{Type: "item_updated", Collection: "tasks", ItemID: "x"}, {Type: "member_added"}} {
|
|
if sseEventVisibleFor(v, u.ID, ev) {
|
|
t.Errorf("%s: %s delivered through a user-row fault", name, ev.Type)
|
|
}
|
|
}
|
|
}
|
|
}
|