feat: configure agents with durable user sessions and scoped access
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user