Pulse/internal/cloudcp/portal/handlers_test.go
2026-07-07 20:37:18 +01:00

1801 lines
62 KiB
Go

package portal
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/admin"
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
stripe "github.com/stripe/stripe-go/v82"
)
func newTestRegistry(t *testing.T) *registry.TenantRegistry {
t.Helper()
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
return reg
}
func newTestMux(reg *registry.TenantRegistry) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle(PortalDashboardPath, admin.AdminKeyMiddleware("secret-key", HandlePortalDashboard(reg)))
mux.Handle(PortalWorkspacePath, admin.AdminKeyMiddleware("secret-key", HandlePortalWorkspaceDetail(reg)))
return mux
}
type staticSetupFactReader map[string]WorkspaceSetupFacts
func (r staticSetupFactReader) FactsForWorkspace(tenantID string) WorkspaceSetupFacts {
return r[tenantID]
}
func doRequest(t *testing.T, h http.Handler, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
req.Header.Set("X-Admin-Key", "secret-key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func renderPortalHTML(t *testing.T, bootstrap BootstrapData) string {
t.Helper()
rec := httptest.NewRecorder()
renderPortalPage(rec, "test-nonce", "/favicon.svg?v=test-favicon", bootstrap)
if rec.Code != http.StatusOK {
t.Fatalf("renderPortalPage returned %d", rec.Code)
}
return rec.Body.String()
}
func extractPortalBootstrapJSONFromHTML(t *testing.T, html string) string {
t.Helper()
startMarker := `<script id="pulse-account-bootstrap" type="application/json">`
start := strings.Index(html, startMarker)
if start < 0 {
t.Fatal("bootstrap script tag not found in portal HTML")
}
start += len(startMarker)
end := strings.Index(html[start:], `</script>`)
if end < 0 {
t.Fatal("bootstrap script closing tag not found in portal HTML")
}
return html[start : start+end]
}
func newPortalSessionFixture(t *testing.T) (*registry.TenantRegistry, *cpauth.Service, string, string, string) {
t.Helper()
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
userID, err := registry.GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Portal Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&registry.User{ID: userID, Email: "owner@example.com"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateMembership(&registry.AccountMembership{
AccountID: accountID,
UserID: userID,
Role: registry.MemberRoleOwner,
}); err != nil {
t.Fatal(err)
}
sessionSvc, err := cpauth.NewService(t.TempDir())
if err != nil {
t.Fatalf("NewService: %v", err)
}
t.Cleanup(sessionSvc.Close)
token, err := sessionSvc.GenerateSessionToken(userID, "owner@example.com", cpauth.SessionTTL)
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
return reg, sessionSvc, token, accountID, userID
}
type dashboardResp struct {
Account struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Kind registry.AccountKind `json:"kind"`
} `json:"account"`
Workspaces []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
State registry.TenantState `json:"state"`
HealthCheckOK bool `json:"health_check_ok"`
SetupStatus string `json:"setup_status"`
AgentCount *int `json:"agent_count"`
AgentTokenCount *int `json:"agent_token_count"`
UnusedAgentTokenCount *int `json:"unused_agent_token_count"`
LastAgentSeenAt *time.Time `json:"last_agent_seen_at"`
AlertRouteCount *int `json:"alert_route_count"`
DisabledAlertRouteCount *int `json:"disabled_alert_route_count"`
ActiveCriticalAlertCount *int `json:"active_critical_alert_count"`
ActiveWarningAlertCount *int `json:"active_warning_alert_count"`
ActiveAlertsUpdatedAt *time.Time `json:"active_alerts_updated_at"`
ReportScheduleCount *int `json:"report_schedule_count"`
DisabledReportScheduleCount *int `json:"disabled_report_schedule_count"`
LastHealthCheck *time.Time `json:"last_health_check"`
CreatedAt time.Time `json:"created_at"`
} `json:"workspaces"`
Summary struct {
Total int `json:"total"`
Active int `json:"active"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
Suspended int `json:"suspended"`
Critical int `json:"critical"`
} `json:"summary"`
}
func TestPortalDashboard(t *testing.T) {
reg := newTestRegistry(t)
mux := newTestMux(reg)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
t.Fatal(err)
}
tenantActiveID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
tenantSuspendedID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
tenantDeletedID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
tenantDeletingID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
created1 := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC)
created2 := time.Date(2026, 2, 10, 11, 0, 0, 0, time.UTC)
lastCheck := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC)
if err := reg.Create(&registry.Tenant{
ID: tenantActiveID,
AccountID: accountID,
DisplayName: "Acme Dental",
State: registry.TenantStateActive,
CreatedAt: created1,
LastHealthCheck: &lastCheck,
HealthCheckOK: true,
}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&registry.Tenant{
ID: tenantSuspendedID,
AccountID: accountID,
DisplayName: "Suspended Workspace",
State: registry.TenantStateSuspended,
CreatedAt: created2,
HealthCheckOK: false,
}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&registry.Tenant{
ID: tenantDeletedID,
AccountID: accountID,
DisplayName: "Deleted Workspace",
State: registry.TenantStateDeleted,
CreatedAt: created2,
}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&registry.Tenant{
ID: tenantDeletingID,
AccountID: accountID,
DisplayName: "Deleting Workspace",
State: registry.TenantStateDeleting,
CreatedAt: created2,
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp dashboardResp
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
if resp.Account.ID != accountID {
t.Fatalf("account.id = %q, want %q", resp.Account.ID, accountID)
}
if resp.Account.DisplayName != "Example MSP" {
t.Fatalf("account.display_name = %q, want %q", resp.Account.DisplayName, "Example MSP")
}
if resp.Account.Kind != registry.AccountKindMSP {
t.Fatalf("account.kind = %q, want %q", resp.Account.Kind, registry.AccountKindMSP)
}
if len(resp.Workspaces) != 2 {
t.Fatalf("workspaces len = %d, want %d", len(resp.Workspaces), 2)
}
// Make assertions order-independent.
sort.Slice(resp.Workspaces, func(i, j int) bool { return resp.Workspaces[i].ID < resp.Workspaces[j].ID })
wsByID := map[string]dashboardRespWorkspace{}
// local helper type for easier indexing
for _, ws := range resp.Workspaces {
wsByID[ws.ID] = dashboardRespWorkspace{
ID: ws.ID,
DisplayName: ws.DisplayName,
State: ws.State,
HealthCheckOK: ws.HealthCheckOK,
SetupStatus: ws.SetupStatus,
AgentCount: ws.AgentCount,
AgentTokenCount: ws.AgentTokenCount,
UnusedAgentTokenCount: ws.UnusedAgentTokenCount,
LastAgentSeenAt: ws.LastAgentSeenAt,
AlertRouteCount: ws.AlertRouteCount,
DisabledAlertRouteCount: ws.DisabledAlertRouteCount,
ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount,
ActiveWarningAlertCount: ws.ActiveWarningAlertCount,
ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt,
ReportScheduleCount: ws.ReportScheduleCount,
DisabledReportScheduleCount: ws.DisabledReportScheduleCount,
LastHealthCheck: ws.LastHealthCheck,
CreatedAt: ws.CreatedAt,
}
}
active := wsByID[tenantActiveID]
if active.ID == "" {
t.Fatalf("missing active workspace id %q", tenantActiveID)
}
if active.DisplayName != "Acme Dental" {
t.Fatalf("active.display_name = %q, want %q", active.DisplayName, "Acme Dental")
}
if active.State != registry.TenantStateActive {
t.Fatalf("active.state = %q, want %q", active.State, registry.TenantStateActive)
}
if !active.HealthCheckOK {
t.Fatalf("active.health_check_ok = false, want true")
}
if active.SetupStatus != "setup_path" {
t.Fatalf("active.setup_status = %q, want setup_path", active.SetupStatus)
}
if active.LastHealthCheck == nil || !active.LastHealthCheck.Equal(lastCheck) {
t.Fatalf("active.last_health_check = %v, want %v", active.LastHealthCheck, lastCheck)
}
if !active.CreatedAt.Equal(created1) {
t.Fatalf("active.created_at = %v, want %v", active.CreatedAt, created1)
}
susp := wsByID[tenantSuspendedID]
if susp.ID == "" {
t.Fatalf("missing suspended workspace id %q", tenantSuspendedID)
}
if susp.State != registry.TenantStateSuspended {
t.Fatalf("suspended.state = %q, want %q", susp.State, registry.TenantStateSuspended)
}
if susp.SetupStatus != "review" {
t.Fatalf("suspended.setup_status = %q, want review", susp.SetupStatus)
}
if resp.Summary.Total != 2 {
t.Fatalf("summary.total = %d, want %d", resp.Summary.Total, 2)
}
if resp.Summary.Active != 1 {
t.Fatalf("summary.active = %d, want %d", resp.Summary.Active, 1)
}
if resp.Summary.Healthy != 1 {
t.Fatalf("summary.healthy = %d, want %d", resp.Summary.Healthy, 1)
}
if resp.Summary.Unhealthy != 0 {
t.Fatalf("summary.unhealthy = %d, want %d", resp.Summary.Unhealthy, 0)
}
if resp.Summary.Suspended != 1 {
t.Fatalf("summary.suspended = %d, want %d", resp.Summary.Suspended, 1)
}
}
func TestPortalDashboardUsesWorkspaceSetupFacts(t *testing.T) {
reg := newTestRegistry(t)
mux := http.NewServeMux()
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
t.Fatal(err)
}
tenantInstallID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
tenantOutputsID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
tenantReadyID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
lastSeen := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
for _, tenant := range []*registry.Tenant{
{
ID: tenantInstallID,
AccountID: accountID,
DisplayName: "Install",
State: registry.TenantStateActive,
CreatedAt: lastSeen,
HealthCheckOK: true,
},
{
ID: tenantOutputsID,
AccountID: accountID,
DisplayName: "Outputs",
State: registry.TenantStateActive,
CreatedAt: lastSeen,
HealthCheckOK: true,
},
{
ID: tenantReadyID,
AccountID: accountID,
DisplayName: "Ready",
State: registry.TenantStateActive,
CreatedAt: lastSeen,
HealthCheckOK: true,
},
} {
if err := reg.Create(tenant); err != nil {
t.Fatal(err)
}
}
mux.Handle(PortalDashboardPath, admin.AdminKeyMiddleware("secret-key", HandlePortalDashboardWithSetupFacts(reg, staticSetupFactReader{
tenantInstallID: {
AgentCount: intPtr(0),
AgentTokenCount: intPtr(1),
UnusedAgentTokenCount: intPtr(1),
AlertRouteCount: intPtr(0),
DisabledAlertRouteCount: intPtr(0),
ReportScheduleCount: intPtr(0),
DisabledReportScheduleCount: intPtr(0),
},
tenantOutputsID: {
AgentCount: intPtr(1),
AgentTokenCount: intPtr(1),
UnusedAgentTokenCount: intPtr(0),
LastAgentSeenAt: &lastSeen,
AlertRouteCount: intPtr(1),
DisabledAlertRouteCount: intPtr(1),
ReportScheduleCount: intPtr(0),
DisabledReportScheduleCount: intPtr(1),
},
tenantReadyID: {
AgentCount: intPtr(2),
AgentTokenCount: intPtr(2),
UnusedAgentTokenCount: intPtr(0),
LastAgentSeenAt: &lastSeen,
AlertRouteCount: intPtr(1),
DisabledAlertRouteCount: intPtr(0),
ReportScheduleCount: intPtr(1),
DisabledReportScheduleCount: intPtr(0),
},
})))
req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp dashboardResp
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
got := map[string]dashboardRespWorkspace{}
for _, ws := range resp.Workspaces {
got[ws.ID] = dashboardRespWorkspace{
ID: ws.ID,
DisplayName: ws.DisplayName,
State: ws.State,
HealthCheckOK: ws.HealthCheckOK,
SetupStatus: ws.SetupStatus,
AgentCount: ws.AgentCount,
AgentTokenCount: ws.AgentTokenCount,
UnusedAgentTokenCount: ws.UnusedAgentTokenCount,
LastAgentSeenAt: ws.LastAgentSeenAt,
AlertRouteCount: ws.AlertRouteCount,
DisabledAlertRouteCount: ws.DisabledAlertRouteCount,
ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount,
ActiveWarningAlertCount: ws.ActiveWarningAlertCount,
ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt,
ReportScheduleCount: ws.ReportScheduleCount,
DisabledReportScheduleCount: ws.DisabledReportScheduleCount,
LastHealthCheck: ws.LastHealthCheck,
CreatedAt: ws.CreatedAt,
}
}
if got[tenantInstallID].SetupStatus != "install_agents" {
t.Fatalf("install setup_status = %q, want install_agents", got[tenantInstallID].SetupStatus)
}
if got[tenantOutputsID].SetupStatus != "configure_outputs" {
t.Fatalf("outputs setup_status = %q, want configure_outputs", got[tenantOutputsID].SetupStatus)
}
if got[tenantReadyID].SetupStatus != "ready" {
t.Fatalf("ready setup_status = %q, want ready", got[tenantReadyID].SetupStatus)
}
if got[tenantReadyID].AgentCount == nil || *got[tenantReadyID].AgentCount != 2 {
t.Fatalf("ready agent_count = %v, want 2", got[tenantReadyID].AgentCount)
}
if got[tenantReadyID].LastAgentSeenAt == nil || !got[tenantReadyID].LastAgentSeenAt.Equal(lastSeen) {
t.Fatalf("ready last_agent_seen_at = %v, want %v", got[tenantReadyID].LastAgentSeenAt, lastSeen)
}
if got[tenantInstallID].AgentTokenCount == nil || *got[tenantInstallID].AgentTokenCount != 1 {
t.Fatalf("install agent_token_count = %v, want 1", got[tenantInstallID].AgentTokenCount)
}
if got[tenantInstallID].UnusedAgentTokenCount == nil || *got[tenantInstallID].UnusedAgentTokenCount != 1 {
t.Fatalf("install unused_agent_token_count = %v, want 1", got[tenantInstallID].UnusedAgentTokenCount)
}
if got[tenantOutputsID].DisabledAlertRouteCount == nil || *got[tenantOutputsID].DisabledAlertRouteCount != 1 {
t.Fatalf("outputs disabled_alert_route_count = %v, want 1", got[tenantOutputsID].DisabledAlertRouteCount)
}
if got[tenantOutputsID].DisabledReportScheduleCount == nil || *got[tenantOutputsID].DisabledReportScheduleCount != 1 {
t.Fatalf("outputs disabled_report_schedule_count = %v, want 1", got[tenantOutputsID].DisabledReportScheduleCount)
}
}
func TestPortalDashboardRollsUpActiveAlertsAndSortsCriticalFirst(t *testing.T) {
reg := newTestRegistry(t)
mux := http.NewServeMux()
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
t.Fatal(err)
}
criticalID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
unknownID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
for _, tenant := range []*registry.Tenant{
{ID: unknownID, AccountID: accountID, DisplayName: "Unknown Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true},
{ID: criticalID, AccountID: accountID, DisplayName: "Critical Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true},
} {
if err := reg.Create(tenant); err != nil {
t.Fatal(err)
}
}
mux.Handle(PortalDashboardPath, admin.AdminKeyMiddleware("secret-key", HandlePortalDashboardWithSetupFacts(reg, staticSetupFactReader{
criticalID: {
AgentCount: intPtr(1),
AlertRouteCount: intPtr(1),
ReportScheduleCount: intPtr(1),
ActiveCriticalAlertCount: intPtr(2),
ActiveWarningAlertCount: intPtr(3),
ActiveAlertsUpdatedAt: &now,
DisabledAlertRouteCount: intPtr(0),
DisabledReportScheduleCount: intPtr(0),
},
unknownID: {
AgentCount: intPtr(1),
AlertRouteCount: intPtr(1),
ReportScheduleCount: intPtr(1),
DisabledAlertRouteCount: intPtr(0),
DisabledReportScheduleCount: intPtr(0),
},
})))
req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp dashboardResp
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
if len(resp.Workspaces) != 2 {
t.Fatalf("workspaces len = %d, want 2", len(resp.Workspaces))
}
if resp.Workspaces[0].ID != criticalID {
t.Fatalf("first workspace = %s, want critical workspace %s", resp.Workspaces[0].ID, criticalID)
}
if resp.Workspaces[0].ActiveCriticalAlertCount == nil || *resp.Workspaces[0].ActiveCriticalAlertCount != 2 {
t.Fatalf("critical active_critical_alert_count = %v, want 2", resp.Workspaces[0].ActiveCriticalAlertCount)
}
if resp.Workspaces[0].ActiveWarningAlertCount == nil || *resp.Workspaces[0].ActiveWarningAlertCount != 3 {
t.Fatalf("critical active_warning_alert_count = %v, want 3", resp.Workspaces[0].ActiveWarningAlertCount)
}
if resp.Workspaces[0].ActiveAlertsUpdatedAt == nil || !resp.Workspaces[0].ActiveAlertsUpdatedAt.Equal(now) {
t.Fatalf("critical active_alerts_updated_at = %v, want %v", resp.Workspaces[0].ActiveAlertsUpdatedAt, now)
}
if resp.Workspaces[1].ActiveCriticalAlertCount != nil || resp.Workspaces[1].ActiveWarningAlertCount != nil {
t.Fatalf("unknown alert counts = critical %v warning %v, want nil", resp.Workspaces[1].ActiveCriticalAlertCount, resp.Workspaces[1].ActiveWarningAlertCount)
}
if resp.Summary.Critical != 1 {
t.Fatalf("summary.critical = %d, want 1", resp.Summary.Critical)
}
}
type dashboardRespWorkspace struct {
ID string
DisplayName string
State registry.TenantState
HealthCheckOK bool
SetupStatus string
AgentCount *int
AgentTokenCount *int
UnusedAgentTokenCount *int
LastAgentSeenAt *time.Time
AlertRouteCount *int
DisabledAlertRouteCount *int
ActiveCriticalAlertCount *int
ActiveWarningAlertCount *int
ActiveAlertsUpdatedAt *time.Time
ReportScheduleCount *int
DisabledReportScheduleCount *int
LastHealthCheck *time.Time
CreatedAt time.Time
}
func TestPortalDashboardEmpty(t *testing.T) {
reg := newTestRegistry(t)
mux := newTestMux(reg)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Empty MSP"}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp dashboardResp
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
if len(resp.Workspaces) != 0 {
t.Fatalf("workspaces len = %d, want %d", len(resp.Workspaces), 0)
}
if resp.Summary.Total != 0 || resp.Summary.Active != 0 || resp.Summary.Healthy != 0 || resp.Summary.Unhealthy != 0 || resp.Summary.Suspended != 0 {
t.Fatalf("summary = %+v, want all zeros", resp.Summary)
}
}
func TestPortalWorkspaceDetail(t *testing.T) {
reg := newTestRegistry(t)
mux := newTestMux(reg)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
t.Fatal(err)
}
tenantID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
created := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC)
lastCheck := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC)
if err := reg.Create(&registry.Tenant{
ID: tenantID,
AccountID: accountID,
DisplayName: "Acme Dental",
State: registry.TenantStateActive,
CreatedAt: created,
LastHealthCheck: &lastCheck,
HealthCheckOK: true,
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/workspaces/"+tenantID+"?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Account struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Kind registry.AccountKind `json:"kind"`
} `json:"account"`
Workspace registry.Tenant `json:"workspace"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
if resp.Account.ID != accountID {
t.Fatalf("account.id = %q, want %q", resp.Account.ID, accountID)
}
if resp.Workspace.ID != tenantID {
t.Fatalf("workspace.id = %q, want %q", resp.Workspace.ID, tenantID)
}
if resp.Workspace.AccountID != accountID {
t.Fatalf("workspace.account_id = %q, want %q", resp.Workspace.AccountID, accountID)
}
if resp.Workspace.DisplayName != "Acme Dental" {
t.Fatalf("workspace.display_name = %q, want %q", resp.Workspace.DisplayName, "Acme Dental")
}
if resp.Workspace.State != registry.TenantStateActive {
t.Fatalf("workspace.state = %q, want %q", resp.Workspace.State, registry.TenantStateActive)
}
if !resp.Workspace.HealthCheckOK {
t.Fatalf("workspace.health_check_ok = false, want true")
}
if resp.Workspace.LastHealthCheck == nil || !resp.Workspace.LastHealthCheck.Equal(lastCheck) {
t.Fatalf("workspace.last_health_check = %v, want %v", resp.Workspace.LastHealthCheck, lastCheck)
}
if !resp.Workspace.CreatedAt.Equal(created) {
t.Fatalf("workspace.created_at = %v, want %v", resp.Workspace.CreatedAt, created)
}
}
func TestPortalWorkspaceDetailHidesDeletedWorkspaces(t *testing.T) {
reg := newTestRegistry(t)
mux := newTestMux(reg)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
t.Fatal(err)
}
tenantID, err := registry.GenerateTenantID()
if err != nil {
t.Fatal(err)
}
if err := reg.Create(&registry.Tenant{
ID: tenantID,
AccountID: accountID,
DisplayName: "Deleted Workspace",
State: registry.TenantStateDeleted,
CreatedAt: time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC),
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/workspaces/"+tenantID+"?account_id="+accountID, nil)
rec := doRequest(t, mux, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
// --- Billing portal redirect tests ---
func newBillingHandler(t *testing.T, reg *registry.TenantRegistry, apiKey string, returnURL string,
mock func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error),
) http.HandlerFunc {
t.Helper()
h := &billingPortalHandler{
reg: reg,
cfg: BillingPortalConfig{
StripeAPIKey: apiKey,
ReturnURL: returnURL,
},
createSession: mock,
}
return h.serveHTTP
}
func TestBillingPortalRedirect_Success(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateStripeAccount(&registry.StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_billing123",
}); err != nil {
t.Fatal(err)
}
var capturedParams *stripe.BillingPortalSessionParams
handler := newBillingHandler(t, reg, "sk_test_key", "https://cloud.example.com/portal",
func(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
capturedParams = params
return &stripe.BillingPortalSession{URL: "https://billing.stripe.com/session/bps_test"}, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
}
if resp["url"] != "https://billing.stripe.com/session/bps_test" {
t.Fatalf("url = %q, want stripe billing portal URL", resp["url"])
}
if capturedParams == nil {
t.Fatal("expected createSession to be called")
}
if got := stripe.StringValue(capturedParams.Customer); got != "cus_test_billing123" {
t.Fatalf("Customer = %q, want %q", got, "cus_test_billing123")
}
if got := stripe.StringValue(capturedParams.ReturnURL); got != "https://cloud.example.com/portal" {
t.Fatalf("ReturnURL = %q, want %q", got, "https://cloud.example.com/portal")
}
}
func TestBillingPortalRedirect_AdminRoleAllowed(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateStripeAccount(&registry.StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_admin",
}); err != nil {
t.Fatal(err)
}
handler := newBillingHandler(t, reg, "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
return &stripe.BillingPortalSession{URL: "https://billing.stripe.com/session/admin"}, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "admin")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestBillingPortalRedirect_ForbiddenForTechRole(t *testing.T) {
handler := newBillingHandler(t, newTestRegistry(t), "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id=a_test", nil)
req.Header.Set("X-User-Role", "tech")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestBillingPortalRedirect_ForbiddenForReadOnlyRole(t *testing.T) {
handler := newBillingHandler(t, newTestRegistry(t), "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id=a_test", nil)
req.Header.Set("X-User-Role", "read_only")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestBillingPortalRedirect_MethodNotAllowed(t *testing.T) {
handler := newBillingHandler(t, newTestRegistry(t), "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodGet, "/api/portal/billing?account_id=a_test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
}
func TestBillingPortalRedirect_MissingAccountID(t *testing.T) {
handler := newBillingHandler(t, newTestRegistry(t), "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing", nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestBillingPortalRedirect_NoStripeAccount(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "No Billing"}); err != nil {
t.Fatal(err)
}
handler := newBillingHandler(t, reg, "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
func TestBillingPortalRedirect_StripeAPIKeyNotConfigured(t *testing.T) {
handler := newBillingHandler(t, newTestRegistry(t), "", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
t.Fatal("should not be called")
return nil, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id=a_test", nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestBillingPortalRedirect_StripeError(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateStripeAccount(&registry.StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_err",
}); err != nil {
t.Fatal(err)
}
handler := newBillingHandler(t, reg, "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
return nil, fmt.Errorf("stripe API unavailable")
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusBadGateway, rec.Body.String())
}
}
func TestBillingPortalRedirect_EmptyURL(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateStripeAccount(&registry.StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_empty",
}); err != nil {
t.Fatal(err)
}
handler := newBillingHandler(t, reg, "sk_test_key", "",
func(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
return &stripe.BillingPortalSession{URL: ""}, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusBadGateway, rec.Body.String())
}
}
// --- Portal page template rendering tests ---
func TestPortalPageTemplate_AccessManagementRendered(t *testing.T) {
html := renderPortalHTML(t, BuildBootstrapData(true, "admin@example.com", []portalPageAccount{
{
ID: "a_managed",
Kind: "cloud",
KindLabel: "Cloud",
Name: "My Cloud Account",
Role: "owner",
CanManage: true,
HasBilling: false,
},
}, false))
mustContain := []string{
`id="portal-app-root"`,
`if (account.can_manage) {`,
`data-actor-role="`,
`id="access-list-`,
`data-shell-section="access"`,
`data-action="invite-member"`,
}
for _, needle := range mustContain {
if !strings.Contains(html, needle) {
t.Errorf("expected %q in rendered HTML", needle)
}
}
var payload map[string]any
if err := json.Unmarshal([]byte(extractPortalBootstrapJSONFromHTML(t, html)), &payload); err != nil {
t.Fatalf("unmarshal bootstrap JSON: %v", err)
}
if got := payload["authenticated"]; got != true {
t.Fatalf("authenticated = %#v, want true", got)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v, want one account", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account = %#v", accounts[0])
}
if got := account["can_manage"]; got != true {
t.Fatalf("can_manage = %#v, want true", got)
}
if got := account["role"]; got != "owner" {
t.Fatalf("role = %#v, want owner", got)
}
}
func TestPortalPageTemplate_TeamManagementHiddenForNonManagers(t *testing.T) {
html := renderPortalHTML(t, BuildBootstrapData(true, "viewer@example.com", []portalPageAccount{
{
ID: "a_readonly",
Kind: "cloud",
KindLabel: "Cloud",
Name: "Readonly Account",
Role: "read_only",
CanManage: false,
HasBilling: false,
},
}, false))
var payload map[string]any
if err := json.Unmarshal([]byte(extractPortalBootstrapJSONFromHTML(t, html)), &payload); err != nil {
t.Fatalf("unmarshal bootstrap JSON: %v", err)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v, want one account", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account = %#v", accounts[0])
}
if got := account["can_manage"]; got != false {
t.Fatalf("can_manage = %#v, want false", got)
}
if got := account["role"]; got != "read_only" {
t.Fatalf("role = %#v, want read_only", got)
}
}
func TestPortalPageTemplate_ActorRolePassedToSection(t *testing.T) {
for _, role := range []string{"owner", "admin"} {
t.Run(role, func(t *testing.T) {
html := renderPortalHTML(t, BuildBootstrapData(true, "test@example.com", []portalPageAccount{
{
ID: "a_test",
Kind: "cloud",
KindLabel: "Cloud",
Name: "Test",
Role: role,
CanManage: true,
},
}, false))
if !strings.Contains(html, `data-actor-role="`) {
t.Fatalf("expected actor-role render seam in portal shell")
}
var payload map[string]any
if err := json.Unmarshal([]byte(extractPortalBootstrapJSONFromHTML(t, html)), &payload); err != nil {
t.Fatalf("unmarshal bootstrap JSON: %v", err)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v, want one account", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account = %#v", accounts[0])
}
if got := account["role"]; got != role {
t.Fatalf("role = %#v, want %q", got, role)
}
})
}
}
func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) {
html := renderPortalHTML(t, BuildBootstrapData(true, "owner@example.com", []portalPageAccount{
{
ID: "a_test",
Kind: "cloud",
KindLabel: "Cloud",
Name: "Test Account",
},
}, false))
mustContain := []string{
"<title>Pulse Account</title>",
`<link rel="icon" href="/favicon.svg?v=test-favicon" type="image/svg+xml">`,
`id="portal-user-info"`,
`id="portal-app-root"`,
`id="pulse-account-bootstrap"`,
`"authenticated":true`,
`"commercial_api_base_url":"/api/portal/commercial"`,
fmt.Sprintf(`"portal_path":"%s"`, PortalPagePath),
fmt.Sprintf(`"bootstrap_path":"%s"`, PortalBootstrapPath),
fmt.Sprintf(`"magic_link_request_path":"%s"`, PortalMagicLinkRequestPath),
fmt.Sprintf(`"signup_path":"%s"`, PortalSignupPath),
fmt.Sprintf(`"logout_path":"%s"`, PortalLogoutPath),
fmt.Sprintf(`"account_api_base_path":"%s"`, PortalAccountAPIBasePath),
fmt.Sprintf(`"portal_api_base_path":"%s"`, PortalAPIBasePath),
`"email":"owner@example.com"`,
`"accounts":[{"id":"a_test"`,
}
for _, needle := range mustContain {
if !strings.Contains(html, needle) {
t.Errorf("expected %q in rendered HTML", needle)
}
}
if strings.Contains(html, `const LICENSE_API_BASE = 'https://license.pulserelay.pro';`) {
t.Errorf("expected commercial API base URL to be renderer-owned, not hardcoded in the asset")
}
if strings.Contains(html, `window.location.reload()`) {
t.Errorf("expected workspace lifecycle to refresh the bootstrap contract instead of forcing a page reload")
}
if strings.Contains(html, `showToast('Member invited!');`+"\n"+` loadTeam(accountID);`) {
t.Errorf("expected membership mutations to refresh the account bootstrap instead of only repainting the team table")
}
if strings.Contains(html, `onclick="`) {
t.Errorf("expected portal shell interactions to be delegated through data-action attributes instead of inline onclick handlers")
}
if strings.Contains(html, `assets/portal_shell.js`) || strings.Contains(html, `assets/portal_services.js`) || strings.Contains(html, `assets/portal.css`) {
t.Errorf("expected portal runtime to load from the built dist bundle, not the old handwritten asset paths")
}
if strings.Contains(html, `window.PulseAccountPortal`) {
t.Errorf("expected Pulse Account frontend coordination to stay module-owned, not revive a browser-global runtime")
}
if strings.Contains(html, `pulse-account-render`) {
t.Errorf("expected Pulse Account frontend coordination to stay module-owned, not revive a document-wide render event")
}
if strings.Contains(html, `await fetch('/auth/logout'`) {
t.Errorf("expected portal paths to be renderer-owned, not hardcoded in the asset")
}
}
func TestBuildPortalBootstrapJSON_Contract(t *testing.T) {
lastHealthCheck := time.Date(2026, 3, 27, 9, 0, 0, 0, time.UTC)
memberCreatedAt := time.Date(2026, 3, 27, 8, 30, 0, 0, time.UTC)
bootstrapJSON, err := MarshalBootstrapJSON(BuildBootstrapData(true, "owner@example.com", []portalPageAccount{
{
ID: "a_test",
Kind: "cloud",
KindLabel: "Cloud",
Name: "Test Account",
Role: "owner",
CanManage: true,
HasBilling: true,
Members: []portalPageMember{
{
UserID: "u_owner",
Email: "owner@example.com",
Role: registry.MemberRoleOwner,
CreatedAt: memberCreatedAt,
},
},
Workspaces: []portalPageWorkspace{
{
ID: "t_one",
DisplayName: "Tenant One",
State: "active",
Healthy: true,
HealthStatus: "healthy",
SetupStatus: "setup_path",
LastHealthCheck: &lastHealthCheck,
},
},
},
}, true))
if err != nil {
t.Fatalf("MarshalBootstrapJSON: %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(bootstrapJSON), &payload); err != nil {
t.Fatalf("unmarshal bootstrap JSON: %v", err)
}
if got := payload["authenticated"]; got != true {
t.Fatalf("authenticated = %#v, want true", got)
}
if got := payload["email"]; got != "owner@example.com" {
t.Fatalf("email = %#v, want owner@example.com", got)
}
if got := payload["has_self_hosted_commercial"]; got != true {
t.Fatalf("has_self_hosted_commercial = %#v, want true", got)
}
if got := payload["commercial_api_base_url"]; got != "/api/portal/commercial" {
t.Fatalf("commercial_api_base_url = %#v", got)
}
if got := payload["portal_path"]; got != PortalPagePath {
t.Fatalf("portal_path = %#v", got)
}
if got := payload["bootstrap_path"]; got != PortalBootstrapPath {
t.Fatalf("bootstrap_path = %#v", got)
}
if got := payload["magic_link_request_path"]; got != PortalMagicLinkRequestPath {
t.Fatalf("magic_link_request_path = %#v", got)
}
if got := payload["signup_path"]; got != PortalSignupPath {
t.Fatalf("signup_path = %#v", got)
}
if got := payload["logout_path"]; got != PortalLogoutPath {
t.Fatalf("logout_path = %#v", got)
}
if got := payload["account_api_base_path"]; got != PortalAccountAPIBasePath {
t.Fatalf("account_api_base_path = %#v", got)
}
if got := payload["portal_api_base_path"]; got != PortalAPIBasePath {
t.Fatalf("portal_api_base_path = %#v", got)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v, want one account", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account payload = %#v", accounts[0])
}
if got := account["id"]; got != "a_test" {
t.Fatalf("account id = %#v", got)
}
if got := account["can_manage"]; got != true {
t.Fatalf("account can_manage = %#v", got)
}
members, ok := account["members"].([]any)
if !ok || len(members) != 1 {
t.Fatalf("members = %#v, want one member", account["members"])
}
member, ok := members[0].(map[string]any)
if !ok {
t.Fatalf("member payload = %#v", members[0])
}
if got := member["email"]; got != "owner@example.com" {
t.Fatalf("member email = %#v", got)
}
if got := member["role"]; got != "owner" {
t.Fatalf("member role = %#v", got)
}
if got := member["created_at"]; got != "2026-03-27T08:30:00Z" {
t.Fatalf("member created_at = %#v", got)
}
workspaces, ok := account["workspaces"].([]any)
if !ok || len(workspaces) != 1 {
t.Fatalf("workspaces = %#v, want one workspace", account["workspaces"])
}
workspace, ok := workspaces[0].(map[string]any)
if !ok {
t.Fatalf("workspace payload = %#v", workspaces[0])
}
if got := workspace["display_name"]; got != "Tenant One" {
t.Fatalf("workspace display_name = %#v", got)
}
if got := workspace["healthy"]; got != true {
t.Fatalf("workspace healthy = %#v", got)
}
if got := workspace["health_status"]; got != "healthy" {
t.Fatalf("workspace health_status = %#v, want healthy", got)
}
if got := workspace["setup_status"]; got != "setup_path" {
t.Fatalf("workspace setup_status = %#v, want setup_path", got)
}
if got := workspace["last_health_check"]; got != "2026-03-27T09:00:00Z" {
t.Fatalf("workspace last_health_check = %#v, want 2026-03-27T09:00:00Z", got)
}
if got := workspace["created_at"]; got != "0001-01-01T00:00:00Z" {
t.Fatalf("workspace created_at = %#v", got)
}
}
func TestBuildPortalBootstrapJSON_IncludesMSPSetupTemplate(t *testing.T) {
bootstrapJSON, err := MarshalBootstrapJSON(BuildBootstrapData(true, "owner@example.com", []portalPageAccount{
{
ID: "a_msp",
Kind: string(registry.AccountKindMSP),
KindLabel: "MSP",
Name: "Example MSP",
Role: "owner",
CanManage: true,
},
}, false))
if err != nil {
t.Fatalf("MarshalBootstrapJSON: %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(bootstrapJSON), &payload); err != nil {
t.Fatalf("unmarshal bootstrap JSON: %v", err)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v, want one account", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account payload = %#v", accounts[0])
}
templates, ok := account["setup_templates"].([]any)
if !ok || len(templates) != 1 {
t.Fatalf("setup_templates = %#v, want one template", account["setup_templates"])
}
template, ok := templates[0].(map[string]any)
if !ok {
t.Fatalf("setup template payload = %#v", templates[0])
}
if got := template["title"]; got != "Standard client onboarding" {
t.Fatalf("setup template title = %#v", got)
}
agentNaming, ok := template["agent_naming"].(string)
if !ok || !strings.Contains(agentNaming, "repeated hostnames") {
t.Fatalf("setup template agent_naming = %#v, want repeated-hostname guidance", template["agent_naming"])
}
}
func TestHandlePortalBootstrap_Success(t *testing.T) {
reg, sessionSvc, token, accountID, _ := newPortalSessionFixture(t)
if err := reg.Create(&registry.Tenant{
ID: "t_bootstrap",
AccountID: accountID,
DisplayName: "Bootstrap Workspace",
State: registry.TenantStateActive,
CreatedAt: time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC),
HealthCheckOK: true,
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/bootstrap", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
HandlePortalBootstrap(sessionSvc, reg, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal bootstrap response: %v", err)
}
if got := payload["email"]; got != "owner@example.com" {
t.Fatalf("email = %#v", got)
}
if got := payload["has_self_hosted_commercial"]; got != false {
t.Fatalf("has_self_hosted_commercial = %#v, want false", got)
}
if got := payload["portal_api_base_path"]; got != PortalAPIBasePath {
t.Fatalf("portal_api_base_path = %#v", got)
}
if got := payload["bootstrap_path"]; got != PortalBootstrapPath {
t.Fatalf("bootstrap_path = %#v", got)
}
accounts, ok := payload["accounts"].([]any)
if !ok || len(accounts) != 1 {
t.Fatalf("accounts = %#v", payload["accounts"])
}
account, ok := accounts[0].(map[string]any)
if !ok {
t.Fatalf("account = %#v", accounts[0])
}
members, ok := account["members"].([]any)
if !ok || len(members) != 1 {
t.Fatalf("members = %#v", account["members"])
}
member, ok := members[0].(map[string]any)
if !ok {
t.Fatalf("member = %#v", members[0])
}
if got := member["email"]; got != "owner@example.com" {
t.Fatalf("member email = %#v", got)
}
workspaces, ok := account["workspaces"].([]any)
if !ok || len(workspaces) != 1 {
t.Fatalf("workspaces = %#v", account["workspaces"])
}
workspace, ok := workspaces[0].(map[string]any)
if !ok {
t.Fatalf("workspace = %#v", workspaces[0])
}
if got := workspace["display_name"]; got != "Bootstrap Workspace" {
t.Fatalf("workspace display_name = %#v", got)
}
if got := workspace["created_at"]; got != "2026-03-25T10:00:00Z" {
t.Fatalf("workspace created_at = %#v", got)
}
if got := workspace["setup_status"]; got != "setup_path" {
t.Fatalf("workspace setup_status = %#v, want setup_path", got)
}
}
func TestHandlePortalBootstrapIncludesWorkspaceSetupFacts(t *testing.T) {
reg, sessionSvc, token, accountID, _ := newPortalSessionFixture(t)
lastSeen := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
if err := reg.Create(&registry.Tenant{
ID: "t_bootstrap_facts",
AccountID: accountID,
DisplayName: "Bootstrap Workspace",
State: registry.TenantStateActive,
CreatedAt: time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC),
HealthCheckOK: true,
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/bootstrap", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
HandlePortalBootstrapWithSignupPathAndSetupFacts(sessionSvc, reg, nil, PortalSignupPath, staticSetupFactReader{
"t_bootstrap_facts": {
AgentCount: intPtr(1),
AgentTokenCount: intPtr(2),
UnusedAgentTokenCount: intPtr(1),
LastAgentSeenAt: &lastSeen,
AlertRouteCount: intPtr(1),
DisabledAlertRouteCount: intPtr(1),
ReportScheduleCount: intPtr(0),
DisabledReportScheduleCount: intPtr(1),
},
}).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal bootstrap response: %v", err)
}
accounts := payload["accounts"].([]any)
account := accounts[0].(map[string]any)
workspaces := account["workspaces"].([]any)
workspace := workspaces[0].(map[string]any)
if got := workspace["setup_status"]; got != "configure_outputs" {
t.Fatalf("workspace setup_status = %#v, want configure_outputs", got)
}
if got := workspace["agent_count"]; got != float64(1) {
t.Fatalf("workspace agent_count = %#v, want 1", got)
}
if got := workspace["agent_token_count"]; got != float64(2) {
t.Fatalf("workspace agent_token_count = %#v, want 2", got)
}
if got := workspace["unused_agent_token_count"]; got != float64(1) {
t.Fatalf("workspace unused_agent_token_count = %#v, want 1", got)
}
if got := workspace["alert_route_count"]; got != float64(1) {
t.Fatalf("workspace alert_route_count = %#v, want 1", got)
}
if got := workspace["disabled_alert_route_count"]; got != float64(1) {
t.Fatalf("workspace disabled_alert_route_count = %#v, want 1", got)
}
if got := workspace["report_schedule_count"]; got != float64(0) {
t.Fatalf("workspace report_schedule_count = %#v, want 0", got)
}
if got := workspace["disabled_report_schedule_count"]; got != float64(1) {
t.Fatalf("workspace disabled_report_schedule_count = %#v, want 1", got)
}
if got := workspace["last_agent_seen_at"]; got != "2026-04-01T10:00:00Z" {
t.Fatalf("workspace last_agent_seen_at = %#v, want 2026-04-01T10:00:00Z", got)
}
}
func TestHandlePortalBootstrap_RequiresAuth(t *testing.T) {
reg := newTestRegistry(t)
sessionSvc, err := cpauth.NewService(t.TempDir())
if err != nil {
t.Fatalf("NewService: %v", err)
}
t.Cleanup(sessionSvc.Close)
req := httptest.NewRequest(http.MethodGet, "/api/portal/bootstrap", nil)
rec := httptest.NewRecorder()
HandlePortalBootstrap(sessionSvc, reg, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestHandlePortalBootstrap_RevokedSessionUnauthorized(t *testing.T) {
reg, sessionSvc, token, _, userID := newPortalSessionFixture(t)
if _, err := reg.RevokeUserSessions(userID); err != nil {
t.Fatalf("RevokeUserSessions: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/bootstrap", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
HandlePortalBootstrap(sessionSvc, reg, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestPortalBootstrapHTMLAndHandlerStayInSync(t *testing.T) {
reg, sessionSvc, token, accountID, _ := newPortalSessionFixture(t)
if err := reg.Create(&registry.Tenant{
ID: "t_sync",
AccountID: accountID,
DisplayName: "Sync Workspace",
State: registry.TenantStateActive,
CreatedAt: time.Date(2026, 3, 25, 11, 0, 0, 0, time.UTC),
HealthCheckOK: true,
}); err != nil {
t.Fatal(err)
}
claims, err := validatePortalSessionClaims(httptest.NewRequest(http.MethodGet, PortalPagePath, nil), nil, nil)
if err == nil || claims != nil {
t.Fatal("expected nil-session validation to require auth")
}
loadedAccounts, err := loadPortalAccountsForUser(reg, strings.TrimSpace("u_missing"))
if err != nil {
t.Fatalf("loadPortalAccountsForUser for missing user: %v", err)
}
if len(loadedAccounts) != 0 {
t.Fatalf("missing user accounts = %d, want 0", len(loadedAccounts))
}
req := httptest.NewRequest(http.MethodGet, "/api/portal/bootstrap", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
HandlePortalBootstrap(sessionSvc, reg, func(_ context.Context, email string) (*CommercialIdentity, error) {
if email != "owner@example.com" {
t.Fatalf("lookup email = %q", email)
}
return &CommercialIdentity{HasCommercialIdentity: true}, nil
}).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bootstrap status = %d, want %d", rec.Code, http.StatusOK)
}
claimsReq := httptest.NewRequest(http.MethodGet, PortalPagePath, nil)
claimsReq.Header.Set("Authorization", "Bearer "+token)
validClaims, err := validatePortalSessionClaims(claimsReq, sessionSvc, reg)
if err != nil {
t.Fatalf("validatePortalSessionClaims: %v", err)
}
accounts, err := loadPortalAccountsForUser(reg, validClaims.UserID)
if err != nil {
t.Fatalf("loadPortalAccountsForUser: %v", err)
}
html := renderPortalHTML(t, BuildBootstrapData(true, validClaims.Email, accounts, true))
handlerJSON := strings.TrimSpace(rec.Body.String())
pageJSON := strings.TrimSpace(extractPortalBootstrapJSONFromHTML(t, html))
if handlerJSON != pageJSON {
t.Fatalf("bootstrap payload drift:\nhandler=%s\npage=%s", handlerJSON, pageJSON)
}
}
func TestPortalPageTemplate_UsesPulseAccountBrandingWhenSignedOut(t *testing.T) {
html := renderPortalHTML(t, BuildAnonymousBootstrapData())
mustContain := []string{
"<title>Pulse Account</title>",
`<link rel="icon" href="/favicon.svg?v=test-favicon" type="image/svg+xml">`,
"Pulse Account",
`id="portal-app-root"`,
"Enter the commercial email address for your Pulse account.",
`"authenticated":false`,
fmt.Sprintf(`"magic_link_request_path":"%s"`, PortalMagicLinkRequestPath),
fmt.Sprintf(`"signup_path":"%s"`, PortalSignupPath),
`data-portal-action="send-magic-link"`,
`data-portal-action="resend-magic-link"`,
}
for _, needle := range mustContain {
if !strings.Contains(html, needle) {
t.Errorf("expected %q in rendered HTML", needle)
}
}
}
func TestBillingPortalRedirect_NoReturnURL(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateStripeAccount(&registry.StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_noreturn",
}); err != nil {
t.Fatal(err)
}
var capturedParams *stripe.BillingPortalSessionParams
handler := newBillingHandler(t, reg, "sk_test_key", "",
func(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
capturedParams = params
return &stripe.BillingPortalSession{URL: "https://billing.stripe.com/session/bps_test"}, nil
},
)
req := httptest.NewRequest(http.MethodPost, "/api/portal/billing?account_id="+accountID, nil)
req.Header.Set("X-User-Role", "owner")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
if capturedParams == nil {
t.Fatal("expected createSession to be called")
}
if capturedParams.ReturnURL != nil {
t.Fatalf("ReturnURL = %q, want nil (no return URL configured)", stripe.StringValue(capturedParams.ReturnURL))
}
}
func TestCommercialProxy_Success(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/v1/manage/request" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("content-type = %q", got)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if got := payload["email"]; got != "owner@example.com" {
t.Fatalf("email = %#v", got)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer upstream.Close()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, PortalCommercialProxyPath+"v1/manage/request", strings.NewReader(`{"email":"owner@example.com"}`))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("commercial_path", "v1/manage/request")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL})(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"ok":true}` {
t.Fatalf("body = %q", got)
}
}
func TestCommercialProxy_RejectsUnsupportedRoute(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, PortalCommercialProxyPath+"v1/not-allowed", strings.NewReader(`{}`))
req.SetPathValue("commercial_path", "v1/not-allowed")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: "https://license.pulserelay.pro"})(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCommercialProxy_UpstreamFailureReturnsBadGateway(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
}))
defer upstream.Close()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, PortalCommercialProxyPath+"v1/manage/request", strings.NewReader(`{"email":"owner@example.com"}`))
req.SetPathValue("commercial_path", "v1/manage/request")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL})(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway)
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"upstream unavailable"}` {
t.Fatalf("body = %q", got)
}
}
func TestCommercialProxy_AllowsPricingAndCheckoutGETRequests(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/v1/public/pricing-model" {
t.Fatalf("path = %s, want /v1/public/pricing-model", r.URL.Path)
}
if got := r.URL.Query().Get("track"); got != "v6" {
t.Fatalf("track = %q, want v6", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"title":"pricing"}`))
}))
defer upstream.Close()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, PortalCommercialProxyPath+"v1/public/pricing-model?track=v6", nil)
req.SetPathValue("commercial_path", "v1/public/pricing-model")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL})(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"title":"pricing"}` {
t.Fatalf("body = %q", got)
}
}
func TestCommercialProxy_AllowsPortalHandoffGETRequests(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/v1/checkout/portal-handoff" {
t.Fatalf("path = %s, want /v1/checkout/portal-handoff", r.URL.Path)
}
if got := r.URL.Query().Get("portal_handoff_id"); got != "cph_upgrade" {
t.Fatalf("portal_handoff_id = %q, want cph_upgrade", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"portal_handoff_id":"cph_upgrade","feature":"self_hosted_plan"}`))
}))
defer upstream.Close()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, PortalCommercialProxyPath+"v1/checkout/portal-handoff?portal_handoff_id=cph_upgrade", nil)
req.SetPathValue("commercial_path", "v1/checkout/portal-handoff")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL})(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"portal_handoff_id":"cph_upgrade","feature":"self_hosted_plan"}` {
t.Fatalf("body = %q", got)
}
}
func TestCommercialProxy_RejectsLegacyCheckoutIntentBootstrap(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, PortalCommercialProxyPath+"v1/checkout/intent?checkout_intent_id=cki_legacy", nil)
req.SetPathValue("commercial_path", "v1/checkout/intent")
HandleCommercialProxy(CommercialProxyConfig{BaseURL: "https://license.pulserelay.pro"})(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}