| 1 | package directorydata |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | const personA = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 19 | const personB = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 20 | const privateName = "CANARY-TEAM-NAME-2b71" |
| 21 | |
| 22 | func 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 | } |
| 43 | func 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. |
| 51 | func 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 | } |
| 78 | func 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 | } |
| 82 | func 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 | } |
| 86 | func 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 | } |
| 132 | func 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 | } |
| 155 | func 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 | } |
| 198 | func 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 | } |
| 221 | func 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 | |