mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
1801 lines
62 KiB
Go
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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Portal Account"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateUser(®istry.User{ID: userID, Email: "owner@example.com"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateMembership(®istry.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(®istry.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(®istry.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(®istry.Tenant{
|
|
ID: tenantSuspendedID,
|
|
AccountID: accountID,
|
|
DisplayName: "Suspended Workspace",
|
|
State: registry.TenantStateSuspended,
|
|
CreatedAt: created2,
|
|
HealthCheckOK: false,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.Create(®istry.Tenant{
|
|
ID: tenantDeletedID,
|
|
AccountID: accountID,
|
|
DisplayName: "Deleted Workspace",
|
|
State: registry.TenantStateDeleted,
|
|
CreatedAt: created2,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.Create(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test Account"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateStripeAccount(®istry.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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateStripeAccount(®istry.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(®istry.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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateStripeAccount(®istry.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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateStripeAccount(®istry.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(®istry.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(®istry.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(®istry.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(®istry.Account{ID: accountID, Kind: registry.AccountKindIndividual, DisplayName: "Test"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.CreateStripeAccount(®istry.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)
|
|
}
|
|
}
|