This repository has no description
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}