Files
sub2api-cn-relay-manager/internal/app/public_chat_metrics_test.go
phamnazage-jpg 4e2ee087fd feat(vNext.4): implement trusted-subject security chain for portal user key self-service
- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved

This implements the secure chain:
  Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)

Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services

Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
2026-06-09 07:48:03 +08:00

267 lines
10 KiB
Go

package app
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/metrics"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestPublicV1ChatCompletionsQuotaExhaustedRecordsMetric(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
const plaintextKey = "sk-test-quota-exhausted"
if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{
KeyID: "key_quota_exhausted",
OwnerSubjectID: "portal-user",
KeyFingerprint: "sha256:" + sha256Hex(plaintextKey),
MaskedPreview: "sk-****sted",
DisplayName: "quota key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-5.4"},
AdminStatus: "active",
QuotaStatus: "exhausted",
}); err != nil {
t.Fatalf("UserKeys().Create() error = %v", err)
}
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
ProxyRouteChatCompletions: func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) {
t.Fatal("proxy should not be called when quota is exhausted")
return ProxyRouteChatCompletionsResult{}, nil
},
}, appTestDSN(t, store))
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}]}`))
req.Header.Set("Authorization", "Bearer "+plaintextKey)
req.Header.Set("Content-Type", "application/json")
resp := httptestRecorder(handler, req)
if resp.code != http.StatusForbidden {
t.Fatalf("status code = %d, want 403 body=%s", resp.code, resp.Body().String())
}
assertJSONContains(t, resp.Body().Bytes(), "error.code", "quota_exhausted")
metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp := httptest.NewRecorder()
metrics.Handler().ServeHTTP(metricsResp, metricsReq)
body := metricsResp.Body.String()
if !strings.Contains(body, "user_key_chat_requests_total") || !strings.Contains(body, "quota_exhausted") {
t.Fatalf("metrics body missing quota_exhausted chat metric: %s", body)
}
}
func TestPublicV1ChatCompletionsPropagatesUpstreamFailureStatusAndMetric(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
const plaintextKey = "sk-test-upstream-429"
if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{
KeyID: "key_upstream_429",
OwnerSubjectID: "portal-user",
KeyFingerprint: "sha256:" + sha256Hex(plaintextKey),
MaskedPreview: "sk-****-429",
DisplayName: "upstream 429 key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-5.4"},
AdminStatus: "active",
QuotaStatus: "ok",
}); err != nil {
t.Fatalf("UserKeys().Create() error = %v", err)
}
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
ProxyRouteChatCompletions: func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) {
return ProxyRouteChatCompletionsResult{
Forward: RouteChatCompletionsForwardInfo{
OK: false,
UpstreamStatus: http.StatusTooManyRequests,
ErrorClass: "gateway_rate_limited",
Response: map[string]any{
"error": map[string]any{
"code": "upstream_rate_limited",
"message": "upstream rejected request",
},
},
},
}, nil
},
}, appTestDSN(t, store))
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}]}`))
req.Header.Set("Authorization", "Bearer "+plaintextKey)
req.Header.Set("Content-Type", "application/json")
resp := httptestRecorder(handler, req)
if resp.code != http.StatusTooManyRequests {
t.Fatalf("status code = %d, want 429 body=%s", resp.code, resp.Body().String())
}
assertJSONContains(t, resp.Body().Bytes(), "error.code", "upstream_rate_limited")
metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp := httptest.NewRecorder()
metrics.Handler().ServeHTTP(metricsResp, metricsReq)
body := metricsResp.Body.String()
if !strings.Contains(body, `user_key_chat_requests_total{result="gateway_rate_limited"}`) {
t.Fatalf("metrics body missing gateway_rate_limited metric: %s", body)
}
}
func TestPublicV1ChatCompletionsRejectsDisallowedModel(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
const plaintextKey = "sk-test-disallowed-model"
if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{
KeyID: "key_disallowed_model",
OwnerSubjectID: "portal-user",
KeyFingerprint: "sha256:" + sha256Hex(plaintextKey),
MaskedPreview: "sk-****odel",
DisplayName: "model restricted key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-4.1"},
AdminStatus: "active",
QuotaStatus: "ok",
}); err != nil {
t.Fatalf("UserKeys().Create() error = %v", err)
}
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
ProxyRouteChatCompletions: func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) {
t.Fatal("proxy should not be called for disallowed model")
return ProxyRouteChatCompletionsResult{}, nil
},
}, appTestDSN(t, store))
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}]}`))
req.Header.Set("Authorization", "Bearer "+plaintextKey)
req.Header.Set("Content-Type", "application/json")
resp := httptestRecorder(handler, req)
if resp.code != http.StatusForbidden {
t.Fatalf("status code = %d, want 403 body=%s", resp.code, resp.Body().String())
}
assertJSONContains(t, resp.Body().Bytes(), "error.code", "model_not_allowed")
}
func TestPublicV1ChatCompletionsRejectsExpiredKey(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
const plaintextKey = "sk-test-expired-key"
if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{
KeyID: "key_expired",
OwnerSubjectID: "portal-user",
KeyFingerprint: "sha256:" + sha256Hex(plaintextKey),
MaskedPreview: "sk-****ired",
DisplayName: "expired key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-5.4"},
AdminStatus: "active",
QuotaStatus: "ok",
}); err != nil {
t.Fatalf("UserKeys().Create() error = %v", err)
}
if _, err := store.SQLDB().ExecContext(context.Background(), `UPDATE user_keys SET expires_at = ? WHERE key_id = ?`, "2020-01-01T00:00:00Z", "key_expired"); err != nil {
t.Fatalf("set expires_at error = %v", err)
}
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
ProxyRouteChatCompletions: func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) {
t.Fatal("proxy should not be called for expired key")
return ProxyRouteChatCompletionsResult{}, nil
},
}, appTestDSN(t, store))
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}]}`))
req.Header.Set("Authorization", "Bearer "+plaintextKey)
req.Header.Set("Content-Type", "application/json")
resp := httptestRecorder(handler, req)
if resp.code != http.StatusForbidden {
t.Fatalf("status code = %d, want 403 body=%s", resp.code, resp.Body().String())
}
assertJSONContains(t, resp.Body().Bytes(), "error.code", "key_expired")
}
func TestPublicV1ChatCompletionsTouchesLastUsedAtOnSuccess(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
const plaintextKey = "sk-test-last-used"
if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{
KeyID: "key_last_used",
OwnerSubjectID: "portal-user",
KeyFingerprint: "sha256:" + sha256Hex(plaintextKey),
MaskedPreview: "sk-****used",
DisplayName: "active key",
LogicalGroupID: "gpt-shared",
AllowedModels: []string{"gpt-5.4"},
AdminStatus: "active",
QuotaStatus: "ok",
}); err != nil {
t.Fatalf("UserKeys().Create() error = %v", err)
}
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
ProxyRouteChatCompletions: func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) {
return ProxyRouteChatCompletionsResult{Forward: RouteChatCompletionsForwardInfo{OK: true, UpstreamStatus: http.StatusOK, Response: map[string]any{"id": "chatcmpl_ok", "object": "chat.completion", "model": "gpt-5.4", "choices": []map[string]any{{"index": 0, "message": map[string]any{"role": "assistant", "content": "pong"}, "finish_reason": "stop"}}}}}, nil
},
}, appTestDSN(t, store))
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"ping"}]}`))
req.Header.Set("Authorization", "Bearer "+plaintextKey)
req.Header.Set("Content-Type", "application/json")
resp := httptestRecorder(handler, req)
if resp.code != http.StatusOK {
t.Fatalf("status code = %d, want 200 body=%s", resp.code, resp.Body().String())
}
record, err := store.UserKeys().GetByID(context.Background(), "key_last_used")
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if strings.TrimSpace(record.LastUsedAt) == "" {
t.Fatalf("LastUsedAt = %q, want non-empty after successful chat", record.LastUsedAt)
}
}
func TestMetricsMiddlewareUsesRoutePatternForKeyReset(t *testing.T) {
t.Parallel()
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
handler := NewAPIHandler("t", ActionSet{
UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)),
})
req := httptest.NewRequest(http.MethodPost, "/api/keys/key_abc123/reset", nil)
req.Header.Set("Content-Type", "application/json")
_ = httptestRecorder(handler, req)
metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp := httptest.NewRecorder()
metrics.Handler().ServeHTTP(metricsResp, metricsReq)
body := metricsResp.Body.String()
if !strings.Contains(body, "/api/keys/{key_id}/reset") {
t.Fatalf("expected normalized route pattern in metrics output, got: %s", body)
}
}