Files
pad/internal/server/bug3335_document_restore_scope_test.go
T
xarmian daf94614ca fix(documents): restore only a document of the URL's workspace (BUG-3335) (#1724)
handleRestoreDocument called store.RestoreDocument(id), which read and
un-archived the document by ID alone and committed; only afterwards did
the handler compare workspaces, and then "compensated" with a separate
DeleteDocument. An editor of one workspace could restore another
workspace's archived document by ID, exposing it until the re-delete and
leaving its status, updated_at and attachment-reference stamps changed.

RestoreDocument now takes the workspace and scopes both its SELECT and
UPDATE by workspace_id, so a foreign document is sql.ErrNoRows and is
never written; the handler answers 404 for that, 500 for any other error,
and no longer compensates.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:15:31 -04:00

86 lines
3.3 KiB
Go

package server
import (
"database/sql"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3335: restoring a document is scoped to the workspace in the URL. An
// editor of workspace A naming an archived document of workspace B must get
// a 404 and leave B's document exactly as it was: still archived, same status
// and updated_at. The handler used to restore by ID, commit, and only then
// compare workspaces and re-delete as a separate write.
func TestBUG3335_RestoreDocumentIsScopedToTheURLWorkspace(t *testing.T) {
srv := testServer(t)
ownerCookie := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
mkWS := func(name string) *models.Workspace {
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{"name": name}, ownerCookie)
if rr.Code != http.StatusCreated {
t.Fatalf("create %s: %d %s", name, rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
return &ws
}
wsA, wsB := mkWS("Alpha"), mkWS("Bravo")
editor, err := srv.store.CreateUser(models.UserCreate{Email: "editor@test.com", Name: "Editor", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
if err := srv.store.AddWorkspaceMember(wsA.ID, editor.ID, "editor"); err != nil {
t.Fatal(err)
}
editorCookie, err := srv.store.CreateSession(editor.ID, "web-test", "192.0.2.1", "", webSessionTTL)
if err != nil {
t.Fatal(err)
}
doc, err := srv.store.CreateDocument(wsB.ID, models.DocumentCreate{Title: "B secret", Content: "b body", Status: "active"})
if err != nil {
t.Fatal(err)
}
if err := srv.store.DeleteDocument(doc.ID); err != nil {
t.Fatal(err)
}
read := func() (deleted bool, status, updated string) {
t.Helper()
var deletedAt sql.NullString
if err := srv.store.DB().QueryRow(`SELECT deleted_at, status, updated_at FROM documents WHERE id = ?`, doc.ID).
Scan(&deletedAt, &status, &updated); err != nil {
t.Fatal(err)
}
return deletedAt.Valid, status, updated
}
// updated_at has one-second resolution; backdate it so a write in this
// same second (restore, then the compensating re-delete) is visible.
if _, err := srv.store.DB().Exec(`UPDATE documents SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?`, doc.ID); err != nil {
t.Fatal(err)
}
wasDeleted, wasStatus, wasUpdated := read()
if !wasDeleted {
t.Fatal("precondition: the document should be archived")
}
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+wsA.Slug+"/documents/"+doc.ID+"/restore", nil, editorCookie)
if rr.Code != http.StatusNotFound {
t.Fatalf("cross-workspace restore: status %d %s, want 404", rr.Code, rr.Body.String())
}
if deleted, status, updated := read(); !deleted || status != wasStatus || updated != wasUpdated {
t.Fatalf("B's document changed: deleted=%v status=%q updated=%q, want deleted=true status=%q updated=%q",
deleted, status, updated, wasStatus, wasUpdated)
}
// Control: restoring through the document's own workspace still works.
rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+wsB.Slug+"/documents/"+doc.ID+"/restore", nil, ownerCookie)
if rr.Code != http.StatusOK {
t.Fatalf("own-workspace restore: status %d %s, want 200", rr.Code, rr.Body.String())
}
if deleted, _, _ := read(); deleted {
t.Fatal("own-workspace restore left the document archived")
}
}