mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 00:43:34 +00:00
Exercised the full provider portal as a pilot MSP would and fixed what made it feel broken: - The signed-out portal promised "a sign-in link is on the way" even when the control plane has no email provider (the bundle default), and team invitations silently sent nothing. The portal bootstrap now carries email_sign_in_available and provider_hosted_mode; the sign-in page shows the host command that actually prints a link, and the invite panel says invitation emails are not sent and how to hand over a link instead. - New "provider-msp portal-link --email" CLI mints a one-time portal link for an account member or pending invitee, so teammates can sign in at all on email-less installs (bootstrap only covers the owner). - Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them and provider-hosted MSP mode defaults to 7 days. - Creating a client past the license cap showed a generic "Failed to create workspace." toast: the limit error is now a JSON payload with current/limit, the API client no longer drops non-JSON error bodies (double body read), and the toast explains the license limit. - Copy polish: provider-mode sign-in intro (no refunds/privacy register), least-privilege default invite role, queue tile label matches "Client onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh summary now prints the bootstrap next step and day-2 sign-in commands, .env.example and docs/MSP.md document portal sign-in and sessions. Contracts updated (cloud-paid, api-contracts, deployment-installability, security-privacy) with verification pins in tenant_handlers_test, config_test, magiclink_test, and provider_msp_deploy_test. Verified live against a dockerless control plane: portal-link for an invitee redeems, promotes the invitation, and sets a 7-day session; the at-cap toast shows the license copy; portal vitest suite and cloudcp/auth/account/installtests Go suites pass.
352 lines
8.6 KiB
Go
352 lines
8.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGenerateToken_Format(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
token, err := svc.GenerateToken("alice@example.com", "t-abc123")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken: %v", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(token, "ml1_") {
|
|
t.Errorf("token should have ml1_ prefix, got %q", token)
|
|
}
|
|
if len(token) < 10 {
|
|
t.Errorf("token too short: %q", token)
|
|
}
|
|
}
|
|
|
|
func TestGenerateToken_Uniqueness(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
t1, _ := svc.GenerateToken("alice@example.com", "t-abc123")
|
|
t2, _ := svc.GenerateToken("alice@example.com", "t-abc123")
|
|
if t1 == t2 {
|
|
t.Error("two tokens should be unique")
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_Valid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
token, err := svc.GenerateToken("bob@example.com", "t-xyz")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken: %v", err)
|
|
}
|
|
|
|
result, err := svc.ValidateToken(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken: %v", err)
|
|
}
|
|
if result.Email != "bob@example.com" {
|
|
t.Errorf("email = %q, want bob@example.com", result.Email)
|
|
}
|
|
if result.TenantID != "t-xyz" {
|
|
t.Errorf("tenantID = %q, want t-xyz", result.TenantID)
|
|
}
|
|
if result.Target != MagicLinkTargetTenant {
|
|
t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetTenant)
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_PortalTargetAllowsEmptyTenant(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
token, err := svc.GeneratePortalToken("buyer@example.com", "")
|
|
if err != nil {
|
|
t.Fatalf("GeneratePortalToken: %v", err)
|
|
}
|
|
|
|
result, err := svc.ValidateToken(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken: %v", err)
|
|
}
|
|
if result.Email != "buyer@example.com" {
|
|
t.Errorf("email = %q, want buyer@example.com", result.Email)
|
|
}
|
|
if result.TenantID != "" {
|
|
t.Errorf("tenantID = %q, want empty", result.TenantID)
|
|
}
|
|
if result.Target != MagicLinkTargetPortal {
|
|
t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetPortal)
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_AlreadyUsed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
token, _ := svc.GenerateToken("carol@example.com", "t-111")
|
|
|
|
// First use succeeds.
|
|
_, err = svc.ValidateToken(token)
|
|
if err != nil {
|
|
t.Fatalf("first ValidateToken: %v", err)
|
|
}
|
|
|
|
// Second use fails.
|
|
_, err = svc.ValidateToken(token)
|
|
if err != ErrTokenUsed {
|
|
t.Fatalf("expected ErrTokenUsed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_Expired(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
// Override TTL to make tokens expire immediately.
|
|
svc.ttl = -1 * time.Second
|
|
|
|
token, _ := svc.GenerateToken("dave@example.com", "t-222")
|
|
|
|
_, err = svc.ValidateToken(token)
|
|
if err != ErrTokenExpired {
|
|
t.Fatalf("expected ErrTokenExpired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_Invalid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
_, err = svc.ValidateToken("ml1_totally-bogus-token")
|
|
if err != ErrTokenInvalid {
|
|
t.Fatalf("expected ErrTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildVerifyURL(t *testing.T) {
|
|
tests := []struct {
|
|
baseURL string
|
|
token string
|
|
want string
|
|
}{
|
|
{
|
|
baseURL: "https://cloud.pulserelay.pro",
|
|
token: "ml1_abc123",
|
|
want: "https://cloud.pulserelay.pro/auth/magic-link/verify?token=ml1_abc123",
|
|
},
|
|
{
|
|
baseURL: "https://cloud.pulserelay.pro/",
|
|
token: "ml1_def456",
|
|
want: "https://cloud.pulserelay.pro/auth/magic-link/verify?token=ml1_def456",
|
|
},
|
|
{
|
|
baseURL: "",
|
|
token: "ml1_test",
|
|
want: "",
|
|
},
|
|
{
|
|
baseURL: "https://cloud.pulserelay.pro",
|
|
token: "",
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
got := BuildVerifyURL(tt.baseURL, tt.token)
|
|
if got != tt.want {
|
|
t.Errorf("BuildVerifyURL(%q, %q) = %q, want %q", tt.baseURL, tt.token, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyPersistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
svc1, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService (first): %v", err)
|
|
}
|
|
|
|
// Verify key file was created.
|
|
keyPath := filepath.Join(dir, hmacKeyFile)
|
|
if _, err := os.Stat(keyPath); err != nil {
|
|
t.Fatalf("key file not created: %v", err)
|
|
}
|
|
|
|
// Generate a token with the first service.
|
|
token, _ := svc1.GenerateToken("eve@example.com", "t-333")
|
|
svc1.Close()
|
|
|
|
// Open a second service — it should load the same key and be able to validate the token.
|
|
svc2, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService (second): %v", err)
|
|
}
|
|
defer svc2.Close()
|
|
|
|
result, err := svc2.ValidateToken(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken across services: %v", err)
|
|
}
|
|
if result.Email != "eve@example.com" {
|
|
t.Errorf("email = %q, want eve@example.com", result.Email)
|
|
}
|
|
}
|
|
|
|
func TestKeyPersistence_SecuresKeyFilePermissions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
svc.Close()
|
|
|
|
info, err := os.Stat(filepath.Join(dir, hmacKeyFile))
|
|
if err != nil {
|
|
t.Fatalf("stat key file: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o600 {
|
|
t.Fatalf("key file perms = %o, want %o", got, 0o600)
|
|
}
|
|
}
|
|
|
|
func TestNewService_RejectsShortExistingKeyFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, hmacKeyFile), make([]byte, hmacKeySize-1), 0o600); err != nil {
|
|
t.Fatalf("write short key file: %v", err)
|
|
}
|
|
|
|
_, err := NewService(dir)
|
|
if err == nil || !strings.Contains(err.Error(), "expected 32 bytes") {
|
|
t.Fatalf("expected invalid key length error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewService_RejectsSymlinkedKeyFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
realKeyPath := filepath.Join(dir, "real.key")
|
|
if err := os.WriteFile(realKeyPath, make([]byte, hmacKeySize), 0o600); err != nil {
|
|
t.Fatalf("write real key: %v", err)
|
|
}
|
|
|
|
keyPath := filepath.Join(dir, hmacKeyFile)
|
|
if err := os.Symlink(realKeyPath, keyPath); err != nil {
|
|
t.Skipf("symlink not supported on this platform: %v", err)
|
|
}
|
|
|
|
_, err := NewService(dir)
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe key file") {
|
|
t.Fatalf("expected unsafe key file error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewService_SecuresDataDirPermissions(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "cp-data")
|
|
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
svc.Close()
|
|
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatalf("stat data dir: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != privateDirPerm {
|
|
t.Fatalf("data dir perms = %o, want %o", got, privateDirPerm)
|
|
}
|
|
}
|
|
|
|
func TestNewService_HardensExistingPermissiveDataDir(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "cp-data")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("create data dir: %v", err)
|
|
}
|
|
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
svc.Close()
|
|
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatalf("stat data dir: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != privateDirPerm {
|
|
t.Fatalf("data dir perms = %o, want %o", got, privateDirPerm)
|
|
}
|
|
}
|
|
|
|
func TestSessionTTLOrDefault(t *testing.T) {
|
|
dir := t.TempDir()
|
|
svc, err := NewService(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
defer svc.Close()
|
|
|
|
if got := svc.SessionTTLOrDefault(); got != SessionTTL {
|
|
t.Fatalf("SessionTTLOrDefault = %v, want package default %v", got, SessionTTL)
|
|
}
|
|
|
|
svc.SetSessionTTL(7 * 24 * time.Hour)
|
|
if got := svc.SessionTTLOrDefault(); got != 7*24*time.Hour {
|
|
t.Fatalf("SessionTTLOrDefault = %v, want 168h after SetSessionTTL", got)
|
|
}
|
|
|
|
// Non-positive overrides are ignored so a misconfigured caller cannot
|
|
// issue never-expiring or instantly-expired sessions.
|
|
svc.SetSessionTTL(0)
|
|
if got := svc.SessionTTLOrDefault(); got != 7*24*time.Hour {
|
|
t.Fatalf("SessionTTLOrDefault = %v, want 168h after ignored zero override", got)
|
|
}
|
|
|
|
// The issued session token must honor the configured TTL.
|
|
token, err := svc.GenerateSessionToken("u_test", "owner@example.com", svc.SessionTTLOrDefault())
|
|
if err != nil {
|
|
t.Fatalf("GenerateSessionToken: %v", err)
|
|
}
|
|
claims, err := svc.ValidateSessionToken(token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateSessionToken: %v", err)
|
|
}
|
|
lifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
|
|
if lifetime != 7*24*time.Hour {
|
|
t.Fatalf("session lifetime = %v, want 168h", lifetime)
|
|
}
|
|
}
|