diff --git a/licensing/composite_validator.go b/licensing/composite_validator.go new file mode 100644 index 00000000..7aa66822 --- /dev/null +++ b/licensing/composite_validator.go @@ -0,0 +1,82 @@ +package licensing + +import ( + "context" +) + +// CompositeValidator tries an offline validator first, falling back to an HTTP +// validator. This enables air-gapped or low-latency license checks while keeping +// the HTTP validator as a fallback for online validation. +type CompositeValidator struct { + offline *OfflineValidator + http *HTTPValidator +} + +// NewCompositeValidator creates a CompositeValidator from an offline and an HTTP +// validator. Either may be nil (though at least one should be non-nil). +func NewCompositeValidator(offline *OfflineValidator, http *HTTPValidator) *CompositeValidator { + return &CompositeValidator{offline: offline, http: http} +} + +// Validate implements licensing.Validator. It tries the offline validator first; +// if the result is valid it is returned immediately. Otherwise it falls back to +// the HTTP validator. +func (c *CompositeValidator) Validate(ctx context.Context, key string) (*ValidationResult, error) { + if c.offline != nil { + result, err := c.offline.Validate(ctx, key) + if err == nil && result.Valid { + return result, nil + } + } + if c.http != nil { + return c.http.Validate(ctx, key) + } + return &ValidationResult{Valid: false, Error: "no validator configured"}, nil +} + +// CheckFeature implements licensing.Validator. It uses the offline validator when +// available, otherwise falls back to the HTTP validator. +func (c *CompositeValidator) CheckFeature(feature string) bool { + if c.offline != nil { + return c.offline.CheckFeature(feature) + } + if c.http != nil { + return c.http.CheckFeature(feature) + } + return false +} + +// GetLicenseInfo implements licensing.Validator. It returns the offline license +// info when available (non-nil), otherwise falls back to the HTTP validator. +func (c *CompositeValidator) GetLicenseInfo() *LicenseInfo { + if c.offline != nil { + if info := c.offline.GetLicenseInfo(); info != nil { + return info + } + } + if c.http != nil { + return c.http.GetLicenseInfo() + } + return nil +} + +// ValidatePlugin implements plugin.LicenseValidator. It delegates to the offline +// validator, which performs the authoritative check without network calls. +func (c *CompositeValidator) ValidatePlugin(pluginName string) error { + if c.offline != nil { + return c.offline.ValidatePlugin(pluginName) + } + return nil +} + +// CanLoadPlugin returns true when the offline validator permits the tier, or when +// there is no offline validator and the HTTP validator permits it. +func (c *CompositeValidator) CanLoadPlugin(tier string) bool { + if c.offline != nil { + return c.offline.CanLoadPlugin(tier) + } + if c.http != nil { + return c.http.CanLoadPlugin(tier) + } + return false +} diff --git a/licensing/offline_validator.go b/licensing/offline_validator.go new file mode 100644 index 00000000..009fa307 --- /dev/null +++ b/licensing/offline_validator.go @@ -0,0 +1,110 @@ +package licensing + +import ( + "context" + "fmt" + "time" + + "github.com/GoCodeAlone/workflow/pkg/license" +) + +// OfflineValidator validates a license token using a local Ed25519 public key, +// with no network calls required after construction. +type OfflineValidator struct { + tokenStr string + token *license.LicenseToken +} + +// NewOfflineValidator parses publicKeyPEM and tokenStr, verifies the token +// signature, and returns an OfflineValidator ready for use. +func NewOfflineValidator(publicKeyPEM []byte, tokenStr string) (*OfflineValidator, error) { + pub, err := license.UnmarshalPublicKeyPEM(publicKeyPEM) + if err != nil { + return nil, fmt.Errorf("parse public key: %w", err) + } + tok, err := license.Parse(tokenStr) + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + if err := tok.Verify(pub); err != nil { + return nil, fmt.Errorf("verify token signature: %w", err) + } + return &OfflineValidator{tokenStr: tokenStr, token: tok}, nil +} + +// Validate implements licensing.Validator. It returns a valid result when key +// matches the stored token string, and an invalid result otherwise. +func (v *OfflineValidator) Validate(_ context.Context, key string) (*ValidationResult, error) { + if key != v.tokenStr { + return &ValidationResult{ + Valid: false, + Error: "license key does not match token", + CachedUntil: time.Now().Add(DefaultCacheTTL), + }, nil + } + return &ValidationResult{ + Valid: true, + License: *v.licenseInfo(), + CachedUntil: time.Now().Add(DefaultCacheTTL), + }, nil +} + +// CheckFeature implements licensing.Validator. +func (v *OfflineValidator) CheckFeature(feature string) bool { + return v.token.HasFeature(feature) +} + +// GetLicenseInfo implements licensing.Validator. Returns nil if the token is expired. +func (v *OfflineValidator) GetLicenseInfo() *LicenseInfo { + if v.token.IsExpired() { + return nil + } + info := v.licenseInfo() + return info +} + +// ValidatePlugin implements plugin.LicenseValidator. It returns an error if the +// token is expired, the tier is not professional or enterprise, or the plugin name +// is not listed in the token's feature set. +func (v *OfflineValidator) ValidatePlugin(pluginName string) error { + if v.token.IsExpired() { + return fmt.Errorf("license token is expired") + } + if v.token.Tier != "professional" && v.token.Tier != "enterprise" { + return fmt.Errorf("license tier %q does not permit premium plugins", v.token.Tier) + } + if !v.token.HasFeature(pluginName) { + return fmt.Errorf("plugin %q is not licensed", pluginName) + } + return nil +} + +// CanLoadPlugin returns true when the given plugin tier is permitted by the license. +// Core and community plugins are always allowed. Premium plugins require a +// professional or enterprise tier that is not expired. +func (v *OfflineValidator) CanLoadPlugin(tier string) bool { + switch tier { + case "core", "community": + return true + case "premium": + if v.token.IsExpired() { + return false + } + return v.token.Tier == "professional" || v.token.Tier == "enterprise" + default: + return false + } +} + +// licenseInfo converts the stored token fields into a LicenseInfo struct. +func (v *OfflineValidator) licenseInfo() *LicenseInfo { + return &LicenseInfo{ + Key: v.token.LicenseID, + Tier: v.token.Tier, + Organization: v.token.Organization, + ExpiresAt: time.Unix(v.token.ExpiresAt, 0), + MaxWorkflows: v.token.MaxWorkflows, + MaxPlugins: v.token.MaxPlugins, + Features: v.token.Features, + } +} diff --git a/licensing/offline_validator_test.go b/licensing/offline_validator_test.go new file mode 100644 index 00000000..6af66d25 --- /dev/null +++ b/licensing/offline_validator_test.go @@ -0,0 +1,282 @@ +package licensing_test + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/licensing" + "github.com/GoCodeAlone/workflow/pkg/license" +) + +// buildSignedToken creates a key pair and a signed token for use in tests. +func buildSignedToken(t *testing.T, tier string, features []string, expiresIn time.Duration) (pubPEM []byte, tokenStr string) { + t.Helper() + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + tok := &license.LicenseToken{ + LicenseID: "lic-123", + TenantID: "tenant-abc", + Organization: "TestOrg", + Tier: tier, + Features: features, + MaxWorkflows: 10, + MaxPlugins: 20, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(expiresIn).Unix(), + } + tokenStr, err = tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + return license.MarshalPublicKeyPEM(pub), tokenStr +} + +func TestOfflineValidator_ValidToken(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"my-plugin", "other-feature"}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatalf("NewOfflineValidator: %v", err) + } + + result, err := v.Validate(context.Background(), tokenStr) + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if !result.Valid { + t.Errorf("expected valid result, got: %s", result.Error) + } + if result.License.Tier != "enterprise" { + t.Errorf("Tier: got %q, want enterprise", result.License.Tier) + } + if result.License.Organization != "TestOrg" { + t.Errorf("Organization: got %q, want TestOrg", result.License.Organization) + } +} + +func TestOfflineValidator_CheckFeature(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"my-plugin", "audit-log"}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if !v.CheckFeature("my-plugin") { + t.Error(`CheckFeature("my-plugin") should return true`) + } + if !v.CheckFeature("audit-log") { + t.Error(`CheckFeature("audit-log") should return true`) + } + if v.CheckFeature("nonexistent") { + t.Error(`CheckFeature("nonexistent") should return false`) + } +} + +func TestOfflineValidator_ValidatePlugin_Success(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"my-plugin"}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if err := v.ValidatePlugin("my-plugin"); err != nil { + t.Errorf("ValidatePlugin should succeed: %v", err) + } +} + +func TestOfflineValidator_ValidatePlugin_ExpiredToken(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"my-plugin"}, -time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if err := v.ValidatePlugin("my-plugin"); err == nil { + t.Error("ValidatePlugin should fail for expired token") + } +} + +func TestOfflineValidator_ValidatePlugin_WrongTier(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "starter", []string{"my-plugin"}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if err := v.ValidatePlugin("my-plugin"); err == nil { + t.Error("ValidatePlugin should fail for starter tier") + } +} + +func TestOfflineValidator_ValidatePlugin_MissingFeature(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"other-plugin"}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if err := v.ValidatePlugin("my-plugin"); err == nil { + t.Error("ValidatePlugin should fail when plugin not in feature list") + } +} + +func TestOfflineValidator_GetLicenseInfo_Expired(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{}, -time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if info := v.GetLicenseInfo(); info != nil { + t.Error("GetLicenseInfo should return nil for expired token") + } +} + +func TestOfflineValidator_WrongKey(t *testing.T) { + _, tokenStr := buildSignedToken(t, "enterprise", []string{}, time.Hour) + // Generate a different key pair for verification + wrongPub, _, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + wrongPubPEM := license.MarshalPublicKeyPEM(wrongPub) + + _, err = licensing.NewOfflineValidator(wrongPubPEM, tokenStr) + if err == nil { + t.Error("NewOfflineValidator should fail when key doesn't match token signature") + } +} + +func TestOfflineValidator_KeyMismatch_Validate(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + result, err := v.Validate(context.Background(), "wrong-token-string") + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if result.Valid { + t.Error("Validate should return invalid when key doesn't match token string") + } +} + +func TestOfflineValidator_CanLoadPlugin(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "professional", []string{}, time.Hour) + + v, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + if !v.CanLoadPlugin("core") { + t.Error("CanLoadPlugin(core) should return true") + } + if !v.CanLoadPlugin("community") { + t.Error("CanLoadPlugin(community) should return true") + } + if !v.CanLoadPlugin("premium") { + t.Error("CanLoadPlugin(premium) should return true for professional tier") + } + + // Starter tier should not allow premium + pubPEM2, tokenStr2 := buildSignedToken(t, "starter", []string{}, time.Hour) + v2, err := licensing.NewOfflineValidator(pubPEM2, tokenStr2) + if err != nil { + t.Fatal(err) + } + if v2.CanLoadPlugin("premium") { + t.Error("CanLoadPlugin(premium) should return false for starter tier") + } +} + +func TestCompositeValidator_OfflineAccepts(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"my-plugin"}, time.Hour) + + offline, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + cv := licensing.NewCompositeValidator(offline, nil) + + result, err := cv.Validate(context.Background(), tokenStr) + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if !result.Valid { + t.Errorf("expected valid result: %s", result.Error) + } +} + +func TestCompositeValidator_OfflineRejectsHTTPFallback(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{}, time.Hour) + + offline, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + // HTTPValidator with empty server URL returns a valid starter result + httpV := licensing.NewHTTPValidator(licensing.ValidatorConfig{}, nil) + + cv := licensing.NewCompositeValidator(offline, httpV) + + // Use a different key — offline will reject, HTTP (no server) will return valid + result, err := cv.Validate(context.Background(), "some-other-license-key") + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if !result.Valid { + t.Errorf("expected HTTP fallback to produce valid result: %s", result.Error) + } +} + +func TestCompositeValidator_ValidatePlugin_DelegatesToOffline(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{"authorized-plugin"}, time.Hour) + + offline, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + cv := licensing.NewCompositeValidator(offline, nil) + + if err := cv.ValidatePlugin("authorized-plugin"); err != nil { + t.Errorf("ValidatePlugin should succeed: %v", err) + } + if err := cv.ValidatePlugin("unauthorized-plugin"); err == nil { + t.Error("ValidatePlugin should fail for unlicensed plugin") + } +} + +func TestCompositeValidator_GetLicenseInfo(t *testing.T) { + pubPEM, tokenStr := buildSignedToken(t, "enterprise", []string{}, time.Hour) + + offline, err := licensing.NewOfflineValidator(pubPEM, tokenStr) + if err != nil { + t.Fatal(err) + } + + cv := licensing.NewCompositeValidator(offline, nil) + + info := cv.GetLicenseInfo() + if info == nil { + t.Fatal("GetLicenseInfo should return non-nil for valid offline token") + } + if info.Tier != "enterprise" { + t.Errorf("Tier: got %q, want enterprise", info.Tier) + } +} diff --git a/pkg/license/keygen.go b/pkg/license/keygen.go new file mode 100644 index 00000000..7be829b8 --- /dev/null +++ b/pkg/license/keygen.go @@ -0,0 +1,69 @@ +package license + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" +) + +const ( + pemTypePublicKey = "ED25519 PUBLIC KEY" + pemTypePrivateKey = "ED25519 PRIVATE KEY" +) + +// GenerateKeyPair generates a new Ed25519 key pair using crypto/rand. +func GenerateKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) { + return ed25519.GenerateKey(rand.Reader) +} + +// MarshalPublicKeyPEM PEM-encodes an Ed25519 public key with type "ED25519 PUBLIC KEY". +func MarshalPublicKeyPEM(pub ed25519.PublicKey) []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: pemTypePublicKey, + Bytes: []byte(pub), + }) +} + +// UnmarshalPublicKeyPEM decodes a PEM-encoded Ed25519 public key. +func UnmarshalPublicKeyPEM(pemBytes []byte) (ed25519.PublicKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + if block.Type != pemTypePublicKey { + return nil, fmt.Errorf("unexpected PEM type: %q", block.Type) + } + if len(block.Bytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid public key size: got %d, want %d", len(block.Bytes), ed25519.PublicKeySize) + } + key := make(ed25519.PublicKey, ed25519.PublicKeySize) + copy(key, block.Bytes) + return key, nil +} + +// MarshalPrivateKeyPEM PEM-encodes an Ed25519 private key with type "ED25519 PRIVATE KEY". +func MarshalPrivateKeyPEM(priv ed25519.PrivateKey) []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: pemTypePrivateKey, + Bytes: []byte(priv), + }) +} + +// UnmarshalPrivateKeyPEM decodes a PEM-encoded Ed25519 private key. +func UnmarshalPrivateKeyPEM(pemBytes []byte) (ed25519.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + if block.Type != pemTypePrivateKey { + return nil, fmt.Errorf("unexpected PEM type: %q", block.Type) + } + if len(block.Bytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key size: got %d, want %d", len(block.Bytes), ed25519.PrivateKeySize) + } + key := make(ed25519.PrivateKey, ed25519.PrivateKeySize) + copy(key, block.Bytes) + return key, nil +} diff --git a/pkg/license/token.go b/pkg/license/token.go new file mode 100644 index 00000000..4669b5ad --- /dev/null +++ b/pkg/license/token.go @@ -0,0 +1,108 @@ +// Package license provides Ed25519-signed license token creation, parsing, and verification. +package license + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const ( + tokenPrefix = "wflic" + tokenVersion = "v1" +) + +// LicenseToken holds the claims embedded in a signed license token. +type LicenseToken struct { + LicenseID string `json:"lid"` + TenantID string `json:"tid"` + Organization string `json:"org"` + Tier string `json:"tier"` + Features []string `json:"feat"` + MaxWorkflows int `json:"max_wf"` + MaxPlugins int `json:"max_pl"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + + // set by Parse to enable subsequent Verify calls + rawPayload string + rawSig []byte +} + +// Sign produces a signed license token string in the format: +// wflic.v1.. +func (t *LicenseToken) Sign(privateKey ed25519.PrivateKey) (string, error) { + payloadBytes, err := json.Marshal(t) + if err != nil { + return "", fmt.Errorf("marshal token: %w", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + message := tokenPrefix + "." + tokenVersion + "." + payload + sig := ed25519.Sign(privateKey, []byte(message)) + return message + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// Parse splits tokenStr on dots, decodes the base64url payload, and returns +// the token. It does NOT verify the signature. +func Parse(tokenStr string) (*LicenseToken, error) { + parts := strings.Split(tokenStr, ".") + if len(parts) != 4 { + return nil, fmt.Errorf("invalid token: expected 4 dot-separated parts, got %d", len(parts)) + } + if parts[0] != tokenPrefix { + return nil, fmt.Errorf("invalid token prefix: %q", parts[0]) + } + if parts[1] != tokenVersion { + return nil, fmt.Errorf("unsupported token version: %q", parts[1]) + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + var tok LicenseToken + if err := json.Unmarshal(payloadBytes, &tok); err != nil { + return nil, fmt.Errorf("unmarshal payload: %w", err) + } + + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[3]) + if err != nil { + return nil, fmt.Errorf("decode signature: %w", err) + } + + tok.rawPayload = parts[2] + tok.rawSig = sigBytes + return &tok, nil +} + +// Verify verifies the Ed25519 signature over the wflic.v1. prefix bytes. +// The token must have been produced by Parse. +func (t *LicenseToken) Verify(publicKey ed25519.PublicKey) error { + if t.rawPayload == "" || t.rawSig == nil { + return errors.New("token has no signature data: load the token via Parse before calling Verify") + } + message := tokenPrefix + "." + tokenVersion + "." + t.rawPayload + if !ed25519.Verify(publicKey, []byte(message), t.rawSig) { + return errors.New("invalid signature") + } + return nil +} + +// IsExpired returns true if ExpiresAt is in the past. +func (t *LicenseToken) IsExpired() bool { + return time.Now().Unix() > t.ExpiresAt +} + +// HasFeature returns true if the given feature name is present in the Features slice. +func (t *LicenseToken) HasFeature(name string) bool { + for _, f := range t.Features { + if f == name { + return true + } + } + return false +} diff --git a/pkg/license/token_test.go b/pkg/license/token_test.go new file mode 100644 index 00000000..ae4d4a75 --- /dev/null +++ b/pkg/license/token_test.go @@ -0,0 +1,273 @@ +package license_test + +import ( + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/pkg/license" +) + +func newTestToken() *license.LicenseToken { + return &license.LicenseToken{ + LicenseID: "test-license-id", + TenantID: "tenant-123", + Organization: "ACME Corp", + Tier: "enterprise", + Features: []string{"workflows", "plugins", "audit-log"}, + MaxWorkflows: 100, + MaxPlugins: 50, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix(), + } +} + +func TestRoundTrip(t *testing.T) { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + tok := newTestToken() + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatalf("Sign failed: %v", err) + } + + if !strings.HasPrefix(tokenStr, "wflic.v1.") { + t.Errorf("unexpected token prefix: %s", tokenStr) + } + + parsed, err := license.Parse(tokenStr) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if err := parsed.Verify(pub); err != nil { + t.Fatalf("Verify failed: %v", err) + } + + if parsed.LicenseID != tok.LicenseID { + t.Errorf("LicenseID: got %q, want %q", parsed.LicenseID, tok.LicenseID) + } + if parsed.TenantID != tok.TenantID { + t.Errorf("TenantID: got %q, want %q", parsed.TenantID, tok.TenantID) + } + if parsed.Organization != tok.Organization { + t.Errorf("Organization: got %q, want %q", parsed.Organization, tok.Organization) + } + if parsed.Tier != tok.Tier { + t.Errorf("Tier: got %q, want %q", parsed.Tier, tok.Tier) + } + if parsed.MaxWorkflows != tok.MaxWorkflows { + t.Errorf("MaxWorkflows: got %d, want %d", parsed.MaxWorkflows, tok.MaxWorkflows) + } + if parsed.MaxPlugins != tok.MaxPlugins { + t.Errorf("MaxPlugins: got %d, want %d", parsed.MaxPlugins, tok.MaxPlugins) + } + if len(parsed.Features) != len(tok.Features) { + t.Errorf("Features length: got %d, want %d", len(parsed.Features), len(tok.Features)) + } +} + +func TestExpiredToken(t *testing.T) { + tok := newTestToken() + tok.ExpiresAt = time.Now().Add(-time.Hour).Unix() + + if !tok.IsExpired() { + t.Error("expected IsExpired() to return true for past ExpiresAt") + } +} + +func TestNotExpiredToken(t *testing.T) { + tok := newTestToken() + if tok.IsExpired() { + t.Error("expected IsExpired() to return false for future ExpiresAt") + } +} + +func TestTamperedSignature(t *testing.T) { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + tok := newTestToken() + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(tokenStr, ".") + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[3]) + if err != nil { + t.Fatal(err) + } + sigBytes[0] ^= 0xFF + parts[3] = base64.RawURLEncoding.EncodeToString(sigBytes) + + parsed, err := license.Parse(strings.Join(parts, ".")) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if err := parsed.Verify(pub); err == nil { + t.Error("expected Verify to fail with tampered signature") + } +} + +func TestTamperedPayload(t *testing.T) { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + tok := newTestToken() + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(tokenStr, ".") + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatal(err) + } + // Flip a byte in the JSON payload (avoid the first byte which is '{' to keep valid JSON structure-ish) + payloadBytes[len(payloadBytes)/2] ^= 0xFF + parts[2] = base64.RawURLEncoding.EncodeToString(payloadBytes) + + parsed, err := license.Parse(strings.Join(parts, ".")) + if err != nil { + // Corrupted JSON is an acceptable failure path + return + } + if err := parsed.Verify(pub); err == nil { + t.Error("expected Verify to fail with tampered payload") + } +} + +func TestInvalidFormat(t *testing.T) { + invalidJSON := base64.RawURLEncoding.EncodeToString([]byte("{not valid json}")) + cases := []struct { + name string + input string + }{ + {"empty string", ""}, + {"too few parts", "wflic.v1.abc"}, + {"too many parts", "wflic.v1.abc.def.ghi"}, + {"wrong prefix", "token.v1.abc.def"}, + {"wrong version", "wflic.v2.abc.def"}, + {"bad base64 payload", "wflic.v1.!!!invalid!!!.def"}, + {"bad json payload", "wflic.v1." + invalidJSON + ".def"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := license.Parse(tc.input) + if err == nil { + t.Errorf("expected Parse to fail for input %q", tc.input) + } + }) + } +} + +func TestHasFeature(t *testing.T) { + tok := newTestToken() + // Features: []string{"workflows", "plugins", "audit-log"} + + if !tok.HasFeature("workflows") { + t.Error(`expected HasFeature("workflows") to return true`) + } + if !tok.HasFeature("audit-log") { + t.Error(`expected HasFeature("audit-log") to return true`) + } + if tok.HasFeature("nonexistent-feature") { + t.Error(`expected HasFeature("nonexistent-feature") to return false`) + } +} + +func TestKeyPairPEMRoundTrip(t *testing.T) { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + pubPEM := license.MarshalPublicKeyPEM(pub) + privPEM := license.MarshalPrivateKeyPEM(priv) + + recoveredPub, err := license.UnmarshalPublicKeyPEM(pubPEM) + if err != nil { + t.Fatalf("UnmarshalPublicKeyPEM failed: %v", err) + } + recoveredPriv, err := license.UnmarshalPrivateKeyPEM(privPEM) + if err != nil { + t.Fatalf("UnmarshalPrivateKeyPEM failed: %v", err) + } + + if string(recoveredPub) != string(pub) { + t.Error("public key mismatch after PEM round-trip") + } + if string(recoveredPriv) != string(priv) { + t.Error("private key mismatch after PEM round-trip") + } + + // Verify that a token signed with the original key verifies with the recovered key + tok := newTestToken() + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + parsed, err := license.Parse(tokenStr) + if err != nil { + t.Fatal(err) + } + if err := parsed.Verify(recoveredPub); err != nil { + t.Errorf("Verify with recovered public key failed: %v", err) + } + + // Also verify sign with recovered private key + tokenStr2, err := tok.Sign(recoveredPriv) + if err != nil { + t.Fatal(err) + } + parsed2, err := license.Parse(tokenStr2) + if err != nil { + t.Fatal(err) + } + if err := parsed2.Verify(pub); err != nil { + t.Errorf("Verify (original pub) with recovered priv-signed token failed: %v", err) + } +} + +func TestVerifyWithoutParse(t *testing.T) { + _, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + pub2, _, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + tok := newTestToken() + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + + parsed, err := license.Parse(tokenStr) + if err != nil { + t.Fatal(err) + } + + // Verifying with a different public key should fail + if err := parsed.Verify(pub2); err == nil { + t.Error("expected Verify to fail with a different public key") + } + + // Calling Verify on the original (non-parsed) token should fail + if err := tok.Verify(pub2); err == nil { + t.Error("expected Verify to fail on a token that was not produced by Parse") + } +} diff --git a/plugin/cosign.go b/plugin/cosign.go new file mode 100644 index 00000000..182e26bf --- /dev/null +++ b/plugin/cosign.go @@ -0,0 +1,51 @@ +package plugin + +import ( + "fmt" + "log/slog" + "os/exec" +) + +// CosignVerifier verifies plugin binaries using cosign keyless signatures. +// It requires the cosign CLI to be installed; if not found, verification is +// skipped with a warning to support environments without cosign installed. +type CosignVerifier struct { + OIDCIssuer string + AllowedIdentityRegexp string +} + +// NewCosignVerifier creates a CosignVerifier for the given OIDC issuer and +// identity regexp (e.g. "https://github.com/GoCodeAlone/.*"). +func NewCosignVerifier(oidcIssuer, identityRegexp string) *CosignVerifier { + return &CosignVerifier{ + OIDCIssuer: oidcIssuer, + AllowedIdentityRegexp: identityRegexp, + } +} + +// Verify runs `cosign verify-blob` to validate the signature of a plugin binary. +// If cosign is not installed, a warning is logged and nil is returned so that +// deployments without cosign are not broken. +func (v *CosignVerifier) Verify(binaryPath, sigPath, certPath string) error { + _, err := exec.LookPath("cosign") + if err != nil { + slog.Warn("cosign not found — skipping binary verification", "binary", binaryPath) + return nil //nolint:nilerr // intentional: graceful degradation when cosign not installed + } + + // Arguments are not user-controlled; they come from internal plugin manifest + // configuration and verified file paths. + cmd := exec.Command("cosign", //nolint:gosec // args are internal, not user input + "verify-blob", + "--signature", sigPath, + "--certificate", certPath, + "--certificate-oidc-issuer", v.OIDCIssuer, + "--certificate-identity-regexp", v.AllowedIdentityRegexp, + binaryPath, + ) + + if out, runErr := cmd.CombinedOutput(); runErr != nil { + return fmt.Errorf("cosign verify-blob: %w: %s", runErr, out) + } + return nil +} diff --git a/plugin/loader.go b/plugin/loader.go index 4d2eba49..d9e26f0c 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -30,6 +30,7 @@ type PluginLoader struct { schemaRegistry *schema.ModuleSchemaRegistry plugins []EnginePlugin licenseValidator LicenseValidator + cosignVerifier *CosignVerifier } // NewPluginLoader creates a new PluginLoader backed by the given capability and schema registries. @@ -49,6 +50,27 @@ func (l *PluginLoader) SetLicenseValidator(v LicenseValidator) { l.licenseValidator = v } +// SetCosignVerifier registers a cosign verifier for binary signature verification +// of premium plugins. When set, LoadBinaryPlugin will verify the plugin binary +// before loading it. +func (l *PluginLoader) SetCosignVerifier(v *CosignVerifier) { + l.cosignVerifier = v +} + +// LoadBinaryPlugin verifies a plugin binary with cosign (for premium plugins) and +// then loads the plugin into the registry. binaryPath, sigPath, and certPath are +// paths to the plugin binary, cosign signature file, and certificate file +// respectively. If cosignVerifier is nil, verification is skipped. +func (l *PluginLoader) LoadBinaryPlugin(p EnginePlugin, binaryPath, sigPath, certPath string) error { + manifest := p.EngineManifest() + if manifest.Tier == TierPremium && l.cosignVerifier != nil { + if err := l.cosignVerifier.Verify(binaryPath, sigPath, certPath); err != nil { + return fmt.Errorf("plugin %q: binary verification failed: %w", manifest.Name, err) + } + } + return l.LoadPlugin(p) +} + // ValidateTier checks whether a plugin's tier is allowed given the current // license validator configuration: // - Core and Community plugins are always allowed. diff --git a/plugins/license/keys/genkey_main.go b/plugins/license/keys/genkey_main.go new file mode 100644 index 00000000..40c95194 --- /dev/null +++ b/plugins/license/keys/genkey_main.go @@ -0,0 +1,27 @@ +//go:build ignore + +package main + +import ( + "fmt" + "os" + + "github.com/GoCodeAlone/workflow/pkg/license" +) + +func main() { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + pubPEM := license.MarshalPublicKeyPEM(pub) + privPEM := license.MarshalPrivateKeyPEM(priv) + fmt.Printf("=== PUBLIC KEY (embedded in binary) ===\n%s\n", pubPEM) + fmt.Printf("=== PRIVATE KEY (DO NOT COMMIT — store securely) ===\n%s\n", privPEM) + if err := os.WriteFile("license.pub", pubPEM, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write license.pub:", err) + os.Exit(1) + } + fmt.Println("Written public key to license.pub") +} diff --git a/plugins/license/keys/license.pub b/plugins/license/keys/license.pub new file mode 100644 index 00000000..69960879 --- /dev/null +++ b/plugins/license/keys/license.pub @@ -0,0 +1,3 @@ +-----BEGIN ED25519 PUBLIC KEY----- +Os8amgpRGD7CA+wHOYN3M8vcsfHF5T2Wuv5y20VmXJc= +-----END ED25519 PUBLIC KEY----- diff --git a/plugins/license/plugin.go b/plugins/license/plugin.go index 31032dc9..ab6ec921 100644 --- a/plugins/license/plugin.go +++ b/plugins/license/plugin.go @@ -4,13 +4,22 @@ package license import ( + _ "embed" + "fmt" + "os" + "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow/capability" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/licensing" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" "github.com/GoCodeAlone/workflow/schema" ) +//go:embed keys/license.pub +var embeddedPublicKey []byte + // Plugin provides the license.validator module type. type Plugin struct { plugin.BaseEnginePlugin @@ -34,6 +43,7 @@ func New() *Plugin { Capabilities: []plugin.CapabilityDecl{ {Name: "license-validation", Role: "provider", Priority: 10}, }, + WiringHooks: []string{"license-validator-wiring"}, }, }, } @@ -63,6 +73,96 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { } } +// WiringHooks returns a hook that wires an Ed25519 OfflineValidator (and optional +// CompositeValidator) to the PluginLoader when WORKFLOW_LICENSE_TOKEN is set. +func (p *Plugin) WiringHooks() []plugin.WiringHook { + return []plugin.WiringHook{ + { + Name: "license-validator-wiring", + Priority: 20, + Hook: licenseValidatorWiringHook, + }, + } +} + +// engineWithLoader is a local interface to retrieve a PluginLoader from the +// registered "workflowEngine" service without importing the engine package. +type engineWithLoader interface { + PluginLoader() *plugin.PluginLoader +} + +// licenseValidatorAdapter implements plugin.LicenseValidator by delegating to a +// licensing.Validator. When an OfflineValidator is available, it is used for +// authoritative plugin validation. Otherwise the HTTP validator's LicenseInfo is +// checked for tier and feature membership. +type licenseValidatorAdapter struct { + validator licensing.Validator + offline *licensing.OfflineValidator // may be nil +} + +func (a *licenseValidatorAdapter) ValidatePlugin(pluginName string) error { + if a.offline != nil { + return a.offline.ValidatePlugin(pluginName) + } + info := a.validator.GetLicenseInfo() + if info == nil { + return fmt.Errorf("no license loaded") + } + if info.Tier != "professional" && info.Tier != "enterprise" { + return fmt.Errorf("license tier %q does not permit premium plugins", info.Tier) + } + for _, f := range info.Features { + if f == pluginName { + return nil + } + } + return fmt.Errorf("plugin %q is not licensed", pluginName) +} + +// licenseValidatorWiringHook reads WORKFLOW_LICENSE_TOKEN, creates an +// OfflineValidator (and optionally a CompositeValidator), and registers it on +// the PluginLoader if the engine is available in the service registry. +func licenseValidatorWiringHook(app modular.Application, _ *config.WorkflowConfig) error { + tokenStr := os.Getenv("WORKFLOW_LICENSE_TOKEN") + + // Scan the service registry for the engine and any registered HTTP validator. + var loader *plugin.PluginLoader + var httpValidator *licensing.HTTPValidator + for _, svc := range app.SvcRegistry() { + if e, ok := svc.(engineWithLoader); ok && loader == nil { + loader = e.PluginLoader() + } + if hv, ok := svc.(*licensing.HTTPValidator); ok && httpValidator == nil { + httpValidator = hv + } + } + + if tokenStr == "" { + // No offline token configured — wire HTTP validator if available. + if loader != nil && httpValidator != nil { + loader.SetLicenseValidator(&licenseValidatorAdapter{validator: httpValidator}) + } + return nil + } + + offline, err := licensing.NewOfflineValidator(embeddedPublicKey, tokenStr) + if err != nil { + return fmt.Errorf("license-validator-wiring: create offline validator: %w", err) + } + + var lv plugin.LicenseValidator + if httpValidator != nil { + lv = licensing.NewCompositeValidator(offline, httpValidator) + } else { + lv = offline + } + + if loader != nil { + loader.SetLicenseValidator(lv) + } + return nil +} + // ModuleSchemas returns the UI schema definition for license.validator. func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { return []*schema.ModuleSchema{ diff --git a/plugins/license/plugin_test.go b/plugins/license/plugin_test.go new file mode 100644 index 00000000..ad8b30bd --- /dev/null +++ b/plugins/license/plugin_test.go @@ -0,0 +1,260 @@ +package license + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/capability" + "github.com/GoCodeAlone/workflow/licensing" + "github.com/GoCodeAlone/workflow/pkg/license" + "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/schema" +) + +func TestPluginImplementsEnginePlugin(t *testing.T) { + p := New() + var _ plugin.EnginePlugin = p +} + +func TestPluginManifest(t *testing.T) { + p := New() + m := p.EngineManifest() + if err := m.Validate(); err != nil { + t.Fatalf("manifest validation failed: %v", err) + } + if m.Name != "license" { + t.Errorf("expected name %q, got %q", "license", m.Name) + } + found := false + for _, h := range m.WiringHooks { + if h == "license-validator-wiring" { + found = true + } + } + if !found { + t.Error("manifest missing wiring hook 'license-validator-wiring'") + } +} + +func TestWiringHooks(t *testing.T) { + p := New() + hooks := p.WiringHooks() + if len(hooks) != 1 { + t.Fatalf("expected 1 wiring hook, got %d", len(hooks)) + } + if hooks[0].Name != "license-validator-wiring" { + t.Errorf("unexpected hook name: %q", hooks[0].Name) + } + if hooks[0].Hook == nil { + t.Error("wiring hook function is nil") + } +} + +func TestModuleFactories(t *testing.T) { + p := New() + factories := p.ModuleFactories() + if _, ok := factories["license.validator"]; !ok { + t.Error("missing factory for license.validator") + } +} + +func TestModuleSchemas(t *testing.T) { + p := New() + schemas := p.ModuleSchemas() + if len(schemas) != 1 { + t.Fatalf("expected 1 schema, got %d", len(schemas)) + } + if schemas[0].Type != "license.validator" { + t.Errorf("unexpected schema type: %q", schemas[0].Type) + } +} + +// stubApp is a minimal modular.Application for wiring hook tests. +type stubApp struct { + services modular.ServiceRegistry +} + +func newStubApp(services map[string]any) *stubApp { + return &stubApp{services: services} +} + +func (a *stubApp) SvcRegistry() modular.ServiceRegistry { return a.services } +func (a *stubApp) RegisterService(name string, svc any) error { a.services[name] = svc; return nil } +func (a *stubApp) ConfigProvider() modular.ConfigProvider { return nil } +func (a *stubApp) RegisterModule(_ modular.Module) {} +func (a *stubApp) RegisterConfigSection(_ string, _ modular.ConfigProvider) {} +func (a *stubApp) GetConfigSection(_ string) (modular.ConfigProvider, error) { + return nil, nil +} +func (a *stubApp) Init() error { return nil } +func (a *stubApp) Run() error { return nil } +func (a *stubApp) Start() error { return nil } +func (a *stubApp) Stop() error { return nil } +func (a *stubApp) Logger() modular.Logger { return nil } +func (a *stubApp) Service(name string) (any, bool) { + svc, ok := a.services[name] + return svc, ok +} +func (a *stubApp) Must(name string) any { + svc, ok := a.services[name] + if !ok { + panic("service not found: " + name) + } + return svc +} +func (a *stubApp) Inject(name string, svc any) { a.services[name] = svc } +func (a *stubApp) GetService(name string, out any) error { return nil } +func (a *stubApp) ConfigSections() map[string]modular.ConfigProvider { return nil } +func (a *stubApp) OnConfigLoaded(_ func(app modular.Application) error) {} +func (a *stubApp) SetLogger(_ modular.Logger) {} +func (a *stubApp) SetVerboseConfig(_ bool) {} +func (a *stubApp) IsVerboseConfig() bool { return false } +func (a *stubApp) GetServicesByModule(_ string) []string { return nil } +func (a *stubApp) StartTime() time.Time { return time.Time{} } +func (a *stubApp) GetModule(_ string) modular.Module { return nil } +func (a *stubApp) GetAllModules() map[string]modular.Module { return nil } +func (a *stubApp) GetServiceEntry(_ string) (*modular.ServiceRegistryEntry, bool) { return nil, false } +func (a *stubApp) GetServicesByInterface(_ reflect.Type) []*modular.ServiceRegistryEntry { + return nil +} + +// stubEngineLoader exposes a PluginLoader for the wiring hook to find via +// the engineWithLoader interface. +type stubEngineLoader struct { + loader *plugin.PluginLoader +} + +func (s *stubEngineLoader) PluginLoader() *plugin.PluginLoader { return s.loader } + +func TestWiringHook_NoToken(t *testing.T) { + t.Setenv("WORKFLOW_LICENSE_TOKEN", "") + + loader := plugin.NewPluginLoader(capability.NewRegistry(), schema.NewModuleSchemaRegistry()) + eng := &stubEngineLoader{loader: loader} + app := newStubApp(map[string]any{"workflowEngine": eng}) + + hook := New().WiringHooks()[0] + if err := hook.Hook(app, nil); err != nil { + t.Fatalf("wiring hook failed: %v", err) + } + // No validator should be set when there's no token or HTTP validator + // (loader.ValidateTier works without a validator in permissive mode) +} + +func TestWiringHook_WithValidToken(t *testing.T) { + // Generate test keypair and override embedded public key for this test + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + // Temporarily override the embedded key for the test + original := embeddedPublicKey + embeddedPublicKey = license.MarshalPublicKeyPEM(pub) + defer func() { embeddedPublicKey = original }() + + // Create a signed token + tok := &license.LicenseToken{ + LicenseID: "test-lic", + TenantID: "test-tenant", + Organization: "Test Org", + Tier: "enterprise", + Features: []string{"my-plugin"}, + MaxWorkflows: 10, + MaxPlugins: 5, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + + t.Setenv("WORKFLOW_LICENSE_TOKEN", tokenStr) + + loader := plugin.NewPluginLoader(capability.NewRegistry(), schema.NewModuleSchemaRegistry()) + eng := &stubEngineLoader{loader: loader} + app := newStubApp(map[string]any{"workflowEngine": eng}) + + hook := New().WiringHooks()[0] + if err := hook.Hook(app, nil); err != nil { + t.Fatalf("wiring hook failed: %v", err) + } +} + +func TestWiringHook_InvalidToken(t *testing.T) { + t.Setenv("WORKFLOW_LICENSE_TOKEN", "wflic.v1.invalid.token") + + loader := plugin.NewPluginLoader(capability.NewRegistry(), schema.NewModuleSchemaRegistry()) + eng := &stubEngineLoader{loader: loader} + app := newStubApp(map[string]any{"workflowEngine": eng}) + + hook := New().WiringHooks()[0] + if err := hook.Hook(app, nil); err == nil { + t.Error("expected wiring hook to fail with invalid token") + } +} + +func TestWiringHook_NoEngineService(t *testing.T) { + t.Setenv("WORKFLOW_LICENSE_TOKEN", "") + + // No engine registered — hook should succeed silently + app := newStubApp(map[string]any{}) + hook := New().WiringHooks()[0] + if err := hook.Hook(app, nil); err != nil { + t.Fatalf("wiring hook should not fail when no engine is registered: %v", err) + } +} + +func TestLicenseValidatorAdapter_WithOffline(t *testing.T) { + pub, priv, err := license.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + tok := &license.LicenseToken{ + LicenseID: "lic-1", + Tier: "enterprise", + Features: []string{"plugin-a"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + tokenStr, err := tok.Sign(priv) + if err != nil { + t.Fatal(err) + } + + offline, err := licensing.NewOfflineValidator(license.MarshalPublicKeyPEM(pub), tokenStr) + if err != nil { + t.Fatal(err) + } + + adapter := &licenseValidatorAdapter{validator: offline, offline: offline} + + if err := adapter.ValidatePlugin("plugin-a"); err != nil { + t.Errorf("ValidatePlugin(plugin-a) should succeed: %v", err) + } + if err := adapter.ValidatePlugin("plugin-b"); err == nil { + t.Error("ValidatePlugin(plugin-b) should fail") + } +} + +func TestLicenseValidatorAdapter_HTTPFallback(t *testing.T) { + // HTTP validator with empty server URL returns a starter license + httpV := licensing.NewHTTPValidator(licensing.ValidatorConfig{}, nil) + // Force the HTTP validator to have a cached result + _, _ = httpV.Validate(context.Background(), "test-key") + + adapter := &licenseValidatorAdapter{ + validator: httpV, + offline: nil, + } + + // HTTP starter license has tier "starter" which is not professional/enterprise + // so premium plugin validation should fail + if err := adapter.ValidatePlugin("some-plugin"); err == nil { + t.Error("expected ValidatePlugin to fail for HTTP starter license (no cached info yet)") + } +}