This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / db / migrations_test.go
8.1 kB 259 lines
1package db 2 3import ( 4 "context" 5 "database/sql" 6 "path/filepath" 7 "slices" 8 "testing" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "tangled.org/core/rbac/v2" 12) 13 14// seedLegacyDB writes a pre-rbac/v2 spindle database: legacy `repos`, the tables that used to 15// hold ACL state, and a casbin `acl` table as rbac.NewEnforcer would have left it. 16func seedLegacyDB(t *testing.T, path string) { 17 t.Helper() 18 raw, err := sql.Open("sqlite3", path) 19 if err != nil { 20 t.Fatalf("open: %v", err) 21 } 22 defer raw.Close() 23 24 if _, err := raw.Exec(` 25 create table migrations ( 26 id integer primary key autoincrement, 27 name text unique 28 ); 29 30 create table known_dids (did text primary key); 31 32 create table spindle_members ( 33 id integer primary key autoincrement, 34 did text not null, 35 rkey text not null, 36 instance text not null, 37 subject text not null, 38 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 39 unique (did, rkey) 40 ); 41 42 create table repos ( 43 id integer primary key autoincrement, 44 knot text not null, 45 owner text not null, 46 rkey text not null, 47 repo_did text, 48 created_at text, 49 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 50 unique(owner, rkey) 51 ); 52 53 create table repo_collaborators ( 54 id integer primary key autoincrement, 55 owner_did text not null, 56 rkey text not null, 57 subject text not null, 58 repo_did text not null, 59 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 60 unique(owner_did, rkey) 61 ); 62 63 create table acl ( 64 p_type text default '' not null, 65 v0 text default '' not null, 66 v1 text default '' not null, 67 v2 text default '' not null, 68 v3 text default '' not null, 69 v4 text default '' not null, 70 v5 text default '' not null 71 ); 72 73 -- one repo_did with two rkeys (rename/alias siblings) plus a row that never got a did 74 insert into repos (knot, owner, rkey, repo_did, created_at) values 75 ('knot.test', 'did:plc:alice', 'old-rkey', 'did:plc:repo1', '2024-01-01T00:00:00Z'), 76 ('knot.test', 'did:plc:alice', 'new-rkey', 'did:plc:repo1', '2024-06-01T00:00:00Z'), 77 ('knot.test', 'did:plc:alice', 'no-did-rkey', null, null); 78 79 -- did = whoever published the record (the spindle owner), subject = the member 80 insert into spindle_members (did, rkey, instance, subject) values 81 ('did:plc:owner', '3kmember001', 'spindle.test', 'did:plc:member'), 82 ('did:plc:owner', '3kmember002', 'spindle.test', 'did:plc:member2'); 83 84 -- known_dids mixed collaborators in with members, so it must not seed the members table 85 insert into known_dids (did) values 86 ('did:plc:member'), 87 ('did:plc:bob'); 88 89 insert into acl (p_type, v0, v1, v2, v3) values 90 ('g', 'did:plc:owner', 'server:owner', 'spindle:spindle.test', ''), 91 ('g', 'did:plc:member', 'server:member', 'spindle:spindle.test', ''), 92 ('g', 'did:plc:member2', 'server:member', 'spindle:spindle.test', ''), 93 -- repo policies, deliberately duplicated to exercise the distinct 94 ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:owner'), 95 ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:push'), 96 ('p', 'did:plc:alice', 'thisserver', 'did:plc:repo1', 'repo:settings'), 97 ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:collaborator'), 98 ('p', 'did:plc:bob', 'thisserver', 'did:plc:repo1', 'repo:push'), 99 ('p', 'server:owner', 'thisserver', 'did:plc:repo1', 'repo:delete'); 100 `); err != nil { 101 t.Fatalf("seed: %v", err) 102 } 103} 104 105func TestMigrateLegacyAcl(t *testing.T) { 106 path := filepath.Join(t.TempDir(), "spindle.db") 107 seedLegacyDB(t, path) 108 109 d, err := Make(context.Background(), path) 110 if err != nil { 111 t.Fatalf("Make: %v", err) 112 } 113 defer d.Close() 114 115 t.Run("collapses repo_did siblings", func(t *testing.T) { 116 repo, err := d.GetRepoByDid("did:plc:repo1") 117 if err != nil { 118 t.Fatalf("GetRepoByDid: %v", err) 119 } 120 // prefers the row with the latest created_at, like the old CollapseRepoSiblings 121 if repo.Rkey != "new-rkey" { 122 t.Errorf("kept rkey %q, want new-rkey", repo.Rkey) 123 } 124 125 var n int 126 if err := d.QueryRow(`select count(*) from repos`).Scan(&n); err != nil { 127 t.Fatalf("count repos: %v", err) 128 } 129 if n != 1 { 130 t.Errorf("repos has %d rows, want 1 (siblings collapsed, did-less row dropped)", n) 131 } 132 }) 133 134 t.Run("seeds members from spindle_members", func(t *testing.T) { 135 members, err := d.ListAllowedMembers() 136 if err != nil { 137 t.Fatalf("ListAllowedMembers: %v", err) 138 } 139 140 // `subject` is the member, `did` is the inviting owner - both are members 141 for _, want := range []syntax.DID{"did:plc:member", "did:plc:member2", "did:plc:owner"} { 142 if !slices.Contains(members, want) { 143 t.Errorf("members missing %s, got %v", want, members) 144 } 145 } 146 147 // known_dids also held collaborators; seeding `members` from it would promote them 148 // to spindle members, i.e. let them register their own repos 149 if slices.Contains(members, "did:plc:bob") { 150 t.Errorf("collaborator did:plc:bob was granted spindle membership, got %v", members) 151 } 152 }) 153 154 t.Run("rewrites acl into acl_2 grouping rows", func(t *testing.T) { 155 rows, err := d.Query(`select p_type, v0, v1, v2 from acl_2 order by v1, v0`) 156 if err != nil { 157 t.Fatalf("query acl_2: %v", err) 158 } 159 defer rows.Close() 160 161 var got []string 162 for rows.Next() { 163 var pType, v0, v1, v2 string 164 if err := rows.Scan(&pType, &v0, &v1, &v2); err != nil { 165 t.Fatalf("scan: %v", err) 166 } 167 got = append(got, pType+"|"+v0+"|"+v1+"|"+v2) 168 } 169 if err := rows.Err(); err != nil { 170 t.Fatalf("rows: %v", err) 171 } 172 173 want := []string{ 174 "g|did:plc:bob|repo:collaborator|did:plc:repo1", 175 "g|did:plc:alice|repo:owner|did:plc:repo1", 176 } 177 if len(got) != len(want) { 178 t.Fatalf("acl_2 rows = %v, want %v", got, want) 179 } 180 for i := range want { 181 if got[i] != want[i] { 182 t.Errorf("acl_2 row %d = %q, want %q", i, got[i], want[i]) 183 } 184 } 185 }) 186 187 // the point of the rewrite: migrated grants have to actually enforce 188 t.Run("migrated grants enforce", func(t *testing.T) { 189 e, err := rbac.NewEnforcer(path) 190 if err != nil { 191 t.Fatalf("rbac.NewEnforcer: %v", err) 192 } 193 194 for _, tc := range []struct { 195 name string 196 did syntax.DID 197 want bool 198 }{ 199 {"owner", "did:plc:alice", true}, 200 {"collaborator", "did:plc:bob", true}, 201 {"stranger", "did:plc:eve", false}, 202 } { 203 ok, err := e.IsRepoSecretsAllowed(tc.did, "did:plc:repo1") 204 if err != nil { 205 t.Fatalf("IsRepoSecretsAllowed(%s): %v", tc.name, err) 206 } 207 if ok != tc.want { 208 t.Errorf("IsRepoSecretsAllowed(%s) = %v, want %v", tc.name, ok, tc.want) 209 } 210 211 ok, err = e.IsRepoCiTriggerAllowed(tc.did, "did:plc:repo1") 212 if err != nil { 213 t.Fatalf("IsRepoCiTriggerAllowed(%s): %v", tc.name, err) 214 } 215 if ok != tc.want { 216 t.Errorf("IsRepoCiTriggerAllowed(%s) = %v, want %v", tc.name, ok, tc.want) 217 } 218 } 219 }) 220 221 // the base schema recreates these empty on every boot, so assert the stale rows are gone 222 // rather than the tables - reopen to get past the recreate. 223 t.Run("clears legacy acl tables", func(t *testing.T) { 224 d.Close() 225 reopened, err := Make(context.Background(), path) 226 if err != nil { 227 t.Fatalf("reopen: %v", err) 228 } 229 defer reopened.Close() 230 231 for _, table := range []string{"known_dids", "repo_collaborators", "spindle_members"} { 232 var n int 233 if err := reopened.QueryRow(`select count(*) from ` + table).Scan(&n); err != nil { 234 t.Fatalf("count %s: %v", table, err) 235 } 236 if n != 0 { 237 t.Errorf("table %s still has %d stale rows", table, n) 238 } 239 } 240 }) 241} 242 243// TestMigrateFreshDB covers the case where no `acl` table exists yet: it is created by the 244// casbin adapter inside rbac.NewEnforcer, which runs after Make. 245func TestMigrateFreshDB(t *testing.T) { 246 d, err := Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) 247 if err != nil { 248 t.Fatalf("Make on fresh db: %v", err) 249 } 250 defer d.Close() 251 252 members, err := d.ListAllowedMembers() 253 if err != nil { 254 t.Fatalf("ListAllowedMembers: %v", err) 255 } 256 if len(members) != 0 { 257 t.Errorf("fresh db has members %v, want none", members) 258 } 259}