236 lines
6.8 KiB
Go
236 lines
6.8 KiB
Go
|
|
package core_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
. "dappco.re/go/core"
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
)
|
||
|
|
|
||
|
|
// --- Entitled ---
|
||
|
|
|
||
|
|
func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
e := c.Entitled("anything")
|
||
|
|
assert.True(t, e.Allowed, "default checker permits everything")
|
||
|
|
assert.True(t, e.Unlimited)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
if action == "premium.feature" {
|
||
|
|
return Entitlement{Allowed: true}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: false, Reason: "not in package"}
|
||
|
|
})
|
||
|
|
|
||
|
|
assert.True(t, c.Entitled("premium.feature").Allowed)
|
||
|
|
assert.False(t, c.Entitled("other.feature").Allowed)
|
||
|
|
assert.Equal(t, "not in package", c.Entitled("other.feature").Reason)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
if action == "social.accounts" {
|
||
|
|
limit := 5
|
||
|
|
used := 3
|
||
|
|
remaining := limit - used
|
||
|
|
if qty > remaining {
|
||
|
|
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: true, Unlimited: true}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Can create 2 more (3 used of 5)
|
||
|
|
e := c.Entitled("social.accounts", 2)
|
||
|
|
assert.True(t, e.Allowed)
|
||
|
|
assert.Equal(t, 5, e.Limit)
|
||
|
|
assert.Equal(t, 3, e.Used)
|
||
|
|
assert.Equal(t, 2, e.Remaining)
|
||
|
|
|
||
|
|
// Can't create 3 more
|
||
|
|
e = c.Entitled("social.accounts", 3)
|
||
|
|
assert.False(t, e.Allowed)
|
||
|
|
assert.Equal(t, "limit exceeded", e.Reason)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_Entitled_Bad_Denied(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
return Entitlement{Allowed: false, Reason: "locked by M1"}
|
||
|
|
})
|
||
|
|
|
||
|
|
e := c.Entitled("product.create")
|
||
|
|
assert.False(t, e.Allowed)
|
||
|
|
assert.Equal(t, "locked by M1", e.Reason)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
var receivedQty int
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
receivedQty = qty
|
||
|
|
return Entitlement{Allowed: true}
|
||
|
|
})
|
||
|
|
|
||
|
|
c.Entitled("test")
|
||
|
|
assert.Equal(t, 1, receivedQty, "default quantity should be 1")
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Action.Run Entitlement Enforcement ---
|
||
|
|
|
||
|
|
func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.Action("work", func(_ context.Context, _ Options) Result {
|
||
|
|
return Result{Value: "done", OK: true}
|
||
|
|
})
|
||
|
|
|
||
|
|
r := c.Action("work").Run(context.Background(), NewOptions())
|
||
|
|
assert.True(t, r.OK)
|
||
|
|
assert.Equal(t, "done", r.Value)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.Action("restricted", func(_ context.Context, _ Options) Result {
|
||
|
|
return Result{Value: "should not reach", OK: true}
|
||
|
|
})
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
if action == "restricted" {
|
||
|
|
return Entitlement{Allowed: false, Reason: "tier too low"}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: true, Unlimited: true}
|
||
|
|
})
|
||
|
|
|
||
|
|
r := c.Action("restricted").Run(context.Background(), NewOptions())
|
||
|
|
assert.False(t, r.OK, "denied action must not execute")
|
||
|
|
err, ok := r.Value.(error)
|
||
|
|
assert.True(t, ok)
|
||
|
|
assert.Contains(t, err.Error(), "not entitled")
|
||
|
|
assert.Contains(t, err.Error(), "tier too low")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
c.Action("allowed", func(_ context.Context, _ Options) Result {
|
||
|
|
return Result{Value: "ok", OK: true}
|
||
|
|
})
|
||
|
|
c.Action("blocked", func(_ context.Context, _ Options) Result {
|
||
|
|
return Result{Value: "nope", OK: true}
|
||
|
|
})
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
if action == "blocked" {
|
||
|
|
return Entitlement{Allowed: false, Reason: "nope"}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: true, Unlimited: true}
|
||
|
|
})
|
||
|
|
|
||
|
|
assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK)
|
||
|
|
assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- NearLimit ---
|
||
|
|
|
||
|
|
func TestEntitlement_NearLimit_Good(t *testing.T) {
|
||
|
|
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
|
||
|
|
assert.True(t, e.NearLimit(0.8))
|
||
|
|
assert.False(t, e.NearLimit(0.9))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) {
|
||
|
|
e := Entitlement{Allowed: true, Unlimited: true}
|
||
|
|
assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) {
|
||
|
|
e := Entitlement{Allowed: true, Limit: 0}
|
||
|
|
assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit")
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- UsagePercent ---
|
||
|
|
|
||
|
|
func TestEntitlement_UsagePercent_Good(t *testing.T) {
|
||
|
|
e := Entitlement{Limit: 100, Used: 75}
|
||
|
|
assert.Equal(t, 75.0, e.UsagePercent())
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) {
|
||
|
|
e := Entitlement{Limit: 0, Used: 5}
|
||
|
|
assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage")
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- RecordUsage ---
|
||
|
|
|
||
|
|
func TestEntitlement_RecordUsage_Good(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
var recorded string
|
||
|
|
var recordedQty int
|
||
|
|
|
||
|
|
c.SetUsageRecorder(func(action string, qty int, ctx context.Context) {
|
||
|
|
recorded = action
|
||
|
|
recordedQty = qty
|
||
|
|
})
|
||
|
|
|
||
|
|
c.RecordUsage("ai.credits", 10)
|
||
|
|
assert.Equal(t, "ai.credits", recorded)
|
||
|
|
assert.Equal(t, 10, recordedQty)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
// No recorder set — should not panic
|
||
|
|
assert.NotPanics(t, func() {
|
||
|
|
c.RecordUsage("anything", 5)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Permission Model Integration ---
|
||
|
|
|
||
|
|
func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) {
|
||
|
|
c := New()
|
||
|
|
|
||
|
|
// Simulate RFC-004 entitlement service
|
||
|
|
packages := map[string]int{
|
||
|
|
"social.accounts": 5,
|
||
|
|
"social.posts.scheduled": 100,
|
||
|
|
"ai.credits": 50,
|
||
|
|
}
|
||
|
|
usage := map[string]int{
|
||
|
|
"social.accounts": 3,
|
||
|
|
"social.posts.scheduled": 45,
|
||
|
|
"ai.credits": 48,
|
||
|
|
}
|
||
|
|
|
||
|
|
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
|
||
|
|
limit, hasFeature := packages[action]
|
||
|
|
if !hasFeature {
|
||
|
|
return Entitlement{Allowed: false, Reason: "feature not in package"}
|
||
|
|
}
|
||
|
|
used := usage[action]
|
||
|
|
remaining := limit - used
|
||
|
|
if qty > remaining {
|
||
|
|
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
|
||
|
|
}
|
||
|
|
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Can create 2 social accounts
|
||
|
|
e := c.Entitled("social.accounts", 2)
|
||
|
|
assert.True(t, e.Allowed)
|
||
|
|
|
||
|
|
// AI credits near limit
|
||
|
|
e = c.Entitled("ai.credits", 1)
|
||
|
|
assert.True(t, e.Allowed)
|
||
|
|
assert.True(t, e.NearLimit(0.8))
|
||
|
|
assert.Equal(t, 96.0, e.UsagePercent())
|
||
|
|
|
||
|
|
// Feature not in package
|
||
|
|
e = c.Entitled("premium.feature")
|
||
|
|
assert.False(t, e.Allowed)
|
||
|
|
}
|