feat: configure agents with durable user sessions and scoped access

This commit is contained in:
HumbleTurtle
2026-09-10 03:29:48 -03:00
parent e8c6a5109e
commit 6c29a318e8
15 changed files with 1306 additions and 410 deletions
+9
View File
@@ -0,0 +1,9 @@
"""All tests use disposable user credential state, including legacy test scripts."""
import pytest
@pytest.fixture(autouse=True)
def isolated_user_credentials(monkeypatch, tmp_path):
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data"))
for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN"):
monkeypatch.delenv(name, raising=False)
+127
View File
@@ -0,0 +1,127 @@
"""Public worker/CLI entry points against synthetic binary HTTP storage."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import uuid
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, parse_qs
import pytest
from test_user_credentials import worker, outcome, SCRIPT
from napseer_credentials import CredentialStore, atomic_json
PROJECT="11111111-1111-4111-8111-111111111111"
NODE="22222222-2222-4222-8222-222222222222"
@pytest.fixture
def storage(tmp_path):
state={"files":{},"corrupt":False,"keys":[],"projects":[]}
node={"id":NODE,"name":"Guide","full_path":"/services/guide","type":"documentation","updated_at":"2026-09-09T00:00:00Z","content_text":"Recovery procedure","metadata":{},"links":[]}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def reply(self,status,value,binary=False):
body=value if binary else json.dumps(value).encode()
self.send_response(status);self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
def do_GET(self):
path=urlsplit(self.path).path
if path=='/v1/projects':return self.reply(200,{'items':state['projects']})
if path=='/v1/api-keys':return self.reply(200,{'items':state['keys']})
if path.endswith('/nodes'): return self.reply(200,{"items":[node]})
if path.endswith('/nodes/'+NODE):return self.reply(200,node)
if path.endswith('/files'): return self.reply(200,{"items":[entry[0] for entry in state["files"].values()]})
identity=path.split('/')[-2] if path.endswith('/metadata') else path.split('/')[-1]
entry=state["files"].get(identity)
if not entry:return self.reply(404,{"error":{"code":"not_found"}})
if path.endswith('/metadata'):return self.reply(200,entry[0])
return self.reply(200,b"corrupted" if state["corrupt"] else entry[1],True)
def do_POST(self):
if self.path=='/v1/projects':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
project={**payload,'id':str(uuid.uuid4()),'encryption_state':'standard'}
state['projects'].append(project);return self.reply(200,project)
if self.path=='/v1/api-keys':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
key={**payload,'id':str(uuid.uuid4())};state['keys'].append(key)
return self.reply(200,{**key,'api_key':'npk_'+uuid.uuid4().hex})
query=parse_qs(urlsplit(self.path).query)
body=self.rfile.read(int(self.headers['Content-Length']))
identity=query['id'][0]
info={"id":identity,"name":query['name'][0],"node_id":query.get('node_id',[None])[0],"size_bytes":len(body),"sha256":hashlib.sha256(body).hexdigest()}
state['files'][identity]=(info,body)
return self.reply(200,info)
def do_DELETE(self):
identity=urlsplit(self.path).path.split('/')[-1];state['files'].pop(identity,None)
return self.reply(200,{"archived":True})
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
store=CredentialStore(tmp_path/'.napseer')
store.login({"base_url":f"http://127.0.0.1:{server.server_port}","token":"synthetic-access","account_mode":"operator_account"})
atomic_json(store.selection,{"project_id":PROJECT})
try:yield state
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_agent_upload_retries_download_and_export_preserve_binary_and_references(tmp_path,storage):
body=bytes([0,255,128,13,10,65]);source=tmp_path/'proof.bin';source.write_bytes(body)
args={"local_path":str(source),"node_id":NODE}
first=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert first['ok'],first.get('code')
identity=first['result']['id']
again=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert again['result']['id']==identity
assert len(storage['files'])==1
destination=tmp_path/'download.bin'
result=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(destination)!r}}})"))
assert result['ok'];assert destination.read_bytes()==body
rejected=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(source)!r}}})"))
assert rejected['code']=='destination_unavailable';assert source.read_bytes()==body
target=tmp_path/'export.zip'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
exported=subprocess.run([sys.executable,str(SCRIPT),'export','--path',str(target)],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert exported.returncode==0
assert json.loads(exported.stdout)['status']=='exported'
with zipfile.ZipFile(target) as archive:
manifest=json.loads(archive.read('manifest.json'))
assert manifest['files'][0]['node_id']==NODE
assert archive.read('files/'+identity)==body
assert json.loads(archive.read('nodes/'+NODE+'.json'))['content_text']=='Recovery procedure'
assert not any('auth' in name for name in archive.namelist())
storage['corrupt']=True
rejected=outcome(worker(tmp_path,f"m.export_project({{'local_path':{str(tmp_path/'broken.zip')!r}}})"))
assert rejected['code']=='file_integrity_failed'
assert not (tmp_path/'broken.zip').exists()
assert not list(tmp_path.glob('.napseer-export-*'))
assert outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_archive',{{'id':{identity!r}}})"))['ok']
assert not storage['files']
def test_cli_key_issuance_writes_private_credential_without_printing_it(tmp_path,storage):
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
created=subprocess.run([sys.executable,str(SCRIPT),'auth','keys','create','--project-id',PROJECT,'--name','Test delegate'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert created.returncode==0
assert 'npk_' not in created.stdout
result=json.loads(created.stdout);path=Path(result['credential_file'])
assert path.stat().st_mode & 0o777 == 0o600
assert json.loads(path.read_text())['token'].startswith('npk_')
assert not (tmp_path/'.napseer/auth.json').exists()
def test_two_agents_bootstrap_one_project_with_general_login(tmp_path,storage):
store=CredentialStore(tmp_path/'.napseer');store.selection.unlink()
first=worker(tmp_path,"m.resolve_project_id({})")
second=worker(tmp_path,"m.resolve_project_id({})")
one,two=outcome(first),outcome(second)
assert one['ok'] and two['ok']
assert one['result']==two['result']
assert len(storage['projects'])==1
assert json.loads(store.user.read_text())['account_mode']=='operator_account'
assert 'project_id' not in json.loads(store.user.read_text())
assert not store.legacy.exists()
assert (tmp_path/'.napseer/project.json').exists()
+27 -83
View File
@@ -29,37 +29,18 @@ def anonymous_auth(mod):
}
def test_initial_enrollment_persists_refresh_credential():
def test_unconfigured_agent_requires_user_login_without_enrollment():
mod = load_module()
mod.TOKEN = None
mod.AUTH = anonymous_auth(mod)
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = None
saved = []
requests = []
mod.save_auth = lambda payload: saved.append(dict(payload))
def request(method, path, payload=None, token_required=False):
requests.append((method, path, payload, token_required))
return {
"token": {
"access_token": "access",
"refresh_token": "refresh",
"expires_at": "2099-01-01T00:00:00Z",
"refresh_expires_at": "",
},
"worker": {"id": "worker", "agent_id": "agent-id"},
}
mod.request_json = request
mod.ensure_enrolled({"slug": "example"})
assert saved[0]["refresh_token"] == "refresh"
assert "refresh_expires_at" in saved[0]
assert [(method, path) for method, path, _, _ in requests] == [
("POST", "/v1/enrollment/token")
]
assert requests[0][3] is False
mod.request_json = lambda *args, **kwargs: requests.append(args)
try:
mod.ensure_enrolled({"slug": "example"})
except mod.SafeToolError as exc:
assert exc.code == "auth_required"
else:
raise AssertionError("unconfigured agents must not enroll")
assert not requests
def test_failed_anonymous_refresh_uses_ssh_recovery():
@@ -107,65 +88,28 @@ def test_failed_anonymous_refresh_uses_ssh_recovery():
assert saved[0]["refresh_token"] == "recovered-refresh"
def test_failed_mixed_state_refresh_uses_ssh_recovery():
def test_oauth_failure_never_falls_back_to_anonymous_identity():
mod = load_module()
mod.AUTH = {
**anonymous_auth(mod),
"account_mode": "operator_project",
"oauth_client_id": "nap-cli",
"project_id": "project",
"ssh_private_key": "test-private-key",
"ssh_public_key": "ssh-ed25519 test-public-key",
}
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = "project"
mod.REFRESH_TOKEN = "stale-refresh"
mod.REFRESH_EXPIRES_AT = None
mod.AUTH = {**anonymous_auth(mod), "account_mode": "operator_project"}
mod.TOKEN = "expired-access"
mod.REFRESH_TOKEN = "revoked-refresh"
mod.refresh_public_auth_state = lambda: mod.AUTH
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key"
mod.sign_with_auth_key = lambda *args: "test-signature"
saved = []
mod.save_auth = lambda payload: saved.append(dict(payload))
def request_form(method, path, payload=None, token_required=False):
assert (method, path, token_required) == ("POST", "/v1/oauth/token", False)
raise RuntimeError("HTTP 401")
def request(method, path, payload=None, token_required=False):
if path == "/v1/enrollment/refresh":
raise RuntimeError("HTTP 401")
if path == "/v1/enrollment/challenges":
return {"challenge_id": "challenge", "challenge_text": "sign-me"}
if path == "/v1/enrollment/verify":
return {
"token": {
"access_token": "recovered-access",
"refresh_token": "recovered-refresh",
"expires_at": "2099-01-01T00:00:00Z",
"refresh_expires_at": "2099-12-31T00:00:00Z",
},
"worker": {
"id": "worker",
"agent_id": "agent-id",
"name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"project_id": "project",
},
}
raise AssertionError(path)
mod.request_form_json = request_form
mod.request_json = request
result = mod.renew_auth()
assert result["method"] == "anonymous_ssh_recovery"
assert result["project_id"] == "project"
assert saved[0]["refresh_token"] == "recovered-refresh"
calls = []
def rejected(*args, **kwargs):
raise mod.SafeToolError("http_401", "Login rejected", status=401)
mod.request_form_json = rejected
mod.request_json = lambda *args, **kwargs: calls.append(args)
try:
mod.renew_auth()
except mod.SafeToolError as exc:
assert exc.code == "auth_required"
else:
raise AssertionError("OAuth revocation must require user login")
assert not calls
if __name__ == "__main__":
test_initial_enrollment_persists_refresh_credential()
test_unconfigured_agent_requires_user_login_without_enrollment()
test_failed_anonymous_refresh_uses_ssh_recovery()
test_failed_mixed_state_refresh_uses_ssh_recovery()
test_oauth_failure_never_falls_back_to_anonymous_identity()
print("ok: anonymous auth recovery tests passed")
+36 -8
View File
@@ -3,6 +3,7 @@ import hashlib
import json
import pathlib
import time
import sys
import pytest
@@ -15,6 +16,8 @@ SCRIPT_PATH = (
def load_installer():
if str(SCRIPT_PATH.parent) not in sys.path:
sys.path.insert(0, str(SCRIPT_PATH.parent))
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -33,7 +36,7 @@ def test_root_help_exposes_only_canonical_commands(capsys):
output = capsys.readouterr().out
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
assert len(visible) == 11
assert len(visible) == 13
for name in visible:
assert f" {name}" in output
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
@@ -436,7 +439,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.2"
assert result["cli_distribution"]["release_version"] == module.CLI_RELEASE_VERSION
assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
@@ -493,10 +496,10 @@ def test_manifest_accepts_a_future_compatible_release():
module = load_installer()
manifest, _payloads = fake_bundle(module)
manifest["release_version"] = "0.2.2"
manifest["contract"]["current"] = "2026-08-02"
manifest["contract"]["current"] = "2026-09-11"
for item in manifest["items"]:
item["version"] = "0.2.2"
item["contract_version"] = "2026-08-02"
item["contract_version"] = "2026-09-11"
items = module.validated_manifest_items(manifest)
@@ -506,11 +509,11 @@ def test_manifest_accepts_a_future_compatible_release():
def test_manifest_rejects_a_minimum_contract_newer_than_installer():
module = load_installer()
manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-08-02"
manifest["contract"]["minimum_supported"] = "2026-08-02"
manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-09-11"
for item in manifest["items"]:
item["contract_version"] = "2026-08-02"
item["minimum_contract_version"] = "2026-08-02"
item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-09-11"
try:
module.validated_manifest_items(manifest)
@@ -520,6 +523,31 @@ def test_manifest_rejects_a_minimum_contract_newer_than_installer():
raise AssertionError("incompatible minimum contract must be rejected")
def test_legacy_update_preserves_active_runtime_and_bootstrap_recovers(tmp_path, monkeypatch):
module = load_installer()
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
manifest, payloads = fake_bundle(module)
previous = module.INSTALL_DIR / "releases" / "previous"
previous.mkdir(parents=True)
(previous / "nap_install.py").write_text("# existing release\n")
(module.INSTALL_DIR / "current").symlink_to(previous, target_is_directory=True)
credentials = module.INSTALL_DIR / "auth.json"
credentials.write_text('{"refresh_token":"synthetic-preserved"}')
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
current_contract = module.CLI_DISTRIBUTION_CONTRACT_VERSION
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", "2026-08-01")
with pytest.raises(RuntimeError, match="newer bootstrap installer"):
module.main(["nap", "update"])
assert (module.INSTALL_DIR / "current").resolve() == previous
assert list((module.INSTALL_DIR / "releases").iterdir()) == [previous]
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", current_contract)
assert module.main(["nap_install.py", "mcp", "install"]) == 0
assert (module.INSTALL_DIR / "current" / "napseer_credentials.py").is_file()
assert credentials.read_text() == '{"refresh_token":"synthetic-preserved"}'
def test_cli_log_compaction_preserves_live_writer_inode(tmp_path):
module = load_installer()
log_path = tmp_path / "gateway.log"
+2 -2
View File
@@ -55,7 +55,7 @@ def run():
mod.DEFAULT_PROJECT_ID = None
assert mod.resolve_project_id({}) == "project-1"
current = {}
current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = "stale-project"
bootstrap_calls = []
@@ -75,7 +75,7 @@ def run():
}
]
current = {}
current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = None
def limited_project(_args):
+1 -1
View File
@@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 52
assert len(tool_by_name) == 58
assert len(tool_by_name) == len(mod.tools())
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
+2 -1
View File
@@ -32,9 +32,10 @@ def mod(monkeypatch):
return module
def test_default_surface_is_exactly_the_core_fifteen(mod):
def test_default_surface_includes_knowledge_files_and_coordination(mod):
assert [tool["name"] for tool in mod.tools()] == [
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
"nap_mv", "nap_rm",
+4 -2
View File
@@ -2,6 +2,7 @@
"""Smoke-test existing-project OAuth attachment for folder bootstrap."""
import importlib.util
import json
import pathlib
import sys
import tempfile
@@ -59,12 +60,12 @@ def run():
result = mod.operator_project_attach({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["token"] == "oauth-token"
assert saves[0]["refresh_token"] == "oauth-refresh-token"
assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project"
assert replacements[0] == mod.WORKER_BINDING_AUTH_KEYS
assert "project_id" not in replacements[0]
assert mod.WORKER_BINDING_AUTH_KEYS.isdisjoint(saves[0])
assert "napseer.projects.read" in saves[0]["oauth_scope"]
assert saves[1]["project_slug"] == "existing-project"
assert result["project_id"] == PROJECT_ID
@@ -81,6 +82,7 @@ def run():
"scope": args["scope"],
}
result = mod.operator_account_login({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["refresh_token"] == "account-refresh-token"
assert saves[0]["account_mode"] == "operator_account"
assert "project_id" not in saves[0]
+5 -3
View File
@@ -59,6 +59,7 @@ def test_account_login_requests_create_and_runtime_scopes():
result = mod.operator_account_login({"open_browser": False})
saves.append(json.loads(pathlib.Path(result["auth_path"]).read_text()))
requested = set(captured[0]["scope"].split())
assert "napseer.projects.write" in requested
assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested)
@@ -84,7 +85,7 @@ def test_legacy_account_scope_fails_before_http_with_recovery():
assert not requests
def test_successful_create_transitions_local_state_to_project_mode():
def test_successful_create_preserves_general_account_mode():
mod = load_module()
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
saves = []
@@ -102,7 +103,8 @@ def test_successful_create_transitions_local_state_to_project_mode():
assert status == "created"
assert project["id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project"
assert "account_mode" not in saves[0]
assert mod.AUTH["account_mode"] == "operator_account"
assert locators == [project]
@@ -162,7 +164,7 @@ def test_anonymous_limits_are_actionable_without_exposing_response_details():
if __name__ == "__main__":
test_account_login_requests_create_and_runtime_scopes()
test_legacy_account_scope_fails_before_http_with_recovery()
test_successful_create_transitions_local_state_to_project_mode()
test_successful_create_preserves_general_account_mode()
test_server_scope_denial_is_actionable()
test_anonymous_limits_are_actionable_without_exposing_response_details()
print("ok: project create OAuth lifecycle passed")
+2 -1
View File
@@ -169,7 +169,8 @@ def test_attach_forwards_and_verifies_repository_project_hint():
assert oauth_calls[0]["project_id_hint"] == PROJECT_ID
assert result["project_id"] == PROJECT_ID
assert result["project_locator"]["project_id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID
credentials = json.loads(pathlib.Path(result["auth_path"]).read_text())
assert credentials["project_id"] == PROJECT_ID
if __name__ == "__main__":
+284
View File
@@ -0,0 +1,284 @@
"""Real workers and synthetic HTTP provider: user sessions across repositories."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
sys.path.insert(0, str(SCRIPT.parent))
from napseer_credentials import CredentialStore, atomic_json, read_json
@pytest.fixture
def provider():
state = {"refreshes": 0, "requests": 0, "failure": None, "revoked": False}
mutex = threading.Lock()
state["refresh_started"] = threading.Event()
state["refresh_release"] = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def reply(self, status, value):
data = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
with mutex:
state["requests"] += 1
if self.headers.get("Authorization") == "Bearer fresh-access" and not state["revoked"]:
self.reply(200, {"ok": True})
else:
self.reply(401, {"error": {"code": "unauthorized"}})
def do_POST(self):
form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
if self.path == "/v1/oauth/revoke":
state["revoked"] = True
return self.reply(200, {})
assert self.path == "/v1/oauth/token", "no enrollment fallback is allowed"
if state["failure"] == "transport":
return self.reply(503, {"error": {"code": "unavailable"}})
if state["failure"] == "malformed":
return self.reply(200, {"access_token": "incomplete"})
if form.get("refresh_token") != ["old-refresh"] or state["refreshes"]:
return self.reply(401, {"error": {"code": "invalid_grant"}})
if state.get("hold_refresh"):
state["refresh_started"].set()
assert state["refresh_release"].wait(5)
with mutex:
state["refreshes"] += 1
self.reply(200, {"access_token": "fresh-access", "refresh_token": "fresh-refresh",
"expires_in": 3600, "refresh_expires_at": "2099-01-01T00:00:00Z"})
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def seed(root, url):
root.mkdir(exist_ok=True)
store = CredentialStore(root / ".napseer")
store.login({"base_url": url, "token": "old-access", "refresh_token": "old-refresh",
"account_mode": "operator_account", "oauth_client_id": "nap-cli"})
return store
def worker(root, expression):
code = "import importlib.util, json, sys\n" + (
"spec = importlib.util.spec_from_file_location('worker', sys.argv[1])\n"
"m = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(m)\n"
"try:\n result = " + expression + "\n print(json.dumps({'ok': True, 'result': result}))\n"
"except Exception as exc:\n print(json.dumps({'ok': False, 'code': getattr(exc, 'code', type(exc).__name__)}))\n"
)
env = dict(os.environ, NAPSEER_PROJECT_ROOT=str(root), NAPSEER_TELEMETRY="0")
for key in ("NAPSEER_AUTH_FILE", "NAPSEER_BASE_URL", "NAPSEER_PROJECT_ID"):
env.pop(key, None)
return subprocess.Popen([sys.executable, "-c", code, str(SCRIPT)], cwd=root, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def outcome(process):
out, _err = process.communicate(timeout=15)
assert process.returncode == 0, "worker must exit without exposing credential state"
return json.loads(out)
def test_shared_login_refreshes_once_across_real_processes_and_survives_restart(tmp_path, provider):
url, state = provider
first, second = tmp_path / "first", tmp_path / "second"
store = seed(first, url)
second.mkdir()
processes = [worker(root, "m.request_json('GET', '/v1/projects')") for root in [first, second]]
assert all(outcome(p)["ok"] for p in processes)
assert state["refreshes"] == 1
assert outcome(worker(second, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 1
assert store.user.stat().st_mode & 0o777 == 0o600
assert not (first / ".napseer/auth.json").exists()
assert not (second / ".napseer/auth.json").exists()
@pytest.mark.parametrize("failure", ["transport", "malformed"])
def test_refresh_failure_preserves_credentials_and_recovers(tmp_path, provider, failure):
url, state = provider
store = seed(tmp_path, url)
before = store.user.read_bytes()
state["failure"] = failure
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert store.user.read_bytes() == before
state["failure"] = None
assert outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
def test_project_override_does_not_fall_back_to_user_authority(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
store.login({"base_url": url, "token": "npk_expired", "account_mode": "api_key"}, project=True)
result = outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))
assert not result["ok"]
assert state["refreshes"] == 0
assert read_json(store.user)["token"] == "old-access"
store.logout(project=True)
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 0
def test_unconfigured_agent_never_enrolls_or_creates_project(tmp_path):
result = outcome(worker(tmp_path, "m.resolve_project_id({})"))
assert result == {"ok": False, "code": "auth_required"}
assert not (tmp_path / ".napseer").exists()
def test_logout_revokes_and_next_worker_observes_tombstone(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
assert outcome(worker(tmp_path, "m.operator_logout({})"))["ok"]
assert state["revoked"]
assert read_json(store.user)["logged_out"]
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["requests"] == 0
def test_global_credentials_keep_repository_selections_independent(tmp_path, provider):
url, _ = provider
first = tmp_path / "first"
store = seed(first, url)
other = CredentialStore(tmp_path / "second/.napseer")
store.write({**store.read(), "project_id": "first"})
other.write({**other.read(), "project_id": "second"})
assert "project_id" not in read_json(store.user)
assert store.read()["project_id"] == "first"
assert other.read()["project_id"] == "second"
def test_logout_waits_for_inflight_rotation_and_cannot_be_undone(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
logout=worker(tmp_path,"m.operator_logout({})")
state["refresh_release"].set()
outcome(refresh)
assert outcome(logout)["ok"]
assert state["revoked"]
assert read_json(store.user).get("logged_out")
assert not outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))["ok"]
def test_refresh_pins_credential_source_when_override_appears(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
override={"base_url":url,"token":"npk_project_test","account_mode":"api_key"}
store.login(override,project=True)
state["refresh_release"].set()
assert not outcome(refresh)["ok"], "retry observes the explicit override"
assert read_json(store.project)==override
assert read_json(store.user)["token"]=="fresh-access"
def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(tmp_path,provider):
url,_=provider
store=seed(tmp_path,url)
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
atomic_json(store.legacy,legacy)
assert store.selected()[1]=="legacy_project"
store.migrate()
assert not store.legacy.exists()
assert store.read()["token"]=="legacy-project"
store.use_user()
assert store.read()["token"]=="old-access"
assert read_json(store.project)==legacy
def test_failed_login_initialization_releases_actual_loopback_socket(tmp_path,monkeypatch):
import socket
spec=importlib.util.spec_from_file_location("cleanup_worker",SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
reserved=socket.socket();reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1];reserved.close()
monkeypatch.setattr(module,"request_json",lambda *a,**k: (_ for _ in ()).throw(RuntimeError("synthetic unavailable")))
with pytest.raises(RuntimeError):
module.oauth_loopback_authorize({"scope":"test","flow":"operator_login","port":port,"open_browser":False})
probe=socket.socket()
try: probe.bind(("127.0.0.1",port))
finally: probe.close()
def test_native_login_uses_requested_service_pkce_callback_and_shared_store(tmp_path,monkeypatch):
import base64
import hashlib
import urllib.request
from urllib.parse import urlsplit, urlencode
state={}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def do_POST(self):
raw=self.rfile.read(int(self.headers['Content-Length']))
if self.path.endswith('/authorizations'):
state['authorization']=json.loads(raw)
payload={"authorization_url":"http://synthetic-browser.invalid/consent"}
else:
form=parse_qs(raw.decode());state['exchanged']=True
expected=base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).decode().rstrip('=')
assert expected==state['authorization']['code_challenge']
assert form['code']==['synthetic-one-time-code']
payload={"access_token":"synthetic-native-access","refresh_token":"synthetic-native-refresh","expires_in":3600}
body=json.dumps(payload).encode();self.send_response(200);self.end_headers();self.wfile.write(body)
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
spec=importlib.util.spec_from_file_location('native_login_worker',SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
monkeypatch.setattr(module,'AUTH_DIR',tmp_path/'.napseer')
monkeypatch.setattr(module,'AUTH_PATH',tmp_path/'.napseer/auth.json')
monkeypatch.setattr(module,'BASE_URL','http://127.0.0.1:1')
store = CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
def consent(_url):
grant=state['authorization']
callback=grant['redirect_uri']+'?'+urlencode({'code':'synthetic-one-time-code','state':grant['state']})
with urllib.request.urlopen(callback,timeout=3) as response: assert response.status==200
return True
monkeypatch.setattr(module.webbrowser,'open',consent)
try:
result=module.operator_account_login({'base_url':f'http://127.0.0.1:{server.server_port}','timeout_seconds':5})
assert result['status']=='authenticated';assert state['exchanged']
store=CredentialStore(tmp_path/'.napseer')
assert store.user.exists();assert not store.legacy.exists()
assert read_json(store.user)['base_url']==f'http://127.0.0.1:{server.server_port}'
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_corrupt_credentials_allow_help_but_never_authenticated_fallback(tmp_path):
store=CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
result=outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))
assert result['code']=='CredentialError'
assert store.user.read_text()=='{broken synthetic state'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path))
help_result=subprocess.run([sys.executable,str(SCRIPT),'auth','--help'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=10)
assert help_result.returncode==0
assert 'nap auth login' in help_result.stdout