from __future__ import annotations
import http.client
import json
from pathlib import Path
import pytest
from adam.config import ConfigManager
from adam.remote_access import RemoteAccessService
from adam.remote_api import RemoteApiError, bounded_float
@pytest.fixture
def remote(tmp_path):
config = ConfigManager(tmp_path)
service = RemoteAccessService(config, None, None)
service.save_settings({'enabled': True})
# Let the OS allocate a port, avoiding a bind/close/rebind race in the test.
config.settings['remote_access']['port'] = 0
service.start()
try:
yield service
finally:
service.shutdown()
def request(service, path='/api/status', *, method='GET', body=None, headers=None, token=None):
connection = http.client.HTTPConnection('127.0.0.1', service._server.server_port, timeout=3)
auth = token if token is not None else service.settings()['token']
fields = {'Authorization': f'Bearer {auth}'}
fields.update(headers or {})
try:
connection.request(method, path, body=body, headers=fields)
response = connection.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
connection.close()
def test_token_rotation_and_disable_take_effect_without_restart(remote):
old = remote.settings()['token']
assert request(remote, token=old)[0] == 200
remote.save_settings({'token': 'replacement-token'})
assert request(remote, token=old)[0] == 401
assert request(remote)[0] == 200
remote.save_settings({'enabled': False})
assert request(remote)[0] == 401
def test_missing_token_is_persisted_once(tmp_path):
config = ConfigManager(tmp_path)
config.update({'remote_access': {'enabled': False}})
service = RemoteAccessService(config, None, None)
try:
first = service.settings()['token']
assert service.settings()['token'] == first
assert ConfigManager(tmp_path).get('remote_access')['token'] == first
finally:
service.shutdown()
def test_remote_cannot_grant_its_own_control(remote):
payload = json.dumps({'auto_approve_training': True})
args = dict(method='POST', body=payload, headers={'Content-Type': 'application/json'})
assert request(remote, '/api/remote-settings', **args)[0] == 403
assert remote.settings()['auto_approve_training'] is False
remote.save_settings({'allow_job_control': True})
assert request(remote, '/api/remote-settings', **args)[0] == 200
remote.save_settings({'allow_job_control': False})
args['body'] = json.dumps({'auto_approve_training': False})
assert request(remote, '/api/remote-settings', **args)[0] == 200
@pytest.mark.parametrize('headers,body,status', [
({'Content-Type': 'application/json', 'Origin': 'https://attacker.invalid'}, '{}', 403),
({'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site'}, '{}', 403),
({'Content-Type': 'text/plain'}, '{}', 415),
({'Content-Type': 'application/json', 'Content-Length': '-1'}, '', 400),
({'Content-Type': 'application/json', 'Content-Length': '20001'}, '', 413),
({'Content-Type': 'application/json'}, '[]', 400),
({'Content-Type': 'application/json'}, '{', 400),
({'Content-Type': 'application/json'}, '{"auto_approve_training":"false"}', 400),
])
def test_rejects_unsafe_requests_before_mutation(remote, headers, body, status):
assert request(remote, '/api/remote-settings', method='POST', body=body, headers=headers)[0] == status
assert remote.settings()['auto_approve_training'] is False
def test_browser_security_headers(remote):
status, headers, _ = request(remote, '/')
assert status == 200
assert headers['Referrer-Policy'] == 'no-referrer'
assert headers['X-Frame-Options'] == 'DENY'
assert "frame-ancestors 'none'" in headers['Content-Security-Policy']
@pytest.mark.parametrize('value', ['nan', 'inf', '-inf', float('nan')])
def test_nonfinite_remote_settings_are_rejected(value):
with pytest.raises(RemoteApiError):
bounded_float(value, minimum=0, maximum=10, default=1, label='Guidance')
def test_dashboard_treats_remote_labels_as_text(tmp_path):
import shutil
import subprocess
from adam.remote_dashboard import remote_dashboard_app_html
node = shutil.which('node')
if not node:
pytest.skip('Node is needed for the dashboard JavaScript regression check')
html = remote_dashboard_app_html()
script = html.split('', 1)[0]
names = ['$', 'list', 'clear', 'text', 'appendText', 'renderQueues', 'renderSystem', 'renderLocations', 'makeSetting', 'fieldId']
functions = '\n'.join(line for line in script.splitlines() if any(line.startswith('function ' + name + '(') for name in names))
harness = r'''
const assert = require('assert');
class Element {
constructor(tag) { this.tagName=tag; this.children=[]; this.textContent=''; }
set innerHTML(value) { throw new Error('Untrusted text reached HTML parsing'); }
appendChild(child) { this.children.push(child); }
get firstChild() { return this.children[0]; }
removeChild(child) { this.children.splice(this.children.indexOf(child),1); }
cloneNode() { return this; }
setAttribute() {}
}
const elements={};
const document={createElement:tag=>new Element(tag),getElementById:id=>elements[id]||(elements[id]=new Element('div'))};
const attack='
';
var state={locationFilter:'',locations:[{id:'1',name:attack,source:attack,available:true}]};
'''
checks = r'''
renderQueues({queue:[{project:attack,status:attack,progress:0}]});
assert.equal(elements.queues.children[0].children[0].textContent,attack);
renderLocations();
assert.equal(elements.locationsList.children[0].children[0].textContent,attack);
makeSetting('preview',{type:'bool',label:attack},false);
renderSystem({cpu_percent:attack});
assert.equal(globalThis.compromised,undefined);
'''
# Parse the complete shipped script too, not just the rendering functions.
path = tmp_path / 'dashboard-test.js'
path.write_text('new Function(' + json.dumps(script) + ');\n' + harness + functions + checks, encoding='utf-8')
result = subprocess.run([node, str(path)], capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stderr