diff --git a/pyproject.toml b/pyproject.toml index c709757..a58551e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "fakeredis>=2.39.0", + "polyfactory>=3.3.0", "pytest>=9.1.1", "pytest-cov>=7.1.0", "ruff>=0.16.8", @@ -44,6 +46,8 @@ dev = [ testpaths = ["tests"] # Lets test files share a name across folders (tests/main/test_routes.py, tests/stacks/test_routes.py) addopts = ["--import-mode=importlib"] +# So conftest.py can load tests/fixtures/ as `tests.fixtures...` +pythonpath = ["."] [tool.coverage.run] source = ["src/makeitso"] diff --git a/src/makeitso/config.py b/src/makeitso/config.py index b9f0ead..557d900 100644 --- a/src/makeitso/config.py +++ b/src/makeitso/config.py @@ -62,6 +62,8 @@ class PytestConfig(LocalConfig): SECRET_KEY = "pytest" # Fixed name, so tests never wipe the real database SQLALCHEMY_DATABASE_URI = _database_uri("mis_test") + # Lets tests post forms without a CSRF token + WTF_CSRF_ENABLED = False configs = { diff --git a/src/makeitso/deploys/tasks.py b/src/makeitso/deploys/tasks.py index c042096..8ba9f18 100644 --- a/src/makeitso/deploys/tasks.py +++ b/src/makeitso/deploys/tasks.py @@ -13,8 +13,7 @@ from rq.job import Job, JobStatus from makeitso.deploys.queries import last_successful_deploy -from makeitso.engage import EngageConfig, parse_config -from makeitso.engage.config import config_files +from makeitso.engage.loader import read_config from makeitso.extensions import db, job_queue from makeitso.models.commit import Commit from makeitso.models.deploy import Deploy, DeployStatus @@ -180,7 +179,8 @@ def _run(deploy: Deploy, workdir: Path, log: _Log) -> DeployStatus: status = _stream(["bash", "bin/checkout.sh"], env, log, CHECKOUT_TIMEOUT, job_id) if status is not DeployStatus.SUCCEEDED: return status - config, name = _read_config(workdir, deploy.stack.environment) + # An invalid file raises EngageConfigError; run_deploy puts its message in the log + config, name = read_config(workdir, deploy.stack.environment) log.write(f"\nSettings from {name}\n" if name else "\nNo engage.yaml, using the defaults\n") log.write(f"\n$ bash {config.deploy.file} (timeout {config.deploy.timeout}s)\n") # Run from the checkout, so the script's relative paths work @@ -194,17 +194,6 @@ def _run(deploy: Deploy, workdir: Path, log: _Log) -> DeployStatus: ) -def _read_config(workdir: Path, environment: str) -> tuple[EngageConfig, str | None]: - """engage..yaml from the checkout, else engage.yaml, else the defaults. - Returns the config and the file it came from""" - for name in config_files(environment): - path = workdir / name - if path.is_file(): - # An invalid file raises EngageConfigError; run_deploy puts its message in the log - return parse_config(path.read_text(), name), name - return EngageConfig(), None - - def _stream( args: list[str], env: dict[str, str], diff --git a/src/makeitso/engage/loader.py b/src/makeitso/engage/loader.py index 961a4f1..be2d51c 100644 --- a/src/makeitso/engage/loader.py +++ b/src/makeitso/engage/loader.py @@ -1,12 +1,34 @@ +from collections.abc import Callable +from pathlib import Path + from makeitso.engage.config import EngageConfig, config_files, parse_config from makeitso.github import GitHubRepo def load_config(repo: GitHubRepo, ref: str, environment: str) -> tuple[EngageConfig, str | None]: - """The stack's config at `ref`, read through the GitHub API: engage..yaml if the - repo has it, else engage.yaml. Returns the config and the file it came from (None: defaults)""" + """The stack's config at `ref`, read through the GitHub API""" + return _find_config(environment, lambda name: repo.file_content(name, ref)) + + +def read_config(folder: Path, environment: str) -> tuple[EngageConfig, str | None]: + """The config from a checked-out repo, e.g. a deploy's folder""" + + def read(name: str) -> str | None: + path = folder / name + return path.read_text() if path.is_file() else None + + return _find_config(environment, read) + + +def _find_config( + environment: str, read: Callable[[str], str | None] +) -> tuple[EngageConfig, str | None]: + """engage..yaml if the repo has it, else engage.yaml, else the defaults. + `read` returns a file's text, or None if it's missing. + Returns the config and the file it came from (None: defaults). + An invalid file raises EngageConfigError""" for name in config_files(environment): - text = repo.file_content(name, ref) + text = read(name) if text is not None: return parse_config(text, name), name return EngageConfig(), None diff --git a/tests/auth/test_login_required.py b/tests/auth/test_login_required.py new file mode 100644 index 0000000..7a956f2 --- /dev/null +++ b/tests/auth/test_login_required.py @@ -0,0 +1,48 @@ +from typing import Any + +from flask import url_for +from werkzeug.routing import Rule + +# Routes anyone can open; every other route must send logged-out users to the login page +PUBLIC_ENDPOINTS = { + # Landing page; shows only the login button when logged out + "main.index", + # Health checks from OpenShift, which can't log in + "main.healthz", + # The login flow itself + "auth.login", + "auth.authorize", + "auth.logout", + # Called by GitHub, which can't log in; checked by its signature instead + "webhooks.receive_github_webhook", + # CSS, JS and images + "static", + "bootstrap.static", +} + + +def _url(rule: Rule) -> str: + """The rule's URL with a placeholder for each part, e.g. /stacks/1/deploys/1""" + values: dict[str, Any] = {name: 1 if name.endswith("_id") else "x" for name in rule.arguments} + return url_for(rule.endpoint, **values) + + +class TestLoginRequired: + def test_every_route_requires_login(self, app, client): + unprotected = [] + for rule in app.url_map.iter_rules(): + if rule.endpoint in PUBLIC_ENDPOINTS: + continue + url = _url(rule) + for method in sorted((rule.methods or set()) - {"HEAD", "OPTIONS"}): + response = client.open(url, method=method) + if response.status_code != 302 or response.location != "/login": + unprotected.append(f"{method} {url} ({rule.endpoint})") + + assert unprotected == [] + + def test_public_endpoints_exist(self, app): + """Catches a route that was renamed or removed but is still listed as public""" + endpoints = {rule.endpoint for rule in app.url_map.iter_rules()} + + assert PUBLIC_ENDPOINTS - endpoints == set() diff --git a/tests/conftest.py b/tests/conftest.py index 9c9be00..7c5ef12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,30 +1,5 @@ -import pytest - -from makeitso import create_app -from makeitso.extensions import db - - -# One app and one set of tables for the whole test run -@pytest.fixture(scope="session") -def app(): - app = create_app("pytest") - with app.app_context(): - db.create_all() - yield app - db.session.remove() - db.drop_all() - - -@pytest.fixture -def client(app): - return app.test_client() - - -# Empty every table after each test, so tests don't see each other's rows -@pytest.fixture(autouse=True) -def clean_db(app): - yield - db.session.rollback() - for table in reversed(db.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() +# Fixtures shared by every test +pytest_plugins = [ + "tests.fixtures.app", + "tests.fixtures.engage", +] diff --git a/tests/deploys/tasks/test_running.py b/tests/deploys/tasks/test_running.py new file mode 100644 index 0000000..49326ed --- /dev/null +++ b/tests/deploys/tasks/test_running.py @@ -0,0 +1,214 @@ +import os +import time + +import pytest + +from makeitso.deploys import tasks +from makeitso.deploys.tasks import ( + _Log, + _script_env, + _stream, + run_deploy, + signal_job, +) +from makeitso.extensions import db +from makeitso.models.deploy import DeployStatus +from makeitso.models.stack_env_var import StackEnvVar +from tests.factories import start_running_deploy + + +class TestStream: + """Runs real bash commands; the watcher checks for timeouts and signals once a second""" + + @staticmethod + def _run_bash(deploy, script: str, timeout: int = 30) -> DeployStatus: + """Run a bash script through _stream and save its output to the deploy""" + log = _Log(deploy) + env = {"PATH": os.environ["PATH"]} + status = _stream(["bash", "-c", script], env, log, timeout, f"deploy-{deploy.id}") + log.save() + return status + + def test_success_logs_every_line(self): + deploy = start_running_deploy() + + status = self._run_bash(deploy, "echo one; echo two >&2; echo three") + + assert status is DeployStatus.SUCCEEDED + assert deploy.output == "one\ntwo\nthree\n" + + def test_failure(self): + assert self._run_bash(start_running_deploy(), "exit 1") is DeployStatus.FAILED + + def test_timeout(self): + status = self._run_bash(start_running_deploy(), "sleep 30", timeout=1) + + assert status is DeployStatus.TIMED_OUT + + def test_cancel(self): + deploy = start_running_deploy() + signal_job(deploy, "cancel") + + assert self._run_bash(deploy, "sleep 30") is DeployStatus.CANCELLED + + def test_interrupt_kills_right_away(self): + deploy = start_running_deploy() + signal_job(deploy, "interrupt") + + # Ignores SIGTERM, so only SIGKILL stops it + assert self._run_bash(deploy, "trap '' TERM; sleep 30") is DeployStatus.INTERRUPTED + + def test_kills_after_grace_period(self, monkeypatch): + # One second instead of 30 minutes between SIGTERM and SIGKILL + monkeypatch.setattr(tasks, "DEPLOY_TIMEOUT", 1) + + # Ignores SIGTERM, like a stuck script; only SIGKILL stops it + status = self._run_bash(start_running_deploy(), "trap '' TERM; sleep 30", timeout=1) + + assert status is DeployStatus.TIMED_OUT + + def test_stops_child_processes(self): + deploy = start_running_deploy() + + # The background sleep is a child process; it prints its pid, then bash waits for it + self._run_bash(deploy, "sleep 30 >/dev/null & echo $!; wait", timeout=1) + + child = int(deploy.output.strip()) + # It may take a moment to disappear after the signal + for _ in range(20): + try: + os.kill(child, 0) + except ProcessLookupError: + return + time.sleep(0.1) + pytest.fail("The child process is still running") + + +class TestLog: + def test_saves_while_running(self, monkeypatch): + # Save on every write instead of once a second + monkeypatch.setattr(tasks, "SAVE_EVERY", 0) + deploy = start_running_deploy() + + _Log(deploy).write("cloning\n") + + # Read back from the database, so the page polling the log would see it + db.session.expire(deploy) + assert deploy.output == "cloning\n" + + def test_waits_between_saves(self): + deploy = start_running_deploy() + + _Log(deploy).write("cloning\n") + + db.session.expire(deploy) + assert deploy.output == "" + + +class TestScriptEnv: + def test_passes_only_what_the_script_needs(self, tmp_path, monkeypatch): + deploy = start_running_deploy() + monkeypatch.setenv("SECRET_KEY", "worker-secret") + db.session.add_all( + [ + StackEnvVar(stack_id=deploy.stack_id, key="NAMESPACE_PREFIX", value="abc123"), + # Can't replace the commit being deployed + StackEnvVar(stack_id=deploy.stack_id, key="COMMIT_SHA", value="not-this"), + ] + ) + db.session.commit() + + env = _script_env(deploy, tmp_path) + + assert env["NAMESPACE_PREFIX"] == "abc123" + assert env["COMMIT_SHA"] == deploy.commit.commit_sha + assert env["ENVIRONMENT"] == "dev" + assert env["WORKDIR"] == str(tmp_path) + assert "SECRET_KEY" not in env + + +class TestRunDeploy: + """The checkout (a git fetch from GitHub) is faked; the deploy script runs for real""" + + @pytest.fixture(autouse=True) + def workspace(self, app, tmp_path, monkeypatch): + monkeypatch.setitem(app.config, "DEPLOY_WORKSPACE", str(tmp_path)) + return tmp_path + + # Replaces the checkout with writing the given files into the deploy's folder + @pytest.fixture + def fake_checkout(self, monkeypatch): + real_stream = tasks._stream + + def fake_checkout( + files: dict[str, str] | None = None, + error: Exception | None = None, + status: DeployStatus = DeployStatus.SUCCEEDED, + ): + def stream(args, env, log, timeout, job_id, cwd=None): + if args != ["bash", "bin/checkout.sh"]: + return real_stream(args, env, log, timeout, job_id, cwd) + os.makedirs(env["WORKDIR"]) + if error: + raise error + for name, text in (files or {}).items(): + with open(os.path.join(env["WORKDIR"], name), "w") as file: + file.write(text) + return status + + monkeypatch.setattr(tasks, "_stream", stream) + + return fake_checkout + + def test_success(self, workspace, fake_checkout): + deploy = start_running_deploy() + fake_checkout({"deploy.sh": "echo deploying $ENVIRONMENT"}) + + run_deploy(deploy.id) + + assert deploy.status is DeployStatus.SUCCEEDED + assert deploy.ended_at is not None + assert "deploying dev" in deploy.output + assert not (workspace / f"deploy-{deploy.id}").exists() + + def test_uses_engage_yaml(self, fake_checkout): + deploy = start_running_deploy() + fake_checkout({"engage.yaml": "deploy:\n file: ship.sh\n", "ship.sh": "echo shipping"}) + + run_deploy(deploy.id) + + assert deploy.status is DeployStatus.SUCCEEDED + assert "Settings from engage.yaml" in deploy.output + assert "shipping" in deploy.output + + def test_failure_ends_log_with_status(self, fake_checkout): + deploy = start_running_deploy() + fake_checkout({"deploy.sh": "echo oops; exit 3"}) + + run_deploy(deploy.id) + + assert deploy.status is DeployStatus.FAILED + assert deploy.output.endswith("\nFailed\n") + + def test_checkout_fails(self, fake_checkout): + deploy = start_running_deploy() + fake_checkout({"deploy.sh": "echo deploying"}, status=DeployStatus.FAILED) + + run_deploy(deploy.id) + + # Stops after the checkout: the script never runs + assert deploy.status is DeployStatus.FAILED + assert "deploying" not in deploy.output + + def test_crash(self, workspace, fake_checkout): + deploy = start_running_deploy() + fake_checkout(error=RuntimeError("boom")) + + # Re-raised, so RQ records the job as failed too + with pytest.raises(RuntimeError, match="boom"): + run_deploy(deploy.id) + + assert deploy.status is DeployStatus.FAILED + assert deploy.ended_at is not None + assert "boom" in deploy.output + assert not (workspace / f"deploy-{deploy.id}").exists() diff --git a/tests/deploys/tasks/test_starting.py b/tests/deploys/tasks/test_starting.py new file mode 100644 index 0000000..67f1d9a --- /dev/null +++ b/tests/deploys/tasks/test_starting.py @@ -0,0 +1,129 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from rq.job import JobStatus + +from makeitso.deploys.tasks import ( + CHECKOUT_TIMEOUT, + DEPLOY_TIMEOUT, + fail_if_lost, + requested_signal, + run_deploy, + signal_job, + start_deploy, +) +from makeitso.extensions import job_queue +from makeitso.models.deploy import DeployStatus +from tests.factories import CommitFactory, DeployFactory, start_running_deploy + + +def _job(deploy): + return job_queue.deploys.fetch_job(f"deploy-{deploy.id}") + + +class TestStartDeploy: + def test_saves_and_queues(self): + commit = CommitFactory.create_sync() + + deploy = start_deploy(commit.stack, commit, timeout=600, deployed_by="octocat", bypass=True) + + assert deploy.status is DeployStatus.IN_PROGRESS + assert deploy.deployed_by == "octocat" + assert deploy.deployed_with_bypass is True + job = _job(deploy) + assert job.func is run_deploy + assert job.args == (deploy.id,) + assert job.timeout == CHECKOUT_TIMEOUT + 600 + 2 * DEPLOY_TIMEOUT + 60 + + def test_first_deploy_has_no_since_commit(self): + assert start_running_deploy().since_commit_id is None + + def test_since_commit_is_live_commit(self): + live = DeployFactory.create_sync() + + deploy = start_running_deploy(CommitFactory.create_sync(stack=live.stack)) + + assert deploy.since_commit_id == live.commit_id + + +class TestSignalJob: + @pytest.mark.parametrize("signal", ["cancel", "interrupt"]) + def test_stores_signal(self, signal): + deploy = start_running_deploy() + + signal_job(deploy, signal) + + assert _job(deploy).meta["signal"] == signal + assert requested_signal(deploy) == signal + + def test_refuses_finished_deploy(self): + with pytest.raises(ValueError, match="not running"): + signal_job(DeployFactory.create_sync(), "cancel") + + def test_refuses_missing_job(self): + deploy = DeployFactory.create_sync(status=DeployStatus.IN_PROGRESS) + + with pytest.raises(ValueError, match="no associated job"): + signal_job(deploy, "cancel") + + def test_no_signal_once_finished(self): + deploy = start_running_deploy() + signal_job(deploy, "cancel") + deploy.status = DeployStatus.CANCELLED + + assert requested_signal(deploy) is None + + +class TestFailIfLost: + def test_leaves_finished_deploy(self): + deploy = DeployFactory.create_sync(status=DeployStatus.SUCCEEDED) + + fail_if_lost(deploy) + + assert deploy.status is DeployStatus.SUCCEEDED + + def test_leaves_live_job(self): + deploy = start_running_deploy() + + fail_if_lost(deploy) + + assert deploy.status is DeployStatus.IN_PROGRESS + + def test_missing_job(self): + deploy = DeployFactory.create_sync(status=DeployStatus.IN_PROGRESS) + + fail_if_lost(deploy) + + assert deploy.status is DeployStatus.FAILED + assert deploy.ended_at is not None + assert "The worker stopped" in deploy.output + + @pytest.mark.parametrize( + ("job_status", "expected"), + [ + (JobStatus.FAILED, DeployStatus.FAILED), + (JobStatus.STOPPED, DeployStatus.ABORTED), + (JobStatus.CANCELED, DeployStatus.ABORTED), + ], + ) + def test_ended_job(self, job_status, expected): + deploy = start_running_deploy() + _job(deploy).set_status(job_status) + + fail_if_lost(deploy) + + assert deploy.status is expected + + @pytest.mark.parametrize( + ("minutes", "expected"), [(1, DeployStatus.IN_PROGRESS), (3, DeployStatus.FAILED)] + ) + def test_worker_heartbeat(self, minutes, expected): + deploy = start_running_deploy() + job = _job(deploy) + job.set_status(JobStatus.STARTED) + job.last_heartbeat = datetime.now(UTC) - timedelta(minutes=minutes) + job.save() + + fail_if_lost(deploy) + + assert deploy.status is expected diff --git a/tests/deploys/test_helpers.py b/tests/deploys/test_helpers.py new file mode 100644 index 0000000..c13c752 --- /dev/null +++ b/tests/deploys/test_helpers.py @@ -0,0 +1,48 @@ +import pytest + +from makeitso.deploys.helpers import deploy_blockers, emergency_mode +from tests.factories import CommitFactory, CommitStatusFactory, StackFactory + + +class TestDeployBlockers: + def test_nothing_blocks_passing_commit(self): + commit = CommitFactory.create_sync() + CommitStatusFactory.create_sync(commit=commit, name="tests", state="success") + + assert deploy_blockers(commit.stack, commit, []) == [] + + def test_locked_stack(self): + stack = StackFactory.create_sync(locked=True, lock_reason="Release freeze") + commit = CommitFactory.create_sync(stack=stack) + + assert deploy_blockers(stack, commit, []) == ["The stack is locked: Release freeze"] + + def test_failed_check(self): + commit = CommitFactory.create_sync() + CommitStatusFactory.create_sync(commit=commit, name="tests", state="failure") + + assert deploy_blockers(commit.stack, commit, []) == ["Some checks failed"] + + def test_allowed_failure_does_not_block(self): + commit = CommitFactory.create_sync() + CommitStatusFactory.create_sync(commit=commit, name="lint", state="failure") + + assert deploy_blockers(commit.stack, commit, ["lint"]) == [] + + def test_emergency_skips_failed_checks_but_not_lock(self): + stack = StackFactory.create_sync(locked=True, lock_reason="Release freeze") + commit = CommitFactory.create_sync(stack=stack) + CommitStatusFactory.create_sync(commit=commit, name="tests", state="failure") + + assert deploy_blockers(stack, commit, [], emergency=True) == [ + "The stack is locked: Release freeze" + ] + + +class TestEmergencyMode: + @pytest.mark.parametrize( + ("query", "expected"), [("?emergency=1", True), ("?emergency=true", False), ("", False)] + ) + def test_only_on_for_emergency_1(self, app, query, expected): + with app.test_request_context(f"/{query}"): + assert emergency_mode() is expected diff --git a/tests/deploys/test_queries.py b/tests/deploys/test_queries.py new file mode 100644 index 0000000..fd16632 --- /dev/null +++ b/tests/deploys/test_queries.py @@ -0,0 +1,90 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError + +from makeitso.deploys.queries import ( + commits_between, + current_deploy, + last_successful_deploy, + previous_deploys, +) +from makeitso.extensions import db +from makeitso.models.deploy import DeployStatus +from tests.factories import CommitFactory, DeployFactory, StackFactory + + +def _ago(minutes: int) -> datetime: + return datetime.now(UTC) - timedelta(minutes=minutes) + + +class TestCurrentDeploy: + def test_running_deploy(self): + commit = CommitFactory.create_sync() + DeployFactory.create_sync(commit=commit, status=DeployStatus.FAILED) + running = DeployFactory.create_sync(commit=commit, status=DeployStatus.IN_PROGRESS) + + assert current_deploy(commit.stack_id) == running + + def test_none_when_nothing_runs(self): + deploy = DeployFactory.create_sync(status=DeployStatus.FAILED) + + assert current_deploy(deploy.stack_id) is None + + def test_only_one_per_stack(self): + commit = CommitFactory.create_sync() + DeployFactory.create_sync(commit=commit, status=DeployStatus.IN_PROGRESS) + + # Another stack can run its own deploy + DeployFactory.create_sync(status=DeployStatus.IN_PROGRESS) + + with pytest.raises(IntegrityError): + DeployFactory.create_sync(commit=commit, status=DeployStatus.IN_PROGRESS) + db.session.rollback() + + +class TestLastSuccessfulDeploy: + def test_newest_succeeded(self): + commit = CommitFactory.create_sync() + DeployFactory.create_sync(commit=commit, started_at=_ago(30)) + newest = DeployFactory.create_sync(commit=commit, started_at=_ago(20)) + DeployFactory.create_sync(commit=commit, status=DeployStatus.FAILED, started_at=_ago(10)) + + assert last_successful_deploy(commit.stack_id) == newest + + def test_none_without_success(self): + deploy = DeployFactory.create_sync(status=DeployStatus.FAILED) + + assert last_successful_deploy(deploy.stack_id) is None + + +class TestPreviousDeploys: + def test_finished_newest_first(self): + commit = CommitFactory.create_sync() + older = DeployFactory.create_sync(commit=commit, started_at=_ago(30)) + newer = DeployFactory.create_sync( + commit=commit, status=DeployStatus.FAILED, started_at=_ago(20) + ) + DeployFactory.create_sync( + commit=commit, status=DeployStatus.IN_PROGRESS, started_at=_ago(10) + ) + + assert previous_deploys(commit.stack_id) == [newer, older] + assert previous_deploys(commit.stack_id, limit=1) == [newer] + + +class TestCommitsBetween: + def test_range(self): + stack = StackFactory.create_sync() + first, second, third, fourth = (CommitFactory.create_sync(stack=stack) for _ in range(4)) + + assert commits_between(stack.id, None, third) == [third, second, first] + assert commits_between(stack.id, first, third) == [third, second] + assert commits_between(stack.id, second) == [fourth, third] + + def test_skips_other_stacks(self): + mine = CommitFactory.create_sync() + # On another stack + CommitFactory.create_sync() + + assert commits_between(mine.stack_id, None) == [mine] diff --git a/tests/deploys/test_views.py b/tests/deploys/test_views.py new file mode 100644 index 0000000..2b7159b --- /dev/null +++ b/tests/deploys/test_views.py @@ -0,0 +1,242 @@ +from datetime import UTC, datetime, timedelta + +import pytest +import sqlalchemy as sa +from flask import url_for + +from makeitso.deploys import views +from makeitso.extensions import db, job_queue +from makeitso.models.deploy import Deploy +from tests.factories import ( + CommitFactory, + CommitStatusFactory, + DeployFactory, + StackFactory, + start_running_deploy, +) + + +def _new_url(commit, **query) -> str: + return url_for( + "stacks.deploys.new", stack_id=commit.stack_id, commit_sha=commit.commit_sha, **query + ) + + +# endpoint: detail, cancel, interrupt or log +def _deploy_url(deploy, endpoint="detail", **query) -> str: + return url_for( + f"stacks.deploys.{endpoint}", stack_id=deploy.stack_id, deploy_id=deploy.id, **query + ) + + +def _deploys(stack_id: int) -> list[Deploy]: + return list(db.session.scalars(sa.select(Deploy).where(Deploy.stack_id == stack_id))) + + +class TestNewDeployPage: + def test_shows_commit(self, client, login): + commit = CommitFactory.create_sync() + login() + + response = client.get(_new_url(commit)) + + assert response.status_code == 200 + assert commit.commit_message.encode() in response.data + + def test_unknown_commit(self, client, login): + stack = StackFactory.create_sync() + login() + + assert ( + client.get( + url_for("stacks.deploys.new", stack_id=stack.id, commit_sha="nope") + ).status_code + == 404 + ) + + def test_archived_stack(self, client, login): + stack = StackFactory.create_sync(archived_at=datetime.now(UTC)) + commit = CommitFactory.create_sync(stack=stack) + login() + + assert client.get(_new_url(commit)).status_code == 404 + + def test_shows_config_error(self, client, login, engage_yaml): + engage_yaml("deploy:\n timeout: soon\n") + login() + + response = client.get(_new_url(CommitFactory.create_sync())) + + assert response.status_code == 200 + assert b"Invalid engage.yaml" in response.data + + +class TestStartDeployPage: + # What start_deploy saves is tested in tasks/test_starting.py + def test_starts_and_shows_deploy(self, client, login): + commit = CommitFactory.create_sync() + login("someone") + + response = client.post(_new_url(commit)) + + [deploy] = _deploys(commit.stack_id) + assert response.location == _deploy_url(deploy) + assert deploy.deployed_by == "someone" + + def test_while_one_runs(self, client, login): + running = start_running_deploy() + login() + + response = client.post(_new_url(running.commit)) + + assert response.location == _deploy_url(running) + assert _deploys(running.stack_id) == [running] + + def test_double_click(self, client, login, monkeypatch): + running = start_running_deploy() + real_current_deploy = views.current_deploy + checks = [] + + # The first check misses the running deploy, as when two clicks arrive together; + # the database's one-running-deploy rule then refuses the second save + def current_deploy(stack_id): + checks.append(stack_id) + return None if len(checks) == 1 else real_current_deploy(stack_id) + + monkeypatch.setattr(views, "current_deploy", current_deploy) + login() + + response = client.post(_new_url(running.commit)) + + assert response.location == _deploy_url(running) + assert _deploys(running.stack_id) == [running] + + @pytest.mark.parametrize("ticked", [[], ["Is it Friday?"]]) + def test_needs_checklist(self, client, login, engage_yaml, ticked): + commit = CommitFactory.create_sync() + engage_yaml("review:\n checklist:\n - Is it Friday?\n") + login() + + response = client.post(_new_url(commit), data={"checklist": ticked}) + + if ticked: + assert len(_deploys(commit.stack_id)) == 1 + else: + assert response.location == _new_url(commit) + assert _deploys(commit.stack_id) == [] + + # Which things block a deploy is tested in test_helpers.py + @pytest.mark.parametrize("blocker", ["locked", "failed_checks"]) + def test_blocked(self, client, login, blocker): + stack = StackFactory.create_sync(locked=blocker == "locked", lock_reason="Release freeze") + commit = CommitFactory.create_sync(stack=stack) + if blocker == "failed_checks": + CommitStatusFactory.create_sync(commit=commit, name="tests", state="failure") + login() + + response = client.post(_new_url(commit)) + + assert response.location == _new_url(commit) + assert _deploys(commit.stack_id) == [] + + def test_emergency_bypasses_failed_checks(self, client, login): + commit = CommitFactory.create_sync() + CommitStatusFactory.create_sync(commit=commit, name="tests", state="failure") + login() + + client.post(_new_url(commit, emergency=1)) + + [deploy] = _deploys(commit.stack_id) + assert deploy.deployed_with_bypass is True + + def test_invalid_config(self, client, login, engage_yaml): + commit = CommitFactory.create_sync() + engage_yaml("deploy:\n timeout: soon\n") + login() + + response = client.post(_new_url(commit)) + + assert response.location == _new_url(commit) + assert _deploys(commit.stack_id) == [] + + +class TestDeployPage: + def test_shows_deploy(self, client, login): + deploy = start_running_deploy() + login() + + response = client.get(_deploy_url(deploy)) + + assert response.status_code == 200 + assert deploy.commit.commit_message.encode() in response.data + + def test_deploy_of_other_stack(self, client, login): + deploy = start_running_deploy() + other = StackFactory.create_sync() + login() + + assert ( + client.get( + url_for("stacks.deploys.detail", stack_id=other.id, deploy_id=deploy.id) + ).status_code + == 404 + ) + + +class TestSignal: + @pytest.mark.parametrize("signal", ["cancel", "interrupt"]) + def test_stores_signal(self, client, login, signal): + deploy = start_running_deploy() + login() + + response = client.post(_deploy_url(deploy, signal)) + + assert response.location == _deploy_url(deploy) + job = job_queue.deploys.fetch_job(f"deploy-{deploy.id}") + assert job is not None + assert job.meta["signal"] == signal + + def test_finished_deploy(self, client, login): + deploy = DeployFactory.create_sync() + login() + + response = client.post(_deploy_url(deploy, "cancel"), follow_redirects=True) + + assert b"Cannot signal a deploy that is not running" in response.data + + +class TestLog: + def test_returns_new_output(self, client, login): + deploy = start_running_deploy() + deploy.output = "first\nsecond\n" + db.session.commit() + login() + + response = client.get(_deploy_url(deploy, "log", offset=6)) + + assert response.status_code == 200 + assert b"second" in response.data + assert b"first" not in response.data + + def test_stops_polling_when_finished(self, client, login): + deploy = DeployFactory.create_sync() + login() + + # 286 tells HTMX to stop polling + assert client.get(_deploy_url(deploy, "log")).status_code == 286 + + +class TestDeployList: + def test_newest_first(self, client, login): + stack = StackFactory.create_sync() + now = datetime.now(UTC) + older = CommitFactory.create_sync(stack=stack, commit_message="Older") + newer = CommitFactory.create_sync(stack=stack, commit_message="Newer") + DeployFactory.create_sync(commit=older, started_at=now - timedelta(days=1)) + DeployFactory.create_sync(commit=newer, started_at=now) + login() + + response = client.get(url_for("stacks.deploys.list", stack_id=stack.id)) + + assert response.status_code == 200 + assert response.data.index(b"Newer") < response.data.index(b"Older") diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py new file mode 100644 index 0000000..882568c --- /dev/null +++ b/tests/factories/__init__.py @@ -0,0 +1,18 @@ +"""Test data, one file per model. Each factory fills the fields a test doesn't set with random +values, and `create_sync()` saves the row: DeployFactory.create_sync(status=DeployStatus.FAILED) + +Fields that change how the app behaves (status, locks, archiving...) are pinned to normal values, +so a test only sets what it is about. A new factory goes in its own file and is added below""" + +from tests.factories.commit import CommitFactory +from tests.factories.commit_status import CommitStatusFactory +from tests.factories.deploy import DeployFactory, start_running_deploy +from tests.factories.stack import StackFactory + +__all__ = [ + "CommitFactory", + "CommitStatusFactory", + "DeployFactory", + "StackFactory", + "start_running_deploy", +] diff --git a/tests/factories/base.py b/tests/factories/base.py new file mode 100644 index 0000000..99115aa --- /dev/null +++ b/tests/factories/base.py @@ -0,0 +1,13 @@ +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory + +from makeitso.extensions import db + + +class BaseFactory[T](SQLAlchemyFactory[T]): + __is_base_factory__ = True + # create_sync() adds the row to this session and commits + __session__ = db.session + # Ids come from the database, so they follow creation order like real rows + __set_primary_key__ = False + # Parents are set through their relationship; a random id would point nowhere + __set_foreign_keys__ = False diff --git a/tests/factories/commit.py b/tests/factories/commit.py new file mode 100644 index 0000000..8f17969 --- /dev/null +++ b/tests/factories/commit.py @@ -0,0 +1,13 @@ +from polyfactory import Use + +from makeitso.models.commit import Commit +from tests.factories.base import BaseFactory +from tests.factories.stack import StackFactory + + +class CommitFactory(BaseFactory[Commit]): + # A new stack, saved with the commit + stack = Use(StackFactory.build) + deploys = Use(list) + commit_statuses = Use(list) + pull_request_number = None diff --git a/tests/factories/commit_status.py b/tests/factories/commit_status.py new file mode 100644 index 0000000..a6912d9 --- /dev/null +++ b/tests/factories/commit_status.py @@ -0,0 +1,11 @@ +from polyfactory import Use + +from makeitso.models.commit_status import CommitStatus +from tests.factories.base import BaseFactory +from tests.factories.commit import CommitFactory + + +# A CI check; passing unless the test sets state="failure" +class CommitStatusFactory(BaseFactory[CommitStatus]): + commit = Use(CommitFactory.build) + state = "success" diff --git a/tests/factories/deploy.py b/tests/factories/deploy.py new file mode 100644 index 0000000..7137057 --- /dev/null +++ b/tests/factories/deploy.py @@ -0,0 +1,29 @@ +from polyfactory import Ignore, PostGenerated, Use + +from makeitso.deploys.tasks import start_deploy +from makeitso.models.commit import Commit +from makeitso.models.deploy import Deploy, DeployStatus +from tests.factories.base import BaseFactory +from tests.factories.commit import CommitFactory + + +# A finished, successful deploy +class DeployFactory(BaseFactory[Deploy]): + commit = Use(CommitFactory.build) + # Always the commit's own stack + stack = PostGenerated(lambda _name, values: values["commit"].stack) + since_commit = None + status = DeployStatus.SUCCEEDED + output = "" + deployed_with_bypass = False + deployed_by = "octocat" + # The database sets it to now + started_at = Ignore() + ended_at = None + + +def start_running_deploy(commit: Commit | None = None) -> Deploy: + """A running deploy with its queued job, as the deploy page starts it. + DeployFactory can't make one: without a job, pages would mark it as lost""" + commit = commit or CommitFactory.create_sync() + return start_deploy(commit.stack, commit, timeout=600, deployed_by="octocat") diff --git a/tests/factories/stack.py b/tests/factories/stack.py new file mode 100644 index 0000000..32a7b0e --- /dev/null +++ b/tests/factories/stack.py @@ -0,0 +1,18 @@ +from polyfactory import Use + +from makeitso.models.stack import Stack +from tests.factories.base import BaseFactory + + +class StackFactory(BaseFactory[Stack]): + # No commits, deploys or env vars unless a test makes them + __set_relationships__ = False + + environment = "dev" + locked = False + lock_reason = None + continuous_deploy = False + allow_failures = Use(list) + synced_at = None + sync_failures = 0 + archived_at = None diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/app.py b/tests/fixtures/app.py new file mode 100644 index 0000000..ecf2339 --- /dev/null +++ b/tests/fixtures/app.py @@ -0,0 +1,54 @@ +import pytest +from fakeredis import FakeRedis +from rq import Queue + +from makeitso import create_app +from makeitso.extensions import db +from makeitso.redis_queue import QUEUE_NAMES + + +# One app and one set of tables for the whole test run. +# A request context (not just an app context), so tests can build URLs with url_for +@pytest.fixture(scope="session") +def app(): + app = create_app("pytest") + with app.test_request_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture +def client(app): + return app.test_client() + + +# Empty every table after each test, so tests don't see each other's rows +@pytest.fixture(autouse=True) +def clean_db(app): + yield + db.session.rollback() + for table in reversed(db.metadata.sorted_tables): + db.session.execute(table.delete()) + db.session.commit() + + +# An empty in-memory Redis for each test, so no test reaches a real one +@pytest.fixture(autouse=True) +def fake_redis(app, monkeypatch): + connection = FakeRedis() + queues = {name: Queue(name, connection=connection) for name in QUEUE_NAMES} + monkeypatch.setitem(app.extensions, "rq_queues", queues) + return connection + + +# Log the test client in as a GitHub user: login() or login("someone") +@pytest.fixture +def login(client): + def login(user: str = "octocat") -> None: + with client.session_transaction() as session: + session["user"] = {"login": user, "avatar_url": ""} + session["token"] = {"access_token": "test-token"} + + return login diff --git a/tests/fixtures/engage.py b/tests/fixtures/engage.py new file mode 100644 index 0000000..b1a49df --- /dev/null +++ b/tests/fixtures/engage.py @@ -0,0 +1,17 @@ +import pytest + +from makeitso.github import GitHubRepo + + +# Files the app reads from a repo through the GitHub API (engage.yaml), served from memory +# No files (so the engage.yaml defaults) unless a test calls engage_yaml("...") +@pytest.fixture(autouse=True) +def engage_yaml(monkeypatch): + files: dict[str, str] = {} + monkeypatch.setattr(GitHubRepo, "file_content", lambda repo, path, ref: files.get(path)) + + # engage_yaml("deploy:\n timeout: 60\n") or engage_yaml(text, "engage.prod.yaml") + def engage_yaml(text: str, name: str = "engage.yaml") -> None: + files[name] = text + + return engage_yaml diff --git a/tests/main/test_routes.py b/tests/main/test_routes.py index 99d0828..6b72cb7 100644 --- a/tests/main/test_routes.py +++ b/tests/main/test_routes.py @@ -1,43 +1,29 @@ -import pytest +from tests.factories import StackFactory -from makeitso.extensions import db -from makeitso.models.stack import Stack +class TestHealthz: + def test_ok(self, client): + response = client.get("/healthz") -@pytest.fixture -def stack(): - stack = Stack( - organization="bcgov", - repository="secret-repo", - branch="develop", - name="secret-repo-dev", - environment="dev", - ) - db.session.add(stack) - db.session.commit() - return stack + assert response.status_code == 200 + assert response.json == {"status": "ok"} -def test_healthz(client): - response = client.get("/healthz") +class TestIndex: + def test_logged_out_hides_stacks(self, client): + stack = StackFactory.create_sync() - assert response.status_code == 200 - assert response.json == {"status": "ok"} + response = client.get("/") + assert response.status_code == 200 + assert b"Log in with GitHub" in response.data + assert stack.repository.encode() not in response.data -def test_index_logged_out_hides_stacks(client, stack): - response = client.get("/") + def test_logged_in_lists_stacks(self, client, login): + stack = StackFactory.create_sync() + login() - assert response.status_code == 200 - assert b"Log in with GitHub" in response.data - assert b"secret-repo" not in response.data + response = client.get("/") - -def test_index_logged_in_lists_stacks(client, stack): - with client.session_transaction() as session: - session["user"] = {"login": "octocat", "avatar_url": ""} - - response = client.get("/") - - assert response.status_code == 200 - assert b"bcgov/secret-repo" in response.data + assert response.status_code == 200 + assert f"{stack.organization}/{stack.repository}".encode() in response.data diff --git a/uv.lock b/uv.lock index 7bec297..2fd3b58 100644 --- a/uv.lock +++ b/uv.lock @@ -384,6 +384,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "faker" +version = "40.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/f5/cf09c597bac1c2600e4f77200a7abb73e20fb2a542c11d0c557c1c4220f5/faker-40.41.0.tar.gz", hash = "sha256:5383e4ebba7061491530ae5952a33dcf8f161a250c89717fa0ac856602dcfaf3", size = 2031440, upload-time = "2026-10-05T22:08:14.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/52/9ae853e8c70d6b77fe81b1563378d8ee49aa316cf19502a2e0bf1e4a873e/faker-40.41.0-py3-none-any.whl", hash = "sha256:35a7f66282990698c20c3e6dd57180900206b760c2f777112c085cc217e7de92", size = 2067633, upload-time = "2026-10-05T22:08:12.554Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/27/3ed3eee5e5a929345c37024b814a70f6e2452ffdab77a2680c2ebba3614a/fakeredis-2.39.0.tar.gz", hash = "sha256:e89c3410f290330042638ff5cca3e22788fa267dcaf28a64b4f483e14577208d", size = 301722, upload-time = "2026-10-01T12:35:19.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ca/8bf657139922808196e6480ec6ed94008897e23d603abd5b27538cfdf811/fakeredis-2.39.0-py3-none-any.whl", hash = "sha256:acd1450575259634db2942d5bae93e383aac32bb9968aab29fe7b0c2ab880bb8", size = 186508, upload-time = "2026-10-01T12:35:17.899Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -526,6 +551,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "fakeredis" }, + { name = "polyfactory" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -553,6 +580,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "fakeredis", specifier = ">=2.39.0" }, + { name = "polyfactory", specifier = ">=3.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.16.8" }, @@ -619,6 +648,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polyfactory" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/68/7717bd9e63ed254617a7d3dc9260904fb736d6ea203e58ffddcb186c64e4/polyfactory-3.3.0.tar.gz", hash = "sha256:237258b6ff43edf362ffd1f68086bb796466f786adfa002b0ac256dbf2246e9a", size = 348668, upload-time = "2026-02-22T09:46:28.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/34/b6f19941adcdaf415b5e8a8d577499f5b6a76b59cbae37f9b125a9ffe9f2/polyfactory-3.3.0-py3-none-any.whl", hash = "sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e", size = 62707, upload-time = "2026-02-22T09:46:25.985Z" }, +] + [[package]] name = "psycopg" version = "3.3.6" @@ -953,6 +995,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.1"