Compare commits

...

4 commits

Author SHA1 Message Date
2d958ba520 ci: forgejo publish yaml
Some checks failed
ci / build_and_publish (push) Failing after -2s
Configured to run Ruff and tests
2026-06-08 12:31:42 +01:00
fff7ccde12 test: super admin dep override
Test user super admin account added via override rather than assumed present.
2026-06-08 11:10:39 +01:00
1aac45eb76 feat: group ids in get user endpoints 2026-06-08 10:51:01 +01:00
903b24d17d ruff: config and initial run 2026-06-08 10:45:38 +01:00
18 changed files with 61 additions and 40 deletions

View file

@ -0,0 +1,23 @@
---
name: ci
on:
push:
branches:
- main
jobs:
build_and_publish:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:runner-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.19"
- run: uv python install # Gets Python version from pyproject.toml
- run: uv sync
- run: uv run pytest test
env:
ENVIRONMENT: testing

View file

@ -2,6 +2,11 @@
add-bounds = "major" add-bounds = "major"
exclude-newer = "P2W" exclude-newer = "P2W"
[tool.ruff]
exclude = [
".alembic"
]
[project] [project]
name = "cloud-api" name = "cloud-api"
version = "0.1.0" version = "0.1.0"

View file

@ -66,6 +66,9 @@ def get_super_admin_list():
def empty_su_list(): def empty_su_list():
return [] return []
def testing_su_list():
return ["admin@test.com"]
su_list_dependency = Annotated[list[User], Depends(get_super_admin_list)] su_list_dependency = Annotated[list[User], Depends(get_super_admin_list)]
async def user_model_super_admin(user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency): async def user_model_super_admin(user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency):

View file

@ -39,7 +39,7 @@ from src.iam.schemas import IAMGetGroupPermissionsResponse, IAMGetGroupUsersResp
GroupSchema, IAMPostGroupResponse, IAMPutGroupPermissionRequest, IAMPutGroupPermissionResponse, \ GroupSchema, IAMPostGroupResponse, IAMPutGroupPermissionRequest, IAMPutGroupPermissionResponse, \
IAMPutGroupUserRequest, IAMPutGroupUserResponse, IAMDeleteGroupPermissionRequest, IAMDeleteGroupPermissionResponse, \ IAMPutGroupUserRequest, IAMPutGroupUserResponse, IAMDeleteGroupPermissionRequest, IAMDeleteGroupPermissionResponse, \
IAMDeleteGroupUserRequest, IAMDeleteGroupUserResponse, IAMGetPermissionsResponse, IAMPostPermissionRequest, \ IAMDeleteGroupUserRequest, IAMDeleteGroupUserResponse, IAMGetPermissionsResponse, IAMPostPermissionRequest, \
IAMPostPermissionResponse, PermissionSchema, IAMDeletePermissionRequest, IAMGetPermissionsSearchRequest, IAMGetPermissionsSearchResponse IAMPostPermissionResponse, IAMDeletePermissionRequest, IAMGetPermissionsSearchRequest, IAMGetPermissionsSearchResponse
router = APIRouter( router = APIRouter(
tags=["IAM"], tags=["IAM"],
@ -195,7 +195,7 @@ async def delete_permission(db: db_dependency, su: super_admin_dependency, perm_
@router.post("/permissions/search", response_model=IAMGetPermissionsSearchResponse) @router.post("/permissions/search", response_model=IAMGetPermissionsSearchResponse)
async def get_permissions(db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: IAMGetPermissionsSearchRequest): async def post_permissions(db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: IAMGetPermissionsSearchRequest):
permission_query = db.query(Perm) permission_query = db.query(Perm)
if request_model.service_id is not None: if request_model.service_id is not None:

View file

@ -11,7 +11,7 @@ from src.database import db_dependency
from src.schemas import ResourceName from src.schemas import ResourceName
from src.auth.exceptions import UnauthorizedException from src.auth.exceptions import UnauthorizedException
from fastapi import HTTPException, status, Request, Depends from fastapi import Request, Depends
def valid_service_key(db: db_dependency, request: Request, rn: ResourceName) -> bool: def valid_service_key(db: db_dependency, request: Request, rn: ResourceName) -> bool:

View file

@ -22,7 +22,6 @@ from fastapi.params import Query
from psycopg.errors import UniqueViolation from psycopg.errors import UniqueViolation
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from src.auth.exceptions import UnauthorizedException
from src.contact.schemas import ContactModel from src.contact.schemas import ContactModel
from src.exceptions import UnprocessableContentException, ConflictException from src.exceptions import UnprocessableContentException, ConflictException
from src.contact.models import Contact from src.contact.models import Contact

View file

@ -42,5 +42,5 @@ class User(Base):
def groups(self): def groups(self):
result = defaultdict(list) result = defaultdict(list)
for group in self.group_rel: for group in self.group_rel:
result[group.org_rel.name].append(group.name) result[group.org_rel.name].append({"name": group.name, "id": group.id})
return dict(result) return dict(result)

View file

@ -48,7 +48,7 @@ class UserResponse(CustomBaseModel):
last_name: str last_name: str
email: str email: str
organisations: list[Optional[dict[str, str|int]]] organisations: list[Optional[dict[str, str|int]]]
groups: Optional[dict[str, list[str]]] = None groups: Optional[dict[str, list[dict[str, str|int]]]] = None
class OrgResponse(CustomBaseModel): class OrgResponse(CustomBaseModel):

View file

@ -11,7 +11,7 @@ from src.organisation.models import Organisation as Org
from src.contact.models import Contact from src.contact.models import Contact
from src.iam.models import Group, Permission from src.iam.models import Group, Permission
from src.auth.service import get_current_user, get_dev_user from src.auth.service import get_current_user, get_dev_user
from src.auth.dependencies import empty_su_list, get_super_admin_list from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
from src.database import engine, Base, get_db from src.database import engine, Base, get_db
@ -39,6 +39,7 @@ async def default_client(db_session) -> AsyncGenerator[AsyncClient, None]:
return db_session return db_session
app.dependency_overrides[get_db] = get_db_override app.dependency_overrides[get_db] = get_db_override
app.dependency_overrides[get_current_user] = get_dev_user app.dependency_overrides[get_current_user] = get_dev_user
app.dependency_overrides[get_super_admin_list] = testing_su_list
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://localhost:8000/api/v1") as ac: async with AsyncClient(transport=transport, base_url="http://localhost:8000/api/v1") as ac:
yield ac yield ac

View file

@ -6,8 +6,6 @@ Delete endpoints are currently skipped because the testing system cannot use bod
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import default_client
from src.organisation.models import Organisation as Org, OrgUsers from src.organisation.models import Organisation as Org, OrgUsers
from src.user.models import User from src.user.models import User
from src.iam.models import Group from src.iam.models import Group
@ -83,7 +81,7 @@ async def test_get_org_groups_auth_approval(default_client: AsyncClient):
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_contact_auth_approval(default_client: AsyncClient): async def test_get_org_contact_auth_approval(default_client: AsyncClient):
resp = await default_client.get(f"/org/contact?org_id=1&contact_type=billing") resp = await default_client.get("/org/contact?org_id=1&contact_type=billing")
assert resp.status_code != 422 assert resp.status_code != 422
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -3,11 +3,8 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import no_su_client
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.user.models import User from src.user.models import User
from src.iam.models import Group
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -5,8 +5,6 @@ DELETE endpoints are not tested
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import no_su_client
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.user.models import User from src.user.models import User
from src.iam.models import Group from src.iam.models import Group
@ -70,7 +68,7 @@ async def test_get_org_groups_auth_root(no_su_client: AsyncClient):
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_contact_auth_root(no_su_client: AsyncClient): async def test_get_org_contact_auth_root(no_su_client: AsyncClient):
resp = await no_su_client.get(f"/org/contact?org_id=2&contact_type=billing") resp = await no_su_client.get("/org/contact?org_id=2&contact_type=billing")
assert resp.status_code != 422 assert resp.status_code != 422
assert resp.status_code == 401 assert resp.status_code == 401
assert "Must be the org's root user" in resp.json()["detail"] assert "Must be the org's root user" in resp.json()["detail"]

View file

@ -4,8 +4,6 @@ This testing module removes the testing user override to verify that endpoints w
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import no_user_client
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_self_db_auth_user(no_user_client: AsyncClient): async def test_get_self_db_auth_user(no_user_client: AsyncClient):

View file

@ -1,7 +1,6 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import default_client
@pytest.mark.anyio @pytest.mark.anyio
async def test_healthcheck(default_client: AsyncClient): async def test_healthcheck(default_client: AsyncClient):

View file

@ -7,7 +7,7 @@ from src.user.models import User
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.iam.models import Group from src.iam.models import Group
from .conftest import default_client, db_session, generate_query_and_status from .conftest import generate_query_and_status
@pytest.mark.anyio @pytest.mark.anyio
@ -25,7 +25,7 @@ async def test_post_act_on_resource_endpoint_success(default_client: AsyncClient
data = resp.json() data = resp.json()
assert resp.status_code == 200 assert resp.status_code == 200
assert data == True assert data is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -133,7 +133,7 @@ async def test_get_group_permissions_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "permissions" in data assert "permissions" in data
assert type(data["permissions"]) == list assert isinstance(data["permissions"], list)
assert data["permissions"][0]["service_name"] == "Test Service" assert data["permissions"][0]["service_name"] == "Test Service"
assert data["permissions"][0]["resource"] == "test_resource" assert data["permissions"][0]["resource"] == "test_resource"
assert data["permissions"][0]["action"] == "read" assert data["permissions"][0]["action"] == "read"
@ -175,7 +175,7 @@ async def test_get_group_users_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "users" in data assert "users" in data
assert type(data["users"]) == list assert isinstance(data["users"], list)
assert data["users"][0]["id"] == 1 assert data["users"][0]["id"] == 1
assert data["users"][0]["first_name"] == "Admin" assert data["users"][0]["first_name"] == "Admin"
assert data["users"][0]["last_name"] == "Test" assert data["users"][0]["last_name"] == "Test"
@ -218,7 +218,7 @@ async def test_post_group_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "group" in data assert "group" in data
assert type(data["group"]) == dict assert isinstance(data["group"], dict)
assert data["group"]["name"] == "New Group" assert data["group"]["name"] == "New Group"
assert data["group"]["id"] == 2 assert data["group"]["id"] == 2
@ -255,12 +255,12 @@ async def test_put_group_perm_success(default_client: AsyncClient, db_session):
assert resp.status_code == 200 assert resp.status_code == 200
assert "group" in data assert "group" in data
assert type(data["group"]) == dict assert isinstance(data["group"], dict)
assert data["group"]["name"] == "Test Group Two" assert data["group"]["name"] == "Test Group Two"
assert data["group"]["id"] == 2 assert data["group"]["id"] == 2
assert "permissions" in data assert "permissions" in data
assert type(data["permissions"]) == list assert isinstance(data["permissions"], list)
assert data["permissions"][0]["service_name"] == "Test Service" assert data["permissions"][0]["service_name"] == "Test Service"
assert data["permissions"][0]["resource"] == "test_resource" assert data["permissions"][0]["resource"] == "test_resource"
assert data["permissions"][0]["action"] == "read" assert data["permissions"][0]["action"] == "read"
@ -316,7 +316,7 @@ async def test_put_group_perm_mismatch(default_client: AsyncClient, db_session,
db_session.add(Org(name="Another Test Org", root_user_id=1, billing_contact_id=1, owner_contact_id=2, security_contact_id=3, status="approved")) db_session.add(Org(name="Another Test Org", root_user_id=1, billing_contact_id=1, owner_contact_id=2, security_contact_id=3, status="approved"))
db_session.add(Group(name="Another Test Group", org_id=2)) db_session.add(Group(name="Another Test Group", org_id=2))
db_session.flush() db_session.flush()
resp = await default_client.put(f"/iam/group/permission", json=body) resp = await default_client.put("/iam/group/permission", json=body)
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.json()["detail"] == "Group does not belong to this organization" assert resp.json()["detail"] == "Group does not belong to this organization"
@ -332,12 +332,12 @@ async def test_put_group_user_success(default_client: AsyncClient, db_session):
assert resp.status_code == 200 assert resp.status_code == 200
assert "group" in data assert "group" in data
assert type(data["group"]) == dict assert isinstance(data["group"], dict)
assert data["group"]["name"] == "Test Group" assert data["group"]["name"] == "Test Group"
assert data["group"]["id"] == 1 assert data["group"]["id"] == 1
assert "users" in data assert "users" in data
assert type(data["users"]) == list assert isinstance(data["users"], list)
assert data["users"][1]["id"] == 2 assert data["users"][1]["id"] == 2
assert data["users"][1]["first_name"] == "User" assert data["users"][1]["first_name"] == "User"
assert data["users"][1]["last_name"] == "Test" assert data["users"][1]["last_name"] == "Test"
@ -387,7 +387,7 @@ async def test_get_permissions_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "permissions" in data assert "permissions" in data
assert type(data["permissions"]) == list assert isinstance(data["permissions"], list)
assert data["permissions"][0]["service_name"] == "Test Service" assert data["permissions"][0]["service_name"] == "Test Service"
assert data["permissions"][0]["resource"] == "test_resource" assert data["permissions"][0]["resource"] == "test_resource"
assert data["permissions"][0]["action"] == "read" assert data["permissions"][0]["action"] == "read"
@ -470,7 +470,7 @@ async def test_post_perm_search_success(default_client: AsyncClient, db_session,
assert resp.status_code == 200 assert resp.status_code == 200
assert "permissions" in data assert "permissions" in data
assert type(data["permissions"]) == list assert isinstance(data["permissions"], list)
assert data["permissions"][0]["service_name"] == "Test Service" assert data["permissions"][0]["service_name"] == "Test Service"
assert data["permissions"][0]["resource"] == "test_resource" assert data["permissions"][0]["resource"] == "test_resource"
assert data["permissions"][0]["action"] == "read" assert data["permissions"][0]["action"] == "read"

View file

@ -6,7 +6,7 @@ from httpx import AsyncClient
from src.organisation.models import Organisation, OrgUsers from src.organisation.models import Organisation, OrgUsers
from src.user.models import User from src.user.models import User
from .conftest import default_client, generate_query_and_status from .conftest import generate_query_and_status
@pytest.mark.anyio @pytest.mark.anyio
@ -151,7 +151,7 @@ async def test_get_org_users_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "users" in data assert "users" in data
assert type(data["users"]) == list assert isinstance(data["users"], list)
assert len(data["users"]) == 1 assert len(data["users"]) == 1
assert data["users"][0] == "admin@test.com" assert data["users"][0] == "admin@test.com"
@ -181,7 +181,7 @@ async def test_post_org_user_success(default_client: AsyncClient, db_session):
assert resp.status_code == 200 assert resp.status_code == 200
assert "users" in data assert "users" in data
assert type(data["users"]) == list assert isinstance(data["users"], list)
assert "user@test.org" in data["users"] assert "user@test.org" in data["users"]
@ -264,7 +264,7 @@ async def test_get_org_groups_success(default_client: AsyncClient):
assert resp.status_code == 200 assert resp.status_code == 200
assert "groups" in data assert "groups" in data
assert type(data["groups"]) == list assert isinstance(data["groups"], list)
assert "Test Group" in data["groups"] assert "Test Group" in data["groups"]
@ -379,7 +379,7 @@ async def test_patch_org_contact_success(default_client: AsyncClient, key: str,
], ],
) )
@pytest.mark.anyio @pytest.mark.anyio
async def test_patch_org_status_status_checks(default_client: AsyncClient, body: dict[str, str], expected_status: int): async def test_patch_org_contact_status_checks(default_client: AsyncClient, body: dict[str, str], expected_status: int):
resp = await default_client.patch("/org/contact", json=body) resp = await default_client.patch("/org/contact", json=body)
assert resp.status_code == expected_status assert resp.status_code == expected_status

View file

@ -4,7 +4,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import default_client, generate_query_and_status from .conftest import generate_query_and_status
@pytest.mark.anyio @pytest.mark.anyio
@ -38,7 +38,7 @@ async def test_post_service_success(default_client: AsyncClient):
assert "service" in data assert "service" in data
assert data["service"]["name"] == "New Test Service" assert data["service"]["name"] == "New Test Service"
assert data["service"]["id"] == 2 assert data["service"]["id"] == 2
assert type(data["service"]["api_key"]) == str assert isinstance(data["service"]["api_key"], str)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -64,7 +64,7 @@ async def test_patch_service_success(default_client: AsyncClient):
assert "service" in data assert "service" in data
assert data["service"]["name"] == "Test Service" assert data["service"]["name"] == "Test Service"
assert data["service"]["id"] == 1 assert data["service"]["id"] == 1
assert type(data["service"]["api_key"]) == str assert isinstance(data["service"]["api_key"], str)
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -6,7 +6,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from .conftest import default_client, generate_query_and_status from .conftest import generate_query_and_status
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_self_db_success(default_client: AsyncClient): async def test_get_self_db_success(default_client: AsyncClient):