diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 7cf9939490..6c06389cae 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -366,13 +366,12 @@ jobs: mode: walltime run: pytest ./src/performance --codspeed - coverage: - name: Tests - DB [SQLite] + Coverage ${{ matrix.python_version }} + sqlite: + name: Tests - DB [SQLite] ${{ matrix.python_version }} runs-on: ubuntu-24.04 needs: ["code-style", "paths-filter"] if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true' - continue-on-error: true # continue if a step fails so that coverage gets pushed strategy: matrix: python_version: [3.12, 3.14] @@ -404,21 +403,8 @@ jobs: run: | invoke migrate --detect python3 .github/scripts/check_migration_files.py - - name: Coverage Tests - run: invoke dev.test --check --coverage --translations - - name: Upload raw coverage to artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: coverage - path: .coverage - retention-days: 14 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - if: always() - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: inventree/InvenTree - flags: backend + - name: Run Tests + run: invoke dev.test --check --translations performance: name: Tests - Performance @@ -458,7 +444,7 @@ jobs: run: inv dev.test --pytest postgres: - name: Tests - DB [PostgreSQL] + name: Tests - DB [PostgreSQL] + Coverage runs-on: ubuntu-24.04 needs: ["code-style", "paths-filter"] if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true' @@ -501,8 +487,25 @@ jobs: dev-install: true update: true static: true - - name: Run Tests - run: invoke dev.test --check --translations + # Coverage is collected here (not on SQLite) so that tests which require + # select_for_update (concurrency / locking tests) contribute to coverage + - name: Coverage Tests + run: invoke dev.test --check --coverage --translations + - name: Upload raw coverage to artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: coverage + path: .coverage + include-hidden-files: true + retention-days: 14 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + if: always() + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: inventree/InvenTree + flags: backend - name: Data Export Test uses: $/.github/actions/migration diff --git a/codecov.yml b/codecov.yml index 8199d9ec8a..5af2ed5410 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,6 @@ ignore: - "src/backend/InvenTree/**/test_migrations.py" + - "src/backend/InvenTree/**/migrations/**" coverage: status: @@ -20,11 +21,10 @@ flag_management: statuses: - type: project target: 85% + # Legacy flag - no longer uploaded (replaced by migrations-sqlite / -mysql / -postgresql). + # Disable carryforward so its stale report (incl. files since deleted) is dropped. - name: migrations - carryforward: true - statuses: - - type: project - target: 38% + carryforward: false - name: web carryforward: true statuses: diff --git a/pyproject.toml b/pyproject.toml index 1076f0896a..5beaf5875f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,7 +119,7 @@ unused-ignore-comment = "error" [tool.coverage.run] source = ["src/backend/InvenTree", "InvenTree"] dynamic_context = "test_function" -omit = ["*/test_migrations.py"] +omit = ["*/test_migrations.py", "*/migrations/*"] [tool.coverage.html] show_contexts = true diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index f43ed1be7e..81a346a699 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -454,6 +454,14 @@ MIDDLEWARE = ensure_removed( MIDDLEWARE, 'oauth2_provider.middleware.OAuth2TokenMiddleware', 'MIDDLEWARE' ) +if TESTING: + # WhiteNoise scans the entire STATIC_ROOT whenever the middleware chain is loaded, + # which happens for every new test client (i.e. every test). No unit test relies on + # static files being served via HTTP, so the middleware is not loaded during testing. + MIDDLEWARE = [ + m for m in MIDDLEWARE if m != 'whitenoise.middleware.WhiteNoiseMiddleware' + ] + # Audit log configuration (django-auditlog) # Ref: https://django-auditlog.readthedocs.io/en/latest/usage.html#settings # Note: AUDITLOG_LOGENTRY_MODEL is a swappable model, and must not be changed once migrations have been applied diff --git a/src/backend/InvenTree/audit/context.py b/src/backend/InvenTree/audit/context.py index c988c1bbf8..0f8d60d155 100644 --- a/src/backend/InvenTree/audit/context.py +++ b/src/backend/InvenTree/audit/context.py @@ -296,9 +296,11 @@ def resolve_cid() -> Optional[str]: """Resolve the current correlation ID. - Within a request: the request ID bound by django-structlog, so that audit entries - can be matched against log entries (and the X-Request-ID header) for the request. - Note: django-structlog uses the incoming 'X-Request-ID' header if provided - (e.g. by a reverse proxy), otherwise generates a new UUID. + can be matched against log entries (and the X-InvenTree-ReqId header) for the request. + Note: django-structlog generates a new UUID for each request - an incoming + 'X-Request-ID' header is not trusted (TRUST_UPSTREAM_REQUEST_ID is not set), + so clients cannot spoof the correlation ID. The ID is returned to the client + in the 'X-InvenTree-ReqId' response header. - Otherwise (e.g. background tasks, or if the structlog middleware is not installed): a server-generated ID (see audit_request / audit_source). """ diff --git a/src/backend/InvenTree/audit/tests.py b/src/backend/InvenTree/audit/tests.py index 68c784520f..2e22a119fe 100644 --- a/src/backend/InvenTree/audit/tests.py +++ b/src/backend/InvenTree/audit/tests.py @@ -458,29 +458,38 @@ class AuditAPITest(AuditTestMixin, InvenTreeAPITestCase): self.assertEqual(data[0]['changes']['description'][1], 'Session edit') def test_correlation_id(self): - """The correlation ID matches the request ID (as used by structlog).""" + """The correlation ID matches the request ID (as used by structlog). + + The request ID is generated server-side - a client-supplied X-Request-ID + header is not trusted (django-structlog TRUST_UPSTREAM_REQUEST_ID is not set), + so a client cannot spoof the correlation ID recorded in the audit log. + """ url = reverse('api-po-detail', kwargs={'pk': self.po.pk}) - # Request ID provided by the client / reverse proxy - self.patch(url, {'description': 'One'}, HTTP_X_REQUEST_ID='req-abc-123') - - entry = self.entries_for(self.po, action=AuditLogEntry.Action.UPDATE).get() - self.assertEqual(entry.cid, 'req-abc-123') - - # Otherwise a unique ID is generated per request - self.patch(url, {'description': 'Two'}) - self.patch(url, {'description': 'Three'}) - - cids = list( - self - .entries_for(self.po, action=AuditLogEntry.Action.UPDATE) - .exclude(cid='req-abc-123') - .values_list('cid', flat=True) + # A client-supplied request ID is ignored + response = self.patch( + url, {'description': 'One'}, HTTP_X_REQUEST_ID='req-abc-123' ) - self.assertEqual(len(cids), 2) - self.assertTrue(all(cids)) - self.assertNotEqual(cids[0], cids[1]) + entry = self.entries_for(self.po, action=AuditLogEntry.Action.UPDATE).get() + self.assertNotEqual(entry.cid, 'req-abc-123') + self.assertEqual(entry.cid, response['X-InvenTree-ReqId']) + + # A unique ID is generated per request, matching the response header + req_ids = {entry.cid} + + for description in ['Two', 'Three']: + response = self.patch(url, {'description': description}) + req_ids.add(response['X-InvenTree-ReqId']) + + cids = set( + self.entries_for(self.po, action=AuditLogEntry.Action.UPDATE).values_list( + 'cid', flat=True + ) + ) + + self.assertEqual(len(cids), 3) + self.assertEqual(cids, req_ids) @override_settings(BASIC_AUTH_ENABLED=True) def test_basic_auth_actor(self): diff --git a/tasks.py b/tasks.py index a03315fcd7..750c00d004 100644 --- a/tasks.py +++ b/tasks.py @@ -336,6 +336,7 @@ def main(): def builtin_apps(): """Returns a list of installed apps.""" return [ + 'audit', 'build', 'common', 'company',