Files
BehzadandClaude Sonnet 5 fa4e214eb1 Call it a node pack, not a plugin
"Plugin" is not ComfyUI's vocabulary. What Manager installs into custom_nodes/
is a custom node pack, and what the Registry publishes is a "node" — so to
someone browsing the Registry, "plugin" reads slightly off-ecosystem, like
calling a VS Code extension an add-on. The README is the listing page, so the
wording is user-facing.

Replaces the eight occurrences with "node pack", "the nodes", or "this pack" as
each sentence reads best. "Extension" stays reserved for its narrow ComfyUI
meaning: the frontend JS registered via app.registerExtension, which is
web/styleref.js and nothing else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:56:44 +02:00

224 lines
8.7 KiB
Python

"""Auth tests — PKCE, credentials file handling, expiry, headless detection."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import stat
import styleref_auth as auth
# ── PKCE ─────────────────────────────────────────────────────────────────────
def test_pkce_challenge_is_s256_of_verifier():
verifier, challenge = auth.generate_pkce_pair()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.decode()
.rstrip("=")
)
assert challenge == expected
def test_pkce_verifier_length_within_rfc7636():
verifier, _ = auth.generate_pkce_pair()
assert 43 <= len(verifier) <= 128
def test_pkce_pairs_are_unique():
assert auth.generate_pkce_pair()[0] != auth.generate_pkce_pair()[0]
def test_authorize_url_carries_pkce_and_scopes():
url = auth.build_authorize_url(
"https://styleref.io", "client-1", "http://127.0.0.1:5000/callback", "chal", "st"
)
assert url.startswith("https://styleref.io/oauth/authorize?")
for fragment in ("response_type=code", "code_challenge=chal", "code_challenge_method=S256", "state=st"):
assert fragment in url
# Scopes stay identical to the CLI's so one credentials file serves both.
assert "extract" in url
# ── credentials ──────────────────────────────────────────────────────────────
def test_credentials_path_prefers_xdg():
path = auth.credentials_path({"XDG_CONFIG_HOME": "/cfg", "HOME": "/home/u"})
assert path == os.path.join("/cfg", "styleref", "credentials.json")
def test_credentials_path_matches_the_cli_default():
"""CLI and nodes must share one file, or signing in twice becomes necessary."""
path = auth.credentials_path({"HOME": "/home/u"})
assert path == os.path.join("/home/u", ".config", "styleref", "credentials.json")
def test_saved_credentials_are_owner_only(tmp_path, monkeypatch):
"""A world-readable token file would leak the account on a shared box."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
auth.save_credentials({"access_token": "secret", "expires_at": 0})
path = auth.credentials_path()
mode = stat.S_IMODE(os.stat(path).st_mode)
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
with open(path, encoding="utf-8") as fh:
assert json.load(fh)["access_token"] == "secret"
def test_load_credentials_returns_none_when_absent(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
assert auth.load_credentials() is None
def test_load_credentials_survives_a_corrupt_file(tmp_path, monkeypatch):
"""A truncated file must degrade to anonymous, not crash every node."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
path = auth.credentials_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write("{not json")
assert auth.load_credentials() is None
# ── expiry ───────────────────────────────────────────────────────────────────
def test_is_expired_uses_a_safety_margin():
"""
A token expiring in 10s is treated as expired: an extraction started now
would outlive it mid-poll.
"""
now = 1_000_000.0
assert auth.is_expired({"expires_at": now + 10_000}, now_ms=now) is True
assert auth.is_expired({"expires_at": now + 120_000}, now_ms=now) is False
def test_missing_expiry_counts_as_expired():
assert auth.is_expired({}) is True
def test_to_stored_credentials_converts_expires_in_to_absolute_ms():
stored = auth.to_stored_credentials(
{"access_token": "a", "refresh_token": "r", "expires_in": 3600},
"https://styleref.io/api/oauth/token",
"client-1",
now_ms=1_000_000.0,
)
assert stored["expires_at"] == 1_000_000.0 + 3_600_000
assert stored["client_id"] == "client-1"
# ── token selection ──────────────────────────────────────────────────────────
def test_env_token_wins_over_stored_credentials(tmp_path, monkeypatch):
"""STYLEREF_TOKEN is the documented headless path — it must not be overridden."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
auth.save_credentials({"access_token": "stored", "expires_at": 9e15})
monkeypatch.setenv("STYLEREF_TOKEN", "from-env")
assert auth.bearer_token() == "from-env"
def test_bearer_token_is_none_when_signed_out(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("STYLEREF_TOKEN", raising=False)
assert auth.bearer_token() is None
def test_dead_session_degrades_to_anonymous(tmp_path, monkeypatch):
"""Public nodes must keep working when a refresh fails."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("STYLEREF_TOKEN", raising=False)
auth.save_credentials({"access_token": "old", "expires_at": 0, "refresh_token": "r"})
monkeypatch.setattr(auth, "refresh_credentials", lambda _c: None)
assert auth.bearer_token() is None
# ── headless detection ───────────────────────────────────────────────────────
def test_ssh_session_is_headless():
assert auth.is_headless({"SSH_CONNECTION": "10.0.0.1 22", "DISPLAY": ":0"}) is True
def test_override_forces_browser_login():
"""Someone port-forwarding into a remote box has a working loopback."""
assert auth.is_headless({"SSH_CONNECTION": "x", "STYLEREF_FORCE_BROWSER_LOGIN": "1"}) is False
def test_override_can_force_headless_on_a_desktop():
assert auth.is_headless({"STYLEREF_FORCE_BROWSER_LOGIN": "0"}) is True
def test_headless_help_leads_with_the_credentials_file_then_the_env_var():
"""
This text is the entire recovery path for remote users — keep it complete.
Copying credentials.json is the primary path because it carries the refresh
token, so the session renews itself. STYLEREF_TOKEN must stay documented as
the short-lived fallback, with its ~1h expiry stated: an access token that
silently stops working is the failure mode this help text exists to prevent.
There is no token UI on the account page, so it must not be named here.
"""
help_text = auth.HEADLESS_HELP
assert "credentials.json" in help_text
assert "npx styleref login" in help_text
assert "STYLEREF_TOKEN" in help_text
assert "hour" in help_text
assert "styleref.io/account" not in help_text
# The refreshing path must come first, or readers take the expiring one.
assert help_text.index("credentials.json") < help_text.index("STYLEREF_TOKEN")
# ── OAuth callback page ──────────────────────────────────────────────────────
class _FakeWFile:
def __init__(self):
self.data = b""
def write(self, chunk: bytes) -> None:
self.data += chunk
def _run_callback(path: str, expected_state: str) -> tuple[dict, bytes]:
"""Drive _CallbackHandler.do_GET without a socket."""
handler = object.__new__(auth._CallbackHandler)
handler.path = path
handler.wfile = _FakeWFile()
handler.send_response = lambda *_a, **_k: None
handler.send_header = lambda *_a, **_k: None
handler.end_headers = lambda *_a, **_k: None
auth._CallbackHandler.expected_state = expected_state
auth._CallbackHandler.result = {}
handler.do_GET()
return auth._CallbackHandler.result, handler.wfile.data
def test_valid_callback_serves_the_success_page():
result, page = _run_callback("/callback?code=abc&state=st", expected_state="st")
assert result == {"code": "abc"}
assert b"Signed in to StyleRef" in page
def test_state_mismatch_serves_the_failure_page_and_discards_the_code():
"""
CSRF case: the browser must NOT claim success while the node reports a
failed sign-in — the page and the result must agree.
"""
result, page = _run_callback("/callback?code=abc&state=WRONG", expected_state="st")
assert "error" in result and "code" not in result
assert b"didn't complete" in page
assert b"Signed in" not in page
def test_missing_code_serves_the_failure_page():
result, page = _run_callback("/callback?state=st", expected_state="st")
assert "error" in result
assert b"didn't complete" in page