mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
4d6ebd426c
* fix(server): attribution comes from the request, never the body (BUG-3372) A body could set who wrote a comment, an item, a link or a version, and from which client: author (free text, rendered as the comment's author), created_by, source, last_modified_by and version_source all yielded to a body value, so any editor could post as another member, or file a write as an agent's or a human's at will. - Comments and replies: author is the signed-in account's name (empty, labelled by kind, with no account); created_by and source are the request's. - Item create: created_by and source are the request's. Item update: last_modified_by and the version row's source are the request's, and a body source no longer moves items.source (an update never should). - Links (blocks / parent / ...): created_by is the request's. - The canonical account columns are filled on every request write path: items.created_by_user_id and last_modified_by_user_id (create, update, artifact import, cross-workspace copy), item_links.user_id (link create, parent set on create and update). Account deletion already nulls all three. - X-Pad-Agent stays the agent signal (self-declared; it only labels the caller's own writes) but is trimmed, stripped of control characters and capped at 64 runes; a header that cleans to nothing is not an agent. No client sends any of these fields except the web's comment create and the MCP comment door, which send the values the server derives anyway. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): legacy document doors and bulk edits stamp attribution from the request (BUG-3372) Codex review round 1: - POST/PATCH /documents still took created_by, source and last_modified_by from the body; they are now the request's. - Bulk field and tag edits stamped the writer's kind but not the account; they now fill last_modified_by_user_id like a single update. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): the document autosave hint reads the body's source before attribution is stamped (BUG-3372) The legacy document PATCH skips its activity row for a content-only web autosave, keyed on the body's source. Stamping source from the request made every cookie request 'web', so every content-only update lost its activity row (TestActivityEndpoints). The body's value is kept as that hint only: it decides whether an activity row is written, never what any row says. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): the document autosave skip also requires a web request (BUG-3372) Lead's review: the autosave hint is the body's, so any caller could skip the activity row; a PAT or agent sending "source":"web" on a content-only PATCH wrote no audit record. The skip now also requires the request's own source to be web. Red-first test with the cookie control. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
300 lines
11 KiB
Go
300 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// BUG-3372: attribution (who wrote a comment, an item, a link or a version,
|
|
// and from which client) comes from the request, never the body. A body
|
|
// used to win on author, created_by, source, last_modified_by and
|
|
// version_source, so any editor could post as another member, or as an
|
|
// agent or a human at will. Each leg sends the forged fields and a control
|
|
// without them: both must stamp the same values, which proves the
|
|
// assertions read the stamping rather than a fixture default.
|
|
|
|
type attributionFixture struct {
|
|
srv *Server
|
|
tok string
|
|
user *models.User
|
|
ws *models.Workspace
|
|
coll *models.Collection
|
|
item *models.Item
|
|
}
|
|
|
|
func newAttributionFixture(t *testing.T) attributionFixture {
|
|
t.Helper()
|
|
srv := testServer(t)
|
|
user, tok := loginTestUserAs(t, srv, "alice3372@example.test", "Alice", "pw-3372-abcdefgh")
|
|
ws := mustCreateOwnedWorkspace(t, srv, "Attribution 3372", user)
|
|
coll := mustCollection(t, srv, ws.ID, "Tasks")
|
|
item := mustItem(t, srv, ws.ID, coll.ID, "target")
|
|
return attributionFixture{srv, tok, user, ws, coll, item}
|
|
}
|
|
|
|
func (f attributionFixture) do(t *testing.T, method, path string, body any) map[string]any {
|
|
t.Helper()
|
|
rr := doRequestWithCookie(f.srv, method, "/api/v1/workspaces/"+f.ws.Slug+path, body, f.tok)
|
|
if rr.Code/100 != 2 {
|
|
t.Fatalf("%s %s: %d %s", method, path, rr.Code, rr.Body.String())
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", path, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
var forgedAttribution = map[string]any{
|
|
"author": "Mallory", "created_by": "agent", "source": "skill",
|
|
}
|
|
|
|
func withForged(body map[string]any, forged bool) map[string]any {
|
|
out := map[string]any{}
|
|
for k, v := range body {
|
|
out[k] = v
|
|
}
|
|
if forged {
|
|
for k, v := range forgedAttribution {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestBUG3372_CommentAttributionIsTheRequests(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
parent := f.do(t, "POST", "/items/"+f.item.Slug+"/comments", map[string]any{"body": "parent"})
|
|
|
|
for _, forged := range []bool{false, true} {
|
|
name := map[bool]string{false: "control", true: "forged"}[forged]
|
|
t.Run(name, func(t *testing.T) {
|
|
for _, c := range []struct {
|
|
route string
|
|
path string
|
|
}{
|
|
{"comment", "/items/" + f.item.Slug + "/comments"},
|
|
{"reply", "/comments/" + parent["id"].(string) + "/replies"},
|
|
} {
|
|
got := f.do(t, "POST", c.path, withForged(map[string]any{"body": "x"}, forged))
|
|
if got["author"] != "Alice" || got["created_by"] != "user" || got["source"] != "web" || got["user_id"] != f.user.ID {
|
|
t.Errorf("%s: author=%v created_by=%v source=%v user_id=%v, want Alice/user/web/%s",
|
|
c.route, got["author"], got["created_by"], got["source"], got["user_id"], f.user.ID)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func readUserColumns(t *testing.T, srv *Server, query, id string) (string, string) {
|
|
t.Helper()
|
|
var a, b sql.NullString
|
|
if err := srv.store.DB().QueryRow(query, id).Scan(&a, &b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a.String, b.String
|
|
}
|
|
|
|
func TestBUG3372_ItemAttributionIsTheRequests(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
for _, forged := range []bool{false, true} {
|
|
name := map[bool]string{false: "control", true: "forged"}[forged]
|
|
t.Run(name, func(t *testing.T) {
|
|
created := f.do(t, "POST", "/collections/"+f.coll.Slug+"/items",
|
|
withForged(map[string]any{"title": "made " + name}, forged))
|
|
if created["created_by"] != "user" || created["source"] != "web" {
|
|
t.Errorf("create: created_by=%v source=%v, want user/web", created["created_by"], created["source"])
|
|
}
|
|
id := created["id"].(string)
|
|
by, mod := readUserColumns(t, f.srv, `SELECT created_by_user_id, last_modified_by_user_id FROM items WHERE id = ?`, id)
|
|
if by != f.user.ID || mod != f.user.ID {
|
|
t.Errorf("create: created_by_user_id=%q last_modified_by_user_id=%q, want %s", by, mod, f.user.ID)
|
|
}
|
|
|
|
update := map[string]any{"content": "edited " + name}
|
|
if forged {
|
|
update["last_modified_by"] = "agent"
|
|
update["source"] = "cli"
|
|
update["version_source"] = "cli"
|
|
}
|
|
updated := f.do(t, "PATCH", "/items/"+created["slug"].(string), update)
|
|
if updated["last_modified_by"] != "user" || updated["source"] != "web" {
|
|
t.Errorf("update: last_modified_by=%v source=%v, want user/web (items.source does not move on update)",
|
|
updated["last_modified_by"], updated["source"])
|
|
}
|
|
var vsource string
|
|
if err := f.srv.store.DB().QueryRow(
|
|
`SELECT source FROM item_versions WHERE item_id = ? ORDER BY version_seq DESC LIMIT 1`, id).Scan(&vsource); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if vsource != "web" {
|
|
t.Errorf("update: newest version source = %q, want web", vsource)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBUG3372_LinkAttributionIsTheRequests(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
for _, forged := range []bool{false, true} {
|
|
name := map[bool]string{false: "control", true: "forged"}[forged]
|
|
t.Run(name, func(t *testing.T) {
|
|
for _, linkType := range []string{"blocks", "parent"} {
|
|
target := mustItem(t, f.srv, f.ws.ID, f.coll.ID, linkType+" "+name)
|
|
body := map[string]any{"target_id": target.ID, "link_type": linkType}
|
|
if forged {
|
|
body["created_by"] = "agent"
|
|
}
|
|
link := f.do(t, "POST", "/items/"+f.item.Slug+"/links", body)
|
|
if link["created_by"] != "user" {
|
|
t.Errorf("%s link: created_by=%v, want user", linkType, link["created_by"])
|
|
}
|
|
var uid sql.NullString
|
|
if err := f.srv.store.DB().QueryRow(`SELECT user_id FROM item_links WHERE id = ?`, link["id"]).Scan(&uid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uid.String != f.user.ID {
|
|
t.Errorf("%s link: user_id=%q, want %s", linkType, uid.String, f.user.ID)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The self-declared agent name is kept (it only ever labels the caller's
|
|
// own writes) but bounded: no control characters, at most 64 runes, and a
|
|
// header that is nothing but those does not make the request an agent's.
|
|
func TestBUG3372_AgentNameIsBounded(t *testing.T) {
|
|
req := func(v string) *http.Request {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Pad-Agent", v)
|
|
return r
|
|
}
|
|
if got := agentNameFromRequest(req(" Claude\tCode \x01")); got != "ClaudeCode" {
|
|
t.Errorf("control characters: %q, want ClaudeCode", got)
|
|
}
|
|
long := agentNameFromRequest(req(strings.Repeat("é", 200)))
|
|
if utf8.RuneCountInString(long) != maxAgentNameRunes {
|
|
t.Errorf("long name kept %d runes, want %d", utf8.RuneCountInString(long), maxAgentNameRunes)
|
|
}
|
|
if actor, _ := actorFromRequest(req("\x01\x02")); actor != "user" {
|
|
t.Errorf("a control-only header made the request %q, want user", actor)
|
|
}
|
|
if actor, _ := actorFromRequest(req("Claude")); actor != "agent" {
|
|
t.Errorf("a real agent name made the request %q, want agent", actor)
|
|
}
|
|
}
|
|
|
|
// A cross-workspace copy creates a row in the destination; the copier is
|
|
// the account that created it.
|
|
func TestBUG3372_CopyRecordsTheCopiersAccount(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
dst := mustCreateOwnedWorkspace(t, f.srv, "Attribution 3372 Dest", f.user)
|
|
dstColl := mustCollection(t, f.srv, dst.ID, "Tasks")
|
|
rr := doRequestWithCookie(f.srv, "POST", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.item.Slug+"/copy",
|
|
map[string]any{"target_workspace": dst.Slug, "target_collection": dstColl.Slug}, f.tok)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("copy: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var out struct {
|
|
Item struct {
|
|
ID string `json:"id"`
|
|
} `json:"item"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil || out.Item.ID == "" {
|
|
t.Fatalf("decode copy: %v %s", err, rr.Body.String())
|
|
}
|
|
by, mod := readUserColumns(t, f.srv, `SELECT created_by_user_id, last_modified_by_user_id FROM items WHERE id = ?`, out.Item.ID)
|
|
if by != f.user.ID || mod != f.user.ID {
|
|
t.Errorf("copy: created_by_user_id=%q last_modified_by_user_id=%q, want %s", by, mod, f.user.ID)
|
|
}
|
|
}
|
|
|
|
// A bulk edit records the account that made it, as a single update does.
|
|
func TestBUG3372_BulkUpdateRecordsTheAccount(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
bulk := f.do(t, "POST", "/items/bulk", map[string]any{"op": "tag", "ids": []string{f.item.ID}, "tags": []string{"x3372"}})
|
|
if updated, _ := bulk["updated"].([]any); len(updated) != 1 {
|
|
t.Fatalf("bulk tag did not update the item: %v", bulk)
|
|
}
|
|
_, mod := readUserColumns(t, f.srv, `SELECT created_by_user_id, last_modified_by_user_id FROM items WHERE id = ?`, f.item.ID)
|
|
if mod != f.user.ID {
|
|
t.Errorf("bulk tag: last_modified_by_user_id=%q, want %s", mod, f.user.ID)
|
|
}
|
|
}
|
|
|
|
// The legacy document endpoints are still mounted; they stamp attribution
|
|
// from the request too.
|
|
func TestBUG3372_DocumentAttributionIsTheRequests(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
for _, forged := range []bool{false, true} {
|
|
name := map[bool]string{false: "control", true: "forged"}[forged]
|
|
t.Run(name, func(t *testing.T) {
|
|
body := map[string]any{"title": "doc " + name, "content": "x"}
|
|
if forged {
|
|
body["created_by"] = "agent"
|
|
body["source"] = "skill"
|
|
}
|
|
doc := f.do(t, "POST", "/documents", body)
|
|
if doc["created_by"] != "user" || doc["source"] != "web" {
|
|
t.Errorf("create: created_by=%v source=%v, want user/web", doc["created_by"], doc["source"])
|
|
}
|
|
patch := map[string]any{"content": "y"}
|
|
if forged {
|
|
patch["last_modified_by"] = "agent"
|
|
patch["source"] = "skill"
|
|
}
|
|
upd := f.do(t, "PATCH", "/documents/"+doc["id"].(string), patch)
|
|
if upd["last_modified_by"] != "user" {
|
|
t.Errorf("update: last_modified_by=%v, want user", upd["last_modified_by"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The document autosave hint (a content-only PATCH whose body says
|
|
// "source":"web" writes no activity row) must also match the request: a PAT
|
|
// or agent saying "web" is not the web editor, and used to write no audit
|
|
// row at all.
|
|
func TestBUG3372_AutosaveHintNeedsAWebRequest(t *testing.T) {
|
|
f := newAttributionFixture(t)
|
|
doc := f.do(t, "POST", "/documents", map[string]any{"title": "autosave 3372", "content": "x"})
|
|
docID := doc["id"].(string)
|
|
updatedRows := func() int {
|
|
t.Helper()
|
|
var n int
|
|
if err := f.srv.store.DB().QueryRow(
|
|
`SELECT COUNT(*) FROM activities WHERE document_id = ? AND action = 'updated'`, docID).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Control: the web editor's autosave (cookie, body source web) still
|
|
// skips the row.
|
|
f.do(t, "PATCH", "/documents/"+docID, map[string]any{"content": "web autosave", "source": "web"})
|
|
if n := updatedRows(); n != 0 {
|
|
t.Fatalf("control: a cookie autosave wrote %d activity rows, want 0", n)
|
|
}
|
|
|
|
pat, err := f.srv.store.CreateAPIToken(f.user.ID, models.APITokenCreate{Name: "autosave-3372"}, 30, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr := doRequestWithBearer(f.srv, "PATCH", "/api/v1/workspaces/"+f.ws.Slug+"/documents/"+docID, pat.Token,
|
|
map[string]any{"content": "pat claiming web", "source": "web"})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PAT patch: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if n := updatedRows(); n != 1 {
|
|
t.Errorf("a PAT sending source=web wrote %d activity rows, want 1", n)
|
|
}
|
|
}
|