blob: 9711e4eec7bacbb0c99ebf426df04efed4217594 [file]
# 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 urllib.error
import urllib.request as _ur
from email.message import Message
import pytest
from magpie_gitlab.client import (
GitLabConfig,
GitLabError,
_auth_headers,
_build_opener,
_SafeRedirectHandler,
get_json,
get_paged_json,
get_project,
load_config,
quote_path,
)
from .conftest import build_mock_response
# ---------------------------------------------------------------------------
# load_config
# ---------------------------------------------------------------------------
def test_load_config_default(monkeypatch):
monkeypatch.delenv("GITLAB_INSTANCE_URL", raising=False)
monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_TOKEN", "token")
cfg = load_config()
assert cfg.instance_url == "https://gitlab.com"
assert cfg.token == "token"
assert cfg.token_type == "bearer"
assert cfg.auth_scheme == ""
def test_load_config_ci_job_token(monkeypatch):
"""CI_JOB_TOKEN should be used when GITLAB_TOKEN is absent."""
monkeypatch.delenv("GITLAB_INSTANCE_URL", raising=False)
monkeypatch.delenv("GITLAB_TOKEN", raising=False)
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("CI_JOB_TOKEN", "ci-job-tok-456")
cfg = load_config()
assert cfg.token == "ci-job-tok-456"
assert cfg.token_type == "job_token"
def test_load_config_gitlab_token_takes_precedence(monkeypatch):
"""GITLAB_TOKEN wins when both are set."""
monkeypatch.delenv("GITLAB_INSTANCE_URL", raising=False)
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_TOKEN", "pat-wins")
monkeypatch.setenv("CI_JOB_TOKEN", "ci-loses")
cfg = load_config()
assert cfg.token == "pat-wins"
assert cfg.token_type == "bearer"
def test_load_config_insecure_url(monkeypatch):
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_TOKEN", "token")
monkeypatch.setenv("GITLAB_INSTANCE_URL", "http://gitlab.insecure.com")
with pytest.raises(
GitLabError,
match="Insecure instance URL scheme 'http': HTTPS is required",
):
load_config()
def test_load_config_localhost_http_allowed(monkeypatch):
"""HTTP is allowed for localhost / 127.0.0.1 / ::1 (local dev / testing)."""
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_TOKEN", "token")
monkeypatch.setenv("GITLAB_INSTANCE_URL", "http://localhost:8080")
cfg = load_config()
assert cfg.instance_url == "http://localhost:8080"
monkeypatch.setenv("GITLAB_INSTANCE_URL", "http://127.0.0.1:8080")
cfg2 = load_config()
assert cfg2.instance_url == "http://127.0.0.1:8080"
def test_load_config_localhost_non_http_rejected(monkeypatch):
"""Non-HTTP schemes on localhost (ftp, file, etc.) must be rejected."""
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_TOKEN", "token")
monkeypatch.setenv("GITLAB_INSTANCE_URL", "ftp://localhost:21")
with pytest.raises(
GitLabError,
match="Insecure instance URL scheme 'ftp': HTTPS is required",
):
load_config()
monkeypatch.setenv("GITLAB_INSTANCE_URL", "file://localhost/tmp")
with pytest.raises(
GitLabError,
match="Insecure instance URL scheme 'file': HTTPS is required",
):
load_config()
def test_load_config_custom(mock_env):
cfg = load_config()
assert cfg.instance_url == "https://gitlab.example.com"
assert cfg.token == "glpat-test123"
# ---------------------------------------------------------------------------
# quote_path
# ---------------------------------------------------------------------------
def test_quote_path():
assert quote_path("group/project") == "group%2Fproject"
# ---------------------------------------------------------------------------
# _build_opener (unmocked)
# ---------------------------------------------------------------------------
def test_build_opener_installs_safe_redirect_handler():
opener = _build_opener()
handlers = getattr(opener, "handlers", [])
assert any(isinstance(h, _SafeRedirectHandler) for h in handlers)
# ---------------------------------------------------------------------------
# SafeRedirectHandler
# ---------------------------------------------------------------------------
def test_safe_redirect_blocks_https_to_http():
"""HTTPS->HTTP downgrade must be blocked."""
handler = _SafeRedirectHandler()
req = _ur.Request("https://gitlab.example.com/api")
with pytest.raises(GitLabError, match="HTTPS-to-HTTP downgrade"):
handler.redirect_request(req, None, 302, "Found", {}, "http://gitlab.example.com/api")
def test_safe_redirect_blocks_cross_origin():
"""Cross-origin host redirect must be blocked."""
handler = _SafeRedirectHandler()
req = _ur.Request("https://gitlab.example.com/api")
with pytest.raises(GitLabError, match="cross-origin redirect"):
handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example.com/steal")
def test_safe_redirect_blocks_port_mismatch():
"""Cross-port redirect on same host must be blocked."""
handler = _SafeRedirectHandler()
req = _ur.Request("https://gitlab.example.com/api")
with pytest.raises(GitLabError, match="cross-origin redirect"):
handler.redirect_request(req, None, 302, "Found", {}, "https://gitlab.example.com:8443/api")
def test_safe_redirect_allows_same_origin():
"""Same-origin same-scheme absolute redirect should be allowed."""
handler = _SafeRedirectHandler()
req = _ur.Request("https://gitlab.example.com/api/old")
result = handler.redirect_request(req, None, 302, "Found", {}, "https://gitlab.example.com/api/new")
assert result is not None
assert result.full_url == "https://gitlab.example.com/api/new"
def test_safe_redirect_allows_relative_same_origin():
"""Relative redirect on same origin should be resolved and allowed."""
handler = _SafeRedirectHandler()
req = _ur.Request("https://gitlab.example.com/api/v4/projects")
result = handler.redirect_request(req, None, 302, "Found", {}, "/api/v4/projects/1")
assert result is not None
assert result.full_url == "https://gitlab.example.com/api/v4/projects/1"
# ---------------------------------------------------------------------------
# _auth_headers
# ---------------------------------------------------------------------------
def test_auth_headers_unauthenticated():
cfg = GitLabConfig(token=None, instance_url="https://gitlab.example.com")
headers = _auth_headers(cfg)
assert headers == {"Accept": "application/json"}
def test_auth_headers_glpat_default():
cfg = GitLabConfig(token="glpat-secret", instance_url="https://gitlab.example.com")
headers = _auth_headers(cfg)
assert headers.get("PRIVATE-TOKEN") == "glpat-secret"
assert "Authorization" not in headers
def test_auth_headers_bearer_default():
cfg = GitLabConfig(token="oauth-token", instance_url="https://gitlab.example.com")
headers = _auth_headers(cfg)
assert headers.get("Authorization") == "Bearer oauth-token"
assert "PRIVATE-TOKEN" not in headers
def test_auth_headers_job_token_default():
cfg = GitLabConfig(token="job-tok", instance_url="https://gitlab.example.com", token_type="job_token")
headers = _auth_headers(cfg)
assert headers.get("JOB-TOKEN") == "job-tok"
def test_auth_headers_explicit_scheme_bearer():
"""Explicit GITLAB_AUTH_SCHEME=Bearer overrides glpat- default."""
cfg = GitLabConfig(
token="glpat-token",
instance_url="https://gitlab.example.com",
auth_scheme="Bearer",
)
headers = _auth_headers(cfg)
assert headers.get("Authorization") == "Bearer glpat-token"
assert "PRIVATE-TOKEN" not in headers
def test_auth_headers_explicit_scheme_private_token():
cfg = GitLabConfig(
token="custom-pat",
instance_url="https://gitlab.example.com",
auth_scheme="Private-Token",
)
headers = _auth_headers(cfg)
assert headers.get("PRIVATE-TOKEN") == "custom-pat"
def test_auth_headers_explicit_scheme_job_token():
cfg = GitLabConfig(
token="custom-job-tok",
instance_url="https://gitlab.example.com",
auth_scheme="Job-Token",
)
headers = _auth_headers(cfg)
assert headers.get("JOB-TOKEN") == "custom-job-tok"
def test_auth_headers_explicit_scheme_jobtoken_no_hyphen():
cfg = GitLabConfig(
token="custom-job-tok",
instance_url="https://gitlab.example.com",
auth_scheme="JobToken",
)
headers = _auth_headers(cfg)
assert headers.get("JOB-TOKEN") == "custom-job-tok"
def test_auth_headers_invalid_scheme_raises():
cfg = GitLabConfig(
token="token",
instance_url="https://gitlab.example.com",
auth_scheme="Basic",
)
with pytest.raises(GitLabError, match="Unsupported GITLAB_AUTH_SCHEME: 'Basic'"):
_auth_headers(cfg)
# ---------------------------------------------------------------------------
# get_json
# ---------------------------------------------------------------------------
def test_get_json_unauthenticated(mock_urlopen, monkeypatch):
"""Unauthenticated public reads should succeed with no auth header."""
monkeypatch.delenv("GITLAB_TOKEN", raising=False)
monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
monkeypatch.delenv("GITLAB_AUTH_SCHEME", raising=False)
monkeypatch.setenv("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
mock_urlopen.return_value = build_mock_response({"public": "repo"})
cfg = load_config()
res = get_json("https://gitlab.example.com/api/v4/projects/public%2Frepo", cfg)
assert res == {"public": "repo"}
req = mock_urlopen.call_args[0][0]
assert "Authorization" not in req.headers
assert "Private-token" not in req.headers
assert "Job-token" not in req.headers
assert req.headers.get("Accept") == "application/json"
def test_get_json_http_error(mock_urlopen, mock_env):
mock_urlopen.side_effect = urllib.error.HTTPError("url", 404, "Not Found", Message(), None)
cfg = load_config()
with pytest.raises(GitLabError, match="HTTP 404: Not Found"):
get_json("https://gitlab.example.com/api", cfg)
# ---------------------------------------------------------------------------
# get_paged_json -- pagination & bounded limit
# ---------------------------------------------------------------------------
def test_get_paged_json_single_page(mock_urlopen, mock_env):
mock_urlopen.return_value = build_mock_response([{"id": 1}], headers={"X-Next-Page": ""})
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg)
assert items == [{"id": 1}]
assert mock_urlopen.call_count == 1
def test_get_paged_json_multi_page(mock_urlopen, mock_env):
page1 = build_mock_response([{"id": 1}], headers={"X-Next-Page": "2"})
page2 = build_mock_response([{"id": 2}], headers={"X-Next-Page": ""})
mock_urlopen.side_effect = [page1, page2]
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues?state=opened", cfg)
assert items == [{"id": 1}, {"id": 2}]
assert mock_urlopen.call_count == 2
# Verify second request preserved query params
req2 = mock_urlopen.call_args_list[1][0][0]
assert "state=opened" in req2.full_url
assert "page=2" in req2.full_url
def test_get_paged_json_max_pages(mock_urlopen, mock_env, capsys):
"""Pagination must halt when max_pages ceiling is reached."""
page1 = build_mock_response([{"id": 1}], headers={"X-Next-Page": "2"})
page2 = build_mock_response([{"id": 2}], headers={"X-Next-Page": "3"})
page3 = build_mock_response([{"id": 3}], headers={"X-Next-Page": ""})
mock_urlopen.side_effect = [page1, page2, page3]
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg, max_pages=2)
assert items == [{"id": 1}, {"id": 2}]
assert mock_urlopen.call_count == 2
err = capsys.readouterr().err
assert "Results capped at 2 items (2 pages)" in err
def test_get_paged_json_with_limit_fewer_than_page(mock_urlopen, mock_env, capsys):
"""Limit fewer than page size fetches only 1 page, caps items, and emits no notice."""
data = [{"id": i} for i in range(100)]
mock_urlopen.return_value = build_mock_response(data, headers={"X-Next-Page": "2"})
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg, limit=5)
assert len(items) == 5
assert mock_urlopen.call_count == 1
err = capsys.readouterr().err
assert err == ""
def test_get_paged_json_short_page_continues_until_limit(mock_urlopen, mock_env):
"""When pages return fewer items than default per_page, pagination continues until limit is met."""
page1 = build_mock_response([{"id": 1}, {"id": 2}], headers={"X-Next-Page": "2"})
page2 = build_mock_response([{"id": 3}, {"id": 4}], headers={"X-Next-Page": "3"})
page3 = build_mock_response([{"id": 5}, {"id": 6}], headers={"X-Next-Page": ""})
mock_urlopen.side_effect = [page1, page2, page3]
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg, limit=5)
assert len(items) == 5
assert [x["id"] for x in items] == [1, 2, 3, 4, 5]
assert mock_urlopen.call_count == 3
def test_get_paged_json_with_limit_multi_page(mock_urlopen, mock_env):
"""Limit spanning multiple pages only fetches the required number of pages."""
page1 = build_mock_response([{"id": i} for i in range(100)], headers={"X-Next-Page": "2"})
page2 = build_mock_response([{"id": i} for i in range(100, 200)], headers={"X-Next-Page": "3"})
mock_urlopen.side_effect = [page1, page2]
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg, limit=150)
assert len(items) == 150
assert mock_urlopen.call_count == 2
def test_get_paged_json_non_list_single(mock_urlopen, mock_env):
mock_urlopen.return_value = build_mock_response({"single": "object"})
cfg = load_config()
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/resource", cfg)
assert items == [{"single": "object"}]
def test_get_paged_json_non_list_subsequent_raises(mock_urlopen, mock_env):
page1 = build_mock_response([{"id": 1}], headers={"X-Next-Page": "2"})
page2 = build_mock_response({"error": "invalid"}, headers={"X-Next-Page": ""})
mock_urlopen.side_effect = [page1, page2]
cfg = load_config()
with pytest.raises(GitLabError, match="Unexpected non-list response"):
get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg)
def test_get_paged_json_limit_gt_1000(mock_urlopen, mock_env):
"""Limit > 1000 should bypass the max_pages=10 default and fetch required pages."""
pages = []
# limit=1050 means we need 11 pages (100 items per page).
for i in range(1, 12):
next_page = str(i + 1) if i < 11 else ""
pages.append(build_mock_response([{"id": j} for j in range(100)], headers={"X-Next-Page": next_page}))
mock_urlopen.side_effect = pages
cfg = load_config()
# default max_pages is 10, but limit=1050 should override target_pages to 11
items = get_paged_json("https://gitlab.example.com/api/v4/projects/test/issues", cfg, limit=1050)
assert len(items) == 1050
assert mock_urlopen.call_count == 11
# ---------------------------------------------------------------------------
# get_project
# ---------------------------------------------------------------------------
def test_get_project(mock_urlopen, mock_env):
mock_urlopen.return_value = build_mock_response({"id": 42, "name": "my-project"})
cfg = load_config()
project = get_project("group/my-project", cfg)
assert project == {"id": 42, "name": "my-project"}
req = mock_urlopen.call_args[0][0]
assert req.full_url == "https://gitlab.example.com/api/v4/projects/group%2Fmy-project"