OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_identity_flow.gd

310 lines · gd ref:
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