internal/control/runnerattach_test.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/control/runnerattach_test.go history · blame · raw

253 lines · 9453 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"strconv"
  6	"strings"
  7	"testing"
  8
  9	"gitbay.org/gitbay/internal/config"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14// attachFixture: alice (not admin) owns alice/app with a build queued;
 15// mallory (not admin) owns mallory/evil with an older build queued. Each
 16// has a runner-scoped key. The Ctx polls as the given user with the given
 17// key, which is what the SSH listener produces.
 18type attachFixture struct {
 19	st                   *store.Store
 20	alice, mallory       int64
 21	aliceKey, malloryKey store.SSHKey
 22	app, evil            store.Repo
 23	appBuild, evilBuild  int64
 24}
 25
 26func newAttachFixture(t *testing.T) attachFixture {
 27	t.Helper()
 28	st, err := store.Open(":memory:")
 29	if err != nil {
 30		t.Fatal(err)
 31	}
 32	t.Cleanup(func() { st.Close() })
 33	if err := st.MigrateUp(); err != nil {
 34		t.Fatal(err)
 35	}
 36	var f attachFixture
 37	f.st = st
 38	mk := func(name, fp string) (int64, store.SSHKey, store.Repo, string) {
 39		uid, err := st.CreateUser(name, false)
 40		if err != nil {
 41			t.Fatal(err)
 42		}
 43		if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner", ""); err != nil {
 44			t.Fatal(err)
 45		}
 46		k, _ := st.SSHKeyByFingerprint(fp)
 47		repoName := map[string]string{"alice": "app", "mallory": "evil"}[name]
 48		rid, err := st.CreateRepo("user", uid, repoName, "public")
 49		if err != nil {
 50			t.Fatal(err)
 51		}
 52		repo, _ := st.RepoByID(rid)
 53		return uid, k, repo, repoName
 54	}
 55	f.mallory, f.malloryKey, f.evil, _ = mk("mallory", "SHA256:mallory")
 56	f.alice, f.aliceKey, f.app, _ = mk("alice", "SHA256:alice")
 57	// mallory's build is older, so an unrestricted claim would take it.
 58	f.evilBuild, err = st.CreateBuild(f.evil.ID, "unit", "aaa111", "main", "[]", "", "", true)
 59	if err != nil {
 60		t.Fatal(err)
 61	}
 62	f.appBuild, err = st.CreateBuild(f.app.ID, "unit", "bbb222", "main", "[]", "", "", true)
 63	if err != nil {
 64		t.Fatal(err)
 65	}
 66	return f
 67}
 68
 69func (f attachFixture) ctx(uid int64, key store.SSHKey, admin bool) (*Ctx, *bytes.Buffer) {
 70	var out bytes.Buffer
 71	name := "alice"
 72	if uid == f.mallory {
 73		name = "mallory"
 74	}
 75	return &Ctx{
 76		User:   store.User{ID: uid, Username: name, IsAdmin: admin},
 77		Scope:  key.Scope,
 78		Source: key.Fingerprint,
 79		Store:  f.st,
 80		Cfg:    config.Config{Server: config.Server{Root: "/nonexistent", SiteURL: "https://x.test"}},
 81		Stdin:  strings.NewReader(""),
 82		Stdout: &out,
 83		Stderr: &out,
 84	}, &out
 85}
 86
 87// A runner key with no attachment claims nothing, whatever is queued.
 88func TestRunnerNextUnattachedClaimsNothing(t *testing.T) {
 89	f := newAttachFixture(t)
 90	c, out := f.ctx(f.alice, f.aliceKey, false)
 91	if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") {
 92		t.Fatalf("exit %d: %s", code, out.String())
 93	}
 94	b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
 95	if b.Status != "pending" {
 96		t.Fatalf("unattached key claimed a build: %s", b.Status)
 97	}
 98}
 99
100// An attached key claims its repository's build and not the older one
101// queued elsewhere; naming a repository outside the attachments is refused.
102func TestRunnerNextAttachedClaimsOwnRepoOnly(t *testing.T) {
103	f := newAttachFixture(t)
104	if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
105		t.Fatal(err)
106	}
107	c, out := f.ctx(f.alice, f.aliceKey, false)
108	if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
109		t.Fatalf("exit %d: %s", code, out.String())
110	}
111	if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "pending" {
112		t.Fatalf("mallory's build was touched: %s", b.Status)
113	}
114	c, out = f.ctx(f.alice, f.aliceKey, false)
115	if code := runRunnerNext(c, []string{"mallory/evil"}); code != protocol.ExitDenied {
116		t.Fatalf("naming an unattached repo: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
117	}
118}
119
120// The heartbeat is recorded against the key, and admin runners shows it
121// with its fingerprint and attachments. The column is the attachments even
122// when the key polled with a narrower -repos, and none when it has no
123// attachment at all.
124func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) {
125	f := newAttachFixture(t)
126	for _, id := range []int64{f.app.ID, f.evil.ID} {
127		if err := f.st.AttachRunner(f.aliceKey.ID, id); err != nil {
128			t.Fatal(err)
129		}
130	}
131	c, _ := f.ctx(f.alice, f.aliceKey, false)
132	runRunnerNext(c, []string{"alice/app"})
133	c, _ = f.ctx(f.mallory, f.malloryKey, false)
134	runRunnerNext(c, nil)
135	admin, out := f.ctx(f.alice, f.aliceKey, true)
136	admin.Scope = "full"
137	if code := runAdminRunners(admin, nil); code != protocol.ExitOK {
138		t.Fatalf("admin runners: exit %d: %s", code, out.String())
139	}
140	if !strings.Contains(out.String(), "alice\tSHA256:alice\t") ||
141		!strings.Contains(out.String(), "\talice/app,mallory/evil\t") {
142		t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String())
143	}
144	if !strings.Contains(out.String(), "\tnone\t") {
145		t.Fatalf("mallory's unattached runner key is not none:\n%s", out.String())
146	}
147}
148
149// The instance-admin bypass is the key, not the account: a runner-scoped
150// key on an admin account claims only what it is attached to.
151func TestRunnerNextAdminAccountRunnerKeyIsConfined(t *testing.T) {
152	f := newAttachFixture(t)
153	c, out := f.ctx(f.alice, f.aliceKey, true)
154	if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") {
155		t.Fatalf("exit %d: %s", code, out.String())
156	}
157	for _, b := range []struct {
158		repo   store.Repo
159		number int64
160	}{{f.app, f.appBuild}, {f.evil, f.evilBuild}} {
161		if got, _ := f.st.BuildByNumber(b.repo.ID, b.number); got.Status != "pending" {
162			t.Fatalf("%s claimed by an unattached runner key: %s", b.repo.Path(), got.Status)
163		}
164	}
165	if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
166		t.Fatal(err)
167	}
168	c, out = f.ctx(f.alice, f.aliceKey, true)
169	if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
170		t.Fatalf("attached claim: exit %d: %s", code, out.String())
171	}
172	if got, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); got.Status != "pending" {
173		t.Fatalf("mallory's build was claimed: %s", got.Status)
174	}
175}
176
177// Untrusted builds are skipped unless the runner asks.
178func TestRunnerNextUntrustedFlag(t *testing.T) {
179	f := newAttachFixture(t)
180	if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
181		t.Fatal(err)
182	}
183	c, _ := f.ctx(f.alice, f.aliceKey, false)
184	runRunnerNext(c, nil) // takes the trusted build
185	fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false)
186	if err != nil {
187		t.Fatal(err)
188	}
189	c, out := f.ctx(f.alice, f.aliceKey, false)
190	runRunnerNext(c, nil)
191	if !strings.Contains(out.String(), "no pending builds") {
192		t.Fatalf("fork head claimed without --untrusted: %s", out.String())
193	}
194	c, out = f.ctx(f.alice, f.aliceKey, false)
195	if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
196		t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String())
197	}
198	if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" {
199		t.Fatalf("fork build is %s, want running", b.Status)
200	}
201}
202
203// runner done and runner log on a build whose repository is not attached
204// to the key are refused.
205func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
206	f := newAttachFixture(t)
207	if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil {
208		t.Fatal(err)
209	}
210	c, _ := f.ctx(f.mallory, f.malloryKey, false)
211	runRunnerNext(c, nil) // mallory holds her own build
212	evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
213	c, out := f.ctx(f.alice, f.aliceKey, false)
214	id := strconv.FormatInt(evil.ID, 10)
215	if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied {
216		t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
217	}
218	c, out = f.ctx(f.alice, f.aliceKey, false)
219	if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied {
220		t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
221	}
222	if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" {
223		t.Fatalf("build was finished by a foreign key: %s", b.Status)
224	}
225}
226
227// admin runners forget drops one heartbeat row by fingerprint.
228func TestAdminRunnersForget(t *testing.T) {
229	f := newAttachFixture(t)
230	c, _ := f.ctx(f.alice, f.aliceKey, false)
231	runRunnerNext(c, nil)
232	admin, out := f.ctx(f.alice, f.aliceKey, true)
233	admin.Scope = "full"
234	if code := runAdminRunnersForget(admin, []string{"SHA256:nobody"}); code != protocol.ExitNotFound {
235		t.Fatalf("unknown fingerprint: exit %d, want %d: %s", code, protocol.ExitNotFound, out.String())
236	}
237	admin, out = f.ctx(f.alice, f.aliceKey, true)
238	admin.Scope = "full"
239	if code := runAdminRunnersForget(admin, []string{f.aliceKey.Fingerprint}); code != protocol.ExitOK {
240		t.Fatalf("forget: exit %d: %s", code, out.String())
241	}
242	admin, out = f.ctx(f.alice, f.aliceKey, true)
243	admin.Scope = "full"
244	runAdminRunners(admin, nil)
245	if strings.Contains(out.String(), f.aliceKey.Fingerprint) {
246		t.Fatalf("row still listed after forget:\n%s", out.String())
247	}
248	user, out := f.ctx(f.alice, f.aliceKey, false)
249	user.Scope = "full"
250	if code := runAdminRunnersForget(user, []string{f.aliceKey.Fingerprint}); code != protocol.ExitDenied {
251		t.Fatalf("non-admin forgot a runner: exit %d: %s", code, out.String())
252	}
253}