Commit 34a4fc3e58

34a4fc3e58656dd7fbc9d435f13abfdf72f37e16

parent: 1030a916f9

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 18:23 UTC

control: gate the admin noun in Dispatch, and test ReadsStdin

Each admin handler called requireInstanceAdmin itself, some through
adminRepo, and nothing caught a new one that forgot. Dispatch now
refuses any admin command for a non-admin before the handler runs;
the per-handler checks stay.

TestAdminNounGatedInDispatch dispatches every admin command as a
non-admin and expects denial. TestStdinCommandsReadStdin asserts that
a command whose usage reads stdin sets ReadsStdin, since Dispatch
otherwise hands it an empty reader and --file - stores nothing.

Closes #127

Layout: unified · split

internal/control/control.go +5
@@ -99,6 +99,11 @@ func Dispatch(c *Ctx, argv []string) int {
9999 if c.User.Disabled {
100100 return c.fail(protocol.ExitDenied, "this account is disabled")
101101 }
102 // The admin noun is gated here as well as in each handler, so a new
103 // admin command that forgets requireInstanceAdmin is still refused.
104 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
105 return c.fail(protocol.ExitDenied, "admin commands are for instance admins")
106 }
102107 if c.User.Pending && !pendingAllowed(cmd.Path) {
103108 return c.fail(protocol.ExitDenied,
104109 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
internal/control/control_test.go +30
@@ -9,6 +9,7 @@ import (
99 "testing"
1010
1111 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
1213)
1314
1415// TestEveryCommandReachableFromBareSSH asserts that each registered command's
@@ -163,3 +164,32 @@ func TestHelpListsEveryCommandSorted(t *testing.T) {
163164 t.Error("help output is not sorted by path")
164165 }
165166}
167
168// TestStdinCommandsReadStdin: a command whose usage says its input arrives
169// on stdin must set ReadsStdin, or Dispatch hands it an empty reader and
170// --file - silently stores nothing (#127).
171func TestStdinCommandsReadStdin(t *testing.T) {
172 for _, cmd := range Commands() {
173 u := cmd.Usage
174 wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") ||
175 strings.Contains(u, "stdin") || strings.Contains(u, "--key -")
176 if wants && !cmd.ReadsStdin {
177 t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u)
178 }
179 }
180}
181
182// TestAdminNounGatedInDispatch: every admin command is refused for a
183// non-admin by the dispatcher itself, before any handler runs.
184func TestAdminNounGatedInDispatch(t *testing.T) {
185 for _, cmd := range Commands() {
186 if cmd.Path[0] != "admin" {
187 continue
188 }
189 var out, errOut bytes.Buffer
190 c := &Ctx{User: store.User{Username: "nobody"}, Scope: "full", Stdout: &out, Stderr: &errOut}
191 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied {
192 t.Errorf("%s: non-admin got exit %d, want %d", strings.Join(cmd.Path, " "), code, protocol.ExitDenied)
193 }
194 }
195}