Files
Pulse/pkg/cloudauth/handoff_test.go
rcourtman 463e4eff50 feat(cloud): implement signup + magic link flow (C-6)
Complete the post-checkout signup flow: Stripe checkout → container
starts → magic link generated → user clicks → logged into tenant
dashboard.

- Add pkg/cloudauth for shared HMAC-SHA256 handoff token sign/verify
- Add internal/cloudcp/auth for control plane magic link service with
  SQLite-backed token store (standalone, no internal/api dependency)
- Add magic link verify handler on control plane that generates a
  short-lived handoff token and redirects to tenant container
- Add /auth/cloud-handoff endpoint on tenant side that validates
  handoff token and creates a session using existing auth machinery
- Expand provisioner to write per-tenant handoff key, poll container
  health (2s interval, 60s timeout), and generate magic link on success
- Wire magic link service into control plane server and routes
2026-02-10 21:54:23 +00:00

132 lines
3.2 KiB
Go

package cloudauth
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func TestGenerateHandoffKey(t *testing.T) {
key, err := GenerateHandoffKey()
if err != nil {
t.Fatalf("GenerateHandoffKey: %v", err)
}
if len(key) != 32 {
t.Fatalf("expected 32 bytes, got %d", len(key))
}
// Ensure two keys are different (probabilistic but extremely reliable).
key2, _ := GenerateHandoffKey()
if string(key) == string(key2) {
t.Fatal("two generated keys are identical")
}
}
func TestSignVerifyRoundTrip(t *testing.T) {
key, _ := GenerateHandoffKey()
token, err := Sign(key, "alice@example.com", "t-abc123", 60*time.Second)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if token == "" {
t.Fatal("Sign returned empty token")
}
email, tenantID, err := Verify(key, token)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if email != "alice@example.com" {
t.Errorf("email = %q, want alice@example.com", email)
}
if tenantID != "t-abc123" {
t.Errorf("tenantID = %q, want t-abc123", tenantID)
}
}
func TestVerifyExpiredToken(t *testing.T) {
key, _ := GenerateHandoffKey()
// Sign with a TTL of -1 second so the token is already expired.
token, err := Sign(key, "bob@example.com", "t-xyz", -1*time.Second)
if err != nil {
t.Fatalf("Sign: %v", err)
}
_, _, err = Verify(key, token)
if err != ErrHandoffExpired {
t.Fatalf("expected ErrHandoffExpired, got %v", err)
}
}
func TestVerifyWrongKey(t *testing.T) {
key1, _ := GenerateHandoffKey()
key2, _ := GenerateHandoffKey()
token, _ := Sign(key1, "carol@example.com", "t-111", 60*time.Second)
_, _, err := Verify(key2, token)
if err != ErrHandoffInvalid {
t.Fatalf("expected ErrHandoffInvalid, got %v", err)
}
}
func TestVerifyTamperedPayload(t *testing.T) {
key, _ := GenerateHandoffKey()
token, _ := Sign(key, "dave@example.com", "t-222", 60*time.Second)
// Tamper with the payload portion (before the dot).
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
t.Fatal("token missing dot separator")
}
// Decode payload, modify, re-encode.
payloadBytes, _ := base64.RawURLEncoding.DecodeString(parts[0])
tampered := make([]byte, len(payloadBytes))
copy(tampered, payloadBytes)
// Flip a byte.
if len(tampered) > 5 {
tampered[5] ^= 0xFF
}
parts[0] = base64.RawURLEncoding.EncodeToString(tampered)
tamperedToken := parts[0] + "." + parts[1]
_, _, err := Verify(key, tamperedToken)
if err != ErrHandoffInvalid {
t.Fatalf("expected ErrHandoffInvalid, got %v", err)
}
}
func TestVerifyEmptyInputs(t *testing.T) {
key, _ := GenerateHandoffKey()
_, _, err := Verify(nil, "some-token")
if err != ErrHandoffInvalid {
t.Errorf("nil key: expected ErrHandoffInvalid, got %v", err)
}
_, _, err = Verify(key, "")
if err != ErrHandoffInvalid {
t.Errorf("empty token: expected ErrHandoffInvalid, got %v", err)
}
}
func TestSignValidation(t *testing.T) {
key, _ := GenerateHandoffKey()
_, err := Sign(nil, "a@b.com", "t-1", time.Minute)
if err == nil {
t.Error("Sign with nil key should fail")
}
_, err = Sign(key, "", "t-1", time.Minute)
if err == nil {
t.Error("Sign with empty email should fail")
}
_, err = Sign(key, "a@b.com", "", time.Minute)
if err == nil {
t.Error("Sign with empty tenantID should fail")
}
}