- 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
338 lines
11 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|
|
}
|