diff --git a/roles/github_runner_cluster/tasks/mesh/tailscale_api.yml b/roles/github_runner_cluster/tasks/mesh/tailscale_api.yml index ec071775..fe0f56b0 100644 --- a/roles/github_runner_cluster/tasks/mesh/tailscale_api.yml +++ b/roles/github_runner_cluster/tasks/mesh/tailscale_api.yml @@ -70,7 +70,8 @@ tags: ["{{ github_runner_cluster_mesh_tag }}"] # 90 days: comfortably under this tailnet's own observed maxKeyDuration (180 days), and short enough that a compromised key doesn't stay valid indefinitely - rotation itself is the manual step documented above. expirySeconds: 7776000 - description: "github-runner fleet vpn-auth join key (Ansible-managed)" + # Letters, digits, spaces and hyphens only: the API refuses a description with any other character ("keys: description had invalid characters"), parentheses, commas and colons included. + description: "github-runner fleet vpn-auth join key Ansible-managed" status_code: [200] delegate_to: localhost no_log: true diff --git a/tests/unit/test_tailscale_api.py b/tests/unit/test_tailscale_api.py new file mode 100644 index 00000000..2b02c722 --- /dev/null +++ b/tests/unit/test_tailscale_api.py @@ -0,0 +1,35 @@ +"""Tests for what the tailscale mesh provider's API tasks (roles/github_runner_cluster/tasks/mesh/tailscale_api.yml) send to Tailscale's API.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path +from typing import Any + +import yaml + +TASKS = Path(__file__).resolve().parents[2] / "roles" / "github_runner_cluster" / "tasks" / "mesh" / "tailscale_api.yml" + + +def key_request() -> dict[str, Any]: + """The body of the request that creates the join key.""" + tasks: list[dict[str, Any]] = yaml.safe_load(TASKS.read_text()) + task = next(task for task in tasks if task.get("name", "").startswith("Create a reusable, pre-authorized Tailscale join key")) + body: dict[str, Any] = task["ansible.builtin.uri"]["body"] + return body + + +class JoinKeyRequestTest(unittest.TestCase): + def test_the_description_uses_only_characters_the_api_accepts(self) -> None: + # The API answers 400 "keys: description had invalid characters" for parentheses, commas and colons; letters, digits, spaces and hyphens were accepted. + self.assertRegex(key_request()["description"], re.compile(r"\A[A-Za-z0-9 -]+\Z")) + + def test_the_key_is_reusable_preauthorised_tagged_and_not_ephemeral(self) -> None: + create = key_request()["capabilities"]["devices"]["create"] + self.assertEqual((create["reusable"], create["ephemeral"], create["preauthorized"]), (True, False, True)) + self.assertEqual(create["tags"], ["{{ github_runner_cluster_mesh_tag }}"]) + + +if __name__ == "__main__": + unittest.main()