mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
fix(cloudcp): proxy portal commercial actions same-origin
This commit is contained in:
@@ -251,7 +251,9 @@ canonical frontend state seam for:
|
||||
|
||||
1. account identity context
|
||||
2. hosted account and workspace summaries
|
||||
3. public/commercial edge URLs needed by the account shell
|
||||
3. public-site URLs plus same-origin portal route paths for commercial actions,
|
||||
so the browser shell can stay behind the control-plane CSP instead of
|
||||
calling shared license APIs cross-origin
|
||||
4. signed-out versus signed-in shell state, so login, session expiry, and
|
||||
authenticated account runtime all inherit one owned page contract instead of
|
||||
separate server-rendered templates
|
||||
|
||||
@@ -214,6 +214,12 @@ account identity context, signed-out versus signed-in shell state, workspace
|
||||
summaries, and renderer-owned public, commercial, and control-plane route
|
||||
configuration, including the canonical bootstrap route path, magic-link request
|
||||
path, signup path, and stable workspace summary fields such as `created_at`.
|
||||
Commercial self-service actions in that shell must stay same-origin as well:
|
||||
the frontend may only call the portal-owned `/api/portal/commercial/*` routes,
|
||||
and `internal/cloudcp/portal/commercial_proxy.go` plus `internal/cloudcp/routes.go`
|
||||
own the server-side proxy boundary to the shared license/commercial APIs so
|
||||
the browser runtime does not widen control-plane CSP with direct cross-origin
|
||||
commercial fetches.
|
||||
`/portal` is now one bootstrap-driven shell for both anonymous and
|
||||
authenticated users, so new account frontend work must extend that shared
|
||||
contract rather than inventing a second local payload shape, reviving separate
|
||||
|
||||
@@ -26,19 +26,19 @@ type BootstrapAccount struct {
|
||||
}
|
||||
|
||||
type BootstrapData struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Email string `json:"email"`
|
||||
PublicSiteURL string `json:"public_site_url"`
|
||||
SupportEmail string `json:"support_email"`
|
||||
CommercialAPIBaseURL string `json:"commercial_api_base_url"`
|
||||
PortalPath string `json:"portal_path"`
|
||||
BootstrapPath string `json:"bootstrap_path"`
|
||||
MagicLinkRequestPath string `json:"magic_link_request_path"`
|
||||
SignupPath string `json:"signup_path"`
|
||||
LogoutPath string `json:"logout_path"`
|
||||
AccountAPIBasePath string `json:"account_api_base_path"`
|
||||
PortalAPIBasePath string `json:"portal_api_base_path"`
|
||||
Accounts []BootstrapAccount `json:"accounts"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Email string `json:"email"`
|
||||
PublicSiteURL string `json:"public_site_url"`
|
||||
SupportEmail string `json:"support_email"`
|
||||
CommercialAPIBasePath string `json:"commercial_api_base_path"`
|
||||
PortalPath string `json:"portal_path"`
|
||||
BootstrapPath string `json:"bootstrap_path"`
|
||||
MagicLinkRequestPath string `json:"magic_link_request_path"`
|
||||
SignupPath string `json:"signup_path"`
|
||||
LogoutPath string `json:"logout_path"`
|
||||
AccountAPIBasePath string `json:"account_api_base_path"`
|
||||
PortalAPIBasePath string `json:"portal_api_base_path"`
|
||||
Accounts []BootstrapAccount `json:"accounts"`
|
||||
}
|
||||
|
||||
func MarshalBootstrapJSON(data BootstrapData) (template.JS, error) {
|
||||
@@ -75,19 +75,19 @@ func BuildBootstrapData(authenticated bool, email string, accounts []portalPageA
|
||||
}
|
||||
|
||||
return BootstrapData{
|
||||
Authenticated: authenticated,
|
||||
Email: email,
|
||||
PublicSiteURL: defaultPublicSiteURL,
|
||||
SupportEmail: defaultSupportEmail,
|
||||
CommercialAPIBaseURL: defaultCommercialAPIBaseURL,
|
||||
PortalPath: defaultPortalPath,
|
||||
BootstrapPath: PortalBootstrapPath,
|
||||
MagicLinkRequestPath: PortalMagicLinkRequestPath,
|
||||
SignupPath: PortalSignupPath,
|
||||
LogoutPath: defaultLogoutPath,
|
||||
AccountAPIBasePath: defaultAccountAPIBasePath,
|
||||
PortalAPIBasePath: defaultPortalAPIBasePath,
|
||||
Accounts: bootstrapAccounts,
|
||||
Authenticated: authenticated,
|
||||
Email: email,
|
||||
PublicSiteURL: defaultPublicSiteURL,
|
||||
SupportEmail: defaultSupportEmail,
|
||||
CommercialAPIBasePath: PortalCommercialAPIBasePath,
|
||||
PortalPath: defaultPortalPath,
|
||||
BootstrapPath: PortalBootstrapPath,
|
||||
MagicLinkRequestPath: PortalMagicLinkRequestPath,
|
||||
SignupPath: PortalSignupPath,
|
||||
LogoutPath: defaultLogoutPath,
|
||||
AccountAPIBasePath: defaultAccountAPIBasePath,
|
||||
PortalAPIBasePath: defaultPortalAPIBasePath,
|
||||
Accounts: bootstrapAccounts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultCommercialProxyBaseURL = "https://license.pulserelay.pro"
|
||||
|
||||
var allowedCommercialProxyEndpoints = map[string]string{
|
||||
"/v1/manage/request": http.MethodPost,
|
||||
"/v1/manage": http.MethodPost,
|
||||
"/v1/retrieve-license/request": http.MethodPost,
|
||||
"/v1/retrieve-license": http.MethodPost,
|
||||
"/v1/gdpr/request-export": http.MethodPost,
|
||||
"/v1/gdpr/export": http.MethodPost,
|
||||
"/v1/gdpr/request-delete": http.MethodPost,
|
||||
"/v1/gdpr/confirm-delete": http.MethodPost,
|
||||
"/v1/self-refund": http.MethodPost,
|
||||
}
|
||||
|
||||
type CommercialProxyConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func HandleCommercialProxy(cfg CommercialProxyConfig) http.HandlerFunc {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultCommercialProxyBaseURL
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := normalizeCommercialProxyPath(r.PathValue("commercial_path"))
|
||||
expectedMethod, ok := allowedCommercialProxyEndpoints[targetPath]
|
||||
if !ok || expectedMethod != r.Method {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
upstreamURL, err := url.Parse(baseURL + targetPath)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if contentType := strings.TrimSpace(r.Header.Get("Content-Type")); contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, "commercial_proxy_unavailable"), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(resp.Body, 1<<20))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCommercialProxyPath(raw string) string {
|
||||
raw = "/" + strings.TrimSpace(raw)
|
||||
raw = strings.ReplaceAll(raw, "\\", "/")
|
||||
for strings.Contains(raw, "//") {
|
||||
raw = strings.ReplaceAll(raw, "//", "/")
|
||||
}
|
||||
if raw == "/" {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "6c3fac3d004ef32e5bee17d4f8f80a569a64e0ee8582c417d79c7c537cce8d6d",
|
||||
"source_hash": "f6a8fe4433646b19222e045466d685cd413111f5af747e80af1ff31d4cf5d177",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+2
-2
@@ -293,7 +293,7 @@
|
||||
}, "Failed to sign out.");
|
||||
},
|
||||
postCommercialJSON: function(path, body) {
|
||||
return request(bootstrap().commercial_api_base_url + path, {
|
||||
return request(bootstrap().commercial_api_base_path + path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
@@ -1634,7 +1634,7 @@
|
||||
return {
|
||||
public_site_url: embeddedBootstrap.public_site_url || "https://pulserelay.pro",
|
||||
support_email: embeddedBootstrap.support_email || "support@pulserelay.pro",
|
||||
commercial_api_base_url: embeddedBootstrap.commercial_api_base_url || "",
|
||||
commercial_api_base_path: embeddedBootstrap.commercial_api_base_path || "/api/portal/commercial",
|
||||
portal_path: embeddedBootstrap.portal_path || "/portal",
|
||||
bootstrap_path: embeddedBootstrap.bootstrap_path || "/api/portal/bootstrap",
|
||||
magic_link_request_path: embeddedBootstrap.magic_link_request_path || "/api/public/magic-link/request",
|
||||
|
||||
@@ -28,7 +28,7 @@ async function flushAsync() {
|
||||
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/api/public/magic-link/request',
|
||||
|
||||
@@ -8,7 +8,7 @@ const bootstrap: PortalBootstrapData = {
|
||||
email: 'owner@example.com',
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/api/public/magic-link/request',
|
||||
|
||||
@@ -142,7 +142,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
|
||||
}, 'Failed to sign out.');
|
||||
},
|
||||
postCommercialJSON: function<T>(path: string, body: Record<string, unknown>) {
|
||||
return request<T>(bootstrap().commercial_api_base_url + path, {
|
||||
return request<T>(bootstrap().commercial_api_base_path + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -27,7 +27,7 @@ async function flushAsync() {
|
||||
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/api/public/magic-link/request',
|
||||
@@ -242,10 +242,10 @@ describe('portal app', function() {
|
||||
accounts: accounts,
|
||||
});
|
||||
}
|
||||
if (url === 'https://license.pulserelay.pro/v1/retrieve-license/request') {
|
||||
if (url === '/api/portal/commercial/v1/retrieve-license/request') {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url === 'https://license.pulserelay.pro/v1/retrieve-license') {
|
||||
if (url === '/api/portal/commercial/v1/retrieve-license') {
|
||||
return jsonResponse({
|
||||
license: {
|
||||
token: 'pulse_token_123',
|
||||
@@ -284,14 +284,14 @@ describe('portal app', function() {
|
||||
await flushAsync();
|
||||
|
||||
expect(fetchMock.mock.calls).toContainEqual([
|
||||
'https://license.pulserelay.pro/v1/retrieve-license/request',
|
||||
'/api/portal/commercial/v1/retrieve-license/request',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'owner@example.com' }),
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock.mock.calls).toContainEqual([
|
||||
'https://license.pulserelay.pro/v1/retrieve-license',
|
||||
'/api/portal/commercial/v1/retrieve-license',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'owner@example.com', code: '123456' }),
|
||||
|
||||
@@ -17,7 +17,7 @@ async function flushAsync() {
|
||||
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/magic-link',
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('portal runtime', function() {
|
||||
var defaults = createBootstrapDefaults({
|
||||
public_site_url: 'https://cloud.pulserelay.pro',
|
||||
support_email: 'help@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/account',
|
||||
bootstrap_path: '/api/account/bootstrap',
|
||||
magic_link_request_path: '/api/public/account/magic-link/request',
|
||||
@@ -28,12 +28,12 @@ describe('portal runtime', function() {
|
||||
authenticated: true,
|
||||
email: 'owner@example.com',
|
||||
accounts: [],
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
});
|
||||
|
||||
expect(runtime.store.getBootstrap().authenticated).toBe(true);
|
||||
expect(runtime.store.getBootstrap().email).toBe('owner@example.com');
|
||||
expect(runtime.bootstrapDefaults.commercial_api_base_url).toBe('https://license.pulserelay.pro');
|
||||
expect(runtime.bootstrapDefaults.commercial_api_base_path).toBe('/api/portal/commercial');
|
||||
});
|
||||
|
||||
it('derives canonical email and service handoff from the portal URL', function() {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createBootstrapDefaults(
|
||||
return {
|
||||
public_site_url: embeddedBootstrap.public_site_url || 'https://pulserelay.pro',
|
||||
support_email: embeddedBootstrap.support_email || 'support@pulserelay.pro',
|
||||
commercial_api_base_url: embeddedBootstrap.commercial_api_base_url || '',
|
||||
commercial_api_base_path: embeddedBootstrap.commercial_api_base_path || '/api/portal/commercial',
|
||||
portal_path: embeddedBootstrap.portal_path || '/portal',
|
||||
bootstrap_path: embeddedBootstrap.bootstrap_path || '/api/portal/bootstrap',
|
||||
magic_link_request_path: embeddedBootstrap.magic_link_request_path || '/api/public/magic-link/request',
|
||||
|
||||
@@ -18,7 +18,7 @@ function createBootstrap(overrides: Partial<PortalBootstrapData> = {}): PortalBo
|
||||
email: 'owner@example.com',
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/auth/magic-link',
|
||||
|
||||
@@ -15,7 +15,7 @@ function createBootstrap(overrides: Partial<PortalBootstrapData> = {}): PortalBo
|
||||
email: 'owner@example.com',
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/auth/magic-link',
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { PortalBootstrapData } from './types';
|
||||
const bootstrapDefaults: Omit<PortalBootstrapData, 'authenticated' | 'email' | 'accounts'> = {
|
||||
public_site_url: 'https://pulserelay.pro',
|
||||
support_email: 'support@pulserelay.pro',
|
||||
commercial_api_base_url: 'https://license.pulserelay.pro',
|
||||
commercial_api_base_path: '/api/portal/commercial',
|
||||
portal_path: '/portal',
|
||||
bootstrap_path: '/api/portal/bootstrap',
|
||||
magic_link_request_path: '/api/public/magic-link/request',
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface PortalBootstrapData {
|
||||
email: string;
|
||||
public_site_url: string;
|
||||
support_email: string;
|
||||
commercial_api_base_url: string;
|
||||
commercial_api_base_path: string;
|
||||
portal_path: string;
|
||||
bootstrap_path: string;
|
||||
magic_link_request_path: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ package portal
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
@@ -786,7 +787,7 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) {
|
||||
`id="portal-app-root"`,
|
||||
`id="pulse-account-bootstrap"`,
|
||||
`"authenticated":true`,
|
||||
`"commercial_api_base_url":"https://license.pulserelay.pro"`,
|
||||
fmt.Sprintf(`"commercial_api_base_path":"%s"`, PortalCommercialAPIBasePath),
|
||||
fmt.Sprintf(`"portal_path":"%s"`, PortalPagePath),
|
||||
fmt.Sprintf(`"bootstrap_path":"%s"`, PortalBootstrapPath),
|
||||
fmt.Sprintf(`"magic_link_request_path":"%s"`, PortalMagicLinkRequestPath),
|
||||
@@ -863,8 +864,8 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) {
|
||||
if got := payload["email"]; got != "owner@example.com" {
|
||||
t.Fatalf("email = %#v, want owner@example.com", got)
|
||||
}
|
||||
if got := payload["commercial_api_base_url"]; got != "https://license.pulserelay.pro" {
|
||||
t.Fatalf("commercial_api_base_url = %#v", got)
|
||||
if got := payload["commercial_api_base_path"]; got != PortalCommercialAPIBasePath {
|
||||
t.Fatalf("commercial_api_base_path = %#v", got)
|
||||
}
|
||||
if got := payload["portal_path"]; got != PortalPagePath {
|
||||
t.Fatalf("portal_path = %#v", got)
|
||||
@@ -982,6 +983,64 @@ func TestHandlePortalBootstrap_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommercialProxy_ForwardsAllowedRequest(t *testing.T) {
|
||||
var (
|
||||
gotMethod string
|
||||
gotPath string
|
||||
gotContentType string
|
||||
gotBody string
|
||||
)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
gotBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, PortalCommercialAPIBasePath+"/v1/retrieve-license/request", strings.NewReader(`{"email":"owner@example.com"}`))
|
||||
req.SetPathValue("commercial_path", "v1/retrieve-license/request")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("upstream method = %q, want %q", gotMethod, http.MethodPost)
|
||||
}
|
||||
if gotPath != "/v1/retrieve-license/request" {
|
||||
t.Fatalf("upstream path = %q, want %q", gotPath, "/v1/retrieve-license/request")
|
||||
}
|
||||
if gotContentType != "application/json" {
|
||||
t.Fatalf("upstream content-type = %q, want %q", gotContentType, "application/json")
|
||||
}
|
||||
if gotBody != `{"email":"owner@example.com"}` {
|
||||
t.Fatalf("upstream body = %q", gotBody)
|
||||
}
|
||||
if rec.Body.String() != `{"ok":true}` {
|
||||
t.Fatalf("response body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommercialProxy_RejectsUnknownPath(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, PortalCommercialAPIBasePath+"/v1/not-allowed", strings.NewReader(`{}`))
|
||||
req.SetPathValue("commercial_path", "v1/not-allowed")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
HandleCommercialProxy(CommercialProxyConfig{BaseURL: "https://license.pulserelay.pro"}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePortalBootstrap_RequiresAuth(t *testing.T) {
|
||||
reg := newTestRegistry(t)
|
||||
sessionSvc, err := cpauth.NewService(t.TempDir())
|
||||
|
||||
@@ -42,13 +42,12 @@ type portalPageData struct {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPublicSiteURL = "https://pulserelay.pro"
|
||||
defaultSupportEmail = "support@pulserelay.pro"
|
||||
defaultCommercialAPIBaseURL = "https://license.pulserelay.pro"
|
||||
defaultPortalPath = PortalPagePath
|
||||
defaultLogoutPath = PortalLogoutPath
|
||||
defaultAccountAPIBasePath = PortalAccountAPIBasePath
|
||||
defaultPortalAPIBasePath = PortalAPIBasePath
|
||||
defaultPublicSiteURL = "https://pulserelay.pro"
|
||||
defaultSupportEmail = "support@pulserelay.pro"
|
||||
defaultPortalPath = PortalPagePath
|
||||
defaultLogoutPath = PortalLogoutPath
|
||||
defaultAccountAPIBasePath = PortalAccountAPIBasePath
|
||||
defaultPortalAPIBasePath = PortalAPIBasePath
|
||||
)
|
||||
|
||||
var errPortalAuthRequired = errors.New("portal auth required")
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package portal
|
||||
|
||||
const (
|
||||
PortalPagePath = "/portal"
|
||||
PortalBootstrapPath = "/api/portal/bootstrap"
|
||||
PortalDashboardPath = "/api/portal/dashboard"
|
||||
PortalWorkspacePath = "/api/portal/workspaces/{tenant_id}"
|
||||
PortalBillingPath = "/api/portal/billing"
|
||||
PortalLogoutPath = "/auth/logout"
|
||||
PortalMagicLinkRequestPath = "/api/public/magic-link/request"
|
||||
PortalSignupPath = "/signup"
|
||||
PortalAccountAPIBasePath = "/api/accounts"
|
||||
PortalAPIBasePath = "/api/portal"
|
||||
PortalPagePath = "/portal"
|
||||
PortalBootstrapPath = "/api/portal/bootstrap"
|
||||
PortalDashboardPath = "/api/portal/dashboard"
|
||||
PortalWorkspacePath = "/api/portal/workspaces/{tenant_id}"
|
||||
PortalBillingPath = "/api/portal/billing"
|
||||
PortalCommercialAPIBasePath = "/api/portal/commercial"
|
||||
PortalCommercialAPIPath = "/api/portal/commercial/{commercial_path...}"
|
||||
PortalLogoutPath = "/auth/logout"
|
||||
PortalMagicLinkRequestPath = "/api/public/magic-link/request"
|
||||
PortalSignupPath = "/signup"
|
||||
PortalAccountAPIBasePath = "/api/accounts"
|
||||
PortalAPIBasePath = "/api/portal"
|
||||
)
|
||||
|
||||
@@ -230,6 +230,9 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
|
||||
mux.Handle(portal.PortalBootstrapPath, portalAPILimiter.Middleware(sessionAuth(portal.HandlePortalBootstrap(deps.MagicLinks, deps.Registry))))
|
||||
mux.Handle(portal.PortalDashboardPath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalDashboard(deps.Registry))))
|
||||
mux.Handle(portal.PortalWorkspacePath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalWorkspaceDetail(deps.Registry))))
|
||||
mux.Handle(portal.PortalCommercialAPIPath, portalAPILimiter.Middleware(sessionAuth(portal.HandleCommercialProxy(portal.CommercialProxyConfig{
|
||||
BaseURL: deps.Config.LicenseServerURL,
|
||||
}))))
|
||||
|
||||
// Stripe Customer Portal redirect (session + account-membership authenticated)
|
||||
billingCfg := portal.BillingPortalConfig{
|
||||
|
||||
@@ -338,6 +338,13 @@ func TestRegisterRoutes_PortalBootstrapRequiresSession(t *testing.T) {
|
||||
if revokedRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked status = %d, want %d (body=%q)", revokedRec.Code, http.StatusUnauthorized, revokedRec.Body.String())
|
||||
}
|
||||
|
||||
commercialReq := httptest.NewRequest(http.MethodPost, portal.PortalCommercialAPIBasePath+"/v1/retrieve-license/request", strings.NewReader(`{"email":"owner@example.com"}`))
|
||||
commercialRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(commercialRec, commercialReq)
|
||||
if commercialRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("portal commercial unauth status = %d, want %d (body=%q)", commercialRec.Code, http.StatusUnauthorized, commercialRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user