mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A teammate's agent writes a decision into runbook.md, it lands in your folder in fifteen seconds, and nothing tells you. Two halves of the fix: post_sync's payload gains `user` and `note` per changed entry, so a local recipe can post "Dana's Claude updated shared/findings/eu-checkout.md" instead of "1 file changed" — the copy was blocked by the payload, not by formatting. journal.LastOps is what makes it possible: Replay drops deleted paths and FileState carries no author, so deletes had nothing to attribute to. Less, Replay and FileState are untouched. And a hub-side webhook: one optional Webhook on Project, admin-set, https on Slack/Teams hosts only, never returned by any API. It fires AFTER the response from both places an op is born — handleStorePut and RemoteSource.OnCommit at the end of appendOps, which covers uploads, removes, restores and undo-run in one field. Goroutine, bounded client, log once, no retry queue: a slow Slack must never 502 a push, because the client retries a 502 and the retry re-fires the webhook. A project with no webhook set produces zero new behavior and zero new outbound requests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package webapp
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A metaBackend is one MetaStore implementation under test. reset clears any
|
|
// shared durable state before the run; open returns a fresh store over the
|
|
// SAME underlying storage, so the suite can write, reopen, and prove the data
|
|
// persisted.
|
|
type metaBackend struct {
|
|
name string
|
|
reset func(t *testing.T)
|
|
open func(t *testing.T) MetaStore
|
|
}
|
|
|
|
func metaBackends(t *testing.T) []metaBackend {
|
|
dir := t.TempDir()
|
|
sqlitePath := filepath.Join(t.TempDir(), "meta.db")
|
|
backends := []metaBackend{
|
|
{
|
|
name: "file",
|
|
reset: func(t *testing.T) {},
|
|
open: func(t *testing.T) MetaStore {
|
|
s, err := OpenFileStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
},
|
|
},
|
|
{
|
|
name: "sqlite",
|
|
reset: func(t *testing.T) {},
|
|
open: func(t *testing.T) MetaStore {
|
|
s, err := OpenSQLStore("sqlite", sqlitePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
},
|
|
},
|
|
}
|
|
// Postgres/Supabase is exercised only when a DSN is reachable.
|
|
if dsn := os.Getenv("BDRIVE_TEST_POSTGRES"); dsn != "" {
|
|
backends = append(backends, metaBackend{
|
|
name: "postgres",
|
|
reset: func(t *testing.T) {
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("postgres reset: %v", err)
|
|
}
|
|
defer db.Close()
|
|
// EVERY table migrate() creates, schema_meta included. A
|
|
// partial reset is not a reset: dropping projects while
|
|
// leaving schema_meta recording version 1 reproduces, exactly,
|
|
// the half-applied migration addColumns refuses to touch — the
|
|
// next open rebuilds projects WITHOUT the guarded
|
|
// default_level column and then finds a version that says it
|
|
// should be there. It also used to leave project_perms and
|
|
// device_rows rows behind for the following test to inherit.
|
|
db.Exec(`DROP TABLE IF EXISTS accounts, tokens, auth_policy, projects, project_perms,
|
|
orgs, org_members, invites, shares, devices, device_rows, read_stats,
|
|
read_sessions, meta_version, schema_meta`)
|
|
},
|
|
open: func(t *testing.T) MetaStore {
|
|
s, err := OpenSQLStore("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
},
|
|
})
|
|
} else {
|
|
t.Log("BDRIVE_TEST_POSTGRES not set — postgres backend UNTESTED in this run")
|
|
}
|
|
return backends
|
|
}
|
|
|
|
// TestMetaStoreConformance runs the same service-level operations against every
|
|
// backend, then reopens the store and asserts the data survived — covering
|
|
// accounts+tokens, the signup policy, pending/approve, projects (create-or-join,
|
|
// rename, delete), orgs (roles), invites (create/redeem/uses/validity), shares
|
|
// (create/revoke/expiry), and devices.
|
|
func TestMetaStoreConformance(t *testing.T) {
|
|
for _, be := range metaBackends(t) {
|
|
t.Run(be.name, func(t *testing.T) {
|
|
be.reset(t)
|
|
|
|
// ---- write everything through the services ----
|
|
st := be.open(t)
|
|
|
|
auth, err := NewBuiltinAuth(st.Accounts(), true, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// account + token
|
|
u, err := auth.signup("dev@x.io", "Dev", "password1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := auth.issueToken(u.ID, "cli")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := auth.userForToken(tok); !ok {
|
|
t.Fatal("token should authenticate its account")
|
|
}
|
|
// pending + policy
|
|
if err := auth.SetPolicy(false, true); err != nil { // require approval
|
|
t.Fatal(err)
|
|
}
|
|
pend, err := auth.signup("pending@x.io", "Pend", "password1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pend.Status != statusPending {
|
|
t.Fatalf("new account status = %q, want pending", pend.Status)
|
|
}
|
|
|
|
projects, err := NewProjectDB(st.Projects())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p1, created, err := projects.GetOrCreate("wiki", "o-1")
|
|
if err != nil || !created {
|
|
t.Fatalf("create wiki: created=%v err=%v", created, err)
|
|
}
|
|
if _, again, _ := projects.GetOrCreate("wiki", "o-1"); again {
|
|
t.Fatal("same name+org must join, not create")
|
|
}
|
|
if _, other, _ := projects.GetOrCreate("wiki", "o-2"); !other {
|
|
t.Fatal("same name in a different org must create")
|
|
}
|
|
if err := projects.Rename(p1.ID, "handbook"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
desc, icon := "everything support needs", "book-open"
|
|
hook := "https://hooks.slack.com/services/T0/B0/conformance"
|
|
if err := projects.Update(p1.ID, nil, &desc, &icon, &hook); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
|
|
if err := projects.SetPerm(p2.ID, "doomed@x.io", PermAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := projects.Delete(p2.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// per-project permissions ride along with the project record
|
|
if err := projects.SetCreator(p1.ID, "Boss@X.io"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := projects.SetDefault(p1.ID, PermNone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := projects.SetTemplate(p1.ID, "para"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for email, level := range map[string]string{
|
|
"boss@x.io": PermAdmin, "reader@x.io": PermRead, "cutoff@x.io": PermNone,
|
|
} {
|
|
if err := projects.SetPerm(p1.ID, email, level); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
orgs, err := NewOrgDB(st.Orgs())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
org, err := orgs.Create("Acme", "boss@x.io")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := orgs.AddMember(org.ID, "worker@x.io", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := orgs.SetRole(org.ID, "worker@x.io", RoleOwner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inv, err := orgs.CreateInvite(org.ID, "boss@x.io", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orgs.RecordInviteUse(inv.Token)
|
|
if !orgs.ValidInvite(inv.Token) {
|
|
t.Fatal("fresh invite should be valid")
|
|
}
|
|
|
|
shares, err := NewShareDB(st.Shares())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
live, err := shares.Create(p1.ID, "handbook.md", "boss@x.io", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gone, _ := shares.Create(p1.ID, "temp.md", "boss@x.io", time.Hour)
|
|
if !shares.Revoke(gone.Token) {
|
|
t.Fatal("revoke should succeed")
|
|
}
|
|
dated, _ := shares.Create(p1.ID, "deck.md", "boss@x.io", 0)
|
|
if _, ok, err := shares.SetExpiry(dated.Token, time.Hour); err != nil || !ok {
|
|
t.Fatalf("set expiry: %v %v", ok, err)
|
|
}
|
|
|
|
devices, err := NewDeviceRegistry(st.Devices())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
devices.Observe(DeviceInfo{ID: "d1", Name: "laptop", OS: "mac", User: "dev@x.io", IP: "1.2.3.4"})
|
|
|
|
reads, err := NewReadLedger(st.Reads(), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reads.Record(p1.ID, "handbook.md", ReadKindHuman, "dev@x.io")
|
|
reads.Record(p1.ID, "handbook.md", ReadKindHuman, "boss@x.io")
|
|
reads.Record(p1.ID, "wiki/deep.md", ReadKindAgent, "d1")
|
|
// Per-session read detail rides its own repo, so it needs its own
|
|
// pass on every backend: two sessions on one device must stay two
|
|
// sets of rows, and one of them must prune away by date.
|
|
reads.WithSessions(st.SessionReads(), 0)
|
|
reads.RecordSession(p1.ID, "sess-a", "d1", "handbook.md")
|
|
reads.RecordSession(p1.ID, "sess-a", "d1", "wiki/deep.md")
|
|
reads.RecordSession(p1.ID, "sess-b", "d1", "handbook.md")
|
|
if err := reads.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SessionReads().PutBatch([]SessionRead{{
|
|
Project: p1.ID, Session: "sess-old", Device: "d1", Path: "handbook.md",
|
|
Last: time.Now().UTC().Add(-90 * 24 * time.Hour),
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// ---- reopen and verify everything persisted ----
|
|
st2 := be.open(t)
|
|
defer st2.Close()
|
|
|
|
auth2, _ := NewBuiltinAuth(st2.Accounts(), true, nil)
|
|
if _, ok := auth2.userForToken(tok); !ok {
|
|
t.Fatal("token lost across reload")
|
|
}
|
|
if !auth2.RequireApproval || auth2.RequireVerification {
|
|
t.Fatal("policy lost across reload")
|
|
}
|
|
if got := auth2.PendingUsers(); len(got) != 1 || got[0].Email != "pending@x.io" {
|
|
t.Fatalf("pending users after reload = %+v", got)
|
|
}
|
|
if err := auth2.Approve(pend.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(auth2.PendingUsers()) != 0 {
|
|
t.Fatal("approve did not clear pending")
|
|
}
|
|
|
|
projects2, _ := NewProjectDB(st2.Projects())
|
|
list := projects2.List()
|
|
if len(list) != 2 { // handbook (o-1), wiki (o-2); scratch was deleted
|
|
t.Fatalf("projects after reload = %+v", list)
|
|
}
|
|
hb, ok := projects2.Get(p1.ID)
|
|
if !ok || hb.Name != "handbook" {
|
|
t.Fatalf("rename lost across reload: %+v", hb)
|
|
}
|
|
// Description/icon and creator/default_level are all columns
|
|
// migrate() has to ADD to an already-created projects table; the
|
|
// reopen above already ran migrate() a second time, so surviving
|
|
// here proves it's a no-op.
|
|
if hb.Description != "everything support needs" || hb.Icon != "book-open" {
|
|
t.Fatalf("description/icon lost across reload: %+v", hb)
|
|
}
|
|
if hb.Creator != "boss@x.io" || hb.Default != PermNone {
|
|
t.Fatalf("creator/default lost across reload: %+v", hb)
|
|
}
|
|
// The webhook is the field a SQL backend drops silently: PutMeta
|
|
// names its columns one by one, so a column the schema lacks is
|
|
// simply not written — green on the file backend, gone on Postgres.
|
|
if hb.Webhook != "https://hooks.slack.com/services/T0/B0/conformance" {
|
|
t.Fatalf("webhook did not survive a reopen: %q", hb.Webhook)
|
|
}
|
|
if hb.Template != "para" {
|
|
t.Fatalf("template lost across reload: %+v", hb)
|
|
}
|
|
if hb.Perms["boss@x.io"] != PermAdmin || hb.Perms["reader@x.io"] != PermRead ||
|
|
hb.Perms["cutoff@x.io"] != PermNone || len(hb.Perms) != 3 {
|
|
t.Fatalf("grants lost across reload: %+v", hb.Perms)
|
|
}
|
|
if _, ok := projects2.Get(p2.ID); ok {
|
|
t.Fatal("deleted project (and its grants) came back after reload")
|
|
}
|
|
|
|
orgs2, _ := NewOrgDB(st2.Orgs())
|
|
ro, ok := orgs2.Get(org.ID)
|
|
if !ok || ro.Members["boss@x.io"] != RoleOwner || ro.Members["worker@x.io"] != RoleOwner {
|
|
t.Fatalf("org roles lost across reload: %+v", ro)
|
|
}
|
|
if !orgs2.ValidInvite(inv.Token) {
|
|
t.Fatal("invite lost across reload")
|
|
}
|
|
if got := orgs2.ListInvites(org.ID); len(got) != 1 || got[0].Uses != 1 {
|
|
t.Fatalf("invite uses after reload = %+v", got)
|
|
}
|
|
|
|
shares2, _ := NewShareDB(st2.Shares())
|
|
if _, ok := shares2.Get(live.Token); !ok {
|
|
t.Fatal("live share lost across reload")
|
|
}
|
|
if _, ok := shares2.Get(gone.Token); ok {
|
|
t.Fatal("revoked share came back after reload")
|
|
}
|
|
if got, ok := shares2.Get(dated.Token); !ok || got.Expires.IsZero() {
|
|
t.Fatalf("patched expiry lost across reload: %+v", got)
|
|
}
|
|
|
|
devices2, _ := NewDeviceRegistry(st2.Devices())
|
|
d, ok := devices2.Get("d1")
|
|
if !ok || d.Name != "laptop" || d.IP != "1.2.3.4" {
|
|
t.Fatalf("device lost across reload: %+v", d)
|
|
}
|
|
|
|
reads2, err := NewReadLedger(st2.Reads(), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
heat := reads2.Heat(p1.ID, "", time.Time{})
|
|
if e := heat["handbook.md"]; e.Human != 2 || e.Readers != 2 {
|
|
t.Fatalf("read buckets lost across reload: %+v", e)
|
|
}
|
|
if e := heat["wiki/deep.md"]; e.Agent != 1 || e.Readers != 0 {
|
|
t.Fatalf("agent read bucket lost across reload: %+v", e)
|
|
}
|
|
if sub := reads2.Heat(p1.ID, "wiki", time.Time{}); len(sub) != 1 {
|
|
t.Fatalf("prefix heat = %+v, want only wiki/deep.md", sub)
|
|
}
|
|
|
|
sessions := st2.SessionReads()
|
|
got, err := sessions.ListBySession(p1.ID, "sess-a", "d1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 || got[0].Path != "handbook.md" || got[1].Path != "wiki/deep.md" {
|
|
t.Fatalf("session-a rows lost across reload: %+v", got)
|
|
}
|
|
if other, _ := sessions.ListBySession(p1.ID, "sess-b", "d1"); len(other) != 1 {
|
|
t.Fatalf("session-b rows = %+v, want its own single row", other)
|
|
}
|
|
// Wrong device, same session id: the query is keyed on both, which
|
|
// is what keeps a forged report off somebody else's run card.
|
|
if none, _ := sessions.ListBySession(p1.ID, "sess-a", "d2"); len(none) != 0 {
|
|
t.Fatalf("session rows leaked across devices: %+v", none)
|
|
}
|
|
if err := sessions.PruneBefore(time.Now().UTC().AddDate(0, 0, -30)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if old, _ := sessions.ListBySession(p1.ID, "sess-old", "d1"); len(old) != 0 {
|
|
t.Fatalf("prune left expired session rows: %+v", old)
|
|
}
|
|
if kept, _ := sessions.ListBySession(p1.ID, "sess-a", "d1"); len(kept) != 2 {
|
|
t.Fatalf("prune took recent session rows too: %+v", kept)
|
|
}
|
|
// The aggregate the run cards do NOT come from is untouched by any
|
|
// of that — session rows never enter the bucket map.
|
|
if e := reads2.Heat(p1.ID, "", time.Time{})["handbook.md"]; e.Human != 2 {
|
|
t.Fatalf("bucket heat changed with session rows: %+v", e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// migrate() only ever created tables, so the columns permissions added need a
|
|
// real ALTER on a hub that is already running. Prove both halves: an old
|
|
// projects table gains them (with its rows intact), and migrating again is a
|
|
// no-op rather than an error.
|
|
func TestSQLMigrateAddsPermissionColumns(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "old.db")
|
|
old, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// the pre-permissions schema, verbatim
|
|
if _, err := old.Exec(`CREATE TABLE projects (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, org TEXT NOT NULL DEFAULT '',
|
|
created TEXT NOT NULL DEFAULT '')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := old.Exec(`INSERT INTO projects (id,name,org,created) VALUES ('p-0000abcd','wiki','o-1','')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old.Close()
|
|
|
|
for i := 0; i < 2; i++ { // opening twice re-runs migrate()
|
|
st, err := OpenSQLStore("sqlite", path)
|
|
if err != nil {
|
|
t.Fatalf("open %d: %v", i, err)
|
|
}
|
|
projects, err := NewProjectDB(st.Projects())
|
|
if err != nil {
|
|
t.Fatalf("load %d: %v", i, err)
|
|
}
|
|
p, ok := projects.Get("p-0000abcd")
|
|
if !ok || p.Name != "wiki" {
|
|
t.Fatalf("pre-existing row lost on upgrade: %+v", p)
|
|
}
|
|
// An upgraded row has no creator and an empty default, which reads as
|
|
// write — the whole "no behavior change on upgrade" promise.
|
|
if p.Creator != "" || p.Default != "" || p.level() != PermWrite {
|
|
t.Fatalf("upgraded row = %+v, want empty creator/default reading as write", p)
|
|
}
|
|
if i == 0 {
|
|
if err := projects.SetPerm(p.ID, "a@x.io", PermRead); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else if p.Perms["a@x.io"] != PermRead {
|
|
t.Fatalf("grant lost across reopen: %+v", p.Perms)
|
|
}
|
|
st.Close()
|
|
}
|
|
}
|