* add unsloth studio desktop app
* Fix review findings
- studio/src-tauri/tauri.conf.json: retarget updater to staging repo
(danielhanchen/unsloth-staging-2); switch to unslothai/unsloth on upstream merge.
- studio/src-tauri/linux/postremove.sh: drop the interactive read loop and the
/home/* iteration. Package maintainer scripts must stay non-interactive and
must not touch other users' data.
- studio/frontend/src/app/auth-guards.ts: honor tauriAutoAuth() boolean. Failed
auto-auth now redirects to /login; requireGuest/requirePasswordChangeFlow
only redirect to /chat when auth succeeds. The new early-return on failed
auth is intentional so the login / change-password flows remain reachable
when desktop auth is not yet established.
- studio/frontend/src/config/env.ts: keep fetched=false on health failure so
later calls retry instead of caching the client-side platform guess.
- studio/src-tauri/src/install.rs: pick the available system package manager
(apt-get, dnf, zypper, pacman); AppImage bundles run on non-Debian distros.
- studio/frontend/src/lib/open-link.ts + markdown-text/sources callers: return
boolean from openLink so callers only preventDefault on handled URLs; relative
hrefs now navigate natively.
- studio/frontend/src/features/settings/tabs/about-tab.tsx: fetch(apiUrl(...))
so the version request targets the backend port in desktop mode. The bare
/api/health predates the Tauri webview (blame: the earlier onboarding commit,
which ran with same-origin frontend/backend); in desktop mode the webview
origin is tauri://localhost so the bare path fails.
- install.ps1: gate the install_python_stack.py hotfix on a sentinel comment
instead of a content regex; append the sentinel after applying so reruns
are unambiguous.
- unsloth_cli/commands/studio.py _write_auth_secret: use the atomic mkstemp +
os.replace path on Windows too; chmod calls are wrapped in try/except OSError.
- studio/src-tauri/src/preflight.rs probe_existing_backends: fan out the health
probes concurrently; desktop-auth status still runs sequentially per candidate.
reqwest::Client is internally Arc-wrapped so the in-loop .clone() is a
refcount bump, not a deep clone; annotated inline.
- studio/src-tauri/src/preflight.rs run_cli_probe: wait() after kill() to reap
the child, matching probe_cli_capability.
- studio/src-tauri/src/process.rs + main.rs: add stop_backend_detached and use
it from the tray quit handler so the 5s graceful-wait does not block the
Tauri main loop. RunEvent::Exit keeps the synchronous safety-net call.
- studio/backend/main.py: drop the permissive localhost CORS regex in
api-only mode; the explicit allow_origins list is sufficient.
- .github/workflows/release-desktop.yml: drop max-parallel: 1 so platform
builds run in parallel, and lift releaseBody to an env var so the three
tauri-action invocations share one source of truth.
* Fix review findings (loop 2)
- studio/backend/auth/storage.py update_password: clear_desktop_secret()
alongside clear_bootstrap_password() so rotating the admin password
also revokes any previously provisioned .desktop_secret. Without this,
an old local desktop credential keeps minting fresh admin tokens via
/api/auth/desktop-login after a password rotation.
- studio/src-tauri/src/desktop_auth.rs provision_desktop_auth: wrap
cmd.output().await in tokio::time::timeout(30s). DESKTOP_AUTH_LOCK is
held across the whole desktop_auth flow, and previously a hanging
`unsloth studio provision-desktop-auth` subprocess would pin the lock
indefinitely and freeze every subsequent desktop_auth call.
* Add review tests
* Consolidate review tests
Merge review-added tests into the existing studio/backend/tests/test_desktop_auth.py
(the PR's authoritative desktop-auth test file). Drops three scaffolding files under
tests/python/ in favor of five focused tests next to the tests they extend:
- test_update_password_clears_desktop_secret (runtime)
- test_update_password_on_unknown_user_leaves_desktop_secret_intact (runtime)
- test_cli_provisioning_delegates_to_storage_create_desktop_secret (source-level)
- test_cli_connect_auth_db_reads_storage_db_path (source-level)
- test_desktop_auth_provision_has_bounded_timeout (Rust source-level)
* Revert auth-guards.ts Tauri branches to unconditional form
The review loop on PR 5144 introduced a regression: the isTauri branch of
requireAuth redirected to /login when tauriAutoAuth() returned false, and
requireGuest / requirePasswordChangeFlow silently fell through on the same
condition. The Tauri desktop app authenticates via a local auto-generated
secret; it must never surface /login or /change-password to the user. A
failed auto-auth should let the startup layer retry, not expose a password
form.
Restore the three Tauri branches to the author's original unconditional
form (requireAuth: return; requireGuest / requirePasswordChangeFlow: throw
redirect({to: '/chat'})). Keep the rest of the review fixes -- the
apiUrl() fetch wrapping, authRedirect helper, and fetchAuthStatus refactor
are all legitimate improvements and are preserved.
* Revert release-desktop.yml to author's version
The review loop's workflow-file tweaks (drop max-parallel: 1, lift releaseBody
to an env var) are cosmetic. OAuth tokens cannot push workflow-file changes,
and fine-grained PATs cannot honor maintainerCanModify on a third-party fork.
Reverting the workflow file to wasimysaid's version lets the push go through
without needing a classic PAT with both repo and workflow scopes.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
597 lines
18 KiB
Python
597 lines
18 KiB
Python
import importlib.util
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import secrets
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import jwt
|
|
import pytest
|
|
from fastapi import APIRouter, FastAPI
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from fastapi.testclient import TestClient
|
|
|
|
from auth import storage
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def isolated_auth_db(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
|
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
|
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
|
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
|
yield
|
|
|
|
|
|
def seed_user(*, must_change_password = False):
|
|
storage.create_initial_user(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
password = "human-password-123",
|
|
jwt_secret = secrets.token_urlsafe(64),
|
|
must_change_password = must_change_password,
|
|
)
|
|
|
|
|
|
def auth_client():
|
|
route_path = Path(__file__).resolve().parents[1] / "routes" / "auth.py"
|
|
spec = importlib.util.spec_from_file_location("_desktop_auth_route", route_path)
|
|
auth_route = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(auth_route)
|
|
|
|
app = FastAPI()
|
|
app.include_router(auth_route.router, prefix = "/api/auth")
|
|
return TestClient(app)
|
|
|
|
|
|
def data_recipe_jobs_module():
|
|
route_path = (
|
|
Path(__file__).resolve().parents[1] / "routes" / "data_recipe" / "jobs.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location(
|
|
"_desktop_data_recipe_jobs", route_path
|
|
)
|
|
jobs_route = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(jobs_route)
|
|
return jobs_route
|
|
|
|
|
|
def local_recipe():
|
|
return {
|
|
"model_providers": [{"name": "local", "is_local": True}],
|
|
"model_configs": [{"alias": "local-model", "provider": "local"}],
|
|
"columns": [{"column_type": "llm-text", "model_alias": "local-model"}],
|
|
}
|
|
|
|
|
|
def local_recipe_request(token):
|
|
return SimpleNamespace(
|
|
headers = {"authorization": f"Bearer {token}"},
|
|
app = SimpleNamespace(state = SimpleNamespace(server_port = 8888)),
|
|
scope = {},
|
|
base_url = "http://testserver/",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def loaded_local_model(monkeypatch):
|
|
inference_module = SimpleNamespace(
|
|
get_llama_cpp_backend = lambda: SimpleNamespace(is_loaded = True),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "routes.inference", inference_module)
|
|
|
|
|
|
def test_desktop_secret_round_trip_uses_real_admin_subject():
|
|
seed_user()
|
|
raw = storage.create_desktop_secret()
|
|
|
|
assert raw.startswith("desktop-")
|
|
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
assert storage.validate_desktop_secret(raw + "x") is None
|
|
|
|
|
|
def test_create_desktop_secret_rotates_old_secret():
|
|
seed_user()
|
|
old = storage.create_desktop_secret()
|
|
new = storage.create_desktop_secret()
|
|
|
|
assert old != new
|
|
assert storage.validate_desktop_secret(old) is None
|
|
assert storage.validate_desktop_secret(new) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
|
|
def test_clear_desktop_secret_invalidates_secret():
|
|
seed_user()
|
|
raw = storage.create_desktop_secret()
|
|
|
|
storage.clear_desktop_secret()
|
|
|
|
assert storage.validate_desktop_secret(raw) is None
|
|
|
|
|
|
def test_ensure_default_admin_does_not_recreate_bootstrap_for_existing_admin():
|
|
seed_user()
|
|
|
|
created = storage.ensure_default_admin()
|
|
|
|
assert created is False
|
|
assert not storage._BOOTSTRAP_PW_PATH.exists()
|
|
|
|
|
|
def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch):
|
|
created = storage.ensure_default_admin()
|
|
bootstrap_pw = storage._BOOTSTRAP_PW_PATH.read_text().strip()
|
|
|
|
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
|
created_again = storage.ensure_default_admin()
|
|
|
|
assert created is True
|
|
assert storage._BOOTSTRAP_PW_PATH.exists()
|
|
assert created_again is False
|
|
assert storage.get_bootstrap_password() == bootstrap_pw
|
|
|
|
|
|
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
|
|
seed_user()
|
|
storage._BOOTSTRAP_PW_PATH.write_text(" \n")
|
|
|
|
created = storage.ensure_default_admin()
|
|
|
|
assert created is False
|
|
assert storage._BOOTSTRAP_PW_PATH.read_text() == " \n"
|
|
assert storage.get_bootstrap_password() is None
|
|
|
|
|
|
def test_web_login_token_has_no_desktop_marker_and_keeps_password_gate():
|
|
seed_user(must_change_password = True)
|
|
client = auth_client()
|
|
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json = {
|
|
"username": storage.DEFAULT_ADMIN_USERNAME,
|
|
"password": "human-password-123",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["must_change_password"] is True
|
|
payload = jwt.decode(
|
|
body["access_token"],
|
|
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
|
algorithms = ["HS256"],
|
|
)
|
|
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
|
assert "desktop" not in payload
|
|
|
|
gated = client.post(
|
|
"/api/auth/api-keys",
|
|
headers = {"Authorization": f"Bearer {body['access_token']}"},
|
|
json = {"name": "web"},
|
|
)
|
|
assert gated.status_code == 403
|
|
|
|
|
|
def test_desktop_login_mints_admin_token_without_clearing_web_password_change():
|
|
seed_user(must_change_password = True)
|
|
raw = storage.create_desktop_secret()
|
|
client = auth_client()
|
|
|
|
response = client.post("/api/auth/desktop-login", json = {"secret": raw})
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["access_token"]
|
|
assert body["refresh_token"]
|
|
assert body["token_type"] == "bearer"
|
|
assert body["must_change_password"] is False
|
|
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
|
|
|
|
payload = jwt.decode(
|
|
body["access_token"],
|
|
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
|
algorithms = ["HS256"],
|
|
)
|
|
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
|
assert payload["desktop"] is True
|
|
|
|
|
|
def test_desktop_refresh_preserves_desktop_marker():
|
|
seed_user(must_change_password = True)
|
|
raw = storage.create_desktop_secret()
|
|
client = auth_client()
|
|
login_body = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()
|
|
|
|
response = client.post(
|
|
"/api/auth/refresh",
|
|
json = {"refresh_token": login_body["refresh_token"]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["must_change_password"] is False
|
|
payload = jwt.decode(
|
|
body["access_token"],
|
|
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
|
algorithms = ["HS256"],
|
|
)
|
|
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
|
assert payload["desktop"] is True
|
|
|
|
|
|
def test_desktop_session_uses_real_admin_identity_for_api_keys():
|
|
seed_user(must_change_password = True)
|
|
raw = storage.create_desktop_secret()
|
|
client = auth_client()
|
|
token = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()[
|
|
"access_token"
|
|
]
|
|
|
|
response = client.post(
|
|
"/api/auth/api-keys",
|
|
headers = {"Authorization": f"Bearer {token}"},
|
|
json = {"name": "desktop"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
rows = storage.list_api_keys(storage.DEFAULT_ADMIN_USERNAME)
|
|
assert [row["name"] for row in rows] == ["desktop"]
|
|
|
|
|
|
def test_local_recipe_token_preserves_desktop_marker(loaded_local_model):
|
|
from auth.authentication import create_access_token, get_current_subject
|
|
|
|
seed_user(must_change_password = True)
|
|
jobs_route = data_recipe_jobs_module()
|
|
incoming_token = create_access_token(
|
|
subject = storage.DEFAULT_ADMIN_USERNAME,
|
|
desktop = True,
|
|
)
|
|
recipe = local_recipe()
|
|
|
|
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
|
|
|
|
local_token = recipe["model_providers"][0]["api_key"]
|
|
payload = jwt.decode(
|
|
local_token,
|
|
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
|
algorithms = ["HS256"],
|
|
)
|
|
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
|
assert payload["desktop"] is True
|
|
credentials = HTTPAuthorizationCredentials(
|
|
scheme = "Bearer",
|
|
credentials = local_token,
|
|
)
|
|
assert (
|
|
asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
|
|
)
|
|
|
|
|
|
def test_local_recipe_token_keeps_web_marker_absent(loaded_local_model):
|
|
from auth.authentication import create_access_token
|
|
|
|
seed_user(must_change_password = False)
|
|
jobs_route = data_recipe_jobs_module()
|
|
incoming_token = create_access_token(subject = storage.DEFAULT_ADMIN_USERNAME)
|
|
recipe = local_recipe()
|
|
|
|
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
|
|
|
|
local_token = recipe["model_providers"][0]["api_key"]
|
|
payload = jwt.decode(
|
|
local_token,
|
|
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
|
algorithms = ["HS256"],
|
|
)
|
|
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
|
assert "desktop" not in payload
|
|
|
|
|
|
def test_desktop_login_rejects_invalid_secret():
|
|
seed_user(must_change_password = False)
|
|
client = auth_client()
|
|
|
|
response = client.post(
|
|
"/api/auth/desktop-login",
|
|
json = {"secret": "desktop-invalid"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
|
|
from unsloth_cli.commands import studio as studio_cli
|
|
|
|
path = tmp_path / ".desktop_secret"
|
|
if platform.system() != "Windows":
|
|
path.write_text("old-secret")
|
|
os.chmod(path, 0o644)
|
|
|
|
studio_cli._write_auth_secret(path, "desktop-secret")
|
|
|
|
assert path.read_text() == "desktop-secret"
|
|
if platform.system() != "Windows":
|
|
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
|
|
|
|
|
def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
from unsloth_cli.commands import studio as studio_cli
|
|
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / "auth.db").write_text("db")
|
|
(auth_dir / ".bootstrap_password").write_text("boot")
|
|
(auth_dir / ".desktop_secret").write_text("new")
|
|
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
|
|
|
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not (auth_dir / "auth.db").exists()
|
|
assert not (auth_dir / ".bootstrap_password").exists()
|
|
assert not (auth_dir / ".desktop_secret").exists()
|
|
|
|
|
|
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
from unsloth_cli.commands import studio as studio_cli
|
|
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / ".desktop_secret").write_text("new")
|
|
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
|
|
|
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not (auth_dir / ".desktop_secret").exists()
|
|
|
|
|
|
def test_desktop_capabilities_json_reports_rollout_safe_flags():
|
|
from typer.testing import CliRunner
|
|
import unsloth_cli.commands.studio as studio_cli
|
|
|
|
result = CliRunner().invoke(
|
|
studio_cli.studio_app,
|
|
["desktop-capabilities", "--json"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
body = json.loads(result.output)
|
|
assert body["desktop_protocol_version"] == 1
|
|
assert body["supports_provision_desktop_auth"] is True
|
|
assert body["supports_api_only"] is True
|
|
assert isinstance(body["version"], str)
|
|
|
|
|
|
def test_health_response_reports_desktop_capability_fields(monkeypatch):
|
|
router_stub = SimpleNamespace(
|
|
auth_router = APIRouter(),
|
|
data_recipe_router = APIRouter(),
|
|
datasets_router = APIRouter(),
|
|
export_router = APIRouter(),
|
|
inference_router = APIRouter(),
|
|
models_router = APIRouter(),
|
|
training_history_router = APIRouter(),
|
|
training_router = APIRouter(),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "routes", router_stub)
|
|
|
|
import studio.backend.main as backend_main
|
|
|
|
monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False)
|
|
|
|
body = asyncio.run(backend_main.health_check())
|
|
|
|
assert body["desktop_protocol_version"] == 1
|
|
assert body["supports_desktop_auth"] is True
|
|
|
|
|
|
def test_provision_desktop_auth_writes_secret_and_creates_db_without_backend_deps(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
|
|
code = """
|
|
import builtins
|
|
import sys
|
|
from pathlib import Path
|
|
from typer.testing import CliRunner
|
|
|
|
studio_home = Path(sys.argv[1])
|
|
real_import = builtins.__import__
|
|
|
|
def guarded_import(name, *args, **kwargs):
|
|
blocked = ("auth", "fastapi", "structlog", "utils")
|
|
if name in blocked or name.startswith(("auth.", "utils.")):
|
|
raise ModuleNotFoundError(name)
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
builtins.__import__ = guarded_import
|
|
from unsloth_cli.commands import studio as studio_cli
|
|
|
|
studio_cli.STUDIO_HOME = studio_home
|
|
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
|
|
if result.exit_code != 0:
|
|
print(result.output)
|
|
if result.exception is not None:
|
|
raise result.exception
|
|
raise SystemExit(result.exit_code)
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code, str(tmp_path)],
|
|
cwd = Path(__file__).resolve().parents[3],
|
|
env = {**os.environ, "PYTHONPATH": "."},
|
|
text = True,
|
|
capture_output = True,
|
|
)
|
|
assert result.returncode == 0, result.stderr + result.stdout
|
|
secret = (auth_dir / ".desktop_secret").read_text()
|
|
assert secret.startswith("desktop-")
|
|
|
|
conn = sqlite3.connect(auth_dir / "auth.db")
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
user = conn.execute(
|
|
"""
|
|
SELECT username, password_salt, password_hash, must_change_password
|
|
FROM auth_user
|
|
"""
|
|
).fetchone()
|
|
app_secrets = {
|
|
row["key"]: row["value"]
|
|
for row in conn.execute("SELECT key, value FROM app_secrets")
|
|
}
|
|
refresh_columns = {
|
|
row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
bootstrap_password = (auth_dir / ".bootstrap_password").read_text().strip()
|
|
bootstrap_hash = hashlib.pbkdf2_hmac(
|
|
"sha256",
|
|
bootstrap_password.encode("utf-8"),
|
|
user["password_salt"].encode("utf-8"),
|
|
100_000,
|
|
).hex()
|
|
|
|
assert bootstrap_password
|
|
assert user["username"] == "unsloth"
|
|
assert user["must_change_password"] == 1
|
|
assert bootstrap_hash == user["password_hash"]
|
|
assert len(app_secrets["api_key_pbkdf2_salt"]) == 64
|
|
assert len(app_secrets["desktop_secret_hash"]) == 64
|
|
assert app_secrets["desktop_secret_created_at"]
|
|
assert "is_desktop" in refresh_columns
|
|
|
|
monkeypatch.setattr(storage, "DB_PATH", auth_dir / "auth.db")
|
|
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
|
assert storage.validate_desktop_secret(secret) == storage.DEFAULT_ADMIN_USERNAME
|
|
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
|
|
|
|
|
|
def test_provision_desktop_auth_keeps_existing_admin_password(tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
from unsloth_cli.commands import studio as studio_cli
|
|
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
|
|
|
conn = sqlite3.connect(auth_dir / "auth.db")
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE auth_user (
|
|
id INTEGER PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_salt TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
jwt_secret TEXT NOT NULL,
|
|
must_change_password INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO auth_user (
|
|
username, password_salt, password_hash, jwt_secret, must_change_password
|
|
)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
("unsloth", "existing-salt", "existing-hash", "existing-jwt", 0),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not (auth_dir / ".bootstrap_password").exists()
|
|
conn = sqlite3.connect(auth_dir / "auth.db")
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
user = conn.execute(
|
|
"""
|
|
SELECT password_salt, password_hash, jwt_secret, must_change_password
|
|
FROM auth_user WHERE username = ?
|
|
""",
|
|
("unsloth",),
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
assert dict(user) == {
|
|
"password_salt": "existing-salt",
|
|
"password_hash": "existing-hash",
|
|
"jwt_secret": "existing-jwt",
|
|
"must_change_password": 0,
|
|
}
|
|
|
|
|
|
def test_update_password_clears_desktop_secret():
|
|
seed_user()
|
|
raw = storage.create_desktop_secret()
|
|
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
changed = storage.update_password(
|
|
storage.DEFAULT_ADMIN_USERNAME, "new-admin-password"
|
|
)
|
|
assert changed is True
|
|
assert storage.validate_desktop_secret(raw) is None
|
|
|
|
|
|
def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
|
|
seed_user()
|
|
raw = storage.create_desktop_secret()
|
|
|
|
changed = storage.update_password("not-a-user", "irrelevant")
|
|
assert changed is False
|
|
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
|
|
|
|
|
def test_desktop_auth_provision_has_bounded_timeout():
|
|
rs_path = (
|
|
Path(__file__).resolve().parents[3]
|
|
/ "studio"
|
|
/ "src-tauri"
|
|
/ "src"
|
|
/ "desktop_auth.rs"
|
|
)
|
|
src = rs_path.read_text()
|
|
start = src.index("async fn provision_desktop_auth(")
|
|
depth = 0
|
|
body_start = src.index("{", start)
|
|
body_end = None
|
|
for i in range(body_start, len(src)):
|
|
c = src[i]
|
|
if c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
body_end = i + 1
|
|
break
|
|
assert body_end is not None
|
|
body = src[start:body_end]
|
|
assert "tokio::time::timeout" in body
|
|
import re
|
|
|
|
m = re.search(r"Duration::from_secs\(\s*(\d+)\s*\)", body)
|
|
assert m is not None
|
|
seconds = int(m.group(1))
|
|
assert 5 <= seconds <= 120
|