Files
ServerKit/backend/tests/test_server_timeline.py

382 lines
13 KiB
Python

"""Plan 81 M4: server timeline merge, authorization, and cursor contracts."""
import base64
from datetime import datetime, timedelta
import json
from factories import headers_for, make_application, make_server, make_user
def _restore_point(db, *, server_id, scope_type, scope_id, created_at,
actor_user_id=None, label=None):
from app.models.restore_point import RestorePoint
point = RestorePoint(
server_id=server_id,
scope_type=scope_type,
scope_id=str(scope_id),
trigger='manual',
label=label,
payload_hash='a' * 64,
payload_json='{}',
coverage_json='["outside"]',
actor_user_id=actor_user_id,
keep=True,
created_at=created_at,
updated_at=created_at,
)
db.session.add(point)
db.session.commit()
return point
def _snapshot(db, application, created_at, *, summary='config changed'):
from app.models.deployment_snapshot import DeploymentSnapshot
snapshot = DeploymentSnapshot(
application_id=application.id,
snapshot_hash='b' * 64,
config_json='{}',
summary=summary,
created_at=created_at,
)
db.session.add(snapshot)
db.session.commit()
return snapshot
def _audit(db, *, action, created_at, target_type=None, target_id=None,
details=None, user_id=None, ip_address='203.0.113.1',
user_agent='secret-agent'):
from app.models.audit_log import AuditLog
row = AuditLog(
action=action,
target_type=target_type,
target_id=target_id,
user_id=user_id,
created_at=created_at,
ip_address=ip_address,
user_agent=user_agent,
)
row.set_details(details or {})
db.session.add(row)
db.session.commit()
return row
def test_timeline_requires_developer_and_returns_typed_missing_server(
client, db_session):
server = make_server(db_session)
viewer = make_user(db_session, role='viewer')
developer = make_user(db_session, role='developer')
url = f'/api/v1/servers/{server.id}/timeline'
assert client.get(url).status_code == 401
assert client.get(url, headers=headers_for(viewer)).status_code == 403
missing = '00000000-0000-0000-0000-000000000000'
response = client.get(
f'/api/v1/servers/{missing}/timeline',
headers=headers_for(developer),
)
assert response.status_code == 404
assert response.get_json()['code'] == 'server_timeline_server_not_found'
def test_timeline_global_order_ties_and_cursor_have_no_duplicates(
client, db_session):
server = make_server(db_session)
owner = make_user(db_session, role='developer')
application = make_application(
db_session, user_id=owner.id, server_id=server.id,
)
when = datetime(2026, 8, 23, 12, 0, 0, 123456)
older = when - timedelta(seconds=1)
audit_low = _audit(
db_session, action='server.low', created_at=when,
target_type='server', target_id=server.id,
)
audit_high = _audit(
db_session, action='server.high', created_at=when,
details={'server_id': server.id},
)
point = _restore_point(
db_session, server_id=server.id, scope_type='cron', scope_id='root',
created_at=when, actor_user_id=owner.id,
)
snapshot = _snapshot(db_session, application, when)
old_audit = _audit(
db_session, action='server.old', created_at=older,
details={'server_id': server.id},
)
headers = headers_for(owner)
# limit=1 deliberately splits equal-time rows from the same source across
# pages, exercising the native-id component of the keyset cursor.
url = f'/api/v1/servers/{server.id}/timeline?limit=1'
seen = []
cursor = None
while True:
page_url = url + (f'&before={cursor}' if cursor else '')
response = client.get(page_url, headers=headers)
assert response.status_code == 200, response.get_json()
body = response.get_json()
seen.extend((event['type'], event['source_id']) for event in body['events'])
cursor = body['next_cursor']
if cursor is None:
break
assert seen == [
('audit', audit_high.id),
('audit', audit_low.id),
('restore_point', point.id),
('deployment_snapshot', snapshot.id),
('audit', old_audit.id),
]
assert len(seen) == len(set(seen))
def test_timeline_type_filter_and_snapshot_attribution(client, db_session):
server = make_server(db_session)
owner = make_user(db_session, role='developer')
application = make_application(
db_session, user_id=owner.id, server_id=server.id, name='timeline-app',
)
snapshot = _snapshot(
db_session, application, datetime(2026, 8, 23, 11),
summary='image updated',
)
_audit(
db_session, action='server.other', created_at=datetime(2026, 8, 23, 12),
details={'server_id': server.id},
)
response = client.get(
f'/api/v1/servers/{server.id}/timeline?types=deployment_snapshot',
headers=headers_for(owner),
)
assert response.status_code == 200
assert response.get_json() == {
'events': [{
'id': f'deployment_snapshot:{snapshot.id}',
'type': 'deployment_snapshot',
'source_id': snapshot.id,
'created_at': '2026-08-23T11:00:00.000000',
'action': 'deployment.snapshot',
'actor_user_id': None,
'application_id': application.id,
'application_name': 'timeline-app',
'deployment_id': None,
'snapshot_hash': 'b' * 64,
'summary': 'image updated',
}],
'next_cursor': None,
}
def test_app_owned_events_are_grant_filtered_before_pagination(
client, db_session):
from app.services.resource_grant_service import ResourceGrantService
server = make_server(db_session)
caller = make_user(db_session, role='developer')
other = make_user(db_session, role='developer')
visible = make_application(
db_session, user_id=other.id, server_id=server.id, name='shared',
)
hidden = make_application(
db_session, user_id=other.id, server_id=server.id, name='hidden',
)
ResourceGrantService.grant(
caller.id, 'application', visible.id, role='viewer',
)
when = datetime(2026, 8, 23, 12)
visible_snapshot = _snapshot(db_session, visible, when)
_snapshot(db_session, hidden, when + timedelta(seconds=3))
visible_point = _restore_point(
db_session, server_id=server.id, scope_type='env', scope_id=visible.id,
created_at=when - timedelta(seconds=1),
)
_restore_point(
db_session, server_id=server.id, scope_type='env', scope_id=hidden.id,
created_at=when + timedelta(seconds=2),
)
visible_audit = _audit(
db_session, action='app.update', created_at=when - timedelta(seconds=2),
target_type='app', target_id=visible.id,
)
_audit(
db_session, action='app.secret', created_at=when + timedelta(seconds=1),
target_type='app', target_id=hidden.id,
details={'server_id': server.id},
)
response = client.get(
f'/api/v1/servers/{server.id}/timeline?limit=3',
headers=headers_for(caller),
)
assert response.status_code == 200
rows = response.get_json()['events']
assert [(row['type'], row['source_id']) for row in rows] == [
('deployment_snapshot', visible_snapshot.id),
('restore_point', visible_point.id),
('audit', visible_audit.id),
]
assert response.get_json()['next_cursor'] is None
def test_legacy_env_fallback_and_explicit_server_history(client, db_session):
target = make_server(db_session, name='target')
other_server = make_server(db_session, name='other')
owner = make_user(db_session, role='developer')
current = make_application(
db_session, user_id=owner.id, server_id=target.id, name='current',
)
moved = make_application(
db_session, user_id=owner.id, server_id=other_server.id, name='moved',
)
legacy = _restore_point(
db_session, server_id=None, scope_type='env', scope_id=current.id,
created_at=datetime(2026, 8, 23, 10), label='legacy current',
)
explicit_history = _restore_point(
db_session, server_id=target.id, scope_type='env', scope_id=moved.id,
created_at=datetime(2026, 8, 23, 9), label='before app moved',
)
_restore_point(
db_session, server_id=None, scope_type='env', scope_id=moved.id,
created_at=datetime(2026, 8, 23, 11), label='belongs elsewhere',
)
response = client.get(
f'/api/v1/servers/{target.id}/timeline?types=restore_point',
headers=headers_for(owner),
)
assert response.status_code == 200
assert [row['source_id'] for row in response.get_json()['events']] == [
legacy.id, explicit_history.id,
]
def test_audit_exact_attribution_duplicate_suppression_and_safe_projection(
client, db_session):
server = make_server(db_session)
other = make_server(db_session)
developer = make_user(db_session, role='developer')
when = datetime(2026, 8, 23, 12)
direct = _audit(
db_session, action='server.direct', created_at=when,
target_type='server', target_id=server.id, user_id=developer.id,
details={'state': 'ready', 'password': 'must-not-appear'},
)
routed = _audit(
db_session, action='api.mutation', created_at=when - timedelta(seconds=1),
target_type='servers',
details={
'route_args': {'server_id': server.id},
'method': 'POST',
'payload': {'api_secret': 'must-not-appear'},
},
)
detailed = _audit(
db_session, action='server.detail', created_at=when - timedelta(seconds=2),
target_type='fleet_repair',
details={'server_id': server.id, 'kind': 'service', 'ip': 'hidden'},
)
_audit(
db_session, action='server.wrong', created_at=when + timedelta(seconds=1),
details={'server_id': other.id},
)
_audit(
db_session, action='restore_point.create', created_at=when + timedelta(seconds=2),
target_type='restore_point', details={'server_id': server.id},
)
response = client.get(
f'/api/v1/servers/{server.id}/timeline?types=audit',
headers=headers_for(developer),
)
assert response.status_code == 200
events = response.get_json()['events']
assert [event['source_id'] for event in events] == [
direct.id, routed.id, detailed.id,
]
assert events[0]['actor_username'] == developer.username
assert events[0]['details'] == {'state': 'ready'}
assert events[1]['details'] == {'method': 'POST'}
assert events[2]['details'] == {
'kind': 'service', 'server_id': server.id,
}
for event in events:
assert 'ip_address' not in event
assert 'user_agent' not in event
assert 'payload' not in event['details']
def test_timeline_rejects_invalid_query_and_mismatched_cursor(
client, db_session):
first_server = make_server(db_session)
second_server = make_server(db_session)
developer = make_user(db_session, role='developer')
headers = headers_for(developer)
now = datetime(2026, 8, 23, 12)
_audit(
db_session, action='one', created_at=now,
details={'server_id': first_server.id},
)
_audit(
db_session, action='two', created_at=now - timedelta(seconds=1),
details={'server_id': first_server.id},
)
base = f'/api/v1/servers/{first_server.id}/timeline'
invalid = (
'?types=unknown',
'?limit=0',
'?limit=201',
'?limit=wat',
'?before=not-a-cursor',
'?extra=yes',
'?limit=1&limit=2',
)
for query in invalid:
response = client.get(base + query, headers=headers)
assert response.status_code == 400, (query, response.get_json())
assert 'code' in response.get_json()
first = client.get(base + '?types=audit&limit=1', headers=headers)
assert first.status_code == 200
cursor = first.get_json()['next_cursor']
assert cursor
wrong_server = client.get(
f'/api/v1/servers/{second_server.id}/timeline'
f'?types=audit&limit=1&before={cursor}',
headers=headers,
)
assert wrong_server.status_code == 400
assert wrong_server.get_json()['code'] == 'invalid_cursor'
wrong_types = client.get(
base + f'?types=restore_point&limit=1&before={cursor}',
headers=headers,
)
assert wrong_types.status_code == 400
assert wrong_types.get_json()['code'] == 'invalid_cursor'
encoded = cursor.encode('ascii')
encoded += b'=' * (-len(encoded) % 4)
tampered_payload = json.loads(base64.urlsafe_b64decode(encoded))
tampered_payload['source'] = 'restore_point'
tampered = base64.urlsafe_b64encode(json.dumps(
tampered_payload, sort_keys=True, separators=(',', ':'),
).encode()).rstrip(b'=').decode()
response = client.get(
base + f'?types=audit&limit=1&before={tampered}', headers=headers,
)
assert response.status_code == 400
assert response.get_json()['code'] == 'invalid_cursor'