mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Add a database/sql MetaStore (db_sql.go) targeting SQLite locally and Postgres/Supabase in production through one portable schema: real tables (accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices), idempotent CREATE TABLE IF NOT EXISTS migrations at Open, ?→$N placeholder rebinding for Postgres, times as RFC3339 text, and transactional multi-row writes (an org and its members). Pure-Go drivers only (modernc.org/sqlite, jackc/pgx v5) so CGO-free builds keep working. One shared conformance suite (db_conformance_test.go) runs the same service-level operations — accounts+tokens, policy, pending/approve, projects create-or-join/rename/delete, org roles, invite create/redeem/ uses/validity, share create/revoke/expiry, devices — then reopens the store and asserts everything persisted. Runs against file AND sqlite always, and postgres when BDRIVE_TEST_POSTGRES is set. Verified green on all three against a real local Postgres. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
242 lines
6.6 KiB
Go
242 lines
6.6 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()
|
|
db.Exec(`DROP TABLE IF EXISTS accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices`)
|
|
},
|
|
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)
|
|
}
|
|
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
|
|
if err := projects.Delete(p2.ID); 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")
|
|
}
|
|
|
|
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"})
|
|
|
|
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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|