🤖 memoria: enforce sibling custody on document routes
Draft implementation under settled access rules; independent design and full release review remain pending. Reuses existing notices and preserves local document behavior.
Co-Authored-By: Codex <noreply@openai.com>
7 files changed,
+424
−60
internal/memoria/custody_test.go+1 −2internal/memoria/handlers.go+44 −40internal/memoria/reader.go+152 −0internal/memoria/store.go+8 −7internal/memoria/templates/index.html+6 −3internal/memoriatest/custody_routes_test.go+203 −7tools/drive/main.go+10 −1
CI passed — run details
diff --git a/internal/memoria/custody_test.go b/internal/memoria/custody_test.go| index 9b35870..8c30c7f 100644 |
| --- a/internal/memoria/custody_test.go |
| +++ b/internal/memoria/custody_test.go |
| @@ -83,8 +83,7 @@ func storeWithASiblingDocument(t *testing.T, custody Custody) *Store { |
| return s |
| } |
| -// §4.1 end to end, through the only method that returns a renderable |
| -// passage. |
| +// §4.1 end to end through Search. |
| // |
| // This is the gate the sibling package's unit tests cannot be: they prove |
| // `Decide` maps values and `Rule` fans out, and neither proves that the |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 9e70f4e..dd2296b 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/flash" |
| "github.com/go-chi/chi/v5" |
| + "gorm.io/gorm" |
| "amadan.net/oficina/memoria/internal/memoria/query" |
| ) |
| @@ -131,15 +132,16 @@ func (a *app) UseAuth(x *auth.Auth) { a.auth = x } |
| // because the layout states them on every screen: a corpus count is how |
| // a person tells "nothing matched" from "nothing indexed". |
| type page struct { |
| - Signed bool |
| - Chunks int |
| - Query string |
| - Searched bool |
| - Flash string |
| - FlashKind string |
| - Results []Result |
| - Documents []Summary |
| - Drop dropZone |
| + Signed bool |
| + Chunks int |
| + Query string |
| + Searched bool |
| + Flash string |
| + FlashKind string |
| + Results []Result |
| + Documents []Summary |
| + DocumentNotices []string |
| + Drop dropZone |
| // Notices is §6.5's loud withholding: what this page could not show |
| // and which app it came from. **Never a count of what the reader may |
| @@ -537,6 +539,11 @@ func (a *app) chunkCount() int { |
| } |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| + addr, ok := a.identity(r) |
| + if !ok || addr == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| drop := newDropZone() |
| p := page{ |
| @@ -547,11 +554,12 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| Drop: drop, |
| } |
| a.runSearch(r, &p) |
| - docs, err := a.store.Summaries(r.Context()) |
| + docs, notices, err := a.store.ReaderSummaries(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: addr}) |
| if err != nil { |
| a.logger.Error("list documents", "err", err) |
| + notices = []string{"could not read that document"} |
| } |
| - p.Documents = docs |
| + p.Documents, p.DocumentNotices = docs, notices |
| if f := r.URL.Query().Get("stored"); f != "" { |
| p.Flash, p.FlashKind = drop.outcomeCounts(count(f), count(r.URL.Query().Get("refused"))) |
| } |
| @@ -1024,42 +1032,30 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| - title, body, err := a.store.Body(r.Context(), id) |
| - if err != nil { |
| - // A row that is not there 404s. It never 403s, so a document |
| - // that exists but is not yours is indistinguishable from one |
| - // that does not exist. |
| + addr, ok := a.identity(r) |
| + if !ok || addr == "" { |
| http.NotFound(w, r) |
| return |
| } |
| - // Read the STORED chunks. Re-chunking here would derive ordinals |
| - // from the current chunker while every citation carries the ordinal |
| - // from the one that ran at ingest. |
| - stored, err := a.store.Passages(r.Context(), id) |
| + doc, notices, err := a.store.ReadDocument(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: addr}, id) |
| if err != nil { |
| - a.logger.Error("read passages", "id", id, "err", err) |
| - http.Error(w, "could not read that document", http.StatusInternalServerError) |
| + if len(notices) > 0 { |
| + http.Error(w, strings.Join(notices, "\n"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + http.NotFound(w, r) |
| + } else { |
| + http.Error(w, "could not read that document", http.StatusInternalServerError) |
| + } |
| return |
| } |
| - passages := make([]passage, 0, len(stored)) |
| - for _, p := range stored { |
| + passages := make([]passage, 0, len(doc.Passages)) |
| + for _, p := range doc.Passages { |
| passages = append(passages, passage{Ordinal: p.Ordinal, Heading: p.Heading, Text: p.Text}) |
| } |
| - source, revision, nbytes, err := a.store.Meta(r.Context(), id) |
| - if err != nil { |
| - http.NotFound(w, r) |
| - return |
| - } |
| - _ = body |
| - render(w, "document", documentPage{ |
| - Signed: true, |
| - Chunks: a.chunkCount(), |
| - Title: title, |
| - Source: source, |
| - Bytes: nbytes, |
| - Revision: revision, |
| - Passages: passages, |
| - }) |
| + render(w, "document", documentPage{Signed: true, Chunks: a.chunkCount(), Title: doc.Title, |
| + Source: doc.Source, Bytes: doc.Bytes, Revision: doc.Revision, Passages: passages}) |
| } |
| func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| @@ -1068,7 +1064,15 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| - if err := a.store.Delete(r.Context(), id); err != nil { |
| + if addr, ok := a.identity(r); !ok || addr == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err := a.store.DeleteOwned(r.Context(), id); err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| // The delete did not happen, and this used to say nothing about |
| // that: the error was logged and the redirect issued exactly as |
| // if it had. Someone who asked for a passage to stop being |
diff --git a/internal/memoria/reader.go b/internal/memoria/reader.go| new file mode 100644 |
| index 0000000..50d0a65 |
| --- /dev/null |
| +++ b/internal/memoria/reader.go |
| @@ -0,0 +1,152 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "amadan.net/oficina/suite" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/seal" |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| +) |
| + |
| +func (s *Store) documentCandidate(d Document) (Result, error) { |
| + r := Result{DocumentID: d.ID, Source: d.Source} |
| + if len(d.SiblingItemID) == 0 && d.SiblingRevision == 0 { |
| + return r, nil |
| + } |
| + if len(d.SiblingItemID) == 0 || d.SiblingRevision == 0 { |
| + return Result{}, errors.New("ingest: a sibling document needs both an item id and a revision") |
| + } |
| + id, err := s.sealer.Open(seal.PurposeItemID, d.SiblingItemID) |
| + if err != nil { |
| + return Result{}, err |
| + } |
| + if len(id) == 0 { |
| + return Result{}, errors.New("ingest: a sibling document needs both an item id and a revision") |
| + } |
| + r.item = sibling.Candidate{Item: suite.ItemRevision{ItemID: string(id), Revision: d.SiblingRevision}} |
| + return r, nil |
| +} |
| + |
| +func (s *Store) ReaderSummaries(ctx context.Context, asker suite.Subject) ([]Summary, []string, error) { |
| + var docs []Document |
| + var counts []struct { |
| + DocumentID int64 |
| + Total int |
| + } |
| + if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error { |
| + if err := tx.Order("updated_at desc").Find(&docs).Error; err != nil { |
| + return err |
| + } |
| + return tx.Model(&Chunk{}).Select("document_id, COUNT(*) AS total").Group("document_id").Scan(&counts).Error |
| + }); err != nil { |
| + return nil, nil, err |
| + } |
| + candidates := make([]Result, 0, len(docs)) |
| + for _, d := range docs { |
| + candidate, err := s.documentCandidate(d) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + candidates = append(candidates, candidate) |
| + } |
| + ruled := s.rule(ctx, asker, candidates) |
| + allowed := make(map[int64]bool, len(ruled.Results)) |
| + for _, r := range ruled.Results { |
| + allowed[r.DocumentID] = true |
| + } |
| + chunkCounts := make(map[int64]int, len(counts)) |
| + for _, c := range counts { |
| + chunkCounts[c.DocumentID] = c.Total |
| + } |
| + out := make([]Summary, 0, len(allowed)) |
| + for _, d := range docs { |
| + if !allowed[d.ID] { |
| + continue |
| + } |
| + title, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + var path string |
| + if len(d.SealedPath) > 0 { |
| + opened, err := s.sealer.Open(seal.PurposePath, d.SealedPath) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + path = string(opened) |
| + } |
| + out = append(out, Summary{ID: d.ID, Source: d.Source, Title: string(title), Path: path, |
| + Bytes: d.Bytes, Chunks: chunkCounts[d.ID], Updated: d.UpdatedAt.Format("2 Jan 2006, 15:04"), |
| + CanDelete: len(d.SiblingItemID) == 0 && d.SiblingRevision == 0}) |
| + } |
| + return out, ruled.Notices, nil |
| +} |
| + |
| +type ReaderDocument struct { |
| + Title string |
| + Source string |
| + Revision int |
| + Bytes int |
| + Passages []Passage |
| +} |
| + |
| +func (s *Store) ReadDocument(ctx context.Context, asker suite.Subject, id int64) (ReaderDocument, []string, error) { |
| + var d Document |
| + var rows []Chunk |
| + if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error { |
| + if err := tx.Where("id = ?", id).First(&d).Error; err != nil { |
| + return err |
| + } |
| + return tx.Where("document_id = ?", id).Order("ordinal").Find(&rows).Error |
| + }); err != nil { |
| + return ReaderDocument{}, nil, err |
| + } |
| + candidate, err := s.documentCandidate(d) |
| + if err != nil { |
| + return ReaderDocument{}, nil, err |
| + } |
| + ruled := s.rule(ctx, asker, []Result{candidate}) |
| + if len(ruled.Results) == 0 { |
| + return ReaderDocument{}, ruled.Notices, gorm.ErrRecordNotFound |
| + } |
| + title, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle) |
| + if err != nil { |
| + return ReaderDocument{}, nil, err |
| + } |
| + out := ReaderDocument{Title: string(title), Source: d.Source, Revision: d.Revision, Bytes: d.Bytes, Passages: make([]Passage, 0, len(rows))} |
| + for _, row := range rows { |
| + text, err := s.sealer.Open(seal.PurposeChunk, row.SealedText) |
| + if err != nil { |
| + return ReaderDocument{}, nil, err |
| + } |
| + heading, err := s.sealer.Open(seal.PurposeTitle, row.SealedHeading) |
| + if err != nil { |
| + return ReaderDocument{}, nil, err |
| + } |
| + out.Passages = append(out.Passages, Passage{Ordinal: row.Ordinal, Heading: string(heading), Text: string(text)}) |
| + } |
| + return out, nil, nil |
| +} |
| + |
| +func (s *Store) DeleteOwned(ctx context.Context, id int64) error { |
| + return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error { |
| + guard := tx.Exec(`UPDATE documents SET id = id WHERE id = ? |
| + AND (sibling_item_id IS NULL OR length(sibling_item_id) = 0) AND sibling_revision = 0`, id) |
| + if guard.Error != nil { |
| + return guard.Error |
| + } |
| + if guard.RowsAffected != 1 { |
| + return gorm.ErrRecordNotFound |
| + } |
| + if err := s.ix.RemoveDocument(id); err != nil { |
| + return err |
| + } |
| + if err := tx.Where("document_id = ?", id).Delete(&Chunk{}).Error; err != nil { |
| + return err |
| + } |
| + return tx.Where("id = ?", id).Delete(&Document{}).Error |
| + }) |
| +} |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index 56350fb..5860c0a 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -102,13 +102,14 @@ type Page struct { |
| // Summary is a document as the list shows it. |
| type Summary struct { |
| - ID int64 |
| - Source string |
| - Title string |
| - Path string |
| - Bytes int |
| - Chunks int |
| - Updated string |
| + CanDelete bool |
| + ID int64 |
| + Source string |
| + Title string |
| + Path string |
| + Bytes int |
| + Chunks int |
| + Updated string |
| } |
| // externalID keys a document by its stable source identifier. |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index 6ced7c3..b6447e8 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -172,9 +172,12 @@ |
| </section> |
| <section rst-section rst-refresh="documents"> |
| + {{if .DocumentNotices}}<div rst-callout rst-tone="warning" rst-withheld> |
| + {{range .DocumentNotices}}<p rst-callout-body>{{.}}</p>{{end}} |
| + </div>{{end}} |
| <h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2> |
| {{if not .Documents}} |
| - <p rst-cell-mut>Nothing stored yet.</p> |
| + {{if not .DocumentNotices}}<p rst-cell-mut>Nothing stored yet.</p>{{end}} |
| {{else}} |
| <table rst-table> |
| <thead><tr><th>Title</th><th>Source</th><th>Passages</th><th>Updated</th><th></th></tr></thead> |
| @@ -186,9 +189,9 @@ |
| <td rst-cell-mut>{{.Chunks}}</td> |
| <td rst-cell-mut>{{.Updated}}</td> |
| <td> |
| - <form method="post" action="/documents/{{.ID}}/delete" rst-inline> |
| + {{if .CanDelete}}<form method="post" action="/documents/{{.ID}}/delete" rst-inline> |
| <button rst-btn="quiet compact" class="rst-danger">Delete</button> |
| - </form> |
| + </form>{{end}} |
| </td> |
| </tr> |
| {{end}} |
diff --git a/internal/memoriatest/custody_routes_test.go b/internal/memoriatest/custody_routes_test.go| index 6e19678..0181fc1 100644 |
| --- a/internal/memoriatest/custody_routes_test.go |
| +++ b/internal/memoriatest/custody_routes_test.go |
| @@ -6,9 +6,11 @@ import ( |
| "io" |
| "log/slog" |
| "net/http" |
| + "net/http/httptest" |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -18,16 +20,21 @@ import ( |
| ) |
| type routeCustody struct { |
| - answer suite.Authorization |
| - asks []suite.Subject |
| - pairs []suite.ItemRevision |
| + unresolved bool |
| + onRule func() |
| + answer suite.Authorization |
| + asks []suite.Subject |
| + pairs []suite.ItemRevision |
| } |
| func (c *routeCustody) Sibling(source string) (sibling.Sibling, bool) { |
| - return sibling.Sibling{Name: "Docs"}, source == "Docs" |
| + return sibling.Sibling{Name: "Docs"}, source == "Docs" && !c.unresolved |
| } |
| func (c *routeCustody) Rule(_ context.Context, who suite.Subject, candidates []sibling.Candidate) []sibling.Disposition { |
| + if c.onRule != nil { |
| + c.onRule() |
| + } |
| c.asks = append(c.asks, who) |
| out := make([]sibling.Disposition, len(candidates)) |
| for i, candidate := range candidates { |
| @@ -37,7 +44,7 @@ func (c *routeCustody) Rule(_ context.Context, who suite.Subject, candidates []s |
| return out |
| } |
| -func custodyRouteApp(t *testing.T, custody memoria.Custody) (http.Handler, *memoria.Store, int64, int64) { |
| +func custodyRouteApp(t *testing.T, custody memoria.Custody, alter ...func(*db.DB)) (http.Handler, *memoria.Store, int64, int64) { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| @@ -67,12 +74,19 @@ func custodyRouteApp(t *testing.T, custody memoria.Custody) (http.Handler, *memo |
| if err != nil { |
| t.Fatal(err) |
| } |
| + for _, change := range alter { |
| + change(d) |
| + } |
| return h, s, siblingDoc, localDoc |
| } |
| // Baseline regression evidence: these HTTP routes ingest actual sibling |
| // coordinates, while retaining local content as a positive control. |
| -// Independent reviewer mutations will be recorded after design approval. |
| +// MUTATION-TESTED 2026-09-07: independently selected by the coordinator. |
| +// Forcing list inclusion, ignoring the document ruling, rereading chunks |
| +// after S2, dropping the delete predicate, treating malformed coordinates |
| +// as local, and enabling sibling Delete each failed on its named state. |
| +// Each mutation was reverted separately before the final green run. |
| func TestSiblingDocumentHTTPRequiresCustody(t *testing.T) { |
| for _, answer := range []suite.Authorization{suite.NOT_VISIBLE, suite.STALE, suite.UNKNOWN, suite.VISIBLE} { |
| for _, route := range []string{"/", "/?q=quarterly", "/search?q=quarterly", "/documents/%d"} { |
| @@ -92,10 +106,25 @@ func TestSiblingDocumentHTTPRequiresCustody(t *testing.T) { |
| } else { |
| for _, secret := range []string{"SiblingTitleCanary", "SiblingHeadingCanary", "SiblingBodyCanary"} { |
| if strings.Contains(body, secret) { |
| - t.Errorf("unruled sibling field rendered: %s", secret) |
| + t.Errorf("unruled sibling field rendered at HTTP %d: %s", r.Code, secret) |
| } |
| } |
| } |
| + if answer == suite.NOT_VISIBLE && strings.Contains(body, "rst-withheld") { |
| + t.Error("NOT_VISIBLE produced a notice") |
| + } |
| + if (answer == suite.UNKNOWN || answer == suite.STALE) && !strings.Contains(body, "Docs ") { |
| + t.Error("withholding was silent") |
| + } |
| + if route == "/documents/%d" && answer == suite.NOT_VISIBLE && r.Code != http.StatusNotFound { |
| + t.Errorf("denial status%d", r.Code) |
| + } |
| + if route == "/documents/%d" && (answer == suite.UNKNOWN || answer == suite.STALE) && r.Code != http.StatusServiceUnavailable { |
| + t.Errorf("withholding status%d", r.Code) |
| + } |
| + if route == "/" && strings.Contains(body, fmt.Sprintf("action=\"/documents/%d/delete\"", id)) { |
| + t.Error("sibling delete affordance rendered") |
| + } |
| if len(custody.asks) == 0 { |
| t.Error("route did not obtain custody ruling") |
| } |
| @@ -148,3 +177,170 @@ func TestSiblingDocumentHTTPManualDeleteHasNoAuthority(t *testing.T) { |
| t.Error("local deletion did not remove row") |
| } |
| } |
| + |
| +func TestSiblingDocumentHTTPUnresolvedCustody(t *testing.T) { |
| + for _, custody := range []memoria.Custody{nil, &routeCustody{answer: suite.VISIBLE, unresolved: true}} { |
| + h, _, id, _ := custodyRouteApp(t, custody) |
| + for _, target := range []string{"/", fmt.Sprintf("/documents/%d", id)} { |
| + r := get(t, h, target) |
| + if strings.Contains(r.Body.String(), "SiblingTitleCanary") || strings.Contains(r.Body.String(), "SiblingBodyCanary") { |
| + t.Error("unresolved custody disclosed content") |
| + } |
| + if !strings.Contains(r.Body.String(), "Docs did not answer") { |
| + t.Error("unresolved custody was silent") |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestSiblingDocumentHTTPRechecksVisibility(t *testing.T) { |
| + for _, target := range []string{"/", "/documents/%d"} { |
| + custody := &routeCustody{answer: suite.VISIBLE} |
| + h, _, id, _ := custodyRouteApp(t, custody) |
| + if strings.Contains(target, "%d") { |
| + target = fmt.Sprintf(target, id) |
| + } |
| + if !strings.Contains(get(t, h, target).Body.String(), "SiblingTitleCanary") { |
| + t.Fatal("visible control missing") |
| + } |
| + before := len(custody.asks) |
| + custody.answer = suite.NOT_VISIBLE |
| + if strings.Contains(get(t, h, target).Body.String(), "SiblingTitleCanary") { |
| + t.Error("revocation did not withhold") |
| + } |
| + if len(custody.asks) <= before { |
| + t.Error("next request did not reauthorize") |
| + } |
| + } |
| +} |
| + |
| +func TestSiblingDocumentHTTPConcurrentIngestCannotReplaceRuledSnapshot(t *testing.T) { |
| + for _, route := range []string{"/", "/documents/%d"} { |
| + t.Run(route, func(t *testing.T) { |
| + custody := &routeCustody{answer: suite.VISIBLE} |
| + h, s, id, _ := custodyRouteApp(t, custody) |
| + target := route |
| + if strings.Contains(target, "%d") { |
| + target = fmt.Sprintf(target, id) |
| + } |
| + entered, resume := make(chan struct{}), make(chan struct{}) |
| + defer func() { |
| + select { |
| + case <-resume: |
| + default: |
| + close(resume) |
| + } |
| + }() |
| + custody.onRule = func() { close(entered); <-resume } |
| + done := make(chan *httptest.ResponseRecorder, 1) |
| + go func() { |
| + r := httptest.NewRecorder() |
| + h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, target, nil)) |
| + done <- r |
| + }() |
| + select { |
| + case <-entered: |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("request never reached S2") |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + _, err := s.Ingest(ctx, memoria.IngestSpec{Source: "Docs", Key: "route-sibling", Title: "UnruledNewTitle", Body: "UnruledNewBody", ItemID: "sibling-item-route", SiblingRevision: 20}) |
| + if err != nil { |
| + t.Fatalf("concurrent ingest: %v", err) |
| + } |
| + close(resume) |
| + var r *httptest.ResponseRecorder |
| + select { |
| + case r = <-done: |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("request did not finish") |
| + } |
| + if strings.Contains(r.Body.String(), "UnruledNewTitle") || strings.Contains(r.Body.String(), "UnruledNewBody") { |
| + t.Error("new revision substituted after old ruling") |
| + } |
| + if !strings.Contains(r.Body.String(), "SiblingTitleCanary") { |
| + t.Error("authorized snapshot control missing") |
| + } |
| + for _, pair := range custody.pairs { |
| + if pair.Revision != 19 { |
| + t.Errorf("wrong revision asked: %d", pair.Revision) |
| + } |
| + } |
| + _, body, err := s.Body(context.Background(), id) |
| + if err != nil || body != "UnruledNewBody" { |
| + t.Error("concurrent ingest did not actually commit") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestSiblingDocumentHTTPVisibleReadCannotGrantDelete(t *testing.T) { |
| + custody := &routeCustody{answer: suite.VISIBLE} |
| + h, s, id, _ := custodyRouteApp(t, custody) |
| + if get(t, h, fmt.Sprintf("/documents/%d", id)).Code != http.StatusOK { |
| + t.Fatal("visible read failed") |
| + } |
| + if post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil).Code != http.StatusNotFound { |
| + t.Error("read grant allowed delete") |
| + } |
| + if _, _, err := s.Body(context.Background(), id); err != nil { |
| + t.Error("visible sibling row removed") |
| + } |
| +} |
| + |
| +func TestSiblingDocumentHTTPMalformedCoordinateNeverBecomesLocal(t *testing.T) { |
| + for _, assignment := range []string{"sibling_item_id = NULL", "sibling_revision = 0", "sibling_item_id = x'010203'"} { |
| + t.Run(assignment, func(t *testing.T) { |
| + custody := &routeCustody{answer: suite.VISIBLE} |
| + h, s, id, localID := custodyRouteApp(t, custody, func(d *db.DB) { |
| + if err := d.G.Exec("UPDATE documents SET "+assignment+" WHERE source = ?", "Docs").Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + for _, target := range []string{"/", fmt.Sprintf("/documents/%d", id)} { |
| + r := get(t, h, target) |
| + if strings.Contains(r.Body.String(), "SiblingTitleCanary") || strings.Contains(r.Body.String(), "SiblingBodyCanary") { |
| + t.Error("malformed coordinate disclosed content") |
| + } |
| + } |
| + if get(t, h, fmt.Sprintf("/documents/%d", localID)).Code != http.StatusOK { |
| + t.Error("local control failed") |
| + } |
| + if post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil).Code != http.StatusNotFound { |
| + t.Error("malformed sibling deletion not refused") |
| + } |
| + if _, _, err := s.Body(context.Background(), id); err != nil { |
| + t.Error("malformed sibling row deleted") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalDeleteSQLFailureKeepsDocumentWithoutLiveCitation(t *testing.T) { |
| + h, s, _, id := custodyRouteApp(t, nil, func(d *db.DB) { |
| + if err := d.G.Exec(`CREATE TRIGGER refuse_local_delete BEFORE DELETE ON documents |
| + WHEN OLD.source = 'upload' BEGIN SELECT RAISE(ABORT, 'fixture refusal'); END`).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + r := post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil) |
| + if r.Code != http.StatusSeeOther { |
| + t.Errorf("delete failure status%d", r.Code) |
| + } |
| + _, body, err := s.Body(context.Background(), id) |
| + if err != nil || !strings.Contains(body, "LocalBodyControl") { |
| + t.Error("failed SQL delete lost document") |
| + } |
| + found, err := s.Search(context.Background(), suite.Subject{Kind: suite.SubjectAddress, Value: "reader@example.test"}, "LocalBodyControl", 10) |
| + if err != nil || len(found.Results) != 0 { |
| + t.Errorf("failed delete left live citation: %v", err) |
| + } |
| + if _, err := s.Rebuild(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + found, err = s.Search(context.Background(), suite.Subject{Kind: suite.SubjectAddress, Value: "reader@example.test"}, "LocalBodyControl", 10) |
| + if err != nil || len(found.Results) != 1 { |
| + t.Error("rebuild did not restore retained document to index") |
| + } |
| +} |
diff --git a/tools/drive/main.go b/tools/drive/main.go| index 0bb1d58..9a75026 100644 |
| --- a/tools/drive/main.go |
| +++ b/tools/drive/main.go |
| @@ -20,6 +20,7 @@ import ( |
| "flag" |
| "fmt" |
| "log/slog" |
| + "net" |
| "net/http" |
| "os" |
| "path/filepath" |
| @@ -189,7 +190,7 @@ func seedSiblings(app interface { |
| {Name: "Sheets", BaseURL: "https://drive.sheets.oficina.works", Token: "drive"}, |
| {Name: "Calendar", BaseURL: "https://drive.calendar.oficina.works", Token: "drive"}, |
| } |
| - custody, err := memoria.NewSiblings("drive", nil, apps) |
| + custody, err := memoria.NewSiblings("drive", func(context.Context, string, string) (net.Conn, error) { return nil, net.ErrClosed }, apps) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, "seed siblings:", err) |
| return |
| @@ -197,6 +198,14 @@ func seedSiblings(app interface { |
| app.UseSiblings(custody) |
| store := app.Store() |
| + if summaries, err := store.Summaries(ctx); err == nil && len(summaries) > 0 { |
| + title, body, err := store.Body(ctx, summaries[0].ID) |
| + if err == nil { |
| + if _, err := store.Ingest(ctx, memoria.IngestSpec{Source: "Docs", Key: "drive-docs", Title: title, Body: body, ItemID: "drive-docs", SiblingRevision: 7}); err != nil { |
| + panic(err) |
| + } |
| + } |
| + } |
| now := time.Now().UTC() |
| // Docs: quiet, and polled a moment ago. |