extends SceneTree
## Gate: in-game keet identity — first-run naming + vault, back up / change
## password, show phrase, restore, rename (hyper-games #89 §3.2 + §5).
##
## Before: an unnamed identity stayed "Player", nothing in the game called
## identity.setName or vault.*, and the only naming/backup path was the
## launcher's TTY `identity create` over the Unix socket (not on Windows).
##
## Part A drives IdentityFlow against a scripted stub client and checks the
## exact RPCs. Part B checks the menu wiring, with the menu taken out of
## game.tscn on its own (as gate_purge_confirm does).
##
## Run with no daemon: HYPERTPS_DAEMON_TCP=127.0.0.1:1.
const IdentityFlow := preload("res://scenes/game/identity_flow.gd")
const StubClient := preload("res://scenes/test/_stub_keet_client.gd")
const WORDS_24 := "abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual"
var ok := true
var _ran := false
var _changed := 0
var _swapping := 0
func _p(c: bool, m: String) -> void:
print(("ok " if c else "FAIL: ") + m)
if not c:
ok = false
func _process(_delta: float) -> bool:
if _ran:
return true
_ran = true
_run()
print("GATE identity_flow: %s" % ("PASS" if ok else "FAIL"))
quit(0 if ok else 1)
return true
func _on_changed() -> void:
_changed += 1
func _on_swapping() -> void:
_swapping += 1
func _new_flow(stub: RefCounted) -> Node:
var flow: Node = IdentityFlow.new()
flow.client = stub
flow.interactive = true
flow.identity_changed.connect(_on_changed)
flow.identity_swapping.connect(_on_swapping)
root.add_child(flow)
_changed = 0
_swapping = 0
return flow
func _fill(flow: Node, key: String, value: String) -> void:
flow.form.inputs[key].text = value
func _ok(flow: Node) -> void:
flow.form.dlg.confirmed.emit()
func _run() -> void:
_first_run_fresh()
_first_run_vault_exists()
_first_run_named_or_headless()
_backup_change_password()
_backup_phrase_dropped()
_show_phrase()
_restore()
_menu_wiring()
func _first_run_fresh() -> void:
print("-- first run, fresh identity (no name, no vault)")
var stub := StubClient.new()
stub.replies = {"vault.status": {"exists": false}}
var flow := _new_flow(stub)
flow.check_first_run({"memberId": "abcd1234", "displayName": null})
_p(flow.form.get("kind", "") == "first_run", "the first-run step opens")
_p(flow.form.inputs.has("password") and flow.form.inputs.has("confirm"), "it asks for a backup password")
_p(flow.form.dlg is AcceptDialog and not (flow.form.dlg is ConfirmationDialog), "it is required (no Cancel)")
flow.check_first_run({"memberId": "abcd1234", "displayName": null})
_p(stub.methods().count("vault.status") == 1, "a second identity reply does not open a second step")
_fill(flow, "name", " ")
_ok(flow)
_p(stub.methods() == ["vault.status"], "a blank name sends nothing")
_fill(flow, "name", "clode")
_fill(flow, "password", "short")
_fill(flow, "confirm", "short")
_ok(flow)
_p(stub.methods() == ["vault.status"], "a short password sends nothing (%s)" % flow.form.status.text)
_fill(flow, "password", "correct horse")
_fill(flow, "confirm", "correct horsf")
_ok(flow)
_p(stub.methods() == ["vault.status"], "mismatched passwords send nothing")
_fill(flow, "confirm", "correct horse")
_ok(flow)
_p(stub.methods() == ["vault.status", "vault.setup", "identity.setName"], "vault.setup, then identity.setName (%s)" % str(stub.methods()))
_p(stub.last("vault.setup") == {"password": "correct horse"}, "vault.setup gets only the password: it backs up, never restores")
_p(stub.last("identity.setName").get("name", "") == "clode", "the trimmed name is set in keet")
_p(flow.form.is_empty(), "the step closes")
_p(_changed == 1, "identity_changed fires once (the menu refetches identity)")
flow.queue_free()
func _first_run_vault_exists() -> void:
print("-- first run, vault already there")
var stub := StubClient.new()
stub.replies = {"vault.status": {"exists": true}}
var flow := _new_flow(stub)
flow.check_first_run({"memberId": "abcd1234", "displayName": ""})
_p(not flow.form.inputs.has("password"), "no password asked when a backup exists")
_fill(flow, "name", "clode")
_ok(flow)
_p(stub.methods() == ["vault.status", "identity.setName"], "only the name is set (%s)" % str(stub.methods()))
flow.queue_free()
print("-- first run, keet-core dropped the phrase and there is no vault")
stub = StubClient.new()
stub.replies = {"vault.status": {"exists": false},
"vault.setup": {"__err": "keet-core no longer holds this identity's recovery phrase and there is no vault: pass phrase (the 24 words) to back it up"}}
flow = _new_flow(stub)
flow.check_first_run({"memberId": "abcd1234"})
_fill(flow, "name", "clode")
_fill(flow, "password", "correct horse")
_fill(flow, "confirm", "correct horse")
_ok(flow)
_p(stub.methods() == ["vault.status", "vault.setup", "identity.setName"], "naming still completes (%s)" % str(stub.methods()))
_p(flow.form.is_empty(), "and the step closes")
flow.queue_free()
print("-- first run, backup fails for another reason")
stub = StubClient.new()
stub.replies = {"vault.status": {"exists": false}, "vault.setup": {"__err": "Timed out after 180 s"}}
flow = _new_flow(stub)
flow.check_first_run({"memberId": "abcd1234"})
_fill(flow, "name", "clode")
_fill(flow, "password", "correct horse")
_fill(flow, "confirm", "correct horse")
_ok(flow)
_p(not stub.methods().has("identity.setName"), "the name is not set past a failed backup")
_p(not flow.form.is_empty() and flow.form.status.text.contains("Timed out"), "the step stays open and shows the error")
_p(not flow.form.dlg.get_ok_button().disabled, "Continue is usable again")
flow.queue_free()
func _first_run_named_or_headless() -> void:
print("-- no first run when named, or headless")
var stub := StubClient.new()
var flow := _new_flow(stub)
flow.check_first_run({"memberId": "abcd1234", "displayName": "clode"})
flow.check_first_run({"memberId": "abcd1234", "displayName": null, "username": "clode0"})
_p(stub.calls.is_empty() and flow.form.is_empty(), "a named identity gets no step")
flow.interactive = false
flow.check_first_run({"memberId": "abcd1234", "displayName": null})
_p(stub.calls.is_empty() and flow.form.is_empty(), "a headless run is never blocked by it")
flow.queue_free()
func _backup_change_password() -> void:
print("-- back up: vault exists = change password")
var stub := StubClient.new()
stub.replies = {"vault.status": {"exists": true}}
var flow := _new_flow(stub)
flow.backup()
_p(flow.form.get("kind", "") == "change_password", "it offers a password change")
_fill(flow, "old", "old password!")
_fill(flow, "password", "new password!")
_fill(flow, "confirm", "new password!")
_ok(flow)
_p(stub.last("vault.setup") == {"password": "new password!", "oldPassword": "old password!"}, "vault.setup {password, oldPassword} (%s)" % str(stub.last("vault.setup")))
_p(flow.form.is_empty(), "closes on success")
flow.queue_free()
stub = StubClient.new()
stub.replies = {"vault.status": {"exists": true}, "vault.setup": {"__err": "Decryption failed — wrong password or corrupted vault"}}
flow = _new_flow(stub)
flow.backup()
_fill(flow, "old", "wrong password")
_fill(flow, "password", "new password!")
_fill(flow, "confirm", "new password!")
_ok(flow)
_p(not flow.form.is_empty() and flow.form.status.text == "Wrong password.", "a wrong current password says so (%s)" % (flow.form.status.text if not flow.form.is_empty() else "closed"))
flow.queue_free()
func _backup_phrase_dropped() -> void:
print("-- back up: no vault, keet-core dropped the phrase")
var stub := StubClient.new()
stub.replies = {"vault.status": {"exists": false},
"vault.setup": {"__err": "keet-core no longer holds this identity's recovery phrase and there is no vault: pass phrase (the 24 words) to back it up"}}
var flow := _new_flow(stub)
flow.backup()
_p(not flow.form.inputs.phrase.visible, "the words field starts hidden")
_fill(flow, "password", "correct horse")
_fill(flow, "confirm", "correct horse")
_ok(flow)
_p(flow.form.inputs.phrase.visible, "after the refusal it asks for the words")
stub.replies["vault.setup"] = {"ok": true}
_fill(flow, "phrase", " ABANDON ability\nable " + WORDS_24.substr(WORDS_24.find("about")))
_ok(flow)
var sent: Dictionary = stub.last("vault.setup")
_p(sent.get("phrase", "") == WORDS_24, "the words go to vault.setup normalized (%s)" % sent.get("phrase", ""))
_p(not stub.methods().has("vault.import") and not stub.methods().has("identity-manager.import"), "backing up never restores")
_p(flow.form.is_empty(), "closes on success")
flow.queue_free()
func _show_phrase() -> void:
print("-- show recovery phrase")
var stub := StubClient.new()
stub.replies = {"vault.export": {"phrase": WORDS_24}}
var flow := _new_flow(stub)
flow.show_phrase()
_fill(flow, "password", "correct horse")
_ok(flow)
_p(stub.last("vault.export") == {"password": "correct horse"}, "vault.export with the password")
_p(flow.form.get("shown", false) and flow.form.inputs.phrase.text == WORDS_24, "the words are shown")
_p(not flow.form.inputs.phrase.editable, "read-only")
_p(flow.form.inputs.password.text.is_empty(), "the password field is cleared")
_ok(flow)
_p(flow.form.is_empty() and stub.methods().count("vault.export") == 1, "Done closes it without another export")
flow.queue_free()
func _restore() -> void:
print("-- restore from phrase")
var stub := StubClient.new()
var flow := _new_flow(stub)
flow.restore()
_fill(flow, "phrase", "only three words")
_fill(flow, "password", "correct horse")
_fill(flow, "confirm", "correct horse")
_ok(flow)
_p(not flow.form.has("ask"), "a short phrase is refused before asking")
_fill(flow, "phrase", WORDS_24)
_ok(flow)
_p(flow.form.has("ask"), "a full phrase opens the switch confirmation")
_p(stub.calls.is_empty() and _swapping == 0, "nothing is sent before confirming")
flow.form.ask.canceled.emit()
_p(stub.calls.is_empty() and _swapping == 0, "cancel sends nothing")
_ok(flow)
flow.form.ask.confirmed.emit()
_p(_swapping == 1, "identity_swapping fires (the menu leaves the room)")
var sent: Dictionary = stub.last("identity-manager.import")
_p(sent.get("phrase", "") == WORDS_24 and sent.get("password", "") == "correct horse", "identity-manager.import {phrase, password}")
_p(String(sent.get("name", "")).begins_with("restored-"), "a new identity entry: the current one is kept")
_p(not stub.methods().has("vault.import"), "never vault.import (it overwrites the current identity in place)")
_p(flow.form.is_empty() and _changed == 1, "closes, identity_changed fires")
flow.queue_free()
func _menu_wiring() -> void:
print("-- menu wiring")
var keet: Node = root.get_node_or_null("KeetService")
var game_scene := load("res://scenes/game/game.tscn") as PackedScene
if keet == null or game_scene == null:
_p(false, "KeetService + game.tscn available")
return
var game: Node = game_scene.instantiate()
var menu: Node = game.get_node("MenuUI/MenuMain")
menu.get_parent().remove_child(menu)
game.free()
root.add_child(menu)
var flow: Node = menu.get("_identity_flow")
_p(flow != null, "the menu owns an IdentityFlow")
if flow == null:
menu.queue_free()
return
var stub := StubClient.new()
stub.replies = {"vault.status": {"exists": false}}
flow.client = stub
flow.interactive = true
for label in ["Back up…", "Recovery phrase…", "Restore…"]:
_p(_find_button(menu, label) != null, "Network panel has \"%s\"" % label)
menu._on_identity_loaded({"memberId": "abcd1234", "displayName": null})
_p(flow.form.get("kind", "") == "first_run", "an unnamed identity opens the first-run step")
_p(root.get_node("GameManager").player_name != "Player", "the menu no longer says \"Player\" (got %s)" % root.get_node("GameManager").player_name)
flow._close()
flow._first_run_open = false
var name_input: LineEdit = menu.get_node("NetworkPanel/NameInput")
var before: String = root.get_node("GameManager").player_name
name_input.text = "local-only"
name_input.text_changed.emit("local-only")
_p(root.get_node("GameManager").player_name == before, "typing in the name box no longer forks a local name")
name_input.text_submitted.emit("renamed")
_p(stub.last("identity.setName").get("name", "") == "renamed", "submitting it renames in keet")
keet.current_room_id = "gateidroom"
flow.identity_swapping.emit()
_p(keet.current_room_id == "", "a restore leaves the current room first")
menu.queue_free()
func _find_button(node: Node, text: String) -> Button:
for c in node.find_children("*", "Button", true, false):
if (c as Button).text == text:
return c
return null