Files
sub2api-cn-relay-manager/internal/app/key_self_service_handlers_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

338 lines
11 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const (
testTrustedSubjectHeader = "X-CRM-Authenticated-Subject"
testTrustedProxySecretHeader = "X-CRM-Trusted-Proxy"
testTrustedProxySecret = "shared-secret"
)
func withTrustedProxyAuth(h *UserKeyHandler) *UserKeyHandler {
if h == nil {
return nil
}
clone := *h
clone.TrustedSubjectHeader = testTrustedSubjectHeader
clone.TrustedProxySecretHeader = testTrustedProxySecretHeader
clone.TrustedProxySecret = testTrustedProxySecret
return &clone
}
func applyTrustedProxyHeaders(req *http.Request, subjectID string) {
req.Header.Set(testTrustedSubjectHeader, subjectID)
req.Header.Set(testTrustedProxySecretHeader, testTrustedProxySecret)
}
func TestGeneratePlaintextKeyAndExtractSubjectID(t *testing.T) {
t.Parallel()
plaintext, fingerprint := generatePlaintextKey()
if !strings.HasPrefix(plaintext, "sk-") {
t.Fatalf("plaintext = %q, want sk- prefix", plaintext)
}
if !strings.HasPrefix(fingerprint, "sha256:") {
t.Fatalf("fingerprint = %q, want sha256 prefix", fingerprint)
}
t.Run("rejects bearer fallback when trusted proxy auth is not configured", func(t *testing.T) {
h := &UserKeyHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
req.Header.Set("Authorization", "Bearer abcdefgh12345678")
_, httpErr := h.extractSubjectID(req)
if httpErr == nil {
t.Fatal("expected unauthorized error when trusted proxy auth is not configured")
}
if httpErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", httpErr.StatusCode)
}
})
t.Run("rejects portal subject header when trusted proxy auth is not configured", func(t *testing.T) {
h := &UserKeyHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
req.Header.Set("X-Portal-Subject", "portal-user:1")
_, httpErr := h.extractSubjectID(req)
if httpErr == nil {
t.Fatal("expected unauthorized error when trusted proxy auth is not configured")
}
if httpErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", httpErr.StatusCode)
}
})
t.Run("accepts trusted proxy subject when proxy secret matches", func(t *testing.T) {
h := &UserKeyHandler{
TrustedSubjectHeader: "X-CRM-Authenticated-Subject",
TrustedProxySecretHeader: "X-CRM-Trusted-Proxy",
TrustedProxySecret: "shared-secret",
}
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
req.Header.Set("X-CRM-Authenticated-Subject", "portal-user:1")
req.Header.Set("X-CRM-Trusted-Proxy", "shared-secret")
subjectID, httpErr := h.extractSubjectID(req)
if httpErr != nil {
t.Fatalf("extractSubjectID() unexpected error: %+v", httpErr)
}
if subjectID != "portal-user:1" {
t.Fatalf("subjectID = %q, want portal-user:1", subjectID)
}
})
}
func TestHandleUserKeyListNotImplemented(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
rr := httptest.NewRecorder()
serveWithMetrics(t, req, rr, func(w http.ResponseWriter, r *http.Request) {
handleListUserKeys(w, r, nil)
})
if rr.Code != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleUserKeyListSuccess(t *testing.T) {
t.Parallel()
h := withTrustedProxyAuth(&UserKeyHandler{
listFn: func(ctx context.Context, subjectID string) ([]UserKeyMeta, error) {
if subjectID != "portal-user:1" {
t.Fatalf("subjectID = %q, want portal-user:1", subjectID)
}
return []UserKeyMeta{{KeyID: "key_1", AdminStatus: "active"}}, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
applyTrustedProxyHeaders(req, "portal-user:1")
rr := httptest.NewRecorder()
serveWithMetrics(t, req, rr, func(w http.ResponseWriter, r *http.Request) {
handleListUserKeys(w, r, h)
})
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "key_1") {
t.Fatalf("body missing key_1: %s", rr.Body.String())
}
}
func TestHandleGetUserKeyMissingKeyID(t *testing.T) {
t.Parallel()
h := withTrustedProxyAuth(&UserKeyHandler{getFn: func(context.Context, string, string) (UserKeyMeta, error) {
t.Fatal("getFn should not be called when key_id is missing")
return UserKeyMeta{}, nil
}})
req := httptest.NewRequest(http.MethodGet, "/api/keys/", nil)
applyTrustedProxyHeaders(req, "portal-user:1")
rr := httptest.NewRecorder()
serveWithMetrics(t, req, rr, func(w http.ResponseWriter, r *http.Request) {
handleGetUserKey(w, r, h)
})
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleUserKeyMutationHandlers(t *testing.T) {
t.Parallel()
meta := UserKeyMeta{KeyID: "key_1", MaskedPreview: "sk-****1234", AdminStatus: "active"}
cases := []struct {
name string
method string
path string
handlerFn func(http.ResponseWriter, *http.Request, *UserKeyHandler)
userHandler *UserKeyHandler
wantStatus int
wantBody string
}{
{
name: "get-success",
method: http.MethodGet,
path: "/api/keys/key_1",
handlerFn: handleGetUserKey,
userHandler: &UserKeyHandler{getFn: func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) {
if keyID != "key_1" || subjectID != "portal-user:1" {
t.Fatalf("getFn args = (%q,%q)", keyID, subjectID)
}
return meta, nil
}},
wantStatus: http.StatusOK,
wantBody: "key_1",
},
{
name: "reset-success",
method: http.MethodPost,
path: "/api/keys/key_1/reset",
handlerFn: handleResetUserKey,
userHandler: &UserKeyHandler{resetFn: func(ctx context.Context, keyID, subjectID string) (ResetUserKeyResponse, error) {
if keyID != "key_1" || subjectID != "portal-user:1" {
t.Fatalf("resetFn args = (%q,%q)", keyID, subjectID)
}
return ResetUserKeyResponse{PlaintextKey: "sk-new", MaskedPreview: "sk-****new", AdminStatus: "active"}, nil
}},
wantStatus: http.StatusOK,
wantBody: "sk-new",
},
{
name: "pause-success",
method: http.MethodPost,
path: "/api/keys/key_1/pause",
handlerFn: handlePauseUserKey,
userHandler: &UserKeyHandler{pauseFn: func(ctx context.Context, keyID, subjectID, reason string) (UserKeyMeta, error) {
if keyID != "key_1" || subjectID != "portal-user:1" || reason != "user requested pause" {
t.Fatalf("pauseFn args = (%q,%q,%q)", keyID, subjectID, reason)
}
paused := meta
paused.AdminStatus = "paused"
return paused, nil
}},
wantStatus: http.StatusOK,
wantBody: "paused",
},
{
name: "resume-success",
method: http.MethodPost,
path: "/api/keys/key_1/resume",
handlerFn: handleResumeUserKey,
userHandler: &UserKeyHandler{resumeFn: func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) {
if keyID != "key_1" || subjectID != "portal-user:1" {
t.Fatalf("resumeFn args = (%q,%q)", keyID, subjectID)
}
return meta, nil
}},
wantStatus: http.StatusOK,
wantBody: "active",
},
{
name: "delete-success",
method: http.MethodDelete,
path: "/api/keys/key_1",
handlerFn: handleDeleteUserKey,
userHandler: &UserKeyHandler{deleteFn: func(ctx context.Context, keyID, subjectID string) error {
if keyID != "key_1" || subjectID != "portal-user:1" {
t.Fatalf("deleteFn args = (%q,%q)", keyID, subjectID)
}
return nil
}},
wantStatus: http.StatusOK,
wantBody: "deleted",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, func() io.Reader {
if tc.name == "pause-success" {
return strings.NewReader(`{"reason":"user requested pause"}`)
}
return nil
}())
req.Header.Set("X-Portal-Subject", "portal-user:1")
req.SetPathValue("key_id", "key_1")
rr := httptest.NewRecorder()
applyTrustedProxyHeaders(req, "portal-user:1")
serveWithMetrics(t, req, rr, func(w http.ResponseWriter, r *http.Request) {
tc.handlerFn(w, r, withTrustedProxyAuth(tc.userHandler))
})
if rr.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d body=%s", rr.Code, tc.wantStatus, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), tc.wantBody) {
t.Fatalf("body missing %q: %s", tc.wantBody, rr.Body.String())
}
})
}
}
func serveWithMetrics(t *testing.T, req *http.Request, rr *httptest.ResponseRecorder, fn func(http.ResponseWriter, *http.Request)) {
t.Helper()
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fn(w, r)
}).ServeHTTP(rr, req)
}
func TestHandleListUserKeysResponseShape(t *testing.T) {
t.Parallel()
h := withTrustedProxyAuth(&UserKeyHandler{listFn: func(context.Context, string) ([]UserKeyMeta, error) {
return []UserKeyMeta{{KeyID: "key_json", AdminStatus: "active"}}, nil
}})
req := httptest.NewRequest(http.MethodGet, "/api/keys", nil)
applyTrustedProxyHeaders(req, "portal-user:json")
rr := httptest.NewRecorder()
handleListUserKeys(rr, req, h)
var payload struct {
Keys []UserKeyMeta `json:"keys"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Keys) != 1 || payload.Keys[0].KeyID != "key_json" {
t.Fatalf("payload = %+v, want one key_json entry", payload)
}
}
func TestHandleUserKeyMutationHandlersErrorPaths(t *testing.T) {
t.Parallel()
cases := []struct {
name string
handlerFn func(http.ResponseWriter, *http.Request, *UserKeyHandler)
userHandler *UserKeyHandler
wantStatus int
}{
{
name: "reset-not-found",
handlerFn: handleResetUserKey,
userHandler: &UserKeyHandler{resetFn: func(context.Context, string, string) (ResetUserKeyResponse, error) {
return ResetUserKeyResponse{}, fmt.Errorf("key %q not found", "key_1")
}},
wantStatus: http.StatusNotFound,
},
{
name: "pause-not-found",
handlerFn: handlePauseUserKey,
userHandler: &UserKeyHandler{pauseFn: func(context.Context, string, string, string) (UserKeyMeta, error) {
return UserKeyMeta{}, fmt.Errorf("key %q not found", "key_1")
}},
wantStatus: http.StatusNotFound,
},
{
name: "resume-not-found",
handlerFn: handleResumeUserKey,
userHandler: &UserKeyHandler{resumeFn: func(context.Context, string, string) (UserKeyMeta, error) {
return UserKeyMeta{}, fmt.Errorf("key %q not found", "key_1")
}},
wantStatus: http.StatusNotFound,
},
{
name: "delete-not-found",
handlerFn: handleDeleteUserKey,
userHandler: &UserKeyHandler{deleteFn: func(context.Context, string, string) error {
return fmt.Errorf("key %q not found", "key_1")
}},
wantStatus: http.StatusNotFound,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/keys/key_1", nil)
applyTrustedProxyHeaders(req, "portal-user:1")
req.SetPathValue("key_id", "key_1")
rr := httptest.NewRecorder()
tc.handlerFn(rr, req, withTrustedProxyAuth(tc.userHandler))
if rr.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d body=%s", rr.Code, tc.wantStatus, rr.Body.String())
}
})
}
}