internal/control/auditrefusal_test.go

main
gitbay/internal/control/auditrefusal_test.go history · blame · raw

195 lines · 6369 bytes

8 symbols in this file
  1package control
  2
  3import (
  4	"slices"
  5	"strings"
  6	"testing"
  7	"time"
  8
  9	"gitbay.org/gitbay/internal/protocol"
 10	"gitbay.org/gitbay/internal/store"
 11)
 12
 13func TestRefusedWritesAreAudited(t *testing.T) {
 14	refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
 15	st, repo, _ := newQueueTestRepo(t)
 16	bobID, err := st.CreateUser("bob", false)
 17	if err != nil {
 18		t.Fatal(err)
 19	}
 20	bob := store.User{ID: bobID, Username: "bob"}
 21
 22	c, _ := pruneCtx(st, t.TempDir(), bob)
 23	if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied {
 24		t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
 25	}
 26	// A refused read is not a write attempt.
 27	c, _ = pruneCtx(st, t.TempDir(), bob)
 28	if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied {
 29		t.Fatalf("audit: exit %d", code)
 30	}
 31	got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10})
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" {
 36		t.Fatalf("entries: %+v", got)
 37	}
 38}
 39
 40func TestRefusalAuditIsRateLimited(t *testing.T) {
 41	refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
 42	st, repo, _ := newQueueTestRepo(t)
 43	bobID, err := st.CreateUser("bob", false)
 44	if err != nil {
 45		t.Fatal(err)
 46	}
 47	for range refusalsPerMinute + 5 {
 48		c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
 49		Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"})
 50	}
 51	refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100})
 52	throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100})
 53	if len(refused) != refusalsPerMinute || len(throttled) != 1 {
 54		t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled))
 55	}
 56}
 57
 58// The #257 refusal of a minting command under an expiring credential is
 59// a refused write like any other.
 60func TestExpiringMintRefusalIsAudited(t *testing.T) {
 61	refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
 62	st, _, _ := newQueueTestRepo(t)
 63	bobID, err := st.CreateUser("bob", false)
 64	if err != nil {
 65		t.Fatal(err)
 66	}
 67	exp := time.Now().Add(time.Hour)
 68	c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
 69	c.Expires = &exp
 70	if code := Dispatch(c, []string{"token", "create", "--name", "x"}); code != protocol.ExitDenied {
 71		t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
 72	}
 73	got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
 74	if err != nil {
 75		t.Fatal(err)
 76	}
 77	if len(got) != 1 || got[0].Action != "refused token create" {
 78		t.Fatalf("entries: %+v", got)
 79	}
 80}
 81
 82// A gate refuses before parseFlags, so the row must not keep a value
 83// glued to its flag, a value that looks like a flag, or a positional
 84// past the target.
 85func TestRefusalRowKeepsNoValues(t *testing.T) {
 86	refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
 87	st, repo, _ := newQueueTestRepo(t)
 88	bobID, err := st.CreateUser("bob", false)
 89	if err != nil {
 90		t.Fatal(err)
 91	}
 92	for _, argv := range [][]string{
 93		{"issue", "create", repo.Path(), "--body=hunter2"},
 94		{"issue", "create", repo.Path(), "--title", "--body=hunter2"},
 95		{"repo", "secret", "set", repo.Path(), "NAME", "hunter2"},
 96	} {
 97		c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
 98		c.ReadOnly = true
 99		if code := Dispatch(c, argv); code != protocol.ExitDenied {
100			t.Fatalf("%q: exit %d, want %d", argv, code, protocol.ExitDenied)
101		}
102	}
103	got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
104	if err != nil {
105		t.Fatal(err)
106	}
107	if len(got) != 3 {
108		t.Fatalf("entries: %+v", got)
109	}
110	for _, e := range got {
111		if strings.Contains(e.Data, "hunter2") || strings.Contains(e.Data, "NAME") {
112			t.Errorf("%s kept a value: %s", e.Action, e.Data)
113		}
114		if !strings.Contains(e.Data, repo.Path()) {
115			t.Errorf("%s lost its target: %s", e.Action, e.Data)
116		}
117	}
118}
119
120func TestRefusalArgs(t *testing.T) {
121	for _, tc := range []struct{ in, want []string }{
122		{[]string{"o/r", "NAME", "value"}, []string{"o/r"}},
123		{[]string{"o/r", "--body=x", "--title", "--label=y"}, []string{"o/r", "--body", "--title", "--label"}},
124		{[]string{"o/r", "--title", "t", "--", "a", "b"}, []string{"o/r", "--title"}},
125	} {
126		if got := refusalArgs(tc.in); !slices.Equal(got, tc.want) {
127			t.Errorf("refusalArgs(%q) = %q, want %q", tc.in, got, tc.want)
128		}
129	}
130}
131
132func TestRefusalLimiterWindowResets(t *testing.T) {
133	l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
134	now := time.Unix(1_000_000, 0)
135	for i := range refusalsPerMinute {
136		if v := l.allow(1, now); v != refusalRecord {
137			t.Fatalf("refusal %d: %d", i, v)
138		}
139	}
140	if v := l.allow(1, now); v != refusalThrottleActor {
141		t.Fatalf("first past the limit: %d", v)
142	}
143	if v := l.allow(1, now.Add(59*time.Second)); v != refusalDrop {
144		t.Fatalf("second past the limit: %d", v)
145	}
146	if v := l.allow(2, now); v != refusalRecord {
147		t.Fatalf("another actor: %d", v)
148	}
149	if v := l.allow(1, now.Add(time.Minute)); v != refusalRecord {
150		t.Fatalf("next minute: %d", v)
151	}
152}
153
154func TestRefusalLimiterGlobalCeiling(t *testing.T) {
155	l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
156	now := time.Unix(1_000_000, 0)
157	var rec, global int
158	for actor := range int64(refusalsPerMinuteGlobal/refusalsPerMinute + 10) {
159		for range refusalsPerMinute {
160			switch l.allow(actor, now) {
161			case refusalRecord:
162				rec++
163			case refusalThrottleGlobal:
164				global++
165			case refusalThrottleActor:
166				t.Fatal("actor throttled under its own limit")
167			}
168		}
169	}
170	if rec != refusalsPerMinuteGlobal || global != 1 {
171		t.Fatalf("%d recorded, %d global throttle rows", rec, global)
172	}
173	if v := l.allow(9999, now.Add(time.Minute)); v != refusalRecord {
174		t.Fatalf("next minute: %d", v)
175	}
176}
177
178func TestRefusalLimiterPrunes(t *testing.T) {
179	l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
180	now := time.Unix(1_000_000, 0)
181	for actor := range int64(4097) {
182		l.seen[actor] = &refusalWindow{start: now, n: 1}
183	}
184	l.allow(5000, now.Add(time.Minute))
185	if len(l.seen) != 1 {
186		t.Fatalf("%d windows after prune, want 1", len(l.seen))
187	}
188	for actor := range int64(4097) {
189		l.seen[actor] = &refusalWindow{start: now.Add(time.Minute), n: 1}
190	}
191	l.allow(6000, now.Add(time.Minute+time.Second))
192	if len(l.seen) != 4099 {
193		t.Fatalf("%d windows, want 4099: a live window was pruned", len(l.seen))
194	}
195}