diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7a750a1..7485c5b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,73 +1,29 @@ name: CI on: - push: pull_request: jobs: - precommit-and-security: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install pre-commit - run: pip install pre-commit - - - name: Run pre-commit hooks - run: pre-commit run --all-files --color always - - - name: Install Poetry and export plugin - run: | - pip install poetry - poetry self add poetry-plugin-export - - - name: Install pip-audit - run: pip install pip-audit - - - name: Audit dev dependencies (Poetry lockfile) - run: | - poetry export -f requirements.txt --without-hashes --with dev \ - | pip-audit -r /dev/stdin - - typecheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - run: pip install mypy - - run: mypy - test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install -e . pytest - - run: pytest tests/unit tests/security - - name: Integration tests (best effort) - continue-on-error: true - run: pytest tests/integration - package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install build - - run: python -m build + - name: Install project and tooling + run: pip install -e . pytest mypy pre-commit + + - name: Lint and typecheck + if: matrix.python-version == '3.13' + run: | + pre-commit run --all-files --color always + mypy + + - name: Unit and security tests + run: pytest tests/unit tests/security diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index de60a4f..1d6051a 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -20,6 +20,18 @@ from schedls.security import ( validate_name, ) +_UNTRUSTED_UID = 65534 + + +def _relinquish_ownership(path) -> None: + """Own a fixture by a non-root user when the suite itself runs as root. + + The helper checks distinguish root-owned files from user-owned ones, so a + root test process must make its "untrusted" fixtures owned by someone else. + """ + if os.geteuid() == 0: + os.chown(path, _UNTRUSTED_UID, _UNTRUSTED_UID) + @pytest.mark.parametrize( "name", @@ -114,6 +126,7 @@ def test_resolve_helper_rejects_user_owned_dir_when_root(tmp_path, monkeypatch) helper = helper_dir / "evilhelper" helper.write_text("#!/bin/sh\ntrue\n") helper.chmod(0o755) + _relinquish_ownership(helper_dir) monkeypatch.setenv("PATH", str(helper_dir)) monkeypatch.setattr(os, "geteuid", lambda: 0) assert resolve_helper("evilhelper") is None @@ -134,6 +147,7 @@ def test_runner_refuses_untrusted_absolute_helper(tmp_path, monkeypatch) -> None helper = tmp_path / "evil" helper.write_text("#!/bin/sh\ntrue\n") helper.chmod(0o755) + _relinquish_ownership(helper) monkeypatch.setattr(os, "geteuid", lambda: 0) with pytest.raises(SafetyRefusalError): CommandRunner().run([str(helper)]) diff --git a/tests/unit/test_timefmt.py b/tests/unit/test_timefmt.py index 2211122..2b73a7f 100644 --- a/tests/unit/test_timefmt.py +++ b/tests/unit/test_timefmt.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from datetime import UTC, datetime, timedelta, timezone import pytest @@ -7,6 +8,15 @@ import pytest from schedls import timefmt +@pytest.fixture +def london_tz(monkeypatch): + monkeypatch.setenv("TZ", "Europe/London") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + @pytest.mark.parametrize( ("value", "seconds"), [ @@ -50,10 +60,11 @@ def test_parse_placeholders() -> None: assert timefmt.parse_systemd_timestamp("0") is None -def test_format_datetime_round_trip_zone() -> None: +def test_format_datetime_round_trip_zone(london_tz) -> None: dt = datetime(2026, 9, 25, 2, 0, 0, tzinfo=timezone(timedelta(hours=1))) text = timefmt.format_datetime(dt) assert text.startswith("Fri 25 Sep 2026 02:00:00") + assert text.endswith("BST") def test_format_short_relative() -> None: