| @@ -0,0 +1,195 @@ |
| |
1 | package control |
| |
2 | |
| |
3 | import ( |
| |
4 | "slices" |
| |
5 | "strings" |
| |
6 | "testing" |
| |
7 | "time" |
| |
8 | |
| |
9 | "gitbay.org/gitbay/internal/protocol" |
| |
10 | "gitbay.org/gitbay/internal/store" |
| |
11 | ) |
| |
12 | |
| |
13 | func 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 | |
| |
40 | func 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. |
| |
60 | func 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. |
| |
85 | func 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 | |
| |
120 | func 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 | |
| |
132 | func 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 | |
| |
154 | func 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 | |
| |
178 | func 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 | } |