|
7 | 7 | "net/http/httptest" |
8 | 8 | "strings" |
9 | 9 | "testing" |
| 10 | + "time" |
10 | 11 |
|
11 | 12 | "qoder2api/models" |
12 | 13 | "qoder2api/stats" |
@@ -105,13 +106,17 @@ func TestPATSetAndGet(t *testing.T) { |
105 | 106 | if w.Code != 200 { |
106 | 107 | t.Errorf("expected 200, got %d", w.Code) |
107 | 108 | } |
| 109 | + // The plaintext PAT must never appear in the response; only the masked form. |
| 110 | + if strings.Contains(w.Body.String(), "pt-test123") { |
| 111 | + t.Error("response must not leak the plaintext PAT") |
| 112 | + } |
108 | 113 | var resp map[string]string |
109 | 114 | json.Unmarshal(w.Body.Bytes(), &resp) |
110 | | - if resp["pat"] != "pt-test123" { |
111 | | - t.Errorf("expected 'pt-test123', got %q", resp["pat"]) |
| 115 | + if _, ok := resp["pat"]; ok { |
| 116 | + t.Error("response must not contain a 'pat' field") |
112 | 117 | } |
113 | | - if resp["pat_masked"] == "" { |
114 | | - t.Error("expected non-empty pat_masked") |
| 118 | + if resp["pat_masked"] != "pt-t****t123" { |
| 119 | + t.Errorf("expected masked PAT, got %q", resp["pat_masked"]) |
115 | 120 | } |
116 | 121 | } |
117 | 122 |
|
@@ -213,9 +218,9 @@ func TestStatsEndpoint(t *testing.T) { |
213 | 218 | return models.DefaultCatalog().Keys() |
214 | 219 | } |
215 | 220 | rec := stats.NewRecorder(nil) |
216 | | - rec.Record("Qwen3.7-Max", true, true) |
217 | | - rec.Record("Qwen3.7-Max", false, true) |
218 | | - rec.Record("DeepSeek-V4-Pro", true, false) |
| 221 | + rec.Record("Qwen3.7-Max", true) |
| 222 | + rec.Record("Qwen3.7-Max", false) |
| 223 | + rec.Record("DeepSeek-V4-Pro", true) |
219 | 224 | a := New(s, mf, rec) |
220 | 225 |
|
221 | 226 | w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil) |
@@ -320,3 +325,58 @@ func TestAddKeyDuplicateReturns409(t *testing.T) { |
320 | 325 | t.Errorf("expected 409 for duplicate key, got %d", w.Code) |
321 | 326 | } |
322 | 327 | } |
| 328 | + |
| 329 | +// --- Regression tests for review fixes --- |
| 330 | + |
| 331 | +// Sustained brute-force attempts must stay locked out: the old shift-based |
| 332 | +// backoff overflowed int64 nanoseconds after ~34 consecutive failures and |
| 333 | +// silently disabled the lockout entirely. |
| 334 | +func TestLoginLimiterStaysLockedAfterManyFailures(t *testing.T) { |
| 335 | + l := newLoginLimiter() |
| 336 | + for i := 0; i < 100; i++ { |
| 337 | + l.fail("1.2.3.4") |
| 338 | + } |
| 339 | + if locked, _ := l.check("1.2.3.4"); !locked { |
| 340 | + t.Fatal("attacker unlocked after 100 consecutive failures") |
| 341 | + } |
| 342 | + // And a capped wait is reported, not a bogus negative one. |
| 343 | + _, retry := l.check("1.2.3.4") |
| 344 | + if retry > 31*time.Second { |
| 345 | + t.Errorf("retry hint exceeds cap: %v", retry) |
| 346 | + } |
| 347 | +} |
| 348 | + |
| 349 | +// Changing the password must invalidate all pre-existing sessions. |
| 350 | +func TestPasswordChangeInvalidatesSessions(t *testing.T) { |
| 351 | + a, s := newAdmin(t) |
| 352 | + if err := s.SetPassword("oldpass"); err != nil { |
| 353 | + t.Fatal(err) |
| 354 | + } |
| 355 | + token, err := a.createSession() |
| 356 | + if err != nil { |
| 357 | + t.Fatal(err) |
| 358 | + } |
| 359 | + if !a.isValidSession(token) { |
| 360 | + t.Fatal("session should be valid before password change") |
| 361 | + } |
| 362 | + w := doRequest(t, a.handlePassword, "POST", "/admin/api/password", |
| 363 | + map[string]string{"current_password": "oldpass", "new_password": "newpass"}) |
| 364 | + if w.Code != 200 { |
| 365 | + t.Fatalf("password change failed: %d %s", w.Code, w.Body.String()) |
| 366 | + } |
| 367 | + if a.isValidSession(token) { |
| 368 | + t.Error("old session must be invalidated after password change") |
| 369 | + } |
| 370 | +} |
| 371 | + |
| 372 | +func TestClientIPv6(t *testing.T) { |
| 373 | + r := httptest.NewRequest("GET", "/", nil) |
| 374 | + r.RemoteAddr = "[::1]:8080" |
| 375 | + if got := clientIP(r); got != "::1" { |
| 376 | + t.Errorf("clientIP([::1]:8080) = %q, want ::1", got) |
| 377 | + } |
| 378 | + r.RemoteAddr = "1.2.3.4:5678" |
| 379 | + if got := clientIP(r); got != "1.2.3.4" { |
| 380 | + t.Errorf("clientIP(1.2.3.4:5678) = %q, want 1.2.3.4", got) |
| 381 | + } |
| 382 | +} |
0 commit comments