diff --git a/forensics/ingest.py b/forensics/ingest.py index 9cb462a..ca89cdb 100644 --- a/forensics/ingest.py +++ b/forensics/ingest.py @@ -321,6 +321,7 @@ def save_invalid_upload( validation_error=error, ) if fallback_group is not None: + audit_file.groups.add(fallback_group) AuditGroup.objects.filter(id=fallback_group.id).update(updated_at=timezone.now()) return IngestionResult(audit_file=audit_file, created=True) except IntegrityError: diff --git a/forensics/tests.py b/forensics/tests.py index ad24a2a..53da759 100644 --- a/forensics/tests.py +++ b/forensics/tests.py @@ -1094,6 +1094,29 @@ def test_invalid_jsonl_returns_400_and_saves_quarantined_upload(self): self.assertEqual(bad_event.raw_line, "{not-json}") self.assertIn("JSON", bad_event.validation_error) + def test_invalid_utf8_group_upload_links_file_to_fallback_group(self): + raw_token, _token = UploadToken.issue("ios test client") + dump_bytes = b"\xff\xfeinvalid marmot audit bytes" + + response = self.client.post( + reverse("api-group-audit-log-upload", kwargs={"group_slug": "mobile-qa"}), + data=dump_bytes, + content_type="application/x-ndjson", + HTTP_AUTHORIZATION=f"Bearer {raw_token}", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["validation_status"], AuditFile.STATUS_INVALID) + self.assertEqual(response.json()["group"], "mobile-qa") + self.assertEqual(response.json()["groups"], ["mobile-qa"]) + + audit_file = AuditFile.objects.get() + fallback_group = AuditGroup.objects.get(slug="mobile-qa") + self.assertEqual(audit_file.validation_status, AuditFile.STATUS_INVALID) + self.assertEqual(audit_file.events.get().group, fallback_group) + self.assertEqual(groups_for_audit_file(audit_file), [fallback_group]) + self.assertEqual(list(audit_files_for_group(fallback_group)), [audit_file]) + def test_invalid_utf8_upload_file_sha256_race_returns_existing_audit_file(self): # save_invalid_upload() does the same check-then-create on file_sha256 # as the valid ingestion path. If a concurrent request inserts the same @@ -2123,6 +2146,35 @@ def test_unexpected_ingest_error_quarantines_upload_not_500(self): # The raw upload text is preserved intact as evidence, not dropped. self.assertEqual(audit_file.raw_text, body) + def test_unexpected_ingest_error_group_upload_links_file_to_fallback_group(self): + from django.db import DataError + + raw_token, _token = UploadToken.issue("ios test client") + body = representative_audit_log() + + with mock.patch.object( + ingest_module, "create_events", side_effect=DataError("simulated bind overflow") + ): + response = self.client.post( + reverse("api-group-audit-log-upload", kwargs={"group_slug": "mobile-qa"}), + data=body, + content_type="application/x-ndjson", + HTTP_AUTHORIZATION=f"Bearer {raw_token}", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["validation_status"], AuditFile.STATUS_INVALID) + self.assertEqual(response.json()["group"], "mobile-qa") + self.assertEqual(response.json()["groups"], ["mobile-qa"]) + + audit_file = AuditFile.objects.get() + fallback_group = AuditGroup.objects.get(slug="mobile-qa") + self.assertEqual(audit_file.validation_status, AuditFile.STATUS_INVALID) + self.assertEqual(audit_file.raw_text, body) + self.assertEqual(audit_file.events.get().group, fallback_group) + self.assertEqual(groups_for_audit_file(audit_file), [fallback_group]) + self.assertEqual(list(audit_files_for_group(fallback_group)), [audit_file]) + def test_all_supported_audit_kind_variants_are_normalized(self): raw_token, _token = UploadToken.issue("ios test client") cases = [