|
9 | 9 | "testing" |
10 | 10 | "time" |
11 | 11 |
|
| 12 | + "qoder2api/auth" |
12 | 13 | "qoder2api/models" |
13 | 14 | "qoder2api/stats" |
14 | 15 | "qoder2api/store" |
@@ -380,3 +381,127 @@ func TestClientIPv6(t *testing.T) { |
380 | 381 | t.Errorf("clientIP(1.2.3.4:5678) = %q, want 1.2.3.4", got) |
381 | 382 | } |
382 | 383 | } |
| 384 | + |
| 385 | +// -- Config: stream timeouts -- |
| 386 | + |
| 387 | +func TestConfigGetIncludesTimeouts(t *testing.T) { |
| 388 | + a, _ := newAdmin(t) |
| 389 | + w := doRequest(t, a.handleConfig, "GET", "/admin/api/config", nil) |
| 390 | + if w.Code != 200 { |
| 391 | + t.Fatalf("expected 200, got %d", w.Code) |
| 392 | + } |
| 393 | + var resp map[string]interface{} |
| 394 | + json.Unmarshal(w.Body.Bytes(), &resp) |
| 395 | + if int(resp["chat_timeout_seconds"].(float64)) != store.DefaultChatTimeoutSeconds { |
| 396 | + t.Errorf("expected default chat timeout %d, got %v", store.DefaultChatTimeoutSeconds, resp["chat_timeout_seconds"]) |
| 397 | + } |
| 398 | + if int(resp["idle_timeout_seconds"].(float64)) != store.DefaultIdleTimeoutSeconds { |
| 399 | + t.Errorf("expected default idle timeout %d, got %v", store.DefaultIdleTimeoutSeconds, resp["idle_timeout_seconds"]) |
| 400 | + } |
| 401 | +} |
| 402 | + |
| 403 | +// Saving timeouts must persist to the store and hot-apply to the auth |
| 404 | +// package without a restart. |
| 405 | +func TestConfigSetTimeoutsHotApplied(t *testing.T) { |
| 406 | + defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout) |
| 407 | + a, s := newAdmin(t) |
| 408 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{ |
| 409 | + "host": "0.0.0.0", "port": 10081, |
| 410 | + "chat_timeout_seconds": 90, "idle_timeout_seconds": 45, |
| 411 | + }) |
| 412 | + if w.Code != 200 { |
| 413 | + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) |
| 414 | + } |
| 415 | + if s.GetChatTimeoutSeconds() != 90 || s.GetIdleTimeoutSeconds() != 45 { |
| 416 | + t.Errorf("timeouts not persisted: chat=%d idle=%d", s.GetChatTimeoutSeconds(), s.GetIdleTimeoutSeconds()) |
| 417 | + } |
| 418 | + timeouts := auth.CurrentStreamTimeouts() |
| 419 | + if timeouts.Header != 90*time.Second || timeouts.Idle != 45*time.Second { |
| 420 | + t.Errorf("timeouts not hot-applied: %+v", timeouts) |
| 421 | + } |
| 422 | + // GET reflects the effective values. |
| 423 | + w = doRequest(t, a.handleConfig, "GET", "/admin/api/config", nil) |
| 424 | + var resp map[string]interface{} |
| 425 | + json.Unmarshal(w.Body.Bytes(), &resp) |
| 426 | + if int(resp["chat_timeout_seconds"].(float64)) != 90 { |
| 427 | + t.Errorf("GET should report 90, got %v", resp["chat_timeout_seconds"]) |
| 428 | + } |
| 429 | +} |
| 430 | + |
| 431 | +// Omitted timeout fields (0) must keep current values so legacy callers |
| 432 | +// that only send host/port keep working; out-of-range values are rejected. |
| 433 | +func TestConfigSetTimeoutValidation(t *testing.T) { |
| 434 | + defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout) |
| 435 | + a, s := newAdmin(t) |
| 436 | + s.SetChatTimeoutSeconds(90) |
| 437 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{"host": "0.0.0.0", "port": 10081}) |
| 438 | + if w.Code != 200 { |
| 439 | + t.Fatalf("legacy body should be accepted, got %d: %s", w.Code, w.Body.String()) |
| 440 | + } |
| 441 | + if s.GetChatTimeoutSeconds() != 90 { |
| 442 | + t.Errorf("legacy body must not touch timeouts, got %d", s.GetChatTimeoutSeconds()) |
| 443 | + } |
| 444 | + for _, body := range []map[string]interface{}{ |
| 445 | + {"host": "0.0.0.0", "port": 10081, "chat_timeout_seconds": 5000}, |
| 446 | + {"host": "0.0.0.0", "port": 10081, "idle_timeout_seconds": -3}, |
| 447 | + } { |
| 448 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", body) |
| 449 | + if w.Code != 400 { |
| 450 | + t.Errorf("expected 400 for %+v, got %d: %s", body, w.Code, w.Body.String()) |
| 451 | + } |
| 452 | + } |
| 453 | +} |
| 454 | + |
| 455 | +// When timeouts are pinned by env vars the panel must refuse changes (the |
| 456 | +// env would silently win again after the next restart). |
| 457 | +func TestConfigTimeoutsEnvManaged(t *testing.T) { |
| 458 | + t.Setenv("QODER_CHAT_TIMEOUT_SECONDS", "90") |
| 459 | + a, _ := newAdmin(t) |
| 460 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{ |
| 461 | + "host": "0.0.0.0", "port": 10081, "chat_timeout_seconds": 60, |
| 462 | + }) |
| 463 | + if w.Code != 400 { |
| 464 | + t.Errorf("expected 400 when env-managed, got %d: %s", w.Code, w.Body.String()) |
| 465 | + } |
| 466 | +} |
| 467 | + |
| 468 | +// A legacy host/port-only save must not clobber env-pinned timeouts with the |
| 469 | +// persisted (file) values. |
| 470 | +func TestConfigLegacyBodyKeepsEnvTimeouts(t *testing.T) { |
| 471 | + auth.SetStreamTimeouts(200*time.Second, 300*time.Second) |
| 472 | + defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout) |
| 473 | + t.Setenv("QODER_CHAT_TIMEOUT_SECONDS", "200") |
| 474 | + |
| 475 | + a, s := newAdmin(t) |
| 476 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{"host": "0.0.0.0", "port": 10081}) |
| 477 | + if w.Code != 200 { |
| 478 | + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) |
| 479 | + } |
| 480 | + timeouts := auth.CurrentStreamTimeouts() |
| 481 | + if timeouts.Header != 200*time.Second { |
| 482 | + t.Errorf("legacy save clobbered env timeout, got %s", timeouts.Header) |
| 483 | + } |
| 484 | + if s.GetChatTimeoutSeconds() != store.DefaultChatTimeoutSeconds { |
| 485 | + t.Errorf("legacy save must not touch stored timeouts, got %d", s.GetChatTimeoutSeconds()) |
| 486 | + } |
| 487 | +} |
| 488 | + |
| 489 | +// A request mixing a valid and an invalid timeout must apply neither. |
| 490 | +func TestConfigPartialInvalidTimeoutAppliesNothing(t *testing.T) { |
| 491 | + defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout) |
| 492 | + a, s := newAdmin(t) |
| 493 | + w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{ |
| 494 | + "host": "0.0.0.0", "port": 10081, |
| 495 | + "chat_timeout_seconds": 90, "idle_timeout_seconds": 9999, |
| 496 | + }) |
| 497 | + if w.Code != 400 { |
| 498 | + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) |
| 499 | + } |
| 500 | + if s.GetChatTimeoutSeconds() != store.DefaultChatTimeoutSeconds { |
| 501 | + t.Errorf("valid field must not be applied when another field is invalid, got %d", s.GetChatTimeoutSeconds()) |
| 502 | + } |
| 503 | + timeouts := auth.CurrentStreamTimeouts() |
| 504 | + if timeouts.Header != auth.DefaultChatHeaderTimeout { |
| 505 | + t.Errorf("hot-apply must not run on rejected input, got %s", timeouts.Header) |
| 506 | + } |
| 507 | +} |
0 commit comments