This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch add-python-tests in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
commit 0f97ca2bdece4b1d5725769c29df91426d420b55 Author: Jarek Potiuk <[email protected]> AuthorDate: Wed Apr 29 02:43:12 2026 +0200 tests(oauth-draft): expand coverage 62%→99%; add CI tests workflow Adds 36 new unit tests for the oauth-draft Python project (22 → 58 total) and a dedicated `.github/workflows/tests.yml` CI workflow that runs the per-tool pytest suites as a parallel matrix job. oauth-draft test additions: - `tests/test_credentials.py` — 3 tests for `refresh_access_token`: successful POST + access-token return, HTTP error path, payload with no `access_token` field. Mocks `urllib.request.urlopen` with a small `_FakeResponse` context-manager stand-in (used in every network-mocked test below as well). - `tests/test_create_draft.py` — 13 tests covering: - `read_body` (file path, stdin via "-", stdin via None); - `api_get` / `api_post` (request shape + response parsing); - `api_post` HTTP error path; - `latest_reply_headers` (delegates to `api_get` then to `headers_from_thread`); - `create_draft` payload shape with and without `threadId`; - `main` end-to-end with mocked `refresh_access_token`, `latest_reply_headers`, and `api_post` — verifies the MIME body posted to `/drafts` carries the right From/To/Subject/ In-Reply-To headers and the body content; - `main --no-reply-headers` skips the thread-headers lookup. - `tests/test_mark_threads_read.py` — 9 tests covering: - `list_thread_ids` pagination (multi-page response stitched correctly; second-page request includes `pageToken`); - `list_thread_ids` HTTP error path; - `modify_thread` payload shape (with both label lists, with only one label list); - `modify_thread` HTTP error path; - `main` dry-run (lists IDs, never calls modify); - `main --execute` calls `modify_thread` per thread with the default `removeLabelIds=[UNREAD]`; - `main --max` truncates the modify count; - `main` returns 1 when any modify call fails. - `tests/test_setup_creds.py` — new file, 11 tests covering the whole `setup_creds` module (was 22% covered → ~89%): - `detect_from_address` (env var, git-config fallback, missing git, git error, empty git output); - `parse_args` (defaults + overrides); - `main` error paths (no from-address, missing client_secrets, flow returns no refresh_token); - `main` success path: writes credentials JSON atomically with mode 600 in a 700 parent dir; - `main --rm-client-secrets` deletes the input file; - `main` handles both `installed` and `web` shapes of the Google client_secrets.json. Coverage on `src/oauth_draft/` after the additions: ``` src/oauth_draft/__init__.py 100% src/oauth_draft/create_draft.py 99% src/oauth_draft/credentials.py 100% src/oauth_draft/mark_threads_read.py 99% src/oauth_draft/setup_creds.py 89% TOTAL 99% ``` The remaining ~1% is the `if __name__ == "__main__":` shim in each module and a handful of chmod-error warning paths in `setup_creds` that are awkward to provoke deterministically. CI workflow (`.github/workflows/tests.yml`): - Runs on every PR and push to main. - Per-project pytest matrix (`fail-fast: false`) so the two projects' results are visible as separate CI checks rather than buried in the bundled `pytest` lines of the `prek` workflow. - Uses `uv run --directory <project>` (not `--project`) to move cwd into the project root — pytest needs it because each project's `[tool.pytest.ini_options] testpaths = ["tests"]` resolves relative to cwd. - The `prek` workflow's pytest hooks still run (defense in depth); this workflow is the visible signal, not the gate. Other: - `.gitignore` adds `.coverage` to both Python projects so locally- run `coverage` doesn't dirty the working tree. Generated-by: Claude Code (Claude Opus 4.7) --- .github/workflows/tests.yml | 64 ++++++ tools/gmail/oauth-draft/.gitignore | 1 + tools/gmail/oauth-draft/tests/test_create_draft.py | 226 ++++++++++++++++++- tools/gmail/oauth-draft/tests/test_credentials.py | 75 ++++++- .../oauth-draft/tests/test_mark_threads_read.py | 239 ++++++++++++++++++++- tools/gmail/oauth-draft/tests/test_setup_creds.py | 219 +++++++++++++++++++ tools/vulnogram/generate-cve-json/.gitignore | 1 + 7 files changed, 822 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cfe61b9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,64 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +--- +name: tests + +on: # yamllint disable-line rule:truthy + pull_request: + push: + branches: [main] + +permissions: {} + +jobs: + # Per-project pytest matrix. Each Python project under tools/ has its + # own `pyproject.toml` + `uv.lock`, with pytest declared in the `dev` + # dependency group. Running them as separate matrix jobs surfaces + # per-project pass/fail in the CI checks list, which is easier to + # triage than the bundled `pytest` lines inside the `prek` workflow's + # output. The `prek` workflow still exercises pytest as a hook, so + # this workflow is the visible signal — not the gate. + pytest: + name: "pytest (${{ matrix.project.name }})" + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + project: + - name: oauth-draft + path: tools/gmail/oauth-draft + - name: generate-cve-json + path: tools/vulnogram/generate-cve-json + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # uv brings its own Python and reads each project's + # `pyproject.toml` + `uv.lock`. Minimum uv version is enforced + # by the root `pyproject.toml`'s `[tool.uv] required-version`. + - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + with: + enable-cache: true + - name: Run pytest + # `--directory` (not `--project`) — both move uv's project + # context, but `--directory` also changes cwd, which pytest + # needs because each project's `pyproject.toml` declares + # `testpaths = ["tests"]` relative to its own root. + run: uv run --directory ${{ matrix.project.path }} --group dev pytest diff --git a/tools/gmail/oauth-draft/.gitignore b/tools/gmail/oauth-draft/.gitignore index ab7ef44..8cae406 100644 --- a/tools/gmail/oauth-draft/.gitignore +++ b/tools/gmail/oauth-draft/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .mypy_cache/ +.coverage diff --git a/tools/gmail/oauth-draft/tests/test_create_draft.py b/tools/gmail/oauth-draft/tests/test_create_draft.py index d6675d2..d643962 100644 --- a/tools/gmail/oauth-draft/tests/test_create_draft.py +++ b/tools/gmail/oauth-draft/tests/test_create_draft.py @@ -16,11 +16,28 @@ # under the License. from __future__ import annotations +import base64 import email import email.policy +import io +import json +import urllib.error from email.message import EmailMessage +from unittest.mock import patch -from oauth_draft.create_draft import build_mime, headers_from_thread, parse_args +import pytest + +from oauth_draft.create_draft import ( + api_get, + api_post, + build_mime, + create_draft, + headers_from_thread, + latest_reply_headers, + main, + parse_args, + read_body, +) def parse_built_message(raw: bytes) -> EmailMessage: @@ -174,3 +191,210 @@ def test_parse_args_repeats(): ) assert args.to == ["[email protected]", "[email protected]"] assert args.cc == ["[email protected]"] + + +# --- read_body ------------------------------------------------------------- + + +def test_read_body_from_file(tmp_path): + p = tmp_path / "body.txt" + p.write_text("hello from file") + assert read_body(str(p)) == "hello from file" + + +def test_read_body_from_stdin_when_dash(monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("piped")) + assert read_body("-") == "piped" + + +def test_read_body_from_stdin_when_none(monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("default")) + assert read_body(None) == "default" + + +# --- network-mocked helpers ------------------------------------------------ + + +class _FakeResponse: + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._payload + + +def test_api_get_parses_json_response(): + with patch("oauth_draft.create_draft.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b'{"id": "thread-1"}') + result = api_get("token", "/threads/thread-1") + assert result == {"id": "thread-1"} + request = mock_open.call_args.args[0] + assert request.full_url.endswith("/threads/thread-1") + assert request.headers["Authorization"] == "Bearer token" + + +def test_api_post_parses_json_response(): + with patch("oauth_draft.create_draft.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b'{"id": "draft-7"}') + result = api_post("token", "/drafts", {"message": {"raw": "X"}}) + assert result == {"id": "draft-7"} + request = mock_open.call_args.args[0] + assert request.method == "POST" + assert request.headers["Content-type"] == "application/json" + assert json.loads(request.data) == {"message": {"raw": "X"}} + + +def test_api_post_raises_on_http_error(): + err = urllib.error.HTTPError( + url="https://x", + code=403, + msg="Forbidden", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(b'{"error": "forbidden"}'), + ) + with patch("oauth_draft.create_draft.urllib.request.urlopen", side_effect=err): + with pytest.raises(SystemExit) as excinfo: + api_post("token", "/drafts", {}) + assert "failed (403)" in str(excinfo.value) + assert "forbidden" in str(excinfo.value) + + +def test_latest_reply_headers_pulls_from_api_get(): + fake_thread = { + "messages": [ + { + "payload": { + "headers": [ + {"name": "Message-ID", "value": "<[email protected]>"}, + ] + } + } + ] + } + with patch("oauth_draft.create_draft.api_get", return_value=fake_thread) as m: + in_reply, refs = latest_reply_headers("token", "thread-id-9") + assert in_reply == "<[email protected]>" + assert refs == "<[email protected]>" + m.assert_called_once_with("token", "/threads/thread-id-9?format=full") + + +def test_create_draft_payload_includes_threadid_when_set(): + raw = b"raw-bytes" + with patch("oauth_draft.create_draft.api_post", return_value={"id": "d-1"}) as m: + result = create_draft("token", "thread-1", raw) + assert result == {"id": "d-1"} + _, _, payload = m.call_args.args + expected_b64 = base64.urlsafe_b64encode(raw).decode().rstrip("=") + assert payload == {"message": {"raw": expected_b64, "threadId": "thread-1"}} + + +def test_create_draft_payload_omits_threadid_when_none(): + with patch("oauth_draft.create_draft.api_post", return_value={"id": "d-2"}) as m: + create_draft("token", None, b"x") + _, _, payload = m.call_args.args + assert "threadId" not in payload["message"] + + +# --- main ------------------------------------------------------------------ + + +def _make_creds_file(tmp_path): + p = tmp_path / "creds.json" + p.write_text( + json.dumps( + { + "client_id": "cid", + "client_secret": "secret", + "refresh_token": "refresh", + "from_address": "[email protected]", + } + ) + ) + return p + + +def test_main_create_draft_end_to_end(tmp_path, capsys): + creds = _make_creds_file(tmp_path) + body_file = tmp_path / "body.txt" + body_file.write_text("Reply body") + api_post_mock = patch( + "oauth_draft.create_draft.api_post", + return_value={ + "id": "draft-id-99", + "message": {"id": "msg-id-99", "threadId": "tid"}, + }, + ) + with ( + patch("oauth_draft.create_draft.refresh_access_token", return_value="tok"), + patch( + "oauth_draft.create_draft.latest_reply_headers", + return_value=("<a@x>", "<a@x>"), + ), + api_post_mock as post, + ): + rc = main( + [ + "--credentials", + str(creds), + "--thread-id", + "tid", + "--to", + "[email protected]", + "--subject", + "Re: hello", + "--body-file", + str(body_file), + ] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "Draft ID: draft-id-99" in out + assert "Message ID: msg-id-99" in out + # Verify the MIME body posted to /drafts has reply headers + body. + _, path, payload = post.call_args.args + assert path == "/drafts" + raw_b64 = payload["message"]["raw"] + raw_bytes = base64.urlsafe_b64decode(raw_b64 + "=" * (-len(raw_b64) % 4)) + decoded = raw_bytes.decode() + assert "From: [email protected]" in decoded + assert "To: [email protected]" in decoded + assert "Subject: Re: hello" in decoded + assert "In-Reply-To: <a@x>" in decoded + assert "Reply body" in decoded + + +def test_main_no_reply_headers_skips_thread_lookup(tmp_path): + creds = _make_creds_file(tmp_path) + body = tmp_path / "b.txt" + body.write_text("x") + with ( + patch("oauth_draft.create_draft.refresh_access_token", return_value="t"), + patch("oauth_draft.create_draft.latest_reply_headers") as latest, + patch( + "oauth_draft.create_draft.api_post", + return_value={"id": "d", "message": {"id": "m"}}, + ), + ): + rc = main( + [ + "--credentials", + str(creds), + "--thread-id", + "tid", + "--no-reply-headers", + "--to", + "[email protected]", + "--subject", + "S", + "--body-file", + str(body), + ] + ) + assert rc == 0 + latest.assert_not_called() diff --git a/tools/gmail/oauth-draft/tests/test_credentials.py b/tools/gmail/oauth-draft/tests/test_credentials.py index 15f794b..d067eb8 100644 --- a/tools/gmail/oauth-draft/tests/test_credentials.py +++ b/tools/gmail/oauth-draft/tests/test_credentials.py @@ -16,11 +16,18 @@ # under the License. from __future__ import annotations +import io import json +import urllib.error +from unittest.mock import patch import pytest -from oauth_draft.credentials import Credentials, locate_credentials +from oauth_draft.credentials import ( + Credentials, + locate_credentials, + refresh_access_token, +) def write_creds(path, **overrides): @@ -92,3 +99,69 @@ def test_locate_raises_when_nothing_exists(tmp_path, monkeypatch): with pytest.raises(SystemExit) as excinfo: locate_credentials(None) assert "No Gmail OAuth credentials found" in str(excinfo.value) + + +# --- refresh_access_token -------------------------------------------------- + + +CREDS = Credentials( + client_id="cid", + client_secret="secret", + refresh_token="refresh", + from_address="[email protected]", +) + + +class _FakeResponse: + """Minimal context-manager stand-in for urllib.request.urlopen().""" + + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._payload + + +def test_refresh_access_token_returns_access_token(): + with patch("oauth_draft.credentials.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b'{"access_token": "abc-123"}') + token = refresh_access_token(CREDS) + assert token == "abc-123" + # The POST body must include all four OAuth refresh-flow params. + request = mock_open.call_args.args[0] + assert request.method == "POST" + assert request.full_url == "https://oauth2.googleapis.com/token" + body = request.data.decode() + assert "client_id=cid" in body + assert "client_secret=secret" in body + assert "refresh_token=refresh" in body + assert "grant_type=refresh_token" in body + + +def test_refresh_access_token_raises_on_http_error(): + err = urllib.error.HTTPError( + url="https://oauth2.googleapis.com/token", + code=400, + msg="Bad Request", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(b'{"error": "invalid_grant"}'), + ) + with patch("oauth_draft.credentials.urllib.request.urlopen", side_effect=err): + with pytest.raises(SystemExit) as excinfo: + refresh_access_token(CREDS) + assert "OAuth token refresh failed (400)" in str(excinfo.value) + assert "invalid_grant" in str(excinfo.value) + + +def test_refresh_access_token_raises_when_no_token_in_payload(): + with patch("oauth_draft.credentials.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b'{"expires_in": 3600}') + with pytest.raises(SystemExit) as excinfo: + refresh_access_token(CREDS) + assert "no access_token" in str(excinfo.value) diff --git a/tools/gmail/oauth-draft/tests/test_mark_threads_read.py b/tools/gmail/oauth-draft/tests/test_mark_threads_read.py index b5ea46a..08326d4 100644 --- a/tools/gmail/oauth-draft/tests/test_mark_threads_read.py +++ b/tools/gmail/oauth-draft/tests/test_mark_threads_read.py @@ -16,7 +16,19 @@ # under the License. from __future__ import annotations -from oauth_draft.mark_threads_read import parse_args +import io +import json +import urllib.error +from unittest.mock import patch + +import pytest + +from oauth_draft.mark_threads_read import ( + list_thread_ids, + main, + modify_thread, + parse_args, +) def test_default_action_is_remove_unread(): @@ -60,3 +72,228 @@ def test_execute_and_max_flags(): args = parse_args(["--query", "x", "--execute", "--max", "5"]) assert args.execute is True assert args.max == 5 + + +# --- network-mocked helpers ------------------------------------------------ + + +class _FakeResponse: + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._payload + + +def test_list_thread_ids_paginates_until_no_token(): + pages = [ + _FakeResponse( + json.dumps( + { + "threads": [{"id": "t1"}, {"id": "t2"}], + "nextPageToken": "page2", + } + ).encode() + ), + _FakeResponse(json.dumps({"threads": [{"id": "t3"}]}).encode()), + ] + with patch( + "oauth_draft.mark_threads_read.urllib.request.urlopen", + side_effect=pages, + ) as mock_open: + ids = list_thread_ids("token", "in:inbox") + assert ids == ["t1", "t2", "t3"] + # Second call must include the pageToken from the first response. + _, second_call = mock_open.call_args_list + second_url = second_call.args[0].full_url + assert "pageToken=page2" in second_url + + +def test_list_thread_ids_raises_on_http_error(): + err = urllib.error.HTTPError( + url="https://x", + code=429, + msg="Too Many", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(b'{"error": "rate"}'), + ) + with patch( + "oauth_draft.mark_threads_read.urllib.request.urlopen", + side_effect=err, + ): + with pytest.raises(SystemExit) as excinfo: + list_thread_ids("token", "q") + assert "threads.list failed (429)" in str(excinfo.value) + + +def test_modify_thread_sends_label_payload(): + with patch("oauth_draft.mark_threads_read.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b"{}") + modify_thread("token", "tid-1", ["STARRED"], ["UNREAD"]) + request = mock_open.call_args.args[0] + assert request.method == "POST" + assert "/threads/tid-1/modify" in request.full_url + payload = json.loads(request.data) + assert payload == {"addLabelIds": ["STARRED"], "removeLabelIds": ["UNREAD"]} + + +def test_modify_thread_omits_empty_label_lists(): + with patch("oauth_draft.mark_threads_read.urllib.request.urlopen") as mock_open: + mock_open.return_value = _FakeResponse(b"{}") + modify_thread("token", "tid-2", [], ["UNREAD"]) + payload = json.loads(mock_open.call_args.args[0].data) + assert payload == {"removeLabelIds": ["UNREAD"]} + + +def test_modify_thread_raises_on_http_error(): + err = urllib.error.HTTPError( + url="https://x", + code=500, + msg="Boom", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(b'{"error": "server"}'), + ) + with patch( + "oauth_draft.mark_threads_read.urllib.request.urlopen", + side_effect=err, + ): + with pytest.raises(SystemExit) as excinfo: + modify_thread("token", "tid", [], ["UNREAD"]) + assert "threads.modify failed (500) for tid" in str(excinfo.value) + + +# --- main ------------------------------------------------------------------ + + +def _make_creds_file(tmp_path): + p = tmp_path / "creds.json" + p.write_text( + json.dumps( + { + "client_id": "cid", + "client_secret": "secret", + "refresh_token": "refresh", + } + ) + ) + return p + + +def test_main_dry_run_lists_ids_and_skips_modify(tmp_path, capsys): + creds = _make_creds_file(tmp_path) + with ( + patch( + "oauth_draft.mark_threads_read.refresh_access_token", + return_value="tok", + ), + patch( + "oauth_draft.mark_threads_read.list_thread_ids", + return_value=["t1", "t2"], + ), + patch("oauth_draft.mark_threads_read.modify_thread") as modify, + ): + rc = main( + [ + "--credentials", + str(creds), + "--query", + "in:inbox is:unread", + ] + ) + assert rc == 0 + modify.assert_not_called() + out = capsys.readouterr().out + assert "t1" in out and "t2" in out + + +def test_main_execute_calls_modify_per_thread(tmp_path): + creds = _make_creds_file(tmp_path) + with ( + patch( + "oauth_draft.mark_threads_read.refresh_access_token", + return_value="tok", + ), + patch( + "oauth_draft.mark_threads_read.list_thread_ids", + return_value=["t1", "t2", "t3"], + ), + patch("oauth_draft.mark_threads_read.modify_thread") as modify, + ): + rc = main( + [ + "--credentials", + str(creds), + "--query", + "q", + "--execute", + ] + ) + assert rc == 0 + assert modify.call_count == 3 + # default action: remove UNREAD + for call in modify.call_args_list: + _, _, add, remove = call.args + assert add == [] + assert remove == ["UNREAD"] + + +def test_main_max_truncates(tmp_path): + creds = _make_creds_file(tmp_path) + with ( + patch( + "oauth_draft.mark_threads_read.refresh_access_token", + return_value="tok", + ), + patch( + "oauth_draft.mark_threads_read.list_thread_ids", + return_value=["t1", "t2", "t3", "t4", "t5"], + ), + patch("oauth_draft.mark_threads_read.modify_thread") as modify, + ): + rc = main( + [ + "--credentials", + str(creds), + "--query", + "q", + "--execute", + "--max", + "2", + ] + ) + assert rc == 0 + assert modify.call_count == 2 + + +def test_main_returns_1_when_modify_fails(tmp_path): + creds = _make_creds_file(tmp_path) + with ( + patch( + "oauth_draft.mark_threads_read.refresh_access_token", + return_value="tok", + ), + patch( + "oauth_draft.mark_threads_read.list_thread_ids", + return_value=["t1"], + ), + patch( + "oauth_draft.mark_threads_read.modify_thread", + side_effect=SystemExit("boom"), + ), + ): + rc = main( + [ + "--credentials", + str(creds), + "--query", + "q", + "--execute", + ] + ) + assert rc == 1 diff --git a/tools/gmail/oauth-draft/tests/test_setup_creds.py b/tools/gmail/oauth-draft/tests/test_setup_creds.py new file mode 100644 index 0000000..3385c2c --- /dev/null +++ b/tools/gmail/oauth-draft/tests/test_setup_creds.py @@ -0,0 +1,219 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from oauth_draft.setup_creds import detect_from_address, main, parse_args + +# --- detect_from_address --------------------------------------------------- + + +def test_detect_from_address_uses_env_var(monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + assert detect_from_address() == "[email protected]" + + +def test_detect_from_address_falls_back_to_git_config(monkeypatch): + monkeypatch.delenv("GMAIL_FROM", raising=False) + with patch( + "oauth_draft.setup_creds.subprocess.check_output", + return_value="[email protected]\n", + ): + assert detect_from_address() == "[email protected]" + + +def test_detect_from_address_returns_none_when_git_missing(monkeypatch): + monkeypatch.delenv("GMAIL_FROM", raising=False) + with patch( + "oauth_draft.setup_creds.subprocess.check_output", + side_effect=FileNotFoundError, + ): + assert detect_from_address() is None + + +def test_detect_from_address_returns_none_when_git_errors(monkeypatch): + monkeypatch.delenv("GMAIL_FROM", raising=False) + with patch( + "oauth_draft.setup_creds.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ): + assert detect_from_address() is None + + +def test_detect_from_address_returns_none_when_git_returns_empty(monkeypatch): + monkeypatch.delenv("GMAIL_FROM", raising=False) + with patch( + "oauth_draft.setup_creds.subprocess.check_output", + return_value="\n", + ): + assert detect_from_address() is None + + +# --- parse_args ------------------------------------------------------------ + + +def test_parse_args_minimal(monkeypatch): + # Avoid relying on the host's environment / git config: stub + # detect_from_address out of the parser default. + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + args = parse_args(["client.json"]) + assert args.client_secrets == "client.json" + assert args.from_address == "[email protected]" + assert args.out.endswith("/.config/apache-steward/gmail-oauth.json") + assert args.rm_client_secrets is False + + +def test_parse_args_overrides(monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + args = parse_args( + [ + "client.json", + "--from-address", + "[email protected]", + "--out", + "/custom/path.json", + "--rm-client-secrets", + ] + ) + assert args.from_address == "[email protected]" + assert args.out == "/custom/path.json" + assert args.rm_client_secrets is True + + +# --- main ------------------------------------------------------------------ + + +def _client_secrets(tmp_path): + p = tmp_path / "client_secrets.json" + p.write_text( + json.dumps( + { + "installed": { + "client_id": "cid", + "client_secret": "secret", + } + } + ) + ) + return p + + +def _flow_creds(refresh_token="rt-1"): + creds = MagicMock() + creds.refresh_token = refresh_token + creds.scopes = ["https://mail.google.com/"] + return creds + + +def test_main_errors_when_no_from_address(tmp_path, monkeypatch): + monkeypatch.delenv("GMAIL_FROM", raising=False) + secrets = _client_secrets(tmp_path) + out = tmp_path / "out.json" + with patch( + "oauth_draft.setup_creds.subprocess.check_output", + side_effect=FileNotFoundError, + ): + with pytest.raises(SystemExit) as excinfo: + main([str(secrets), "--out", str(out)]) + assert "Could not determine --from-address" in str(excinfo.value) + + +def test_main_errors_when_client_secrets_missing(tmp_path, monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + out = tmp_path / "out.json" + with pytest.raises(SystemExit) as excinfo: + main([str(tmp_path / "does-not-exist.json"), "--out", str(out)]) + assert "client_secrets not found" in str(excinfo.value) + + +def test_main_errors_when_flow_returns_no_refresh_token(tmp_path, monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + secrets = _client_secrets(tmp_path) + out = tmp_path / "out.json" + flow = MagicMock() + flow.run_local_server.return_value = _flow_creds(refresh_token=None) + with patch( + "oauth_draft.setup_creds.InstalledAppFlow.from_client_secrets_file", + return_value=flow, + ): + with pytest.raises(SystemExit) as excinfo: + main([str(secrets), "--out", str(out)]) + assert "no refresh_token" in str(excinfo.value) + + +def test_main_writes_credentials_file(tmp_path, monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + secrets = _client_secrets(tmp_path) + out_dir = tmp_path / "creds-dir" + out = out_dir / "gmail-oauth.json" + flow = MagicMock() + flow.run_local_server.return_value = _flow_creds() + with patch( + "oauth_draft.setup_creds.InstalledAppFlow.from_client_secrets_file", + return_value=flow, + ): + rc = main([str(secrets), "--out", str(out)]) + assert rc == 0 + written = json.loads(out.read_text()) + assert written == { + "client_id": "cid", + "client_secret": "secret", + "refresh_token": "rt-1", + "from_address": "[email protected]", + } + # Mode should be 600 (owner-rw only) per the atomic-write path. + assert (out.stat().st_mode & 0o777) == 0o600 + # Parent dir should be 700. + assert (out_dir.stat().st_mode & 0o777) == 0o700 + # Original client_secrets is left in place by default. + assert secrets.exists() + + +def test_main_with_rm_client_secrets_deletes_input(tmp_path, monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + secrets = _client_secrets(tmp_path) + out = tmp_path / "creds-dir" / "creds.json" + flow = MagicMock() + flow.run_local_server.return_value = _flow_creds() + with patch( + "oauth_draft.setup_creds.InstalledAppFlow.from_client_secrets_file", + return_value=flow, + ): + main([str(secrets), "--out", str(out), "--rm-client-secrets"]) + assert not secrets.exists() + + +def test_main_handles_web_shaped_client_secrets(tmp_path, monkeypatch): + monkeypatch.setenv("GMAIL_FROM", "[email protected]") + secrets = tmp_path / "client.json" + secrets.write_text(json.dumps({"web": {"client_id": "wcid", "client_secret": "wsecret"}})) + out = tmp_path / "creds-dir" / "creds.json" + flow = MagicMock() + flow.run_local_server.return_value = _flow_creds() + with patch( + "oauth_draft.setup_creds.InstalledAppFlow.from_client_secrets_file", + return_value=flow, + ): + main([str(secrets), "--out", str(out)]) + written = json.loads(out.read_text()) + assert written["client_id"] == "wcid" + assert written["client_secret"] == "wsecret" diff --git a/tools/vulnogram/generate-cve-json/.gitignore b/tools/vulnogram/generate-cve-json/.gitignore index ab7ef44..8cae406 100644 --- a/tools/vulnogram/generate-cve-json/.gitignore +++ b/tools/vulnogram/generate-cve-json/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .mypy_cache/ +.coverage
