- 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
267 lines
10 KiB
Go
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)
|
|
}
|
|
}
|