Pulse/internal/cloudcp/auth/magiclink_test.go
rcourtman 0e40ec07cb Make the provider MSP portal honest and self-sufficient without an email provider
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.
2026-07-10 11:22:57 +01:00

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)
}
}