mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
85c49cf2fd
* feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388)
SPEC-6 U1 (DOC-3371 v12 §4): the door every app write will go through, and
the two tests that guard it. No app mutations or routes yet; U2 adds them
under these guards.
- store.FencedTx:
- BeginFenced's first statement reads app_installs (auth_epoch, state,
workspace), FOR SHARE on Postgres, and refuses unless the admitted epoch
is current, the install is active and the workspace matches.
- There is no raw Exec and no free-form query. The lock primitives
(LockWorkspaceSeq, NextItemNumber, NextSeq) take the human item path's
own workspace lock.
- Companion guards: requireCompanionCollection, and requireCompanionItem
for derived rows.
- Every field is unexported, and a zero value refuses every method.
- internal/appstore: holds *store.Store concretely; withFence runs one
mutation in one FencedTx; CheckFence is the live path until U2.
- The boundary test (go/packages + go/types) walks every module function
the package can reach. It fails on:
- a *store.Store method off a reviewed allow-list (stronger than the
spec's deny-list)
- database/sql calls outside the door
- Store.db or (*Store).DB
- an interface call or declaration an internal/store type answers
- reflect or unsafe
- the ordinary attachments Put
One fixture per rule under testdata is asserted to be reported.
- The reverse test: no package outside internal/store and internal/appstore
references BeginFenced.
- storetest.CaptureWrites:
- SQLite: modernc's pre-update hook on a single connection, with a
sentinel write proving the hook is still on it.
- Postgres: statement-level audit triggers installed by the harness.
The harness is proven red on a human create (items plus FTS5 shadow
tables) and on a test-only trigger write at depth > 0, and silent on a
read. The blind inventory is asserted exactly: items_fts, comments_fts,
documents_fts and sqlite_sequence.
- guardConn.Unwrap exposes the driver connection to the harness.
- Migration: app_installs (SQLite 112 / PG 086); its columns are added to
the NUL baseline, since no writer exists before U8, which must validate
origin.
Moved to U2, where they are first used: DebounceActivity, CheckItemCap and
the via_app columns.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): close the boundary checker's gaps and the harness's coverage holes (TASK-3388)
Codex round 1. Each gap now has a testdata fixture asserted to be reported,
and the six P1 fixtures were checked against the round-0 checker:
- package-var: package-level var initializers and init functions are walked
for the root and every module package it brings in that the human store's
own closure does not
- generic: instantiated generic methods are normalized through Origin()
- embedded-sql: rules check a method's DECLARING receiver, so a method
promoted through an embedded *sql.DB or *store.Store is caught at the call
- func-field: a call through a function value is refused (dynamic-call)
unless it is a parameter of an unexported root function that every call
site passes a literal or a named function
- linkname: a go:linkname directive or an unsafe import in a walked package
is refused
- whole module: the walk covers the whole module, not only internal/
- embedded-store: the old checker caught this only by walking into
DeleteItem's body; it is now caught at the call
Against the round-0 checker, all six P1 fixtures fail (pkgvar, helperinit,
generic, embeddedsql, funcfield, linkname); the extra embeddedstore fixture
passed there, for the reason above.
Harness:
- SQLite: PRAGMA data_version, read on the hooked connection at the start
and end, refuses a capture during which another connection committed. A
replaced connection is refused at the sentinel. Both branches are tested;
the data_version test passes silently without the check.
- Postgres: triggers are ensured at every capture, so tables created later
are covered; a table created during a capture is refused; captures are
serialized per database.
- The Postgres lock tests wait until pg_stat_activity shows a backend
waiting on a Lock, instead of treating 500ms as proof of blocking.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse every function value instead of verifying callbacks; working-trigger check (TASK-3388)
Codex round 2 found five P1s in the checker and one P2 in the harness.
Four of the P1s were bypasses of one mechanism: verifying where a callback
parameter came from. They were reassignment, aliasing through a pointer, a
method expression shifting the argument index, and interface dispatch past
the call-site census. This is a guard, not a proof, so the mechanism is
removed rather than patched again:
- withFence(fn) is gone; app mutations open and commit their FencedTx
directly.
- Every call through a function value is refused (dynamic-call).
- Any use of a variable, parameter or field of function type is refused
(func-value). This also closes the fifth P1: an exported function-typed
package var in the store's own import closure, run through
sync.Once.Do, whose initializer the walk excludes.
Interface checks now judge the INSTANTIATED receiver, since Mutator[string]
can be implemented where Mutator[T] is not; Origin() is used only for
indexing.
Harness P2: a pad_write_audit_t trigger counts only when it is enabled,
statement-level, AFTER, on INSERT/UPDATE/DELETE, and calls
pad_write_audit_fn. Otherwise it is dropped and recreated.
Fixtures genericiface, callbackparam, methodexprcallback and oncevar each
pass against the round-1 checker and are caught now. A Postgres test
disables the trigger and expects the next capture to see the write.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse interface dispatch on unbound type arguments (TASK-3388)
Codex round 3: a generic helper run[T](m Mutator[T]) is walked once, with T
unbound, so Mutator[T] never matched *store.Store and the call went
unflagged. An interface call whose receiver is, or is instantiated with, a
type parameter is now refused, rather than propagating type arguments
through the walk. Fixture genericifacebody passes the round-2 checker and is
caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): no interface dispatch inside a generic body (TASK-3388)
Codex round 4 found two more ways a type parameter hides from the
round-3 check:
- an anonymous interface constraint (or a generic alias)
- a type parameter nested inside a func, slice, map or alias type argument
Chasing type parameters through every type form is open-ended, so the class
is closed outright: a generic function body, walked once with its parameters
unbound, may make no interface method call except on error. A non-generic
function cannot hold an unbound type parameter, so the ordinary
implemented-by-store check stays exact there. Fixtures genericconstraint and
genericnested both pass the round-3 checker and are caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
306 lines
9.4 KiB
Go
306 lines
9.4 KiB
Go
package storetest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// captureStore is Postgres under make test-pg, SQLite otherwise.
|
|
func captureStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
|
|
return NewPostgres(t)
|
|
}
|
|
return NewSQLite(t)
|
|
}
|
|
|
|
func seedCollection(t *testing.T, s *store.Store) (*models.Workspace, *models.Collection) {
|
|
t.Helper()
|
|
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Capture"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
col, err := s.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Tickets",
|
|
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open"}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ws, col
|
|
}
|
|
|
|
// The instrument must go RED on real writes before its silence about an app
|
|
// write means anything. A human create writes the item, and on SQLite its
|
|
// search-index triggers write FTS5 shadow tables at depth > 0.
|
|
func TestCaptureWrites_SeesARealWrite(t *testing.T) {
|
|
s := captureStore(t)
|
|
ws, col := seedCollection(t, s)
|
|
writes := CaptureWrites(t, s, func() {
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Captured", Content: "searchable body", Fields: `{"status":"open"}`}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
tables := Tables(writes)
|
|
if !contains(tables, "items") {
|
|
t.Fatalf("an item create did not capture items: %v", tables)
|
|
}
|
|
if s.D().Driver() == store.DriverPostgres {
|
|
return
|
|
}
|
|
// The schema's FTS triggers write the virtual items_fts (blind); FTS5
|
|
// then writes its shadow tables through its own statements, which the
|
|
// hook sees at depth 0.
|
|
var shadow bool
|
|
for _, w := range writes {
|
|
if strings.HasPrefix(w.Table, "items_fts_") {
|
|
shadow = true
|
|
}
|
|
}
|
|
if !shadow {
|
|
t.Fatalf("FTS5 shadow writes not captured: %v", writes)
|
|
}
|
|
}
|
|
|
|
// A write a TRIGGER makes into an ordinary table is captured, at depth > 0.
|
|
// No trigger in today's schema writes an ordinary table (every data-writing
|
|
// trigger targets an FTS virtual table), so a test-only one on this store's
|
|
// private copy stands in for the first that does.
|
|
func TestCaptureWrites_SeesATriggerWrite(t *testing.T) {
|
|
s := NewSQLite(t)
|
|
for _, q := range []string{
|
|
`CREATE TABLE cap_src (x TEXT)`,
|
|
`CREATE TABLE cap_log (x TEXT)`,
|
|
`CREATE TRIGGER cap_tr AFTER INSERT ON cap_src BEGIN INSERT INTO cap_log VALUES (NEW.x); END`,
|
|
} {
|
|
if _, err := s.DB().Exec(q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writes := CaptureWrites(t, s, func() {
|
|
if _, err := s.DB().Exec(`INSERT INTO cap_src VALUES ('a')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
var direct, triggered bool
|
|
for _, w := range writes {
|
|
if w.Table == "cap_src" && w.Depth == 0 {
|
|
direct = true
|
|
}
|
|
if w.Table == "cap_log" && w.Depth > 0 {
|
|
triggered = true
|
|
}
|
|
}
|
|
if !direct || !triggered {
|
|
t.Fatalf("direct=%v triggered=%v: %v", direct, triggered, writes)
|
|
}
|
|
}
|
|
|
|
// A read writes nothing, and the capture says so.
|
|
func TestCaptureWrites_ReadCapturesNothing(t *testing.T) {
|
|
s := captureStore(t)
|
|
ws, col := seedCollection(t, s)
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Existing", Fields: `{"status":"open"}`}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writes := CaptureWrites(t, s, func() {
|
|
if _, err := s.ListItems(ws.ID, models.ItemListParams{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
if len(writes) != 0 {
|
|
t.Fatalf("a read captured writes: %v", writes)
|
|
}
|
|
}
|
|
|
|
// The pre-update hook is blind to virtual tables and to SQLite's own system
|
|
// tables. DOC-3371 §4 states that inventory exactly; a new virtual or system
|
|
// table fails here until its blind spot is considered.
|
|
func TestBlindInventory(t *testing.T) {
|
|
s := NewSQLite(t)
|
|
rows, err := s.DB().Query(`SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND (sql LIKE 'CREATE VIRTUAL TABLE%' OR name LIKE 'sqlite\_%' ESCAPE '\')`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var got []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = append(got, name)
|
|
}
|
|
sort.Strings(got)
|
|
want := []string{"comments_fts", "documents_fts", "items_fts", "sqlite_sequence"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("blind inventory = %v, want exactly %v", got, want)
|
|
}
|
|
}
|
|
|
|
func contains(list []string, v string) bool {
|
|
for _, x := range list {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fatalRecorder stands in for *testing.T so a test can observe the harness
|
|
// REFUSING a capture: Fatalf records the message and ends the goroutine, as
|
|
// the real one does.
|
|
type fatalRecorder struct {
|
|
testing.TB
|
|
msg string
|
|
}
|
|
|
|
func (r *fatalRecorder) Fatalf(format string, args ...any) {
|
|
r.msg = fmt.Sprintf(format, args...)
|
|
runtime.Goexit()
|
|
}
|
|
|
|
func (r *fatalRecorder) Fatal(args ...any) {
|
|
r.msg = fmt.Sprint(args...)
|
|
runtime.Goexit()
|
|
}
|
|
|
|
// runRecorded runs CaptureWrites under a recorder and returns its fatal
|
|
// message, or "" if the capture completed.
|
|
func runRecorded(t *testing.T, s *store.Store, fn func()) string {
|
|
t.Helper()
|
|
r := &fatalRecorder{TB: t}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
CaptureWrites(r, s, fn)
|
|
}()
|
|
<-done
|
|
return r.msg
|
|
}
|
|
|
|
// A write committed on a connection OTHER than the hooked one is not
|
|
// observed by the hook, so the capture must refuse rather than report a
|
|
// clean result (codex round 1 on TASK-3388: before this check, widening the
|
|
// pool mid-capture lost a write with both sentinels passing).
|
|
func TestCaptureWrites_RefusesAWriteOnAnotherConnection(t *testing.T) {
|
|
s := NewSQLite(t)
|
|
if _, err := s.DB().Exec(`CREATE TABLE cap_other (x TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg := runRecorded(t, s, func() {
|
|
db := s.DB()
|
|
db.SetMaxOpenConns(2)
|
|
hooked, err := db.Conn(context.Background())
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer hooked.Close()
|
|
// With the hooked connection checked out, this runs on a second one.
|
|
if _, err := db.Exec(`INSERT INTO cap_other VALUES ('lost')`); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
// Either refusal is right: another connection committed, or the hooked
|
|
// connection itself was replaced.
|
|
if !strings.Contains(msg, "another connection committed") && !strings.Contains(msg, "no longer on the hooked connection") {
|
|
t.Fatalf("capture did not refuse a write on another connection; got %q", msg)
|
|
}
|
|
}
|
|
|
|
// Postgres: a table created after an earlier capture is covered by the next
|
|
// one, and a table created DURING a capture is refused, not silently missed
|
|
// (codex round 1 on TASK-3388).
|
|
func TestCaptureWrites_PostgresCoversTablesCreatedLater(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
s := NewPostgres(t)
|
|
CaptureWrites(t, s, func() {})
|
|
for _, q := range []string{
|
|
`CREATE TABLE cap_src (x TEXT)`,
|
|
`CREATE TABLE cap_log (x TEXT)`,
|
|
`CREATE FUNCTION cap_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO cap_log VALUES (NEW.x); RETURN NULL; END $$`,
|
|
`CREATE TRIGGER cap_tr AFTER INSERT ON cap_src FOR EACH ROW EXECUTE FUNCTION cap_fn()`,
|
|
} {
|
|
if _, err := s.DB().Exec(q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writes := CaptureWrites(t, s, func() {
|
|
if _, err := s.DB().Exec(`INSERT INTO cap_src VALUES ('a')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
if tables := Tables(writes); !contains(tables, "cap_src") || !contains(tables, "cap_log") {
|
|
t.Fatalf("tables created after the first capture were not covered: %v", tables)
|
|
}
|
|
msg := runRecorded(t, s, func() {
|
|
if _, err := s.DB().Exec(`CREATE TABLE cap_during (x TEXT)`); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if !strings.Contains(msg, "unaudited") {
|
|
t.Fatalf("a table created during capture was not refused; got %q", msg)
|
|
}
|
|
}
|
|
|
|
// The data_version branch: the hooked connection stays in place while a
|
|
// write commits through a different handle on the same database file.
|
|
func TestCaptureWrites_RefusesACommitFromAnotherHandle(t *testing.T) {
|
|
s := NewSQLite(t)
|
|
if _, err := s.DB().Exec(`CREATE TABLE cap_other (x TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var path string
|
|
if err := s.DB().QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&path); err != nil || path == "" {
|
|
t.Fatalf("database file: %q %v", path, err)
|
|
}
|
|
other, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer other.Close()
|
|
msg := runRecorded(t, s, func() {
|
|
if _, err := other.Exec(`INSERT INTO cap_other VALUES ('lost')`); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if !strings.Contains(msg, "another connection committed") {
|
|
t.Fatalf("capture did not refuse a commit from another handle; got %q", msg)
|
|
}
|
|
}
|
|
|
|
// A same-named trigger that is disabled is not coverage: the next capture
|
|
// replaces it and sees the write (codex round 2 on TASK-3388).
|
|
func TestCaptureWrites_PostgresReplacesABrokenTrigger(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
s := NewPostgres(t)
|
|
ws, col := seedCollection(t, s)
|
|
CaptureWrites(t, s, func() {})
|
|
if _, err := s.DB().Exec(`ALTER TABLE items DISABLE TRIGGER pad_write_audit_t`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writes := CaptureWrites(t, s, func() {
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "After tamper", Fields: `{"status":"open"}`}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
if !contains(Tables(writes), "items") {
|
|
t.Fatalf("a disabled audit trigger hid the write: %v", Tables(writes))
|
|
}
|
|
}
|