Unauthorized actors no longer grab leases; controlled PATCH projects through DocGov so author_last_edited and audit survive, with idempotent retry on failure. Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
5.5 KiB
Go
154 lines
5.5 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/docrevision"
|
|
)
|
|
|
|
func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
ctx := context.Background()
|
|
dataRoot := srv.dataRoot()
|
|
|
|
// WorkDir whose parent is a regular file → SaveDocumentContent MkdirAll fails after ledger commit.
|
|
blocker := filepath.Join(dataRoot, "proj-block")
|
|
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workDir := filepath.Join(blocker, "ws-proj")
|
|
docID := "doc_proj1_patch_order"
|
|
|
|
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeRev := st.HeadRevisionNo
|
|
beforeSnap := st.HeadSnapshotID
|
|
|
|
token := loginTestAdmin(t, srv)
|
|
body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}`
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body)))
|
|
withBearerAuth(req, token)
|
|
req.Header.Set("Idempotency-Key", "patch-order-k1")
|
|
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
|
w := httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("projection failure want 500 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["code"] != "DOCUMENT_PROJECTION_FAILED" {
|
|
t.Fatalf("code want DOCUMENT_PROJECTION_FAILED got %#v", payload["code"])
|
|
}
|
|
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap {
|
|
t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after)
|
|
}
|
|
}
|
|
|
|
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
ctx := context.Background()
|
|
docID := "doc_proj1_lease_denied"
|
|
workDir := filepath.Join(srv.dataRoot(), "ws-lease")
|
|
_ = os.MkdirAll(workDir, 0o755)
|
|
|
|
// Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails.
|
|
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := st.HeadRevisionNo
|
|
|
|
token := loginTestAdmin(t, srv)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
|
bytes.NewReader([]byte(`{"markdown":"# L\n\nchanged\n"}`)))
|
|
withBearerAuth(req, token)
|
|
req.Header.Set("Idempotency-Key", "lease-denied-k1")
|
|
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
|
w := httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code == http.StatusOK {
|
|
t.Fatalf("non-author must not save successfully: %s", w.Body.String())
|
|
}
|
|
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.HeadRevisionNo != before {
|
|
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
|
|
}
|
|
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" {
|
|
t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease")
|
|
}
|
|
}
|
|
|
|
func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
ctx := context.Background()
|
|
workDir := filepath.Join(srv.dataRoot(), "ws-audit")
|
|
_ = os.MkdirAll(workDir, 0o755)
|
|
docID := "doc_proj1_patch_audit"
|
|
|
|
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
token := loginTestAdmin(t, srv)
|
|
md := "# base\n\nbody\n\nhuman edit\n"
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
|
bytes.NewReader([]byte(`{"markdown":`+strconv.Quote(md)+`}`)))
|
|
withBearerAuth(req, token)
|
|
req.Header.Set("Idempotency-Key", "patch-audit-k1")
|
|
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
|
w := httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("want 200 got %d %s", w.Code, w.Body.String())
|
|
}
|
|
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc.AuthorLastEditedAt == nil {
|
|
t.Fatal("SaveDocumentContent projection must set author_last_edited_at")
|
|
}
|
|
if !doc.AuthorClaimed {
|
|
t.Fatal("author_claimed should be true after human save projection")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
|
|
t.Fatalf("output.md should exist after SaveDocumentContent: %v", err)
|
|
}
|
|
}
|