Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@ def ping(*args):
# just above) so "Web Research" can create a real node - this ordering
# (canvas_document exists before register_plugins runs) is load-bearing.
register_plugins(bus, notifications_state, bus.canvas_document)
register_settings(bus, settings_manager)
# R7.4a: register_settings now takes notifications_state too, so the
# API-provider page's save-validation/init-failure paths can surface a
# real banner (same load-bearing ordering precedent as register_plugins/
# register_chat_library above - notifications_state already exists by
# this point in every case).
register_settings(bus, settings_manager, notifications_state)
# R6.4: register_chat_library needs the same session's canvas_document
# (built above) so loadChat can actually restore a session into it, and
# notifications_state so a failed/empty load can surface a real banner -
Expand Down
307 changes: 297 additions & 10 deletions backend/settings.py

Large diffs are not rendered by default.

131 changes: 131 additions & 0 deletions backend/tests/test_backend_api_key_env_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Tests for environment-variable API key fallback in api_provider.initialize_api().

Ported from graphlink_app/tests/test_api_key_env_fallback.py (Qt-removal
plan R7.4a) - pure logic against api_provider.py/graphlink_task_config.py,
both confirmed Qt-free repo-root survivor modules; only the import (the
renamed graphlink_task_config, not the legacy graphlink_config shim) and
the removed graphlink_app-relative sys.path insert changed. Named
test_backend_* (not test_api_key_env_fallback.py) because the legacy file
of that exact name still exists in graphlink_app/tests/ until the R7.6
cutover - two same-basename modules under different, __init__.py-less
test directories collide in pytest's default import mode, the same
collision test_backend_composer.py was already named to avoid.

Regression coverage for the OpenAI env-var gap: Anthropic and Gemini both fall back to a
GRAPHLINK_<PROVIDER>_API_KEY / vendor-standard env var when no key is passed in, but the
OpenAI-compatible branch didn't - a user with OPENAI_API_KEY set in their environment (a
very standard thing to have) but nothing saved in Graphlink's own Settings would get
"API key not configured" instead of it just working, unlike every other provider.
"""

from unittest.mock import MagicMock, patch

import api_provider
import graphlink_task_config as config


def _reset_api_provider_state(monkeypatch):
monkeypatch.setattr(api_provider, "USE_API_MODE", False)
monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", None)
monkeypatch.setattr(api_provider, "API_CLIENT", None)
monkeypatch.setattr(api_provider, "API_KEY", None)
monkeypatch.setattr(api_provider, "API_BASE_URL", None)


class TestOpenAiApiKeyEnvFallback:
def test_falls_back_to_graphlink_prefixed_env_var(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-graphlink-env")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1")

fake_openai_cls.assert_called_once_with(api_key="from-graphlink-env", base_url="https://api.example.com/v1")

def test_falls_back_to_vendor_standard_env_var(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "from-vendor-env")
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1")

fake_openai_cls.assert_called_once_with(api_key="from-vendor-env", base_url="https://api.example.com/v1")

def test_explicitly_passed_key_wins_over_env_vars(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-env")
monkeypatch.setenv("OPENAI_API_KEY", "from-env-2")
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "from-settings", "https://api.example.com/v1")

fake_openai_cls.assert_called_once_with(api_key="from-settings", base_url="https://api.example.com/v1")

def test_still_raises_when_no_key_anywhere_and_base_url_is_remote(self, monkeypatch):
import pytest

_reset_api_provider_state(monkeypatch)
monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

with pytest.raises(RuntimeError, match="API key not configured"):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1")

def test_local_base_url_still_uses_dummy_key_when_nothing_configured(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "http://localhost:11434/v1")

fake_openai_cls.assert_called_once_with(api_key="dummy-key-for-local", base_url="http://localhost:11434/v1")


class TestLegacyGraphiteEnvVarStillWorks:
"""The app was renamed from Graphite to Graphlink; GRAPHITE_*_API_KEY env vars set
before the rename must keep working so existing power-user shell configs don't
silently break. GRAPHLINK_* always takes priority when both are set."""

def test_openai_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False)
monkeypatch.setenv("GRAPHITE_OPENAI_API_KEY", "from-legacy-graphite-env")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1")

fake_openai_cls.assert_called_once_with(api_key="from-legacy-graphite-env", base_url="https://api.example.com/v1")

def test_new_prefixed_env_var_wins_over_legacy_one(self, monkeypatch):
_reset_api_provider_state(monkeypatch)
monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-new-env")
monkeypatch.setenv("GRAPHITE_OPENAI_API_KEY", "from-legacy-env")
fake_openai_cls = MagicMock()

with patch("openai.OpenAI", fake_openai_cls):
api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1")

fake_openai_cls.assert_called_once_with(api_key="from-new-env", base_url="https://api.example.com/v1")

def test_anthropic_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch):
monkeypatch.delenv("GRAPHLINK_ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("GRAPHITE_ANTHROPIC_API_KEY", "legacy-ant-key")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

assert api_provider._get_anthropic_api_key() == "legacy-ant-key"

def test_gemini_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch):
monkeypatch.delenv("GRAPHLINK_GEMINI_API_KEY", raising=False)
monkeypatch.setenv("GRAPHITE_GEMINI_API_KEY", "legacy-gem-key")
monkeypatch.delenv("GEMINI_API_KEY", raising=False)

assert api_provider._get_gemini_api_key() == "legacy-gem-key"
199 changes: 199 additions & 0 deletions backend/tests/test_backend_secrets_at_rest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Tests for secrets-at-rest encryption (graphlink_secrets + SettingsManager wiring).

Ported from graphlink_app/tests/test_secrets_at_rest.py (Qt-removal plan
R7.4a) - 100% Qt-free already (confirmed by import grep: only json, pathlib,
graphlink_secrets, graphlink_licensing.SettingsManager), it just needed a
new home now that the R7.4a API-provider settings page makes this the
active, exercised secrets path rather than a deferred one. Named
test_backend_* (not test_secrets_at_rest.py) because the legacy file of
that exact name still exists in graphlink_app/tests/ until the R7.6
cutover - two same-basename modules under different, __init__.py-less
test directories collide in pytest's default import mode, the same
collision test_backend_composer.py was already named to avoid.

Regression coverage for secrets stored in plaintext: API keys and the GitHub
token were stored as plaintext JSON in session.dat. They are now
DPAPI-protected ("dpapi:" + base64 blob, bound to the Windows user account)
with three hard requirements pinned down here:

1. Roundtrip: what you set is what you get back, but the on-disk bytes never
contain the plaintext.
2. Legacy migration: a pre-existing plaintext session.dat is silently
upgraded on the first load - the plaintext leaves disk immediately, and
getters still return it.
3. Graceful degradation: when DPAPI is unavailable (non-Windows), everything
behaves exactly as before this change - plaintext in, plaintext out, no
crash, no rewrite loop. Simulated by stubbing the internal _dpapi_call to
fail.

These tests run on real DPAPI (Windows dev machines and the windows-latest
CI runner).
"""

import json

import graphlink_secrets
from graphlink_licensing import SettingsManager


class TestProtectUnprotectPrimitives:
def test_roundtrip(self):
protected = graphlink_secrets.protect("sk-super-secret")
assert graphlink_secrets.unprotect(protected) == "sk-super-secret"

def test_protected_form_is_prefixed_and_not_the_plaintext(self):
protected = graphlink_secrets.protect("sk-super-secret")
assert protected.startswith("dpapi:")
assert "sk-super-secret" not in protected

def test_protect_is_idempotent(self):
once = graphlink_secrets.protect("sk-super-secret")
assert graphlink_secrets.protect(once) == once

def test_legacy_plaintext_passes_through_unprotect_unchanged(self):
assert graphlink_secrets.unprotect("sk-legacy-plaintext") == "sk-legacy-plaintext"

def test_empty_values_stay_empty(self):
assert graphlink_secrets.protect("") == ""
assert graphlink_secrets.unprotect("") == ""

def test_undecryptable_blob_returns_empty_not_garbage(self):
# e.g. a session.dat copied from another user account/machine - the app must
# see "not configured", never hand corrupt bytes to a provider client.
assert graphlink_secrets.unprotect("dpapi:AAAA") == ""
assert graphlink_secrets.unprotect("dpapi:!!!not-base64!!!") == ""

def test_unicode_secrets_roundtrip(self):
secret = "pässwörd-秘密-🔑"
assert graphlink_secrets.unprotect(graphlink_secrets.protect(secret)) == secret

def test_plaintext_secret_that_starts_with_the_prefix_is_still_encrypted(self):
# Adversarial-review finding: the "dpapi:" prefix is in-band signaling. A
# plaintext secret that itself begins with "dpapi:" (e.g. a proxy master key a
# user types into the API settings dialog) must NOT be mistaken for an already-
# encrypted blob - otherwise it would be stored as plaintext and read back as "".
secret = "dpapi:my-actual-secret"
protected = graphlink_secrets.protect(secret)

assert protected != secret # it was actually encrypted, not passed through
assert graphlink_secrets.unprotect(protected) == secret # ...and round-trips

def test_prefixed_plaintext_with_base64_valid_suffix_is_still_encrypted(self):
# Harder variant: the suffix is valid base64 ("AAAA" -> 3 bytes) but not a real
# DPAPI blob, so it must not be treated as already-encrypted either.
secret = "dpapi:AAAA"
protected = graphlink_secrets.protect(secret)

assert graphlink_secrets.unprotect(protected) == secret


class TestSettingsManagerStoresSecretsEncrypted:
def test_set_api_settings_leaves_no_plaintext_on_disk(self, tmp_path):
state_file = tmp_path / "session.dat"
manager = SettingsManager(state_file)

manager.set_api_settings(
"OpenAI-Compatible", "https://api.openai.com/v1",
"sk-openai-secret", "sk-ant-secret", "AIza-gemini-secret",
)

raw = state_file.read_text(encoding="utf-8")
assert "sk-openai-secret" not in raw
assert "sk-ant-secret" not in raw
assert "AIza-gemini-secret" not in raw
assert manager.get_openai_key() == "sk-openai-secret"
assert manager.get_anthropic_key() == "sk-ant-secret"
assert manager.get_gemini_key() == "AIza-gemini-secret"

def test_set_github_token_leaves_no_plaintext_on_disk(self, tmp_path):
state_file = tmp_path / "session.dat"
manager = SettingsManager(state_file)

manager.set_github_token("ghp_super_secret_token")

raw = state_file.read_text(encoding="utf-8")
assert "ghp_super_secret_token" not in raw
assert manager.get_github_token() == "ghp_super_secret_token"

def test_secrets_survive_a_reload_from_disk(self, tmp_path):
state_file = tmp_path / "session.dat"
SettingsManager(state_file).set_github_token("ghp_reload_me")

reloaded = SettingsManager(state_file)

assert reloaded.get_github_token() == "ghp_reload_me"

def test_reset_api_settings_still_clears_keys(self, tmp_path):
manager = SettingsManager(tmp_path / "session.dat")
manager.set_api_settings("OpenAI-Compatible", "url", "k1", "k2", "k3")

manager.reset_api_settings()

assert manager.get_openai_key() == ""
assert manager.get_anthropic_key() == ""
assert manager.get_gemini_key() == ""


class TestLegacyPlaintextMigration:
def _write_legacy_state(self, state_file, **secrets):
state = {"theme": "dark"}
state.update(secrets)
state_file.write_text(json.dumps(state), encoding="utf-8")

def test_plaintext_secrets_are_encrypted_on_first_load(self, tmp_path):
state_file = tmp_path / "session.dat"
self._write_legacy_state(
state_file,
openai_api_key="sk-legacy-openai",
github_access_token="ghp_legacy",
)

manager = SettingsManager(state_file)

# Getters return the plaintext, but the file no longer contains it.
assert manager.get_openai_key() == "sk-legacy-openai"
assert manager.get_github_token() == "ghp_legacy"
raw = state_file.read_text(encoding="utf-8")
assert "sk-legacy-openai" not in raw
assert "ghp_legacy" not in raw
assert json.loads(raw)["openai_api_key"].startswith("dpapi:")

def test_migration_does_not_rewrite_when_nothing_needs_migrating(self, tmp_path):
state_file = tmp_path / "session.dat"
manager = SettingsManager(state_file) # fresh defaults, all secrets empty
mtime_after_first_load = state_file.stat().st_mtime_ns

SettingsManager(state_file) # second load - nothing to migrate

assert state_file.stat().st_mtime_ns == mtime_after_first_load

def test_already_encrypted_secrets_are_not_double_wrapped(self, tmp_path):
state_file = tmp_path / "session.dat"
SettingsManager(state_file).set_github_token("ghp_once")
stored_once = json.loads(state_file.read_text(encoding="utf-8"))["github_access_token"]

SettingsManager(state_file) # reload triggers the migration pass again

stored_twice = json.loads(state_file.read_text(encoding="utf-8"))["github_access_token"]
assert stored_twice == stored_once


class TestGracefulDegradationWithoutDpapi:
def test_everything_behaves_like_before_when_dpapi_is_unavailable(self, tmp_path, monkeypatch):
# Simulate a non-Windows platform / DPAPI failure: protect() falls back to
# plaintext, unprotect() passes plaintext through, migration rewrites nothing.
monkeypatch.setattr(graphlink_secrets, "_dpapi_call", lambda data, encrypt: None)

state_file = tmp_path / "session.dat"
manager = SettingsManager(state_file)
manager.set_github_token("ghp_plain_fallback")

assert manager.get_github_token() == "ghp_plain_fallback"
raw = json.loads(state_file.read_text(encoding="utf-8"))
assert raw["github_access_token"] == "ghp_plain_fallback" # plaintext, as before

# Reload with DPAPI still unavailable: no crash, no rewrite loop, same value.
mtime_before = state_file.stat().st_mtime_ns
reloaded = SettingsManager(state_file)
assert reloaded.get_github_token() == "ghp_plain_fallback"
assert state_file.stat().st_mtime_ns == mtime_before
Loading
Loading