internal/store/secrets_test.go

v1.39.0
gitbay/internal/store/secrets_test.go history · blame · raw

349 lines · 10931 bytes

  1package store
  2
  3import (
  4	"path/filepath"
  5	"strings"
  6	"testing"
  7
  8	"gitbay.org/gitbay/internal/seal"
  9)
 10
 11// keyedStore is a migrated store with a key file of one key.
 12func keyedStore(t *testing.T) (*Store, string, int64, int64) {
 13	t.Helper()
 14	s := open(t)
 15	if err := s.MigrateUp(); err != nil {
 16		t.Fatal(err)
 17	}
 18	path := filepath.Join(t.TempDir(), "secret.key")
 19	k, err := seal.NewKey()
 20	if err != nil {
 21		t.Fatal(err)
 22	}
 23	if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
 24		t.Fatal(err)
 25	}
 26	ring, err := seal.Load(path)
 27	if err != nil {
 28		t.Fatal(err)
 29	}
 30	s.SetKeyring(ring)
 31	uid, err := s.CreateUser("alice", false)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	repoID, err := s.CreateRepo("user", uid, "app", "public")
 36	if err != nil {
 37		t.Fatal(err)
 38	}
 39	return s, path, uid, repoID
 40}
 41
 42// raw reads every stored value of the secret columns.
 43func raw(t *testing.T, s *Store) []string {
 44	t.Helper()
 45	var out []string
 46	for _, sc := range secretColumns {
 47		rows, err := s.DB.Query("SELECT " + sc.column + " FROM " + sc.table + " WHERE " + sc.column + " != ''")
 48		if err != nil {
 49			t.Fatal(err)
 50		}
 51		for rows.Next() {
 52			var v string
 53			if err := rows.Scan(&v); err != nil {
 54				t.Fatal(err)
 55			}
 56			out = append(out, v)
 57		}
 58		rows.Close()
 59	}
 60	return out
 61}
 62
 63func TestSecretColumnsAreSealed(t *testing.T) {
 64	s, _, uid, repoID := keyedStore(t)
 65	if err := s.SetBuildSecret(repoID, "DEPLOY", "ci-secret"); err != nil {
 66		t.Fatal(err)
 67	}
 68	if _, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*"); err != nil {
 69		t.Fatal(err)
 70	}
 71	if _, err := s.AddMirror(repoID, "push", "https://mirror.example/r.git", "u", "mirror-token"); err != nil {
 72		t.Fatal(err)
 73	}
 74	if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil {
 75		t.Fatal(err)
 76	}
 77
 78	vals := raw(t, s)
 79	if len(vals) != 4 {
 80		t.Fatalf("stored %d values, want 4: %v", len(vals), vals)
 81	}
 82	for _, v := range vals {
 83		if !seal.IsSealed(v) {
 84			t.Errorf("stored in clear: %q", v)
 85		}
 86		for _, plain := range []string{"ci-secret", "hook-secret", "mirror-token", "apns-token"} {
 87			if strings.Contains(v, plain) {
 88				t.Errorf("%q carries %q", v, plain)
 89			}
 90		}
 91	}
 92
 93	secrets, err := s.BuildSecrets(repoID)
 94	if err != nil || secrets["DEPLOY"] != "ci-secret" {
 95		t.Fatalf("BuildSecrets = %v, %v", secrets, err)
 96	}
 97	hooks, err := s.ListWebhooks(repoID)
 98	if err != nil || len(hooks) != 1 || hooks[0].Secret != "hook-secret" {
 99		t.Fatalf("ListWebhooks = %+v, %v", hooks, err)
100	}
101	ms, err := s.ListMirrors(repoID)
102	if err != nil || len(ms) != 1 || ms[0].Token != "mirror-token" {
103		t.Fatalf("ListMirrors = %+v, %v", ms, err)
104	}
105	due, err := s.DueMirrors(3600)
106	if err != nil || len(due) != 1 || due[0].Token != "mirror-token" {
107		t.Fatalf("DueMirrors = %+v, %v", due, err)
108	}
109	ds, err := s.PushDevices(uid)
110	if err != nil || len(ds) != 1 || ds[0].Token != "apns-token" {
111		t.Fatalf("PushDevices = %+v, %v", ds, err)
112	}
113
114	// An empty webhook secret or mirror token stays empty: it means none.
115	if _, err := s.AddWebhook(repoID, "https://hook.example/y", "", "*"); err != nil {
116		t.Fatal(err)
117	}
118	if _, err := s.AddMirror(repoID, "push", "https://mirror.example/s.git", "", ""); err != nil {
119		t.Fatal(err)
120	}
121	var empty int
122	s.DB.QueryRow("SELECT (SELECT COUNT(*) FROM webhooks WHERE secret = '') + (SELECT COUNT(*) FROM mirrors WHERE token = '')").Scan(&empty)
123	if empty != 2 {
124		t.Errorf("empty values stored as %d rows of '', want 2", empty)
125	}
126}
127
128// The queue readers open what they join.
129func TestSealedQueueReaders(t *testing.T) {
130	s, _, uid, repoID := keyedStore(t)
131	hook, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*")
132	if err != nil {
133		t.Fatal(err)
134	}
135	if _, err := s.DB.Exec("INSERT INTO events (repo_id, kind, data_json) VALUES (?, 'push', '{}')", repoID); err != nil {
136		t.Fatal(err)
137	}
138	if _, err := s.DB.Exec("INSERT INTO webhook_deliveries (webhook_id, event_id) SELECT ?, MAX(id) FROM events", hook); err != nil {
139		t.Fatal(err)
140	}
141	dd, err := s.DueDeliveries(10)
142	if err != nil || len(dd) != 1 || dd[0].Secret != "hook-secret" {
143		t.Fatalf("DueDeliveries = %+v, %v", dd, err)
144	}
145
146	if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil {
147		t.Fatal(err)
148	}
149	if err := s.EnqueuePush(uid, "t", "b", "/p"); err != nil {
150		t.Fatal(err)
151	}
152	qp, err := s.DuePush(10)
153	if err != nil || len(qp) != 1 || qp[0].Token != "apns-token" {
154		t.Fatalf("DuePush = %+v, %v", qp, err)
155	}
156}
157
158// A sealed value copied into another row of its column does not open:
159// the additional data names the row as well as the column.
160func TestSealedValueBoundToRow(t *testing.T) {
161	s, _, uid, repoID := keyedStore(t)
162	other, err := s.CreateRepo("user", uid, "other", "public")
163	if err != nil {
164		t.Fatal(err)
165	}
166	bob, err := s.CreateUser("bob", false)
167	if err != nil {
168		t.Fatal(err)
169	}
170	must := func(err error) {
171		t.Helper()
172		if err != nil {
173			t.Fatal(err)
174		}
175	}
176	must(s.SetBuildSecret(repoID, "A", "a"))
177	must(s.SetBuildSecret(repoID, "B", "b"))
178	must(s.SetBuildSecret(other, "A", "c"))
179	_, err = s.AddWebhook(repoID, "https://hook.example/1", "s1", "*")
180	must(err)
181	_, err = s.AddWebhook(repoID, "https://hook.example/2", "s2", "*")
182	must(err)
183	_, err = s.AddMirror(repoID, "push", "https://m.example/1.git", "", "t1")
184	must(err)
185	_, err = s.AddMirror(repoID, "pull", "https://m.example/2.git", "", "t2")
186	must(err)
187	_, err = s.AddPushDevice(uid, "d1", "")
188	must(err)
189	_, err = s.AddPushDevice(bob, "d2", "")
190	must(err)
191
192	// push_devices.token is unique, so alice's row goes before her
193	// sealed token is copied into bob's.
194	var aliceTok string
195	must(s.DB.QueryRow("SELECT token FROM push_devices WHERE user_id = ?", uid).Scan(&aliceTok))
196	_, err = s.DB.Exec("DELETE FROM push_devices WHERE user_id = ?", uid)
197	must(err)
198
199	cases := []struct {
200		name string
201		copy string
202		read func() error
203	}{
204		{"build secret to another name",
205			"UPDATE build_secrets SET value = (SELECT value FROM build_secrets WHERE repo_id = ?1 AND name = 'A') WHERE repo_id = ?1 AND name = 'B'",
206			func() error { _, err := s.BuildSecrets(repoID); return err }},
207		{"build secret to another repository",
208			"UPDATE build_secrets SET value = (SELECT value FROM build_secrets WHERE repo_id = ?1 AND name = 'A') WHERE repo_id = ?2 AND name = 'A'",
209			func() error { _, err := s.BuildSecrets(other); return err }},
210		{"webhook secret",
211			"UPDATE webhooks SET secret = (SELECT secret FROM webhooks WHERE url LIKE '%/1') WHERE url LIKE '%/2'",
212			func() error { _, err := s.ListWebhooks(repoID); return err }},
213		{"mirror token",
214			"UPDATE mirrors SET token = (SELECT token FROM mirrors WHERE direction = 'push') WHERE direction = 'pull'",
215			func() error { _, err := s.ListMirrors(repoID); return err }},
216		{"push token",
217			"UPDATE push_devices SET token = ?5 WHERE user_id = ?4",
218			func() error { _, err := s.PushDevices(bob); return err }},
219	}
220	for _, c := range cases {
221		if _, err := s.DB.Exec(c.copy, repoID, other, uid, bob, aliceTok); err != nil {
222			t.Fatalf("%s: %v", c.name, err)
223		}
224		if err := c.read(); err == nil {
225			t.Errorf("%s: a value copied from another row opened", c.name)
226		}
227	}
228}
229
230// A token re-registered under another account changes hands by its
231// hash, since two seals of one token differ.
232func TestPushDeviceUpsertBySealedToken(t *testing.T) {
233	s, _, uid, _ := keyedStore(t)
234	bob, err := s.CreateUser("bob", false)
235	if err != nil {
236		t.Fatal(err)
237	}
238	first, err := s.AddPushDevice(uid, "tok", "phone")
239	if err != nil {
240		t.Fatal(err)
241	}
242	second, err := s.AddPushDevice(bob, "tok", "ipad")
243	if err != nil {
244		t.Fatal(err)
245	}
246	if first != second {
247		t.Fatalf("re-registration made row %d beside %d", second, first)
248	}
249	d, err := s.PushDevices(bob)
250	if err != nil || len(d) != 1 || d[0].Token != "tok" {
251		t.Fatalf("PushDevices after handover = %+v, %v", d, err)
252	}
253	if err := s.DeletePushDeviceByToken("tok"); err != nil {
254		t.Fatal(err)
255	}
256	if d, _ := s.PushDevices(bob); len(d) != 0 {
257		t.Fatalf("device left after delete by token: %+v", d)
258	}
259}
260
261// A device row from before token_hash existed is found by its clear
262// token until ResealSecrets fills the hash.
263func TestPushDeviceUnhashedRow(t *testing.T) {
264	s, _, uid, _ := keyedStore(t)
265	if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'old', '')", uid); err != nil {
266		t.Fatal(err)
267	}
268	var first int64
269	s.DB.QueryRow("SELECT id FROM push_devices").Scan(&first)
270	id, err := s.AddPushDevice(uid, "old", "phone")
271	if err != nil || id != first {
272		t.Fatalf("AddPushDevice = %d, %v; want row %d", id, err, first)
273	}
274	if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'older', '')", uid); err != nil {
275		t.Fatal(err)
276	}
277	if err := s.DeletePushDeviceByToken("older"); err != nil {
278		t.Fatal(err)
279	}
280	if d, _ := s.PushDevices(uid); len(d) != 1 || d[0].Token != "old" {
281		t.Fatalf("PushDevices = %+v", d)
282	}
283}
284
285// Rows written before sealing existed, and rows under a retired key,
286// end up under the current key.
287func TestResealSecrets(t *testing.T) {
288	s, path, uid, repoID := keyedStore(t)
289	if _, err := s.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'OLD', 'clear-value')", repoID); err != nil {
290		t.Fatal(err)
291	}
292	if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'clear-token', '')", uid); err != nil {
293		t.Fatal(err)
294	}
295	n, err := s.ResealSecrets()
296	if err != nil || n != 2 {
297		t.Fatalf("ResealSecrets = %d, %v; want 2", n, err)
298	}
299	for _, v := range raw(t, s) {
300		if !seal.IsSealed(v) {
301			t.Errorf("still clear: %q", v)
302		}
303	}
304	var hash string
305	s.DB.QueryRow("SELECT COALESCE(token_hash, '') FROM push_devices").Scan(&hash)
306	if hash != tokenHash("clear-token") {
307		t.Errorf("token_hash = %q", hash)
308	}
309	if d, err := s.PushDevices(uid); err != nil || len(d) != 1 || d[0].Token != "clear-token" {
310		t.Fatalf("PushDevices after reseal = %+v, %v", d, err)
311	}
312	if n, _ := s.ResealSecrets(); n != 0 {
313		t.Errorf("second reseal rewrote %d values", n)
314	}
315
316	// Rotation: add a key, reseal, drop the old key; the value still opens.
317	old, err := seal.ReadKeys(path)
318	if err != nil {
319		t.Fatal(err)
320	}
321	next, _ := seal.NewKey()
322	if err := seal.WriteKeys(path, append(old, next)); err != nil {
323		t.Fatal(err)
324	}
325	if n, err := s.ResealSecrets(); err != nil || n != 2 {
326		t.Fatalf("reseal after rotation = %d, %v; want 2", n, err)
327	}
328	if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
329		t.Fatal(err)
330	}
331	use, err := s.SecretKeyUse()
332	if err != nil || use[next.ID] != 2 || len(use) != 1 {
333		t.Fatalf("SecretKeyUse = %v, %v", use, err)
334	}
335	if got, _ := s.BuildSecrets(repoID); got["OLD"] != "clear-value" {
336		t.Fatalf("value after rotation: %v", got)
337	}
338}
339
340func TestSealedValueWithoutKeyFails(t *testing.T) {
341	s, _, _, repoID := keyedStore(t)
342	if err := s.SetBuildSecret(repoID, "X", "v"); err != nil {
343		t.Fatal(err)
344	}
345	s.SetKeyring(nil)
346	if _, err := s.BuildSecrets(repoID); err == nil {
347		t.Fatal("opened a sealed value with no key loaded")
348	}
349}