mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
feat(workspaces): a per-user open set of workspace tabs, stored server-side and filtered on read to the workspaces the caller can see (TASK-3256) (#1619)
PLAN-3002 U1. A new user_workspace_tabs table (migration 098 / pg 073)
holds each user's open tabs: position, an ephemeral flag (at most one per
user) and a same-workspace last_route. The migration seeds each user's
first six live member workspaces in today's bar order (Dave's Q8).
/api/v1/me/workspace-tabs lists, opens, reorders, patches (pin,
last_route) and closes. Every response re-reads the caller's visible set
after the write and serves only tabs in it, so a tab never names a
workspace the caller cannot open, even for a loss path that left its row.
Member removal, grant revoke, soft delete, purge and owner account
deletion also delete rows, as hygiene.
last_route is refused unless it sits under the workspace's own
/{owner}/{ws} prefix, checked on the raw and the percent-decoded path
(codex rounds 1-2: %2e%2e and %252e%252e), and gets a NUL trigger (099).
Web client only; U2 builds the store on top of it.
Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
This commit is contained in:
@@ -121,6 +121,7 @@ Collab WebSocket dials (`/api/v1/collab/{itemID}`) are NOT in the general API bu
|
||||
One browser tab's content writes to an item are ordered by the server, also **per instance** (BUG-3080). Every item-pane content PATCH carries `client_write: {tab, n}` — a random id minted per page load (never a session or user id) and a counter that only rises — and a write whose `n` is below one already APPLIED for the same (item, tab) answers `409 superseded_write` (details name the `n` it lost to) and writes nothing; the pane treats that as success and never retries it. It exists for the teardown flushes, which are both dispatched before the tab sees either result, so no version token can order them (both would carry the same `expected_seq`, and the older landing first would get the NEWER refused — which is why those writes carry none). The mark is one in-memory map keyed (item, tab), bounded at 10,000 keys and forgotten after 10 minutes idle: it is lost on restart and not shared between instances, so on a multi-instance deployment it is best-effort. A missing mark refuses nothing, so every one of those gaps degrades to the behaviour before it existed, never to anything worse. A write with no `client_write` (the CLI, MCP, any other API caller) is never compared against a mark. Refusals are counted in `pad_content_writes_superseded_total`.
|
||||
- `GET /api/v1/collab/{itemID}?schema_version=N` — WebSocket upgrade for real-time collaborative editing (Yjs binary protocol; client must announce schema version)
|
||||
- `GET /workspaces/{ws}/members` — list members + pending invitations
|
||||
- `GET/POST/PUT /api/v1/me/workspace-tabs`, `PATCH/DELETE /api/v1/me/workspace-tabs/{slug}` — the caller's open set of workspace tabs (PLAN-3002 U1 / TASK-3256), stored server-side in `user_workspace_tabs` so the bar is the same on every device. POST `{slug, ephemeral}` opens (a durable open pins an ephemeral tab; an ephemeral open replaces the caller's one ephemeral tab in its position), PUT `[slug…]` reorders (unlisted open tabs keep their order after the listed ones), PATCH `{pin?, last_route?}`, DELETE closes. Every call answers `{tabs: [...]}`, filtered on READ to the caller's visible set (members and guests, live workspaces, the token allow-list), so a tab never names a workspace the caller cannot open even when a loss path left its row; member removal, grant revoke, soft delete, purge and owner account deletion also delete rows, as hygiene. A workspace outside that set answers the workspace 404 on POST/PATCH, byte-identical to an unknown slug, and is ignored with the same 200 on PUT/DELETE. `last_route` is accepted only under the workspace's own `/{owner}/{ws}` prefix (no `.`/`..` or empty segments, no backslash, control or space characters, max 2048), and never for an owner with no username, whose path would be protocol-relative. Migration 098 seeded each user's first six live member workspaces in (sort_order, name) order; `workspace_members.sort_order` and `PUT /workspaces/reorder` remain the MEMBERSHIP order the CLI, MCP and export read. Web client only: no CLI or MCP surface
|
||||
- `POST /workspaces/{ws}/members/invite` — invite user to workspace
|
||||
- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `user`)
|
||||
- `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkspaceTab is one workspace in a user's open set: a tab in the workspace
|
||||
// tab bar (PLAN-3002 U1 / TASK-3256). Stored as a user_workspace_tabs row, one
|
||||
// per (user, workspace). The whole row is server-side so the bar is the same
|
||||
// on every device (PLAN-3002 Q1).
|
||||
type WorkspaceTab struct {
|
||||
WorkspaceID string `json:"-"`
|
||||
// Workspace fields the bar renders, filled from the caller's visible set
|
||||
// at read time.
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
// IsGuest marks a workspace the caller reaches through grants only
|
||||
// (PLAN-3002 Q10: guests enter the open set like members, with the guest
|
||||
// marker).
|
||||
IsGuest bool `json:"is_guest"`
|
||||
|
||||
Position int `json:"position"`
|
||||
// Ephemeral marks a tab a landing opened that has not been kept yet. At
|
||||
// most one per user.
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
// LastRoute is the in-workspace path the tab returns to, or empty.
|
||||
LastRoute string `json:"last_route,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
"github.com/PerpetualSoftware/pad/internal/store"
|
||||
)
|
||||
|
||||
// The per-user open set of workspace tabs (PLAN-3002 U1 / TASK-3256):
|
||||
// /api/v1/me/workspace-tabs. User-scoped, so it sits outside the /{slug}
|
||||
// access subrouter, like PUT /workspaces/reorder, and every handler resolves
|
||||
// the workspace against the caller's OWN visible set instead. Web client
|
||||
// only: no CLI or MCP surface (CONVE-191).
|
||||
//
|
||||
// Every response that lists tabs lists only tabs whose workspace is in that
|
||||
// visible set, so a tab never names a workspace the caller cannot open, even
|
||||
// for a loss path that never deleted its row.
|
||||
|
||||
// maxWorkspaceTabLastRouteLen bounds a stored last_route. A route is a path
|
||||
// plus a short query; 2048 is the conventional URL ceiling and far above any
|
||||
// route the web app builds.
|
||||
const maxWorkspaceTabLastRouteLen = 2048
|
||||
|
||||
type workspaceTabsResponse struct {
|
||||
Tabs []models.WorkspaceTab `json:"tabs"`
|
||||
}
|
||||
|
||||
// tabsVisibleWorkspaces is the set every tab read and write is checked
|
||||
// against: the caller's workspaces (members and guests, live only), narrowed
|
||||
// by an OAuth token's allow-list exactly as GET /workspaces narrows it.
|
||||
func (s *Server) tabsVisibleWorkspaces(r *http.Request, userID string) ([]models.Workspace, error) {
|
||||
visible, err := s.store.GetUserWorkspaces(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterWorkspacesByTokenAllowlist(r.Context(), visible), nil
|
||||
}
|
||||
|
||||
func findVisibleWorkspace(visible []models.Workspace, slug string) *models.Workspace {
|
||||
for i := range visible {
|
||||
if visible[i].Slug == slug {
|
||||
return &visible[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeWorkspaceTabs answers with the caller's current, visibility-filtered
|
||||
// open set. Every write answers with it too, so the client never needs a
|
||||
// second round trip to learn what the write did.
|
||||
//
|
||||
// The visible set is read AGAIN here rather than reused from the request's
|
||||
// start: access revoked while the write ran must not be served from the
|
||||
// earlier snapshot (codex round 1 on TASK-3256). A row the write left for a
|
||||
// workspace revoked in that window stays stored and is hidden by this filter
|
||||
// on every read, which is the design's read-side invariant.
|
||||
func (s *Server) writeWorkspaceTabs(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
visible, err := s.tabsVisibleWorkspaces(r, userID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := s.store.ListWorkspaceTabRows(userID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, workspaceTabsResponse{Tabs: store.VisibleWorkspaceTabs(rows, visible)})
|
||||
}
|
||||
|
||||
// tabsCaller returns the user and their visible set, or writes the refusal.
|
||||
func (s *Server) tabsCaller(w http.ResponseWriter, r *http.Request) (string, []models.Workspace, bool) {
|
||||
userID := currentUserID(r)
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return "", nil, false
|
||||
}
|
||||
visible, err := s.tabsVisibleWorkspaces(r, userID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return "", nil, false
|
||||
}
|
||||
return userID, visible, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListWorkspaceTabs(w http.ResponseWriter, r *http.Request) {
|
||||
userID := currentUserID(r)
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
s.writeWorkspaceTabs(w, r, userID)
|
||||
}
|
||||
|
||||
// handleOpenWorkspaceTab: POST {slug, ephemeral}. A workspace outside the
|
||||
// caller's visible set answers the workspace 404 (BUG-3069), the same answer
|
||||
// an unknown slug gets.
|
||||
func (s *Server) handleOpenWorkspaceTab(w http.ResponseWriter, r *http.Request) {
|
||||
userID, visible, ok := s.tabsCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Slug string `json:"slug"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
}
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if input.Slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "slug is required")
|
||||
return
|
||||
}
|
||||
ws := findVisibleWorkspace(visible, input.Slug)
|
||||
if ws == nil {
|
||||
writeWorkspaceNotFound(w, "Workspace not found")
|
||||
return
|
||||
}
|
||||
if err := s.store.OpenWorkspaceTab(userID, ws.ID, input.Ephemeral); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.writeWorkspaceTabs(w, r, userID)
|
||||
}
|
||||
|
||||
// handleReorderWorkspaceTabs: PUT [slug, …], the bar's full order. Slugs the
|
||||
// caller cannot see, or that are not open, are ignored silently (the answer an
|
||||
// unknown slug gets from PUT /workspaces/reorder), so the response says
|
||||
// nothing about which slugs exist.
|
||||
func (s *Server) handleReorderWorkspaceTabs(w http.ResponseWriter, r *http.Request) {
|
||||
userID, visible, ok := s.tabsCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var slugs []string
|
||||
if err := decodeJSON(r, &slugs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
order := make([]string, 0, len(slugs))
|
||||
for _, slug := range slugs {
|
||||
if ws := findVisibleWorkspace(visible, slug); ws != nil {
|
||||
order = append(order, ws.ID)
|
||||
}
|
||||
}
|
||||
if err := s.store.ReorderWorkspaceTabs(userID, order); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.writeWorkspaceTabs(w, r, userID)
|
||||
}
|
||||
|
||||
// handleCloseWorkspaceTab: DELETE /{slug}. Closing is idempotent, and a slug
|
||||
// outside the visible set is a no-op with the same 200, so this door is not
|
||||
// an existence oracle either.
|
||||
func (s *Server) handleCloseWorkspaceTab(w http.ResponseWriter, r *http.Request) {
|
||||
userID, visible, ok := s.tabsCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ws := findVisibleWorkspace(visible, chi.URLParam(r, "slug")); ws != nil {
|
||||
if err := s.store.CloseWorkspaceTab(userID, ws.ID); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.writeWorkspaceTabs(w, r, userID)
|
||||
}
|
||||
|
||||
// handleUpdateWorkspaceTab: PATCH /{slug} {pin?, last_route?}. pin makes an
|
||||
// ephemeral tab durable (there is no unpin); last_route replaces the stored
|
||||
// route, and "" or null clears it.
|
||||
func (s *Server) handleUpdateWorkspaceTab(w http.ResponseWriter, r *http.Request) {
|
||||
userID, visible, ok := s.tabsCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Pin *bool `json:"pin"`
|
||||
LastRoute optionalString `json:"last_route"`
|
||||
}
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if input.Pin == nil && !input.LastRoute.Set {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "nothing to update: send pin or last_route")
|
||||
return
|
||||
}
|
||||
if input.Pin != nil && !*input.Pin {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "pin can only be true: a kept tab is not made ephemeral again")
|
||||
return
|
||||
}
|
||||
ws := findVisibleWorkspace(visible, chi.URLParam(r, "slug"))
|
||||
if ws == nil {
|
||||
writeWorkspaceNotFound(w, "Workspace not found")
|
||||
return
|
||||
}
|
||||
u := store.WorkspaceTabUpdate{Pin: input.Pin != nil && *input.Pin}
|
||||
if input.LastRoute.Set {
|
||||
route := input.LastRoute.Value
|
||||
if route != "" {
|
||||
if msg := validateWorkspaceTabLastRoute(route, ws.OwnerUsername, ws.Slug); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
u.LastRoute = &route
|
||||
}
|
||||
if err := s.store.UpdateWorkspaceTab(userID, ws.ID, u); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "This workspace is not open in a tab")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.writeWorkspaceTabs(w, r, userID)
|
||||
}
|
||||
|
||||
// validateWorkspaceTabLastRoute accepts only a same-workspace path: the
|
||||
// workspace's own /{owner}/{ws} or anything under /{owner}/{ws}/, with an
|
||||
// optional query or fragment. Anything else would turn the stored route into
|
||||
// an open redirect when the client navigates to it. It returns "" when the
|
||||
// route is acceptable, else the refusal message.
|
||||
func validateWorkspaceTabLastRoute(route, owner, slug string) string {
|
||||
if len(route) > maxWorkspaceTabLastRouteLen {
|
||||
return "last_route is too long"
|
||||
}
|
||||
if owner == "" || slug == "" {
|
||||
return "this workspace has no route prefix to store a last_route under"
|
||||
}
|
||||
for _, c := range route {
|
||||
if c == '\\' || unicode.IsControl(c) || unicode.IsSpace(c) {
|
||||
return "last_route must be a plain path"
|
||||
}
|
||||
}
|
||||
path := route
|
||||
if i := strings.IndexAny(path, "?#"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
// Checked on the DECODED path too. A URL parser reads %2e%2e as a ..
|
||||
// segment (codex round 1 on TASK-3256), so the literal checks below alone
|
||||
// let /{owner}/{ws}/%2e%2e/other through to navigate out of the
|
||||
// workspace. Decoding everything is a superset of the dot-segment rule,
|
||||
// and an escape that does not decode is refused. So is a '%' that SURVIVES
|
||||
// one decode (codex round 2): %252e%252e decodes to %2e%2e, which any layer
|
||||
// decoding once more reads as .. again. The web app's routes are slugs and
|
||||
// refs, so a path never needs one; the query is not checked.
|
||||
decoded, err := url.PathUnescape(path)
|
||||
if err != nil {
|
||||
return "last_route must be a valid path"
|
||||
}
|
||||
if strings.Contains(decoded, "%") {
|
||||
return "last_route must not be encoded twice"
|
||||
}
|
||||
for _, c := range decoded {
|
||||
if c == '\\' || unicode.IsControl(c) || unicode.IsSpace(c) {
|
||||
return "last_route must be a plain path"
|
||||
}
|
||||
}
|
||||
prefix := "/" + owner + "/" + slug
|
||||
if path != prefix && !strings.HasPrefix(path, prefix+"/") {
|
||||
return "last_route must be a path inside this workspace"
|
||||
}
|
||||
for _, p := range []string{path, decoded} {
|
||||
for _, seg := range strings.Split(p[1:], "/") {
|
||||
if seg == "." || seg == ".." {
|
||||
return "last_route must not contain . or .. segments"
|
||||
}
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return "last_route must not contain an empty segment"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// optionalString tells an absent JSON member (Set false) from a present one.
|
||||
// null and "" both decode to a present empty string, which clears.
|
||||
type optionalString struct {
|
||||
Set bool
|
||||
Value string
|
||||
}
|
||||
|
||||
func (o *optionalString) UnmarshalJSON(b []byte) error {
|
||||
o.Set = true
|
||||
if string(b) == "null" {
|
||||
o.Value = ""
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, &o.Value)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// Handler tests for /api/v1/me/workspace-tabs (TASK-3256). They go through the
|
||||
// real router with a session cookie, so the route binding, auth and CSRF are
|
||||
// exercised along with the handlers. testServer is SQLite-only; the store
|
||||
// tests carry the Postgres coverage.
|
||||
|
||||
type tabsFixture struct {
|
||||
srv *Server
|
||||
user *models.User
|
||||
token string
|
||||
}
|
||||
|
||||
func newTabsFixture(t *testing.T) tabsFixture {
|
||||
t.Helper()
|
||||
srv := testServer(t)
|
||||
user, err := srv.store.CreateUser(models.UserCreate{
|
||||
Email: "tabs@test.com", Name: "Tabs", Username: "tabber", Password: "correct-horse-battery-staple", Role: "member",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
token := loginUser(t, srv, "tabs@test.com", "correct-horse-battery-staple")
|
||||
return tabsFixture{srv, user, token}
|
||||
}
|
||||
|
||||
func (f tabsFixture) do(t *testing.T, method, path string, body any) (int, workspaceTabsResponse, map[string]any) {
|
||||
t.Helper()
|
||||
rec := doRequestWithCookie(f.srv, method, "/api/v1/me/workspace-tabs"+path, body, f.token)
|
||||
var tabs workspaceTabsResponse
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tabs)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
|
||||
return rec.Code, tabs, raw
|
||||
}
|
||||
|
||||
func errorDetails(raw map[string]any) map[string]any {
|
||||
e, _ := raw["error"].(map[string]any)
|
||||
d, _ := e["details"].(map[string]any)
|
||||
return d
|
||||
}
|
||||
|
||||
func tabSlugs(r workspaceTabsResponse) string {
|
||||
var s []string
|
||||
for _, tab := range r.Tabs {
|
||||
s = append(s, tab.Slug)
|
||||
}
|
||||
return strings.Join(s, ",")
|
||||
}
|
||||
|
||||
func TestWorkspaceTabsAPI_OpenReorderPatchClose(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
a := mustCreateOwnedWorkspace(t, f.srv, "Alpha", f.user)
|
||||
b := mustCreateOwnedWorkspace(t, f.srv, "Beta", f.user)
|
||||
c := mustCreateOwnedWorkspace(t, f.srv, "Gamma", f.user)
|
||||
|
||||
code, got, _ := f.do(t, "GET", "/", nil)
|
||||
if code != http.StatusOK || len(got.Tabs) != 0 {
|
||||
t.Fatalf("initial GET: %d %+v", code, got)
|
||||
}
|
||||
|
||||
if code, got, _ = f.do(t, "POST", "/", map[string]any{"slug": a.Slug}); code != http.StatusOK || tabSlugs(got) != a.Slug {
|
||||
t.Fatalf("open a: %d %s", code, tabSlugs(got))
|
||||
}
|
||||
f.do(t, "POST", "/", map[string]any{"slug": b.Slug, "ephemeral": true})
|
||||
code, got, _ = f.do(t, "POST", "/", map[string]any{"slug": c.Slug})
|
||||
if want := strings.Join([]string{a.Slug, b.Slug, c.Slug}, ","); code != http.StatusOK || tabSlugs(got) != want {
|
||||
t.Fatalf("after opens: %d %s, want %s", code, tabSlugs(got), want)
|
||||
}
|
||||
if !got.Tabs[1].Ephemeral || got.Tabs[0].Ephemeral {
|
||||
t.Fatalf("ephemeral flags wrong: %+v", got.Tabs)
|
||||
}
|
||||
|
||||
code, got, _ = f.do(t, "PUT", "/", []string{c.Slug, a.Slug, "no-such-workspace"})
|
||||
if want := strings.Join([]string{c.Slug, a.Slug, b.Slug}, ","); code != http.StatusOK || tabSlugs(got) != want {
|
||||
t.Fatalf("reorder: %d %s, want %s", code, tabSlugs(got), want)
|
||||
}
|
||||
|
||||
owner := got.Tabs[0].OwnerUsername
|
||||
if owner == "" {
|
||||
t.Fatal("tabs carry no owner_username; the route prefix cannot be built")
|
||||
}
|
||||
route := "/" + owner + "/" + b.Slug + "/tasks?view=board"
|
||||
code, got, _ = f.do(t, "PATCH", "/"+b.Slug, map[string]any{"pin": true, "last_route": route})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("patch: %d", code)
|
||||
}
|
||||
if tab := got.Tabs[2]; tab.Slug != b.Slug || tab.Ephemeral || tab.LastRoute != route {
|
||||
t.Fatalf("patch result: %+v", tab)
|
||||
}
|
||||
code, got, _ = f.do(t, "PATCH", "/"+b.Slug, map[string]any{"last_route": nil})
|
||||
if code != http.StatusOK || got.Tabs[2].LastRoute != "" {
|
||||
t.Fatalf("clear route: %d %+v", code, got.Tabs[2])
|
||||
}
|
||||
|
||||
code, got, _ = f.do(t, "DELETE", "/"+a.Slug, nil)
|
||||
if want := strings.Join([]string{c.Slug, b.Slug}, ","); code != http.StatusOK || tabSlugs(got) != want {
|
||||
t.Fatalf("close: %d %s, want %s", code, tabSlugs(got), want)
|
||||
}
|
||||
if code, _, raw := f.do(t, "PATCH", "/"+a.Slug, map[string]any{"pin": true}); code != http.StatusNotFound || errorDetails(raw) != nil {
|
||||
t.Fatalf("patch of a closed tab in a visible workspace: %d %v, want a 404 with no workspace marker", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTabsAPI_LastRouteIsSameWorkspaceOnly(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
b := mustCreateOwnedWorkspace(t, f.srv, "B", f.user)
|
||||
// A sibling whose slug extends b's, so a prefix check without the
|
||||
// trailing slash would accept b's routes pointing into it.
|
||||
bx := mustCreateOwnedWorkspace(t, f.srv, b.Slug+"x", f.user)
|
||||
_, got, _ := f.do(t, "POST", "/", map[string]any{"slug": b.Slug})
|
||||
owner := got.Tabs[0].OwnerUsername
|
||||
base := "/" + owner + "/" + b.Slug
|
||||
|
||||
for _, ok := range []string{base, base + "/", base + "/tasks", base + "/tasks/TASK-1?peek=1#c", base + "/settings", base + "/tasks?q=a%20b", base + "/caf%C3%A9", base + "/tasks?q=50%25"} {
|
||||
if code, _, raw := f.do(t, "PATCH", "/"+b.Slug, map[string]any{"last_route": ok}); code != http.StatusOK {
|
||||
t.Errorf("route %q refused: %d %v", ok, code, raw)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"/" + owner + "/" + bx.Slug + "/tasks",
|
||||
base + "x/tasks",
|
||||
"https://evil.example/" + owner + "/" + b.Slug,
|
||||
"//evil.example" + base,
|
||||
base + "/../other/tasks",
|
||||
base + "/./tasks",
|
||||
base + "//evil.example",
|
||||
base + "\\tasks",
|
||||
base + "/tasks\n",
|
||||
base + "/t asks",
|
||||
"/someoneelse/" + b.Slug + "/tasks",
|
||||
"tasks",
|
||||
base + "/" + strings.Repeat("a", maxWorkspaceTabLastRouteLen),
|
||||
// Percent-encoded forms a URL parser decodes (codex round 1).
|
||||
base + "/%2e%2e/other/tasks",
|
||||
base + "/%2E%2E/other/tasks",
|
||||
base + "/.%2e/other/tasks",
|
||||
base + "/%2e./other/tasks",
|
||||
base + "/%2e/tasks",
|
||||
base + "/%2F%2Fevil.example",
|
||||
base + "/%5ctasks",
|
||||
base + "/tasks%00",
|
||||
base + "/tasks%0a",
|
||||
base + "/t%20asks",
|
||||
base + "/%zz",
|
||||
base + "/%252e%252e/other/tasks",
|
||||
base + "/%25",
|
||||
} {
|
||||
code, _, raw := f.do(t, "PATCH", "/"+b.Slug, map[string]any{"last_route": bad})
|
||||
if code != http.StatusBadRequest || raw["error"] == nil {
|
||||
t.Errorf("route %q: %d %v, want 400", bad, code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, body := range []map[string]any{{}, {"pin": false}, {"unknown": 1}} {
|
||||
if code, _, _ := f.do(t, "PATCH", "/"+b.Slug, body); code != http.StatusBadRequest {
|
||||
t.Errorf("PATCH %v: %d, want 400", body, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace the caller cannot see answers exactly what an unknown slug
|
||||
// answers, on every door, and never lands in the open set.
|
||||
func TestWorkspaceTabsAPI_InvisibleWorkspaceIsNotAnOracle(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
other := mustCreateUser(t, f.srv, "other@test.com", "Other", "member")
|
||||
theirs := mustCreateOwnedWorkspace(t, f.srv, "Theirs", other)
|
||||
mine := mustCreateOwnedWorkspace(t, f.srv, "Mine", f.user)
|
||||
f.do(t, "POST", "/", map[string]any{"slug": mine.Slug})
|
||||
|
||||
for _, slug := range []string{theirs.Slug, "no-such-workspace"} {
|
||||
code, _, raw := f.do(t, "POST", "/", map[string]any{"slug": slug})
|
||||
details := errorDetails(raw)
|
||||
if code != http.StatusNotFound || details["scope"] != "workspace" {
|
||||
t.Fatalf("open %s: %d %v, want the workspace 404", slug, code, raw)
|
||||
}
|
||||
code, _, raw = f.do(t, "PATCH", "/"+slug, map[string]any{"pin": true})
|
||||
details = errorDetails(raw)
|
||||
if code != http.StatusNotFound || details["scope"] != "workspace" {
|
||||
t.Fatalf("patch %s: %d %v, want the workspace 404", slug, code, raw)
|
||||
}
|
||||
code, got, _ := f.do(t, "DELETE", "/"+slug, nil)
|
||||
if code != http.StatusOK || tabSlugs(got) != mine.Slug {
|
||||
t.Fatalf("close %s: %d %s", slug, code, tabSlugs(got))
|
||||
}
|
||||
}
|
||||
if code, got, _ := f.do(t, "PUT", "/", []string{theirs.Slug, mine.Slug}); code != http.StatusOK || tabSlugs(got) != mine.Slug {
|
||||
t.Fatalf("reorder naming theirs: %d %s", code, tabSlugs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant is enforced on READ: a row whose workspace the caller lost by
|
||||
// a path that never pruned it is not served.
|
||||
func TestWorkspaceTabsAPI_ReadFilterHidesALostWorkspace(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
owner := mustCreateUser(t, f.srv, "owner@test.com", "Owner", "member")
|
||||
ws := mustCreateOwnedWorkspace(t, f.srv, "Shared", owner)
|
||||
if err := f.srv.store.AddWorkspaceMember(ws.ID, f.user.ID, "editor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, got, _ := f.do(t, "POST", "/", map[string]any{"slug": ws.Slug}); code != http.StatusOK || tabSlugs(got) != ws.Slug {
|
||||
t.Fatalf("open: %d %s", code, tabSlugs(got))
|
||||
}
|
||||
// Remove the membership underneath the store's loss path, so the row
|
||||
// stays behind.
|
||||
if _, err := f.srv.store.DB().Exec(`DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, ws.ID, f.user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := f.srv.store.ListWorkspaceTabRows(f.user.ID)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("precondition: the stale row must still be stored: %v %v", rows, err)
|
||||
}
|
||||
if code, got, _ := f.do(t, "GET", "/", nil); code != http.StatusOK || len(got.Tabs) != 0 {
|
||||
t.Fatalf("GET served a lost workspace: %d %+v", code, got)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner with no username (legal: usernames arrived in migration 029 with
|
||||
// an empty default) has no /{owner}/{ws} prefix, and the path the web app
|
||||
// would build, //{ws}, is protocol-relative. No last_route is stored for it.
|
||||
func TestWorkspaceTabsAPI_NoLastRouteWithoutAnOwnerUsername(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
legacy := mustCreateUser(t, f.srv, "legacy@test.com", "Legacy", "member")
|
||||
ws := mustCreateOwnedWorkspace(t, f.srv, "Old", legacy)
|
||||
if err := f.srv.store.AddWorkspaceMember(ws.ID, f.user.ID, "editor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, got, _ := f.do(t, "POST", "/", map[string]any{"slug": ws.Slug})
|
||||
if len(got.Tabs) != 1 || got.Tabs[0].OwnerUsername != "" {
|
||||
t.Fatalf("precondition: an ownerless-username tab: %+v", got.Tabs)
|
||||
}
|
||||
for _, route := range []string{"//" + ws.Slug + "/tasks", "/" + ws.Slug + "/tasks"} {
|
||||
if code, _, _ := f.do(t, "PATCH", "/"+ws.Slug, map[string]any{"last_route": route}); code != http.StatusBadRequest {
|
||||
t.Errorf("route %q: %d, want 400", route, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTabsAPI_RequiresAUser(t *testing.T) {
|
||||
f := newTabsFixture(t)
|
||||
rec := doRequest(f.srv, "GET", "/api/v1/me/workspace-tabs/", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous GET: %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1757,6 +1757,18 @@ func (s *Server) setupRouter() {
|
||||
// CLI session) and 412s when the claim secret isn't wired.
|
||||
r.Post("/oauth/claim", s.handleOAuthClaim)
|
||||
|
||||
// The caller's open set of workspace tabs (PLAN-3002 U1 /
|
||||
// TASK-3256). User-scoped, outside the /{slug} access subrouter;
|
||||
// each handler checks the workspace against the caller's own
|
||||
// visible set. Web client only.
|
||||
r.Route("/me/workspace-tabs", func(r chi.Router) {
|
||||
r.Get("/", s.handleListWorkspaceTabs)
|
||||
r.Post("/", s.handleOpenWorkspaceTab)
|
||||
r.Put("/", s.handleReorderWorkspaceTabs)
|
||||
r.Delete("/{slug}", s.handleCloseWorkspaceTab)
|
||||
r.Patch("/{slug}", s.handleUpdateWorkspaceTab)
|
||||
})
|
||||
|
||||
// Workspaces
|
||||
r.Route("/workspaces", func(r chi.Router) {
|
||||
r.Get("/", s.handleListWorkspaces)
|
||||
|
||||
+50
-13
@@ -83,16 +83,30 @@ func (s *Store) ListCollectionGrants(collectionID string) ([]models.CollectionGr
|
||||
}
|
||||
|
||||
// DeleteCollectionGrant revokes a collection grant by ID, scoped to a workspace.
|
||||
//
|
||||
// When it was the grantee's last access to the workspace, their workspace
|
||||
// tab goes with it (TASK-3256).
|
||||
func (s *Store) DeleteCollectionGrant(id, workspaceID string) error {
|
||||
result, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete collection grant: %w", err)
|
||||
return fmt.Errorf("delete collection grant: begin: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
defer tx.Rollback()
|
||||
var userID string
|
||||
err = tx.QueryRow(s.q("SELECT user_id FROM collection_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID).Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete collection grant: read: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(s.q("DELETE FROM collection_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID); err != nil {
|
||||
return fmt.Errorf("delete collection grant: %w", err)
|
||||
}
|
||||
if err := s.pruneWorkspaceTabIfNoAccessTx(tx, userID, workspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Item Grants ---
|
||||
@@ -171,16 +185,30 @@ func (s *Store) ListItemGrants(itemID string) ([]models.ItemGrant, error) {
|
||||
}
|
||||
|
||||
// DeleteItemGrant revokes an item grant by ID, scoped to a workspace.
|
||||
//
|
||||
// When it was the grantee's last access to the workspace, their workspace
|
||||
// tab goes with it (TASK-3256).
|
||||
func (s *Store) DeleteItemGrant(id, workspaceID string) error {
|
||||
result, err := s.db.Exec(s.q("DELETE FROM item_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete item grant: %w", err)
|
||||
return fmt.Errorf("delete item grant: begin: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
defer tx.Rollback()
|
||||
var userID string
|
||||
err = tx.QueryRow(s.q("SELECT user_id FROM item_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID).Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete item grant: read: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(s.q("DELETE FROM item_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID); err != nil {
|
||||
return fmt.Errorf("delete item grant: %w", err)
|
||||
}
|
||||
if err := s.pruneWorkspaceTabIfNoAccessTx(tx, userID, workspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Cross-cutting queries ---
|
||||
@@ -260,16 +288,25 @@ func (s *Store) listUserItemGrants(workspaceID, userID string) ([]models.ItemGra
|
||||
|
||||
// RevokeAllUserGrants deletes all collection and item grants for a user in a workspace.
|
||||
// Used when removing a member with "revoke all access" option.
|
||||
// The user's workspace tab goes too when they are not a member (TASK-3256).
|
||||
func (s *Store) RevokeAllUserGrants(workspaceID, userID string) error {
|
||||
_, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke grants: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.Exec(s.q("DELETE FROM collection_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke collection grants: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(s.q("DELETE FROM item_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
||||
_, err = tx.Exec(s.q("DELETE FROM item_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke item grants: %w", err)
|
||||
}
|
||||
return nil
|
||||
if err := s.pruneWorkspaceTabIfNoAccessTx(tx, userID, workspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ResolveUserPermission resolves the effective permission for a user on a specific
|
||||
|
||||
@@ -150,6 +150,7 @@ var migratedRefusalTables = []string{
|
||||
"share_links",
|
||||
"progress_snapshots",
|
||||
"user_report_layouts",
|
||||
"user_workspace_tabs",
|
||||
"item_grants",
|
||||
"collection_grants",
|
||||
"workspace_members",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Migration 098: the per-user open set of workspace tabs (PLAN-3002 U1 / TASK-3256).
|
||||
--
|
||||
-- One row per (user, workspace) the user has OPEN in the workspace tab bar.
|
||||
-- The design is on PLAN-3002's trail (§2 storage, §3 seed); Dave's rulings Q1
|
||||
-- (the same open set on every device, so the whole row is server-side) and Q8
|
||||
-- (seed 6) are what this migration encodes.
|
||||
--
|
||||
-- position the tab's place in the bar, per user. It is its own order and
|
||||
-- does NOT drive workspace_members.sort_order, which stays the
|
||||
-- membership order the CLI, MCP and account export read.
|
||||
-- ephemeral a tab opened by a landing (deep link, invitation accept) that
|
||||
-- has not been kept yet. At most one per user; the store enforces
|
||||
-- it under the per-user write rather than with a partial index.
|
||||
-- last_route the in-workspace path the tab returns to. The server accepts
|
||||
-- only a path under the workspace's own /{owner}/{ws}/ prefix.
|
||||
--
|
||||
-- Keyed on users and workspaces, NOT workspace_members, so a guest (who has no
|
||||
-- member row) can hold a tab. The invariant "a tab never names a workspace the
|
||||
-- user cannot see" is enforced on READ; the loss paths also delete rows, as
|
||||
-- hygiene.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_workspace_tabs (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
ephemeral INTEGER NOT NULL DEFAULT 0,
|
||||
last_route TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, workspace_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workspace_tabs_workspace ON user_workspace_tabs(workspace_id);
|
||||
|
||||
-- Seed: each user's first six live member workspaces, in the order the bar
|
||||
-- shows them today (workspace_members.sort_order, then name), as durable tabs.
|
||||
-- Guests get nothing; their first landing opens an ephemeral tab.
|
||||
INSERT OR IGNORE INTO user_workspace_tabs (user_id, workspace_id, position, ephemeral, last_route, created_at, updated_at)
|
||||
SELECT user_id, workspace_id, rn - 1, 0, NULL, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
FROM (
|
||||
SELECT wm.user_id AS user_id, wm.workspace_id AS workspace_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY wm.user_id ORDER BY wm.sort_order, w.name, w.id) AS rn
|
||||
FROM workspace_members wm
|
||||
JOIN workspaces w ON w.id = wm.workspace_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
) ranked
|
||||
WHERE rn <= 6;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Layer B of the NUL invariant (DOC-2823 S2), for columns added after 084.
|
||||
--
|
||||
-- GENERATED from internal/store/nulcolumns.go. Do not edit by hand — run
|
||||
-- GEN_NUL_TRIGGERS=1 go test ./internal/store/ -run TestGenerateNULTriggerMigration
|
||||
-- and commit the result. TestNULTriggersMatchTheList fails if this file and the
|
||||
-- Go list disagree.
|
||||
--
|
||||
-- WHY A SEPARATE FILE (BUG-3108). A trigger can only follow its column. 084
|
||||
-- runs before these columns exist, and SQLite re-parses every trigger during
|
||||
-- an ALTER TABLE ... RENAME, so a trigger there naming a later column breaks
|
||||
-- the next table rebuild. The predicate, the marker and the SQLite-only scope
|
||||
-- are 084's; its header carries the measurements.
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS pad_nul099_user_workspace_tabs_last_route_ins
|
||||
BEFORE INSERT ON user_workspace_tabs
|
||||
FOR EACH ROW WHEN NEW.last_route IS NOT NULL AND (
|
||||
instr(NEW.last_route, char(0)) > 0
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'pad_nul_invariant: user_workspace_tabs.last_route must not contain a NUL');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS pad_nul099_user_workspace_tabs_last_route_upd
|
||||
BEFORE UPDATE OF last_route ON user_workspace_tabs
|
||||
FOR EACH ROW WHEN NEW.last_route IS NOT NULL AND (
|
||||
instr(NEW.last_route, char(0)) > 0
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'pad_nul_invariant: user_workspace_tabs.last_route must not contain a NUL');
|
||||
END;
|
||||
|
||||
@@ -251,6 +251,10 @@ user_report_layouts.created_at
|
||||
user_report_layouts.updated_at
|
||||
user_report_layouts.user_id
|
||||
user_report_layouts.workspace_id
|
||||
user_workspace_tabs.created_at
|
||||
user_workspace_tabs.updated_at
|
||||
user_workspace_tabs.user_id
|
||||
user_workspace_tabs.workspace_id
|
||||
users.created_at
|
||||
users.disabled_at
|
||||
users.email_verified_at
|
||||
|
||||
@@ -269,6 +269,11 @@ var nulColumns = []nulColumn{
|
||||
// but a config-file string can decode a \u0000 escape to a real NUL, so
|
||||
// the copy is not covered by its source. Second ring.
|
||||
{"item_decisions", "model", classText},
|
||||
// A workspace tab's last_route is the request body's `last_route`
|
||||
// (TASK-3256). The handler refuses control characters, NUL among them,
|
||||
// before the store sees it; the trigger holds the rule for every writer.
|
||||
// The table's other text columns are ids and timestamps the server mints.
|
||||
{"user_workspace_tabs", "last_route", classText},
|
||||
}
|
||||
|
||||
// nulTriggerMigrations are the generated trigger files, in migration order.
|
||||
@@ -290,6 +295,7 @@ var nulColumns = []nulColumn{
|
||||
var nulTriggerMigrations = []string{
|
||||
"084_nul_invariant_triggers.sql",
|
||||
"094_nul_invariant_triggers_post084.sql",
|
||||
"099_nul_invariant_triggers_post098.sql",
|
||||
}
|
||||
|
||||
// nulColumnTriggerFile assigns a column to a trigger file other than the
|
||||
@@ -297,6 +303,8 @@ var nulTriggerMigrations = []string{
|
||||
var nulColumnTriggerFile = map[string]string{
|
||||
"items.lease_holder": "094_nul_invariant_triggers_post084.sql",
|
||||
"item_decisions.model": "094_nul_invariant_triggers_post084.sql",
|
||||
// 098 introduced the table (TASK-3256).
|
||||
"user_workspace_tabs.last_route": "099_nul_invariant_triggers_post098.sql",
|
||||
}
|
||||
|
||||
// nulTriggerFileFor names the trigger file a column's triggers are rendered
|
||||
|
||||
@@ -161,8 +161,8 @@ func (r *NULScanReport) ByColumn() map[string]int {
|
||||
// bug's trail.
|
||||
//
|
||||
// COST, stated because an operator should not be surprised by it: one
|
||||
// unindexed scan per protected column — 133 of them today (24 JSON-classed,
|
||||
// 109 text), measured from NULProtectedColumns rather than counted by hand.
|
||||
// unindexed scan per protected column — 134 of them today (24 JSON-classed,
|
||||
// 110 text), measured from NULProtectedColumns rather than counted by hand.
|
||||
// There is no index that would help, since the predicate is a substring search
|
||||
// over the value. That is cheap next to the migration it guards, which reads
|
||||
// every row of every table anyway.
|
||||
|
||||
@@ -336,7 +336,7 @@ func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||
func TestScanCostFiguresMatchTheList(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
wantTotal = 133
|
||||
wantTotal = 134
|
||||
wantJSON = 24
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migration 073 (Postgres): the per-user open set of workspace tabs (PLAN-3002 U1 / TASK-3256).
|
||||
-- Postgres counterpart to migrations/098_user_workspace_tabs.sql.
|
||||
--
|
||||
-- One row per (user, workspace) the user has OPEN in the workspace tab bar.
|
||||
-- The design is on PLAN-3002's trail (§2 storage, §3 seed); Dave's rulings Q1
|
||||
-- (the same open set on every device, so the whole row is server-side) and Q8
|
||||
-- (seed 6) are what this migration encodes.
|
||||
--
|
||||
-- position the tab's place in the bar, per user. It is its own order and
|
||||
-- does NOT drive workspace_members.sort_order, which stays the
|
||||
-- membership order the CLI, MCP and account export read.
|
||||
-- ephemeral a tab opened by a landing (deep link, invitation accept) that
|
||||
-- has not been kept yet. At most one per user; the store enforces
|
||||
-- it under the per-user write rather than with a partial index.
|
||||
-- last_route the in-workspace path the tab returns to. The server accepts
|
||||
-- only a path under the workspace's own /{owner}/{ws}/ prefix.
|
||||
--
|
||||
-- Keyed on users and workspaces, NOT workspace_members, so a guest (who has no
|
||||
-- member row) can hold a tab. The invariant "a tab never names a workspace the
|
||||
-- user cannot see" is enforced on READ; the loss paths also delete rows, as
|
||||
-- hygiene.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_workspace_tabs (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
ephemeral BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_route TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, workspace_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workspace_tabs_workspace ON user_workspace_tabs(workspace_id);
|
||||
|
||||
-- Seed: each user's first six live member workspaces, in the order the bar
|
||||
-- shows them today (workspace_members.sort_order, then name), as durable tabs.
|
||||
-- Guests get nothing; their first landing opens an ephemeral tab.
|
||||
INSERT INTO user_workspace_tabs (user_id, workspace_id, position, ephemeral, last_route, created_at, updated_at)
|
||||
SELECT user_id, workspace_id, rn - 1, FALSE, NULL, to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM (
|
||||
SELECT wm.user_id AS user_id, wm.workspace_id AS workspace_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY wm.user_id ORDER BY wm.sort_order, w.name, w.id) AS rn
|
||||
FROM workspace_members wm
|
||||
JOIN workspaces w ON w.id = wm.workspace_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
) ranked
|
||||
WHERE rn <= 6
|
||||
ON CONFLICT (user_id, workspace_id) DO NOTHING;
|
||||
+10
-1
@@ -963,7 +963,8 @@ func (s *Store) DeleteUser(id string) error {
|
||||
// worth de-identifying (sessions, tokens, memberships, sent invitations,
|
||||
// issued grants, created share links, MCP audit, OAuth connections).
|
||||
// - Cascade: rows the schema already removes/nulls at DELETE FROM users
|
||||
// time — item_stars, user_report_layouts, {collection,item}_grants.user_id
|
||||
// time — item_stars, user_report_layouts, user_workspace_tabs,
|
||||
// {collection,item}_grants.user_id
|
||||
// (ON DELETE CASCADE); items.assigned_user_id and activities.user_id
|
||||
// (ON DELETE SET NULL; activities gained its FK action in migrations
|
||||
// 072/050).
|
||||
@@ -1009,6 +1010,14 @@ func (s *Store) DeleteAccountAtomic(userID string) error {
|
||||
`), ts, ts, userID); err != nil {
|
||||
return fmt.Errorf("delete account: delete owned workspaces: %w", err)
|
||||
}
|
||||
// Other users' tabs for those workspaces go with them, as on any soft
|
||||
// delete (TASK-3256). Every owned workspace is soft-deleted by now.
|
||||
if _, err := tx.Exec(s.q(`
|
||||
DELETE FROM user_workspace_tabs
|
||||
WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_id = ?)
|
||||
`), userID); err != nil {
|
||||
return fmt.Errorf("delete account: delete tabs of owned workspaces: %w", err)
|
||||
}
|
||||
|
||||
// exec runs one cleanup statement keyed on userID, wrapping the error with
|
||||
// context. Each statement clears a reference to the user so the final
|
||||
|
||||
@@ -77,8 +77,16 @@ func (s *Store) AddWorkspaceMember(workspaceID, userID, role string, opts ...Min
|
||||
}
|
||||
|
||||
// RemoveWorkspaceMember removes a user from a workspace.
|
||||
//
|
||||
// The user's workspace tab goes with it when no grant keeps them in the
|
||||
// workspace (TASK-3256).
|
||||
func (s *Store) RemoveWorkspaceMember(workspaceID, userID string) error {
|
||||
result, err := s.db.Exec(
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.Exec(
|
||||
s.q("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?"),
|
||||
workspaceID, userID,
|
||||
)
|
||||
@@ -89,7 +97,10 @@ func (s *Store) RemoveWorkspaceMember(workspaceID, userID string) error {
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
if err := s.pruneWorkspaceTabIfNoAccessTx(tx, userID, workspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RemoveWorkspaceMemberAndRevokeGrants atomically removes a user from a workspace
|
||||
@@ -119,6 +130,9 @@ func (s *Store) RemoveWorkspaceMemberAndRevokeGrants(workspaceID, userID string)
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err := s.pruneWorkspaceTabIfNoAccessTx(tx, userID, workspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ var purgeWorkspaceChildDeletes = []struct{ what, query string }{
|
||||
{"share links", `DELETE FROM share_links WHERE workspace_id = ?`},
|
||||
{"oauth connection workspaces", `DELETE FROM oauth_connection_workspaces WHERE workspace_id = ?`},
|
||||
{"user report layouts", `DELETE FROM user_report_layouts WHERE workspace_id = ?`},
|
||||
{"user workspace tabs", `DELETE FROM user_workspace_tabs WHERE workspace_id = ?`},
|
||||
{"workspace members", `DELETE FROM workspace_members WHERE workspace_id = ?`},
|
||||
{"attachments", `DELETE FROM attachments WHERE workspace_id = ?`},
|
||||
{"detach mcp audit log", `UPDATE mcp_audit_log SET workspace_id = NULL WHERE workspace_id = ?`},
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// The per-user open set of workspace tabs (PLAN-3002 U1 / TASK-3256). The
|
||||
// design is on PLAN-3002's trail, §2 and §3.
|
||||
//
|
||||
// These methods store ROWS; they do not decide visibility. A row may name a
|
||||
// workspace the user can no longer see (a loss path nobody wired, a grant
|
||||
// that lapsed with its collection), so the caller must intersect what it reads
|
||||
// with the user's visible set before serving it: the invariant "a tab never
|
||||
// names a workspace the caller cannot open" is enforced on read, by
|
||||
// VisibleWorkspaceTabs. The deletes on the known loss paths are hygiene.
|
||||
//
|
||||
// Every write locks the user's row first (lockUserTabsTx), so two writes for
|
||||
// one user serialise. That is what keeps "at most one ephemeral tab per user"
|
||||
// true under concurrent opens without a partial unique index, which the two
|
||||
// dialects spell differently.
|
||||
|
||||
// WorkspaceTabRow is a stored user_workspace_tabs row.
|
||||
type WorkspaceTabRow struct {
|
||||
WorkspaceID string
|
||||
Position int
|
||||
Ephemeral bool
|
||||
LastRoute string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// ListWorkspaceTabRows returns the user's stored tab rows in bar order. It
|
||||
// does not filter by visibility; see the note above.
|
||||
func (s *Store) ListWorkspaceTabRows(userID string) ([]WorkspaceTabRow, error) {
|
||||
return listWorkspaceTabRowsQ(s, s.db, userID)
|
||||
}
|
||||
|
||||
func listWorkspaceTabRowsQ(s *Store, q Queryer, userID string) ([]WorkspaceTabRow, error) {
|
||||
rows, err := q.Query(s.q(`
|
||||
SELECT workspace_id, position, ephemeral, COALESCE(last_route, ''), created_at, updated_at
|
||||
FROM user_workspace_tabs
|
||||
WHERE user_id = ?
|
||||
ORDER BY position ASC, created_at ASC, workspace_id ASC
|
||||
`), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workspace tabs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WorkspaceTabRow
|
||||
for rows.Next() {
|
||||
var r WorkspaceTabRow
|
||||
if err := rows.Scan(&r.WorkspaceID, &r.Position, &r.Ephemeral, &r.LastRoute, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan workspace tab: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// VisibleWorkspaceTabs intersects stored rows with the caller's visible
|
||||
// workspace set, in row order. visible must be exactly what the caller may
|
||||
// list (GetUserWorkspaces, after any token allow-list filter); a row whose
|
||||
// workspace is not in it is dropped, never served.
|
||||
func VisibleWorkspaceTabs(rows []WorkspaceTabRow, visible []models.Workspace) []models.WorkspaceTab {
|
||||
byID := make(map[string]*models.Workspace, len(visible))
|
||||
for i := range visible {
|
||||
byID[visible[i].ID] = &visible[i]
|
||||
}
|
||||
out := make([]models.WorkspaceTab, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ws, ok := byID[r.WorkspaceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, models.WorkspaceTab{
|
||||
WorkspaceID: r.WorkspaceID,
|
||||
Slug: ws.Slug,
|
||||
Name: ws.Name,
|
||||
OwnerUsername: ws.OwnerUsername,
|
||||
IsGuest: ws.IsGuest,
|
||||
Position: r.Position,
|
||||
Ephemeral: r.Ephemeral,
|
||||
LastRoute: r.LastRoute,
|
||||
CreatedAt: parseTime(r.CreatedAt),
|
||||
UpdatedAt: parseTime(r.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lockUserTabsTx takes the per-user write lock every tab write holds. On
|
||||
// Postgres it is the users-row lock enforceUserLimitTx takes (FOR NO KEY
|
||||
// UPDATE, which does not stall the FK checks of inserts referencing the
|
||||
// user). On SQLite the transaction is already BEGIN IMMEDIATE, which
|
||||
// serialises every writer; the read still proves the user exists.
|
||||
func (s *Store) lockUserTabsTx(tx *sql.Tx, userID string) error {
|
||||
lock := `SELECT id FROM users WHERE id = ?`
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
lock += ` FOR NO KEY UPDATE`
|
||||
}
|
||||
var id string
|
||||
if err := tx.QueryRow(s.q(lock), userID).Scan(&id); err != nil {
|
||||
return fmt.Errorf("workspace tabs: lock user %s: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenWorkspaceTab adds a workspace to the user's open set.
|
||||
//
|
||||
// - Already open: a durable open pins an ephemeral tab (PLAN-3002 Q9); an
|
||||
// ephemeral open of a tab that is already there changes nothing, and never
|
||||
// demotes a durable tab.
|
||||
// - Not open, durable: appended at the end.
|
||||
// - Not open, ephemeral: it REPLACES the user's current ephemeral tab, in
|
||||
// that tab's position, so there is at most one. With no ephemeral tab it
|
||||
// is appended.
|
||||
//
|
||||
// The caller must already have checked that the user may see the workspace.
|
||||
func (s *Store) OpenWorkspaceTab(userID, workspaceID string, ephemeral bool) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open workspace tab: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.lockUserTabsTx(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
ts := now()
|
||||
|
||||
var existingEphemeral bool
|
||||
err = tx.QueryRow(s.q(`
|
||||
SELECT ephemeral FROM user_workspace_tabs WHERE user_id = ? AND workspace_id = ?
|
||||
`), userID, workspaceID).Scan(&existingEphemeral)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existingEphemeral && !ephemeral {
|
||||
if _, err := tx.Exec(s.q(`
|
||||
UPDATE user_workspace_tabs SET ephemeral = ?, updated_at = ?
|
||||
WHERE user_id = ? AND workspace_id = ?
|
||||
`), s.dialect.BoolToInt(false), ts, userID, workspaceID); err != nil {
|
||||
return fmt.Errorf("open workspace tab: pin: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
case err != sql.ErrNoRows:
|
||||
return fmt.Errorf("open workspace tab: read: %w", err)
|
||||
}
|
||||
|
||||
position := -1
|
||||
if ephemeral {
|
||||
var replaced string
|
||||
var replacedPos int
|
||||
err := tx.QueryRow(s.q(`
|
||||
SELECT workspace_id, position FROM user_workspace_tabs
|
||||
WHERE user_id = ? AND ephemeral = ?
|
||||
ORDER BY position ASC
|
||||
LIMIT 1
|
||||
`), userID, s.dialect.BoolToInt(true)).Scan(&replaced, &replacedPos)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return fmt.Errorf("open workspace tab: read ephemeral: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
position = replacedPos
|
||||
}
|
||||
// Every ephemeral row goes, not just the one read: "at most one" holds
|
||||
// even for a set that somehow held two.
|
||||
if _, err := tx.Exec(s.q(`
|
||||
DELETE FROM user_workspace_tabs WHERE user_id = ? AND ephemeral = ?
|
||||
`), userID, s.dialect.BoolToInt(true)); err != nil {
|
||||
return fmt.Errorf("open workspace tab: replace ephemeral: %w", err)
|
||||
}
|
||||
}
|
||||
if position < 0 {
|
||||
if err := tx.QueryRow(s.q(`
|
||||
SELECT COALESCE(MAX(position) + 1, 0) FROM user_workspace_tabs WHERE user_id = ?
|
||||
`), userID).Scan(&position); err != nil {
|
||||
return fmt.Errorf("open workspace tab: next position: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`
|
||||
INSERT INTO user_workspace_tabs (user_id, workspace_id, position, ephemeral, last_route, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?)
|
||||
`), userID, workspaceID, position, s.dialect.BoolToInt(ephemeral), ts, ts); err != nil {
|
||||
return fmt.Errorf("open workspace tab: insert: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CloseWorkspaceTab removes a workspace from the user's open set. Closing a
|
||||
// tab that is not open is not an error.
|
||||
func (s *Store) CloseWorkspaceTab(userID, workspaceID string) error {
|
||||
if _, err := s.db.Exec(s.q(`
|
||||
DELETE FROM user_workspace_tabs WHERE user_id = ? AND workspace_id = ?
|
||||
`), userID, workspaceID); err != nil {
|
||||
return fmt.Errorf("close workspace tab: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderWorkspaceTabs rewrites the user's tab positions. The workspaces in
|
||||
// order come first, in that order; ids that name no open tab are ignored.
|
||||
// Open tabs the list leaves out keep their relative order after it, so a
|
||||
// reorder that raced an open on another device does not drop that tab.
|
||||
func (s *Store) ReorderWorkspaceTabs(userID string, order []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reorder workspace tabs: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.lockUserTabsTx(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := listWorkspaceTabRowsQ(s, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open := make(map[string]bool, len(current))
|
||||
for _, r := range current {
|
||||
open[r.WorkspaceID] = true
|
||||
}
|
||||
placed := make(map[string]bool, len(current))
|
||||
next := make([]string, 0, len(current))
|
||||
for _, id := range order {
|
||||
if open[id] && !placed[id] {
|
||||
placed[id] = true
|
||||
next = append(next, id)
|
||||
}
|
||||
}
|
||||
for _, r := range current {
|
||||
if !placed[r.WorkspaceID] {
|
||||
next = append(next, r.WorkspaceID)
|
||||
}
|
||||
}
|
||||
ts := now()
|
||||
for i, id := range next {
|
||||
if _, err := tx.Exec(s.q(`
|
||||
UPDATE user_workspace_tabs SET position = ?, updated_at = ?
|
||||
WHERE user_id = ? AND workspace_id = ?
|
||||
`), i, ts, userID, id); err != nil {
|
||||
return fmt.Errorf("reorder workspace tabs: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// WorkspaceTabUpdate is a PATCH to one open tab. Nil / false members are left
|
||||
// alone.
|
||||
type WorkspaceTabUpdate struct {
|
||||
// Pin makes an ephemeral tab durable. There is no unpin.
|
||||
Pin bool
|
||||
// LastRoute replaces the stored route; an empty string clears it. The
|
||||
// caller validates it (the store does not know the workspace's prefix).
|
||||
LastRoute *string
|
||||
}
|
||||
|
||||
// UpdateWorkspaceTab applies u to the user's tab for workspaceID. It returns
|
||||
// sql.ErrNoRows when that workspace is not open.
|
||||
func (s *Store) UpdateWorkspaceTab(userID, workspaceID string, u WorkspaceTabUpdate) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update workspace tab: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.lockUserTabsTx(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
var ephemeral bool
|
||||
var lastRoute sql.NullString
|
||||
if err := tx.QueryRow(s.q(`
|
||||
SELECT ephemeral, last_route FROM user_workspace_tabs WHERE user_id = ? AND workspace_id = ?
|
||||
`), userID, workspaceID).Scan(&ephemeral, &lastRoute); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return fmt.Errorf("update workspace tab: read: %w", err)
|
||||
}
|
||||
if u.Pin {
|
||||
ephemeral = false
|
||||
}
|
||||
if u.LastRoute != nil {
|
||||
lastRoute = sql.NullString{String: *u.LastRoute, Valid: *u.LastRoute != ""}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`
|
||||
UPDATE user_workspace_tabs SET ephemeral = ?, last_route = ?, updated_at = ?
|
||||
WHERE user_id = ? AND workspace_id = ?
|
||||
`), s.dialect.BoolToInt(ephemeral), lastRoute, now(), userID, workspaceID); err != nil {
|
||||
return fmt.Errorf("update workspace tab: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// execer is the write half of *sql.DB and *sql.Tx.
|
||||
type execer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// pruneWorkspaceTabIfNoAccessTx deletes the user's tab for a workspace when
|
||||
// the user holds neither a membership nor any grant in it. It runs inside the
|
||||
// transaction of a per-user loss path (member removal, grant revoke), after
|
||||
// that path's own delete. Keeping the tab when any grant survives is
|
||||
// deliberate: a member demoted to guest still reaches the workspace, and the
|
||||
// read filter, not this delete, decides what a grant set can see.
|
||||
func (s *Store) pruneWorkspaceTabIfNoAccessTx(ex execer, userID, workspaceID string) error {
|
||||
if _, err := ex.Exec(s.q(`
|
||||
DELETE FROM user_workspace_tabs
|
||||
WHERE user_id = ? AND workspace_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM collection_grants WHERE workspace_id = ? AND user_id = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM item_grants WHERE workspace_id = ? AND user_id = ?)
|
||||
`), userID, workspaceID, workspaceID, userID, workspaceID, userID, workspaceID, userID); err != nil {
|
||||
return fmt.Errorf("prune workspace tab: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteWorkspaceTabsForWorkspace deletes every user's tab for a workspace.
|
||||
// Soft delete calls it; a restore does not bring the rows back (PLAN-3002 Q12:
|
||||
// the restorer gets an ephemeral tab from the landing, others reopen it).
|
||||
func (s *Store) deleteWorkspaceTabsForWorkspace(ex execer, workspaceID string) error {
|
||||
if _, err := ex.Exec(s.q(`
|
||||
DELETE FROM user_workspace_tabs WHERE workspace_id = ?
|
||||
`), workspaceID); err != nil {
|
||||
return fmt.Errorf("delete workspace tabs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// Tests for the per-user open set of workspace tabs (TASK-3256). They run on
|
||||
// whichever dialect testStore gives (SQLite by default, Postgres under
|
||||
// PAD_TEST_POSTGRES_URL): the store is where Postgres coverage for this unit
|
||||
// lives, since internal/server's testServer is SQLite-only.
|
||||
|
||||
func tabsUser(t *testing.T, s *Store, name string) *models.User {
|
||||
t.Helper()
|
||||
u, err := s.CreateUser(models.UserCreate{Name: name, Email: strings.ToLower(name) + "@example.com", Username: strings.ToLower(name)})
|
||||
if err != nil {
|
||||
t.Fatalf("create user %s: %v", name, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func tabsWorkspace(t *testing.T, s *Store, owner *models.User, name string) *models.Workspace {
|
||||
t.Helper()
|
||||
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: name, OwnerID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create workspace %s: %v", name, err)
|
||||
}
|
||||
if err := s.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
|
||||
t.Fatalf("add owner %s: %v", name, err)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
func tabIDs(t *testing.T, s *Store, userID string) []string {
|
||||
t.Helper()
|
||||
rows, err := s.ListWorkspaceTabRows(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list tabs: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.WorkspaceID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tabRow(t *testing.T, s *Store, userID, wsID string) (WorkspaceTabRow, bool) {
|
||||
t.Helper()
|
||||
rows, err := s.ListWorkspaceTabRows(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list tabs: %v", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.WorkspaceID == wsID {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return WorkspaceTabRow{}, false
|
||||
}
|
||||
|
||||
func sameIDs(a, b []string) bool {
|
||||
return strings.Join(a, ",") == strings.Join(b, ",")
|
||||
}
|
||||
|
||||
// runTabsSeed re-executes the tab migration against the store's current data.
|
||||
// The template database runs it before any row exists, so this is the only
|
||||
// way to exercise the seed; the file is idempotent (CREATE ... IF NOT EXISTS,
|
||||
// and the insert skips existing keys).
|
||||
func runTabsSeed(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
fsys, path := migrationsFS, "migrations/098_user_workspace_tabs.sql"
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
fsys, path = pgMigrationsFS, "pgmigrations/073_user_workspace_tabs.sql"
|
||||
}
|
||||
body, err := fsys.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(string(body)); err != nil {
|
||||
t.Fatalf("run migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seed rule (PLAN-3002 §3, Q8): each user's first six live member
|
||||
// workspaces, in (sort_order, name) order, as durable tabs; nothing for a
|
||||
// guest; a soft-deleted workspace is skipped rather than taking a slot.
|
||||
func TestWorkspaceTabs_MigrationSeed(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testStore(t)
|
||||
owner := tabsUser(t, s, "Seedowner")
|
||||
member := tabsUser(t, s, "Seedmember")
|
||||
guest := tabsUser(t, s, "Seedguest")
|
||||
|
||||
// Eight memberships, plus one soft-deleted one ordered FIRST, so a seed
|
||||
// that forgot deleted_at would put it in slot 0.
|
||||
var want []string
|
||||
for i := 0; i < 8; i++ {
|
||||
ws := tabsWorkspace(t, s, owner, fmt.Sprintf("Seed %d", i))
|
||||
if err := s.AddWorkspaceMember(ws.ID, member.ID, "editor"); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
// Reverse the name order with sort_order, so a seed that sorted by
|
||||
// name alone gets the wrong six.
|
||||
if err := s.UpdateWorkspaceSortOrder(member.ID, ws.ID, 10-i); err != nil {
|
||||
t.Fatalf("sort order: %v", err)
|
||||
}
|
||||
want = append([]string{ws.ID}, want...)
|
||||
}
|
||||
want = want[:6]
|
||||
gone := tabsWorkspace(t, s, owner, "Seed gone")
|
||||
if err := s.AddWorkspaceMember(gone.ID, member.ID, "editor"); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
if err := s.UpdateWorkspaceSortOrder(member.ID, gone.ID, -5); err != nil {
|
||||
t.Fatalf("sort order: %v", err)
|
||||
}
|
||||
if err := s.DeleteWorkspace(gone.Slug); err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
|
||||
// The guest reaches one workspace through a collection grant only.
|
||||
gws := tabsWorkspace(t, s, owner, "Seed guested")
|
||||
coll, err := s.CreateCollection(gws.ID, models.CollectionCreate{Name: "Tasks", Slug: "tasks", Prefix: "TSK", Schema: `{"fields":[]}`})
|
||||
if err != nil {
|
||||
t.Fatalf("create collection: %v", err)
|
||||
}
|
||||
if _, err := s.CreateCollectionGrant(gws.ID, coll.ID, guest.ID, "view", owner.ID); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(`DELETE FROM user_workspace_tabs`); err != nil {
|
||||
t.Fatalf("clear tabs: %v", err)
|
||||
}
|
||||
runTabsSeed(t, s)
|
||||
|
||||
got := tabIDs(t, s, member.ID)
|
||||
if !sameIDs(got, want) {
|
||||
t.Fatalf("member seed:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
rows, _ := s.ListWorkspaceTabRows(member.ID)
|
||||
for i, r := range rows {
|
||||
if r.Position != i || r.Ephemeral || r.LastRoute != "" {
|
||||
t.Fatalf("seeded row %d = %+v, want position %d, durable, no route", i, r, i)
|
||||
}
|
||||
}
|
||||
if got := tabIDs(t, s, guest.ID); len(got) != 0 {
|
||||
t.Fatalf("guest was seeded %v, want nothing", got)
|
||||
}
|
||||
|
||||
// Re-running it changes nothing: the insert skips existing keys.
|
||||
runTabsSeed(t, s)
|
||||
if got := tabIDs(t, s, member.ID); !sameIDs(got, want) {
|
||||
t.Fatalf("seed not idempotent: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTabs_OpenCloseReorderUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testStore(t)
|
||||
u := tabsUser(t, s, "Tabber")
|
||||
a := tabsWorkspace(t, s, u, "A")
|
||||
b := tabsWorkspace(t, s, u, "B")
|
||||
c := tabsWorkspace(t, s, u, "C")
|
||||
d := tabsWorkspace(t, s, u, "D")
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM user_workspace_tabs WHERE user_id = ?`), u.ID); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must(s.OpenWorkspaceTab(u.ID, a.ID, false))
|
||||
must(s.OpenWorkspaceTab(u.ID, b.ID, true))
|
||||
must(s.OpenWorkspaceTab(u.ID, c.ID, false))
|
||||
if got := tabIDs(t, s, u.ID); !sameIDs(got, []string{a.ID, b.ID, c.ID}) {
|
||||
t.Fatalf("after opens: %v", got)
|
||||
}
|
||||
|
||||
// A second ephemeral open REPLACES the first, in its position.
|
||||
must(s.OpenWorkspaceTab(u.ID, d.ID, true))
|
||||
if got := tabIDs(t, s, u.ID); !sameIDs(got, []string{a.ID, d.ID, c.ID}) {
|
||||
t.Fatalf("ephemeral replace: %v", got)
|
||||
}
|
||||
if r, _ := tabRow(t, s, u.ID, d.ID); !r.Ephemeral {
|
||||
t.Fatalf("d should be ephemeral: %+v", r)
|
||||
}
|
||||
|
||||
// An ephemeral open of a durable tab never demotes it.
|
||||
must(s.OpenWorkspaceTab(u.ID, a.ID, true))
|
||||
if r, _ := tabRow(t, s, u.ID, a.ID); r.Ephemeral {
|
||||
t.Fatalf("durable tab was demoted: %+v", r)
|
||||
}
|
||||
if r, _ := tabRow(t, s, u.ID, d.ID); !r.Ephemeral {
|
||||
t.Fatalf("re-opening a durable tab touched the ephemeral one: %+v", r)
|
||||
}
|
||||
|
||||
// A durable open of the ephemeral tab pins it in place.
|
||||
must(s.OpenWorkspaceTab(u.ID, d.ID, false))
|
||||
if r, _ := tabRow(t, s, u.ID, d.ID); r.Ephemeral {
|
||||
t.Fatalf("durable open did not pin: %+v", r)
|
||||
}
|
||||
if got := tabIDs(t, s, u.ID); !sameIDs(got, []string{a.ID, d.ID, c.ID}) {
|
||||
t.Fatalf("pin moved the tab: %v", got)
|
||||
}
|
||||
|
||||
// Reorder: listed first in that order, unknown ids ignored, unlisted
|
||||
// open tabs kept after in their old order.
|
||||
must(s.ReorderWorkspaceTabs(u.ID, []string{c.ID, "no-such-id", b.ID}))
|
||||
if got := tabIDs(t, s, u.ID); !sameIDs(got, []string{c.ID, a.ID, d.ID}) {
|
||||
t.Fatalf("reorder: %v", got)
|
||||
}
|
||||
// The positions are rewritten, not merely sorted into place: a tie
|
||||
// broken by created_at would hide a reorder that left rows unwritten.
|
||||
reordered, _ := s.ListWorkspaceTabRows(u.ID)
|
||||
for i, r := range reordered {
|
||||
if r.Position != i {
|
||||
t.Fatalf("after reorder, row %d (%s) has position %d", i, r.WorkspaceID, r.Position)
|
||||
}
|
||||
}
|
||||
|
||||
// Update: pin and route; an unopened workspace is ErrNoRows.
|
||||
must(s.OpenWorkspaceTab(u.ID, b.ID, true))
|
||||
route := "/tabber/b/tasks"
|
||||
must(s.UpdateWorkspaceTab(u.ID, b.ID, WorkspaceTabUpdate{Pin: true, LastRoute: &route}))
|
||||
if r, _ := tabRow(t, s, u.ID, b.ID); r.Ephemeral || r.LastRoute != route {
|
||||
t.Fatalf("update: %+v", r)
|
||||
}
|
||||
empty := ""
|
||||
must(s.UpdateWorkspaceTab(u.ID, b.ID, WorkspaceTabUpdate{LastRoute: &empty}))
|
||||
if r, _ := tabRow(t, s, u.ID, b.ID); r.LastRoute != "" {
|
||||
t.Fatalf("route not cleared: %+v", r)
|
||||
}
|
||||
must(s.CloseWorkspaceTab(u.ID, b.ID))
|
||||
if err := s.UpdateWorkspaceTab(u.ID, b.ID, WorkspaceTabUpdate{Pin: true}); err == nil || !strings.Contains(err.Error(), "no rows") {
|
||||
t.Fatalf("update of a closed tab: err %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
|
||||
// Close is idempotent.
|
||||
must(s.CloseWorkspaceTab(u.ID, b.ID))
|
||||
if got := tabIDs(t, s, u.ID); !sameIDs(got, []string{c.ID, a.ID, d.ID}) {
|
||||
t.Fatalf("after closes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// At most one ephemeral tab per user, under concurrent opens of different
|
||||
// workspaces (PLAN-3002 U1's proving test).
|
||||
func TestWorkspaceTabs_AtMostOneEphemeralUnderConcurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testStore(t)
|
||||
u := tabsUser(t, s, "Racer")
|
||||
var wss []*models.Workspace
|
||||
for i := 0; i < 8; i++ {
|
||||
wss = append(wss, tabsWorkspace(t, s, u, fmt.Sprintf("Race %d", i)))
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM user_workspace_tabs WHERE user_id = ?`), u.ID); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, len(wss))
|
||||
for _, ws := range wss {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs <- s.OpenWorkspaceTab(u.ID, id, true)
|
||||
}(ws.ID)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent open: %v", err)
|
||||
}
|
||||
}
|
||||
rows, err := s.ListWorkspaceTabRows(u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ephemeral := 0
|
||||
for _, r := range rows {
|
||||
if r.Ephemeral {
|
||||
ephemeral++
|
||||
}
|
||||
}
|
||||
if len(rows) != 1 || ephemeral != 1 {
|
||||
t.Fatalf("after %d concurrent ephemeral opens: %d rows, %d ephemeral; want 1 and 1", len(wss), len(rows), ephemeral)
|
||||
}
|
||||
}
|
||||
|
||||
// One test per loss path: the tab row is gone after it, and only the user who
|
||||
// lost access loses the tab.
|
||||
func TestWorkspaceTabs_LossPathsDeleteTheRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type fixture struct {
|
||||
s *Store
|
||||
owner *models.User
|
||||
member *models.User
|
||||
ws *models.Workspace
|
||||
coll *models.Collection
|
||||
}
|
||||
setup := func(t *testing.T) fixture {
|
||||
s := testStore(t)
|
||||
owner := tabsUser(t, s, "Lossowner")
|
||||
member := tabsUser(t, s, "Lossmember")
|
||||
ws := tabsWorkspace(t, s, owner, "Loss")
|
||||
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks", Slug: "tasks", Prefix: "TSK", Schema: `{"fields":[]}`})
|
||||
if err != nil {
|
||||
t.Fatalf("create collection: %v", err)
|
||||
}
|
||||
for _, uid := range []string{owner.ID, member.ID} {
|
||||
if err := s.OpenWorkspaceTab(uid, ws.ID, false); err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
}
|
||||
return fixture{s, owner, member, ws, coll}
|
||||
}
|
||||
has := func(t *testing.T, f fixture, userID string) bool {
|
||||
_, ok := tabRow(t, f.s, userID, f.ws.ID)
|
||||
return ok
|
||||
}
|
||||
grant := func(t *testing.T, f fixture) *models.CollectionGrant {
|
||||
g, err := f.s.CreateCollectionGrant(f.ws.ID, f.coll.ID, f.member.ID, "view", f.owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
itemGrant := func(t *testing.T, f fixture) *models.ItemGrant {
|
||||
it, err := f.s.CreateItem(f.ws.ID, f.coll.ID, models.ItemCreate{Title: "shared", Fields: `{}`})
|
||||
if err != nil {
|
||||
t.Fatalf("create item: %v", err)
|
||||
}
|
||||
g, err := f.s.CreateItemGrant(f.ws.ID, it.ID, f.member.ID, "view", f.owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("item grant: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
t.Run("member removal", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
if err := f.s.AddWorkspaceMember(f.ws.ID, f.member.ID, "editor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.RemoveWorkspaceMember(f.ws.ID, f.member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived member removal")
|
||||
}
|
||||
if !has(t, f, f.owner.ID) {
|
||||
t.Fatal("owner's tab went too")
|
||||
}
|
||||
})
|
||||
t.Run("member removal keeps the tab of a member who stays a guest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
if err := f.s.AddWorkspaceMember(f.ws.ID, f.member.ID, "editor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, f)
|
||||
if err := f.s.RemoveWorkspaceMember(f.ws.ID, f.member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has(t, f, f.member.ID) {
|
||||
t.Fatal("tab deleted although a grant keeps the user in the workspace")
|
||||
}
|
||||
})
|
||||
t.Run("member removal with grant revoke", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
if err := f.s.AddWorkspaceMember(f.ws.ID, f.member.ID, "editor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, f)
|
||||
if err := f.s.RemoveWorkspaceMemberAndRevokeGrants(f.ws.ID, f.member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived member removal with grant revoke")
|
||||
}
|
||||
})
|
||||
t.Run("guest's last collection grant revoked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
g := grant(t, f)
|
||||
if err := f.s.DeleteCollectionGrant(g.ID, f.ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived the guest's last grant")
|
||||
}
|
||||
})
|
||||
t.Run("guest's collection grant revoked while an item grant remains", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
g := grant(t, f)
|
||||
itemGrant(t, f)
|
||||
if err := f.s.DeleteCollectionGrant(g.ID, f.ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has(t, f, f.member.ID) {
|
||||
t.Fatal("tab deleted although an item grant remains")
|
||||
}
|
||||
})
|
||||
t.Run("guest's last item grant revoked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
g := itemGrant(t, f)
|
||||
if err := f.s.DeleteItemGrant(g.ID, f.ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived the guest's last item grant")
|
||||
}
|
||||
})
|
||||
t.Run("revoke all grants", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
grant(t, f)
|
||||
itemGrant(t, f)
|
||||
if err := f.s.RevokeAllUserGrants(f.ws.ID, f.member.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived revoke-all")
|
||||
}
|
||||
})
|
||||
t.Run("soft delete, and restore does not bring it back", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
if err := f.s.DeleteWorkspace(f.ws.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) || has(t, f, f.owner.ID) {
|
||||
t.Fatal("a tab survived soft delete")
|
||||
}
|
||||
if err := f.s.RestoreWorkspace(f.ws.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) || has(t, f, f.owner.ID) {
|
||||
t.Fatal("restore brought a tab back")
|
||||
}
|
||||
})
|
||||
t.Run("purge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
// Purge refuses a live workspace; soft-delete by hand so the tab
|
||||
// rows are still there when the purge runs.
|
||||
if _, err := f.s.db.Exec(f.s.q(`UPDATE workspaces SET deleted_at = ? WHERE id = ?`), now(), f.ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has(t, f, f.member.ID) {
|
||||
t.Fatal("precondition: the row must exist before the purge")
|
||||
}
|
||||
if err := f.s.PurgeWorkspaceData(f.ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("tab survived purge")
|
||||
}
|
||||
})
|
||||
t.Run("owner deletes their account", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := setup(t)
|
||||
if err := f.s.DeleteAccountAtomic(f.owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(t, f, f.member.ID) {
|
||||
t.Fatal("another user's tab survived the owner's account deletion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The read-side invariant: a stored row whose workspace is not in the visible
|
||||
// set is never served, whatever left it there.
|
||||
func TestVisibleWorkspaceTabs_FiltersToTheVisibleSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
rows := []WorkspaceTabRow{
|
||||
{WorkspaceID: "w1", Position: 0},
|
||||
{WorkspaceID: "gone", Position: 1},
|
||||
{WorkspaceID: "w2", Position: 2, Ephemeral: true, LastRoute: "/o/two/tasks"},
|
||||
}
|
||||
visible := []models.Workspace{
|
||||
{ID: "w2", Slug: "two", Name: "Two", OwnerUsername: "o", IsGuest: true},
|
||||
{ID: "w1", Slug: "one", Name: "One", OwnerUsername: "o"},
|
||||
}
|
||||
got := VisibleWorkspaceTabs(rows, visible)
|
||||
if len(got) != 2 || got[0].Slug != "one" || got[1].Slug != "two" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if !got[1].IsGuest || !got[1].Ephemeral || got[1].LastRoute != "/o/two/tasks" {
|
||||
t.Fatalf("fields not carried: %+v", got[1])
|
||||
}
|
||||
if out := VisibleWorkspaceTabs(rows, nil); len(out) != 0 {
|
||||
t.Fatalf("empty visible set served %v", out)
|
||||
}
|
||||
}
|
||||
@@ -329,12 +329,25 @@ func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mod
|
||||
return s.GetWorkspaceBySlug(slug)
|
||||
}
|
||||
|
||||
// DeleteWorkspace soft-deletes a workspace. Every user's tab for it goes in
|
||||
// the same transaction; a restore does not bring them back (TASK-3256,
|
||||
// PLAN-3002 Q12).
|
||||
func (s *Store) DeleteWorkspace(slug string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id string
|
||||
err = tx.QueryRow(s.q(`SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL`), slug).Scan(&id)
|
||||
if err != nil {
|
||||
return err // sql.ErrNoRows when no live workspace has the slug
|
||||
}
|
||||
ts := now()
|
||||
result, err := s.db.Exec(s.q(`
|
||||
result, err := tx.Exec(s.q(`
|
||||
UPDATE workspaces SET deleted_at = ?, updated_at = ?
|
||||
WHERE slug = ? AND deleted_at IS NULL
|
||||
`), ts, ts, slug)
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`), ts, ts, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -342,7 +355,10 @@ func (s *Store) DeleteWorkspace(slug string) error {
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
if err := s.deleteWorkspaceTabsForWorkspace(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RestoreWorkspace un-soft-deletes a workspace: it clears deleted_at so
|
||||
|
||||
Reference in New Issue
Block a user