ScuttleBot

scuttlebot / internal / api / login_test.go
Source Blame History 266 lines
5ac549c… lmata 1 package api_test
5ac549c… lmata 2
5ac549c… lmata 3 import (
5ac549c… lmata 4 "encoding/json"
5ac549c… lmata 5 "net/http"
5ac549c… lmata 6 "testing"
5ac549c… lmata 7
5ac549c… lmata 8 "github.com/conflicthq/scuttlebot/internal/api"
5ac549c… lmata 9 "github.com/conflicthq/scuttlebot/internal/auth"
5ac549c… lmata 10 "github.com/conflicthq/scuttlebot/internal/registry"
5ac549c… lmata 11 "net/http/httptest"
5ac549c… lmata 12 "path/filepath"
5ac549c… lmata 13 )
5ac549c… lmata 14
5ac549c… lmata 15 // newAdminStore creates an AdminStore backed by a temp file.
5ac549c… lmata 16 func newAdminStore(t *testing.T) *auth.AdminStore {
5ac549c… lmata 17 t.Helper()
5ac549c… lmata 18 s, err := auth.NewAdminStore(filepath.Join(t.TempDir(), "admins.json"))
5ac549c… lmata 19 if err != nil {
5ac549c… lmata 20 t.Fatalf("NewAdminStore: %v", err)
5ac549c… lmata 21 }
5ac549c… lmata 22 return s
5ac549c… lmata 23 }
5ac549c… lmata 24
5ac549c… lmata 25 // newTestServerWithAdmins creates a test server with admin auth configured.
5ac549c… lmata 26 func newTestServerWithAdmins(t *testing.T) (*httptest.Server, *auth.AdminStore) {
5ac549c… lmata 27 t.Helper()
5ac549c… lmata 28 admins := newAdminStore(t)
5ac549c… lmata 29 if err := admins.Add("admin", "hunter2"); err != nil {
5ac549c… lmata 30 t.Fatalf("Add admin: %v", err)
5ac549c… lmata 31 }
5ac549c… lmata 32 reg := registry.New(newMock(), []byte("test-signing-key"))
68677f9… noreply 33 srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog)
5ac549c… lmata 34 return httptest.NewServer(srv.Handler()), admins
5ac549c… lmata 35 }
5ac549c… lmata 36
5ac549c… lmata 37 func TestLoginNoAdmins(t *testing.T) {
5ac549c… lmata 38 // When admins is nil, login returns 404.
5ac549c… lmata 39 reg := registry.New(newMock(), []byte("test-signing-key"))
68677f9… noreply 40 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
5ac549c… lmata 41 ts := httptest.NewServer(srv.Handler())
5ac549c… lmata 42 defer ts.Close()
5ac549c… lmata 43
5ac549c… lmata 44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
5ac549c… lmata 45 defer resp.Body.Close()
5ac549c… lmata 46 if resp.StatusCode != http.StatusNotFound {
5ac549c… lmata 47 t.Errorf("expected 404 when no admins configured, got %d", resp.StatusCode)
5ac549c… lmata 48 }
5ac549c… lmata 49 }
5ac549c… lmata 50
5ac549c… lmata 51 func TestLoginValidCredentials(t *testing.T) {
5ac549c… lmata 52 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 53 defer ts.Close()
5ac549c… lmata 54
5ac549c… lmata 55 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "hunter2"}, nil)
5ac549c… lmata 56 defer resp.Body.Close()
5ac549c… lmata 57 if resp.StatusCode != http.StatusOK {
5ac549c… lmata 58 t.Fatalf("expected 200, got %d", resp.StatusCode)
5ac549c… lmata 59 }
5ac549c… lmata 60
5ac549c… lmata 61 var body map[string]string
5ac549c… lmata 62 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
5ac549c… lmata 63 t.Fatalf("decode: %v", err)
5ac549c… lmata 64 }
5ac549c… lmata 65 if body["token"] == "" {
5ac549c… lmata 66 t.Error("expected token in response")
5ac549c… lmata 67 }
5ac549c… lmata 68 if body["username"] != "admin" {
5ac549c… lmata 69 t.Errorf("expected username=admin, got %q", body["username"])
5ac549c… lmata 70 }
5ac549c… lmata 71 }
5ac549c… lmata 72
5ac549c… lmata 73 func TestLoginWrongPassword(t *testing.T) {
5ac549c… lmata 74 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 75 defer ts.Close()
5ac549c… lmata 76
5ac549c… lmata 77 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
5ac549c… lmata 78 defer resp.Body.Close()
5ac549c… lmata 79 if resp.StatusCode != http.StatusUnauthorized {
5ac549c… lmata 80 t.Errorf("expected 401, got %d", resp.StatusCode)
5ac549c… lmata 81 }
5ac549c… lmata 82 }
5ac549c… lmata 83
5ac549c… lmata 84 func TestLoginUnknownUser(t *testing.T) {
5ac549c… lmata 85 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 86 defer ts.Close()
5ac549c… lmata 87
5ac549c… lmata 88 resp := do(t, ts, "POST", "/login", map[string]any{"username": "nobody", "password": "hunter2"}, nil)
5ac549c… lmata 89 defer resp.Body.Close()
5ac549c… lmata 90 if resp.StatusCode != http.StatusUnauthorized {
5ac549c… lmata 91 t.Errorf("expected 401, got %d", resp.StatusCode)
5ac549c… lmata 92 }
5ac549c… lmata 93 }
5ac549c… lmata 94
5ac549c… lmata 95 func TestLoginBadBody(t *testing.T) {
5ac549c… lmata 96 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 97 defer ts.Close()
5ac549c… lmata 98
5ac549c… lmata 99 resp := do(t, ts, "POST", "/login", "not-json", nil)
5ac549c… lmata 100 defer resp.Body.Close()
5ac549c… lmata 101 if resp.StatusCode != http.StatusBadRequest {
5ac549c… lmata 102 t.Errorf("expected 400, got %d", resp.StatusCode)
5ac549c… lmata 103 }
5ac549c… lmata 104 }
5ac549c… lmata 105
5ac549c… lmata 106 func TestLoginRateLimit(t *testing.T) {
5ac549c… lmata 107 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 108 defer ts.Close()
5ac549c… lmata 109
5ac549c… lmata 110 // 11 attempts from the same IP — the 11th should be rate-limited.
5ac549c… lmata 111 var lastStatus int
5ac549c… lmata 112 for i := 0; i < 11; i++ {
5ac549c… lmata 113 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
5ac549c… lmata 114 lastStatus = resp.StatusCode
5ac549c… lmata 115 resp.Body.Close()
5ac549c… lmata 116 }
5ac549c… lmata 117 if lastStatus != http.StatusTooManyRequests {
5ac549c… lmata 118 t.Errorf("expected 429 on 11th attempt, got %d", lastStatus)
5ac549c… lmata 119 }
5ac549c… lmata 120 }
5ac549c… lmata 121
5ac549c… lmata 122 // --- admin management endpoints ---
5ac549c… lmata 123
5ac549c… lmata 124 func TestAdminList(t *testing.T) {
5ac549c… lmata 125 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 126 defer ts.Close()
5ac549c… lmata 127
5ac549c… lmata 128 resp := do(t, ts, "GET", "/v1/admins", nil, authHeader())
5ac549c… lmata 129 defer resp.Body.Close()
5ac549c… lmata 130 if resp.StatusCode != http.StatusOK {
5ac549c… lmata 131 t.Fatalf("expected 200, got %d", resp.StatusCode)
5ac549c… lmata 132 }
5ac549c… lmata 133
5ac549c… lmata 134 var body map[string]any
5ac549c… lmata 135 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
5ac549c… lmata 136 t.Fatalf("decode: %v", err)
5ac549c… lmata 137 }
5ac549c… lmata 138 admins := body["admins"].([]any)
5ac549c… lmata 139 if len(admins) != 1 {
5ac549c… lmata 140 t.Errorf("expected 1 admin, got %d", len(admins))
5ac549c… lmata 141 }
5ac549c… lmata 142 }
5ac549c… lmata 143
5ac549c… lmata 144 func TestAdminAdd(t *testing.T) {
5ac549c… lmata 145 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 146 defer ts.Close()
5ac549c… lmata 147
5ac549c… lmata 148 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob", "password": "passw0rd"}, authHeader())
5ac549c… lmata 149 defer resp.Body.Close()
5ac549c… lmata 150 if resp.StatusCode != http.StatusCreated {
5ac549c… lmata 151 t.Errorf("expected 201, got %d", resp.StatusCode)
5ac549c… lmata 152 }
5ac549c… lmata 153
5ac549c… lmata 154 // List should now have 2.
5ac549c… lmata 155 resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
5ac549c… lmata 156 defer resp2.Body.Close()
5ac549c… lmata 157 var body map[string]any
5ac549c… lmata 158 json.NewDecoder(resp2.Body).Decode(&body)
5ac549c… lmata 159 admins := body["admins"].([]any)
5ac549c… lmata 160 if len(admins) != 2 {
5ac549c… lmata 161 t.Errorf("expected 2 admins after add, got %d", len(admins))
5ac549c… lmata 162 }
5ac549c… lmata 163 }
5ac549c… lmata 164
5ac549c… lmata 165 func TestAdminAddDuplicate(t *testing.T) {
5ac549c… lmata 166 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 167 defer ts.Close()
5ac549c… lmata 168
5ac549c… lmata 169 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "admin", "password": "pw"}, authHeader())
5ac549c… lmata 170 defer resp.Body.Close()
5ac549c… lmata 171 if resp.StatusCode != http.StatusConflict {
5ac549c… lmata 172 t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode)
5ac549c… lmata 173 }
5ac549c… lmata 174 }
5ac549c… lmata 175
5ac549c… lmata 176 func TestAdminAddMissingFields(t *testing.T) {
5ac549c… lmata 177 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 178 defer ts.Close()
5ac549c… lmata 179
5ac549c… lmata 180 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob"}, authHeader())
5ac549c… lmata 181 defer resp.Body.Close()
5ac549c… lmata 182 if resp.StatusCode != http.StatusBadRequest {
5ac549c… lmata 183 t.Errorf("expected 400 when password missing, got %d", resp.StatusCode)
5ac549c… lmata 184 }
5ac549c… lmata 185 }
5ac549c… lmata 186
5ac549c… lmata 187 func TestAdminRemove(t *testing.T) {
5ac549c… lmata 188 ts, admins := newTestServerWithAdmins(t)
5ac549c… lmata 189 defer ts.Close()
5ac549c… lmata 190
5ac549c… lmata 191 // Add a second admin first so we're not removing the only one.
5ac549c… lmata 192 _ = admins.Add("bob", "pw")
5ac549c… lmata 193
5ac549c… lmata 194 resp := do(t, ts, "DELETE", "/v1/admins/admin", nil, authHeader())
5ac549c… lmata 195 defer resp.Body.Close()
5ac549c… lmata 196 if resp.StatusCode != http.StatusNoContent {
5ac549c… lmata 197 t.Errorf("expected 204, got %d", resp.StatusCode)
5ac549c… lmata 198 }
5ac549c… lmata 199
5ac549c… lmata 200 resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
5ac549c… lmata 201 defer resp2.Body.Close()
5ac549c… lmata 202 var body map[string]any
5ac549c… lmata 203 json.NewDecoder(resp2.Body).Decode(&body)
5ac549c… lmata 204 remaining := body["admins"].([]any)
5ac549c… lmata 205 if len(remaining) != 1 {
5ac549c… lmata 206 t.Errorf("expected 1 admin remaining, got %d", len(remaining))
5ac549c… lmata 207 }
5ac549c… lmata 208 }
5ac549c… lmata 209
5ac549c… lmata 210 func TestAdminRemoveUnknown(t *testing.T) {
5ac549c… lmata 211 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 212 defer ts.Close()
5ac549c… lmata 213
5ac549c… lmata 214 resp := do(t, ts, "DELETE", "/v1/admins/nobody", nil, authHeader())
5ac549c… lmata 215 defer resp.Body.Close()
5ac549c… lmata 216 if resp.StatusCode != http.StatusNotFound {
5ac549c… lmata 217 t.Errorf("expected 404, got %d", resp.StatusCode)
5ac549c… lmata 218 }
5ac549c… lmata 219 }
5ac549c… lmata 220
5ac549c… lmata 221 func TestAdminSetPassword(t *testing.T) {
5ac549c… lmata 222 ts, admins := newTestServerWithAdmins(t)
5ac549c… lmata 223 defer ts.Close()
5ac549c… lmata 224
5ac549c… lmata 225 resp := do(t, ts, "PUT", "/v1/admins/admin/password", map[string]any{"password": "newpass"}, authHeader())
5ac549c… lmata 226 defer resp.Body.Close()
5ac549c… lmata 227 if resp.StatusCode != http.StatusNoContent {
5ac549c… lmata 228 t.Errorf("expected 204, got %d", resp.StatusCode)
5ac549c… lmata 229 }
5ac549c… lmata 230
5ac549c… lmata 231 if !admins.Authenticate("admin", "newpass") {
5ac549c… lmata 232 t.Error("new password should authenticate")
5ac549c… lmata 233 }
5ac549c… lmata 234 }
5ac549c… lmata 235
5ac549c… lmata 236 func TestAdminSetPasswordMissing(t *testing.T) {
5ac549c… lmata 237 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 238 defer ts.Close()
5ac549c… lmata 239
5ac549c… lmata 240 resp := do(t, ts, "PUT", "/v1/admins/admin/password", map[string]any{"password": ""}, authHeader())
5ac549c… lmata 241 defer resp.Body.Close()
5ac549c… lmata 242 if resp.StatusCode != http.StatusBadRequest {
5ac549c… lmata 243 t.Errorf("expected 400, got %d", resp.StatusCode)
5ac549c… lmata 244 }
5ac549c… lmata 245 }
5ac549c… lmata 246
5ac549c… lmata 247 func TestAdminEndpointsRequireAuth(t *testing.T) {
5ac549c… lmata 248 ts, _ := newTestServerWithAdmins(t)
5ac549c… lmata 249 defer ts.Close()
5ac549c… lmata 250
5ac549c… lmata 251 endpoints := []struct{ method, path string }{
5ac549c… lmata 252 {"GET", "/v1/admins"},
5ac549c… lmata 253 {"POST", "/v1/admins"},
5ac549c… lmata 254 {"DELETE", "/v1/admins/admin"},
5ac549c… lmata 255 {"PUT", "/v1/admins/admin/password"},
5ac549c… lmata 256 }
5ac549c… lmata 257 for _, e := range endpoints {
5ac549c… lmata 258 t.Run(e.method+" "+e.path, func(t *testing.T) {
5ac549c… lmata 259 resp := do(t, ts, e.method, e.path, nil, nil)
5ac549c… lmata 260 defer resp.Body.Close()
5ac549c… lmata 261 if resp.StatusCode != http.StatusUnauthorized {
5ac549c… lmata 262 t.Errorf("expected 401, got %d", resp.StatusCode)
5ac549c… lmata 263 }
5ac549c… lmata 264 })
5ac549c… lmata 265 }
5ac549c… lmata 266 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button