oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package directorydata
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "errors"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "amadan.net/oficina/home/internal/home/seal"
13 "amadan.net/oficina/home/internal/home/sealtest"
14 "amadan.net/rastrillo/rastrillo/db"
15 "amadan.net/rastrillo/rastrillo/migrate"
16)
17
18const personA = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa"
19const personB = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb"
20const privateName = "CANARY-TEAM-NAME-2b71"
21
22func fixture(t *testing.T) (*Store, *db.DB, string) {
23 t.Helper()
24 p := filepath.Join(t.TempDir(), "directory.db")
25 d, e := db.Open(p, nil)
26 if e != nil {
27 t.Fatal(e)
28 }
29 t.Cleanup(func() { d.Close() })
30 if _, e = migrate.Apply(context.Background(), d, Schema); e != nil {
31 t.Fatal(e)
32 }
33 sealer, e := seal.New(bytes.Repeat([]byte{17}, 32))
34 if e != nil {
35 t.Fatal(e)
36 }
37 s, e := New(d.Writer(), sealer, Config{Apps: []string{"docs", "sheets"}, ValidateRoute: func(r Route) bool { return r.Origin == "https://"+r.Label+"."+r.App+".oficina.works" }})
38 if e != nil {
39 t.Fatal(e)
40 }
41 return s, d, p
42}
43func exec(t *testing.T, d *sql.DB, q string, args ...any) {
44 t.Helper()
45 if _, e := d.Exec(q, args...); e != nil {
46 t.Fatal(e)
47 }
48}
49
50// Only test setup performs these explicit operator-owned writes.
51func team(t *testing.T, s *Store, sqid, label, name string) int64 {
52 t.Helper()
53 tx, e := s.db.Begin()
54 if e != nil {
55 t.Fatal(e)
56 }
57 defer tx.Rollback()
58 r, e := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label)
59 if e != nil {
60 t.Fatal(e)
61 }
62 id, e := r.LastInsertId()
63 if e != nil {
64 t.Fatal(e)
65 }
66 b, e := s.SealTeamName(id, name)
67 if e != nil {
68 t.Fatal(e)
69 }
70 if _, e = tx.Exec(`UPDATE teams SET sealed_name=? WHERE id=?`, b, id); e != nil {
71 t.Fatal(e)
72 }
73 if e = tx.Commit(); e != nil {
74 t.Fatal(e)
75 }
76 return id
77}
78func member(t *testing.T, s *Store, id int64, p, role string) {
79 t.Helper()
80 exec(t, s.db, `INSERT INTO memberships VALUES(?,?,?)`, id, p, role)
81}
82func instance(t *testing.T, s *Store, id int64, label, app, state string) {
83 t.Helper()
84 exec(t, s.db, `INSERT INTO team_instances VALUES(?,?,?,?,?)`, id, app, "https://"+label+"."+app+".oficina.works", label+"-"+app, state)
85}
86func TestIsolationRemovalAndNoAutomaticOwner(t *testing.T) {
87 s, _, _ := fixture(t)
88 ctx := context.Background()
89 a := team(t, s, "A1", "alpha", privateName)
90 b := team(t, s, "B2", "beta", "Other team")
91 instance(t, s, a, "alpha", "docs", "live")
92 instance(t, s, b, "beta", "docs", "live")
93 instance(t, s, a, "alpha", "sheets", "pending")
94 for _, p := range []string{personA, personB} {
95 got, e := s.Choices(ctx, p, "docs")
96 if e != nil || len(got) != 0 {
97 t.Fatal(got, e)
98 }
99 }
100 member(t, s, a, personA, "owner")
101 member(t, s, b, personB, "member")
102 got, e := s.Choices(ctx, personA, "docs")
103 if e != nil || len(got) != 1 || got[0].ID != a || got[0].Name != privateName || got[0].Role != "owner" {
104 t.Fatal(got, e)
105 }
106 for _, sqid := range []string{"B2", "Missing"} {
107 if _, e = s.TeamForMember(ctx, personA, sqid); !errors.Is(e, ErrNotFound) {
108 t.Fatal(e)
109 }
110 }
111 if got, e = s.Choices(ctx, personA, "sheets"); e != nil || len(got) != 0 {
112 t.Fatal(got, e)
113 }
114 member(t, s, b, personA, "member")
115 got, e = s.Choices(ctx, personA, "docs")
116 if e != nil || len(got) != 2 || got[0].ID != a || got[1].ID != b {
117 t.Fatal(got, e)
118 }
119 exec(t, s.db, `DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, personA)
120 if _, e = s.TeamForMember(ctx, personA, "A1"); !errors.Is(e, ErrNotFound) {
121 t.Fatal(e)
122 }
123 got, e = s.Choices(ctx, personA, "docs")
124 if e != nil || len(got) != 1 || got[0].ID != b {
125 t.Fatal(got, e)
126 }
127 exec(t, s.db, `UPDATE team_instances SET state='disabled' WHERE team_id=?`, b)
128 if got, e = s.Choices(ctx, personA, "docs"); e != nil || len(got) != 0 {
129 t.Fatal(got, e)
130 }
131}
132func TestSealedNamePrivacyAndBinding(t *testing.T) {
133 s, _, p := fixture(t)
134 a := team(t, s, "A1", "alpha", privateName)
135 b := team(t, s, "B2", "beta", "Other team")
136 member(t, s, a, personA, "member")
137 member(t, s, b, personA, "member")
138 sealtest.AssertNoPlaintext(t, p)
139 var ciphertext []byte
140 if e := s.db.QueryRow(`SELECT sealed_name FROM teams WHERE id=?`, a).Scan(&ciphertext); e != nil {
141 t.Fatal(e)
142 }
143 exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, b)
144 _, e := s.TeamForMember(context.Background(), personA, "B2")
145 if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) {
146 t.Fatal(e)
147 }
148 ciphertext[len(ciphertext)-1] ^= 1
149 exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, a)
150 if _, e = s.TeamForMember(context.Background(), personA, "A1"); !errors.Is(e, ErrUnavailable) {
151 t.Fatal(e)
152 }
153 sealtest.AssertNoPlaintext(t, p)
154}
155func TestSchemaStableIDsConstraintsAndPrivacy(t *testing.T) {
156 s, d, _ := fixture(t)
157 a := team(t, s, "A1", "alpha", privateName)
158 exec(t, s.db, `DELETE FROM teams WHERE id=?`, a)
159 b := team(t, s, "B2", "beta", "Other team")
160 if b <= a {
161 t.Fatalf("reused ID %d %d", a, b)
162 }
163 if _, e := migrate.Apply(context.Background(), d, Schema); e != nil {
164 t.Fatal(e)
165 }
166 for _, q := range []struct {
167 s string
168 a []any
169 }{
170 {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, "owner@example.invalid", "owner"}},
171 {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, personA, "admin"}},
172 {`INSERT INTO memberships VALUES(?,?,?)`, []any{a, personA, "owner"}},
173 {`UPDATE teams SET roster_epoch=0`, nil},
174 {`UPDATE teams SET sealed_name=?`, []any{privateName}},
175 {`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES('B2','gamma',zeroblob(29),1)`, nil},
176 } {
177 if _, e := s.db.Exec(q.s, q.a...); e == nil {
178 t.Fatalf("accepted %s", q.s)
179 }
180 }
181 rows, e := s.db.Query(`SELECT sql FROM sqlite_master WHERE type='table'`)
182 if e != nil {
183 t.Fatal(e)
184 }
185 defer rows.Close()
186 for rows.Next() {
187 var ddl string
188 if e = rows.Scan(&ddl); e != nil {
189 t.Fatal(e)
190 }
191 for _, forbidden := range []string{"email", "address", "credentials", "CREATE TABLE people", "CREATE TABLE sessions"} {
192 if strings.Contains(ddl, forbidden) {
193 t.Fatalf("forbidden schema %s", forbidden)
194 }
195 }
196 }
197}
198func TestUnsafeStoredRouteRefusesEntireList(t *testing.T) {
199 for _, mutation := range []string{
200 `UPDATE team_instances SET origin='https://attacker.example' WHERE team_id=2`,
201 `UPDATE team_instances SET origin='https://beta.docs.oficina.works/path' WHERE team_id=2`,
202 `UPDATE team_instances SET consumer_id='bad@identity' WHERE team_id=2`,
203 `PRAGMA ignore_check_constraints=ON; UPDATE team_instances SET state='unknown' WHERE team_id=2`,
204 } {
205 t.Run(mutation, func(t *testing.T) {
206 s, _, _ := fixture(t)
207 a := team(t, s, "A1", "alpha", privateName)
208 b := team(t, s, "B2", "beta", "Other team")
209 for i, id := range []int64{a, b} {
210 member(t, s, id, personA, "member")
211 instance(t, s, id, []string{"alpha", "beta"}[i], "docs", "live")
212 }
213 exec(t, s.db, mutation)
214 got, e := s.Choices(context.Background(), personA, "docs")
215 if !errors.Is(e, ErrUnavailable) || got != nil {
216 t.Fatal(got, e)
217 }
218 })
219 }
220}
221func TestInputAndErrorPrivacy(t *testing.T) {
222 s, _, _ := fixture(t)
223 for _, origin := range []string{"http://alpha.example", "https://user@example.com", "https://example.com/", "https://example.com?", "https://EXAMPLE.com", "https://example.com:443", "https://example.com#x", "https://example.com.", "https://localhost", "https://a..example"} {
224 if ValidOrigin(origin) {
225 t.Errorf("accepted %q", origin)
226 }
227 }
228 if !ValidOrigin("https://alpha.docs.oficina.works") {
229 t.Fatal("valid origin refused")
230 }
231 for _, cfg := range []Config{{}, {Apps: []string{"docs"}}, {Apps: []string{"docs", "docs"}, ValidateRoute: func(Route) bool { return true }}, {Apps: []string{"bad/app"}, ValidateRoute: func(Route) bool { return true }}} {
232 if _, e := New(s.db, s.sealer, cfg); !errors.Is(e, ErrInvalid) {
233 t.Fatal(e)
234 }
235 }
236 for _, name := range []string{"", strings.Repeat("x", 257), "\x00private", string([]byte{255}), " "} {
237 if _, e := s.SealTeamName(1, name); !errors.Is(e, ErrInvalid) || strings.Contains(e.Error(), "private") {
238 t.Fatal(e)
239 }
240 }
241 if _, e := s.SealTeamName(0, privateName); !errors.Is(e, ErrInvalid) {
242 t.Fatal(e)
243 }
244 if _, e := s.Choices(context.Background(), personA, "unknown"); !errors.Is(e, ErrInvalid) {
245 t.Fatal(e)
246 }
247 if _, e := s.Choices(context.Background(), "person@example.invalid", "docs"); !errors.Is(e, ErrInvalid) {
248 t.Fatal(e)
249 }
250 s.db.Close()
251 _, e := s.Choices(context.Background(), personA, "docs")
252 if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) {
253 t.Fatal(e)
254 }
255}
256