OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_admin_role.gd

91 lines · gd ref:
extends SceneTree
## GATE — admin commands run with the AUTHOR'S room role, not a claimed one
## (option a, 2026-10-04; the real fix — the daemon verifying the writer — is the
## hyper-games #92 follow-up).
##   R1  offline: your own (or a legacy, author-less) command is admin; another's is peer
##   R2  online: the role is the author's keet membership role (mod / peer / read-only);
##       an unknown author and a legacy key get peer
##   R3  a peer-role member claiming {"requester_role": "admin"} is REFUSED a mod call;
##       a mod is allowed the same call
##   R4  admin:cmd: is author-scoped: I may write my own, not someone else's
##   R5  the TUI writes admin:cmd:<memberId>:<seq> and no longer claims admin
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_admin_role.gd

var ok := true
var checks := 0
var _frame := 0


func _p(c: bool, m: String, got: Variant = "") -> void:
	checks += 1
	print(("ok   " if c else "FAIL: ") + m + ("" if str(got) == "" else "  (" + str(got) + ")"))
	if not c:
		ok = false


func _process(_d: float) -> bool:
	_frame += 1
	if _frame < 5:
		return false
	_run()
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true


func _run() -> void:
	var af: Node = root.get_node("/root/AdminFacade")
	var ks: Node = root.get_node("/root/KeetService")
	var gs: Node = root.get_node("/root/GameState")
	var saved_cache: Array = (ks.get("_member_cache") as Array).duplicate()
	var saved_me: String = String(ks.get("current_member_id"))

	# ---- R1: offline (no roster) ----
	ks.set("_member_cache", [])
	ks.set("current_member_id", "alice")
	_p(af.requester_role_for("alice:1") == "admin", "R1 offline: my own command is admin")
	_p(af.requester_role_for("legacy-seq") == "admin", "R1 offline: a legacy author-less command is admin (gates)")
	_p(af.requester_role_for("bob:1") == "peer", "R1 offline: someone else's is peer")

	# ---- R2: online, keet roster ----
	ks.set("_member_cache", [{"memberId": "alice", "role": "mod"}, {"memberId": "bob", "role": "peer"},
		{"memberId": "ro", "role": "read-only"}, {"memberId": "root", "role": "admin"}])
	_p(af.requester_role_for("alice:7") == "mod", "R2 a mod's command runs as mod")
	_p(af.requester_role_for("root:7") == "admin", "R2 an admin's as admin")
	_p(af.requester_role_for("bob:7") == "peer", "R2 a peer's as peer")
	_p(af.requester_role_for("ro:7") == "read-only", "R2 a read-only member's as read-only (below peer)")
	_p(not af._role_satisfies(af.requester_role_for("ro:7"), "peer"), "R2   which does not satisfy peer")
	_p(af.requester_role_for("eve:7") == "peer", "R2 an unknown author gets peer")
	_p(af.requester_role_for("legacy-seq") == "peer", "R2 online, a legacy key gets peer")

	# ---- R3: the claim is ignored ----
	var claim := {"op": "call", "path": "/global/bots.register", "args": ["gate-r3-bob", ["session"], "none", {}],
		"requester_role": "admin"}
	af._dispatch_command("bob:r3", claim)
	var rb: Variant = gs.get_state("admin:result:bob:r3")
	_p(rb is Dictionary and not bool((rb as Dictionary).get("ok", true)), "R3 a peer claiming admin is REFUSED a mod call", rb)
	var reg: Node = root.get_node("/root/BotRegistry")
	_p(not reg.is_registered("gate-r3-bob"), "R3 and nothing was registered")
	var claim2 := claim.duplicate(true)
	(claim2["args"] as Array)[0] = "gate-r3-alice"
	claim2.erase("requester_role")
	af._dispatch_command("alice:r3", claim2)
	var ra: Variant = gs.get_state("admin:result:alice:r3")
	_p(ra is Dictionary and bool((ra as Dictionary).get("ok", false)), "R3 a mod is allowed the same call (no claim needed)", ra)
	reg.despawn("gate-r3-alice")

	# ---- R4: author-scoped route ----
	ks.set("current_member_id", "bob")
	_p(gs._author_of("admin:cmd:alice:5") == "alice", "R4 admin:cmd: is author-scoped (author = alice)")
	_p(gs.can_write("admin:cmd:bob:5"), "R4 bob may write his own command")
	_p(not gs.can_write("admin:cmd:alice:5"), "R4 bob may NOT write a command as alice")

	# ---- R5: the TUI ----
	var tui := FileAccess.get_file_as_string("res://lib/tui.js")
	_p(tui.contains("const seq = me + ':'"), "R5 the TUI signs commands with its memberId")
	_p(not tui.contains("requester_role: 'admin'"), "R5 and no longer claims admin")

	ks.set("_member_cache", saved_cache)
	ks.set("current_member_id", saved_me)