Files
ServerKit/backend/tests/test_docker_protection.py
Juan DenisandClaude Opus 5 7383820085 refactor(docker): route docker_service through the shared doors
Plan 75 §G1, second migration. docker_service was the heaviest file in the
census: 49 raw subprocess call sites -> 2. Census 258 -> 211.

DockerService.run now SITS ON run_checked instead of re-implementing it, so
G3's runner and G1's door are one thing rather than two. Adds
DockerService.run_compose() for the compose subcommands, whose argv head is
whatever _get_compose_cmd() resolved (`docker compose` v2 vs `docker-compose`
v1) and therefore cannot go through run().

The 2 survivors are the genuine streaming Popen sites — the live container
log stream and the compose-up transcript. A captured call cannot do those.

run_checked gained two things this migration needed:

- `stderr` (the raw stream) as a separate key from `error` (the verdict, None
  on success). git and `docker exec` write to stderr and still succeed, so
  folding the stream into the verdict would invent failures. exec_command
  needs the stream; every other caller wants the verdict.
- merge_stderr=True, for `docker logs` and `compose logs`. A container writes
  its log to both streams; the `result.stdout + result.stderr` concatenation
  this replaces loses the interleaving, so a crash tail landed above the lines
  that led to it.

TIMEOUTS ARE UNCHANGED, deliberately. run_checked defaults to 60s and every
docker call in this file is unbounded today; inheriting the default would cap
`compose up --build`, `docker pull` and `docker build` at a minute and turn
working deploys into timeouts. Every migrated site keeps exactly what it has,
with timeout=None passed explicitly at the long ones and six tests pinning it
(mutation-checked: removing it from pull_image fails the test). Bounding
docker's long operations is a real improvement and a deliberate behaviour
change — its own commit, not a side effect of this one.

Two regressions caught by the suite and fixed here:

- Dropping the try/except around the compose-version probe let an exception
  escape into the caller, surfacing as `compose up` exit_code -1 instead of a
  v1 fallback. run_checked converts the exec failures it knows about, but this
  probe runs before everything else in the compose path and must not be able
  to raise at all. Guard restored, with a test.
- test_docker_protection hand-rolled a CompletedProcess stand-in with only
  returncode and stdout. Once list_containers went through run_checked,
  reading .stderr off it raised AttributeError, the caller's blanket except
  swallowed it, and the test saw an EMPTY CONTAINER LIST rather than an error.
  Retargeted onto the §G7 kit. A private stub is only correct until something
  reads the attribute it forgot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:45:25 -04:00

101 lines
4.1 KiB
Python

"""Tests for ServerKit system-container protection on the Docker page.
ServerKit's own containers (serverkit-frontend / serverkit-backend) run the
panel itself, so the Docker API must refuse to stop/restart/remove them and the
container list must flag them so the UI can hide lifecycle controls.
The predicate and the list annotation are pure (subprocess is mocked); the
route guard tests drive the real blueprint with an admin JWT.
"""
from unittest.mock import patch
import pytest
from app.services.docker_service import DockerService
class TestIsProtectedName:
@pytest.mark.parametrize('name', [
'serverkit-frontend',
'serverkit_frontend',
'serverkit-backend',
'serverkit_backend',
'serverkit',
'/serverkit-backend', # docker inspect Name has a leading slash
'SERVERKIT-FRONTEND', # case-insensitive
])
def test_serverkit_names_are_protected(self, name):
assert DockerService.is_protected_name(name) is True
@pytest.mark.parametrize('name', [
'nginx',
'my-app_web_1',
'postgres',
'',
None,
])
def test_other_names_are_not_protected(self, name):
assert DockerService.is_protected_name(name) is False
class TestListContainersAnnotation:
@staticmethod
def _ps_output(names):
return '\n'.join(
'{"ID": "abc%d", "Names": "%s", "Image": "img", "Status": "Up", '
'"State": "running", "Ports": "", "CreatedAt": "now", "Size": "0B"}'
% (i, n)
for i, n in enumerate(names)
)
def test_protected_flag_set_per_container(self, fake_subprocess):
# Scripted through the shared stub kit (plan 75 §G7). The private fake
# this replaced had only `returncode` and `stdout`; once list_containers
# went through run_checked, reading `.stderr` off it raised
# AttributeError, the caller's blanket except swallowed it, and the
# test saw an empty container list instead of an error. A hand-rolled
# CompletedProcess stand-in is only correct until something reads the
# attribute it forgot.
fake_subprocess.script(['docker', 'ps'],
stdout=self._ps_output(['serverkit-backend', 'nginx']))
containers = DockerService.list_containers()
by_name = {c['name']: c for c in containers}
assert by_name['serverkit-backend']['protected'] is True
assert by_name['nginx']['protected'] is False
class TestIsProtectedContainer:
def test_resolves_name_from_inspect(self):
# docker inspect returns the canonical Name with a leading slash.
with patch.object(DockerService, 'get_container', return_value={'Name': '/serverkit-frontend'}):
assert DockerService.is_protected_container('abc123') is True
def test_unknown_container_not_protected(self):
with patch.object(DockerService, 'get_container', return_value=None):
assert DockerService.is_protected_container('abc123') is False
class TestRouteGuards:
"""The destructive lifecycle routes must 403 on a protected container."""
@pytest.mark.parametrize('method,path', [
('post', '/api/v1/docker/containers/abc123/stop'),
('post', '/api/v1/docker/containers/abc123/restart'),
('delete', '/api/v1/docker/containers/abc123'),
])
def test_protected_container_is_rejected(self, client, auth_headers, method, path):
with patch.object(DockerService, 'is_protected_container', return_value=True):
resp = getattr(client, method)(path, headers=auth_headers, json={})
assert resp.status_code == 403
assert 'system container' in resp.get_json()['error'].lower()
def test_normal_container_stop_passes_through(self, client, auth_headers):
with patch.object(DockerService, 'is_protected_container', return_value=False), \
patch.object(DockerService, 'stop_container', return_value={'success': True}) as stop:
resp = client.post(
'/api/v1/docker/containers/abc123/stop', headers=auth_headers, json={}
)
assert resp.status_code == 200
stop.assert_called_once()