From ce6ab1979c418f0d2fb9925c3a556b96fa2d906e Mon Sep 17 00:00:00 2001 From: bilalesi Date: Wed, 27 May 2026 10:59:14 +0200 Subject: [PATCH 1/4] feat: add invite webhook endpoint with HMAC signature verification - post /invites/webhook - verifies HMAC-SHA256 signature of the request body against INVITE_WEBHOOK_SECRET - adds comprehensive test for the webhook --- virtual_labs/domain/invite.py | 12 + virtual_labs/infrastructure/settings.py | 1 + virtual_labs/routes/invites.py | 58 +++- virtual_labs/tests/test_invite_webhook.py | 249 ++++++++++++++++++ virtual_labs/usecases/invites/__init__.py | 2 + .../usecases/invites/webhook_handler.py | 108 ++++++++ 6 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 virtual_labs/tests/test_invite_webhook.py create mode 100644 virtual_labs/usecases/invites/webhook_handler.py diff --git a/virtual_labs/domain/invite.py b/virtual_labs/domain/invite.py index e6f40e1d..e8198134 100644 --- a/virtual_labs/domain/invite.py +++ b/virtual_labs/domain/invite.py @@ -27,3 +27,15 @@ class InviteOut(BaseModel): virtual_lab_id: UUID4 origin: InviteOrigin accepted: Literal["accepted", "already_accepted"] | None + + +class WebhookHeaders(BaseModel): + x_webhook_signature: str + x_virtual_lab_id: UUID4 + x_project_id: UUID4 + x_user_id: UUID4 + + +class WebhookPayload(BaseModel): + name: str + email: EmailStr diff --git a/virtual_labs/infrastructure/settings.py b/virtual_labs/infrastructure/settings.py index 0fb0c347..8f345955 100644 --- a/virtual_labs/infrastructure/settings.py +++ b/virtual_labs/infrastructure/settings.py @@ -75,6 +75,7 @@ class Settings(BaseSettings): VALIDATE_CERTS: bool = False INVITE_JWT_SECRET: str = "TEST_JWT_SECRET" + INVITE_WEBHOOK_SECRET: str = "" INVITE_EXPIRES_IN_DAYS: int = 7 INVITE_LINK_BASE: str = "http://localhost:3000" diff --git a/virtual_labs/routes/invites.py b/virtual_labs/routes/invites.py index fd957b05..fd3cfbc1 100644 --- a/virtual_labs/routes/invites.py +++ b/virtual_labs/routes/invites.py @@ -1,12 +1,18 @@ -from typing import Tuple +from typing import Annotated, Tuple -from fastapi import APIRouter, Depends, Query -from fastapi.responses import Response +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import JSONResponse, Response +from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from virtual_labs.core.exceptions.api_error import VliError from virtual_labs.core.types import VliAppResponse -from virtual_labs.domain.invite import InvitationResponse, InviteOut +from virtual_labs.domain.invite import ( + InvitationResponse, + InviteOut, + WebhookHeaders, + WebhookPayload, +) from virtual_labs.infrastructure.db.config import default_session_factory from virtual_labs.infrastructure.kc.auth import verify_jwt from virtual_labs.infrastructure.kc.models import AuthUser @@ -52,3 +58,47 @@ async def handle_invite( invite_token=token, auth=auth, ) + + +@router.post( + "/webhook", + operation_id="invites_webhook_handler", + summary="Handle incoming webhook to invite a user to a project", + description="This is tied to the google form invite setup", +) +async def webhook_handler( + request: Request, + session: AsyncSession = Depends(default_session_factory), + x_webhook_signature: Annotated[str, Header()] = ..., + x_virtual_lab_id: Annotated[str, Header()] = ..., + x_project_id: Annotated[str, Header()] = ..., + x_user_id: Annotated[str, Header()] = ..., +) -> JSONResponse: + try: + headers = WebhookHeaders( + x_webhook_signature=x_webhook_signature, + x_virtual_lab_id=x_virtual_lab_id, + x_project_id=x_project_id, + x_user_id=x_user_id, + ) + except ValidationError as e: + return JSONResponse(status_code=422, content={"detail": e.errors()}) + + body = await request.body() + + try: + payload = WebhookPayload.model_validate_json(body) + except ValidationError as e: + return JSONResponse(status_code=422, content={"detail": e.errors()}) + + result = await invite_cases.handle_invite_webhook( + session, + signature=headers.x_webhook_signature, + body=body, + virtual_lab_id=str(headers.x_virtual_lab_id), + project_id=str(headers.x_project_id), + inviter_id=str(headers.x_user_id), + invitee_email=payload.email, + invitee_name=payload.name, + ) + return JSONResponse(status_code=200, content=result) diff --git a/virtual_labs/tests/test_invite_webhook.py b/virtual_labs/tests/test_invite_webhook.py new file mode 100644 index 00000000..144860be --- /dev/null +++ b/virtual_labs/tests/test_invite_webhook.py @@ -0,0 +1,249 @@ +import json +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from httpx import AsyncClient + +from virtual_labs.infrastructure.settings import settings +from virtual_labs.tests.utils import ( + cleanup_resources, + create_mock_lab_with_project, + create_paid_subscription_for_user, + get_headers, + get_user_id_from_test_auth, +) +from virtual_labs.usecases.invites.webhook_handler import compute_webhook_signature + +WEBHOOK_SECRET = "test_webhook_secret_hex_value" + + +def sign(body: bytes) -> str: + """Compute a valid signature for the test webhook secret.""" + return compute_webhook_signature(body, WEBHOOK_SECRET) + + +@pytest_asyncio.fixture(autouse=True) +async def set_webhook_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "INVITE_WEBHOOK_SECRET", WEBHOOK_SECRET) + + +@pytest_asyncio.fixture +async def lab_and_project( + async_test_client: AsyncClient, +) -> AsyncGenerator[tuple[str, str, str], None]: + """Create a virtual lab with a project for testing. Returns (lab_id, project_id, user_id).""" + headers = get_headers("test") + user_id = await get_user_id_from_test_auth( + auth_header=headers.get("Authorization", "") + ) + await create_paid_subscription_for_user(user_id) + + lab, project_id = await create_mock_lab_with_project(async_test_client) + lab_id = lab["id"] + + yield lab_id, project_id, str(user_id) + + await cleanup_resources(client=async_test_client, lab_id=lab_id) + + +@pytest.mark.asyncio +async def test_webhook_successful_invite( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Invite sent successfully" + assert data["invitee_email"] == "invitee@example.com" + assert data["invitee_name"] == "Test Invitee" + + +@pytest.mark.asyncio +async def test_webhook_invalid_signature( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": "invalid_signature", + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_webhook_missing_signature_header( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_missing_virtual_lab_id_header( + async_test_client: AsyncClient, +) -> None: + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Project-Id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "X-User-Id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_invalid_uuid_in_header( + async_test_client: AsyncClient, +) -> None: + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": "not-a-uuid", + "X-Project-Id": "also-not-a-uuid", + "X-User-Id": "not-a-uuid-either", + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_invalid_email_in_body( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "not-an-email"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_project_not_found( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, _, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_webhook_missing_body_fields( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 diff --git a/virtual_labs/usecases/invites/__init__.py b/virtual_labs/usecases/invites/__init__.py index 26f733c6..7d621fbd 100644 --- a/virtual_labs/usecases/invites/__init__.py +++ b/virtual_labs/usecases/invites/__init__.py @@ -1,7 +1,9 @@ from .get_invite_details import get_invite_details from .invitation_handler import invitation_handler +from .webhook_handler import handle_invite_webhook __all__ = [ "invitation_handler", "get_invite_details", + "handle_invite_webhook", ] diff --git a/virtual_labs/usecases/invites/webhook_handler.py b/virtual_labs/usecases/invites/webhook_handler.py new file mode 100644 index 00000000..48e7a763 --- /dev/null +++ b/virtual_labs/usecases/invites/webhook_handler.py @@ -0,0 +1,108 @@ +import hashlib +import hmac +from http import HTTPStatus +from uuid import UUID + +from pydantic import EmailStr +from sqlalchemy.ext.asyncio import AsyncSession + +from virtual_labs.core.exceptions.api_error import VliError, VliErrorCode +from virtual_labs.core.types import UserRoleEnum +from virtual_labs.domain.invite import InvitePayload +from virtual_labs.infrastructure.settings import settings +from virtual_labs.repositories.project_repo import ProjectQueryRepository +from virtual_labs.usecases.project.invite_user_to_project import invite_user_to_project + + +def compute_webhook_signature(body: bytes, secret: str) -> str: + """Compute HMAC-SHA256 signature for a webhook payload.""" + return hmac.HMAC( + secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + + +def verify_webhook_signature(signature: str, body: bytes) -> None: + """ + Verify the webhook signature. + + The sender computes: HMAC-SHA256(body, secret) and hex-encodes the digest. + We compute the same and compare using a timing-safe comparison. + """ + if not settings.INVITE_WEBHOOK_SECRET: + raise VliError( + error_code=VliErrorCode.SERVER_ERROR, + http_status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + message="Webhook secret is not configured", + ) + + expected = compute_webhook_signature(body, settings.INVITE_WEBHOOK_SECRET) + + if not hmac.compare_digest(signature, expected): + raise VliError( + error_code=VliErrorCode.INVALID_PARAMETER, + http_status_code=HTTPStatus.UNAUTHORIZED, + message="Invalid webhook signature", + ) + + +async def handle_invite_webhook( + session: AsyncSession, + *, + signature: str, + body: bytes, + virtual_lab_id: str, + project_id: str, + inviter_id: str, + invitee_email: EmailStr, + invitee_name: str, +) -> dict: + """ + Handle an incoming webhook to invite a user to a project. + + Steps: + 1. Verify the webhook signature + 2. Verify the virtual lab and project exist + 3. Send an invite to the project + """ + # 1. Verify signature + verify_webhook_signature(signature, body) + + vlab_uuid = UUID(virtual_lab_id) + project_uuid = UUID(project_id) + inviter_uuid = UUID(inviter_id) + + # 2. Verify virtual lab and project exist + pqr = ProjectQueryRepository(session) + try: + await pqr.retrieve_one_project_strict( + virtual_lab_id=vlab_uuid, + project_id=project_uuid, + ) + except Exception: + raise VliError( + error_code=VliErrorCode.INVALID_REQUEST, + http_status_code=HTTPStatus.NOT_FOUND, + message=f"Project {project_id} not found in virtual lab {virtual_lab_id}", + ) + + # 3. Send invite to the project (x_user_id is the inviter) + await invite_user_to_project( + session=session, + virtual_lab_id=vlab_uuid, + project_id=project_uuid, + inviter_id=inviter_uuid, + invite_details=InvitePayload( + email=invitee_email, + role=UserRoleEnum.member, + ), + ) + + return { + "message": "Invite sent successfully", + "virtual_lab_id": virtual_lab_id, + "project_id": project_id, + "invitee_email": invitee_email, + "invitee_name": invitee_name, + } From 261273f293b426415aa4e939aab3b72fbd394113 Mon Sep 17 00:00:00 2001 From: bilalesi Date: Wed, 27 May 2026 11:18:29 +0200 Subject: [PATCH 2/4] chore: add google apps script for invite webhook integration --- scripts/invite-google-apps-scripts.js | 84 +++++++++++++++++++++++++++ virtual_labs/routes/invites.py | 14 ++--- 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 scripts/invite-google-apps-scripts.js diff --git a/scripts/invite-google-apps-scripts.js b/scripts/invite-google-apps-scripts.js new file mode 100644 index 00000000..bf7556a5 --- /dev/null +++ b/scripts/invite-google-apps-scripts.js @@ -0,0 +1,84 @@ +const BACKEND_URL = 'VIRTUAL_LAB_API_URL'; + +function onWebinarFormSubmit(e) { + try { + if (!e || !e.response) { + console.error('No form response in event'); + return; + } + const props = PropertiesService.getScriptProperties(); + const secret = props.getProperty('x_webhook_signature'); + const virtualLabId = props.getProperty('x_virtual_lab_id'); + const projectId = props.getProperty('x_project_id'); + const userId = props.getProperty('x_user_id'); + + if(!secret){ + console.error('Missing Script Properties: WEBHOOK_SECRET'); + return; + } + if(!virtualLabId){ + console.error('Missing Script Properties: VIRTUAL_LAB_ID'); + return; + } + if(!projectId){ + console.error('Missing Script Properties: PROJECT_ID'); + return; + } + if(!userId){ + console.error('Missing Script Properties: USER_ID'); + return; + } + + const itemResponses = e.response.getItemResponses(); + const answers = {}; + itemResponses.forEach(ir => { + answers[ir.getItem().getTitle()] = ir.getResponse(); + }); + + const fullName = (answers['Name'] || '').toString().trim(); + const email = (answers['Email'] || '').toString().trim(); + + if (!email) { + console.error('No email in submission'); + return; + } + + + const payload = { + name: fullName, + email: email, + source: 'webinar_form', + submittedAt: new Date().toISOString(), + responseId: e.response ? e.response.getId() : Utilities.getUuid(), + }; + + const body = JSON.stringify(payload); + + // HMAC-SHA256 over the raw body — IDs are inside, so they're signed too. + const signature = Utilities.computeHmacSha256Signature(body, secret) + .map(b => ('0' + (b & 0xff).toString(16)).slice(-2)) + .join(''); + + const response = UrlFetchApp.fetch(`${BACKEND_URL}/invites/webhook`, { + method: 'post', + contentType: 'application/json', + headers: { + 'x-webhook-signature': signature, + 'x-virtual-lab-id': virtualLabId, + 'x-project-id': projectId, + 'x-user-id': userId, + }, + payload: body, + muteHttpExceptions: true, + }); + + const code = response.getResponseCode(); + if (code < 200 || code >= 300) { + console.error('Backend error', code, response.getContentText()); + } else { + console.log('Invite sent for', email); + } + } catch (err) { + console.error('Submission handler failed:', err); + } +} diff --git a/virtual_labs/routes/invites.py b/virtual_labs/routes/invites.py index fd3cfbc1..20ce972d 100644 --- a/virtual_labs/routes/invites.py +++ b/virtual_labs/routes/invites.py @@ -68,18 +68,18 @@ async def handle_invite( ) async def webhook_handler( request: Request, + x_webhook_signature: Annotated[str, Header()], + x_virtual_lab_id: Annotated[str, Header()], + x_project_id: Annotated[str, Header()], + x_user_id: Annotated[str, Header()], session: AsyncSession = Depends(default_session_factory), - x_webhook_signature: Annotated[str, Header()] = ..., - x_virtual_lab_id: Annotated[str, Header()] = ..., - x_project_id: Annotated[str, Header()] = ..., - x_user_id: Annotated[str, Header()] = ..., ) -> JSONResponse: try: headers = WebhookHeaders( x_webhook_signature=x_webhook_signature, - x_virtual_lab_id=x_virtual_lab_id, - x_project_id=x_project_id, - x_user_id=x_user_id, + x_virtual_lab_id=x_virtual_lab_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + x_project_id=x_project_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + x_user_id=x_user_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] ) except ValidationError as e: return JSONResponse(status_code=422, content={"detail": e.errors()}) From 0852794182dd90cbb66badf9eea1b50ffb1d0eb0 Mon Sep 17 00:00:00 2001 From: bilalesi Date: Wed, 27 May 2026 11:52:10 +0200 Subject: [PATCH 3/4] fix: use pydantic and fix webhook tests --- virtual_labs/domain/invite.py | 7 ----- virtual_labs/routes/invites.py | 33 +++++++---------------- virtual_labs/tests/test_invite_webhook.py | 2 -- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/virtual_labs/domain/invite.py b/virtual_labs/domain/invite.py index e8198134..19f5294f 100644 --- a/virtual_labs/domain/invite.py +++ b/virtual_labs/domain/invite.py @@ -29,13 +29,6 @@ class InviteOut(BaseModel): accepted: Literal["accepted", "already_accepted"] | None -class WebhookHeaders(BaseModel): - x_webhook_signature: str - x_virtual_lab_id: UUID4 - x_project_id: UUID4 - x_user_id: UUID4 - - class WebhookPayload(BaseModel): name: str email: EmailStr diff --git a/virtual_labs/routes/invites.py b/virtual_labs/routes/invites.py index 20ce972d..f3fa3ac4 100644 --- a/virtual_labs/routes/invites.py +++ b/virtual_labs/routes/invites.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Header, Query, Request from fastapi.responses import JSONResponse, Response -from pydantic import ValidationError +from pydantic import UUID4 from sqlalchemy.ext.asyncio import AsyncSession from virtual_labs.core.exceptions.api_error import VliError @@ -10,7 +10,6 @@ from virtual_labs.domain.invite import ( InvitationResponse, InviteOut, - WebhookHeaders, WebhookPayload, ) from virtual_labs.infrastructure.db.config import default_session_factory @@ -68,36 +67,22 @@ async def handle_invite( ) async def webhook_handler( request: Request, + payload: WebhookPayload, x_webhook_signature: Annotated[str, Header()], - x_virtual_lab_id: Annotated[str, Header()], - x_project_id: Annotated[str, Header()], - x_user_id: Annotated[str, Header()], + x_virtual_lab_id: Annotated[UUID4, Header()], + x_project_id: Annotated[UUID4, Header()], + x_user_id: Annotated[UUID4, Header()], session: AsyncSession = Depends(default_session_factory), ) -> JSONResponse: - try: - headers = WebhookHeaders( - x_webhook_signature=x_webhook_signature, - x_virtual_lab_id=x_virtual_lab_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] - x_project_id=x_project_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] - x_user_id=x_user_id, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] - ) - except ValidationError as e: - return JSONResponse(status_code=422, content={"detail": e.errors()}) - body = await request.body() - try: - payload = WebhookPayload.model_validate_json(body) - except ValidationError as e: - return JSONResponse(status_code=422, content={"detail": e.errors()}) - result = await invite_cases.handle_invite_webhook( session, - signature=headers.x_webhook_signature, + signature=x_webhook_signature, body=body, - virtual_lab_id=str(headers.x_virtual_lab_id), - project_id=str(headers.x_project_id), - inviter_id=str(headers.x_user_id), + virtual_lab_id=str(x_virtual_lab_id), + project_id=str(x_project_id), + inviter_id=str(x_user_id), invitee_email=payload.email, invitee_name=payload.name, ) diff --git a/virtual_labs/tests/test_invite_webhook.py b/virtual_labs/tests/test_invite_webhook.py index 144860be..502923f5 100644 --- a/virtual_labs/tests/test_invite_webhook.py +++ b/virtual_labs/tests/test_invite_webhook.py @@ -9,7 +9,6 @@ from virtual_labs.tests.utils import ( cleanup_resources, create_mock_lab_with_project, - create_paid_subscription_for_user, get_headers, get_user_id_from_test_auth, ) @@ -37,7 +36,6 @@ async def lab_and_project( user_id = await get_user_id_from_test_auth( auth_header=headers.get("Authorization", "") ) - await create_paid_subscription_for_user(user_id) lab, project_id = await create_mock_lab_with_project(async_test_client) lab_id = lab["id"] From 1571e0bf9d30ec1d3e20cc3d5b6f14913df61ee4 Mon Sep 17 00:00:00 2001 From: bilalesi Date: Wed, 27 May 2026 11:55:50 +0200 Subject: [PATCH 4/4] fix audit ci workflow --- .github/workflows/audit.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b2a374e0..e6a13cc0 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -32,13 +32,13 @@ jobs: - name: Poetry audit id: poetry-audit run: |- - pip install poetry-audit-plugin - poetry audit > audit.txt + pip install "safety==3.7.0" poetry-audit-plugin + poetry audit > audit.txt 2>&1 || true cat audit.txt continue-on-error: true - name: Comment with audit result uses: marocchino/sticky-pull-request-comment@v2 - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && hashFiles('audit.txt') != '' with: recreate: true path: audit.txt