From 078514b69e51d24eb40c064b9cfbbcdd2164587c Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Wed, 11 Mar 2026 19:21:44 +0100 Subject: [PATCH 01/21] Add CORS handling, and load settings from .env --- app/main.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index 6396921..b450ed0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,40 @@ ''' Simple Geolocation with FastAPI ''' +import os from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network from typing import Annotated, Optional, Union import geoip2.database -from geoip2.errors import AddressNotFoundError -from fastapi import FastAPI, Path, Body, Request, Response, status +from dotenv import load_dotenv +from fastapi import Body, FastAPI, Path, Request, Response, status +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from geoip2.errors import AddressNotFoundError from pydantic import BaseModel +# Load environment variables +load_dotenv() + app = FastAPI() -GEOLITE2_ASN_DB = '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb' -GEOLITE2_CITY_DB = '/usr/local/share/GeoIP/GeoLite2-City.mmdb' +# Configure CORS from environment variables +cors_origins = os.getenv('CORS_ALLOW_ORIGINS', 'http://localhost') +allow_origins = [origin.strip() for origin in cors_origins.split(',') + if origin.strip()] + +app.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], +) + +GEOLITE2_ASN_DB = os.getenv('GEOLITE2_ASN_DB', + '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb') +GEOLITE2_CITY_DB = os.getenv('GEOLITE2_CITY_DB', + '/usr/local/share/GeoIP/GeoLite2-City.mmdb') class IPAddressParam(BaseModel): From 479f9900443f1ef44077c4c7ec2432ed4da70c9e Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Fri, 13 Mar 2026 22:25:24 +0100 Subject: [PATCH 02/21] add more linters --- .gitea/workflows/bandit.yml | 17 +++++++++++++++++ .gitea/workflows/pip-audit.yml | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .gitea/workflows/bandit.yml create mode 100644 .gitea/workflows/pip-audit.yml diff --git a/.gitea/workflows/bandit.yml b/.gitea/workflows/bandit.yml new file mode 100644 index 0000000..36d634e --- /dev/null +++ b/.gitea/workflows/bandit.yml @@ -0,0 +1,17 @@ +--- +name: Bandit +on: [push] + + +# XXX need to do stuff with uv +jobs: + build: + runs-on: freebsd + strategy: + matrix: + python-version: ["3.11"] + steps: + - uses: actions/checkout@v4 + - name: Analyse code with Bandit + run: | + bandit -r . diff --git a/.gitea/workflows/pip-audit.yml b/.gitea/workflows/pip-audit.yml new file mode 100644 index 0000000..b713cda --- /dev/null +++ b/.gitea/workflows/pip-audit.yml @@ -0,0 +1,23 @@ +--- +name: pip-audit +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 0' # Weekly on Sunday + +# XXX need to do stuff with uv +jobs: + build: + runs-on: freebsd + strategy: + matrix: + python-version: ["3.11"] + steps: + - uses: actions/checkout@v4 + - name: Check vulnerable components with pip-audit + run: | + pip-audit . + From 8ec34cfbeb0f9a42da097f63c4c3951ebb2b553d Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Fri, 13 Mar 2026 22:26:15 +0100 Subject: [PATCH 03/21] Ignore vim swapfiles --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5d381cc..5170c57 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,5 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Vim swap files +*.sw? From 8f63d5ae683e96e9243496426e582ef7714ba681 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sat, 14 Mar 2026 11:04:41 +0100 Subject: [PATCH 04/21] look in requirements.txt --- .gitea/workflows/pip-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/pip-audit.yml b/.gitea/workflows/pip-audit.yml index b713cda..6e04983 100644 --- a/.gitea/workflows/pip-audit.yml +++ b/.gitea/workflows/pip-audit.yml @@ -19,5 +19,5 @@ jobs: - uses: actions/checkout@v4 - name: Check vulnerable components with pip-audit run: | - pip-audit . + pip-audit -r requirements.txt From 76a1cb86ed39fe18b4e69c0ee1465beb798ceb61 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sat, 14 Mar 2026 15:58:24 +0100 Subject: [PATCH 05/21] Use the linux/docker runner --- .gitea/workflows/pip-audit.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/pip-audit.yml b/.gitea/workflows/pip-audit.yml index 6e04983..f4795d7 100644 --- a/.gitea/workflows/pip-audit.yml +++ b/.gitea/workflows/pip-audit.yml @@ -11,13 +11,28 @@ on: # XXX need to do stuff with uv jobs: build: - runs-on: freebsd + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: + - "3.11" steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade pip-audit + pip install -r requirements.txt + - name: Check vulnerable components with pip-audit run: | pip-audit -r requirements.txt - From 97a4a797b258b1eda1412de1b4786f580ef467e5 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sat, 14 Mar 2026 16:33:52 +0100 Subject: [PATCH 06/21] Chase current FreeBSD versions --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 900a300..f157602 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -geoip2==4.7.0 -fastapi==0.115.6 +geoip2==5.1.0 +fastapi==0.128.0 From 5427fc69b0fce4ec9347e4599edf70ccece1d2fe Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sat, 14 Mar 2026 17:05:49 +0100 Subject: [PATCH 07/21] Fix mypy issue to catch situation where we might not have a client address --- app/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index b450ed0..b9306ae 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,8 @@ from typing import Annotated, Optional, Union import geoip2.database from dotenv import load_dotenv -from fastapi import Body, FastAPI, Path, Request, Response, status +from fastapi import (Body, FastAPI, HTTPException, Path, Request, Response, + status) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse from geoip2.errors import AddressNotFoundError @@ -126,4 +127,6 @@ def root_redirect(req: Request) -> RedirectResponse: ''' Redirect empty request using REMOTE_ADDR ''' + if not req.client: + raise HTTPException(status_code=404, detail="Item not found") return RedirectResponse(url=str(req.url) + str(req.client.host)) From 79d4ec6eb6beacf7bc6567658df31933c2d50cb2 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 11:01:18 +0100 Subject: [PATCH 08/21] Rework to use the debian docker based runner --- .gitea/workflows/bandit.yml | 31 +++++++++++++++++++++++++------ .gitea/workflows/flake8.yml | 30 ++++++++++++++++++++++++------ .gitea/workflows/mypy.yml | 31 +++++++++++++++++++++++++------ .gitea/workflows/pip-audit.yml | 6 +++--- .gitea/workflows/pylint.yml | 30 ++++++++++++++++++++++++------ 5 files changed, 101 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/bandit.yml b/.gitea/workflows/bandit.yml index 36d634e..3290eed 100644 --- a/.gitea/workflows/bandit.yml +++ b/.gitea/workflows/bandit.yml @@ -1,17 +1,36 @@ --- name: Bandit -on: [push] - +on: + push: + branches: [main] + pull_request: + branches: [main] # XXX need to do stuff with uv jobs: - build: - runs-on: freebsd + audit-runtime-security: + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: + - "3.11" steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade bandit + pip install -r requirements.txt + - name: Analyse code with Bandit run: | bandit -r . diff --git a/.gitea/workflows/flake8.yml b/.gitea/workflows/flake8.yml index a50707e..48396fc 100644 --- a/.gitea/workflows/flake8.yml +++ b/.gitea/workflows/flake8.yml @@ -1,17 +1,35 @@ --- name: Flake8 -on: [push] - +on: + push: + branches: [main] + pull_request: + branches: [main] # XXX need to do stuff with uv jobs: - build: - runs-on: freebsd + audit: + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: + - "3.11" steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade flake8 + pip install -r requirements.txt - name: Analyse code with Flake8 run: | flake8 $(git ls-files '*.py') diff --git a/.gitea/workflows/mypy.yml b/.gitea/workflows/mypy.yml index 94c12ea..ac3438f 100644 --- a/.gitea/workflows/mypy.yml +++ b/.gitea/workflows/mypy.yml @@ -1,17 +1,36 @@ --- name: Mypy -on: [push] - +on: + push: + branches: [main] + pull_request: + branches: [main] # XXX need to do stuff with uv jobs: - build: - runs-on: freebsd + audit-typing: + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: + - "3.11" steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade mypy + pip install -r requirements.txt + - name: Analyse code with Mypy run: | mypy --install-types --non-interactive $(git ls-files '*.py') diff --git a/.gitea/workflows/pip-audit.yml b/.gitea/workflows/pip-audit.yml index f4795d7..47b1db0 100644 --- a/.gitea/workflows/pip-audit.yml +++ b/.gitea/workflows/pip-audit.yml @@ -10,17 +10,17 @@ on: # XXX need to do stuff with uv jobs: - build: + audit-dependency-security: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.11" steps: - - name: Checkout code + - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v4 with: python-version: '${{ matrix.python-version }}' diff --git a/.gitea/workflows/pylint.yml b/.gitea/workflows/pylint.yml index d101f6a..ee4e8de 100644 --- a/.gitea/workflows/pylint.yml +++ b/.gitea/workflows/pylint.yml @@ -1,17 +1,35 @@ --- name: Pylint -on: [push] - +on: + push: + branches: [main] + pull_request: + branches: [main] # XXX need to do stuff with uv jobs: - build: - runs-on: freebsd + audit-runtime-security: + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: + - "3.11" steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade pylint + pip install -r requirements.txt - name: Analyse code with Pylint run: | pylint $(git ls-files '*.py') From 633c09ef64c96667ab80110b024a3ee288264d3d Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 11:02:05 +0100 Subject: [PATCH 09/21] Add code coverage --- .gitea/workflows/python-coverage.yml | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .gitea/workflows/python-coverage.yml diff --git a/.gitea/workflows/python-coverage.yml b/.gitea/workflows/python-coverage.yml new file mode 100644 index 0000000..a138770 --- /dev/null +++ b/.gitea/workflows/python-coverage.yml @@ -0,0 +1,50 @@ +name: Python Coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-and-coverage: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.11" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + python -m pip install --upgrade pytest-cov + + - name: Run tests with coverage + run: | + pytest --cov=./ --cov-report=term --cov-report=xml --cov-report=html --junitxml=report.xml + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v3 + with: + name: coverage-reports + path: | + coverage.xml + htmlcov/ + + - name: Upload test results + uses: actions/upload-artifact@v3 + with: + name: test-results + path: report.xml From 230d031e6755ba31c66d50b283e51abbb7ef3ce2 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 12:21:06 +0100 Subject: [PATCH 10/21] Add code coverage. also, install dev depends from requirements-dev.txt --- .gitea/workflows/bandit.yml | 2 +- .gitea/workflows/flake8.yml | 2 +- .gitea/workflows/mypy.yml | 2 +- .gitea/workflows/pip-audit.yml | 2 +- .gitea/workflows/pylint.yml | 2 +- .gitea/workflows/python-coverage.yml | 4 +- .gitignore | 1 + app/test_iplookup.py | 91 ++++++++++++++++++++++++++++ requirements-dev.txt | 5 ++ 9 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 app/test_iplookup.py create mode 100644 requirements-dev.txt diff --git a/.gitea/workflows/bandit.yml b/.gitea/workflows/bandit.yml index 3290eed..1ef602e 100644 --- a/.gitea/workflows/bandit.yml +++ b/.gitea/workflows/bandit.yml @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade bandit pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Analyse code with Bandit run: | diff --git a/.gitea/workflows/flake8.yml b/.gitea/workflows/flake8.yml index 48396fc..69ec686 100644 --- a/.gitea/workflows/flake8.yml +++ b/.gitea/workflows/flake8.yml @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade flake8 pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Analyse code with Flake8 run: | flake8 $(git ls-files '*.py') diff --git a/.gitea/workflows/mypy.yml b/.gitea/workflows/mypy.yml index ac3438f..0409561 100644 --- a/.gitea/workflows/mypy.yml +++ b/.gitea/workflows/mypy.yml @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade mypy pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Analyse code with Mypy run: | diff --git a/.gitea/workflows/pip-audit.yml b/.gitea/workflows/pip-audit.yml index 47b1db0..7f41fb1 100644 --- a/.gitea/workflows/pip-audit.yml +++ b/.gitea/workflows/pip-audit.yml @@ -30,8 +30,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade pip-audit pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Check vulnerable components with pip-audit run: | diff --git a/.gitea/workflows/pylint.yml b/.gitea/workflows/pylint.yml index ee4e8de..e43b69c 100644 --- a/.gitea/workflows/pylint.yml +++ b/.gitea/workflows/pylint.yml @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade pylint pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Analyse code with Pylint run: | pylint $(git ls-files '*.py') diff --git a/.gitea/workflows/python-coverage.yml b/.gitea/workflows/python-coverage.yml index a138770..92435a9 100644 --- a/.gitea/workflows/python-coverage.yml +++ b/.gitea/workflows/python-coverage.yml @@ -29,11 +29,11 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - python -m pip install --upgrade pytest-cov + pip install -r requirements-dev.txt - name: Run tests with coverage run: | - pytest --cov=./ --cov-report=term --cov-report=xml --cov-report=html --junitxml=report.xml + pytest --cov=./ --cov-report=term --cov-report=xml --cov-report=html --junitxml=report.xml app - name: Upload coverage artifacts uses: actions/upload-artifact@v3 diff --git a/.gitignore b/.gitignore index 5170c57..35995e6 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +report.xml # Translations *.mo diff --git a/app/test_iplookup.py b/app/test_iplookup.py new file mode 100644 index 0000000..139aaff --- /dev/null +++ b/app/test_iplookup.py @@ -0,0 +1,91 @@ +''' +Test ismijnverweg geolookup api +''' +import logging +import random +import re +from operator import itemgetter + +from faker import Faker +from fastapi.testclient import TestClient + +from .main import app + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Faker for generating test data +fake = Faker() + +# Create test client +fake_ipv6 = fake.ipv6() +client = TestClient(app, client=(fake_ipv6, 31337)) + + +def test_no_query(): + """Test searching without a query parameter""" + + response = client.get("/") + + assert response.status_code == 200 + results = response.json() + logging.info(results) + assert results['ip'] == fake_ipv6 + assert len(results) > 0 + + +def test_single_query(): + """Test searching with an ip address""" + fake_ipv4 = fake.ipv4_public() + + response = client.get(f"/{fake_ipv4}") + + assert response.status_code == 200 + results = response.json() + logging.info(results) + assert results['ip'] == fake_ipv4 + assert len(results) > 0 + + +def test_multi_query(): + """Test searching with an ip address""" + fake_ips = [{'ip': fake.ipv6() if random.random() > 0.5 else fake.ipv4()} + for _ in range(16)] + + response = client.post("/", json=fake_ips) + + assert response.status_code == 200 + results = response.json() + logging.info(results) + + for ip in map(itemgetter('ip'), results): + assert ip in map(itemgetter('ip'), fake_ips) + + assert len(results) > 0 + + +def test_invalid_query(): + """Test searching with an invalid ip address""" + invalid_ip = '500.312.77.31337' + test_pattern = 'Input is not a valid IPv[46] address' + + response = client.get(f"/{invalid_ip}") + + assert response.status_code == 422 + results = response.json() + logging.info(results) + assert all(map(lambda x: x == invalid_ip, ( + map(itemgetter('input'), results['detail'])))) + assert all(map(lambda x: re.match(test_pattern, x), ( + map(itemgetter('msg'), results['detail'])))) + assert len(results) > 0 + + +if __name__ == "__main__": + # Run tests + test_no_query() + test_single_query() + test_invalid_query() + test_multi_query() + print("All tests passed!") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..dc4504d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +faker==40.11.0 +flake8==7.3.0 +mypy==1.19.1 +pylint==4.0.5 +pytest-cov==7.0.0 From 592d0ccbfbd6a4c4bd11d40d0bd3ebe4806783ae Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 15:02:13 +0100 Subject: [PATCH 11/21] Move tests to their own folder --- .gitea/workflows/python-coverage.yml | 2 +- tests/pytest.ini | 2 ++ {app => tests}/test_iplookup.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 tests/pytest.ini rename {app => tests}/test_iplookup.py (99%) diff --git a/.gitea/workflows/python-coverage.yml b/.gitea/workflows/python-coverage.yml index 92435a9..565d8df 100644 --- a/.gitea/workflows/python-coverage.yml +++ b/.gitea/workflows/python-coverage.yml @@ -33,7 +33,7 @@ jobs: - name: Run tests with coverage run: | - pytest --cov=./ --cov-report=term --cov-report=xml --cov-report=html --junitxml=report.xml app + pytest --cov=./ --cov-report=term --cov-report=xml --cov-report=html --junitxml=report.xml tests - name: Upload coverage artifacts uses: actions/upload-artifact@v3 diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..c6f165f --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = ../app diff --git a/app/test_iplookup.py b/tests/test_iplookup.py similarity index 99% rename from app/test_iplookup.py rename to tests/test_iplookup.py index 139aaff..e5ea522 100644 --- a/app/test_iplookup.py +++ b/tests/test_iplookup.py @@ -9,7 +9,7 @@ from operator import itemgetter from faker import Faker from fastapi.testclient import TestClient -from .main import app +from main import app # Set up logging logging.basicConfig(level=logging.INFO) From 45c55a4ddf544a742072d5a8c329250ee69bddb9 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 15:03:13 +0100 Subject: [PATCH 12/21] Exclude tests and venv --- .gitea/workflows/bandit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/bandit.yml b/.gitea/workflows/bandit.yml index 1ef602e..e70599d 100644 --- a/.gitea/workflows/bandit.yml +++ b/.gitea/workflows/bandit.yml @@ -33,4 +33,4 @@ jobs: - name: Analyse code with Bandit run: | - bandit -r . + bandit -x '**/test_*.py,./.venv/**' -r . From 0b053d2572c798ce6678ba50a1d2b09e05eddfa5 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 15:41:57 +0100 Subject: [PATCH 13/21] Ignore mypy for this specific case in the test --- tests/test_iplookup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_iplookup.py b/tests/test_iplookup.py index e5ea522..55f3956 100644 --- a/tests/test_iplookup.py +++ b/tests/test_iplookup.py @@ -9,7 +9,7 @@ from operator import itemgetter from faker import Faker from fastapi.testclient import TestClient -from main import app +from main import app # type: ignore # Set up logging logging.basicConfig(level=logging.INFO) From 86bfb0171ba46b365d3c18ee2b9d9394e4ff2e41 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 17:46:23 +0100 Subject: [PATCH 14/21] Fix pylint not finding the app from test --- .pylintrc | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..243a383 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,33 @@ +[MASTER] +init-hook="import os, sys; sys.path.append('app')" + +# Enable only error and fatal messages initially +disable=all +enable=E,F + +[FORMAT] +max-line-length=120 + +[BASIC] +# Good variable names which should always be accepted +good-names=i,j,k,ex,Run,_,id + +[TYPECHECK] +# Don't check for missing member access +ignore-mixin-members=yes + +[VARIABLES] +# Don't check for unused arguments in overridden methods +dummy-variables-rgx=_|dummy|^ignored_|^unused_ + +[DESIGN] +# Maximum number of arguments for function / method +max-args=10 + +[SIMILARITIES] +# Minimum lines number of a similarity +min-similarity-lines=4 + +[MISCELLANEOUS] +# List of note tags to take in consideration +notes=FIXME,XXX,TODO From a2ab54d53846c5853c6d09e22567cc99e691047f Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 18:28:47 +0100 Subject: [PATCH 15/21] Add python-dotenv as a runtime dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f157602..59b1eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ geoip2==5.1.0 fastapi==0.128.0 +python-dotenv==1.2.1 From edbabdb9327175bc3fe13af7b6f5ffaf1b3996f6 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 18:32:02 +0100 Subject: [PATCH 16/21] Add bandit as a development dependency --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index dc4504d..e851c5c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ flake8==7.3.0 mypy==1.19.1 pylint==4.0.5 pytest-cov==7.0.0 +bandit==1.7.10 From de419a92b642eaad39a91147bc1cd1206810f9d6 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 18:32:25 +0100 Subject: [PATCH 17/21] Add httpx as a development dependency for the fastapi TestClient --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index e851c5c..68aa5be 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ mypy==1.19.1 pylint==4.0.5 pytest-cov==7.0.0 bandit==1.7.10 +httpx==0.28.1 From b9f06068b05b8c655eac56f49e5d32f1ea24dd56 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 18:33:32 +0100 Subject: [PATCH 18/21] Add pip audit as a development dependency --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 68aa5be..c453b73 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,4 @@ pylint==4.0.5 pytest-cov==7.0.0 bandit==1.7.10 httpx==0.28.1 +pip-audit==2.10.0 From bfbfc13ddaccc0967a97d012150bf2549783d929 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Sun, 15 Mar 2026 20:22:26 +0100 Subject: [PATCH 19/21] Explicitly use starlette 0.50.0 for TestClient's client parameter Seems the pipeline has starlette-0.41.3 cached, which does satisfy starlette<0.51.0,>=0.40.0 but not my specific use :) --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index c453b73..4252fcc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,4 @@ pytest-cov==7.0.0 bandit==1.7.10 httpx==0.28.1 pip-audit==2.10.0 +starlette==0.50.0 From 72cbe73cce66db2b2ef39565979e648225b6b89c Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Tue, 17 Mar 2026 13:18:53 +0100 Subject: [PATCH 20/21] Mock up geoip2.database asn + city methods in contextmanager mode --- tests/test_iplookup.py | 219 ++++++++++++++++++++++++++++++++++------- 1 file changed, 185 insertions(+), 34 deletions(-) diff --git a/tests/test_iplookup.py b/tests/test_iplookup.py index 55f3956..8bdf797 100644 --- a/tests/test_iplookup.py +++ b/tests/test_iplookup.py @@ -4,11 +4,13 @@ Test ismijnverweg geolookup api import logging import random import re +from ipaddress import ip_network from operator import itemgetter +from unittest.mock import MagicMock, patch +import geoip2.database from faker import Faker from fastapi.testclient import TestClient - from main import app # type: ignore # Set up logging @@ -23,63 +25,212 @@ fake_ipv6 = fake.ipv6() client = TestClient(app, client=(fake_ipv6, 31337)) +def gen_testdata(): + ''' + Generate some mocked up GeoIP2 City/ASN entries + ''' + continents = ('EU', 'NA', 'SA', 'AS', 'AU') + asns = {} + cities = {} + # get me max 10 networks to create mocked up entries + networks = list(filter(lambda network: (network.version == 4 + and network.prefixlen < 32 + and network.prefixlen >= 8) + or (network.version == 6 + and network.prefixlen <= 64 + and network.prefixlen >= 56), + (ip_network(fake.unique.ipv4_public(network=True) + if random.random() < 0.25 + else fake.unique.ipv6(network=True)) + for _ in range(50))))[0:10] + for network in networks: + hostaddr = next(network.hosts()) + logging.info('Using %s from %s', hostaddr, network) + asns[hostaddr] = geoip2.models.ASN( + hostaddr, + network=network, + autonomous_system_organization=fake.company(), + autonomous_system_number=fake.random_number(5)) + cities[hostaddr] = geoip2.models.City( + locales=['en'], + city={'names': {'en': fake.city()}}, + country={'iso_code': fake.country_code(), + 'names': {'en': fake.country()}}, + continent={'code': random.choice(continents)}) + return asns, cities + + +def get_mock_reader(test_data): + ''' + Mock the geoip2.database.Reader + ''' + + def _asn_lookup(ip): + try: + logging.info('Looking up ASN info for %s', ip) + return test_data[0][ip] + except KeyError as exc: + raise geoip2.errors.AddressNotFoundError( + f'{ip} not in test database') from exc + + def _city_lookup(ip): + try: + logging.info('Looking up City info for %s', ip) + return test_data[1][ip] + except KeyError as exc: + raise geoip2.errors.AddressNotFoundError( + f'{ip} not in test database') from exc + + mock_reader = MagicMock() + mock_reader_ctx = MagicMock() + mock_reader_ctx.test_data = test_data + mock_reader_ctx.asn = _asn_lookup + mock_reader_ctx.city = _city_lookup + mock_reader.__enter__ = lambda _: mock_reader_ctx + return mock_reader + + +def mock_reader_city(ip): + ''' + Must throw geoip2.errors.AddressNotFoundError when not in our mocked data + Must return + geoip2.models.City(['en'], continent={'code': 'EU', 'geoname_id': 6255148, + 'names': {'de': 'Europa', 'en': 'Europe', 'es': 'Europa', 'fr': 'Europe', 'ja': + 'ヨーロッパ', 'pt-BR': 'Europa', 'ru': 'Европа', 'zh-CN': '欧洲'}}, + country={'geoname_id': 2750405, 'is_in_european_union': True, 'iso_code': 'NL', + 'names': {'de': 'Niederlande', 'en': 'The Netherlands', 'es': 'Holanda', 'fr': + 'Pays-Bas', 'ja': 'オランダ王国', 'pt-BR': 'Holanda', 'ru': 'Нидерланды', + 'zh-CN': '荷兰'}}, registered_country={'geoname_id': 2750405, + 'is_in_european_union': True, 'iso_code': 'NL', 'names': {'de': 'Niederlande', + 'en': 'The Netherlands', 'es': 'Holanda', 'fr': 'Pays-Bas', 'ja': + 'オランダ王国', 'pt-BR': 'Holanda', 'ru': 'Нидерланды', 'zh-CN': '荷兰'}}, + traits={'ip_address': '2a02:898:96::5e8e:f508', 'network': '2a02:898::/32'}, + city={'geoname_id': 2759794, 'names': {'de': 'Amsterdam', 'en': 'Amsterdam', + 'es': 'Ámsterdam', 'fr': 'Amsterdam', 'ja': 'Amusuterudamu', 'pt-BR': + 'Amesterdã', 'ru': 'Амстердам', 'zh-CN': '阿姆斯特丹'}}, + location={'accuracy_radius': 20, 'latitude': 52.3759, 'longitude': 4.8975, + 'time_zone': 'Europe/Amsterdam'}, postal={'code': '1012'}, + subdivisions=[{'geoname_id': 2749879, 'iso_code': 'NH', 'names': {'de': + 'Nordholland', 'en': 'North Holland', 'es': 'Holanda Septentrional', 'fr': + 'Hollande-Septentrionale', 'ja': ' + 北ホラント州', 'pt-BR': 'Holanda do Norte', 'ru': 'Северная Голландия', 'zh-CN': '北荷兰省'}}]) + geoip2.models.City( + locales: Optional[collections.abc.Sequence[str]], + *, + city: Optional[dict] = None, + continent: Optional[dict] = None, + country: Optional[dict] = None, + location: Optional[dict] = None, + ip_address: Union[str, ipaddress.IPv6Address, ipaddress.IPv4Address, NoneType] = None, + maxmind: Optional[dict] = None, + postal: Optional[dict] = None, + prefix_len: Optional[int] = None, + registered_country: Optional[dict] = None, + represented_country: Optional[dict] = None, + subdivisions: Optional[list[dict]] = None, + traits: Optional[dict] = None, + **_, + ) -> None + Docstring: Model for the City Plus web service and the City database. + File: /usr/local/lib/python3.11/site-packages/geoip2/models.py + Type: ABCMeta + Subclasses: Insights, Enterprise + ''' + pass + +def mock_reader_asn(): + ''' + Must throw geoip2.errors.AddressNotFoundError when not in our mocked data + Must return + geoip2.models.ASN('2a02:898:96::5e8e:f508', autonomous_system_number=8283, autonomous_system_organization='Netwerkvereniging Coloclue', network='2a02:898::/32') + Init signature: + geoip2.models.ASN( + ip_address: Union[str, ipaddress.IPv6Address, ipaddress.IPv4Address], + *, + autonomous_system_number: Optional[int] = None, + autonomous_system_organization: Optional[str] = None, + network: Optional[str] = None, + prefix_len: Optional[int] = None, + **_, + ) -> None + Docstring: Model class for the GeoLite2 ASN. + File: /usr/local/lib/python3.11/site-packages/geoip2/models.py + Type: ABCMeta + Subclasses: ISP + + ''' + pass + + def test_no_query(): """Test searching without a query parameter""" + test_data = gen_testdata() - response = client.get("/") + with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): - assert response.status_code == 200 - results = response.json() - logging.info(results) - assert results['ip'] == fake_ipv6 - assert len(results) > 0 + response = client.get("/") + + assert response.status_code == 200 + results = response.json() + logging.info(results) + assert results['ip'] == fake_ipv6 + assert len(results) > 0 def test_single_query(): """Test searching with an ip address""" - fake_ipv4 = fake.ipv4_public() + test_data = gen_testdata() - response = client.get(f"/{fake_ipv4}") + with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + fake_ipv4 = fake.ipv4_public() - assert response.status_code == 200 - results = response.json() - logging.info(results) - assert results['ip'] == fake_ipv4 - assert len(results) > 0 + response = client.get(f"/{fake_ipv4}") + + assert response.status_code == 200 + results = response.json() + logging.info(results) + assert results['ip'] == fake_ipv4 + assert len(results) > 0 def test_multi_query(): """Test searching with an ip address""" - fake_ips = [{'ip': fake.ipv6() if random.random() > 0.5 else fake.ipv4()} - for _ in range(16)] + test_data = gen_testdata() - response = client.post("/", json=fake_ips) + with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + fake_ips = [{'ip': fake.ipv6() if random.random() > 0.5 else fake.ipv4()} + for _ in range(16)] - assert response.status_code == 200 - results = response.json() - logging.info(results) + response = client.post("/", json=fake_ips) - for ip in map(itemgetter('ip'), results): - assert ip in map(itemgetter('ip'), fake_ips) + assert response.status_code == 200 + results = response.json() + logging.info(results) - assert len(results) > 0 + for ip in map(itemgetter('ip'), results): + assert ip in map(itemgetter('ip'), fake_ips) + + assert len(results) > 0 def test_invalid_query(): """Test searching with an invalid ip address""" - invalid_ip = '500.312.77.31337' - test_pattern = 'Input is not a valid IPv[46] address' + test_data = gen_testdata() - response = client.get(f"/{invalid_ip}") + with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + invalid_ip = '500.312.77.31337' + test_pattern = 'Input is not a valid IPv[46] address' - assert response.status_code == 422 - results = response.json() - logging.info(results) - assert all(map(lambda x: x == invalid_ip, ( - map(itemgetter('input'), results['detail'])))) - assert all(map(lambda x: re.match(test_pattern, x), ( - map(itemgetter('msg'), results['detail'])))) - assert len(results) > 0 + response = client.get(f"/{invalid_ip}") + + assert response.status_code == 422 + results = response.json() + logging.info(results) + assert all(map(lambda x: x == invalid_ip, ( + map(itemgetter('input'), results['detail'])))) + assert all(map(lambda x: re.match(test_pattern, x), ( + map(itemgetter('msg'), results['detail'])))) + assert len(results) > 0 if __name__ == "__main__": From 28ff636f870d86cedd4072611a082c16420d44aa Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Tue, 17 Mar 2026 13:41:33 +0100 Subject: [PATCH 21/21] fix flake8 defects --- tests/test_iplookup.py | 87 +++++------------------------------------- 1 file changed, 10 insertions(+), 77 deletions(-) diff --git a/tests/test_iplookup.py b/tests/test_iplookup.py index 8bdf797..fae1619 100644 --- a/tests/test_iplookup.py +++ b/tests/test_iplookup.py @@ -90,83 +90,12 @@ def get_mock_reader(test_data): return mock_reader -def mock_reader_city(ip): - ''' - Must throw geoip2.errors.AddressNotFoundError when not in our mocked data - Must return - geoip2.models.City(['en'], continent={'code': 'EU', 'geoname_id': 6255148, - 'names': {'de': 'Europa', 'en': 'Europe', 'es': 'Europa', 'fr': 'Europe', 'ja': - 'ヨーロッパ', 'pt-BR': 'Europa', 'ru': 'Европа', 'zh-CN': '欧洲'}}, - country={'geoname_id': 2750405, 'is_in_european_union': True, 'iso_code': 'NL', - 'names': {'de': 'Niederlande', 'en': 'The Netherlands', 'es': 'Holanda', 'fr': - 'Pays-Bas', 'ja': 'オランダ王国', 'pt-BR': 'Holanda', 'ru': 'Нидерланды', - 'zh-CN': '荷兰'}}, registered_country={'geoname_id': 2750405, - 'is_in_european_union': True, 'iso_code': 'NL', 'names': {'de': 'Niederlande', - 'en': 'The Netherlands', 'es': 'Holanda', 'fr': 'Pays-Bas', 'ja': - 'オランダ王国', 'pt-BR': 'Holanda', 'ru': 'Нидерланды', 'zh-CN': '荷兰'}}, - traits={'ip_address': '2a02:898:96::5e8e:f508', 'network': '2a02:898::/32'}, - city={'geoname_id': 2759794, 'names': {'de': 'Amsterdam', 'en': 'Amsterdam', - 'es': 'Ámsterdam', 'fr': 'Amsterdam', 'ja': 'Amusuterudamu', 'pt-BR': - 'Amesterdã', 'ru': 'Амстердам', 'zh-CN': '阿姆斯特丹'}}, - location={'accuracy_radius': 20, 'latitude': 52.3759, 'longitude': 4.8975, - 'time_zone': 'Europe/Amsterdam'}, postal={'code': '1012'}, - subdivisions=[{'geoname_id': 2749879, 'iso_code': 'NH', 'names': {'de': - 'Nordholland', 'en': 'North Holland', 'es': 'Holanda Septentrional', 'fr': - 'Hollande-Septentrionale', 'ja': ' - 北ホラント州', 'pt-BR': 'Holanda do Norte', 'ru': 'Северная Голландия', 'zh-CN': '北荷兰省'}}]) - geoip2.models.City( - locales: Optional[collections.abc.Sequence[str]], - *, - city: Optional[dict] = None, - continent: Optional[dict] = None, - country: Optional[dict] = None, - location: Optional[dict] = None, - ip_address: Union[str, ipaddress.IPv6Address, ipaddress.IPv4Address, NoneType] = None, - maxmind: Optional[dict] = None, - postal: Optional[dict] = None, - prefix_len: Optional[int] = None, - registered_country: Optional[dict] = None, - represented_country: Optional[dict] = None, - subdivisions: Optional[list[dict]] = None, - traits: Optional[dict] = None, - **_, - ) -> None - Docstring: Model for the City Plus web service and the City database. - File: /usr/local/lib/python3.11/site-packages/geoip2/models.py - Type: ABCMeta - Subclasses: Insights, Enterprise - ''' - pass - -def mock_reader_asn(): - ''' - Must throw geoip2.errors.AddressNotFoundError when not in our mocked data - Must return - geoip2.models.ASN('2a02:898:96::5e8e:f508', autonomous_system_number=8283, autonomous_system_organization='Netwerkvereniging Coloclue', network='2a02:898::/32') - Init signature: - geoip2.models.ASN( - ip_address: Union[str, ipaddress.IPv6Address, ipaddress.IPv4Address], - *, - autonomous_system_number: Optional[int] = None, - autonomous_system_organization: Optional[str] = None, - network: Optional[str] = None, - prefix_len: Optional[int] = None, - **_, - ) -> None - Docstring: Model class for the GeoLite2 ASN. - File: /usr/local/lib/python3.11/site-packages/geoip2/models.py - Type: ABCMeta - Subclasses: ISP - - ''' - pass - - def test_no_query(): """Test searching without a query parameter""" test_data = gen_testdata() - with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + with patch('geoip2.database.Reader', + return_value=get_mock_reader(test_data)): response = client.get("/") @@ -181,7 +110,8 @@ def test_single_query(): """Test searching with an ip address""" test_data = gen_testdata() - with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + with patch('geoip2.database.Reader', + return_value=get_mock_reader(test_data)): fake_ipv4 = fake.ipv4_public() response = client.get(f"/{fake_ipv4}") @@ -197,8 +127,10 @@ def test_multi_query(): """Test searching with an ip address""" test_data = gen_testdata() - with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): - fake_ips = [{'ip': fake.ipv6() if random.random() > 0.5 else fake.ipv4()} + with patch('geoip2.database.Reader', + return_value=get_mock_reader(test_data)): + fake_ips = [{'ip': fake.ipv6() if random.random() > 0.5 + else fake.ipv4()} for _ in range(16)] response = client.post("/", json=fake_ips) @@ -217,7 +149,8 @@ def test_invalid_query(): """Test searching with an invalid ip address""" test_data = gen_testdata() - with patch('geoip2.database.Reader', return_value=get_mock_reader(test_data)): + with patch('geoip2.database.Reader', + return_value=get_mock_reader(test_data)): invalid_ip = '500.312.77.31337' test_pattern = 'Input is not a valid IPv[46] address'