Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
membership.sx — coop membership as a guarded state machine
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
transitions return explicit {error, CurrentStatus}, never silent no-ops.
project(Subject, App) renders the one canonical state into a per-app claim
({member,Tier,App} / {pending,App} / {lapsed,App} / {denied,App} /
{non_member,App}) — identity reports what the membership is; acl decides
whether the app should honour it. New tests/membership.sx. 92/92.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
7.5 KiB
Plaintext
156 lines
7.5 KiB
Plaintext
;; identity/tests/membership.sx — membership state machine + per-app
|
|
;; grant projection. Valid transitions advance state; invalid ones are
|
|
;; explicit errors. The projection renders one canonical state per app.
|
|
|
|
(define id-membership-test-count 0)
|
|
(define id-membership-test-pass 0)
|
|
(define id-membership-test-fails (list))
|
|
|
|
(define
|
|
id-membership-test
|
|
(fn
|
|
(name actual expected)
|
|
(set! id-membership-test-count (+ id-membership-test-count 1))
|
|
(if
|
|
(= actual expected)
|
|
(set! id-membership-test-pass (+ id-membership-test-pass 1))
|
|
(append! id-membership-test-fails {:name name :expected expected :actual actual}))))
|
|
|
|
(define idm-ev erlang-eval-ast)
|
|
(define idmnm (fn (v) (get v :name)))
|
|
|
|
(identity-load-membership!)
|
|
|
|
;; ── request → pending → approve → active ─────────────────────────
|
|
|
|
(id-membership-test
|
|
"request leaves the subject pending"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
"pending")
|
|
|
|
(id-membership-test
|
|
"approve activates a pending membership"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
|
"active")
|
|
|
|
(id-membership-test
|
|
"status keeps the requested tier"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
|
"supporter")
|
|
|
|
;; ── guarded transitions: invalid moves are explicit errors ───────
|
|
|
|
(id-membership-test
|
|
"requesting twice is an error"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:request(M, alice, basic) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
"exists")
|
|
|
|
(id-membership-test
|
|
"approving an unknown subject is not_found"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n case identity_membership:approve(M, ghost) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
"not_found")
|
|
|
|
(id-membership-test
|
|
"approving an already-active membership is an error"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:approve(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
"active")
|
|
|
|
;; ── lapse / reinstate ────────────────────────────────────────────
|
|
|
|
(id-membership-test
|
|
"active member can lapse"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
"lapsed")
|
|
|
|
(id-membership-test
|
|
"lapsing a pending membership is an error"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:lapse(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
"pending")
|
|
|
|
(id-membership-test
|
|
"lapsed member can reinstate to active"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n identity_membership:reinstate(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
"active")
|
|
|
|
;; ── revoke is terminal ───────────────────────────────────────────
|
|
|
|
(id-membership-test
|
|
"any member can be revoked"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
|
|
"revoked")
|
|
|
|
(id-membership-test
|
|
"a revoked membership cannot be reinstated"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:reinstate(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
|
|
"revoked")
|
|
|
|
;; ── per-app grant projection ─────────────────────────────────────
|
|
|
|
(id-membership-test
|
|
"active member projects as member"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
|
"member")
|
|
|
|
(id-membership-test
|
|
"projection carries the requesting app"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, market) of\n {member, _, App} -> App\n end"))
|
|
"market")
|
|
|
|
(id-membership-test
|
|
"the same subject projects consistently across apps"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n {member, T1, blog} = identity_membership:project(M, alice, blog),\n {member, T2, events} = identity_membership:project(M, alice, events),\n case T1 =:= T2 of\n true -> T1;\n false -> mismatch\n end"))
|
|
"supporter")
|
|
|
|
(id-membership-test
|
|
"unknown subject projects as non_member"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n case identity_membership:project(M, ghost, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
"non_member")
|
|
|
|
(id-membership-test
|
|
"lapsed member projects as lapsed"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
"lapsed")
|
|
|
|
(id-membership-test
|
|
"revoked member projects as denied"
|
|
(idmnm
|
|
(idm-ev
|
|
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
|
|
"denied")
|
|
|
|
(define
|
|
id-membership-test-summary
|
|
(str "membership " id-membership-test-pass "/" id-membership-test-count))
|