package db import ( "context" "database/sql" "path/filepath" "slices" "testing" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/rbac/v2" ) // seedLegacyDB writes a pre-rbac/v2 spindle database: legacy `repos`, the tables that used to // hold ACL state, and a casbin `acl` table as rbac.NewEnforcer would have left it. func seedLegacyDB(t *testing.T, path string) { t.Helper() raw, err := sql.Open("sqlite3", path) if err != nil { t.Fatalf("open: %v", err) } defer raw.Close() if _, err := raw.Exec(` create table migrations ( id integer primary key autoincrement, name text unique ); create table known_dids (did text primary key); create table spindle_members ( id integer primary key autoincrement, did text not null, rkey text not null, instance text not null, subject text not null, created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique (did, rkey) ); create table repos ( id integer primary key autoincrement, knot text not null, owner text not null, rkey text not null, repo_did text, created_at text, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner, rkey) ); create table repo_collaborators ( id integer primary key autoincrement, owner_did text not null, rkey text not null, subject text not null, repo_did text not null, addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(owner_did, rkey) ); create table acl ( p_type text default '' not null, v0 text default '' not null, v1 text default '' not null, v2 text default '' not null, v3 text default '' not null, v4 text default '' not null, v5 text default '' not null ); -- one repo_did with two rkeys (rename/alias siblings) plus a row that never got a did insert into repos (knot, owner, rkey, repo_did, created_at) values ('knot.test', 'did:plc:alice', 'old-rkey', 'did:plc:repo1', '2024-01-01T00:00:00Z'), ('knot.test', 'did:plc:alice', 'new-rkey', 'did:plc:repo1', '2024-06-01T00:00:00Z'), ('knot.test', 'did:plc:alice', 'no-did-rkey', null, null); -- did = whoever published the record (the spindle owner), subject = the member insert into spindle_members (did, rkey, instance, subject) values ('did:plc:owner', '3kmember001', 'spindle.test', 'did:plc:member'), ('did:plc:owner', '3kmember002', 'spindle.test', 'did:plc:member2'); -- known_dids mixed collaborators in with members, so it must not seed the members table insert into known_dids (did) values ('did:plc:member'), ('did:plc:bob'); insert into acl (p_type, v0, v1, v2, v3) values ('g', 'did:plc:owner', 'server:owner', 'spindle:spindle.test', ''), ('g', 'did:plc:member', 'server:member', 'spindle:spindle.test', ''), ('g', 'did:plc:member2', 'server:member', 'spindle:spindle.test', ''), -- repo policies, deliberately duplicated to exercise the distinct ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:owner'), ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:push'), ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:settings'), ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:collaborator'), ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:push'), ('p', 'server:owner', 'thisserver', 'did:plc:repo1', 'repo:delete'); `); err != nil { t.Fatalf("seed: %v", err) } } func TestMigrateLegacyAcl(t *testing.T) { path := filepath.Join(t.TempDir(), "spindle.db") seedLegacyDB(t, path) d, err := Make(context.Background(), path) if err != nil { t.Fatalf("Make: %v", err) } defer d.Close() t.Run("collapses repo_did siblings", func(t *testing.T) { repo, err := d.GetRepoByDid("did:plc:repo1") if err != nil { t.Fatalf("GetRepoByDid: %v", err) } // prefers the row with the latest created_at, like the old CollapseRepoSiblings if repo.Rkey != "new-rkey" { t.Errorf("kept rkey %q, want new-rkey", repo.Rkey) } var n int if err := d.QueryRow(`select count(*) from repos`).Scan(&n); err != nil { t.Fatalf("count repos: %v", err) } if n != 1 { t.Errorf("repos has %d rows, want 1 (siblings collapsed, did-less row dropped)", n) } }) t.Run("seeds members from spindle_members", func(t *testing.T) { members, err := d.ListAllowedMembers() if err != nil { t.Fatalf("ListAllowedMembers: %v", err) } // `subject` is the member, `did` is the inviting owner - both are members for _, want := range []syntax.DID{"did:plc:member", "did:plc:member2", "did:plc:owner"} { if !slices.Contains(members, want) { t.Errorf("members missing %s, got %v", want, members) } } // known_dids also held collaborators; seeding `members` from it would promote them // to spindle members, i.e. let them register their own repos if slices.Contains(members, "did:plc:bob") { t.Errorf("collaborator did:plc:bob was granted spindle membership, got %v", members) } }) t.Run("rewrites acl into acl_2 grouping rows", func(t *testing.T) { rows, err := d.Query(`select p_type, v0, v1, v2 from acl_2 order by v1, v0`) if err != nil { t.Fatalf("query acl_2: %v", err) } defer rows.Close() var got []string for rows.Next() { var pType, v0, v1, v2 string if err := rows.Scan(&pType, &v0, &v1, &v2); err != nil { t.Fatalf("scan: %v", err) } got = append(got, pType+"|"+v0+"|"+v1+"|"+v2) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } want := []string{ "g|did:plc:bob|repo:collaborator|did:plc:repo1", "g|did:plc:alice|repo:owner|did:plc:repo1", } if len(got) != len(want) { t.Fatalf("acl_2 rows = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("acl_2 row %d = %q, want %q", i, got[i], want[i]) } } }) // the point of the rewrite: migrated grants have to actually enforce t.Run("migrated grants enforce", func(t *testing.T) { e, err := rbac.NewEnforcer(path) if err != nil { t.Fatalf("rbac.NewEnforcer: %v", err) } for _, tc := range []struct { name string did syntax.DID want bool }{ {"owner", "did:plc:alice", true}, {"collaborator", "did:plc:bob", true}, {"stranger", "did:plc:eve", false}, } { ok, err := e.IsRepoSecretsAllowed(tc.did, "did:plc:repo1") if err != nil { t.Fatalf("IsRepoSecretsAllowed(%s): %v", tc.name, err) } if ok != tc.want { t.Errorf("IsRepoSecretsAllowed(%s) = %v, want %v", tc.name, ok, tc.want) } ok, err = e.IsRepoCiTriggerAllowed(tc.did, "did:plc:repo1") if err != nil { t.Fatalf("IsRepoCiTriggerAllowed(%s): %v", tc.name, err) } if ok != tc.want { t.Errorf("IsRepoCiTriggerAllowed(%s) = %v, want %v", tc.name, ok, tc.want) } } }) // the base schema recreates these empty on every boot, so assert the stale rows are gone // rather than the tables - reopen to get past the recreate. t.Run("clears legacy acl tables", func(t *testing.T) { d.Close() reopened, err := Make(context.Background(), path) if err != nil { t.Fatalf("reopen: %v", err) } defer reopened.Close() for _, table := range []string{"known_dids", "repo_collaborators", "spindle_members"} { var n int if err := reopened.QueryRow(`select count(*) from ` + table).Scan(&n); err != nil { t.Fatalf("count %s: %v", table, err) } if n != 0 { t.Errorf("table %s still has %d stale rows", table, n) } } }) } // TestMigrateFreshDB covers the case where no `acl` table exists yet: it is created by the // casbin adapter inside rbac.NewEnforcer, which runs after Make. func TestMigrateFreshDB(t *testing.T) { d, err := Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) if err != nil { t.Fatalf("Make on fresh db: %v", err) } defer d.Close() members, err := d.ListAllowedMembers() if err != nil { t.Fatalf("ListAllowedMembers: %v", err) } if len(members) != 0 { t.Errorf("fresh db has members %v, want none", members) } }