* reset-password: rotate the admin credential in place instead of deleting auth.db * reset-password: fix the CI callers and error handling for the in-place rotation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reset-password: narrow the CI change to the jobs that read .bootstrap_password * reset-password: stop over-claiming what the reset revokes and when it takes effect * auth: bind token issuance to the credential version that was verified * auth: bind credential-creating writes to the version the request authenticated with * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: bind the change-password and workflow-key writes to their own credential version * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: read the credential version inside the transaction that validated it * data-recipe: answer 401 when a reset revokes the credential mid job start * Fix lint blocker and Windows path assertion for PR #7573 Drop the now-unused validate_api_key import from studio/backend/auth/authentication.py. Every call site moved to validate_api_key_with_credential, so the Source lint job's import-hoist gate flagged it as a blocker. The wrapper itself stays in storage.py; test_api_key_expiry.py still exercises it. Make test_run_reexec_forwards_resolved_frontend_on_public_launch compare against str(Path(...)) instead of a POSIX literal. _find_frontend_dist returns a Path, so on Windows the forwarded value is \fake\studio\frontend\dist and the assertion could never pass there. Pre-existing, surfaced by running unsloth_cli/tests on Windows. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
1450 lines
59 KiB
Python
1450 lines
59 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the forced terminal password change before public (tunnel) exposure.
|
|
|
|
`unsloth studio --secure` / `--cloudflare` (wildcard bind) must, when the admin
|
|
account still has its seeded bootstrap password, prompt for a new password in
|
|
the terminal BEFORE any re-exec or server exists; without a terminal it warns
|
|
and falls back to the backend bootstrap timeout. Modeled on
|
|
test_studio_cloudflare_flag.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
def _studio():
|
|
from unsloth_cli.commands import studio as _studio_mod
|
|
return _studio_mod
|
|
|
|
|
|
_BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"]
|
|
_NEW_PW = "brand-new-password"
|
|
|
|
|
|
# ── pure trigger matrix ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cloudflare,host,secure,api_only,expected",
|
|
[
|
|
# --secure always implies the tunnel (host already forced to loopback).
|
|
(None, "127.0.0.1", True, False, True),
|
|
(True, "127.0.0.1", True, False, True),
|
|
(None, "127.0.0.1", True, True, True),
|
|
# --cloudflare tunnels only non-api-only wildcard binds.
|
|
(True, "0.0.0.0", False, False, True),
|
|
(True, "::", False, False, True),
|
|
(True, "127.0.0.1", False, False, False),
|
|
(True, "0.0.0.0", False, True, False),
|
|
# Off/unset never prompts without --secure.
|
|
(None, "0.0.0.0", False, False, False),
|
|
(False, "0.0.0.0", False, False, False),
|
|
(None, "127.0.0.1", False, False, False),
|
|
],
|
|
)
|
|
def test_should_prompt_password_change_matrix(cloudflare, host, secure, api_only, expected):
|
|
assert (
|
|
_studio()._should_prompt_password_change(
|
|
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
|
)
|
|
is expected
|
|
)
|
|
|
|
|
|
# ── shared harness ───────────────────────────────────────────────────
|
|
|
|
|
|
class _ExecCaptured(SystemExit):
|
|
def __init__(self, argv):
|
|
super().__init__(0)
|
|
self.argv = list(argv)
|
|
|
|
|
|
def _auth_db(studio_home: Path) -> Path:
|
|
return studio_home / "auth" / "auth.db"
|
|
|
|
|
|
def _seed_auth(studio_mod, *, must_change = True):
|
|
"""Create the CLI-side default admin (must_change_password=1) plus one
|
|
refresh token, mirroring a fresh install that served a login."""
|
|
conn = studio_mod._connect_auth_db()
|
|
try:
|
|
studio_mod._ensure_cli_default_admin(conn)
|
|
if not must_change:
|
|
conn.execute("UPDATE auth_user SET must_change_password = 0")
|
|
conn.execute(
|
|
"INSERT INTO refresh_tokens (token_hash, username, expires_at) VALUES (?, ?, ?)",
|
|
("deadbeef", studio_mod.DEFAULT_ADMIN_USERNAME, "2099-01-01T00:00:00"),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute(
|
|
"SELECT password_hash, jwt_secret FROM auth_user WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
return {"password_hash": row[0], "jwt_secret": row[1]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _auth_state(studio_mod):
|
|
conn = sqlite3.connect(_auth_db(studio_mod.STUDIO_HOME))
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT password_hash, jwt_secret, must_change_password FROM auth_user "
|
|
"WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
n_refresh = conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0]
|
|
return {
|
|
"password_hash": row[0],
|
|
"jwt_secret": row[1],
|
|
"must_change_password": row[2],
|
|
"n_refresh": n_refresh,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _install_prompt_env(
|
|
monkeypatch,
|
|
tmp_path,
|
|
*,
|
|
interactive,
|
|
scripted = _NEW_PW,
|
|
):
|
|
"""Tmp STUDIO_HOME + fake tty + scripted prompt. Returns the event log that
|
|
records prompt calls and re-exec argv in order."""
|
|
studio_mod = _studio()
|
|
events = []
|
|
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
monkeypatch.setattr(studio_mod, "_prompt_streams_interactive", lambda: interactive)
|
|
# cloudflared is "available" by default so the headless --secure strip path
|
|
# proceeds without a real download; the unavailable-tunnel guard has its own
|
|
# dedicated test that overrides this.
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: False)
|
|
|
|
def fake_prompt(verify_current, out = None):
|
|
events.append(("prompt", verify_current))
|
|
if isinstance(scripted, BaseException):
|
|
raise scripted
|
|
return scripted
|
|
|
|
monkeypatch.setattr(studio_mod._password_prompt, "prompt_new_password", fake_prompt)
|
|
return events
|
|
|
|
|
|
def _install_studio_default_reexec(monkeypatch, events):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
# A built frontend dist is present by default so the public-launch UI check
|
|
# passes; the no-dist lockout guard has its own dedicated test.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
|
|
def _install_run_reexec(monkeypatch, events):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
# A built frontend dist is present by default so the public-launch UI check
|
|
# passes deterministically (independent of whether the repo dist was built);
|
|
# the missing-dist lockout guard has its own dedicated test.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
fake_bin = fake_venv / "bin" / "unsloth"
|
|
real_is_file = Path.is_file
|
|
monkeypatch.setattr(
|
|
Path,
|
|
"is_file",
|
|
lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
|
|
)
|
|
from unsloth_cli import _tool_policy as _tp_mod
|
|
|
|
monkeypatch.setattr(
|
|
_tp_mod,
|
|
"resolve_tool_policy",
|
|
lambda host, flag, yes, silent: False if flag is None else bool(flag),
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
|
|
def _invoke_studio_default(monkeypatch, events, args):
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
return CliRunner().invoke(app, args, catch_exceptions = True)
|
|
|
|
|
|
def _invoke_run(monkeypatch, events, args):
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
_install_run_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
return CliRunner().invoke(app, args, catch_exceptions = True)
|
|
|
|
|
|
# ── plain `unsloth studio` ───────────────────────────────────────────
|
|
|
|
|
|
def test_studio_default_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["jwt_secret"] != before["jwt_secret"]
|
|
assert after["n_refresh"] == 0
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
|
|
|
|
def test_studio_default_prompt_rejects_current_password(monkeypatch, tmp_path):
|
|
# The verify_current callback handed to the prompt must recognize the
|
|
# seeded bootstrap password (hash compare with the stored salt).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text().strip()
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
verify_current = events[0][1]
|
|
assert verify_current(bootstrap_pw) is True
|
|
assert verify_current("something-else-entirely") is False
|
|
|
|
|
|
def test_studio_default_non_tty_warns_and_proceeds(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" in combined
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path):
|
|
# Mixed-version safety: a headless public launch must delete the seeded
|
|
# plaintext credential before re-exec so a fresh child of ANY version reads
|
|
# None from disk and never injects it into the public HTML. The launch still
|
|
# proceeds (re-exec captured), and the DB flag stays set so the login page
|
|
# still forces a change and the bootstrap shutdown timer still arms.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_reexec_outer_runpy_keeps_bootstrap_for_local_recovery(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (Codex 3572165931): when the re-exec target is THIS install's own
|
|
# run.py, the child's pre-bind gate sets suppress_bootstrap_injection and never
|
|
# serves the seeded credential publicly, so the parent strip is unnecessary.
|
|
# Skipping it means a --secure launch whose tunnel later fails to connect does
|
|
# not lock the user out, and .bootstrap_password stays for local recovery.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
# Re-exec target IS this install's outer run.py -> child self-suppresses.
|
|
outer_run_py = studio_mod._PACKAGE_ROOT / "studio" / "backend" / "run.py"
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: outer_run_py)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
# Strip skipped: file preserved, must_change still set, launch still re-execs.
|
|
assert bootstrap_file.exists(), result.output
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert "exec" in [k for k, _ in events], events
|
|
|
|
|
|
def test_studio_default_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path):
|
|
# Fresh STUDIO_HOME (no pre-seed): the gate's own _ensure_cli_default_admin
|
|
# does the INSERT. It must COMMIT that seed before re-exec, or conn.close()
|
|
# rolls it back and an OLD child would find no admin, regenerate a fresh
|
|
# bootstrap password + file, and inject THAT -- defeating the file deletion.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
# Deliberately NO _seed_auth(): exercise the gate seeding a fresh DB itself.
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
# The seeded admin persists (committed) so an old child sees it and does not
|
|
# regenerate; the bootstrap file stays deleted; the launch still re-execs.
|
|
state = _auth_state(studio_mod)
|
|
assert state["must_change_password"] == 1
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_studio_default_non_tty_fails_closed_when_bootstrap_removal_fails(monkeypatch, tmp_path):
|
|
# Removing .bootstrap_password IS the protection on this path. If unlink
|
|
# fails (locked file / read-only auth dir) the credential is still on disk
|
|
# for an old child to inject, so the launch must fail closed, not publish.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# The file remains (removal failed) and the DB flag is untouched.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
class _FailingSelectConn:
|
|
"""Wrap a real auth connection but raise on the gate's must_change SELECT,
|
|
so seeding + commit still happen and only the read-back fails (a locked-DB
|
|
window that lands after _ensure_cli_default_admin already wrote the file)."""
|
|
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
def execute(self, sql, *args, **kwargs):
|
|
if sql.lstrip().startswith("SELECT password_salt"):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
return self._inner.execute(sql, *args, **kwargs)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
class _FailingCommitConn:
|
|
"""Wrap a real auth connection but raise on commit(), so a fresh install's
|
|
seeded admin INSERT rolls back on close() -- the seed-committed guarantee the
|
|
gate depends on is not met, even though _ensure_cli_default_admin already
|
|
wrote the .bootstrap_password file."""
|
|
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
def commit(self):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
def test_studio_default_connect_failure_fails_closed(monkeypatch, tmp_path):
|
|
# If the auth DB cannot even be opened (transient lock / unwritable home) we
|
|
# cannot confirm a committed admin exists, so a re-exec'd old studio-venv child
|
|
# could find no admin, regenerate a fresh bootstrap credential, and serve it
|
|
# publicly -- stripping a file we cannot vouch for would not stop that. Refuse
|
|
# rather than publish; a transient lock clears on retry, and the existing
|
|
# credential file is left untouched so a retry can still prompt.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(
|
|
studio_mod,
|
|
"_connect_auth_db",
|
|
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("database is locked")),
|
|
)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# Not stripped: a retry can still prompt/strip once the lock clears.
|
|
assert bootstrap_file.exists()
|
|
|
|
|
|
def test_studio_default_seed_commit_failure_fails_closed(monkeypatch, tmp_path):
|
|
# Fresh install: the gate's own _ensure_cli_default_admin does the INSERT and
|
|
# writes .bootstrap_password, but the commit fails (write lock held past
|
|
# busy_timeout). The uncommitted admin rolls back on close, so a re-exec'd old
|
|
# child would find no admin and regenerate + serve a fresh default credential;
|
|
# stripping cannot stop a regeneration. The gate must fail closed.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
# Deliberately NO _seed_auth(): the gate seeds the fresh DB itself, then commit fails.
|
|
real_connect = studio_mod._connect_auth_db
|
|
monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingCommitConn(real_connect()))
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# The half-written seed file is stripped, and no admin row was committed.
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
verify = sqlite3.connect(_auth_db(tmp_path))
|
|
try:
|
|
assert verify.execute("SELECT COUNT(*) FROM auth_user").fetchone()[0] == 0
|
|
finally:
|
|
verify.close()
|
|
|
|
|
|
def test_studio_default_missing_venv_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression: the venv/run.py launchability check must run BEFORE the headless
|
|
# gate strips .bootstrap_password. Otherwise a failed launch leaves the admin
|
|
# at must_change_password=1 with no password to log in (lockout until
|
|
# reset-password). With the venv missing, exit without stripping the file.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: None) # venv missing
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: None)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# The seeded file survives: launchability failed BEFORE the gate could strip it.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
# The gate never ran (no prompt, no strip).
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "not set up" in combined.lower()
|
|
|
|
|
|
def test_studio_default_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B): a public UI launch needs a built frontend dist -- the
|
|
# login page is the ONLY way to change the seeded password. Resolve it BEFORE
|
|
# the headless gate strips .bootstrap_password, so a missing dist aborts the
|
|
# launch without stripping (no lockout at must_change_password=1 with nothing
|
|
# left to log in with).
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
# Launcher present, but no built frontend dist.
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# The seeded file survives: the frontend check failed BEFORE the gate stripped it.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
# The gate never ran (no prompt, no strip, no exec).
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_studio_default_bad_frontend_path_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B / reviewer finding): a user-supplied --frontend that does
|
|
# not contain index.html must NOT bypass the servable-UI guard. Otherwise the
|
|
# headless gate strips .bootstrap_password and the child serves no login page
|
|
# -> lockout. Validate the path BEFORE the gate and abort without stripping.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
# Auto-resolution would find a dist, but the user forced an empty one (no
|
|
# index.html): the guard must reject it rather than trust it.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
empty_dir = tmp_path / "empty_frontend"
|
|
empty_dir.mkdir()
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(
|
|
app, ["--secure", "--frontend", str(empty_dir)], catch_exceptions = True
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "index.html" in combined.lower()
|
|
|
|
|
|
def test_studio_default_missing_frontend_loopback_cloudflare_still_launches(monkeypatch, tmp_path):
|
|
# The dist guard is scoped to public exposure only. A loopback --cloudflare
|
|
# (default host) does not tunnel, so a missing dist must NOT abort it -- the
|
|
# launch proceeds exactly as before.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--cloudflare"], catch_exceptions = True)
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], (events, result.output)
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend not built" not in combined.lower()
|
|
|
|
|
|
def test_studio_default_in_venv_broken_backend_exits_before_stripping_bootstrap(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (item B / reviewer finding): the in-venv (in-process) path skips
|
|
# the re-exec launcher check, so a headless public launch would seed + strip
|
|
# the seeded .bootstrap_password in the gate before _load_run_module() later
|
|
# fails on a broken/partial venv -> lockout. Validate the backend is
|
|
# importable BEFORE the strip and abort without stripping.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
# Pretend we are already inside the studio venv, with a broken backend.
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio"))
|
|
# A built dist is not present in a fresh clone. The missing-frontend gate
|
|
# runs first and has its own test below; stub it so this one reaches the
|
|
# backend check it is actually about.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
|
|
def _boom():
|
|
raise ImportError("cannot import backend run.py")
|
|
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", _boom)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events # gate never stripped/prompted
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "backend could not be loaded" in combined.lower()
|
|
|
|
|
|
def test_studio_default_in_venv_missing_frontend_exits_before_stripping_bootstrap(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (Codex): the in-venv (in-process) path validated the backend but
|
|
# not the frontend, so a headless public launch would strip the seeded
|
|
# password in the gate before run_server() aborted on a missing dist. Validate
|
|
# the servable frontend BEFORE the strip, same as the re-exec path.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_studio_default_secure_tunnel_unavailable_preserves_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (Codex): a headless --secure launch strips the only plaintext
|
|
# recovery credential before the child proves the tunnel can start. If
|
|
# cloudflared is provably unavailable no public URL comes up (loopback bind),
|
|
# so the strip must be skipped and the launch refused, preserving recovery.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
# cloudflared cannot be found or downloaded -> the --secure tunnel is dead.
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # preserved for recovery, NOT stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert "exec" not in [k for k, _ in events], events # never re-exec'd
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "cloudflared" in combined.lower()
|
|
|
|
|
|
def test_studio_default_wildcard_cloudflare_strips_even_if_tunnel_unavailable(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# The unavailable-tunnel skip is --secure-only: a wildcard --cloudflare binds
|
|
# 0.0.0.0 publicly regardless of the tunnel, so the seeded password must still
|
|
# be stripped even when cloudflared is unavailable.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["-H", "0.0.0.0", "--cloudflare"], catch_exceptions = True)
|
|
|
|
# Still strips (raw public bind) and re-execs.
|
|
assert not bootstrap_file.exists(), result.output
|
|
assert "exec" in [k for k, _ in events], events
|
|
|
|
|
|
def test_tunnel_probe_adds_backend_to_syspath(monkeypatch, tmp_path):
|
|
# Regression (Codex 3572165922): ensure_cloudflared -> _cache_path lazily
|
|
# imports utils.paths.storage_roots, which only resolves when studio/backend is
|
|
# on sys.path. From the outer CLI it is not, so the probe must add it or it
|
|
# false-reports "unavailable" and wrongly refuses --secure. Model that with a
|
|
# cloudflare_tunnel whose ensure_cloudflared resolves ONLY when backend is on
|
|
# sys.path.
|
|
studio_mod = _studio()
|
|
backend = tmp_path / "backend"
|
|
backend.mkdir()
|
|
(backend / "cloudflare_tunnel.py").write_text(
|
|
"import sys\n"
|
|
f"_BACKEND = {str(backend)!r}\n"
|
|
"def ensure_cloudflared():\n"
|
|
" # Resolvable (cached) ONLY when the backend dir is importable.\n"
|
|
" return '/fake/cloudflared' if _BACKEND in sys.path else None\n"
|
|
)
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: backend / "run.py")
|
|
assert str(backend) not in sys.path # precondition
|
|
|
|
result = studio_mod._tunnel_binary_confirmed_unavailable()
|
|
|
|
# ensure_cloudflared resolved (backend was on sys.path) -> available -> not
|
|
# "confirmed unavailable"; without the fix it would false-report True.
|
|
assert result is False
|
|
# The probe cleans up the sys.path entry it added.
|
|
assert str(backend) not in sys.path
|
|
|
|
|
|
def test_studio_default_query_failure_strips_bootstrap_file(monkeypatch, tmp_path):
|
|
# The DB opens and the admin is seeded + committed (so .bootstrap_password is
|
|
# on disk), but reading must_change_password back fails. Returning here would
|
|
# re-exec with the freshly seeded credential still on disk; strip it first.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
real_connect = studio_mod._connect_auth_db
|
|
monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingSelectConn(real_connect()))
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "removing the seeded bootstrap password" in combined.lower()
|
|
|
|
|
|
def test_studio_default_loopback_cloudflare_never_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--cloudflare"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "prompt" not in kinds, events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" not in combined
|
|
|
|
|
|
def test_studio_default_changed_password_never_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_studio_default_refusal_aborts_launch(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(
|
|
monkeypatch, tmp_path, interactive = True, scripted = KeyboardInterrupt()
|
|
)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_wildcard_cloudflare_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["-H", "0.0.0.0", "--cloudflare"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
|
|
|
|
# ── `unsloth studio run` ─────────────────────────────────────────────
|
|
|
|
|
|
def test_run_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["n_refresh"] == 0
|
|
|
|
|
|
def test_run_non_tty_warns_and_proceeds(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" in combined
|
|
|
|
|
|
def test_run_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path):
|
|
# Same mixed-version safety for the `unsloth studio run` re-exec path (which
|
|
# cannot fail-close an old child via a CLI flag): the seeded credential file
|
|
# is deleted before re-exec, the launch still proceeds, and the DB flag holds.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_run_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B / reviewer finding 4): `unsloth studio run` serves the
|
|
# same Unsloth UI and strips the seeded password on a headless public launch,
|
|
# so a missing frontend dist must abort BEFORE the strip -- the same lockout
|
|
# guard as `unsloth studio`, not just `studio run`'s model-load residual.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_run_reexec(monkeypatch, events)
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events # no strip, no exec
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_run_in_venv_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (Codex 3571888563): the in-venv `studio run` path validated only
|
|
# the backend, so a headless public launch would strip the seeded password
|
|
# before run_server() aborted on a missing dist. Validate the frontend first.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_run_reexec_forwards_resolved_frontend_on_public_launch(monkeypatch, tmp_path):
|
|
# Regression (Codex 3571888570): the run re-exec discarded the dist resolved
|
|
# by the pre-strip check and only forwarded a user-supplied --frontend. On a
|
|
# public launch it must forward the resolved dist so a shadowed child that
|
|
# cannot self-resolve one still serves it (no post-strip lockout).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False) # gate is a no-op -> straight to re-exec
|
|
|
|
# _install_run_reexec resolves _find_frontend_dist -> /fake/studio/frontend/dist.
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"]) # no user --frontend
|
|
|
|
exec_argv = [argv for kind, argv in events if kind == "exec"][0]
|
|
assert "--frontend" in exec_argv, exec_argv
|
|
# str(Path(...)), not the literal: Windows renders it with backslashes.
|
|
expected_dist = str(Path("/fake/studio/frontend/dist"))
|
|
assert exec_argv[exec_argv.index("--frontend") + 1] == expected_dist, exec_argv
|
|
|
|
|
|
def test_run_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path):
|
|
# Fresh STUDIO_HOME on the `run` re-exec path: the seeded admin must be
|
|
# committed before re-exec so an old console-script child does not regenerate
|
|
# and inject a fresh bootstrap credential.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
state = _auth_state(studio_mod)
|
|
assert state["must_change_password"] == 1
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_run_non_tty_api_only_fails_closed(monkeypatch, tmp_path):
|
|
# api-only serving never arms the bootstrap shutdown deadline, so a
|
|
# headless public launch with the default password has no safeguard at
|
|
# all: the CLI must refuse rather than promise a shutdown that never comes.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_run(monkeypatch, events, _BASE + ["--secure", "--api-only"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_non_tty_disabled_deadline_fails_closed(monkeypatch, tmp_path):
|
|
# UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables the deadline; headless +
|
|
# default password + public tunnel then has no protection -> refuse.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "0")
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
(None, True), # unset -> default 1h
|
|
("", True),
|
|
("garbage", True), # malformed must not remove protection
|
|
("3600", True),
|
|
("1", True),
|
|
("0", False),
|
|
("-5", False),
|
|
],
|
|
)
|
|
def test_bootstrap_deadline_active_mirrors_backend_parsing(monkeypatch, raw, expected):
|
|
studio_mod = _studio()
|
|
if raw is None:
|
|
monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False)
|
|
else:
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raw)
|
|
assert studio_mod._bootstrap_deadline_active() is expected
|
|
|
|
|
|
def _reset_password_cli(studio_mod):
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.reset_password)
|
|
return CliRunner().invoke(app, [], catch_exceptions = True)
|
|
|
|
|
|
def _password_works(studio_mod, candidate):
|
|
conn = studio_mod._connect_auth_db()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT password_salt, password_hash FROM auth_user WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
return studio_mod._pbkdf2_hex(candidate, row[0].encode("utf-8")) == row[1]
|
|
|
|
|
|
def _printed_password(result):
|
|
line = next(l for l in result.output.splitlines() if l.startswith("New password for"))
|
|
return line.split(": ", 1)[1].strip()
|
|
|
|
|
|
def test_reset_password_rotates_in_place_without_deleting_the_db(monkeypatch, tmp_path):
|
|
# The DB survives, so a running server keeps its admin row and the new password.
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
db_file = tmp_path / "auth" / "auth.db"
|
|
before = _auth_state(studio_mod)
|
|
|
|
result = _reset_password_cli(studio_mod)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert db_file.exists()
|
|
after = _auth_state(studio_mod)
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["jwt_secret"] != before["jwt_secret"]
|
|
assert _password_works(studio_mod, _printed_password(result))
|
|
|
|
|
|
def test_reset_password_waits_out_a_concurrent_writer(monkeypatch, tmp_path):
|
|
# The CLI now writes while the server does; without a busy_timeout this fails.
|
|
import threading
|
|
import time
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
released = threading.Event()
|
|
|
|
def hold_write_lock():
|
|
conn = sqlite3.connect(_auth_db(tmp_path))
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute(
|
|
"INSERT INTO refresh_tokens (token_hash, username, expires_at) "
|
|
"VALUES ('held', 'unsloth', '2099-01-01T00:00:00')"
|
|
)
|
|
time.sleep(0.5)
|
|
conn.rollback()
|
|
conn.close()
|
|
released.set()
|
|
|
|
holder = threading.Thread(target = hold_write_lock)
|
|
holder.start()
|
|
time.sleep(0.1)
|
|
result = _reset_password_cli(studio_mod)
|
|
holder.join()
|
|
|
|
assert released.is_set()
|
|
assert result.exit_code == 0, result.output
|
|
assert _password_works(studio_mod, _printed_password(result))
|
|
|
|
|
|
def test_reset_password_revokes_sessions_and_api_keys(monkeypatch, tmp_path):
|
|
# Deleting auth.db used to drop these implicitly.
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
conn = studio_mod._connect_auth_db()
|
|
conn.execute(
|
|
"INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at) "
|
|
"VALUES (?, 'sk-x', 'hash', 'k', '2026-01-01T00:00:00')",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
assert _reset_password_cli(studio_mod).exit_code == 0
|
|
|
|
conn = studio_mod._connect_auth_db()
|
|
try:
|
|
assert conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] == 0
|
|
assert conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] == 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_reset_password_leaves_the_account_ready_to_log_in(monkeypatch, tmp_path):
|
|
# must_change_password stays 0 on purpose: at 1 a running server injects its
|
|
# startup-cached (now wrong) bootstrap password into the login page.
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
|
|
assert _reset_password_cli(studio_mod).exit_code == 0
|
|
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
|
|
|
|
def test_reset_password_seeds_the_admin_when_no_db_exists(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
|
|
result = _reset_password_cli(studio_mod)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert _password_works(studio_mod, _printed_password(result))
|
|
|
|
|
|
def test_reset_password_reports_an_unwritable_auth_dir(monkeypatch, tmp_path):
|
|
# _connect_auth_db creates auth/ before it opens SQLite, so a read-only Unsloth
|
|
# home raises OSError, not sqlite3.Error.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
|
|
def _boom_mkdir(self, *a, **k):
|
|
raise PermissionError("read-only")
|
|
|
|
monkeypatch.setattr(pathlib.Path, "mkdir", _boom_mkdir)
|
|
|
|
result = _reset_password_cli(studio_mod)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert not isinstance(result.exception, OSError)
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "could not open the auth database" in combined.lower()
|
|
|
|
|
|
def test_reset_password_reports_an_unreadable_db(monkeypatch, tmp_path):
|
|
# Deleting a corrupt DB here would revive the bug: a running server would be
|
|
# left with no admin row, rejecting the correct password until restarted.
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / "auth.db").write_text("not a database")
|
|
|
|
result = _reset_password_cli(studio_mod)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert (auth_dir / "auth.db").exists()
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "could not open the auth database" in combined.lower()
|
|
|
|
|
|
def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch, tmp_path):
|
|
# After a CLI/interactive password change the seeded .bootstrap_password is
|
|
# deleted. If it cannot be unlinked but is still writable (locked file /
|
|
# read-only dir), it must be TRUNCATED so its stale plaintext cannot be
|
|
# re-seeded by generate_bootstrap_password() if auth.db is ever recreated. The
|
|
# change is already committed, so it must NOT roll back.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.read_text().strip()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
conn = studio_mod._connect_auth_db()
|
|
studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "fresh-new-pw-123")
|
|
conn.close()
|
|
|
|
# The change committed (must_change cleared) AND the locked file is truncated.
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
assert bootstrap_file.exists()
|
|
assert bootstrap_file.read_text() == ""
|
|
|
|
|
|
def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path):
|
|
# Fresh install: the CLI gate writes the password hash + JWT secret before
|
|
# the backend ever runs, so this path must apply the same 0700/0600 modes
|
|
# as backend storage.get_connection (sqlite3.connect creates 0644 files
|
|
# under a 022 umask).
|
|
import os as _os
|
|
import stat
|
|
|
|
if _os.name == "nt":
|
|
pytest.skip("POSIX permission bits")
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
conn = studio_mod._connect_auth_db()
|
|
conn.close()
|
|
auth_dir = tmp_path / "auth"
|
|
assert stat.S_IMODE(auth_dir.stat().st_mode) == 0o700
|
|
assert stat.S_IMODE((auth_dir / "auth.db").stat().st_mode) == 0o600
|
|
|
|
|
|
def test_write_auth_secret_terminates_the_file_with_a_newline(monkeypatch, tmp_path):
|
|
# Shared by .bootstrap_password and .desktop_secret; every reader strips.
|
|
studio_mod = _studio()
|
|
path = tmp_path / ".desktop_secret"
|
|
|
|
studio_mod._write_auth_secret(path, "desktop-abc123")
|
|
|
|
# Bytes: read_text would decode CRLF back to "\n" and hide a CR.
|
|
assert path.read_bytes() == b"desktop-abc123\n"
|
|
|
|
|
|
def test_seeded_bootstrap_file_ends_with_a_newline(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
|
|
raw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_bytes()
|
|
|
|
assert raw.endswith(b"\n") and not raw.endswith(b"\r\n")
|
|
|
|
conn = sqlite3.connect(_auth_db(tmp_path))
|
|
try:
|
|
salt, pwd_hash = conn.execute(
|
|
"SELECT password_salt, password_hash FROM auth_user WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
assert studio_mod._pbkdf2_hex(raw.decode("utf-8").strip(), salt.encode("utf-8")) == pwd_hash
|
|
|
|
|
|
# ── non-interactive --password / UNSLOTH_STUDIO_PASSWORD / stdin ──────
|
|
|
|
|
|
def _exec_argv(events):
|
|
return next(argv for kind, argv in events if kind == "exec")
|
|
|
|
|
|
def test_studio_default_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure", "--password", "cli-supplied-pw12"])
|
|
|
|
# No interactive prompt: --password applied in the parent, so the gate no-ops.
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["jwt_secret"] != before["jwt_secret"]
|
|
assert after["n_refresh"] == 0
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
# The secret never crosses to the child argv.
|
|
assert "--password" not in _exec_argv(events)
|
|
|
|
|
|
def test_studio_default_password_via_env_strips_child_env(monkeypatch, tmp_path):
|
|
import os
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12")
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
# Env var stripped so a re-exec'd child cannot re-read it.
|
|
assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ
|
|
|
|
|
|
def test_studio_default_password_via_stdin(monkeypatch, tmp_path):
|
|
# `--password -` reads one line from stdin. CliRunner owns stdin during
|
|
# invoke, so feed it via input= rather than patching sys.stdin.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
CliRunner().invoke(
|
|
app,
|
|
["--secure", "--password", "-"],
|
|
input = "stdin-supplied-pw12\n",
|
|
catch_exceptions = True,
|
|
)
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
|
|
|
|
def test_studio_default_password_too_short_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "short"])
|
|
|
|
assert result.exit_code == 1
|
|
assert [kind for kind, _ in events] == [] # never reached the gate / re-exec
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged
|
|
|
|
|
|
def test_studio_default_password_must_differ_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text().strip()
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", bootstrap_pw])
|
|
|
|
assert result.exit_code == 1
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged
|
|
|
|
|
|
def test_studio_default_password_already_set_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False) # a password is already set
|
|
|
|
result = _invoke_studio_default(
|
|
monkeypatch, events, ["--secure", "--password", "another-pw-12345"]
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert [kind for kind, _ in events] == []
|
|
|
|
|
|
def test_studio_default_password_before_subcommand_errors(monkeypatch, tmp_path):
|
|
# --password on `unsloth studio` (before a subcommand) is a plain-only option;
|
|
# like --secure/--cloudflare it must error, not be silently dropped.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
app = _typer.Typer()
|
|
app.add_typer(studio_mod.studio_app, name = "studio")
|
|
result = CliRunner().invoke(app, ["studio", "--password", "x", "run", "--model", "X"])
|
|
assert result.exit_code == 2
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "--password" in combined
|
|
|
|
|
|
def test_run_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure", "--password", "cli-supplied-pw12"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert "--password" not in _exec_argv(events)
|
|
|
|
|
|
def test_run_password_via_env_strips_child_env(monkeypatch, tmp_path):
|
|
# The `run` mirror must also strip UNSLOTH_STUDIO_PASSWORD before re-exec so a
|
|
# shadowed child cannot re-read the secret (parity with studio_default).
|
|
import os
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12")
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ
|
|
|
|
|
|
def test_studio_default_password_applies_on_headless_wildcard_no_tunnel(monkeypatch, tmp_path):
|
|
# The apply is scoped to "any launch", not just --secure/--cloudflare: a raw
|
|
# public wildcard bind (-H 0.0.0.0, no tunnel) must set the initial password
|
|
# before bind and re-exec, with the gate no-op'ing (must_change now 0).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(
|
|
monkeypatch, events, ["-H", "0.0.0.0", "--password", "headless-set-pw12"]
|
|
)
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert "--password" not in _exec_argv(events)
|