From 856e39242061e2fdebf7365c7b288b551485b9b1 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 7 Sep 2026 21:44:53 -0500 Subject: [PATCH 1/4] Remove Graphene dependencies while preserving auth and API contracts --- .pre-commit-config.yaml | 3 +- .../remove-graphene-dependencies.changed.md | 8 + config/graphql/action_queries.py | 2 +- config/graphql/agent_mutations.py | 2 +- config/graphql/analysis_mutations.py | 2 +- config/graphql/annotation_mutations.py | 2 +- config/graphql/annotation_queries.py | 4 +- .../graphql/authority_frontier_mutations.py | 2 +- config/graphql/authority_mapping_mutations.py | 2 +- .../graphql/authority_namespace_mutations.py | 2 +- config/graphql/authority_pack_api.py | 2 +- config/graphql/badge_mutations.py | 2 +- config/graphql/base_types.py | 2 +- config/graphql/conversation_mutations.py | 2 +- config/graphql/conversation_queries.py | 2 +- config/graphql/conversation_types.py | 2 +- config/graphql/core/LICENSE.graphql-relay | 23 +++ config/graphql/core/auth.py | 11 +- config/graphql/core/filtering.py | 7 +- config/graphql/core/mutations.py | 2 +- config/graphql/core/relay.py | 128 ++++++---------- config/graphql/corpus_category_mutations.py | 2 +- config/graphql/corpus_folder_mutations.py | 2 +- config/graphql/corpus_group_mutations.py | 2 +- config/graphql/corpus_mutations.py | 4 +- config/graphql/corpus_queries.py | 2 +- config/graphql/corpus_types.py | 5 +- config/graphql/custom_resolvers.py | 2 +- config/graphql/document_mutations.py | 2 +- config/graphql/document_queries.py | 2 +- .../document_relationship_mutations.py | 2 +- config/graphql/document_types.py | 11 +- config/graphql/enrichment_mutations.py | 2 +- config/graphql/extract_mutations.py | 2 +- config/graphql/extract_queries.py | 2 +- config/graphql/extract_types.py | 2 +- config/graphql/filters.py | 2 +- config/graphql/ingestion_source_mutations.py | 2 +- config/graphql/jwt_auth.py | 29 ++-- config/graphql/label_mutations.py | 2 +- config/graphql/moderation_mutations.py | 2 +- config/graphql/notification_mutations.py | 2 +- config/graphql/og_metadata_queries.py | 2 +- config/graphql/research_mutations.py | 2 +- config/graphql/research_queries.py | 2 +- config/graphql/schema.py | 12 +- config/graphql/search_queries.py | 2 +- config/graphql/smart_label_mutations.py | 2 +- config/graphql/social_queries.py | 2 +- config/graphql/user_mutations.py | 26 ++-- config/graphql/user_types.py | 3 +- config/graphql/views.py | 3 +- config/graphql/voting_mutations.py | 2 +- config/graphql_auth0_auth/backends.py | 6 +- config/graphql_auth0_auth/utils.py | 2 +- config/jwt_auth/LICENSE | 22 +++ config/jwt_auth/__init__.py | 4 + config/jwt_auth/backends.py | 21 +++ config/jwt_auth/exceptions.py | 19 +++ config/jwt_auth/refresh_token/__init__.py | 1 + config/jwt_auth/refresh_token/apps.py | 9 ++ .../refresh_token/migrations/0001_initial.py | 54 +++++++ .../migrations/0002_auto_20190130_0900.py | 17 +++ .../refresh_token/migrations/__init__.py | 0 config/jwt_auth/refresh_token/models.py | 78 ++++++++++ config/jwt_auth/refresh_token/shortcuts.py | 32 ++++ config/jwt_auth/refresh_token/signals.py | 4 + config/jwt_auth/refresh_token/utils.py | 11 ++ config/jwt_auth/settings.py | 67 ++++++++ config/jwt_auth/shortcuts.py | 14 ++ config/jwt_auth/signals.py | 4 + config/jwt_auth/utils.py | 118 ++++++++++++++ config/jwt_utils.py | 7 +- config/rest_jwt_auth.py | 2 +- config/settings/base.py | 2 +- config/websocket/auth_handshake.py | 2 +- config/websocket/consumers/thread_updates.py | 2 +- .../consumers/unified_agent_conversation.py | 2 +- config/websocket/middleware.py | 2 +- .../graphql_strawberry_migration.md | 54 +++++-- .../graphql-dependency-removal.md | 94 ++++++++++++ mypy.ini | 4 - .../document_imports/services.py | 2 +- opencontractserver/mcp/server.py | 3 +- .../research/services/research_reports.py | 2 +- .../architecture/test_graphql_dependencies.py | 56 +++++++ opencontractserver/tests/base.py | 2 +- .../test_pdf_hash_graphql.py | 4 +- ...est_analysis_extract_hybrid_permissions.py | 2 +- .../test_annotation_permission_inheritance.py | 2 +- .../test_annotation_privacy_scoping.py | 2 +- .../permissioning/test_corpus_visibility.py | 2 +- .../permissioning/test_feedback_mutations.py | 2 +- .../test_permissioned_querysets.py | 2 +- .../tests/permissioning/test_permissioning.py | 2 +- .../research/test_research_start_mutation.py | 2 +- .../tests/test_add_annotation_idor.py | 2 +- opencontractserver/tests/test_agent_memory.py | 3 +- .../tests/test_agentic_highlighter_task.py | 2 +- opencontractserver/tests/test_agents.py | 2 +- .../test_annotation_label_permissions.py | 9 +- .../tests/test_annotation_tree.py | 2 +- .../tests/test_api_id_contract.py | 109 +++++++++++++ .../tests/test_authority_discovery_subset.py | 2 +- .../tests/test_authority_frontier_actions.py | 2 +- .../tests/test_authority_mapping_crud.py | 4 +- .../tests/test_authority_mapping_loader.py | 2 +- .../tests/test_authority_namespace_crud.py | 2 +- .../tests/test_authority_pack_api.py | 2 +- opencontractserver/tests/test_badges.py | 2 +- .../tests/test_batch_run_corpus_action.py | 2 +- .../tests/test_bulk_document_upload.py | 2 +- .../tests/test_caml_pipeline_coverage.py | 2 +- .../test_chat_message_mentioned_resources.py | 2 +- .../tests/test_column_mutations.py | 3 +- .../test_conversation_mutations_graphql.py | 34 ++--- .../tests/test_conversation_query.py | 2 +- .../tests/test_conversation_search.py | 2 +- .../tests/test_corpus_action_graphql.py | 2 +- .../test_corpus_action_template_graphql.py | 2 +- ...us_cards_structural_document_resolution.py | 12 +- .../tests/test_corpus_category.py | 8 +- .../tests/test_corpus_description.py | 47 +++--- .../tests/test_corpus_description_cache.py | 16 +- .../tests/test_corpus_folder_mutations.py | 2 +- .../tests/test_corpus_groups.py | 17 ++- .../tests/test_corpus_intelligence.py | 2 +- .../tests/test_corpus_license.py | 2 +- .../tests/test_corpus_query_optimization.py | 2 +- .../tests/test_corpus_voting.py | 9 +- .../tests/test_coverage_action_queries.py | 2 +- .../tests/test_coverage_agent_types.py | 10 +- .../test_coverage_conversation_mutations.py | 2 +- .../tests/test_coverage_conversation_types.py | 2 +- .../tests/test_coverage_core_relay.py | 2 +- .../test_coverage_corpus_folder_mutations.py | 2 +- .../tests/test_coverage_corpus_queries.py | 2 +- .../tests/test_coverage_document_mutations.py | 2 +- .../tests/test_datacell_mutations.py | 2 +- .../tests/test_default_labelset.py | 2 +- .../tests/test_delete_analysis_mutation.py | 2 +- ...est_doc_annotations_prefetch_n_plus_one.py | 8 +- .../tests/test_document_imports_rest.py | 8 +- .../test_document_imports_worker_token.py | 2 +- .../tests/test_document_mutations.py | 2 +- .../tests/test_document_queries.py | 2 +- .../test_document_relationship_mutations.py | 2 +- .../test_document_relationship_permissions.py | 2 +- .../tests/test_document_relationships.py | 2 +- .../tests/test_document_stats.py | 2 +- .../tests/test_document_uploads.py | 2 +- .../test_document_version_count_optimizer.py | 9 +- .../tests/test_document_versioning_graphql.py | 2 +- .../tests/test_embedder_management.py | 31 ++-- .../tests/test_engagement_metrics_graphql.py | 2 +- .../tests/test_enrichment_run_mutation.py | 2 +- .../tests/test_enrichment_tools.py | 12 +- .../tests/test_export_mutations.py | 2 +- .../tests/test_extract_iterations.py | 2 +- .../tests/test_extract_mutations.py | 2 +- .../tests/test_extract_queries.py | 2 +- .../tests/test_fk_visibility_traversal.py | 2 +- .../test_geographic_annotation_mutations.py | 2 +- .../test_geographic_annotation_service.py | 3 +- ...st_get_document_knowledge_optimizations.py | 5 +- .../tests/test_governance_graph.py | 2 +- .../tests/test_graphql_analyzer_endpoints.py | 2 +- .../test_graphql_import_export_mutations.py | 2 +- .../tests/test_ingestion_source.py | 2 +- .../tests/test_intelligence_setup.py | 2 +- .../tests/test_jwt_auth_contract.py | 144 ++++++++++++++++++ .../tests/test_jwt_refresh_contract.py | 141 +++++++++++++++++ opencontractserver/tests/test_jwt_utils.py | 18 +-- .../tests/test_label_mutations.py | 2 +- opencontractserver/tests/test_leaderboard.py | 2 +- .../tests/test_mention_permissions.py | 12 +- opencontractserver/tests/test_mentions.py | 2 +- .../tests/test_metadata_columns_graphql.py | 2 +- opencontractserver/tests/test_moderation.py | 34 ++--- opencontractserver/tests/test_note_tree.py | 2 +- .../tests/test_notification_graphql.py | 2 +- .../tests/test_notification_websocket.py | 2 +- .../tests/test_og_metadata_queries.py | 2 +- .../tests/test_permanent_deletion.py | 2 +- .../tests/test_permission_fixes.py | 2 +- .../tests/test_personal_corpus.py | 15 +- .../tests/test_query_resolvers.py | 2 +- .../tests/test_rate_limiting.py | 2 +- .../tests/test_run_corpus_action.py | 2 +- .../tests/test_schema_parity.py | 45 +++++- .../tests/test_security_hardening.py | 15 +- .../tests/test_semantic_search_graphql.py | 2 +- .../test_single_doc_analyzer_and_extract.py | 2 +- .../tests/test_singular_node_idor.py | 2 +- .../tests/test_smart_label_mutations.py | 2 +- opencontractserver/tests/test_stats.py | 2 +- ...al_annotations_graphql_backwards_compat.py | 2 +- .../tests/test_token_expiration.py | 10 +- .../test_unified_agent_consumer_delegation.py | 2 +- .../tests/test_url_annotation.py | 2 +- opencontractserver/tests/test_usage_caps.py | 2 +- .../tests/test_user_display_name.py | 11 +- opencontractserver/tests/test_user_handle.py | 4 +- .../test_user_mutations_rate_limiting.py | 2 +- opencontractserver/tests/test_user_privacy.py | 4 +- .../tests/test_versioning_paths_audit.py | 28 ++-- .../tests/test_voting_mutations_graphql.py | 30 ++-- .../tests/test_websocket_auth.py | 2 +- .../test_agent_permission_escalation.py | 4 +- .../websocket/test_unified_agent_consumer.py | 6 +- opencontractserver/utils/ids.py | 58 +++++++ requirements/base.txt | 2 - scripts/validate_frontend_graphql.py | 2 +- 213 files changed, 1631 insertions(+), 557 deletions(-) create mode 100644 changelog.d/remove-graphene-dependencies.changed.md create mode 100644 config/graphql/core/LICENSE.graphql-relay create mode 100644 config/jwt_auth/LICENSE create mode 100644 config/jwt_auth/__init__.py create mode 100644 config/jwt_auth/backends.py create mode 100644 config/jwt_auth/exceptions.py create mode 100644 config/jwt_auth/refresh_token/__init__.py create mode 100644 config/jwt_auth/refresh_token/apps.py create mode 100644 config/jwt_auth/refresh_token/migrations/0001_initial.py create mode 100644 config/jwt_auth/refresh_token/migrations/0002_auto_20190130_0900.py create mode 100644 config/jwt_auth/refresh_token/migrations/__init__.py create mode 100644 config/jwt_auth/refresh_token/models.py create mode 100644 config/jwt_auth/refresh_token/shortcuts.py create mode 100644 config/jwt_auth/refresh_token/signals.py create mode 100644 config/jwt_auth/refresh_token/utils.py create mode 100644 config/jwt_auth/settings.py create mode 100644 config/jwt_auth/shortcuts.py create mode 100644 config/jwt_auth/signals.py create mode 100644 config/jwt_auth/utils.py create mode 100644 docs/test_scripts/graphql-dependency-removal.md create mode 100644 opencontractserver/tests/architecture/test_graphql_dependencies.py create mode 100644 opencontractserver/tests/test_api_id_contract.py create mode 100644 opencontractserver/tests/test_jwt_auth_contract.py create mode 100644 opencontractserver/tests/test_jwt_refresh_contract.py create mode 100644 opencontractserver/utils/ids.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 378683c122..9cf954b462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,8 +122,7 @@ repos: - daphne==4.2.1 - celery==5.6.3 - pgvector==0.4.2 - - strawberry-graphql==0.320.3 - - django-graphql-jwt==0.4.0 + - strawberry-graphql==0.323.2 - pydantic==2.13.3 - psycopg2-binary==2.9.11 # --- Pulled in transitively by app admin autodiscover --------- diff --git a/changelog.d/remove-graphene-dependencies.changed.md b/changelog.d/remove-graphene-dependencies.changed.md new file mode 100644 index 0000000000..fc960d5a92 --- /dev/null +++ b/changelog.d/remove-graphene-dependencies.changed.md @@ -0,0 +1,8 @@ +- Removed Graphene, graphene-django, django-graphql-jwt, and graphql-relay + dependencies. JWT authentication now uses the shared Django/PyJWT runtime in + `config/jwt_auth/`; global IDs and cursor pagination preserve the existing API + format. Deployments using the optional refresh-token app should replace + `graphql_jwt.refresh_token` with `config.jwt_auth.refresh_token` in + `INSTALLED_APPS`; existing tables and migration history are preserved. +- Removed test-only resolver methods and the Graphene schema accessor, expanded + SDL parity checks, and made validation extensions independent per request. diff --git a/config/graphql/action_queries.py b/config/graphql/action_queries.py index 72f5299046..e6f99d1e5e 100644 --- a/config/graphql/action_queries.py +++ b/config/graphql/action_queries.py @@ -33,7 +33,6 @@ import strawberry from graphql import GraphQLError -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import login_required @@ -47,6 +46,7 @@ CorpusActionTemplate, ) from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/agent_mutations.py b/config/graphql/agent_mutations.py index 3d159affae..050e642cf6 100644 --- a/config/graphql/agent_mutations.py +++ b/config/graphql/agent_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -44,6 +43,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/analysis_mutations.py b/config/graphql/analysis_mutations.py index ed3cef2f56..2ecc1cbf86 100644 --- a/config/graphql/analysis_mutations.py +++ b/config/graphql/analysis_mutations.py @@ -32,7 +32,6 @@ import strawberry from django.conf import settings -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied, user_passes_test @@ -43,6 +42,7 @@ from config.graphql.ratelimits import RateLimits, graphql_ratelimit from config.telemetry import record_event from opencontractserver.analyzer.services import AnalysisLifecycleService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/annotation_mutations.py b/config/graphql/annotation_mutations.py index 1225e64e19..8f2c0f9923 100644 --- a/config/graphql/annotation_mutations.py +++ b/config/graphql/annotation_mutations.py @@ -33,7 +33,6 @@ import strawberry from django.core.exceptions import ValidationError from django.db import transaction -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -71,6 +70,7 @@ from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import LabelType, PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user logger = logging.getLogger(__name__) diff --git a/config/graphql/annotation_queries.py b/config/graphql/annotation_queries.py index d207e152f3..3fa787f9c1 100644 --- a/config/graphql/annotation_queries.py +++ b/config/graphql/annotation_queries.py @@ -34,7 +34,6 @@ import strawberry from django.db.models import Q from graphql import GraphQLError -from graphql_relay import from_global_id, to_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -81,6 +80,7 @@ from opencontractserver.documents.models import Document from opencontractserver.enrichment import constants as enrichment_constants from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id, to_global_id logger = logging.getLogger(__name__) @@ -108,7 +108,7 @@ def _resolve_GeographicAnnotationPinType_sample_document_ids(root, info): rather than the service so the service stays decoupled from the Relay encoding scheme. """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id return [to_global_id("DocumentType", pk) for pk in root.sample_document_ids] diff --git a/config/graphql/authority_frontier_mutations.py b/config/graphql/authority_frontier_mutations.py index 37218a410e..068e0ff351 100644 --- a/config/graphql/authority_frontier_mutations.py +++ b/config/graphql/authority_frontier_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -40,6 +39,7 @@ ) from opencontractserver.enrichment.services import AuthorityFrontierService from opencontractserver.enrichment.services.authority_permissions import DENIED +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/authority_mapping_mutations.py b/config/graphql/authority_mapping_mutations.py index a93e6b41af..a1c2480eef 100644 --- a/config/graphql/authority_mapping_mutations.py +++ b/config/graphql/authority_mapping_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -40,6 +39,7 @@ ) from opencontractserver.enrichment.services import AuthorityKeyEquivalenceService from opencontractserver.enrichment.services.authority_mapping_service import DENIED +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/authority_namespace_mutations.py b/config/graphql/authority_namespace_mutations.py index add8ba2654..f400faef5a 100644 --- a/config/graphql/authority_namespace_mutations.py +++ b/config/graphql/authority_namespace_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -40,6 +39,7 @@ ) from opencontractserver.enrichment.services import AuthorityNamespaceService from opencontractserver.enrichment.services.authority_permissions import DENIED +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/authority_pack_api.py b/config/graphql/authority_pack_api.py index fc933ea07d..a933a82470 100644 --- a/config/graphql/authority_pack_api.py +++ b/config/graphql/authority_pack_api.py @@ -5,7 +5,6 @@ from typing import Annotated import strawberry -from graphql_relay import to_global_id from config.graphql.core.scalars import GenericScalar from config.graphql.ratelimits import RateLimits, graphql_ratelimit @@ -13,6 +12,7 @@ AuthorityPackPlan, AuthorityPackService, ) +from opencontractserver.utils.ids import to_global_id @strawberry.type(name="AuthorityPackCorpus") diff --git a/config/graphql/badge_mutations.py b/config/graphql/badge_mutations.py index 1292ac9022..d54a48335a 100644 --- a/config/graphql/badge_mutations.py +++ b/config/graphql/badge_mutations.py @@ -32,7 +32,6 @@ import strawberry from graphql import GraphQLError -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -45,6 +44,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import ( get_for_user_or_none, set_permissions_for_obj_to_user, diff --git a/config/graphql/base_types.py b/config/graphql/base_types.py index bc89f86df8..8e101cafd5 100644 --- a/config/graphql/base_types.py +++ b/config/graphql/base_types.py @@ -235,7 +235,7 @@ class PdfPageInfoType: # Module-level helpers preserved from the graphene base_types module. # --------------------------------------------------------------------------- -from graphql_relay import to_global_id # noqa: E402 +from opencontractserver.utils.ids import to_global_id # noqa: E402 def build_flat_tree( diff --git a/config/graphql/conversation_mutations.py b/config/graphql/conversation_mutations.py index 8e111b7eab..7647ae2d51 100644 --- a/config/graphql/conversation_mutations.py +++ b/config/graphql/conversation_mutations.py @@ -33,7 +33,6 @@ import strawberry from django.db import transaction from django.utils import timezone -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -52,6 +51,7 @@ from opencontractserver.shared.services.base import BaseService from opencontractserver.tasks.agent_tasks import trigger_agent_responses_for_message from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.mention_parser import ( link_message_to_resources, parse_mentions_from_content, diff --git a/config/graphql/conversation_queries.py b/config/graphql/conversation_queries.py index 7e0108657f..5f45310267 100644 --- a/config/graphql/conversation_queries.py +++ b/config/graphql/conversation_queries.py @@ -35,7 +35,6 @@ import strawberry from django.db.models import Count, Prefetch, Q from django.utils import timezone -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -54,6 +53,7 @@ ) from opencontractserver.corpuses.models import Corpus from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/conversation_types.py b/config/graphql/conversation_types.py index bfd1887bb9..47acd63b68 100644 --- a/config/graphql/conversation_types.py +++ b/config/graphql/conversation_types.py @@ -32,7 +32,6 @@ from typing import Annotated, Any import strawberry -from graphql_relay import to_global_id from config.graphql import enums from config.graphql._util import coerce_enum, coerce_str, strip_unset @@ -61,6 +60,7 @@ ) from opencontractserver.notifications.models import Notification from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import to_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/core/LICENSE.graphql-relay b/config/graphql/core/LICENSE.graphql-relay new file mode 100644 index 0000000000..7d5a37611b --- /dev/null +++ b/config/graphql/core/LICENSE.graphql-relay @@ -0,0 +1,23 @@ +ID and pagination compatibility portions adapted from graphql-relay 3.2.0. + +The MIT License (MIT) + +Copyright (c) 2015 Syrus Akbary + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config/graphql/core/auth.py b/config/graphql/core/auth.py index d544741089..bca610afad 100644 --- a/config/graphql/core/auth.py +++ b/config/graphql/core/auth.py @@ -13,16 +13,7 @@ from functools import wraps from typing import Any, Callable - -class JSONWebTokenError(Exception): - default_message: str = "" - - def __init__(self, message: str | None = None): - super().__init__(message or self.default_message) - - -class PermissionDenied(JSONWebTokenError): - default_message = "You do not have permission to perform this action" +from config.jwt_auth.exceptions import JSONWebTokenError, PermissionDenied # noqa: F401 def user_passes_test( diff --git a/config/graphql/core/filtering.py b/config/graphql/core/filtering.py index 9f9ce0a51f..f38516635d 100644 --- a/config/graphql/core/filtering.py +++ b/config/graphql/core/filtering.py @@ -25,7 +25,8 @@ BaseFilterSet, FilterSet, ) -from graphql_relay import from_global_id + +from opencontractserver.utils.ids import from_global_id def to_camel_case(snake_str: str) -> str: @@ -102,7 +103,7 @@ def filter(self, qs, value): return super().filter(qs, gids) -GRAPHENE_FILTER_SET_OVERRIDES = { +FILTER_SET_OVERRIDES = { models.AutoField: {"filter_class": GlobalIDFilter}, models.OneToOneField: {"filter_class": GlobalIDFilter}, models.ForeignKey: {"filter_class": GlobalIDFilter}, @@ -117,7 +118,7 @@ class GrapheneFilterSetMixin(BaseFilterSet): FILTER_DEFAULTS = dict( itertools.chain( - FILTER_FOR_DBFIELD_DEFAULTS.items(), GRAPHENE_FILTER_SET_OVERRIDES.items() + FILTER_FOR_DBFIELD_DEFAULTS.items(), FILTER_SET_OVERRIDES.items() ) ) diff --git a/config/graphql/core/mutations.py b/config/graphql/core/mutations.py index 6188e05788..7042391008 100644 --- a/config/graphql/core/mutations.py +++ b/config/graphql/core/mutations.py @@ -13,7 +13,6 @@ from collections.abc import Sequence from typing import Any -from graphql_relay import from_global_id, to_global_id from rest_framework import serializers from config.graphql.core.auth import PermissionDenied @@ -21,6 +20,7 @@ from config.ratelimit.rates import RateLimits from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user logger = logging.getLogger(__name__) diff --git a/config/graphql/core/relay.py b/config/graphql/core/relay.py index d757db6b33..2e527ef27c 100644 --- a/config/graphql/core/relay.py +++ b/config/graphql/core/relay.py @@ -26,8 +26,8 @@ import django.db.models import strawberry from django.db.models import Manager, QuerySet -from graphql_relay import ( - connection_from_array_slice, + +from opencontractserver.utils.ids import ( cursor_to_offset, from_global_id, get_offset_with_default, @@ -131,53 +131,6 @@ def _is_type_of( definition.is_type_of = _is_type_of - _install_graphene_resolver_aliases(type_name, strawberry_type) - - -def _install_graphene_resolver_aliases(type_name: str, strawberry_type: type) -> None: - """Expose graphene-style ``XType.resolve_(root, info, ...)`` methods. - - graphene resolvers were bound methods callable as - ``XType.resolve_field(obj, info)`` — a form the unit tests use directly to - exercise resolver logic without going through the full schema. The - strawberry port keeps each custom resolver as a module-level - ``_resolve__(root, info, ...)`` function; this installs a - thin ``resolve_`` staticmethod alias onto the type for each, plus - the three permission-annotation fields, so those tests keep working - unchanged. Strawberry ignores arbitrary ``resolve_*`` attributes (only - ``@strawberry.field`` methods and annotated fields matter), so the aliases - are inert for schema execution. - """ - import sys - - module = sys.modules.get(strawberry_type.__module__) - if module is not None: - prefix = f"_resolve_{type_name}_" - for attr_name in dir(module): - if attr_name.startswith(prefix): - field = attr_name[len(prefix) :] - fn = getattr(module, attr_name) - if callable(fn) and not hasattr(strawberry_type, f"resolve_{field}"): - setattr(strawberry_type, f"resolve_{field}", staticmethod(fn)) - # graphene ``get_node`` / ``get_queryset`` classmethods (some unit - # tests — e.g. test_doc_annotations_prefetch — call them directly). - for hook in ("get_node", "get_queryset"): - hook_fn = getattr(module, f"_{hook}_{type_name}", None) - if callable(hook_fn) and not hasattr(strawberry_type, hook): - setattr(strawberry_type, hook, staticmethod(hook_fn)) - - # Permission-annotation fields live in the shared core module, not the - # per-type module, so alias them explicitly. - from config.graphql.core import permissions as _perm - - for field, fn in ( - ("my_permissions", _perm.resolve_my_permissions), - ("is_published", _perm.resolve_is_published), - ("object_shared_with", _perm.resolve_object_shared_with), - ): - if not hasattr(strawberry_type, f"resolve_{field}"): - setattr(strawberry_type, f"resolve_{field}", staticmethod(fn)) - def get_registry_entry(type_name: str) -> TypeRegistryEntry | None: return _TYPE_REGISTRY.get(type_name) @@ -345,8 +298,8 @@ class ConnectionValue: def __init__(self, edges: list[Any], page_info: PageInfo) -> None: self.edges = edges self.page_info = page_info - self.iterable = None - self.length = None + self.iterable: Any = None + self.length: int | None = None class EdgeValue: @@ -374,9 +327,7 @@ def _resolve_current_page(root: ConnectionValue, info: strawberry.Info) -> int: def _resolve_page_count(root: ConnectionValue, info: strawberry.Info) -> int: """Port of ``PdfPageAwareConnection.resolve_page_count``.""" - return max( - list(root.iterable.values_list("page", flat=True).distinct()) # type: ignore[attr-defined] - ) + return max(list(root.iterable.values_list("page", flat=True).distinct())) def make_connection_types( @@ -484,8 +435,6 @@ def resolve_connection_from_iterable( get_offset_with_default(args.get("after"), -1) + 1, array_length, ) - array_slice_length = array_length - slice_start - if ( max_limit is not None and args.get("first", None) is None @@ -493,33 +442,50 @@ def resolve_connection_from_iterable( ): args["first"] = max_limit - connection = connection_from_array_slice( - iterable[slice_start:], - args, - slice_start=slice_start, - array_length=array_length, - array_slice_length=array_slice_length, - # ``connection_from_array_slice`` invokes this as - # ``connection_type(edges=..., pageInfo=...)`` (graphql-relay's camelCase - # kwarg); the lambda adapts the ``pageInfo`` kwarg onto - # ``ConnectionValue``'s ``page_info`` positional — passing - # ``ConnectionValue`` directly would raise on the unexpected kwarg. - connection_type=lambda edges, pageInfo: ConnectionValue( # type: ignore[arg-type] - edges, pageInfo - ), - edge_type=EdgeValue, - page_info_type=lambda startCursor, endCursor, hasPreviousPage, hasNextPage: ( # type: ignore[arg-type] - PageInfo( - has_next_page=hasNextPage, - has_previous_page=hasPreviousPage, - start_cursor=startCursor, - end_cursor=endCursor, - ) + # Compute the requested window before evaluating a queryset. Preserve + # cursor bounds and directional page flags from the existing API. + start_offset = max(slice_start, 0) + end_offset = array_length + after = args.get("after") + before = args.get("before") + after_offset = get_offset_with_default(after, -1) + before_offset = get_offset_with_default(before, end_offset) + if 0 <= after_offset < array_length: + start_offset = max(start_offset, after_offset + 1) + if 0 <= before_offset < array_length: + end_offset = min(end_offset, before_offset) + + first, last = args.get("first"), args.get("last") + if isinstance(first, int): + if first < 0: + raise ValueError("Argument 'first' must be a non-negative integer.") + end_offset = min(end_offset, start_offset + first) + if isinstance(last, int): + if last < 0: + raise ValueError("Argument 'last' must be a non-negative integer.") + start_offset = max(start_offset, end_offset - last) + + window = iterable[slice_start:][ + start_offset - slice_start : end_offset - slice_start + ] + edges = [ + EdgeValue(node=value, cursor=offset_to_cursor(start_offset + index)) + for index, value in enumerate(window) + ] + connection = ConnectionValue( + edges, + PageInfo( + start_cursor=edges[0].cursor if edges else None, + end_cursor=edges[-1].cursor if edges else None, + has_previous_page=isinstance(last, int) + and start_offset > (after_offset + 1 if after else 0), + has_next_page=isinstance(first, int) + and end_offset < (before_offset if before else array_length), ), ) - connection.iterable = iterable # type: ignore[attr-defined] - connection.length = array_length # type: ignore[attr-defined] - return connection # type: ignore[return-value] + connection.iterable = iterable + connection.length = array_length + return connection def resolve_django_connection( diff --git a/config/graphql/corpus_category_mutations.py b/config/graphql/corpus_category_mutations.py index 3004863b8e..b61cca078d 100644 --- a/config/graphql/corpus_category_mutations.py +++ b/config/graphql/corpus_category_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -40,6 +39,7 @@ ) from config.graphql.ratelimits import RateLimits, graphql_ratelimit from opencontractserver.corpuses.services import CorpusCategoryService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/corpus_folder_mutations.py b/config/graphql/corpus_folder_mutations.py index c871a06e00..73ffb871ac 100644 --- a/config/graphql/corpus_folder_mutations.py +++ b/config/graphql/corpus_folder_mutations.py @@ -32,7 +32,6 @@ import strawberry from django.contrib.auth import get_user_model -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -50,6 +49,7 @@ ) from opencontractserver.documents.models import Document from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id User = get_user_model() logger = logging.getLogger(__name__) diff --git a/config/graphql/corpus_group_mutations.py b/config/graphql/corpus_group_mutations.py index ba2dd8584f..b8404e530c 100644 --- a/config/graphql/corpus_group_mutations.py +++ b/config/graphql/corpus_group_mutations.py @@ -14,7 +14,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -23,6 +22,7 @@ from config.graphql.ratelimits import RateLimits, graphql_ratelimit from opencontractserver.corpuses.services import CorpusGroupService from opencontractserver.corpuses.services.corpus_groups import GROUP_NOT_FOUND_MESSAGE +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/corpus_mutations.py b/config/graphql/corpus_mutations.py index bd32c91f00..81bb5c484a 100644 --- a/config/graphql/corpus_mutations.py +++ b/config/graphql/corpus_mutations.py @@ -34,7 +34,6 @@ from django.conf import settings from django.db import DatabaseError, transaction from django.utils import timezone -from graphql_relay import from_global_id, to_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -66,6 +65,7 @@ from opencontractserver.tasks import fork_corpus from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.corpus_collector import collect_corpus_objects +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import ( get_for_user_or_none, set_permissions_for_obj_to_user, @@ -1922,11 +1922,11 @@ def _mutate_RunCorpusAction( @graphql_ratelimit(rate=RateLimits.ADMIN_OPERATION) def mutate(root, info, corpus_action_id: str, document_id: str): from django.core.exceptions import PermissionDenied as DjangoPermissionDenied - from graphql_relay import from_global_id from opencontractserver.corpuses.models import CorpusActionExecution from opencontractserver.documents.models import DocumentPath from opencontractserver.tasks.agent_tasks import run_agent_corpus_action + from opencontractserver.utils.ids import from_global_id user = info.context.user diff --git a/config/graphql/corpus_queries.py b/config/graphql/corpus_queries.py index e3c6307214..6990771bec 100644 --- a/config/graphql/corpus_queries.py +++ b/config/graphql/corpus_queries.py @@ -33,7 +33,6 @@ import strawberry from django.db.models import Count, Q, Subquery from django.db.models.functions import Coalesce -from graphql_relay import from_global_id, to_global_id from config.graphql._util import strip_unset from config.graphql.core.filtering import setup_filterset @@ -73,6 +72,7 @@ from opencontractserver.documents.models import Document from opencontractserver.feedback.models import UserFeedback from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id, to_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/corpus_types.py b/config/graphql/corpus_types.py index a34915ab3c..9c1932294e 100644 --- a/config/graphql/corpus_types.py +++ b/config/graphql/corpus_types.py @@ -34,7 +34,6 @@ import strawberry from django.contrib.auth import get_user_model from django.db.models import OuterRef, Q, Subquery -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import coerce_enum, coerce_str, strip_unset @@ -64,6 +63,7 @@ ) from opencontractserver.shared.services.base import BaseService from opencontractserver.utils.auth import is_authenticated_user +from opencontractserver.utils.ids import from_global_id User = get_user_model() logger = logging.getLogger(__name__) @@ -1798,9 +1798,6 @@ def _get_node_CorpusType(info, pk): # ``_corpus_node_cache`` that collapses the ``corpuses_corpus`` recursive CTE # storm on ``annotation.corpus`` FK access # (``test_corpus_tree_cte_does_not_scale_with_document_count``). -# ``_get_node_CorpusType`` is also still installed on the class as a -# graphene-compat ``get_node`` (for the request-cache unit test) via -# ``_install_graphene_resolver_aliases``. register_type( "CorpusType", CorpusType, diff --git a/config/graphql/custom_resolvers.py b/config/graphql/custom_resolvers.py index 9477071506..84eb3633b1 100644 --- a/config/graphql/custom_resolvers.py +++ b/config/graphql/custom_resolvers.py @@ -6,9 +6,9 @@ from typing import Any from graphql.language import ast as gql_ast -from graphql_relay import from_global_id from opencontractserver.constants.annotations import MANUAL_ANNOTATION_SENTINEL +from opencontractserver.utils.ids import from_global_id # ``DjangoFilterConnectionField`` delivers filter kwargs to the resolver using # the **Django ORM lookup names** declared in ``AnnotationFilter`` — i.e. the diff --git a/config/graphql/document_mutations.py b/config/graphql/document_mutations.py index d1b6d828d1..42c0226138 100644 --- a/config/graphql/document_mutations.py +++ b/config/graphql/document_mutations.py @@ -39,7 +39,6 @@ from django.db.models import Max, Q from django.utils import timezone from graphql import GraphQLError -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -87,6 +86,7 @@ ) from opencontractserver.users.models import UserExport from opencontractserver.utils.etl import is_dict_instance_of_typed_dict +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user logger = logging.getLogger(__name__) diff --git a/config/graphql/document_queries.py b/config/graphql/document_queries.py index 9c21ae9224..1f65547230 100644 --- a/config/graphql/document_queries.py +++ b/config/graphql/document_queries.py @@ -36,7 +36,6 @@ from django.db.models import Count, Q, Sum from django.db.models.functions import Coalesce from graphql import GraphQLError -from graphql_relay import from_global_id, to_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -66,6 +65,7 @@ ) from opencontractserver.documents.services import DocumentRelationshipService from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id, to_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/document_relationship_mutations.py b/config/graphql/document_relationship_mutations.py index c1bb14aa36..e0db7b6382 100644 --- a/config/graphql/document_relationship_mutations.py +++ b/config/graphql/document_relationship_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import login_required @@ -46,6 +45,7 @@ from opencontractserver.documents.services import DocumentRelationshipService from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import get_for_user_or_none logger = logging.getLogger(__name__) diff --git a/config/graphql/document_types.py b/config/graphql/document_types.py index cd0f5701f1..c0162bc837 100644 --- a/config/graphql/document_types.py +++ b/config/graphql/document_types.py @@ -36,7 +36,6 @@ from django.contrib.auth import get_user_model from django.db.models import QuerySet from graphql import GraphQLError -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import coerce_enum, coerce_str, strip_unset @@ -72,6 +71,7 @@ IngestionSource, ) from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id User = get_user_model() logger = logging.getLogger(__name__) @@ -594,9 +594,8 @@ def _resolve_DocumentType_version_history(root, info): attribute-based, so the same data is packed into the plain ``DocumentVersionType`` / ``VersionHistoryType`` value types instead. """ - from graphql_relay import to_global_id - from config.graphql.base_types import DocumentVersionType, VersionHistoryType + from opencontractserver.utils.ids import to_global_id # Get all documents in the version tree the user may see, ordered by # creation. Scoped to ``visible_to_user`` so this resolver cannot leak @@ -658,9 +657,8 @@ def _resolve_DocumentType_path_history(root, info, corpus_id): attribute-based, so the same data is packed into the plain ``PathEventType`` / ``PathHistoryType`` value types instead. """ - from graphql_relay import to_global_id - from config.graphql.base_types import PathEventType, PathHistoryType + from opencontractserver.utils.ids import to_global_id _, corpus_pk = from_global_id(corpus_id) @@ -739,9 +737,8 @@ def _resolve_DocumentType_corpus_versions(root, info, corpus_id): in one query reuses the same result for documents sharing a version_tree_id + corpus_id pair (avoids N+1). """ - from graphql_relay import to_global_id - from config.graphql.base_types import CorpusVersionInfoType + from opencontractserver.utils.ids import to_global_id type_name, corpus_pk = from_global_id(corpus_id) if not type_name or type_name != "CorpusType": diff --git a/config/graphql/enrichment_mutations.py b/config/graphql/enrichment_mutations.py index 735f2d5025..16bbf696e1 100644 --- a/config/graphql/enrichment_mutations.py +++ b/config/graphql/enrichment_mutations.py @@ -32,7 +32,6 @@ import strawberry from django.db import transaction -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -54,6 +53,7 @@ from opencontractserver.enrichment.services.crawl_authorities_service import ( CrawlAuthoritiesService, ) +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/extract_mutations.py b/config/graphql/extract_mutations.py index 8ef5521760..15ac15f77e 100644 --- a/config/graphql/extract_mutations.py +++ b/config/graphql/extract_mutations.py @@ -36,7 +36,6 @@ from django.db import transaction from django.db.models import Q from django.utils import timezone -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -53,6 +52,7 @@ from opencontractserver.shared.services.base import BaseService from opencontractserver.tasks.extract_orchestrator_tasks import run_extract from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import ( get_for_user_or_none, set_permissions_for_obj_to_user, diff --git a/config/graphql/extract_queries.py b/config/graphql/extract_queries.py index 6937da3644..acf8ef248c 100644 --- a/config/graphql/extract_queries.py +++ b/config/graphql/extract_queries.py @@ -33,7 +33,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -59,6 +58,7 @@ from opencontractserver.constants.extracts import EXTRACT_LIST_MAX_PAGE_SIZE from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/extract_types.py b/config/graphql/extract_types.py index 1f85a00db6..da09542909 100644 --- a/config/graphql/extract_types.py +++ b/config/graphql/extract_types.py @@ -31,7 +31,6 @@ from typing import Annotated, Any import strawberry -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import coerce_enum, coerce_str, strip_unset @@ -52,6 +51,7 @@ from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.notifications.models import Notification from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id def _get_datacell_qs(extract, user) -> Any: diff --git a/config/graphql/filters.py b/config/graphql/filters.py index 406d364b86..56e87e1eb1 100644 --- a/config/graphql/filters.py +++ b/config/graphql/filters.py @@ -10,7 +10,6 @@ from django.db.models import Q, QuerySet from django_filters import OrderingFilter from django_filters import rest_framework as filters -from graphql_relay import from_global_id from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.annotations.models import ( @@ -37,6 +36,7 @@ ) from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.users.models import Assignment, UserExport +from opencontractserver.utils.ids import from_global_id User = get_user_model() diff --git a/config/graphql/ingestion_source_mutations.py b/config/graphql/ingestion_source_mutations.py index 7c27d20bc8..1f80f9e21c 100644 --- a/config/graphql/ingestion_source_mutations.py +++ b/config/graphql/ingestion_source_mutations.py @@ -32,7 +32,6 @@ import strawberry from django.db import IntegrityError -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -47,6 +46,7 @@ IngestionSource, IngestionSourceCategory, ) +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import ( PermissionTypes, set_permissions_for_obj_to_user, diff --git a/config/graphql/jwt_auth.py b/config/graphql/jwt_auth.py index 7b65f8247f..4f5ac3c202 100644 --- a/config/graphql/jwt_auth.py +++ b/config/graphql/jwt_auth.py @@ -27,27 +27,26 @@ from __future__ import annotations -from calendar import timegm -from datetime import datetime from typing import Annotated import strawberry from django.middleware.csrf import rotate_token -from graphql_jwt.exceptions import JSONWebTokenError -from graphql_jwt.refresh_token import signals as refresh_token_signals -from graphql_jwt.refresh_token.shortcuts import ( - create_refresh_token, - get_refresh_token, - refresh_token_lazy, -) -from graphql_jwt.settings import jwt_settings -from graphql_jwt.utils import get_payload from config.graphql._util import strip_unset from config.graphql.core.relay import ( register_type, ) from config.graphql.core.scalars import GenericScalar +from config.jwt_auth.exceptions import JSONWebTokenError +from config.jwt_auth.refresh_token import signals as refresh_token_signals +from config.jwt_auth.refresh_token.shortcuts import ( + create_refresh_token, + get_refresh_token, + refresh_token_lazy, +) +from config.jwt_auth.settings import jwt_settings +from config.jwt_auth.utils import get_payload +from config.jwt_auth.utils import refresh_expires_in as _refresh_expires_in @strawberry.type(name="Verify") @@ -78,14 +77,6 @@ def _ensure_token(info, token): return token -def _refresh_expires_in(orig_iat=None): - """Port of ``graphql_jwt.decorators.refresh_expiration`` timestamping.""" - base = ( - orig_iat if orig_iat is not None else timegm(datetime.utcnow().utctimetuple()) - ) - return base + jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds() - - def _maybe_rotate_csrf(info): """Port of ``graphql_jwt.decorators.csrf_rotation``.""" if jwt_settings.JWT_CSRF_ROTATION: diff --git a/config/graphql/label_mutations.py b/config/graphql/label_mutations.py index fbbbfe6185..7fd3b7e917 100644 --- a/config/graphql/label_mutations.py +++ b/config/graphql/label_mutations.py @@ -34,7 +34,6 @@ import strawberry from django.conf import settings from django.core.files.base import ContentFile -from graphql_relay import from_global_id, to_global_id from config.graphql._util import strip_unset from config.graphql.annotation_serializers import AnnotationLabelSerializer @@ -49,6 +48,7 @@ from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import ( get_for_user_or_none, set_permissions_for_obj_to_user, diff --git a/config/graphql/moderation_mutations.py b/config/graphql/moderation_mutations.py index 4ca181074f..940a3f2c57 100644 --- a/config/graphql/moderation_mutations.py +++ b/config/graphql/moderation_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -45,6 +44,7 @@ CorpusModerator, ) from opencontractserver.corpuses.models import Corpus +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/notification_mutations.py b/config/graphql/notification_mutations.py index 71e522a146..d2d4dd2d10 100644 --- a/config/graphql/notification_mutations.py +++ b/config/graphql/notification_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -40,6 +39,7 @@ ) from config.graphql.ratelimits import RateLimits, graphql_ratelimit from opencontractserver.notifications.services import NotificationService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/og_metadata_queries.py b/config/graphql/og_metadata_queries.py index 7132fae742..2d5d461705 100644 --- a/config/graphql/og_metadata_queries.py +++ b/config/graphql/og_metadata_queries.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.og_metadata_types import ( @@ -46,6 +45,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.corpuses.services import CorpusDocumentService from opencontractserver.documents.models import Document +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/research_mutations.py b/config/graphql/research_mutations.py index 6a2dd608ff..18b6c527be 100644 --- a/config/graphql/research_mutations.py +++ b/config/graphql/research_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -46,6 +45,7 @@ ResearchReportService, ) from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/research_queries.py b/config/graphql/research_queries.py index d6fe697da3..65c9aa8acf 100644 --- a/config/graphql/research_queries.py +++ b/config/graphql/research_queries.py @@ -30,7 +30,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import login_required @@ -41,6 +40,7 @@ from opencontractserver.research.models import ResearchReport from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import JobStatus +from opencontractserver.utils.ids import from_global_id def _decode_global_pk(global_id: str) -> int | None: diff --git a/config/graphql/schema.py b/config/graphql/schema.py index 3be5c7fda7..29c8f5a376 100644 --- a/config/graphql/schema.py +++ b/config/graphql/schema.py @@ -201,7 +201,9 @@ if not settings.DEBUG: _custom_rules.append(DisableIntrospection) -_extensions: list = [AddValidationRules(_custom_rules)] +# Strawberry binds execution_context on each extension. Construct it per +# request so overlapping operations never share validation state. +_extensions: list = [lambda: AddValidationRules(_custom_rules)] if getattr(settings, "FILE_URL_SHARED_CACHE_TTL", 0): from config.graphql.file_url_prewarm import FileUrlPrewarmExtension @@ -216,11 +218,3 @@ types=_extra_types, extensions=_extensions, ) - -# Backwards-compatibility accessor: graphene's ``Schema`` exposed the -# underlying graphql-core schema as ``.graphql_schema``. A few call sites -# (frontend-document validation in ``scripts/validate_frontend_graphql.py`` -# and ``test_security_hardening``/``test_authority_mapping_loader``) reach -# for it directly. Strawberry stores it on the private ``_schema``; alias it -# so those references keep working across the migration without a rename. -schema.graphql_schema = schema._schema # type: ignore[attr-defined] diff --git a/config/graphql/search_queries.py b/config/graphql/search_queries.py index a9db902916..062b8039dd 100644 --- a/config/graphql/search_queries.py +++ b/config/graphql/search_queries.py @@ -34,7 +34,6 @@ from django.contrib.postgres.search import SearchQuery from django.db.models import Q from django.db.models.functions import Left -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import login_required @@ -55,6 +54,7 @@ from opencontractserver.documents.models import Document from opencontractserver.shared.services.base import BaseService from opencontractserver.users.models import User +from opencontractserver.utils.ids import from_global_id logger = _logging.getLogger(__name__) diff --git a/config/graphql/smart_label_mutations.py b/config/graphql/smart_label_mutations.py index 6e85551216..2819dc4672 100644 --- a/config/graphql/smart_label_mutations.py +++ b/config/graphql/smart_label_mutations.py @@ -32,7 +32,6 @@ import strawberry from django.db import transaction -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -44,6 +43,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user logger = logging.getLogger(__name__) diff --git a/config/graphql/social_queries.py b/config/graphql/social_queries.py index f83539acbc..5f63c23ee4 100644 --- a/config/graphql/social_queries.py +++ b/config/graphql/social_queries.py @@ -35,7 +35,6 @@ from django.core.cache import cache from django.db.models import Q from graphql import GraphQLError -from graphql_relay import from_global_id from config.graphql import enums from config.graphql._util import strip_unset @@ -69,6 +68,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.notifications.models import Notification from opencontractserver.shared.services.base import BaseService +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/graphql/user_mutations.py b/config/graphql/user_mutations.py index 582888d70e..0d43010f3f 100644 --- a/config/graphql/user_mutations.py +++ b/config/graphql/user_mutations.py @@ -27,22 +27,11 @@ from __future__ import annotations -from calendar import timegm as _timegm -from datetime import datetime as _datetime from typing import Annotated import strawberry from django.contrib.auth import authenticate as _dj_authenticate from django.middleware.csrf import rotate_token as _rotate_token -from graphql_jwt import signals as _jwt_signals -from graphql_jwt.exceptions import JSONWebTokenError as _JWTError -from graphql_jwt.refresh_token.shortcuts import ( - create_refresh_token as _create_refresh_token, -) -from graphql_jwt.refresh_token.shortcuts import ( - refresh_token_lazy as _refresh_token_lazy, -) -from graphql_jwt.settings import jwt_settings as _jwt_settings from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -56,6 +45,16 @@ graphql_ratelimit, graphql_ratelimit_dynamic, ) +from config.jwt_auth import signals as _jwt_signals +from config.jwt_auth.exceptions import JSONWebTokenError as _JWTError +from config.jwt_auth.refresh_token.shortcuts import ( + create_refresh_token as _create_refresh_token, +) +from config.jwt_auth.refresh_token.shortcuts import ( + refresh_token_lazy as _refresh_token_lazy, +) +from config.jwt_auth.settings import jwt_settings as _jwt_settings +from config.jwt_auth.utils import refresh_expires_in @strawberry.type(name="ObtainJSONWebTokenWithUser") @@ -187,10 +186,7 @@ def _mutate_ObtainJSONWebTokenWithUser( result.refresh_token = _refresh_token_lazy(user) # graphql_jwt.decorators.refresh_expiration - result.refresh_expires_in = ( - _timegm(_datetime.utcnow().utctimetuple()) - + _jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds() - ) + result.refresh_expires_in = refresh_expires_in() # graphql_jwt.decorators.csrf_rotation if _jwt_settings.JWT_CSRF_ROTATION: diff --git a/config/graphql/user_types.py b/config/graphql/user_types.py index ab65efd7fa..5b6de833ce 100644 --- a/config/graphql/user_types.py +++ b/config/graphql/user_types.py @@ -331,9 +331,8 @@ def _resolve_UserType_reputation_for_corpus(root, info, corpus_id): Port of UserType.resolve_reputation_for_corpus """ - from graphql_relay import from_global_id - from opencontractserver.conversations.models import UserReputation + from opencontractserver.utils.ids import from_global_id try: _, corpus_pk = from_global_id(corpus_id) diff --git a/config/graphql/views.py b/config/graphql/views.py index acebd692f2..001d2410d2 100644 --- a/config/graphql/views.py +++ b/config/graphql/views.py @@ -72,9 +72,10 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any): # malformed/unknown/inactive API keys (DRF ``AuthenticationFailed`` # raised by ``ApiKeyBackend`` when ``USE_API_KEY_AUTH=True``) both # become a 200 error payload rather than an unhandled 500. - from graphql_jwt.exceptions import JSONWebTokenError from rest_framework.exceptions import AuthenticationFailed + from config.jwt_auth.exceptions import JSONWebTokenError + if isinstance(exc, (JSONWebTokenError, AuthenticationFailed)): return JsonResponse( {"errors": [{"message": str(exc)}], "data": None}, status=200 diff --git a/config/graphql/voting_mutations.py b/config/graphql/voting_mutations.py index 89a0a8754d..61c523534a 100644 --- a/config/graphql/voting_mutations.py +++ b/config/graphql/voting_mutations.py @@ -31,7 +31,6 @@ from typing import Annotated import strawberry -from graphql_relay import from_global_id from config.graphql._util import strip_unset from config.graphql.core.auth import PermissionDenied @@ -50,6 +49,7 @@ from opencontractserver.shared.services.base import BaseService from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.auth import is_authenticated_user +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) diff --git a/config/graphql_auth0_auth/backends.py b/config/graphql_auth0_auth/backends.py index 2ec0026720..dd7faf8f77 100644 --- a/config/graphql_auth0_auth/backends.py +++ b/config/graphql_auth0_auth/backends.py @@ -1,10 +1,10 @@ import logging -import graphql_jwt from django.contrib.auth import get_user_model -from graphql_jwt.exceptions import JSONWebTokenExpired from config.graphql_auth0_auth.utils import get_user_by_token +from config.jwt_auth import utils as jwt_utils +from config.jwt_auth.exceptions import JSONWebTokenExpired UserModel = get_user_model() logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def authenticate(self, request=None, **kwargs): ) return None - token = graphql_jwt.utils.get_credentials(request, **kwargs) + token = jwt_utils.get_credentials(request, **kwargs) logger.debug( f"Auth0RemoteUserJSONWebTokenBackend.authenticate() - token retrieved: {'Present' if token else 'None'}" ) diff --git a/config/graphql_auth0_auth/utils.py b/config/graphql_auth0_auth/utils.py index 0ae7e7190e..781b3e44f2 100644 --- a/config/graphql_auth0_auth/utils.py +++ b/config/graphql_auth0_auth/utils.py @@ -9,9 +9,9 @@ from django.contrib.auth import get_user_model from django.utils import timezone from django.utils.translation import gettext as _ -from graphql_jwt import exceptions from config.graphql_auth0_auth.settings import auth0_settings +from config.jwt_auth import exceptions from opencontractserver.constants import TOKEN_LOG_PREFIX_LENGTH logger = logging.getLogger(__name__) diff --git a/config/jwt_auth/LICENSE b/config/jwt_auth/LICENSE new file mode 100644 index 0000000000..6a9e7bb3fc --- /dev/null +++ b/config/jwt_auth/LICENSE @@ -0,0 +1,22 @@ +JWT compatibility portions adapted from django-graphql-jwt 0.4.0. + +The MIT License (MIT) + +Copyright (c) 2019 mongkok + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/config/jwt_auth/__init__.py b/config/jwt_auth/__init__.py new file mode 100644 index 0000000000..ecdcad5f11 --- /dev/null +++ b/config/jwt_auth/__init__.py @@ -0,0 +1,4 @@ +"""JWT authentication shared by GraphQL, REST, MCP, and WebSockets. + +This package uses PyJWT and Django without importing a GraphQL framework. +""" diff --git a/config/jwt_auth/backends.py b/config/jwt_auth/backends.py new file mode 100644 index 0000000000..011cc21127 --- /dev/null +++ b/config/jwt_auth/backends.py @@ -0,0 +1,21 @@ +"""Django authentication backend for locally signed JWTs.""" + +from django.contrib.auth import get_user_model + +from .shortcuts import get_user_by_token +from .utils import get_credentials + + +class JSONWebTokenBackend: + def authenticate(self, request=None, **kwargs): + if request is None or getattr(request, "_jwt_token_auth", False): + return None + token = get_credentials(request, **kwargs) + return get_user_by_token(token, request) if token is not None else None + + def get_user(self, user_id): + model = get_user_model() + try: + return model._default_manager.get(pk=user_id) + except model.DoesNotExist: + return None diff --git a/config/jwt_auth/exceptions.py b/config/jwt_auth/exceptions.py new file mode 100644 index 0000000000..82d0637f8a --- /dev/null +++ b/config/jwt_auth/exceptions.py @@ -0,0 +1,19 @@ +"""Authentication errors whose messages are part of the public API.""" + +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy as _ + + +class JSONWebTokenError(Exception): + default_message: str | Promise | None = None + + def __init__(self, message=None): + super().__init__(self.default_message if message is None else message) + + +class JSONWebTokenExpired(JSONWebTokenError): + default_message = _("Signature has expired") + + +class PermissionDenied(JSONWebTokenError): + default_message = _("You do not have permission to perform this action") diff --git a/config/jwt_auth/refresh_token/__init__.py b/config/jwt_auth/refresh_token/__init__.py new file mode 100644 index 0000000000..015ee9b523 --- /dev/null +++ b/config/jwt_auth/refresh_token/__init__.py @@ -0,0 +1 @@ +"""Optional persistent refresh tokens; importing helpers does not load models.""" diff --git a/config/jwt_auth/refresh_token/apps.py b/config/jwt_auth/refresh_token/apps.py new file mode 100644 index 0000000000..a15df20738 --- /dev/null +++ b/config/jwt_auth/refresh_token/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class RefreshTokenConfig(AppConfig): + name = "config.jwt_auth.refresh_token" + # Preserve the migration recorder entries, content types, and table name + # for deployments that enabled django-graphql-jwt's optional model. + label = "refresh_token" + verbose_name = "Refresh token" diff --git a/config/jwt_auth/refresh_token/migrations/0001_initial.py b/config/jwt_auth/refresh_token/migrations/0001_initial.py new file mode 100644 index 0000000000..fbaf384315 --- /dev/null +++ b/config/jwt_auth/refresh_token/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 2.1.2 on 2018-10-16 20:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RefreshToken", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "token", + models.CharField( + editable=False, max_length=255, verbose_name="token" + ), + ), + ( + "created", + models.DateTimeField(auto_now_add=True, verbose_name="created"), + ), + ( + "revoked", + models.DateTimeField(blank=True, null=True, verbose_name="revoked"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="refresh_tokens", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "Refresh token", + "verbose_name_plural": "Refresh tokens", + "abstract": False, + }, + ), + migrations.AlterUniqueTogether( + name="refreshtoken", + unique_together={("token", "revoked")}, + ), + ] diff --git a/config/jwt_auth/refresh_token/migrations/0002_auto_20190130_0900.py b/config/jwt_auth/refresh_token/migrations/0002_auto_20190130_0900.py new file mode 100644 index 0000000000..1774a83b5a --- /dev/null +++ b/config/jwt_auth/refresh_token/migrations/0002_auto_20190130_0900.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("refresh_token", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="refreshtoken", + options={ + "verbose_name": "refresh token", + "verbose_name_plural": "refresh tokens", + }, + ), + ] diff --git a/config/jwt_auth/refresh_token/migrations/__init__.py b/config/jwt_auth/refresh_token/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/config/jwt_auth/refresh_token/models.py b/config/jwt_auth/refresh_token/models.py new file mode 100644 index 0000000000..a013e29996 --- /dev/null +++ b/config/jwt_auth/refresh_token/models.py @@ -0,0 +1,78 @@ +"""Optional refresh-token storage with the existing database layout.""" + +import secrets + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from config.jwt_auth.settings import jwt_settings + +from . import signals + + +class RefreshTokenQuerySet(models.QuerySet): + def expired(self): + cutoff = timezone.now() - jwt_settings.JWT_REFRESH_EXPIRATION_DELTA + return self.annotate( + expired=models.Case( + models.When(created__lt=cutoff, then=models.Value(True)), + default=models.Value(False), + output_field=models.BooleanField(), + ) + ) + + +class AbstractRefreshToken(models.Model): + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="refresh_tokens", + verbose_name="user", + ) + token = models.CharField("token", max_length=255, editable=False) + created = models.DateTimeField("created", auto_now_add=True) + revoked = models.DateTimeField("revoked", null=True, blank=True) + objects = RefreshTokenQuerySet.as_manager() + + class Meta: + abstract = True + verbose_name = "refresh token" + verbose_name_plural = "refresh tokens" + unique_together = ("token", "revoked") + + def __str__(self): + return self.token + + def generate_token(self): + return secrets.token_hex(jwt_settings.JWT_REFRESH_TOKEN_N_BYTES) + + def save(self, *args, **kwargs): + if not self.token: + self.token = self._cached_token = self.generate_token() + super().save(*args, **kwargs) + + def get_token(self): + return getattr(self, "_cached_token", self.token) + + def is_expired(self, request=None): + return jwt_settings.JWT_REFRESH_EXPIRED_HANDLER( + int(self.created.timestamp()), request + ) + + def revoke(self, request=None): + self.revoked = timezone.now() + self.save(update_fields=["revoked"]) + signals.refresh_token_revoked.send( + sender=AbstractRefreshToken, request=request, refresh_token=self + ) + + def reuse(self, request=None): + self.token = "" + self.created = timezone.now() + self.save(update_fields=["token", "created"]) + + +class RefreshToken(AbstractRefreshToken): + """Default model, enabled only when this app is in INSTALLED_APPS.""" diff --git a/config/jwt_auth/refresh_token/shortcuts.py b/config/jwt_auth/refresh_token/shortcuts.py new file mode 100644 index 0000000000..07b02c6ba4 --- /dev/null +++ b/config/jwt_auth/refresh_token/shortcuts.py @@ -0,0 +1,32 @@ +from django.utils.functional import lazy + +from config.jwt_auth.exceptions import JSONWebTokenError +from config.jwt_auth.settings import jwt_settings + +from .utils import get_refresh_token_model + + +def get_refresh_token(token, context=None): + model = get_refresh_token_model() + try: + return jwt_settings.JWT_GET_REFRESH_TOKEN_HANDLER( + refresh_token_model=model, token=token, context=context + ) + except model.DoesNotExist as exc: + raise JSONWebTokenError("Invalid refresh token") from exc + + +def create_refresh_token(user, refresh_token=None): + if refresh_token is not None and jwt_settings.JWT_REUSE_REFRESH_TOKENS: + refresh_token.reuse() + return refresh_token + return get_refresh_token_model().objects.create(user=user) + + +def _issue_refresh_token(user, refresh_token=None): + return create_refresh_token(user, refresh_token).get_token() + + +# A tokenAuth operation that selects only `token` must not create a refresh +# record (or require the optional model to be installed). +refresh_token_lazy = lazy(_issue_refresh_token, str) diff --git a/config/jwt_auth/refresh_token/signals.py b/config/jwt_auth/refresh_token/signals.py new file mode 100644 index 0000000000..e919291d3a --- /dev/null +++ b/config/jwt_auth/refresh_token/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +refresh_token_revoked = Signal() +refresh_token_rotated = Signal() diff --git a/config/jwt_auth/refresh_token/utils.py b/config/jwt_auth/refresh_token/utils.py new file mode 100644 index 0000000000..aaf2b14528 --- /dev/null +++ b/config/jwt_auth/refresh_token/utils.py @@ -0,0 +1,11 @@ +from django.apps import apps + +from config.jwt_auth.settings import jwt_settings + + +def get_refresh_token_model(): + return apps.get_model(jwt_settings.JWT_REFRESH_TOKEN_MODEL) + + +def get_refresh_token_by_model(refresh_token_model, token, context=None): + return refresh_token_model.objects.get(token=token, revoked__isnull=True) diff --git a/config/jwt_auth/settings.py b/config/jwt_auth/settings.py new file mode 100644 index 0000000000..1844026c74 --- /dev/null +++ b/config/jwt_auth/settings.py @@ -0,0 +1,67 @@ +"""Keep the deployed GRAPHQL_JWT configuration while owning its runtime.""" + +from datetime import timedelta +from typing import Any + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.module_loading import import_string + +DEFAULTS = { + "JWT_ALGORITHM": "HS256", + "JWT_AUDIENCE": None, + "JWT_ISSUER": None, + "JWT_LEEWAY": 0, + "JWT_PUBLIC_KEY": None, + "JWT_PRIVATE_KEY": None, + "JWT_VERIFY": True, + "JWT_VERIFY_EXPIRATION": False, + "JWT_EXPIRATION_DELTA": timedelta(minutes=5), + "JWT_ALLOW_REFRESH": True, + "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=7), + "JWT_LONG_RUNNING_REFRESH_TOKEN": False, + "JWT_REFRESH_TOKEN_MODEL": "refresh_token.RefreshToken", + "JWT_REFRESH_TOKEN_N_BYTES": 20, + "JWT_REUSE_REFRESH_TOKENS": False, + "JWT_AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_ALLOW_ARGUMENT": False, + "JWT_ARGUMENT_NAME": "token", + "JWT_ENCODE_HANDLER": "config.jwt_auth.utils.jwt_encode", + "JWT_DECODE_HANDLER": "config.jwt_auth.utils.jwt_decode", + "JWT_PAYLOAD_HANDLER": "config.jwt_auth.utils.jwt_payload", + "JWT_PAYLOAD_GET_USERNAME_HANDLER": ( + lambda payload: payload.get(get_user_model().USERNAME_FIELD) + ), + "JWT_GET_USER_BY_NATURAL_KEY_HANDLER": "config.jwt_auth.utils.get_user_by_natural_key", + "JWT_REFRESH_EXPIRED_HANDLER": "config.jwt_auth.utils.refresh_has_expired", + "JWT_GET_REFRESH_TOKEN_HANDLER": ( + "config.jwt_auth.refresh_token.utils.get_refresh_token_by_model" + ), + "JWT_CSRF_ROTATION": False, + "JWT_COOKIE_NAME": "JWT", + "JWT_REFRESH_TOKEN_COOKIE_NAME": "JWT-refresh-token", +} + + +class JWTSettings: + def __getattr__(self, name: str) -> Any: + if name == "JWT_SECRET_KEY": + default = settings.SECRET_KEY + elif name in DEFAULTS: + default = DEFAULTS[name] + else: + raise AttributeError(f"Invalid setting: `{name}`") + value = getattr(settings, "GRAPHQL_JWT", {}).get(name, default) + if name.endswith("_HANDLER") and isinstance(value, str): + # Existing deployments may explicitly configure the old default + # handler paths. Resolve those to the framework-free replacements. + if value.startswith("graphql_jwt."): + value = value.replace("graphql_jwt.", "config.jwt_auth.", 1) + return import_string(value) + return value + + +# Read settings on access so override_settings and signing-key changes cannot +# leave a stale cached secret or handler behind. +jwt_settings = JWTSettings() diff --git a/config/jwt_auth/shortcuts.py b/config/jwt_auth/shortcuts.py new file mode 100644 index 0000000000..25c5b65fdf --- /dev/null +++ b/config/jwt_auth/shortcuts.py @@ -0,0 +1,14 @@ +"""Token issuance and user lookup for all API transports.""" + +from .settings import jwt_settings +from .utils import get_payload, get_user_by_payload + + +def get_token(user, context=None, **extra): + payload = jwt_settings.JWT_PAYLOAD_HANDLER(user, context) + payload.update(extra) + return jwt_settings.JWT_ENCODE_HANDLER(payload, context) + + +def get_user_by_token(token, context=None): + return get_user_by_payload(get_payload(token, context)) diff --git a/config/jwt_auth/signals.py b/config/jwt_auth/signals.py new file mode 100644 index 0000000000..cfb3ffdf17 --- /dev/null +++ b/config/jwt_auth/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +token_issued = Signal() +token_refreshed = Signal() diff --git a/config/jwt_auth/utils.py b/config/jwt_auth/utils.py new file mode 100644 index 0000000000..174c831dad --- /dev/null +++ b/config/jwt_auth/utils.py @@ -0,0 +1,118 @@ +"""JWT claims, signing, validation, and credential extraction. + +The claim names, coercion, and error messages preserve tokens issued before +the Strawberry migration. PyJWT remains responsible for cryptography. +""" + +from datetime import datetime, timezone + +import jwt +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ + +from .exceptions import JSONWebTokenError, JSONWebTokenExpired +from .settings import jwt_settings + + +def jwt_payload(user, context=None): + username = user.get_username() + username = getattr(username, "pk", username) + now = datetime.now(timezone.utc) + payload = { + user.USERNAME_FIELD: username, + "exp": int((now + jwt_settings.JWT_EXPIRATION_DELTA).timestamp()), + } + if jwt_settings.JWT_ALLOW_REFRESH: + payload["origIat"] = int(now.timestamp()) + if jwt_settings.JWT_AUDIENCE is not None: + payload["aud"] = jwt_settings.JWT_AUDIENCE + if jwt_settings.JWT_ISSUER is not None: + payload["iss"] = jwt_settings.JWT_ISSUER + return payload + + +def jwt_encode(payload, context=None): + return jwt.encode( + payload, + jwt_settings.JWT_PRIVATE_KEY or jwt_settings.JWT_SECRET_KEY, + algorithm=jwt_settings.JWT_ALGORITHM, + ) + + +def jwt_decode(token, context=None): + return jwt.decode( + token, + jwt_settings.JWT_PUBLIC_KEY or jwt_settings.JWT_SECRET_KEY, + algorithms=[jwt_settings.JWT_ALGORITHM], + options={ + "verify_exp": jwt_settings.JWT_VERIFY_EXPIRATION, + "verify_aud": jwt_settings.JWT_AUDIENCE is not None, + "verify_signature": jwt_settings.JWT_VERIFY, + }, + leeway=jwt_settings.JWT_LEEWAY, + audience=jwt_settings.JWT_AUDIENCE, + issuer=jwt_settings.JWT_ISSUER, + ) + + +def get_http_authorization(request): + parts = request.META.get(jwt_settings.JWT_AUTH_HEADER_NAME, "").split() + if ( + len(parts) == 2 + and parts[0].lower() == jwt_settings.JWT_AUTH_HEADER_PREFIX.lower() + ): + return parts[1] + return request.COOKIES.get(jwt_settings.JWT_COOKIE_NAME) + + +def get_token_argument(request, **kwargs): + if not jwt_settings.JWT_ALLOW_ARGUMENT: + return None + fields = kwargs.get("input") + if isinstance(fields, dict): + kwargs = fields + return kwargs.get(jwt_settings.JWT_ARGUMENT_NAME) + + +def get_credentials(request, **kwargs): + return get_token_argument(request, **kwargs) or get_http_authorization(request) + + +def get_payload(token, context=None): + try: + return jwt_settings.JWT_DECODE_HANDLER(token, context) + except jwt.ExpiredSignatureError as exc: + raise JSONWebTokenExpired() from exc + except jwt.DecodeError as exc: + raise JSONWebTokenError(_("Error decoding signature")) from exc + except jwt.InvalidTokenError as exc: + raise JSONWebTokenError(_("Invalid token")) from exc + + +def get_user_by_natural_key(username): + model = get_user_model() + try: + return model._default_manager.get_by_natural_key(username) + except model.DoesNotExist: + return None + + +def get_user_by_payload(payload): + username = jwt_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER(payload) + if not username: + raise JSONWebTokenError(_("Invalid payload")) + user = jwt_settings.JWT_GET_USER_BY_NATURAL_KEY_HANDLER(username) + if user is not None and not getattr(user, "is_active", True): + raise JSONWebTokenError(_("User is disabled")) + return user + + +def refresh_has_expired(orig_iat, context=None): + return int(datetime.now(timezone.utc).timestamp()) > refresh_expires_in(orig_iat) + + +def refresh_expires_in(orig_iat=None): + issued_at = ( + int(datetime.now(timezone.utc).timestamp()) if orig_iat is None else orig_iat + ) + return issued_at + jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds() diff --git a/config/jwt_utils.py b/config/jwt_utils.py index 2ee2900134..a7bac054c1 100644 --- a/config/jwt_utils.py +++ b/config/jwt_utils.py @@ -64,8 +64,8 @@ def _validate_graphql_jwt_token(token: str) -> "User": JSONWebTokenExpired: Token has expired. JSONWebTokenError: Token is invalid or user not found. """ - from graphql_jwt.exceptions import JSONWebTokenError - from graphql_jwt.utils import get_payload, get_user_by_payload + from config.jwt_auth.exceptions import JSONWebTokenError + from config.jwt_auth.utils import get_payload, get_user_by_payload logger.debug(f"Validating graphql_jwt token: {token[:10]}...") @@ -99,9 +99,8 @@ def _validate_auth0_token(token: str) -> "User": JSONWebTokenExpired: Token has expired. JSONWebTokenError: Token is invalid or user not found. """ - from graphql_jwt.exceptions import JSONWebTokenError - from config.graphql_auth0_auth.utils import get_user_by_token + from config.jwt_auth.exceptions import JSONWebTokenError logger.debug(f"Validating Auth0 token: {token[:10]}...") diff --git a/config/rest_jwt_auth.py b/config/rest_jwt_auth.py index f0504f03d6..e2cd73067a 100644 --- a/config/rest_jwt_auth.py +++ b/config/rest_jwt_auth.py @@ -68,7 +68,7 @@ def _authenticate_token(self, token: str): Uses the unified jwt_utils.get_user_from_jwt_token() which automatically handles both Auth0 and standard graphql_jwt tokens. """ - from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired + from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired try: user = get_user_from_jwt_token(token) diff --git a/config/settings/base.py b/config/settings/base.py index 242b8ec8b8..9dd9be8bc5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -291,7 +291,7 @@ else: AUTHENTICATION_BACKENDS += [ - "graphql_jwt.backends.JSONWebTokenBackend", + "config.jwt_auth.backends.JSONWebTokenBackend", ] if USE_API_KEY_AUTH: diff --git a/config/websocket/auth_handshake.py b/config/websocket/auth_handshake.py index 265210d52a..924342e401 100644 --- a/config/websocket/auth_handshake.py +++ b/config/websocket/auth_handshake.py @@ -32,8 +32,8 @@ from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser -from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired from config.jwt_utils import get_user_from_jwt_token from config.websocket.middleware import ( WS_CLOSE_PERMISSION_DENIED, diff --git a/config/websocket/consumers/thread_updates.py b/config/websocket/consumers/thread_updates.py index 4989ea577e..b2babfbdfa 100644 --- a/config/websocket/consumers/thread_updates.py +++ b/config/websocket/consumers/thread_updates.py @@ -24,7 +24,6 @@ from channels.db import database_sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer -from graphql_relay import from_global_id from config.ratelimit.decorators import check_ws_rate_limit from config.websocket.auth_handshake import AuthHandshakeMixin @@ -33,6 +32,7 @@ from opencontractserver.conversations.models import Conversation from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/websocket/consumers/unified_agent_conversation.py b/config/websocket/consumers/unified_agent_conversation.py index 2ec922b85b..b15ac1ce09 100644 --- a/config/websocket/consumers/unified_agent_conversation.py +++ b/config/websocket/consumers/unified_agent_conversation.py @@ -34,7 +34,6 @@ from channels.db import database_sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer from django.conf import settings -from graphql_relay import from_global_id from config.ratelimit.decorators import check_ws_rate_limit from config.websocket.auth_handshake import AuthHandshakeMixin @@ -67,6 +66,7 @@ ) from opencontractserver.llms.types import AgentFramework from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id logger = logging.getLogger(__name__) diff --git a/config/websocket/middleware.py b/config/websocket/middleware.py index 6b3d082825..637427e2fa 100644 --- a/config/websocket/middleware.py +++ b/config/websocket/middleware.py @@ -40,8 +40,8 @@ from channels.db import database_sync_to_async from channels.middleware import BaseMiddleware from django.contrib.auth.models import AnonymousUser -from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired from config.jwt_utils import get_user_from_jwt_token logger = logging.getLogger(__name__) diff --git a/docs/architecture/graphql_strawberry_migration.md b/docs/architecture/graphql_strawberry_migration.md index 9b71a00b60..2e0e4e391e 100644 --- a/docs/architecture/graphql_strawberry_migration.md +++ b/docs/architecture/graphql_strawberry_migration.md @@ -3,14 +3,16 @@ The GraphQL API was migrated from **graphene / graphene-django** to **strawberry-graphql** with a machine-verified guarantee of **zero query-shape change**. This doc is a map of where things live and the -invariants that keep the wire contract stable. +invariants that keep the wire contract stable. Graphene, graphene-django, +django-graphql-jwt, and graphql-relay are no longer runtime or test dependencies. ## The wire contract (do not break) - `config/graphql/schema.graphql` — the **golden SDL**, captured from the graphene schema at migration time. It is the source of truth for every type, field, argument (name/type/nullability/default), interface, and enum - member the API exposes. + member the API exposes. Root operation types, union membership, input-field + defaults, and directive definitions are also checked. - `opencontractserver/tests/test_schema_parity.py` — structurally compares the served strawberry schema against the golden SDL and **fails on any drift**. Field ordering and descriptions are not part of the contract; @@ -21,8 +23,11 @@ invariants that keep the wire contract stable. Reproduces graphene / graphene-django behaviours on top of strawberry: -- `core/relay.py` — relay global IDs (`base64("TypeName:pk")`), the `Node` - interface, a **type registry** (`register_type`) mapping type names → +- `opencontractserver/utils/ids.py` — opaque global IDs + (`base64("TypeName:pk")`) and `arrayconnection` cursors, shared by GraphQL, + WebSockets, tasks, and services without importing the schema. Malformed-ID + decoding and GraphQL ID coercion preserve the existing contract. +- `core/relay.py` — the `Node` interface, a **type registry** (`register_type`) mapping type names → Django model + per-type `get_queryset`/`get_node` hooks, countable/PDF-page-aware connection factories, and `resolve_django_connection` (a faithful port of graphene-django's @@ -47,10 +52,8 @@ Reproduces graphene / graphene-django behaviours on top of strawberry: `get_node`/`get_queryset` visibility hook — reproducing graphene-django's auto-converted-FK `CustomField`, so an invisible FK target resolves to `null` rather than leaking its fields across a permission boundary. - - `register_type` also installs graphene-compat `resolve_` - staticmethod aliases (delegating to the `_resolve__` module - functions) so unit tests that call resolvers directly keep working. These - are inert for schema execution. + - Resolver unit tests import the module-level functions directly; type + registration no longer installs test-only methods on schema classes. - `core/scalars.py` — `GenericScalar` / `JSONString` / `BigInt` (graphene wire behaviour). - `core/filtering.py` — django-filter FilterSet ↔ GraphQL argument-name @@ -84,15 +87,32 @@ Per-request authentication happens once in graphene-era `JSONWebTokenMiddleware` + API-key middleware are gone. The `tokenAuth` / `verifyToken` / `refreshToken` mutations are strawberry-native ports (`config/graphql/jwt_auth.py`, `user_mutations.py`) preserving -long-running refresh-token laziness. `django-graphql-jwt` remains only as a -JWT signing/backend utility. +long-running refresh-token laziness. `config/jwt_auth/` owns signing, claim +validation, credential extraction, exceptions, backend, and refresh helpers, +using Django and PyJWT. REST, MCP, and WebSockets use the same code. + +The existing `GRAPHQL_JWT` settings, Bearer headers, cookie fallback, payload +claims (`username`, `exp`, `origIat`), handler overrides, and error messages +remain supported. Explicit old default handler paths are translated to the +new implementations. Auth0 and API-key backend behavior is unchanged. + +Refresh storage remains **optional**, just as before. Deployments that used +`graphql_jwt.refresh_token` in `INSTALLED_APPS` must replace that entry with +`config.jwt_auth.refresh_token`. Its app label (`refresh_token`), table, +fields, and migration names are preserved, so existing rows and migration +history carry over without a data migration. Custom `JWT_REFRESH_TOKEN_MODEL` +and handler settings still work. Default deployments do not install this app; +requesting only an access token never evaluates the lazy refresh-token value. +Python integrations importing JWT helpers or signals should now import from +`config.jwt_auth` instead of the removed dependency. ## Security hardening `DepthLimitValidationRule` + `DisableIntrospection` (production) attach via strawberry's `AddValidationRules` extension, which **appends** to graphql-core's full spec rule set — the graphene-era "custom rules replace spec rules" trap is -structurally impossible now. The GCS file-URL pre-warm middleware became +structurally impossible now. The extension is passed as a factory, so each +request gets an independent execution context. The GCS file-URL pre-warm middleware became `config/graphql/file_url_prewarm.py::FileUrlPrewarmExtension` (installed only when `FILE_URL_SHARED_CACHE_TTL > 0`). @@ -102,5 +122,13 @@ when `FILE_URL_SHARED_CACHE_TTL > 0`). replacement (`Client`, same result-dict shape) plus a `GraphQLTestCase` port for endpoint-level tests. `schema.execute(...)` → `schema.execute_sync(...)` (strawberry uses `variable_values=`, not graphene's `variables=`). -`schema.graphql_schema` is aliased to the underlying graphql-core schema for -compat. +Schema inspection tools use Strawberry's underlying `schema._schema`; the +Graphene `schema.graphql_schema` alias has been removed. + +`test_api_id_contract.py` pins ID/cursor bytes and connection windows. +`test_jwt_auth_contract.py` and `test_jwt_refresh_contract.py` exercise real +HTTP authentication, old claim layouts, error payloads, lazy refresh creation, +rotation, and persistent model compatibility. Existing auth test assertions +remain unchanged; their imports and patch targets point to the owned runtime. +`tests/architecture/test_graphql_dependencies.py` rejects removed imports and +dependency declarations and checks extension instances are request-scoped. diff --git a/docs/test_scripts/graphql-dependency-removal.md b/docs/test_scripts/graphql-dependency-removal.md new file mode 100644 index 0000000000..be64eadd1d --- /dev/null +++ b/docs/test_scripts/graphql-dependency-removal.md @@ -0,0 +1,94 @@ +# GraphQL dependency removal verification + +## Contract + +Remove `graphene`, `graphene-django`, `django-graphql-jwt`, and `graphql-relay` +while preserving authentication, the served SDL, opaque IDs, cursor windows, +and permission boundaries. Keep Strawberry pinned at 0.323.2; separately check +the 0.327.0 upgrade discussed during review. + +Implementation and optional refresh-app migration instructions are in +[the architecture guide](../architecture/graphql_strawberry_migration.md). + +## Reproduce + +Build the Django test image from the changed requirements, then run: + +Use a test environment without `DJANGO_SUPERUSER_USERNAME`, +`DJANGO_SUPERUSER_EMAIL`, and `DJANGO_SUPERUSER_PASSWORD`. The bootstrap +migration otherwise creates an `admin` row that collides with privacy-test +fixtures. These variables can be removed for the test subprocess with `env -u`. + +```bash +docker compose -f test.yml run --rm django python -m pip check +docker compose -f test.yml run --rm django pytest \ + opencontractserver/tests/architecture/test_graphql_dependencies.py \ + opencontractserver/tests/test_schema_parity.py \ + opencontractserver/tests/test_api_id_contract.py \ + opencontractserver/tests/test_jwt_auth_contract.py \ + opencontractserver/tests/test_jwt_refresh_contract.py \ + opencontractserver/tests/test_coverage_core_relay.py \ + opencontractserver/tests/test_coverage_core_scalars.py \ + opencontractserver/tests/test_security_hardening.py \ + opencontractserver/tests/test_jwt_utils.py \ + opencontractserver/tests/test_token_expiration.py \ + opencontractserver/tests/test_user_mutations_rate_limiting.py \ + opencontractserver/tests/test_websocket_auth.py \ + opencontractserver/tests/permissioning \ + opencontractserver/tests/test_fk_visibility_traversal.py \ + opencontractserver/tests/test_singular_node_idor.py +docker compose -f test.yml run --rm django python scripts/validate_frontend_graphql.py +``` + +Confirm the four removed modules are absent with `importlib.util.find_spec` +inside the rebuilt image. Do not regenerate the golden SDL to make tests pass. +For the broader suite, include all test modules importing `config.graphql` or +`config.jwt_auth`, every permission/auth/WebSocket test, and tests whose imports +or resolver calls changed. Backend CI runs the full suite. + +The refresh tests temporarily enable `config.jwt_auth.refresh_token`, create +its table only in the test database, and remove it afterward. They exercise rows +created from the preserved migration state, token selection/rotation, expiry, +revocation, and `JWT_REUSE_REFRESH_TOKENS`. + +## Strawberry upgrade review + +The [0.324–0.326 changelog](https://github.com/strawberry-graphql/strawberry/blob/0.327.0/CHANGELOG.md) +raises the Django minimum to 5.2, changes field metadata handling and operation +directive coercion, exposes schema directives through introspection, and closes +an awaitable-permission bypass. This project uses Django 5.2, synchronous service +permission checks, and no custom schema/operation directives. +[0.327.0](https://github.com/strawberry-graphql/strawberry/releases/tag/0.327.0) +bounds parser/validation caches, neither of which is installed here. + +The installed 0.323.2 and downloaded 0.327.0 `AddValidationRules` source files +were identical. Passing an extension instance was already deprecated in +0.323.2; the schema now uses a factory and tests verify independent instances. +Run the contract checks above in an isolated 0.327.0 environment as well: source +review alone cannot establish scalar, SDL, permission, or request compatibility. + +## Evidence + +- Clean baseline: 181 existing schema/auth/Relay/scalar/security tests passed. + The same tests passed after replacing the dependencies. +- Differential check against the original implementation: 32,400 pagination + combinations matched, including empty windows, invalid cursors, offsets, + forward/backward limits, and error messages; malformed ID decoding matched. +- The golden SDL is unchanged. All 292 frontend operations validate, and + TypeScript compiles without errors. +- A disposable environment with all four packages uninstalled passes + `pip check` and the new ID, HTTP auth, refresh-storage, and dependency guards. +- Existing auth, token-expiration, WebSocket, and rate-limit assertions were + compared structurally to the baseline and are unchanged. +- Final Python type checking passes for all 1,584 source files. The verification + environment uses Django 5.2.16, PyJWT 2.13.0, and graphql-core 3.2.11. +- Strawberry 0.327.0: 248 contract/auth/permission/security tests passed, + including 10 subtests. Its SDL matches the unchanged golden file. +- Final broad run on Strawberry 0.323.2: **4,634 passed**, 75 subtests passed, + and one optional measurement-output test skipped across 219 test modules + (`pytest -n 4 --dist loadscope`, 10m26s). Formatting and lint checks pass. + +The first broad run caught a wrong resolver import in a migrated label test +and background-signal leakage from the new optional-app test. Both were fixed; +the final broad run and candidate checks above include those fixes. No auth +assertions were weakened or skipped. diff --git a/mypy.ini b/mypy.ini index 4b16774114..7211b4c279 100644 --- a/mypy.ini +++ b/mypy.ini @@ -556,10 +556,6 @@ ignore_errors = True # * the standard Django ``setUpTestData``/``setUpClass`` class-attribute # pattern (the dominant ``attr-defined`` category -- e.g. # ``"type[DocumentStatsTestCase]" has no attribute "alice"``); -# * direct calls to the runtime-installed graphene-compat resolver aliases -# (``CorpusType.get_node`` / ``AnnotationType.resolve_document`` / -# ``UserType.resolve_display_name`` / ``CorpusType.resolve_my_vote``) that -# ``register_type`` attaches via ``setattr`` and mypy cannot see statically; # * ``self.client = Client(schema)`` shadowing ``django.test.TestCase.client`` # (typed ``django.test.client.Client``). # None are real type defects and none touch test logic, so these files are diff --git a/opencontractserver/document_imports/services.py b/opencontractserver/document_imports/services.py index 2d3b28f1de..3ed9a53317 100644 --- a/opencontractserver/document_imports/services.py +++ b/opencontractserver/document_imports/services.py @@ -36,7 +36,6 @@ from django.db.models.functions import Coalesce from django.utils import timezone from filetype import filetype -from graphql_relay import from_global_id from opencontractserver.constants.zip_import import ( BULK_UPLOAD_OWNER_CACHE_PREFIX, @@ -60,6 +59,7 @@ ) from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.files import is_plaintext_content +from opencontractserver.utils.ids import from_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user if TYPE_CHECKING: diff --git a/opencontractserver/mcp/server.py b/opencontractserver/mcp/server.py index 22d59ee0da..d6c1954dae 100644 --- a/opencontractserver/mcp/server.py +++ b/opencontractserver/mcp/server.py @@ -35,7 +35,6 @@ PermissionDenied, ValidationError, ) -from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired from mcp.server import Server from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server @@ -47,6 +46,8 @@ from starlette.responses import Response from starlette.routing import Mount, Route +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired + # Module-level import so test-time patching of this symbol is stable # (see MCPAsgiAppAuthTest for the patch sites). from config.jwt_utils import get_user_from_jwt_token diff --git a/opencontractserver/research/services/research_reports.py b/opencontractserver/research/services/research_reports.py index cc93ab2187..3dd322cacd 100644 --- a/opencontractserver/research/services/research_reports.py +++ b/opencontractserver/research/services/research_reports.py @@ -890,7 +890,7 @@ def finalize( # a card does not serialise into one. full_content_parts: list[str] = [] if any(f.get("card") for f in report.findings or []): - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id full_content_parts.append( "[component:research-findings reportId=" diff --git a/opencontractserver/tests/architecture/test_graphql_dependencies.py b/opencontractserver/tests/architecture/test_graphql_dependencies.py new file mode 100644 index 0000000000..3d59f73c33 --- /dev/null +++ b/opencontractserver/tests/architecture/test_graphql_dependencies.py @@ -0,0 +1,56 @@ +"""Prevent the removed schema frameworks from returning via runtime or tests.""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +REMOVED = {"graphene", "graphene_django", "graphql_relay", "graphql_jwt"} + + +def test_no_imports_of_removed_graphql_dependencies(): + violations = [] + for directory in ("config", "opencontractserver", "scripts"): + for path in (ROOT / directory).rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.ImportFrom): + modules = [node.module or ""] + elif isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + else: + continue + for module in modules: + if module.split(".")[0] in REMOVED: + violations.append(f"{path.relative_to(ROOT)}:{node.lineno}") + assert not violations, violations + + +def test_served_schema_does_not_load_removed_frameworks(): + from config.graphql.schema import schema + + assert schema.query is not None + assert not REMOVED.intersection(name.split(".")[0] for name in sys.modules) + + +def test_validation_extensions_are_fresh_for_each_request(): + from strawberry.extensions import AddValidationRules + + from config.graphql.schema import schema + from config.graphql.security import DepthLimitValidationRule + + first = schema.get_extensions(sync=True) + second = schema.get_extensions(sync=True) + validation = next(ext for ext in first if isinstance(ext, AddValidationRules)) + assert DepthLimitValidationRule in validation.validation_rules + assert all(left is not right for left, right in zip(first, second, strict=True)) + + +def test_dependency_manifests_do_not_reintroduce_removed_packages(): + import re + + paths = [*ROOT.glob("requirements/**/*.txt"), ROOT / ".pre-commit-config.yaml"] + for path in paths: + for line in path.read_text().splitlines(): + requirement = line.split("#", 1)[0].strip().lstrip("- ") + name = re.split(r"[<>=!~\[;\s]", requirement)[0].replace("-", "_") + assert name not in REMOVED | {"django_graphql_jwt"}, (path, line) diff --git a/opencontractserver/tests/base.py b/opencontractserver/tests/base.py index 80f6b209ef..18cc879dab 100644 --- a/opencontractserver/tests/base.py +++ b/opencontractserver/tests/base.py @@ -10,9 +10,9 @@ from django.db import connection, connections from django.db.utils import OperationalError from django.test import TestCase, TransactionTestCase, override_settings -from graphql_jwt.shortcuts import get_token from config.asgi import application +from config.jwt_auth.shortcuts import get_token from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.users.models import User diff --git a/opencontractserver/tests/performance_optimizations/test_pdf_hash_graphql.py b/opencontractserver/tests/performance_optimizations/test_pdf_hash_graphql.py index 560a1f5b9a..6bfbc28521 100644 --- a/opencontractserver/tests/performance_optimizations/test_pdf_hash_graphql.py +++ b/opencontractserver/tests/performance_optimizations/test_pdf_hash_graphql.py @@ -56,7 +56,7 @@ def test_pdf_file_hash_field_in_document_query(self): """ # Convert Django ID to GraphQL ID - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id global_id = to_global_id("DocumentType", self.document.id) @@ -125,7 +125,7 @@ def test_document_without_hash(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id global_id = to_global_id("DocumentType", doc_no_pdf.id) diff --git a/opencontractserver/tests/permissioning/test_analysis_extract_hybrid_permissions.py b/opencontractserver/tests/permissioning/test_analysis_extract_hybrid_permissions.py index 60082e94ad..c681cd2050 100644 --- a/opencontractserver/tests/permissioning/test_analysis_extract_hybrid_permissions.py +++ b/opencontractserver/tests/permissioning/test_analysis_extract_hybrid_permissions.py @@ -34,7 +34,6 @@ from django.core.files.base import ContentFile from django.db import transaction from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -49,6 +48,7 @@ from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_ONE_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/permissioning/test_annotation_permission_inheritance.py b/opencontractserver/tests/permissioning/test_annotation_permission_inheritance.py index 3542795167..dd5d36c408 100644 --- a/opencontractserver/tests/permissioning/test_annotation_permission_inheritance.py +++ b/opencontractserver/tests/permissioning/test_annotation_permission_inheritance.py @@ -18,7 +18,6 @@ from django.core.files.base import ContentFile from django.db import transaction from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -31,6 +30,7 @@ from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_ONE_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) diff --git a/opencontractserver/tests/permissioning/test_annotation_privacy_scoping.py b/opencontractserver/tests/permissioning/test_annotation_privacy_scoping.py index bf7ca7c9af..c79e0e75b3 100644 --- a/opencontractserver/tests/permissioning/test_annotation_privacy_scoping.py +++ b/opencontractserver/tests/permissioning/test_annotation_privacy_scoping.py @@ -42,7 +42,6 @@ from django.core.files.base import ContentFile from django.db import transaction from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -57,6 +56,7 @@ from opencontractserver.extracts.models import Extract, Fieldset from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_ONE_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) diff --git a/opencontractserver/tests/permissioning/test_corpus_visibility.py b/opencontractserver/tests/permissioning/test_corpus_visibility.py index 93c3110edb..2197609b2e 100644 --- a/opencontractserver/tests/permissioning/test_corpus_visibility.py +++ b/opencontractserver/tests/permissioning/test_corpus_visibility.py @@ -19,12 +19,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/permissioning/test_feedback_mutations.py b/opencontractserver/tests/permissioning/test_feedback_mutations.py index cec3f07fb3..008faee6f1 100644 --- a/opencontractserver/tests/permissioning/test_feedback_mutations.py +++ b/opencontractserver/tests/permissioning/test_feedback_mutations.py @@ -17,7 +17,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -31,6 +30,7 @@ from opencontractserver.extracts.models import Extract, Fieldset from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_ONE_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/permissioning/test_permissioned_querysets.py b/opencontractserver/tests/permissioning/test_permissioned_querysets.py index 43c9f46b74..2fa43bae70 100644 --- a/opencontractserver/tests/permissioning/test_permissioned_querysets.py +++ b/opencontractserver/tests/permissioning/test_permissioned_querysets.py @@ -3,7 +3,6 @@ from django.db import connection from django.test import TestCase from django.test.utils import CaptureQueriesContext -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -11,6 +10,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/permissioning/test_permissioning.py b/opencontractserver/tests/permissioning/test_permissioning.py index edefffe81b..f05413d25e 100644 --- a/opencontractserver/tests/permissioning/test_permissioning.py +++ b/opencontractserver/tests/permissioning/test_permissioning.py @@ -7,7 +7,6 @@ from django.db.models.signals import post_save from django.dispatch import Signal from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -27,6 +26,7 @@ from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_ONE_PATH from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( get_users_permissions_for_obj, set_permissions_for_obj_to_user, diff --git a/opencontractserver/tests/research/test_research_start_mutation.py b/opencontractserver/tests/research/test_research_start_mutation.py index f4c26bbd51..79fc3b1d62 100644 --- a/opencontractserver/tests/research/test_research_start_mutation.py +++ b/opencontractserver/tests/research/test_research_start_mutation.py @@ -16,12 +16,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus, CorpusGroup from opencontractserver.research.models import ResearchReport +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_add_annotation_idor.py b/opencontractserver/tests/test_add_annotation_idor.py index 64db7b2db4..bef9b53599 100644 --- a/opencontractserver/tests/test_add_annotation_idor.py +++ b/opencontractserver/tests/test_add_annotation_idor.py @@ -13,7 +13,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -25,6 +24,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_agent_memory.py b/opencontractserver/tests/test_agent_memory.py index fc9cff572a..7cda817176 100644 --- a/opencontractserver/tests/test_agent_memory.py +++ b/opencontractserver/tests/test_agent_memory.py @@ -1041,10 +1041,9 @@ def setUp(self): def _execute_mutation(self, user, corpus_pk, enabled): """Execute the ToggleCorpusMemory mutation via the Graphene test client.""" - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id class MockRequest: def __init__(self, u): diff --git a/opencontractserver/tests/test_agentic_highlighter_task.py b/opencontractserver/tests/test_agentic_highlighter_task.py index cbd8969b2c..6e2df51243 100644 --- a/opencontractserver/tests/test_agentic_highlighter_task.py +++ b/opencontractserver/tests/test_agentic_highlighter_task.py @@ -14,7 +14,6 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.test import override_settings -from graphql_relay import from_global_id, to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -24,6 +23,7 @@ from opencontractserver.documents.models import DocumentAnalysisRow from opencontractserver.tests.base import TransactionFixtureTestCase from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_agents.py b/opencontractserver/tests/test_agents.py index 6abc52f929..cd6527fee8 100644 --- a/opencontractserver/tests/test_agents.py +++ b/opencontractserver/tests/test_agents.py @@ -14,7 +14,6 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -23,6 +22,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user diff --git a/opencontractserver/tests/test_annotation_label_permissions.py b/opencontractserver/tests/test_annotation_label_permissions.py index 5b3af4de1d..201f45091d 100644 --- a/opencontractserver/tests/test_annotation_label_permissions.py +++ b/opencontractserver/tests/test_annotation_label_permissions.py @@ -16,7 +16,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from config.graphql.annotation_types import AnnotationLabelType +from config.graphql.annotation_types import _resolve_AnnotationLabelType_my_permissions from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user @@ -62,12 +62,9 @@ def setUp(self): ) def _perms(self, user, label=None): - # graphene passes the model instance as ``self`` to the resolver; mypy - # types the unbound method's first arg as the GraphQL type, hence the - # ignore. return set( - AnnotationLabelType.resolve_my_permissions( - label or self.label, _Info(user) # type: ignore[arg-type] + _resolve_AnnotationLabelType_my_permissions( + label or self.label, _Info(user) ) ) diff --git a/opencontractserver/tests/test_annotation_tree.py b/opencontractserver/tests/test_annotation_tree.py index 0da1d5f5e0..b8033b46a9 100644 --- a/opencontractserver/tests/test_annotation_tree.py +++ b/opencontractserver/tests/test_annotation_tree.py @@ -2,7 +2,6 @@ from django.db import transaction from django.test import TestCase -from graphql_relay import from_global_id, to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -10,6 +9,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.users.models import User +from opencontractserver.utils.ids import from_global_id, to_global_id logger = logging.getLogger(__name__) diff --git a/opencontractserver/tests/test_api_id_contract.py b/opencontractserver/tests/test_api_id_contract.py new file mode 100644 index 0000000000..9186b85461 --- /dev/null +++ b/opencontractserver/tests/test_api_id_contract.py @@ -0,0 +1,109 @@ +"""Pin opaque identifiers and pagination values already held by API clients.""" + +from base64 import b64encode + +import pytest +from graphql import GraphQLError + +from config.graphql.core.relay import resolve_connection_from_iterable +from opencontractserver.utils.ids import ( + cursor_to_offset, + from_global_id, + get_offset_with_default, + offset_to_cursor, + to_global_id, +) + + +@pytest.mark.parametrize( + "type_name,pk,encoded", + [ + ("DocumentType", 42, "RG9jdW1lbnRUeXBlOjQy"), + ("CorpusType", "a:b", "Q29ycHVzVHlwZTphOmI="), + ("UserType", "ü", "VXNlclR5cGU6w7w="), + ("", "", "Og=="), + ], +) +def test_global_ids_are_wire_compatible(type_name, pk, encoded): + assert to_global_id(type_name, pk) == encoded + decoded = from_global_id(encoded) + assert (decoded.type, decoded.id) == decoded + assert decoded == (type_name, str(pk)) + + +@pytest.mark.parametrize("value", [True, None, 1.5, [], {}]) +def test_id_encoder_retains_graphql_scalar_validation(value): + with pytest.raises(GraphQLError): + to_global_id("DocumentType", value) + + +@pytest.mark.parametrize("value", ["?", "not padded", "ü", "/w==", ""]) +def test_invalid_ids_keep_empty_decode_contract(value): + assert from_global_id(value) == ("", "") + + +def test_id_without_type_keeps_decoded_value(): + assert from_global_id("MTIz") == ("", "123") + + +def test_persisted_cursor_and_invalid_cursor_fallback(): + assert offset_to_cursor(2) == "YXJyYXljb25uZWN0aW9uOjI=" + assert cursor_to_offset("YXJyYXljb25uZWN0aW9uOjI=") == 2 + assert get_offset_with_default("invalid", -1) == -1 + assert get_offset_with_default(None, -1) == -1 + # Existing clients get permissive decoding even for a nonstandard prefix. + assert cursor_to_offset(b64encode(b"otherconnection:2").decode()) == 2 + + +@pytest.mark.parametrize( + "args,expected,previous,following", + [ + ({}, list(range(6)), False, False), + ({"first": 2}, [0, 1], False, True), + ({"last": 2}, [4, 5], True, False), + ({"first": 0}, [], False, True), + ({"last": 0}, [], True, False), + ({"first": 4, "last": 2}, [2, 3], True, True), + ({"after": offset_to_cursor(1), "first": 2}, [2, 3], False, True), + ({"before": offset_to_cursor(4), "last": 2}, [2, 3], True, False), + ({"after": offset_to_cursor(20)}, [], False, False), + ({"before": offset_to_cursor(20)}, list(range(6)), False, True), + ({"after": "bad", "first": 2}, [0, 1], False, True), + ({"offset": 2, "first": 2}, [2, 3], False, True), + ({"offset": 2, "after": offset_to_cursor(1)}, [4, 5], False, False), + ], +) +def test_connection_windows_and_page_flags(args, expected, previous, following): + connection = resolve_connection_from_iterable(list(range(6)), args) + assert [edge.node for edge in connection.edges] == expected + assert [edge.cursor for edge in connection.edges] == [ + offset_to_cursor(i) for i in expected + ] + assert connection.length == 6 + assert connection.page_info.has_previous_page is previous + assert connection.page_info.has_next_page is following + assert connection.page_info.start_cursor == ( + offset_to_cursor(expected[0]) if expected else None + ) + assert connection.page_info.end_cursor == ( + offset_to_cursor(expected[-1]) if expected else None + ) + + +def test_default_limit_and_empty_connection(): + connection = resolve_connection_from_iterable(list(range(120)), {}) + assert len(connection.edges) == 100 + assert connection.page_info.has_next_page + empty = resolve_connection_from_iterable([], {"first": 10}) + assert empty.edges == [] + assert empty.page_info.start_cursor is None + assert empty.page_info.end_cursor is None + assert not empty.page_info.has_next_page + + +@pytest.mark.parametrize("argument", ["first", "last"]) +def test_negative_page_size_keeps_error(argument): + with pytest.raises( + ValueError, match=f"Argument '{argument}' must be a non-negative integer." + ): + resolve_connection_from_iterable([1, 2], {argument: -1}) diff --git a/opencontractserver/tests/test_authority_discovery_subset.py b/opencontractserver/tests/test_authority_discovery_subset.py index 843a092923..a1b7c8e075 100644 --- a/opencontractserver/tests/test_authority_discovery_subset.py +++ b/opencontractserver/tests/test_authority_discovery_subset.py @@ -14,10 +14,10 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.testing import Client from opencontractserver.annotations.models import AuthorityFrontier +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_authority_frontier_actions.py b/opencontractserver/tests/test_authority_frontier_actions.py index f352055491..1ddf57ae83 100644 --- a/opencontractserver/tests/test_authority_frontier_actions.py +++ b/opencontractserver/tests/test_authority_frontier_actions.py @@ -275,7 +275,7 @@ def setUp(self): self.doc = Document.objects.create(creator=self.superuser, title="Doc") def _gid(self, row): - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id return to_global_id("AuthorityFrontierNode", row.pk) diff --git a/opencontractserver/tests/test_authority_mapping_crud.py b/opencontractserver/tests/test_authority_mapping_crud.py index 6786d1e2ef..eee8b97b18 100644 --- a/opencontractserver/tests/test_authority_mapping_crud.py +++ b/opencontractserver/tests/test_authority_mapping_crud.py @@ -266,7 +266,7 @@ def test_create_update_delete_round_trip(self): row = AuthorityKeyEquivalence.objects.get(from_key="irc:777") assert row.created_by_id == self.superuser.id - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id gid = to_global_id("AuthorityKeyEquivalenceNode", row.pk) # update @@ -289,7 +289,7 @@ def test_create_denied_for_non_superuser(self): assert not AuthorityKeyEquivalence.objects.filter(from_key="irc:777").exists() def test_mutation_on_managed_row_rejected(self): - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id managed = AuthorityKeyEquivalence.objects.create( from_key="exchange-act:99", to_key="usc-15:99", source="baseline" diff --git a/opencontractserver/tests/test_authority_mapping_loader.py b/opencontractserver/tests/test_authority_mapping_loader.py index 1534859946..d31781924d 100644 --- a/opencontractserver/tests/test_authority_mapping_loader.py +++ b/opencontractserver/tests/test_authority_mapping_loader.py @@ -854,5 +854,5 @@ class BaselineOriginGraphQLExposureTests(TestCase): def test_baseline_origin_exposed_on_namespace_node(self): from config.graphql.schema import schema - fields = schema.graphql_schema.type_map["AuthorityNamespaceNode"].fields + fields = schema._schema.type_map["AuthorityNamespaceNode"].fields assert "baselineOrigin" in fields diff --git a/opencontractserver/tests/test_authority_namespace_crud.py b/opencontractserver/tests/test_authority_namespace_crud.py index 9fd18daa17..08c5c03cfe 100644 --- a/opencontractserver/tests/test_authority_namespace_crud.py +++ b/opencontractserver/tests/test_authority_namespace_crud.py @@ -14,7 +14,6 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from django.test import TestCase -from graphql_relay import to_global_id from opencontractserver.annotations.models import ( AuthorityFrontier, @@ -26,6 +25,7 @@ from opencontractserver.enrichment.services.authority_mapping_loader import ( AuthorityMappingLoader, ) +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_authority_pack_api.py b/opencontractserver/tests/test_authority_pack_api.py index 9f728d0017..327ad218a6 100644 --- a/opencontractserver/tests/test_authority_pack_api.py +++ b/opencontractserver/tests/test_authority_pack_api.py @@ -13,7 +13,6 @@ from django.core.management.base import CommandError from django.db import IntegrityError from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from opencontractserver.annotations.models import ( @@ -28,6 +27,7 @@ AuthorityPackService, ) from opencontractserver.enrichment.services.authority_permissions import DENIED +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_badges.py b/opencontractserver/tests/test_badges.py index a2dd354e24..c1be5d1b57 100644 --- a/opencontractserver/tests/test_badges.py +++ b/opencontractserver/tests/test_badges.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -25,6 +24,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.tasks.badge_tasks import BadgeCriteriaType, check_auto_badges from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_batch_run_corpus_action.py b/opencontractserver/tests/test_batch_run_corpus_action.py index 5adf26f080..5a77636e1a 100644 --- a/opencontractserver/tests/test_batch_run_corpus_action.py +++ b/opencontractserver/tests/test_batch_run_corpus_action.py @@ -9,7 +9,6 @@ from django.contrib.auth import get_user_model from django.test import TransactionTestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.testing import GraphQLTestCase from opencontractserver.corpuses.models import ( @@ -21,6 +20,7 @@ from opencontractserver.corpuses.services import CorpusActionService from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_bulk_document_upload.py b/opencontractserver/tests/test_bulk_document_upload.py index cab475fab6..57b72bab47 100644 --- a/opencontractserver/tests/test_bulk_document_upload.py +++ b/opencontractserver/tests/test_bulk_document_upload.py @@ -11,7 +11,6 @@ from django.core.cache import cache from django.test import TestCase, override_settings from django.test.client import Client -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client as GrapheneClient @@ -23,6 +22,7 @@ from opencontractserver.documents.models import Document from opencontractserver.tasks.import_tasks import process_documents_zip from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_caml_pipeline_coverage.py b/opencontractserver/tests/test_caml_pipeline_coverage.py index d04d730153..8dc4f0dd62 100644 --- a/opencontractserver/tests/test_caml_pipeline_coverage.py +++ b/opencontractserver/tests/test_caml_pipeline_coverage.py @@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -15,6 +14,7 @@ from opencontractserver.documents.models import Document, DocumentProcessingStatus from opencontractserver.documents.signals import process_doc_on_create_atomic from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_chat_message_mentioned_resources.py b/opencontractserver/tests/test_chat_message_mentioned_resources.py index c49d34d209..8cdeee0c0b 100644 --- a/opencontractserver/tests/test_chat_message_mentioned_resources.py +++ b/opencontractserver/tests/test_chat_message_mentioned_resources.py @@ -13,7 +13,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client as GrapheneClient @@ -21,6 +20,7 @@ from opencontractserver.conversations.models import ChatMessage, Conversation from opencontractserver.corpuses.models import Corpus from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_column_mutations.py b/opencontractserver/tests/test_column_mutations.py index f61f0817ed..208c2806f6 100644 --- a/opencontractserver/tests/test_column_mutations.py +++ b/opencontractserver/tests/test_column_mutations.py @@ -2,13 +2,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id -from graphql_relay.node.node import from_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.extracts.models import Column, Fieldset from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_conversation_mutations_graphql.py b/opencontractserver/tests/test_conversation_mutations_graphql.py index 697cd56aca..c0eefcc2e8 100644 --- a/opencontractserver/tests/test_conversation_mutations_graphql.py +++ b/opencontractserver/tests/test_conversation_mutations_graphql.py @@ -93,7 +93,7 @@ def test_create_thread_mutation(self): """ # Get corpus global ID - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_id = to_global_id("CorpusType", self.corpus.id) @@ -140,7 +140,7 @@ def test_create_thread_without_permission(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_id = to_global_id("CorpusType", self.corpus.id) @@ -184,7 +184,7 @@ def test_create_thread_message_mutation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id conversation_id = to_global_id("ConversationType", conversation.id) @@ -229,7 +229,7 @@ def test_create_message_in_locked_thread(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id conversation_id = to_global_id("ConversationType", conversation.id) @@ -284,7 +284,7 @@ def test_reply_to_message_mutation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id parent_id = to_global_id("MessageType", parent_message.id) @@ -328,7 +328,7 @@ def test_delete_conversation_mutation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id conversation_id = to_global_id("ConversationType", conversation.id) @@ -376,7 +376,7 @@ def test_delete_message_mutation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -466,7 +466,7 @@ def test_create_thread_with_document_only(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id document_id = to_global_id("DocumentType", self.document.id) @@ -527,7 +527,7 @@ def test_create_thread_with_both_corpus_and_document(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_id = to_global_id("CorpusType", self.corpus.id) document_id = to_global_id("DocumentType", self.document.id) @@ -704,7 +704,7 @@ def test_update_message_mutation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -755,7 +755,7 @@ def test_update_message_without_permission(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -813,7 +813,7 @@ def test_update_message_moderator_can_edit(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -860,7 +860,7 @@ def test_update_message_locked_conversation(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -908,7 +908,7 @@ def test_update_message_deleted_message(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -953,7 +953,7 @@ def test_update_message_empty_content(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -1040,7 +1040,7 @@ def test_update_message_reparses_mentions(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", message.id) @@ -1117,7 +1117,7 @@ def test_update_message_preserves_parent_relationship(self): # Verify parent relationship is set self.assertEqual(reply_message.parent_message, parent_message) - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mutation = """ mutation UpdateMessage($messageId: ID!, $content: String!) { diff --git a/opencontractserver/tests/test_conversation_query.py b/opencontractserver/tests/test_conversation_query.py index a304e7ff23..8f65d19bf5 100644 --- a/opencontractserver/tests/test_conversation_query.py +++ b/opencontractserver/tests/test_conversation_query.py @@ -2,13 +2,13 @@ from django.contrib.auth.models import AnonymousUser from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.conversations.models import ChatMessage, Conversation from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( PermissionTypes, set_permissions_for_obj_to_user, diff --git a/opencontractserver/tests/test_conversation_search.py b/opencontractserver/tests/test_conversation_search.py index b84e795e0c..c5ad83c850 100644 --- a/opencontractserver/tests/test_conversation_search.py +++ b/opencontractserver/tests/test_conversation_search.py @@ -14,7 +14,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import SimpleTestCase, TestCase, override_settings -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -27,6 +26,7 @@ CoreConversationVectorStore, VectorSearchQuery, ) +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( PermissionTypes, set_permissions_for_obj_to_user, diff --git a/opencontractserver/tests/test_corpus_action_graphql.py b/opencontractserver/tests/test_corpus_action_graphql.py index de5371723b..e522af39fa 100644 --- a/opencontractserver/tests/test_corpus_action_graphql.py +++ b/opencontractserver/tests/test_corpus_action_graphql.py @@ -1,5 +1,4 @@ from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -9,6 +8,7 @@ from opencontractserver.extracts.models import Fieldset from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user diff --git a/opencontractserver/tests/test_corpus_action_template_graphql.py b/opencontractserver/tests/test_corpus_action_template_graphql.py index 2b6b5c3ba1..4b56c789a2 100644 --- a/opencontractserver/tests/test_corpus_action_template_graphql.py +++ b/opencontractserver/tests/test_corpus_action_template_graphql.py @@ -1,5 +1,4 @@ from django.contrib.auth import get_user_model -from graphql_relay import to_global_id from config.graphql.testing import GraphQLTestCase from opencontractserver.corpuses.models import ( @@ -8,6 +7,7 @@ CorpusActionTrigger, ) from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_corpus_cards_structural_document_resolution.py b/opencontractserver/tests/test_corpus_cards_structural_document_resolution.py index 0b64081cf4..8242e9f9f4 100644 --- a/opencontractserver/tests/test_corpus_cards_structural_document_resolution.py +++ b/opencontractserver/tests/test_corpus_cards_structural_document_resolution.py @@ -36,9 +36,8 @@ from django.test import RequestFactory, TestCase from django.test.utils import CaptureQueriesContext from django.utils import timezone -from graphql_relay import from_global_id, to_global_id -from config.graphql.annotation_types import AnnotationType +from config.graphql.annotation_types import _resolve_AnnotationType_document from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.annotations.models import ( @@ -50,6 +49,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() @@ -464,12 +464,12 @@ def test_resolve_document_uncached_fk_uses_permission_gated_fallback(self): fk_uncached._meta.get_field("document").is_cached(fk_uncached), "test setup must fetch the annotation without select_related", ) - resolved = AnnotationType.resolve_document(fk_uncached, self._info(self.user)) + resolved = _resolve_AnnotationType_document(fk_uncached, self._info(self.user)) self.assertIsNotNone(resolved) self.assertEqual(resolved.id, owned_doc.id) fk_uncached_again = Annotation.objects.get(pk=annotation.id) - resolved_for_stranger = AnnotationType.resolve_document( + resolved_for_stranger = _resolve_AnnotationType_document( fk_uncached_again, self._info(self.other_user) ) self.assertIsNone( @@ -510,7 +510,7 @@ def test_resolve_document_structural_fallback_without_corpus_returns_none(self): # Fetched without the corpus/document-scoped prefetch, so # resolve_document falls through to resolve_structural_document_fallback. fresh = Annotation.objects.get(pk=annotation.id) - resolved = AnnotationType.resolve_document(fresh, self._info(self.user)) + resolved = _resolve_AnnotationType_document(fresh, self._info(self.user)) self.assertIsNone(resolved) def test_resolve_document_structural_fallback_resolves_corpus_scoped_document( @@ -524,7 +524,7 @@ def test_resolve_document_structural_fallback_resolves_corpus_scoped_document( """ annotation = self._make_structural_annotations(self.corpus_a, "A")[0] fresh = Annotation.objects.get(pk=annotation.id) - resolved = AnnotationType.resolve_document(fresh, self._info(self.user)) + resolved = _resolve_AnnotationType_document(fresh, self._info(self.user)) self.assertIsNotNone(resolved) self.assertEqual(resolved.id, self.doc_a.id) diff --git a/opencontractserver/tests/test_corpus_category.py b/opencontractserver/tests/test_corpus_category.py index 9f2fe7262e..87f73c54f5 100644 --- a/opencontractserver/tests/test_corpus_category.py +++ b/opencontractserver/tests/test_corpus_category.py @@ -14,7 +14,6 @@ import pytest from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory, TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -27,6 +26,7 @@ from opencontractserver.corpuses.services import CorpusCategoryService from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user @@ -535,7 +535,7 @@ def test_create_corpus_with_categories(self): self.assertIsNotNone(mutation_result["objId"]) # Get the created corpus and verify categories - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id corpus_pk = from_global_id(mutation_result["objId"])[1] corpus = Corpus.objects.get(pk=corpus_pk) @@ -571,7 +571,7 @@ def test_create_corpus_without_categories(self): self.assertTrue(mutation_result["ok"]) # Get the created corpus and verify no categories - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id corpus_pk = from_global_id(mutation_result["objId"])[1] corpus = Corpus.objects.get(pk=corpus_pk) @@ -760,7 +760,7 @@ def test_create_corpus_with_duplicate_category_ids(self): self.assertTrue(mutation_result["ok"]) # Get the created corpus and verify only one category (deduplicated) - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id corpus_pk = from_global_id(mutation_result["objId"])[1] corpus = Corpus.objects.get(pk=corpus_pk) diff --git a/opencontractserver/tests/test_corpus_description.py b/opencontractserver/tests/test_corpus_description.py index 11dc84bc8a..7cd9930e97 100644 --- a/opencontractserver/tests/test_corpus_description.py +++ b/opencontractserver/tests/test_corpus_description.py @@ -12,6 +12,14 @@ from django.test import TestCase +from config.graphql.corpus_types import ( + _resolve_CorpusDescriptionRevisionType_author, + _resolve_CorpusDescriptionRevisionType_created, + _resolve_CorpusDescriptionRevisionType_id, + _resolve_CorpusDescriptionRevisionType_snapshot, + _resolve_CorpusDescriptionRevisionType_version, + _resolve_CorpusType_description_revisions, +) from opencontractserver.corpuses.models import Corpus from opencontractserver.users.models import User @@ -144,9 +152,8 @@ def setUp(self): self.corpus.refresh_from_db() def test_revisions_list_pulls_from_caml_version_tree(self): - from config.graphql.corpus_types import CorpusType - revs = CorpusType.resolve_description_revisions(self.corpus, None) + revs = _resolve_CorpusType_description_revisions(self.corpus, None) # 3 edits → 3 version-tree siblings self.assertEqual(len(revs), 3) @@ -154,9 +161,8 @@ def test_revisions_newest_first_ordering(self): """The list is ordered newest-first (matches the frontend modal which sorts by version desc but expects the array head to be the most recent entry).""" - from config.graphql.corpus_types import CorpusType - revs = CorpusType.resolve_description_revisions(self.corpus, None) + revs = _resolve_CorpusType_description_revisions(self.corpus, None) timestamps = [rev.created for rev in revs] self.assertEqual(timestamps, sorted(timestamps, reverse=True)) @@ -165,43 +171,35 @@ def test_revision_facade_exposes_legacy_shape(self): fields via the ``CorpusDescriptionRevisionType`` facade resolvers — id, version, author, snapshot, created — so the frontend viewer keeps rendering.""" - from config.graphql.corpus_types import ( - CorpusDescriptionRevisionType, - CorpusType, - ) - revs = CorpusType.resolve_description_revisions(self.corpus, None) + revs = _resolve_CorpusType_description_revisions(self.corpus, None) # Each rev is a Document — confirm the facade resolvers map the # Document attributes onto the legacy field names. for rev in revs: self.assertEqual( - CorpusDescriptionRevisionType.resolve_id(rev, None), rev.pk + _resolve_CorpusDescriptionRevisionType_id(rev, None), rev.pk ) self.assertIs( - CorpusDescriptionRevisionType.resolve_author(rev, None), rev.creator + _resolve_CorpusDescriptionRevisionType_author(rev, None), rev.creator ) self.assertEqual( - CorpusDescriptionRevisionType.resolve_created(rev, None), + _resolve_CorpusDescriptionRevisionType_created(rev, None), rev.created, ) # version is 1-indexed within the tree; for 3 siblings it # must be in {1,2,3}. self.assertIn( - CorpusDescriptionRevisionType.resolve_version(rev, None), + _resolve_CorpusDescriptionRevisionType_version(rev, None), {1, 2, 3}, ) def test_revision_snapshot_reads_txt_extract_file_body(self): """``snapshot`` reads the Document's ``txt_extract_file`` body on demand via the shared ``read_caml_body`` helper.""" - from config.graphql.corpus_types import ( - CorpusDescriptionRevisionType, - CorpusType, - ) - revs = CorpusType.resolve_description_revisions(self.corpus, None) + revs = _resolve_CorpusType_description_revisions(self.corpus, None) bodies = { - CorpusDescriptionRevisionType.resolve_snapshot(rev, None) for rev in revs + _resolve_CorpusDescriptionRevisionType_snapshot(rev, None) for rev in revs } # All three edits should be retrievable as snapshots. self.assertSetEqual(bodies, {"v1 body", "v2 body", "v3 body"}) @@ -210,16 +208,12 @@ def test_version_is_1_indexed_and_unique_oldest_first(self): """The 1-indexed version counter must mirror the legacy ``CorpusDescriptionRevision.version`` semantic — oldest = 1, newest = N — so the frontend "Version N" label stays stable.""" - from config.graphql.corpus_types import ( - CorpusDescriptionRevisionType, - CorpusType, - ) - revs = CorpusType.resolve_description_revisions(self.corpus, None) + revs = _resolve_CorpusType_description_revisions(self.corpus, None) # Revs are newest-first; reverse to get oldest-first. oldest_first = list(reversed(revs)) versions = [ - CorpusDescriptionRevisionType.resolve_version(rev, None) + _resolve_CorpusDescriptionRevisionType_version(rev, None) for rev in oldest_first ] self.assertEqual(versions, [1, 2, 3]) @@ -227,7 +221,6 @@ def test_version_is_1_indexed_and_unique_oldest_first(self): def test_empty_when_corpus_has_no_caml_document(self): """A fresh corpus with no Readme.CAML doc returns the empty list (must not raise on ``readme_caml_document_id is None``).""" - from config.graphql.corpus_types import CorpusType bare = Corpus.objects.create(title="Bare", creator=self.user) - self.assertEqual(CorpusType.resolve_description_revisions(bare, None), []) + self.assertEqual(_resolve_CorpusType_description_revisions(bare, None), []) diff --git a/opencontractserver/tests/test_corpus_description_cache.py b/opencontractserver/tests/test_corpus_description_cache.py index 6a3887a3d9..b7f6fd767c 100644 --- a/opencontractserver/tests/test_corpus_description_cache.py +++ b/opencontractserver/tests/test_corpus_description_cache.py @@ -10,6 +10,10 @@ from django.test import SimpleTestCase, TestCase from django.test.utils import CaptureQueriesContext +from config.graphql.corpus_types import ( + _resolve_CorpusType_md_description, + _resolve_CorpusType_readme_caml_document, +) from opencontractserver.constants.truncation import ( MAX_CORPUS_DESCRIPTION_PREVIEW_LENGTH, ) @@ -429,7 +433,6 @@ def setUpTestData(cls): cls.corpus.refresh_from_db() def test_md_description_resolves_to_caml_doc_url(self): - from config.graphql.corpus_types import CorpusType class FakeRequest: def build_absolute_uri(self, url): @@ -438,17 +441,16 @@ def build_absolute_uri(self, url): class FakeInfo: context = FakeRequest() - url = CorpusType.resolve_md_description(self.corpus, FakeInfo()) + url = _resolve_CorpusType_md_description(self.corpus, FakeInfo()) self.assertIsNotNone(url) # The CAML doc body lives in txt_extract_file → URL should reference # the configured storage path for txt-extract files. self.assertTrue(url.startswith("https://example.com/")) def test_md_description_is_none_without_caml_doc(self): - from config.graphql.corpus_types import CorpusType bare = Corpus.objects.create(title="Bare", creator=self.user) - result = CorpusType.resolve_md_description(bare, None) + result = _resolve_CorpusType_md_description(bare, None) self.assertIsNone(result) @@ -489,16 +491,14 @@ def setUpTestData(cls): cls.corpus.refresh_from_db() def test_field_resolves_to_caml_document(self): - from config.graphql.corpus_types import CorpusType - doc = CorpusType.resolve_readme_caml_document(self.corpus, None) + doc = _resolve_CorpusType_readme_caml_document(self.corpus, None) self.assertIsNotNone(doc) self.assertEqual(doc.title, "Readme.CAML") self.assertEqual(doc.file_type, "text/markdown") def test_field_is_none_when_corpus_lacks_caml_doc(self): - from config.graphql.corpus_types import CorpusType bare = Corpus.objects.create(title="Bare", creator=self.user) - result = CorpusType.resolve_readme_caml_document(bare, None) + result = _resolve_CorpusType_readme_caml_document(bare, None) self.assertIsNone(result) diff --git a/opencontractserver/tests/test_corpus_folder_mutations.py b/opencontractserver/tests/test_corpus_folder_mutations.py index 497a43ceae..e23d11de1b 100644 --- a/opencontractserver/tests/test_corpus_folder_mutations.py +++ b/opencontractserver/tests/test_corpus_folder_mutations.py @@ -15,7 +15,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -25,6 +24,7 @@ ) from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_corpus_groups.py b/opencontractserver/tests/test_corpus_groups.py index 22630d161e..d2eff744ad 100644 --- a/opencontractserver/tests/test_corpus_groups.py +++ b/opencontractserver/tests/test_corpus_groups.py @@ -23,12 +23,10 @@ from unittest.mock import patch from asgiref.sync import async_to_sync -from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase, TransactionTestCase -from graphene_django.utils.testing import GraphQLTestCase -from graphql_relay import to_global_id +from config.graphql.testing import GraphQLTestCase from opencontractserver.agents.models import AgentConfiguration from opencontractserver.annotations.models import Annotation from opencontractserver.constants.tools import ( @@ -40,10 +38,10 @@ from opencontractserver.corpuses.services.corpus_groups import GROUP_NOT_FOUND_MESSAGE from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user -User = get_user_model() - class CorpusGroupModelTests(TestCase): """Model-level behaviour: slug auto-generation.""" @@ -731,6 +729,10 @@ class CorpusGroupMineFilterTests(GraphQLTestCase): """ GRAPHQL_URL = "/graphql/" + me: User + other: User + my_group: CorpusGroup + other_public_group: CorpusGroup @classmethod def setUpTestData(cls): @@ -796,6 +798,11 @@ class CorpusGroupGraphQLTests(GraphQLTestCase): """Permission boundaries on the GraphQL surface.""" GRAPHQL_URL = "/graphql/" + owner: User + stranger: User + owner_corpus: Corpus + public_corpus: Corpus + group: CorpusGroup @classmethod def setUpTestData(cls): diff --git a/opencontractserver/tests/test_corpus_intelligence.py b/opencontractserver/tests/test_corpus_intelligence.py index f2472ad631..09923956d3 100644 --- a/opencontractserver/tests/test_corpus_intelligence.py +++ b/opencontractserver/tests/test_corpus_intelligence.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import from_global_id, to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -27,6 +26,7 @@ ) from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentRelationship +from opencontractserver.utils.ids import from_global_id, to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_corpus_license.py b/opencontractserver/tests/test_corpus_license.py index b9192dd63a..d0beff5c1e 100644 --- a/opencontractserver/tests/test_corpus_license.py +++ b/opencontractserver/tests/test_corpus_license.py @@ -14,13 +14,13 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client as GrapheneClient from opencontractserver.corpuses.models import Corpus from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user diff --git a/opencontractserver/tests/test_corpus_query_optimization.py b/opencontractserver/tests/test_corpus_query_optimization.py index 29c2d2b87a..d9784ad47f 100644 --- a/opencontractserver/tests/test_corpus_query_optimization.py +++ b/opencontractserver/tests/test_corpus_query_optimization.py @@ -10,7 +10,6 @@ from django.http import HttpRequest from django.test import RequestFactory, TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -18,6 +17,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id class TestCorpusDocumentCountField(TestCase): diff --git a/opencontractserver/tests/test_corpus_voting.py b/opencontractserver/tests/test_corpus_voting.py index cd607780ca..3fc63faa37 100644 --- a/opencontractserver/tests/test_corpus_voting.py +++ b/opencontractserver/tests/test_corpus_voting.py @@ -26,6 +26,7 @@ from django.db import IntegrityError, transaction from django.test import RequestFactory, TestCase, TransactionTestCase +from config.graphql.corpus_types import _resolve_CorpusType_my_vote from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import ( @@ -43,7 +44,7 @@ def _corpus_relay_id(pk: int) -> str: """Encode a Corpus pk as the Relay global ID used by the GraphQL surface.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id return to_global_id("CorpusType", pk) @@ -672,8 +673,6 @@ def test_my_vote_resolver_uses_service_fallback_when_not_annotated( """ from types import SimpleNamespace - from config.graphql.corpus_types import CorpusType - CorpusVoteService.cast_vote(self.alice, self.public_corpus.pk, "upvote") # Reload via the ORM directly so ``_viewer_vote`` is NOT set. @@ -686,7 +685,7 @@ def test_my_vote_resolver_uses_service_fallback_when_not_annotated( context=self._build_request(self.alice, with_session=False) ) - self.assertEqual(CorpusType.resolve_my_vote(unannotated, info), "UPVOTE") + self.assertEqual(_resolve_CorpusType_my_vote(unannotated, info), "UPVOTE") # And the no-vote branch: a viewer with no prior vote should # get ``None`` from the fallback (not a crash on the missing @@ -695,7 +694,7 @@ def test_my_vote_resolver_uses_service_fallback_when_not_annotated( username="bob-fallback", password="pw", email="b-fb@example.com" ) info_bob = SimpleNamespace(context=self._build_request(bob, with_session=False)) - self.assertIsNone(CorpusType.resolve_my_vote(unannotated, info_bob)) + self.assertIsNone(_resolve_CorpusType_my_vote(unannotated, info_bob)) def test_corpuses_list_my_vote_is_not_n_plus_one(self) -> None: """The corpus list resolver must annotate ``my_vote`` with a single diff --git a/opencontractserver/tests/test_coverage_action_queries.py b/opencontractserver/tests/test_coverage_action_queries.py index 542ed4dde5..c650926b8a 100644 --- a/opencontractserver/tests/test_coverage_action_queries.py +++ b/opencontractserver/tests/test_coverage_action_queries.py @@ -19,7 +19,6 @@ from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -33,6 +32,7 @@ from opencontractserver.extracts.models import Fieldset from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user diff --git a/opencontractserver/tests/test_coverage_agent_types.py b/opencontractserver/tests/test_coverage_agent_types.py index ed79694d0e..b4c6d7cbe1 100644 --- a/opencontractserver/tests/test_coverage_agent_types.py +++ b/opencontractserver/tests/test_coverage_agent_types.py @@ -19,9 +19,8 @@ from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id -from config.graphql.agent_types import AgentConfigurationType +from config.graphql.agent_types import _get_node_AgentConfigurationType from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.agents.models import AgentActionResult, AgentConfiguration @@ -39,6 +38,7 @@ from opencontractserver.extracts.models import Extract, Fieldset from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user @@ -479,11 +479,7 @@ def test_get_node_hook_guards_against_a_null_pk(self): this pins the guard clause itself rather than a query-level scenario. """ info = type("Info", (), {"context": _RequestContext(self.user)})() - # ``get_node`` is installed onto the class at runtime by - # ``register_type``'s aliasing (not statically declared) — see - # ``test_doc_annotations_prefetch_n_plus_one.py`` for the same - # mypy-invisible-but-real attribute accessed the same way. - self.assertIsNone(AgentConfigurationType.get_node(info, None)) # type: ignore[attr-defined] + self.assertIsNone(_get_node_AgentConfigurationType(info, None)) class AgentActionResultFieldResolverTestCase(TestCase): diff --git a/opencontractserver/tests/test_coverage_conversation_mutations.py b/opencontractserver/tests/test_coverage_conversation_mutations.py index b5d5259d40..d10a4e1d66 100644 --- a/opencontractserver/tests/test_coverage_conversation_mutations.py +++ b/opencontractserver/tests/test_coverage_conversation_mutations.py @@ -44,7 +44,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -53,6 +52,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_coverage_conversation_types.py b/opencontractserver/tests/test_coverage_conversation_types.py index 2e40345981..ae56b5b4c3 100644 --- a/opencontractserver/tests/test_coverage_conversation_types.py +++ b/opencontractserver/tests/test_coverage_conversation_types.py @@ -25,7 +25,6 @@ from django.contrib.auth.models import AnonymousUser from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql import conversation_types as ct from config.graphql.schema import schema @@ -56,6 +55,7 @@ ) from opencontractserver.research.models import ResearchReport from opencontractserver.types.enums import JobStatus, PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_coverage_core_relay.py b/opencontractserver/tests/test_coverage_core_relay.py index 8c808159d9..ce48be1272 100644 --- a/opencontractserver/tests/test_coverage_core_relay.py +++ b/opencontractserver/tests/test_coverage_core_relay.py @@ -32,7 +32,6 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from graphql_relay import offset_to_cursor, to_global_id from config.graphql.core.filtering import setup_filterset from config.graphql.core.relay import ( @@ -62,6 +61,7 @@ from opencontractserver.extracts.models import Extract from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import User +from opencontractserver.utils.ids import offset_to_cursor, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user # A name deliberately absent from the type registry, used whenever a test diff --git a/opencontractserver/tests/test_coverage_corpus_folder_mutations.py b/opencontractserver/tests/test_coverage_corpus_folder_mutations.py index d1aaa025d4..ebea2333a9 100644 --- a/opencontractserver/tests/test_coverage_corpus_folder_mutations.py +++ b/opencontractserver/tests/test_coverage_corpus_folder_mutations.py @@ -22,13 +22,13 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus, CorpusFolder from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_coverage_corpus_queries.py b/opencontractserver/tests/test_coverage_corpus_queries.py index e4ff69f194..edd35070cf 100644 --- a/opencontractserver/tests/test_coverage_corpus_queries.py +++ b/opencontractserver/tests/test_coverage_corpus_queries.py @@ -23,12 +23,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Artifact, Corpus, CorpusGroup from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_coverage_document_mutations.py b/opencontractserver/tests/test_coverage_document_mutations.py index 61ff1a4b4b..07cee0ed6e 100644 --- a/opencontractserver/tests/test_coverage_document_mutations.py +++ b/opencontractserver/tests/test_coverage_document_mutations.py @@ -29,7 +29,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -47,6 +46,7 @@ from opencontractserver.types.enums import PermissionTypes from opencontractserver.users.models import UserExport from opencontractserver.utils.files import base_64_encode_bytes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_datacell_mutations.py b/opencontractserver/tests/test_datacell_mutations.py index 7fbc088d15..f8c862ad09 100644 --- a/opencontractserver/tests/test_datacell_mutations.py +++ b/opencontractserver/tests/test_datacell_mutations.py @@ -1,12 +1,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_default_labelset.py b/opencontractserver/tests/test_default_labelset.py index 14ca0b19d8..2f1bb6884c 100644 --- a/opencontractserver/tests/test_default_labelset.py +++ b/opencontractserver/tests/test_default_labelset.py @@ -15,7 +15,6 @@ from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.testing import GraphQLTestCase from opencontractserver.annotations.label_set_seeds import ( @@ -26,6 +25,7 @@ ) from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.corpuses.models import Corpus +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_delete_analysis_mutation.py b/opencontractserver/tests/test_delete_analysis_mutation.py index fb1691764f..35f8efa7c0 100644 --- a/opencontractserver/tests/test_delete_analysis_mutation.py +++ b/opencontractserver/tests/test_delete_analysis_mutation.py @@ -14,11 +14,11 @@ from django.db import transaction from django.db.models.signals import post_save from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_doc_annotations_prefetch_n_plus_one.py b/opencontractserver/tests/test_doc_annotations_prefetch_n_plus_one.py index 4a96687bd8..b1e9afd760 100644 --- a/opencontractserver/tests/test_doc_annotations_prefetch_n_plus_one.py +++ b/opencontractserver/tests/test_doc_annotations_prefetch_n_plus_one.py @@ -46,10 +46,9 @@ from django.db import connection from django.test import override_settings from django.test.utils import CaptureQueriesContext -from graphql_relay import to_global_id from config.graphql.core.permissions import get_anonymous_user_id -from config.graphql.corpus_types import CorpusType +from config.graphql.corpus_types import _get_node_CorpusType from config.graphql.custom_resolvers import ( SUPPORTED_FILTER_KEYS, UNSUPPORTED_FILTER_KEYS, @@ -63,6 +62,7 @@ AnnotationLabel, ) from opencontractserver.tests.base import BaseFixtureTestCase +from opencontractserver.utils.ids import to_global_id # The actual shape the frontend sends after the migration to ``docTypeLabels``. _BADGE_QUERY = """ @@ -308,13 +308,13 @@ def __init__(self, user) -> None: info = _FakeInfo(_Ctx(self.user)) with CaptureQueriesContext(connection) as ctx: - first = CorpusType.get_node(info, self.corpus.pk) + first = _get_node_CorpusType(info, self.corpus.pk) first_call_queries = len(ctx.captured_queries) self.assertIsNotNone(first) with CaptureQueriesContext(connection) as ctx2: for _ in range(10): - cached = CorpusType.get_node(info, self.corpus.pk) + cached = _get_node_CorpusType(info, self.corpus.pk) self.assertEqual(cached.pk, first.pk) self.assertEqual( len(ctx2.captured_queries), diff --git a/opencontractserver/tests/test_document_imports_rest.py b/opencontractserver/tests/test_document_imports_rest.py index 0ec0b33d31..349b4929b4 100644 --- a/opencontractserver/tests/test_document_imports_rest.py +++ b/opencontractserver/tests/test_document_imports_rest.py @@ -25,7 +25,6 @@ from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings -from graphql_relay import to_global_id from rest_framework.test import APIClient from opencontractserver.constants.zip_import import ( @@ -38,6 +37,7 @@ ) from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() @@ -844,7 +844,7 @@ def _payload(self) -> dict: def test_real_jwt_authenticates_single_doc_upload(self): """A valid JWT minted via graphql_jwt must authorise the upload.""" - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token self._set_bearer(get_token(self.user)) response = self.client.post( @@ -869,7 +869,7 @@ def test_missing_authorization_header_is_rejected(self): def test_tampered_jwt_is_rejected(self): """A token with a flipped signature byte must NOT authenticate.""" - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token token = get_token(self.user) # Flip a char in the middle of the signature segment. The trailing @@ -897,7 +897,7 @@ def test_expired_jwt_is_rejected(self): """An expired bearer JWT must be rejected with 401/403.""" from datetime import timedelta - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token # JWT_EXPIRATION_DELTA is read at import time; emit a token whose # exp is firmly in the past by overriding the setting for this test. diff --git a/opencontractserver/tests/test_document_imports_worker_token.py b/opencontractserver/tests/test_document_imports_worker_token.py index 0b37e54c89..a377732741 100644 --- a/opencontractserver/tests/test_document_imports_worker_token.py +++ b/opencontractserver/tests/test_document_imports_worker_token.py @@ -16,7 +16,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings -from graphql_relay import to_global_id from rest_framework.test import APIClient from opencontractserver.annotations.models import LabelSet @@ -33,6 +32,7 @@ store_chunk, ) from opencontractserver.users.models import User +from opencontractserver.utils.ids import to_global_id from opencontractserver.worker_uploads.models import CorpusAccessToken, WorkerAccount _PDF = b"%PDF-1.4 test document\n%%EOF\n" diff --git a/opencontractserver/tests/test_document_mutations.py b/opencontractserver/tests/test_document_mutations.py index d6109cbd39..5c5c34c405 100644 --- a/opencontractserver/tests/test_document_mutations.py +++ b/opencontractserver/tests/test_document_mutations.py @@ -2,13 +2,13 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.files import base_64_encode_bytes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_document_queries.py b/opencontractserver/tests/test_document_queries.py index 637bb603b3..92b591d89f 100644 --- a/opencontractserver/tests/test_document_queries.py +++ b/opencontractserver/tests/test_document_queries.py @@ -1,11 +1,11 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus, CorpusFolder from opencontractserver.documents.models import Document +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_document_relationship_mutations.py b/opencontractserver/tests/test_document_relationship_mutations.py index b8fa49f5fa..cbaac8ac41 100644 --- a/opencontractserver/tests/test_document_relationship_mutations.py +++ b/opencontractserver/tests/test_document_relationship_mutations.py @@ -10,7 +10,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -23,6 +22,7 @@ ) from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_TWO_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_document_relationship_permissions.py b/opencontractserver/tests/test_document_relationship_permissions.py index 141a561c5f..9f707505d0 100644 --- a/opencontractserver/tests/test_document_relationship_permissions.py +++ b/opencontractserver/tests/test_document_relationship_permissions.py @@ -19,7 +19,6 @@ from django.contrib.auth.models import AnonymousUser from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -33,6 +32,7 @@ from opencontractserver.documents.services import DocumentRelationshipService from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_TWO_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_document_relationships.py b/opencontractserver/tests/test_document_relationships.py index 22f2beba2b..d74bd13a74 100644 --- a/opencontractserver/tests/test_document_relationships.py +++ b/opencontractserver/tests/test_document_relationships.py @@ -1,7 +1,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -13,6 +12,7 @@ DocumentRelationship, ) from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_TWO_PATH +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_document_stats.py b/opencontractserver/tests/test_document_stats.py index 268e45b716..1365eb7a09 100644 --- a/opencontractserver/tests/test_document_stats.py +++ b/opencontractserver/tests/test_document_stats.py @@ -16,13 +16,13 @@ from __future__ import annotations from django.contrib.auth import get_user_model -from graphql_relay import to_global_id from config.graphql.testing import GraphQLTestCase from opencontractserver.annotations.models import Annotation, AnnotationLabel from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_document_uploads.py b/opencontractserver/tests/test_document_uploads.py index 5f34650ccb..f3aace3af4 100644 --- a/opencontractserver/tests/test_document_uploads.py +++ b/opencontractserver/tests/test_document_uploads.py @@ -3,7 +3,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase from docx import Document as DocxDocument -from graphql_relay import from_global_id, to_global_id from openpyxl import Workbook from pptx import Presentation @@ -14,6 +13,7 @@ from opencontractserver.pipeline.registry import get_allowed_mime_types from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.files import base_64_encode_bytes +from opencontractserver.utils.ids import from_global_id, to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_document_version_count_optimizer.py b/opencontractserver/tests/test_document_version_count_optimizer.py index 36ec51d420..79e15b88f7 100644 --- a/opencontractserver/tests/test_document_version_count_optimizer.py +++ b/opencontractserver/tests/test_document_version_count_optimizer.py @@ -26,6 +26,7 @@ from django.test import TestCase from django.test.utils import CaptureQueriesContext +from config.graphql.document_types import _resolve_DocumentType_version_count from opencontractserver.documents.models import Document from opencontractserver.documents.services import DocumentVersionService from opencontractserver.types.enums import PermissionTypes @@ -197,21 +198,20 @@ def test_resolver_amortises_over_request(self): ``info.context`` should perform the aggregation once — subsequent resolutions are dict lookups against the cached result. """ - from config.graphql.document_types import DocumentType # Fake an ``info`` object whose ``.context`` is a writable namespace, # mirroring what graphene provides during a GraphQL request. info = SimpleNamespace(context=SimpleNamespace(user=self.owner)) # First call populates the cache. - first = DocumentType.resolve_version_count(self.docs[0], info) + first = _resolve_DocumentType_version_count(self.docs[0], info) self.assertEqual(first, 3) # Subsequent calls must not issue any version_tree_id queries — the # batched aggregation already populated the per-request cache. with CaptureQueriesContext(connection) as captured: results = [ - DocumentType.resolve_version_count(doc, info) for doc in self.docs[1:] + _resolve_DocumentType_version_count(doc, info) for doc in self.docs[1:] ] self.assertTrue(all(r == 3 for r in results)) version_tree_queries = [ @@ -230,7 +230,6 @@ def test_resolver_falls_back_to_one_for_unknown_tree(self): user but somehow reachable from the parent resolver) should resolve to 1 — never 0, since the document itself is at minimum one version. """ - from config.graphql.document_types import DocumentType info = SimpleNamespace(context=SimpleNamespace(user=self.owner)) # Pre-populate the service's cache with only an unrelated tree so @@ -239,5 +238,5 @@ def test_resolver_falls_back_to_one_for_unknown_tree(self): cache_key = f"_doc_version_counts_by_tree_{self.owner.id}" setattr(info.context, cache_key, {uuid.uuid4(): 99}) - result = DocumentType.resolve_version_count(self.docs[0], info) + result = _resolve_DocumentType_version_count(self.docs[0], info) self.assertEqual(result, 1) diff --git a/opencontractserver/tests/test_document_versioning_graphql.py b/opencontractserver/tests/test_document_versioning_graphql.py index 5ba7c7d006..b1a97726a3 100644 --- a/opencontractserver/tests/test_document_versioning_graphql.py +++ b/opencontractserver/tests/test_document_versioning_graphql.py @@ -12,7 +12,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -25,6 +24,7 @@ restore_document, ) from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_embedder_management.py b/opencontractserver/tests/test_embedder_management.py index 4be63cb0f8..666af6987f 100644 --- a/opencontractserver/tests/test_embedder_management.py +++ b/opencontractserver/tests/test_embedder_management.py @@ -150,7 +150,7 @@ def _execute_mutation(self, mutation_str, variables=None): def test_embedder_change_allowed_on_empty_corpus(self): """Changing preferred_embedder is allowed when corpus has no documents.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id global_id = to_global_id("CorpusType", self.corpus.pk) mutation = """ @@ -173,9 +173,8 @@ def test_embedder_change_allowed_on_empty_corpus(self): def test_embedder_change_rejected_with_documents(self): """Changing preferred_embedder is rejected when corpus has documents.""" - from graphql_relay import to_global_id - from opencontractserver.documents.models import Document, DocumentPath + from opencontractserver.utils.ids import to_global_id # Add a document to the corpus doc = Document.objects.create(title="Test Doc", creator=self.user) @@ -212,9 +211,8 @@ def test_embedder_change_rejected_with_documents(self): def test_same_embedder_value_allowed_with_documents(self): """Setting preferred_embedder to same value is allowed even with docs.""" - from graphql_relay import to_global_id - from opencontractserver.documents.models import Document, DocumentPath + from opencontractserver.utils.ids import to_global_id doc = Document.objects.create(title="Test Doc", creator=self.user) DocumentPath.objects.create( @@ -274,7 +272,7 @@ def _execute_mutation(self, mutation_str, variables=None, user=None): @patch("opencontractserver.pipeline.utils.get_component_by_name") def test_reembed_dispatches_task(self, mock_get_component, mock_delay): """ReEmbedCorpus locks corpus and dispatches background task.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -306,7 +304,7 @@ def test_reembed_dispatches_task(self, mock_get_component, mock_delay): @patch("opencontractserver.pipeline.utils.get_component_by_name") def test_reembed_rejects_non_creator(self, mock_get_component): """Only the corpus creator can trigger re-embedding.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -335,7 +333,7 @@ def test_reembed_rejects_non_creator(self, mock_get_component): @patch("opencontractserver.pipeline.utils.get_component_by_name") def test_reembed_noop_when_same_embedder(self, mock_get_component): """ReEmbedCorpus is a no-op when the embedder hasn't changed.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -364,7 +362,7 @@ def test_reembed_noop_when_same_embedder(self, mock_get_component): @patch("opencontractserver.pipeline.utils.get_component_by_name") def test_reembed_rejects_locked_corpus(self, mock_get_component): """ReEmbedCorpus rejects when corpus is already locked.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -395,7 +393,7 @@ def test_reembed_rejects_locked_corpus(self, mock_get_component): def test_reembed_rejects_invalid_embedder(self): """ReEmbedCorpus rejects when the embedder path is invalid.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mutation = """ mutation ReEmbed($corpusId: String!, $newEmbedder: String!) { @@ -696,7 +694,7 @@ def test_second_reembed_rejected_while_first_running( self, mock_get_component, mock_delay ): """The second re-embed attempt fails when the corpus is already locked.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -813,7 +811,7 @@ def _execute_mutation(self, mutation_str, variables=None): @patch("opencontractserver.tasks.fork_tasks.fork_corpus.si") def test_fork_with_embedder_override(self, mock_fork_si): """Forking with preferred_embedder sets the new embedder on the forked corpus.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Mock the celery task chain to prevent actual task execution mock_task = mock_fork_si.return_value @@ -849,7 +847,7 @@ def test_fork_with_embedder_override(self, mock_fork_si): @patch("opencontractserver.tasks.fork_tasks.fork_corpus.si") def test_fork_without_embedder_inherits_source(self, mock_fork_si): """Forking without preferred_embedder inherits from source corpus.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_task = mock_fork_si.return_value mock_task.apply_async.return_value = None @@ -979,7 +977,7 @@ def _add_unembedded_annotation(self): def test_resumes_when_annotations_still_lack_the_embedder( self, mock_get_component, mock_delay ): - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -1011,10 +1009,9 @@ def test_blank_text_annotations_do_not_block_completion( outstanding count included them, a fully-migrated corpus would report work remaining forever and re-dispatch on every call. """ - from graphql_relay import to_global_id - from opencontractserver.annotations.models import Annotation from opencontractserver.documents.models import Document, DocumentPath + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} @@ -1054,7 +1051,7 @@ def test_still_a_noop_when_nothing_is_outstanding( self, mock_get_component, mock_delay ): """The original no-op behaviour survives for a fully-migrated corpus.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id mock_get_component.return_value = type( "FakeEmbedder", (BaseEmbedder,), {"vector_size": 384} diff --git a/opencontractserver/tests/test_engagement_metrics_graphql.py b/opencontractserver/tests/test_engagement_metrics_graphql.py index 5348639e54..a8b0bbf261 100644 --- a/opencontractserver/tests/test_engagement_metrics_graphql.py +++ b/opencontractserver/tests/test_engagement_metrics_graphql.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -28,6 +27,7 @@ ) from opencontractserver.corpuses.models import Corpus from opencontractserver.tasks.corpus_tasks import update_corpus_engagement_metrics +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_enrichment_run_mutation.py b/opencontractserver/tests/test_enrichment_run_mutation.py index a835d4a166..4df8cdbe7c 100644 --- a/opencontractserver/tests/test_enrichment_run_mutation.py +++ b/opencontractserver/tests/test_enrichment_run_mutation.py @@ -15,7 +15,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.testing import Client from opencontractserver.analyzer.services.analysis_lifecycle_service import ( @@ -25,6 +24,7 @@ from opencontractserver.enrichment import constants as C from opencontractserver.shared.services.conventions import ServiceResult from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_enrichment_tools.py b/opencontractserver/tests/test_enrichment_tools.py index 815807571c..44abd534f6 100644 --- a/opencontractserver/tests/test_enrichment_tools.py +++ b/opencontractserver/tests/test_enrichment_tools.py @@ -80,10 +80,9 @@ def setUp(self): ) def _run(self, user): - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id gid = to_global_id("CorpusType", self.corpus.id) query = """ @@ -144,10 +143,9 @@ def setUp(self): ) def _run(self, document_pk): - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id query = """ query ($cid: ID!, $did: ID) { @@ -223,10 +221,9 @@ def setUp(self): self.ref = ref def _run(self, user): - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id gid = to_global_id("CorpusType", self.corpus.id) query = """ @@ -261,10 +258,9 @@ def test_document_filter_is_idor_safe_for_invisible_doc(self): # IDOR: a corpus reader filtering by an INVISIBLE document's id must # not learn whether it has references — that would probe the private # target. The owner, who can see the document, still gets the row. - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id query = """ query ($cid: ID!, $did: ID) { diff --git a/opencontractserver/tests/test_export_mutations.py b/opencontractserver/tests/test_export_mutations.py index 32e9286778..c4e905695a 100644 --- a/opencontractserver/tests/test_export_mutations.py +++ b/opencontractserver/tests/test_export_mutations.py @@ -5,13 +5,13 @@ from django.contrib.auth import get_user_model from django.test import override_settings -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.tests.base import BaseFixtureTestCase from opencontractserver.types.enums import ExportType, PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_extract_iterations.py b/opencontractserver/tests/test_extract_iterations.py index d4a7c9f1fc..d423d2ae79 100644 --- a/opencontractserver/tests/test_extract_iterations.py +++ b/opencontractserver/tests/test_extract_iterations.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -31,6 +30,7 @@ ) from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_extract_mutations.py b/opencontractserver/tests/test_extract_mutations.py index 4db8a0bb00..ea8d5f3768 100644 --- a/opencontractserver/tests/test_extract_mutations.py +++ b/opencontractserver/tests/test_extract_mutations.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -10,6 +9,7 @@ from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_extract_queries.py b/opencontractserver/tests/test_extract_queries.py index ed84adee0e..c156b2a8db 100644 --- a/opencontractserver/tests/test_extract_queries.py +++ b/opencontractserver/tests/test_extract_queries.py @@ -1,7 +1,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -10,6 +9,7 @@ from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_TWO_PATH from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_fk_visibility_traversal.py b/opencontractserver/tests/test_fk_visibility_traversal.py index a8555deeeb..a573ee404e 100644 --- a/opencontractserver/tests/test_fk_visibility_traversal.py +++ b/opencontractserver/tests/test_fk_visibility_traversal.py @@ -19,7 +19,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.core.relay import resolve_visible_fk from config.graphql.schema import schema @@ -28,6 +27,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_geographic_annotation_mutations.py b/opencontractserver/tests/test_geographic_annotation_mutations.py index b9d36f846f..1070f40f41 100644 --- a/opencontractserver/tests/test_geographic_annotation_mutations.py +++ b/opencontractserver/tests/test_geographic_annotation_mutations.py @@ -20,7 +20,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -36,6 +35,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_geographic_annotation_service.py b/opencontractserver/tests/test_geographic_annotation_service.py index 0a0408e32f..dff15dc999 100644 --- a/opencontractserver/tests/test_geographic_annotation_service.py +++ b/opencontractserver/tests/test_geographic_annotation_service.py @@ -459,10 +459,9 @@ class GeographicQueryResolverErrorTests(TestCase): """ def test_corpus_resolver_returns_graphql_error_on_bad_label_type(self): - from graphql_relay import to_global_id - from config.graphql.schema import schema from config.graphql.testing import Client + from opencontractserver.utils.ids import to_global_id owner = User.objects.create_user(username="resolver-owner", password="x") corpus = Corpus.objects.create(title="resolver-c", creator=owner) diff --git a/opencontractserver/tests/test_get_document_knowledge_optimizations.py b/opencontractserver/tests/test_get_document_knowledge_optimizations.py index 11a4edf481..070ef824a5 100644 --- a/opencontractserver/tests/test_get_document_knowledge_optimizations.py +++ b/opencontractserver/tests/test_get_document_knowledge_optimizations.py @@ -26,6 +26,7 @@ from django.test import TestCase from django.test.utils import CaptureQueriesContext +from config.graphql.annotation_types import _resolve_AnnotationType_feedback_count from opencontractserver.annotations.models import Annotation, AnnotationLabel from opencontractserver.annotations.services import AnnotationService from opencontractserver.corpuses.models import Corpus @@ -203,7 +204,6 @@ def test_feedback_count_uses_prefetched_cache(self) -> None: ``AnnotationType.resolve_feedback_count`` must consult the prefetched ``user_feedback`` list rather than firing ``COUNT(*)`` per row. """ - from config.graphql.annotation_types import AnnotationType qs = AnnotationService.get_document_annotations( document_id=self.document.pk, @@ -214,7 +214,8 @@ def test_feedback_count_uses_prefetched_cache(self) -> None: with CaptureQueriesContext(connection) as captured: counts = [ - AnnotationType.resolve_feedback_count(ann, info=None) for ann in results + _resolve_AnnotationType_feedback_count(ann, info=None) + for ann in results ] self.assertEqual(counts, [2] * len(results)) # Zero new queries — every count came from the prefetch cache. diff --git a/opencontractserver/tests/test_governance_graph.py b/opencontractserver/tests/test_governance_graph.py index ec038998c9..06024d3fdd 100644 --- a/opencontractserver/tests/test_governance_graph.py +++ b/opencontractserver/tests/test_governance_graph.py @@ -12,7 +12,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from opencontractserver.annotations.models import AuthorityRelationship from opencontractserver.corpuses.models import Corpus @@ -23,6 +22,7 @@ AuthoritySection, ) from opencontractserver.enrichment.services import EnrichmentService +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_graphql_analyzer_endpoints.py b/opencontractserver/tests/test_graphql_analyzer_endpoints.py index cf0808291b..dce6351601 100644 --- a/opencontractserver/tests/test_graphql_analyzer_endpoints.py +++ b/opencontractserver/tests/test_graphql_analyzer_endpoints.py @@ -11,7 +11,6 @@ from django.db.models.signals import post_save from django.test import TestCase from django.test.client import Client as DjangoClient -from graphql_relay import to_global_id from rest_framework.test import APIClient from config.graphql.schema import schema @@ -33,6 +32,7 @@ create_mock_submission_response, generate_random_analyzer_return_values, ) +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_graphql_import_export_mutations.py b/opencontractserver/tests/test_graphql_import_export_mutations.py index bfb8e803e6..3cca41fab4 100644 --- a/opencontractserver/tests/test_graphql_import_export_mutations.py +++ b/opencontractserver/tests/test_graphql_import_export_mutations.py @@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -14,6 +13,7 @@ from opencontractserver.tests.fixtures import SAMPLE_PDF_FILE_TWO_PATH from opencontractserver.types.dicts import OpenContractsAnnotatedDocumentImportType from opencontractserver.types.enums import LabelType +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_ingestion_source.py b/opencontractserver/tests/test_ingestion_source.py index 569cbe899d..860ce35a5e 100644 --- a/opencontractserver/tests/test_ingestion_source.py +++ b/opencontractserver/tests/test_ingestion_source.py @@ -7,7 +7,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -22,6 +21,7 @@ from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.export_v2 import package_ingestion_sources from opencontractserver.utils.files import base_64_encode_bytes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_intelligence_setup.py b/opencontractserver/tests/test_intelligence_setup.py index 4825048b9f..45e357aa9c 100644 --- a/opencontractserver/tests/test_intelligence_setup.py +++ b/opencontractserver/tests/test_intelligence_setup.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from opencontractserver.constants.corpus_actions import ( INTELLIGENCE_SETUP_TEMPLATE_NAMES, @@ -34,6 +33,7 @@ from opencontractserver.enrichment import constants as enrichment_constants from opencontractserver.shared.services.conventions import ServiceResult from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_jwt_auth_contract.py b/opencontractserver/tests/test_jwt_auth_contract.py new file mode 100644 index 0000000000..4400ca28df --- /dev/null +++ b/opencontractserver/tests/test_jwt_auth_contract.py @@ -0,0 +1,144 @@ +"""Exercise the public auth API with real signed tokens and Django requests.""" + +from datetime import timedelta +from time import time +from unittest.mock import patch + +import jwt +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory, TestCase, override_settings + +from config.jwt_auth.backends import JSONWebTokenBackend +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired +from config.jwt_auth.settings import jwt_settings +from config.jwt_auth.shortcuts import get_token +from config.jwt_auth.utils import get_credentials, get_payload, get_user_by_payload +from opencontractserver.utils.ids import to_global_id + + +class JWTAuthContractTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="jwt_contract", password="contract-password" + ) + + def graphql(self, query, variables=None, **headers): + response = self.client.post( + "/graphql/", + {"query": query, "variables": variables or {}}, + content_type="application/json", + **headers, + ) + self.assertEqual(response.status_code, 200, response.content) + return response.json() + + def test_login_verify_and_authenticated_http_request(self): + # No refresh-token model is installed by default; selecting only an + # access token must still work without evaluating a lazy refresh token. + with patch( + "config.jwt_auth.refresh_token.shortcuts.create_refresh_token" + ) as create_refresh: + response = self.graphql( + 'mutation { tokenAuth(username: "jwt_contract", ' + 'password: "contract-password") { token payload refreshExpiresIn ' + "user { id username } } }" + ) + create_refresh.assert_not_called() + self.assertNotIn("errors", response) + auth = response["data"]["tokenAuth"] + self.assertEqual(auth["user"]["id"], to_global_id("UserType", self.user.pk)) + claims = jwt.decode(auth["token"], settings.SECRET_KEY, algorithms=["HS256"]) + self.assertEqual(claims, auth["payload"]) + self.assertEqual(set(claims), {"username", "exp", "origIat"}) + self.assertEqual(claims["username"], self.user.username) + self.assertEqual(claims["exp"] - claims["origIat"], 7 * 24 * 60 * 60) + verified = self.graphql( + "mutation($token: String!) { verifyToken(token: $token) { payload } }", + {"token": auth["token"]}, + ) + self.assertEqual(verified["data"]["verifyToken"]["payload"], claims) + me = self.graphql( + "{ me { id username } }", HTTP_AUTHORIZATION=f"Bearer {auth['token']}" + ) + self.assertNotIn("errors", me) + self.assertEqual(me["data"]["me"]["username"], self.user.username) + + def test_tokens_with_legacy_claims_still_authenticate(self): + # Issue independently of our helpers, using the existing claim layout. + token = jwt.encode( + {"username": self.user.username, "exp": int(time()) + 300, "origIat": 1}, + settings.SECRET_KEY, + algorithm="HS256", + ) + request = RequestFactory().get( + "/graphql/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + request.user = AnonymousUser() + self.assertEqual(JSONWebTokenBackend().authenticate(request), self.user) + + def test_expired_and_invalid_signature_keep_http_errors(self): + expired = get_token(self.user, exp=int(time()) - 60) + bad_signature = jwt.encode( + {"username": self.user.username, "exp": int(time()) + 60}, + "a-different-test-signing-key-with-adequate-length", + algorithm="HS256", + ) + for token, message in [ + (expired, "Signature has expired"), + (bad_signature, "Error decoding signature"), + ]: + with self.subTest(message=message): + result = self.graphql( + "{ me { id } }", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + self.assertEqual( + result, {"data": None, "errors": [{"message": message}]} + ) + + def test_failed_login_and_missing_refresh_errors(self): + result = self.graphql( + 'mutation { tokenAuth(username: "jwt_contract", password: "wrong") ' + "{ token } }" + ) + self.assertEqual( + result["errors"][0]["message"], "Please enter valid credentials" + ) + result = self.graphql("mutation { refreshToken { token } }") + self.assertEqual(result["errors"][0]["message"], "Refresh token is required") + + def test_disabled_user_and_missing_username_keep_errors(self): + self.user.is_active = False + self.user.save(update_fields=["is_active"]) + with self.assertRaisesMessage(JSONWebTokenError, "User is disabled"): + get_user_by_payload({"username": self.user.username}) + with self.assertRaisesMessage(JSONWebTokenError, "Invalid payload"): + get_user_by_payload({}) + + def test_credentials_precedence_and_cookie_fallback(self): + request = RequestFactory().get("/", HTTP_AUTHORIZATION="bEaReR header-token") + request.COOKIES["JWT"] = "cookie-token" + self.assertEqual(get_credentials(request), "header-token") + request.META["HTTP_AUTHORIZATION"] = "not a bearer header" + self.assertEqual(get_credentials(request), "cookie-token") + with override_settings(GRAPHQL_JWT={"JWT_ALLOW_ARGUMENT": True}): + self.assertEqual( + get_credentials(request, input={"token": "argument-token"}), + "argument-token", + ) + + def test_settings_overrides_and_legacy_handler_paths(self): + with override_settings( + SECRET_KEY="temporary-signing-key-with-sufficient-length", + GRAPHQL_JWT={ + "JWT_PAYLOAD_HANDLER": "graphql_jwt.utils.jwt_payload", + "JWT_EXPIRATION_DELTA": timedelta(seconds=-60), + "JWT_VERIFY_EXPIRATION": True, + }, + ): + self.assertEqual(jwt_settings.JWT_SECRET_KEY, settings.SECRET_KEY) + token = get_token(self.user) + with self.assertRaises(JSONWebTokenExpired): + get_payload(token) + self.assertEqual(jwt_settings.JWT_SECRET_KEY, settings.SECRET_KEY) diff --git a/opencontractserver/tests/test_jwt_refresh_contract.py b/opencontractserver/tests/test_jwt_refresh_contract.py new file mode 100644 index 0000000000..c527fe656e --- /dev/null +++ b/opencontractserver/tests/test_jwt_refresh_contract.py @@ -0,0 +1,141 @@ +"""Optional refresh storage retains its table, migration history, and API.""" + +from contextlib import ExitStack +from datetime import timedelta +from typing import TYPE_CHECKING +from unittest.mock import patch + +from django.apps import apps +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import connection +from django.db.migrations.loader import MigrationLoader +from django.test import TransactionTestCase, override_settings +from django.utils import timezone + +from config.jwt_auth.refresh_token.shortcuts import ( + create_refresh_token, + get_refresh_token, +) + +if TYPE_CHECKING: + from config.jwt_auth.refresh_token.models import RefreshToken + + +@override_settings( + INSTALLED_APPS=[*settings.INSTALLED_APPS, "config.jwt_auth.refresh_token"] +) +class RefreshTokenContractTests(TransactionTestCase): + model: type["RefreshToken"] + + @classmethod + def setUpClass(cls): + # Installing the optional app rebuilds Django's registry. Existing + # apps are already initialized: rerunning their ready() hooks would + # reconnect processing signals disabled by the session test fixtures + # and leak background jobs into subsequent auth/WebSocket tests. + with ExitStack() as initialized_apps: + for app in apps.get_app_configs(): + initialized_apps.enter_context(patch.object(type(app), "ready")) + super().setUpClass() + from config.jwt_auth.refresh_token.models import RefreshToken + + cls.model = RefreshToken + # This app is optional and absent when pytest initially creates its DB. + with connection.schema_editor() as editor: + editor.create_model(cls.model) + + @classmethod + def tearDownClass(cls): + try: + with connection.schema_editor() as editor: + editor.delete_model(cls.model) + finally: + super().tearDownClass() + + def setUp(self): + self.user = get_user_model().objects.create_user( + username="refresh_contract", password="contract-password" + ) + + def graphql(self, query, variables=None): + response = self.client.post( + "/graphql/", + {"query": query, "variables": variables or {}}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200, response.content) + return response.json() + + def refresh(self, token): + return self.graphql( + "mutation($token: String!) { refreshToken(refreshToken: $token) " + "{ token refreshToken payload refreshExpiresIn } }", + {"token": token}, + ) + + def test_storage_and_migration_identity_are_preserved(self): + self.assertEqual(self.model._meta.label, "refresh_token.RefreshToken") + self.assertEqual(self.model._meta.db_table, "refresh_token_refreshtoken") + state = MigrationLoader(connection).project_state( + [("refresh_token", "0002_auto_20190130_0900")] + ) + old_model = state.apps.get_model("refresh_token", "RefreshToken") + self.assertEqual( + {f.column: f.get_internal_type() for f in old_model._meta.fields}, + {f.column: f.get_internal_type() for f in self.model._meta.fields}, + ) + # A row persisted with the existing layout remains usable. + old_model.objects.create(user_id=self.user.pk, token="previously-issued-token") + token = get_refresh_token("previously-issued-token") + self.assertEqual(token.user_id, self.user.pk) + self.assertNotIn("errors", self.refresh(token.token)) + + def test_login_selecting_refresh_token_persists_then_rotates_it(self): + result = self.graphql( + 'mutation { tokenAuth(username: "refresh_contract", ' + 'password: "contract-password") { token refreshToken } }' + ) + self.assertNotIn("errors", result) + token = result["data"]["tokenAuth"]["refreshToken"] + self.assertEqual(len(token), 40) + self.assertEqual(self.model.objects.count(), 1) + refreshed = self.refresh(token) + self.assertNotIn("errors", refreshed) + issued = refreshed["data"]["refreshToken"] + self.assertNotEqual(issued["refreshToken"], token) + self.assertEqual(issued["payload"]["username"], self.user.username) + self.assertEqual(self.model.objects.count(), 2) + # Rotation has historically retained the old token until it expires + # or is revoked explicitly; do not silently change that behavior. + self.assertIsNone(get_refresh_token(token).revoked) + + def test_unknown_revoked_and_expired_token_errors(self): + unknown = self.refresh("unknown") + self.assertEqual(unknown["errors"][0]["message"], "Invalid refresh token") + revoked = create_refresh_token(self.user) + revoked.revoke() + self.assertEqual( + self.refresh(revoked.token)["errors"][0]["message"], "Invalid refresh token" + ) + expired = create_refresh_token(self.user) + self.model.objects.filter(pk=expired.pk).update( + created=timezone.now() - timedelta(days=15) + ) + self.assertEqual( + self.refresh(expired.token)["errors"][0]["message"], + "Refresh token is expired", + ) + + def test_reuse_setting_rotates_existing_record(self): + token = create_refresh_token(self.user) + old_value = token.token + with override_settings( + GRAPHQL_JWT={**settings.GRAPHQL_JWT, "JWT_REUSE_REFRESH_TOKENS": True} + ): + result = self.refresh(old_value) + self.assertNotIn("errors", result) + token.refresh_from_db() + self.assertEqual(token.token, result["data"]["refreshToken"]["refreshToken"]) + self.assertNotEqual(token.token, old_value) + self.assertEqual(self.model.objects.count(), 1) diff --git a/opencontractserver/tests/test_jwt_utils.py b/opencontractserver/tests/test_jwt_utils.py index 7d81421ca4..dc0c1848d3 100644 --- a/opencontractserver/tests/test_jwt_utils.py +++ b/opencontractserver/tests/test_jwt_utils.py @@ -15,9 +15,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings -from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired from rest_framework.test import APIRequestFactory +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired from config.jwt_utils import ( _validate_auth0_token, _validate_graphql_jwt_token, @@ -79,8 +79,8 @@ def setUpTestData(cls): is_active=False, ) - @patch("graphql_jwt.utils.get_user_by_payload") - @patch("graphql_jwt.utils.get_payload") + @patch("config.jwt_auth.utils.get_user_by_payload") + @patch("config.jwt_auth.utils.get_payload") def test_valid_token_returns_user(self, mock_get_payload, mock_get_user): """Valid token should return the authenticated user.""" mock_get_payload.return_value = {"username": "graphql_jwt_user"} @@ -91,7 +91,7 @@ def test_valid_token_returns_user(self, mock_get_payload, mock_get_user): self.assertEqual(result, self.user) mock_get_payload.assert_called_once_with("valid_token") - @patch("graphql_jwt.utils.get_payload") + @patch("config.jwt_auth.utils.get_payload") def test_expired_token_raises_exception(self, mock_get_payload): """Expired token should raise JSONWebTokenExpired.""" mock_get_payload.side_effect = JSONWebTokenExpired() @@ -99,7 +99,7 @@ def test_expired_token_raises_exception(self, mock_get_payload): with self.assertRaises(JSONWebTokenExpired): _validate_graphql_jwt_token("expired_token") - @patch("graphql_jwt.utils.get_payload") + @patch("config.jwt_auth.utils.get_payload") def test_invalid_token_raises_exception(self, mock_get_payload): """Invalid token should raise JSONWebTokenError.""" mock_get_payload.side_effect = JSONWebTokenError("Invalid token") @@ -107,8 +107,8 @@ def test_invalid_token_raises_exception(self, mock_get_payload): with self.assertRaises(JSONWebTokenError): _validate_graphql_jwt_token("invalid_token") - @patch("graphql_jwt.utils.get_user_by_payload") - @patch("graphql_jwt.utils.get_payload") + @patch("config.jwt_auth.utils.get_user_by_payload") + @patch("config.jwt_auth.utils.get_payload") def test_user_not_found_raises_exception(self, mock_get_payload, mock_get_user): """Token for non-existent user should raise JSONWebTokenError.""" mock_get_payload.return_value = {"username": "nonexistent"} @@ -119,8 +119,8 @@ def test_user_not_found_raises_exception(self, mock_get_payload, mock_get_user): self.assertIn("User not found", str(context.exception)) - @patch("graphql_jwt.utils.get_user_by_payload") - @patch("graphql_jwt.utils.get_payload") + @patch("config.jwt_auth.utils.get_user_by_payload") + @patch("config.jwt_auth.utils.get_payload") def test_inactive_user_raises_exception(self, mock_get_payload, mock_get_user): """Token for inactive user should raise JSONWebTokenError.""" mock_get_payload.return_value = {"username": "inactive_user"} diff --git a/opencontractserver/tests/test_label_mutations.py b/opencontractserver/tests/test_label_mutations.py index e5890a1abc..32ec062bb5 100644 --- a/opencontractserver/tests/test_label_mutations.py +++ b/opencontractserver/tests/test_label_mutations.py @@ -8,12 +8,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.types.enums import LabelType, PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_leaderboard.py b/opencontractserver/tests/test_leaderboard.py index f6b17fa23f..a6071b084b 100644 --- a/opencontractserver/tests/test_leaderboard.py +++ b/opencontractserver/tests/test_leaderboard.py @@ -10,7 +10,6 @@ from django.contrib.auth import get_user_model from django.core.cache import cache from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -21,6 +20,7 @@ UserReputation, ) from opencontractserver.corpuses.models import Corpus +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_mention_permissions.py b/opencontractserver/tests/test_mention_permissions.py index 9ed0a37dee..3b8cfb68a5 100644 --- a/opencontractserver/tests/test_mention_permissions.py +++ b/opencontractserver/tests/test_mention_permissions.py @@ -727,9 +727,8 @@ class CorpusScopedMentionSearchTestCase(TestCase): def setUp(self): """Create test users, corpuses, documents, and annotations.""" - from graphql_relay import to_global_id - from opencontractserver.annotations.models import Annotation, AnnotationLabel + from opencontractserver.utils.ids import to_global_id self.owner = User.objects.create_user(username="owner", password="test") @@ -986,9 +985,8 @@ class MockContext: def test_document_search_with_invalid_corpus_returns_empty(self): """Document search with invalid corpus_id returns empty results safely.""" - from graphql_relay import to_global_id - from config.graphql import search_queries as _mention_search + from opencontractserver.utils.ids import to_global_id query = _mention_search @@ -1013,9 +1011,8 @@ class MockContext: def test_annotation_search_with_invalid_corpus_returns_empty(self): """Annotation search with invalid corpus_id returns empty results safely.""" - from graphql_relay import to_global_id - from config.graphql import search_queries as _mention_search + from opencontractserver.utils.ids import to_global_id query = _mention_search @@ -1051,9 +1048,8 @@ class AgentMentionCorpusScopingTestCase(TestCase): def setUp(self): """Create test agents and corpuses.""" - from graphql_relay import to_global_id - from opencontractserver.agents.models import AgentConfiguration + from opencontractserver.utils.ids import to_global_id self.owner = User.objects.create_user(username="owner", password="test") diff --git a/opencontractserver/tests/test_mentions.py b/opencontractserver/tests/test_mentions.py index 4dac6a1e24..53a2542e29 100644 --- a/opencontractserver/tests/test_mentions.py +++ b/opencontractserver/tests/test_mentions.py @@ -14,7 +14,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client as GrapheneClient @@ -26,6 +25,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_metadata_columns_graphql.py b/opencontractserver/tests/test_metadata_columns_graphql.py index 5f262b50df..97798d52f0 100644 --- a/opencontractserver/tests/test_metadata_columns_graphql.py +++ b/opencontractserver/tests/test_metadata_columns_graphql.py @@ -1,6 +1,5 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -8,6 +7,7 @@ from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Column, Datacell, Fieldset from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_moderation.py b/opencontractserver/tests/test_moderation.py index e8bce22e9f..d8ac3ae4ff 100644 --- a/opencontractserver/tests/test_moderation.py +++ b/opencontractserver/tests/test_moderation.py @@ -632,7 +632,7 @@ def setUp(self): def test_add_moderator_idor_prevention(self): """Test that AddModeratorMutation prevents corpus enumeration.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_global_id = to_global_id("CorpusType", self.private_corpus.id) user_global_id = to_global_id("UserType", self.moderator_user.id) @@ -691,9 +691,8 @@ def test_add_moderator_idor_prevention(self): def test_remove_moderator_idor_prevention(self): """Test that RemoveModeratorMutation prevents corpus enumeration.""" - from graphql_relay import to_global_id - from opencontractserver.conversations.models import CorpusModerator + from opencontractserver.utils.ids import to_global_id # First add a moderator (as owner) CorpusModerator.objects.create( @@ -734,9 +733,8 @@ def test_remove_moderator_idor_prevention(self): def test_update_moderator_permissions_idor_prevention(self): """Test that UpdateModeratorPermissionsMutation prevents corpus enumeration.""" - from graphql_relay import to_global_id - from opencontractserver.conversations.models import CorpusModerator + from opencontractserver.utils.ids import to_global_id # First add a moderator (as owner) CorpusModerator.objects.create( @@ -826,7 +824,7 @@ def setUp(self): def test_delete_thread_mutation(self): """Test deleting a thread via GraphQL mutation.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id conv_global_id = to_global_id("ConversationType", self.conversation.id) @@ -870,7 +868,7 @@ def test_delete_thread_mutation(self): def test_delete_thread_permission_denied(self): """Test that non-moderators cannot delete threads.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id conv_global_id = to_global_id("ConversationType", self.conversation.id) @@ -897,7 +895,7 @@ def test_delete_thread_permission_denied(self): def test_restore_thread_mutation(self): """Test restoring a deleted thread via GraphQL mutation.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # First delete the thread self.conversation.soft_delete_thread(self.owner) @@ -971,7 +969,7 @@ def setUp(self): def test_rollback_lock_action(self): """Test rolling back a lock action unlocks the thread.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Lock the thread lock_action = self.conversation.lock(self.owner, reason="Locked for test") @@ -1017,7 +1015,7 @@ def test_rollback_lock_action(self): def test_rollback_pin_action(self): """Test rolling back a pin action unpins the thread.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Pin the thread pin_action = self.conversation.pin(self.owner, reason="Pinned for test") @@ -1056,7 +1054,7 @@ def test_rollback_pin_action(self): def test_rollback_delete_thread_action(self): """Test rolling back a delete action restores the thread.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Delete the thread delete_action = self.conversation.soft_delete_thread( @@ -1089,7 +1087,7 @@ def test_rollback_delete_thread_action(self): def test_rollback_non_rollbackable_action(self): """Test that already-rolled-back actions cannot be rolled back.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Create an unlock action (which is a rollback of lock, not rollbackable itself) self.conversation.lock(self.owner) @@ -1120,7 +1118,7 @@ def test_rollback_non_rollbackable_action(self): def test_rollback_permission_denied(self): """Test that non-moderators cannot rollback actions.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id lock_action = self.conversation.lock(self.owner) action_global_id = to_global_id("ModerationActionType", lock_action.id) @@ -1148,7 +1146,7 @@ def test_rollback_permission_denied(self): def test_rollback_nonexistent_action(self): """Test rolling back a non-existent action.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id fake_action_id = to_global_id("ModerationActionType", 999999) @@ -1207,7 +1205,7 @@ def setUp(self): def test_moderation_actions_query(self): """Test querying moderation actions for a corpus.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_global_id = to_global_id("CorpusType", self.corpus.id) @@ -1243,7 +1241,7 @@ def test_moderation_actions_query(self): def test_moderation_action_query(self): """Test querying a single moderation action by ID.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id action_global_id = to_global_id("ModerationActionType", self.lock_action.id) @@ -1272,7 +1270,7 @@ def test_moderation_action_query(self): def test_moderation_metrics_query(self): """Test querying moderation metrics for a corpus.""" - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id corpus_global_id = to_global_id("CorpusType", self.corpus.id) @@ -1374,7 +1372,7 @@ def setUp(self): self.client = Client(schema) def _query(self, action_pk: int, user) -> dict: - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id action_global_id = to_global_id("ModerationActionType", action_pk) return self.client.execute( diff --git a/opencontractserver/tests/test_note_tree.py b/opencontractserver/tests/test_note_tree.py index 864f2859e2..89109e73ef 100644 --- a/opencontractserver/tests/test_note_tree.py +++ b/opencontractserver/tests/test_note_tree.py @@ -1,7 +1,6 @@ import logging from django.contrib.auth import get_user_model -from graphql_relay import from_global_id, to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -13,6 +12,7 @@ ) from opencontractserver.corpuses.models import Corpus from opencontractserver.tests.base import BaseFixtureTestCase +from opencontractserver.utils.ids import from_global_id, to_global_id User = get_user_model() logger = logging.getLogger(__name__) diff --git a/opencontractserver/tests/test_notification_graphql.py b/opencontractserver/tests/test_notification_graphql.py index 9ea8f24ca5..8cf08fbf83 100644 --- a/opencontractserver/tests/test_notification_graphql.py +++ b/opencontractserver/tests/test_notification_graphql.py @@ -14,7 +14,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -27,6 +26,7 @@ Notification, NotificationTypeChoices, ) +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_notification_websocket.py b/opencontractserver/tests/test_notification_websocket.py index be6893eb57..ac32ad6969 100644 --- a/opencontractserver/tests/test_notification_websocket.py +++ b/opencontractserver/tests/test_notification_websocket.py @@ -318,7 +318,7 @@ async def test_user_isolation_idor_prevention(self): user2 = await database_sync_to_async( lambda: User.objects.get(username="testuser2") )() - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token user2_token_str = await database_sync_to_async(get_token)(user2) diff --git a/opencontractserver/tests/test_og_metadata_queries.py b/opencontractserver/tests/test_og_metadata_queries.py index feef1b1d07..8f33dd3fd4 100644 --- a/opencontractserver/tests/test_og_metadata_queries.py +++ b/opencontractserver/tests/test_og_metadata_queries.py @@ -16,7 +16,6 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -28,6 +27,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Extract, Fieldset +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_permanent_deletion.py b/opencontractserver/tests/test_permanent_deletion.py index c3f6108c8c..273bda69de 100644 --- a/opencontractserver/tests/test_permanent_deletion.py +++ b/opencontractserver/tests/test_permanent_deletion.py @@ -17,7 +17,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -42,6 +41,7 @@ restore_document, ) from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_permission_fixes.py b/opencontractserver/tests/test_permission_fixes.py index b6311a7a4a..4563685fc5 100644 --- a/opencontractserver/tests/test_permission_fixes.py +++ b/opencontractserver/tests/test_permission_fixes.py @@ -20,7 +20,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -34,6 +33,7 @@ from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Fieldset from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_personal_corpus.py b/opencontractserver/tests/test_personal_corpus.py index 975444a4fe..310dd53f7f 100644 --- a/opencontractserver/tests/test_personal_corpus.py +++ b/opencontractserver/tests/test_personal_corpus.py @@ -557,7 +557,7 @@ def test_upload_without_corpus_goes_to_personal_corpus(self): # Get the uploaded document doc_id = result["data"]["uploadDocument"]["document"]["id"] - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id _, doc_pk = from_global_id(doc_id) document = Document.objects.get(pk=doc_pk) @@ -577,8 +577,7 @@ def test_upload_without_corpus_goes_to_personal_corpus(self): def test_upload_with_corpus_goes_to_specified_corpus(self): """Upload with addToCorpusId should go to that corpus, not personal.""" - from graphql_relay import to_global_id - + from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) @@ -616,7 +615,7 @@ def test_upload_with_corpus_goes_to_specified_corpus(self): # Get the uploaded document doc_id = result["data"]["uploadDocument"]["document"]["id"] - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id _, doc_pk = from_global_id(doc_id) document = Document.objects.get(pk=doc_pk) @@ -675,7 +674,7 @@ def test_upload_text_file_without_corpus_goes_to_personal_corpus(self): # Get the uploaded document doc_id = result["data"]["uploadDocument"]["document"]["id"] - from graphql_relay import from_global_id + from opencontractserver.utils.ids import from_global_id _, doc_pk = from_global_id(doc_id) document = Document.objects.get(pk=doc_pk) @@ -966,8 +965,7 @@ def setUp(self): def test_cannot_delete_personal_corpus(self): """Deleting a personal corpus via GraphQL should be blocked.""" - from graphql_relay import to_global_id - + from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) @@ -993,8 +991,7 @@ def test_cannot_delete_personal_corpus(self): def test_can_delete_non_personal_corpus(self): """Deleting a non-personal corpus should still work normally.""" - from graphql_relay import to_global_id - + from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( set_permissions_for_obj_to_user, ) diff --git a/opencontractserver/tests/test_query_resolvers.py b/opencontractserver/tests/test_query_resolvers.py index bbdadca796..778d4c0309 100644 --- a/opencontractserver/tests/test_query_resolvers.py +++ b/opencontractserver/tests/test_query_resolvers.py @@ -11,7 +11,6 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -23,6 +22,7 @@ from opencontractserver.conversations.models import ChatMessage, Conversation from opencontractserver.corpuses.models import Corpus, CorpusFolder from opencontractserver.documents.models import Document, DocumentPath +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( PermissionTypes, set_permissions_for_obj_to_user, diff --git a/opencontractserver/tests/test_rate_limiting.py b/opencontractserver/tests/test_rate_limiting.py index b68194e7cc..fafd6b2df3 100644 --- a/opencontractserver/tests/test_rate_limiting.py +++ b/opencontractserver/tests/test_rate_limiting.py @@ -6,7 +6,6 @@ from django.core.cache import cache from django.test import Client as DjangoClient from django.test import TestCase, override_settings -from graphql_relay import to_global_id from config.graphql.ratelimits import ( RateLimits, @@ -14,6 +13,7 @@ ) from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_run_corpus_action.py b/opencontractserver/tests/test_run_corpus_action.py index fc297eae1e..9bcdc6e0af 100644 --- a/opencontractserver/tests/test_run_corpus_action.py +++ b/opencontractserver/tests/test_run_corpus_action.py @@ -1,7 +1,6 @@ """Tests for the RunCorpusAction mutation.""" from django.contrib.auth import get_user_model -from graphql_relay import to_global_id from config.graphql.testing import GraphQLTestCase from opencontractserver.corpuses.models import ( @@ -10,6 +9,7 @@ CorpusActionExecution, ) from opencontractserver.documents.models import Document, DocumentPath +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_schema_parity.py b/opencontractserver/tests/test_schema_parity.py index 6170a85a3a..91054f76f2 100644 --- a/opencontractserver/tests/test_schema_parity.py +++ b/opencontractserver/tests/test_schema_parity.py @@ -69,6 +69,28 @@ def test_schema_matches_golden_sdl(self) -> None: problems: list[str] = [] + for operation in ("query_type", "mutation_type", "subscription_type"): + expected = getattr(golden, operation) + actual = getattr(served, operation) + if getattr(expected, "name", None) != getattr(actual, "name", None): + problems.append(f"{operation}: {expected} vs {actual}") + + def directives(schema): + return { + directive.name: ( + directive.is_repeatable, + frozenset(location.value for location in directive.locations), + { + name: (str(arg.type), _printed_default(arg)) + for name, arg in directive.args.items() + }, + ) + for directive in schema.directives + } + + if directives(golden) != directives(served): + problems.append("directive definitions differ") + gnames = {n for n in golden.type_map if not n.startswith("__")} snames = {n for n in served.type_map if not n.startswith("__")} for n in sorted(gnames - snames): @@ -95,6 +117,17 @@ def test_schema_matches_golden_sdl(self) -> None: ) continue + if isinstance(gt, GraphQLUnionType): + expected_members = {member.name for member in gt.types} + actual_members = { + member.name for member in cast(GraphQLUnionType, st).types + } + if expected_members != actual_members: + problems.append( + f"union {n}: {expected_members} vs {actual_members}" + ) + continue + if isinstance( gt, (GraphQLObjectType, GraphQLInterfaceType, GraphQLInputObjectType) ): @@ -111,6 +144,9 @@ def test_schema_matches_golden_sdl(self) -> None: g, s = gf[fn], sf[fn] if str(g.type) != str(s.type): problems.append(f"{n}.{fn}: type {g.type} vs {s.type}") + if isinstance(gt, GraphQLInputObjectType): + if _printed_default(g) != _printed_default(s): + problems.append(f"{n}.{fn}: input default differs") ga = getattr(g, "args", {}) or {} sa = getattr(s, "args", {}) or {} for an in sorted(set(ga) - set(sa)): @@ -128,9 +164,14 @@ def test_schema_matches_golden_sdl(self) -> None: f"{_printed_default(ga[an])!r} vs {_printed_default(sa[an])!r}" ) - if isinstance(gt, GraphQLObjectType): + if isinstance(gt, (GraphQLObjectType, GraphQLInterfaceType)): gi = {i.name for i in gt.interfaces} - si = {i.name for i in cast(GraphQLObjectType, st).interfaces} + si = { + i.name + for i in cast( + "GraphQLObjectType | GraphQLInterfaceType", st + ).interfaces + } if gi != si: problems.append(f"{n}: interfaces {gi} vs {si}") diff --git a/opencontractserver/tests/test_security_hardening.py b/opencontractserver/tests/test_security_hardening.py index 1cccae9c03..736bd17350 100644 --- a/opencontractserver/tests/test_security_hardening.py +++ b/opencontractserver/tests/test_security_hardening.py @@ -13,7 +13,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings -from graphql_relay import to_global_id from rest_framework.test import APIClient from config.graphql.schema import schema @@ -22,6 +21,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() @@ -1211,9 +1211,7 @@ def _validate_query(self, query_str, max_depth): document = parse(query_str) from config.graphql.security import DepthLimitValidationRule - errors = validate( - schema.graphql_schema, document, [DepthLimitValidationRule] - ) + errors = validate(schema._schema, document, [DepthLimitValidationRule]) return errors finally: security_module.GRAPHQL_MAX_QUERY_DEPTH = original_depth @@ -1974,11 +1972,10 @@ def test_drf_mutation_obj_id_uses_graphene_type_name_not_metaclass(self): ``graphene_model.__class__.__name__`` (the metaclass name like ``"SubclassWithMeta_Meta"``). """ - from graphql_relay import from_global_id - from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus + from opencontractserver.utils.ids import from_global_id user = User.objects.create_user(username="objIdRegressionUser", password="x") @@ -2026,7 +2023,7 @@ def test_unknown_argument_is_rejected_by_served_rules(self): document = parse( 'query { analyses(bogusArgument: "x") { edges { node { id } } } }' ) - errors = validate(schema.graphql_schema, document, validation_rules) + errors = validate(schema._schema, document, validation_rules) self.assertTrue( any("Unknown argument" in str(e) for e in errors), f"spec validation is OFF on the served endpoint: {errors!r}", @@ -2038,7 +2035,7 @@ def test_unknown_field_is_rejected_by_served_rules(self): from config.graphql.schema import schema, validation_rules document = parse("query { definitelyNotARealField }") - errors = validate(schema.graphql_schema, document, validation_rules) + errors = validate(schema._schema, document, validation_rules) self.assertTrue(errors) def test_depth_limit_still_enforced_alongside_spec_rules(self): @@ -2051,7 +2048,7 @@ def test_depth_limit_still_enforced_alongside_spec_rules(self): for _ in range(20): inner = f"parent {{ {inner} }}" document = parse(f"query {{ corpuses {{ edges {{ node {{ {inner} }} }} }} }}") - errors = validate(schema.graphql_schema, document, validation_rules) + errors = validate(schema._schema, document, validation_rules) self.assertTrue(any("depth" in str(e).lower() for e in errors)) diff --git a/opencontractserver/tests/test_semantic_search_graphql.py b/opencontractserver/tests/test_semantic_search_graphql.py index 3a437f6091..156b318189 100644 --- a/opencontractserver/tests/test_semantic_search_graphql.py +++ b/opencontractserver/tests/test_semantic_search_graphql.py @@ -14,7 +14,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -22,6 +21,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.pipeline.utils import get_default_embedder_path +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import ( PermissionTypes, set_permissions_for_obj_to_user, diff --git a/opencontractserver/tests/test_single_doc_analyzer_and_extract.py b/opencontractserver/tests/test_single_doc_analyzer_and_extract.py index e2188c1e34..1aaa1f7e25 100644 --- a/opencontractserver/tests/test_single_doc_analyzer_and_extract.py +++ b/opencontractserver/tests/test_single_doc_analyzer_and_extract.py @@ -11,7 +11,6 @@ from django.db.models.signals import post_save from django.test import TestCase from django.test.client import Client as DjangoClient -from graphql_relay import from_global_id, to_global_id from rest_framework.test import APIClient from config.graphql.schema import schema @@ -33,6 +32,7 @@ create_mock_submission_response, generate_random_analyzer_return_values, ) +from opencontractserver.utils.ids import from_global_id, to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_singular_node_idor.py b/opencontractserver/tests/test_singular_node_idor.py index a98db43505..c582c99b5b 100644 --- a/opencontractserver/tests/test_singular_node_idor.py +++ b/opencontractserver/tests/test_singular_node_idor.py @@ -27,12 +27,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.core.relay import get_registry_entry from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.users.models import User, UserExport +from opencontractserver.utils.ids import to_global_id # ``get_user_model()`` returns the same concrete ``User`` imported above; the # alias keeps the ``User.objects`` calls below reading like the rest of the diff --git a/opencontractserver/tests/test_smart_label_mutations.py b/opencontractserver/tests/test_smart_label_mutations.py index 44ea1528a0..43e19d9d56 100644 --- a/opencontractserver/tests/test_smart_label_mutations.py +++ b/opencontractserver/tests/test_smart_label_mutations.py @@ -6,13 +6,13 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.annotations.models import AnnotationLabel, LabelSet from opencontractserver.corpuses.models import Corpus from opencontractserver.types.enums import LabelType, PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_stats.py b/opencontractserver/tests/test_stats.py index 00ea3e7525..d4f202534f 100644 --- a/opencontractserver/tests/test_stats.py +++ b/opencontractserver/tests/test_stats.py @@ -1,7 +1,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -11,6 +10,7 @@ from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Extract, Fieldset from opencontractserver.feedback.models import UserFeedback +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_structural_annotations_graphql_backwards_compat.py b/opencontractserver/tests/test_structural_annotations_graphql_backwards_compat.py index 5379929376..705b4a1a68 100644 --- a/opencontractserver/tests/test_structural_annotations_graphql_backwards_compat.py +++ b/opencontractserver/tests/test_structural_annotations_graphql_backwards_compat.py @@ -19,7 +19,6 @@ from django.core.management import call_command from django.test import TestCase from django.utils import timezone -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -31,6 +30,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.types.enums import LabelType, PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_token_expiration.py b/opencontractserver/tests/test_token_expiration.py index c35e1d53f3..b57b48b304 100644 --- a/opencontractserver/tests/test_token_expiration.py +++ b/opencontractserver/tests/test_token_expiration.py @@ -14,9 +14,9 @@ from channels.testing import WebsocketCommunicator from django.contrib.auth import get_user_model from django.test import TestCase -from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired from config.graphql_auth0_auth.backends import Auth0RemoteUserJSONWebTokenBackend +from config.jwt_auth.exceptions import JSONWebTokenError, JSONWebTokenExpired from config.websocket.middleware import ( WS_AUTH_SUBPROTOCOL, WS_CLOSE_TOKEN_EXPIRED, @@ -42,7 +42,7 @@ def setUp(self): self.mock_request = mock.MagicMock() self.mock_request._jwt_token_auth = False - @mock.patch("config.graphql_auth0_auth.backends.graphql_jwt.utils.get_credentials") + @mock.patch("config.graphql_auth0_auth.backends.jwt_utils.get_credentials") @mock.patch("config.graphql_auth0_auth.backends.get_user_by_token") def test_expired_token_raises_exception( self, mock_get_user_by_token, mock_get_credentials @@ -57,7 +57,7 @@ def test_expired_token_raises_exception( with self.assertRaises(JSONWebTokenExpired): self.backend.authenticate(request=self.mock_request) - @mock.patch("config.graphql_auth0_auth.backends.graphql_jwt.utils.get_credentials") + @mock.patch("config.graphql_auth0_auth.backends.jwt_utils.get_credentials") @mock.patch("config.graphql_auth0_auth.backends.get_user_by_token") def test_other_jwt_errors_return_none( self, mock_get_user_by_token, mock_get_credentials @@ -72,7 +72,7 @@ def test_other_jwt_errors_return_none( result = self.backend.authenticate(request=self.mock_request) self.assertIsNone(result) - @mock.patch("config.graphql_auth0_auth.backends.graphql_jwt.utils.get_credentials") + @mock.patch("config.graphql_auth0_auth.backends.jwt_utils.get_credentials") @mock.patch("config.graphql_auth0_auth.backends.get_user_by_token") def test_valid_token_returns_user( self, mock_get_user_by_token, mock_get_credentials @@ -88,7 +88,7 @@ def test_valid_token_returns_user( result = self.backend.authenticate(request=self.mock_request) self.assertEqual(result, mock_user) - @mock.patch("config.graphql_auth0_auth.backends.graphql_jwt.utils.get_credentials") + @mock.patch("config.graphql_auth0_auth.backends.jwt_utils.get_credentials") def test_no_token_returns_none(self, mock_get_credentials): """ Verify that when no token is provided, None is returned (anonymous access). diff --git a/opencontractserver/tests/test_unified_agent_consumer_delegation.py b/opencontractserver/tests/test_unified_agent_consumer_delegation.py index 491871d0ec..4c369be7cd 100644 --- a/opencontractserver/tests/test_unified_agent_consumer_delegation.py +++ b/opencontractserver/tests/test_unified_agent_consumer_delegation.py @@ -23,7 +23,6 @@ from channels.db import database_sync_to_async from channels.testing import WebsocketCommunicator from django.test.utils import override_settings -from graphql_relay import to_global_id from config.websocket.middleware import WS_AUTH_SUBPROTOCOL from opencontractserver.agents.models import AgentConfiguration @@ -39,6 +38,7 @@ FinalEvent, ) from opencontractserver.tests.base import WebsocketFixtureBaseTestCase +from opencontractserver.utils.ids import to_global_id logger = logging.getLogger(__name__) diff --git a/opencontractserver/tests/test_url_annotation.py b/opencontractserver/tests/test_url_annotation.py index 63f346ac9d..93fb464ca2 100644 --- a/opencontractserver/tests/test_url_annotation.py +++ b/opencontractserver/tests/test_url_annotation.py @@ -19,7 +19,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client @@ -33,6 +32,7 @@ from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/test_usage_caps.py b/opencontractserver/tests/test_usage_caps.py index f89e31f223..6c1566675e 100644 --- a/opencontractserver/tests/test_usage_caps.py +++ b/opencontractserver/tests/test_usage_caps.py @@ -4,13 +4,13 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.test import TestCase -from graphql_relay import to_global_id from config.graphql.schema import schema from config.graphql.testing import Client from opencontractserver.corpuses.models import Corpus from opencontractserver.tests import fixtures from opencontractserver.utils.files import base_64_encode_bytes +from opencontractserver.utils.ids import to_global_id User = get_user_model() diff --git a/opencontractserver/tests/test_user_display_name.py b/opencontractserver/tests/test_user_display_name.py index a3f42780a4..245abb1474 100644 --- a/opencontractserver/tests/test_user_display_name.py +++ b/opencontractserver/tests/test_user_display_name.py @@ -20,7 +20,10 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from config.graphql.user_types import UserType +from config.graphql.user_types import ( + _resolve_UserType_display_name, + _resolve_UserType_email, +) User = get_user_model() @@ -47,13 +50,13 @@ def _resolve(user) -> str: every call in a self-view ``info`` so those assertions still target the same logic after the privacy gate was added. """ - return UserType.resolve_display_name(user, _FakeInfo(user)) + return _resolve_UserType_display_name(user, _FakeInfo(user)) def _resolve_as_other(target, viewer) -> str: """Resolve ``displayName`` as ``viewer`` looking at ``target`` — the cross-user (non-self) branch. Always returns slug / redacted handle.""" - return UserType.resolve_display_name(target, _FakeInfo(viewer)) + return _resolve_UserType_display_name(target, _FakeInfo(viewer)) def _resolve_email(user, info) -> Optional[str]: @@ -62,7 +65,7 @@ def _resolve_email(user, info) -> Optional[str]: ``UserType`` is a ``DjangoObjectType`` so its ``self`` is the user model at runtime — the cast keeps mypy happy without a per-call ignore. """ - return UserType.resolve_email(user, info) + return _resolve_UserType_email(user, info) class DisplayNameResolverTestCase(TestCase): diff --git a/opencontractserver/tests/test_user_handle.py b/opencontractserver/tests/test_user_handle.py index 18f98efeeb..70ab01e115 100644 --- a/opencontractserver/tests/test_user_handle.py +++ b/opencontractserver/tests/test_user_handle.py @@ -22,6 +22,7 @@ from config.graphql.schema import schema from config.graphql.testing import Client +from config.graphql.user_types import _resolve_UserType_display_name from opencontractserver.users.handle_generator import ( PLAIN_ATTEMPTS, SUFFIXED_ATTEMPTS, @@ -327,9 +328,8 @@ def __init__(self, user): def _resolve_for(cls, user) -> str: # Calling the resolver directly avoids depending on a `me`-style query # we may not control, while still exercising the production code path. - from config.graphql.user_types import UserType - return UserType.resolve_display_name(user, info=cls._SelfInfo(user)) + return _resolve_UserType_display_name(user, info=cls._SelfInfo(user)) def test_name_takes_priority(self): user = User.objects.create_user( diff --git a/opencontractserver/tests/test_user_mutations_rate_limiting.py b/opencontractserver/tests/test_user_mutations_rate_limiting.py index 2c2e77a8f1..4ecaa7ec57 100644 --- a/opencontractserver/tests/test_user_mutations_rate_limiting.py +++ b/opencontractserver/tests/test_user_mutations_rate_limiting.py @@ -24,9 +24,9 @@ from django.contrib.auth import get_user_model from django.core.cache import cache from django.test import TestCase, override_settings -from graphql_jwt.exceptions import JSONWebTokenError from config.graphql.user_mutations import m_token_auth, m_update_me +from config.jwt_auth.exceptions import JSONWebTokenError from config.ratelimit.decorators import RateLimitExceeded from config.ratelimit.rates import parse_rate diff --git a/opencontractserver/tests/test_user_privacy.py b/opencontractserver/tests/test_user_privacy.py index 0a2347b85b..e045b9304e 100644 --- a/opencontractserver/tests/test_user_privacy.py +++ b/opencontractserver/tests/test_user_privacy.py @@ -34,6 +34,7 @@ from config.graphql.schema import schema from config.graphql.testing import Client +from config.graphql.user_types import _resolve_UserType_display_name from opencontractserver.corpuses.models import Corpus User = get_user_model() @@ -400,7 +401,6 @@ def test_non_self_view_falls_back_to_redacted_handle(self) -> None: # GraphQL layer runs, just without the schema indirection. Lets # us exercise the redacted-handle branch without relying on a # query that would itself need a slug to find the user. - from config.graphql.user_types import UserType class _Info: def __init__(self, ctx): @@ -409,7 +409,7 @@ def __init__(self, ctx): # ``UserType.resolve_display_name`` is typed as a method on the # graphene type, but at runtime ``self`` is the underlying Django # ``User`` instance — that's how DjangoObjectType binds resolvers. - display = UserType.resolve_display_name(target, _Info(_Ctx(viewer))) # type: ignore[arg-type] + display = _resolve_UserType_display_name(target, _Info(_Ctx(viewer))) # type: ignore[arg-type] # Stable redacted handle: never returns the username (which could # be an OAuth ``sub``) and never returns ``""`` or ``None``. self.assertTrue(display.startswith("user_")) diff --git a/opencontractserver/tests/test_versioning_paths_audit.py b/opencontractserver/tests/test_versioning_paths_audit.py index 05d88efcef..962613f79e 100644 --- a/opencontractserver/tests/test_versioning_paths_audit.py +++ b/opencontractserver/tests/test_versioning_paths_audit.py @@ -27,8 +27,13 @@ from django.test import TestCase from django.test.utils import CaptureQueriesContext from django.utils import timezone -from graphql_relay import to_global_id +from config.graphql.document_types import ( + _resolve_DocumentType_has_version_history, + _resolve_DocumentType_last_modified, + _resolve_DocumentType_version_history, + _resolve_DocumentType_version_number, +) from opencontractserver.corpuses.models import Corpus, CorpusFolder from opencontractserver.corpuses.services import ( DocumentLifecycleService, @@ -45,6 +50,7 @@ restore_document, ) from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() @@ -424,48 +430,40 @@ def setUp(self): ) self.corpus_gid = to_global_id("CorpusType", self.corpus.pk) - def _doc_type(self): - from config.graphql.document_types import DocumentType - - return DocumentType - def test_version_number_and_last_modified_share_one_query(self): # M4: both resolvers read the same current path; with the request cache # they cost a single query total (was 2N before). - DocumentType = self._doc_type() info = _fake_info(self.user) with CaptureQueriesContext(connection) as ctx: - vnum = DocumentType.resolve_version_number( + vnum = _resolve_DocumentType_version_number( self.doc_v2, info, self.corpus_gid ) - DocumentType.resolve_last_modified(self.doc_v2, info, self.corpus_gid) + _resolve_DocumentType_last_modified(self.doc_v2, info, self.corpus_gid) self.assertEqual(vnum, 2) self.assertEqual(len(ctx.captured_queries), 1) def test_has_version_history_zero_queries(self): # L1: parent_id check must not fetch the parent row. - DocumentType = self._doc_type() info = _fake_info(self.user) with CaptureQueriesContext(connection) as ctx: - has_history = DocumentType.resolve_has_version_history(self.doc_v2, info) + has_history = _resolve_DocumentType_has_version_history(self.doc_v2, info) self.assertTrue(has_history) self.assertEqual(len(ctx.captured_queries), 0) with CaptureQueriesContext(connection) as ctx: - no_history = DocumentType.resolve_has_version_history(self.doc_v1, info) + no_history = _resolve_DocumentType_has_version_history(self.doc_v1, info) self.assertFalse(no_history) self.assertEqual(len(ctx.captured_queries), 0) def test_version_history_scoped_to_visible_user(self): # M5: the owner sees both versions; an unauthorised user sees none. - DocumentType = self._doc_type() - owner_history = DocumentType.resolve_version_history( + owner_history = _resolve_DocumentType_version_history( self.doc_v2, _fake_info(self.user) ) self.assertEqual(len(owner_history.versions), 2) - other_history = DocumentType.resolve_version_history( + other_history = _resolve_DocumentType_version_history( self.doc_v2, _fake_info(self.other) ) self.assertEqual( diff --git a/opencontractserver/tests/test_voting_mutations_graphql.py b/opencontractserver/tests/test_voting_mutations_graphql.py index 64ec34bf3e..30d657cd75 100644 --- a/opencontractserver/tests/test_voting_mutations_graphql.py +++ b/opencontractserver/tests/test_voting_mutations_graphql.py @@ -109,7 +109,7 @@ def test_vote_message_upvote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -147,7 +147,7 @@ def test_vote_message_downvote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -186,7 +186,7 @@ def test_vote_message_change_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -214,7 +214,7 @@ def test_vote_own_message_fails(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -244,7 +244,7 @@ def test_vote_invalid_vote_type(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -273,7 +273,7 @@ def test_vote_without_permission(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -311,7 +311,7 @@ def test_remove_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -342,7 +342,7 @@ def test_remove_nonexistent_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -366,7 +366,7 @@ def test_vote_message_not_found(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Use a non-existent message ID message_id = to_global_id("MessageType", 99999) @@ -429,7 +429,7 @@ def test_user_vote_field_returns_current_user_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) variables = {"messageId": message_id, "voteType": "upvote"} @@ -459,7 +459,7 @@ def test_user_vote_field_returns_null_for_no_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) @@ -501,7 +501,7 @@ def test_user_vote_field_updates_after_vote_change(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) variables = {"messageId": message_id, "voteType": "downvote"} @@ -543,7 +543,7 @@ def test_user_vote_field_null_after_remove_vote(self): } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id message_id = to_global_id("MessageType", self.message.id) variables = {"messageId": message_id} @@ -581,7 +581,7 @@ def test_idor_protection_vote_message_same_error_for_not_found_and_no_permission } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Test 1: User without permission trying to vote on existing message existing_message_id = to_global_id("MessageType", self.message.id) @@ -640,7 +640,7 @@ def test_idor_protection_remove_vote_same_error_for_not_found_and_no_permission( } """ - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id # Test 1: User without permission on existing message existing_message_id = to_global_id("MessageType", self.message.id) diff --git a/opencontractserver/tests/test_websocket_auth.py b/opencontractserver/tests/test_websocket_auth.py index 47de6e7240..3cbddd0a14 100644 --- a/opencontractserver/tests/test_websocket_auth.py +++ b/opencontractserver/tests/test_websocket_auth.py @@ -19,8 +19,8 @@ from channels.testing import WebsocketCommunicator from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser -from graphql_jwt.shortcuts import get_token +from config.jwt_auth.shortcuts import get_token from config.websocket.auth_handshake import AuthHandshakeMixin from config.websocket.middleware import ( WS_AUTH_SUBPROTOCOL, diff --git a/opencontractserver/tests/websocket/test_agent_permission_escalation.py b/opencontractserver/tests/websocket/test_agent_permission_escalation.py index 96aeb00969..191a7b2b53 100644 --- a/opencontractserver/tests/websocket/test_agent_permission_escalation.py +++ b/opencontractserver/tests/websocket/test_agent_permission_escalation.py @@ -28,9 +28,8 @@ from channels.testing import WebsocketCommunicator from django.contrib.auth import get_user_model from django.test.utils import override_settings -from graphql_jwt.shortcuts import get_token -from graphql_relay import to_global_id +from config.jwt_auth.shortcuts import get_token from config.websocket.middleware import WS_AUTH_SUBPROTOCOL from opencontractserver.agents.models import AgentConfiguration from opencontractserver.corpuses.models import Corpus @@ -42,6 +41,7 @@ from opencontractserver.llms.tools.tool_registry import AVAILABLE_TOOLS from opencontractserver.tests.base import WebsocketFixtureBaseTestCase from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.ids import to_global_id from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() diff --git a/opencontractserver/tests/websocket/test_unified_agent_consumer.py b/opencontractserver/tests/websocket/test_unified_agent_consumer.py index 992bac3052..e87312eaf4 100644 --- a/opencontractserver/tests/websocket/test_unified_agent_consumer.py +++ b/opencontractserver/tests/websocket/test_unified_agent_consumer.py @@ -24,7 +24,6 @@ from channels.db import database_sync_to_async from channels.testing import WebsocketCommunicator from django.test.utils import override_settings -from graphql_relay import to_global_id from config.websocket.consumers.unified_agent_conversation import ( UnifiedAgentConsumer, @@ -45,6 +44,7 @@ ThoughtEvent, ) from opencontractserver.tests.base import WebsocketFixtureBaseTestCase +from opencontractserver.utils.ids import to_global_id logger = logging.getLogger(__name__) @@ -104,7 +104,7 @@ async def test_authenticated_user_without_corpus_permission(self) -> None: password="pw123456!", email="other_corpus@example.com", ) - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token other_token = await database_sync_to_async(get_token)(user=other_user) @@ -183,7 +183,7 @@ async def test_authenticated_user_without_document_permission(self) -> None: password="pw123456!", email="other_doc@example.com", ) - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token other_token = await database_sync_to_async(get_token)(user=other_user) diff --git a/opencontractserver/utils/ids.py b/opencontractserver/utils/ids.py new file mode 100644 index 0000000000..a253c77308 --- /dev/null +++ b/opencontractserver/utils/ids.py @@ -0,0 +1,58 @@ +"""Opaque API IDs and pagination cursors, independent of the schema framework. + +Keep the existing Relay wire format: UTF-8 ``TypeName:id`` and +``arrayconnection:offset`` encoded as base64. Decoding intentionally retains +the permissive behavior used by existing filters and mutation error paths. +""" + +from base64 import b64decode, b64encode +from binascii import Error as Base64Error +from typing import NamedTuple + +from graphql import GraphQLID + +CURSOR_PREFIX = "arrayconnection:" + + +class ResolvedGlobalId(NamedTuple): + type: str + id: str + + +def _decode(value: str) -> str: + try: + return b64decode(value).decode("utf-8") + except (Base64Error, UnicodeError, ValueError): + return "" + + +def to_global_id(type_: str, id_: str | int) -> str: + """Encode an ID using GraphQL's ID scalar coercion rules.""" + value = f"{type_}:{GraphQLID.serialize(id_)}" + return b64encode(value.encode("utf-8")).decode("ascii") + + +def from_global_id(global_id: str) -> ResolvedGlobalId: + value = _decode(global_id) + if ":" not in value: + return ResolvedGlobalId("", value) + type_name, identifier = value.split(":", 1) + return ResolvedGlobalId(type_name, identifier) + + +def offset_to_cursor(offset: int) -> str: + return b64encode(f"{CURSOR_PREFIX}{offset}".encode()).decode("ascii") + + +def cursor_to_offset(cursor: str) -> int | None: + try: + return int(_decode(cursor)[len(CURSOR_PREFIX) :]) + except ValueError: + return None + + +def get_offset_with_default(cursor: str | None = None, default_offset: int = 0) -> int: + if not isinstance(cursor, str): + return default_offset + offset = cursor_to_offset(cursor) + return default_offset if offset is None else offset diff --git a/requirements/base.txt b/requirements/base.txt index 6087545009..624c917063 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -73,8 +73,6 @@ django-guardian # GraphQL # ------------------------------------------------------------------------------ strawberry-graphql==0.323.2 # code-first GraphQL; replaced graphene-django (schema parity pinned by config/graphql/schema.graphql) -graphql-relay==3.2.0 # relay global-id / connection helpers imported directly by config/graphql/* (to_global_id, from_global_id, connection_from_array_slice); pinned explicitly rather than relying on the transitive pull via django-graphql-jwt -django-graphql-jwt==0.4.0 # JWT signing/backends + refresh-token utilities only — its graphene middleware/mutations are replaced by strawberry ports (config/graphql/views.py, jwt_auth.py, user_mutations.py) # Telemetry # ------------------------------------------------------------------------------ diff --git a/scripts/validate_frontend_graphql.py b/scripts/validate_frontend_graphql.py index 01794c8866..73c91ea539 100644 --- a/scripts/validate_frontend_graphql.py +++ b/scripts/validate_frontend_graphql.py @@ -83,7 +83,7 @@ def validate_documents(root: pathlib.Path): continue # fragment-only — interpolated client-side checked += 1 doc = strip_client_fields(doc) - errors = validate(schema.graphql_schema, doc, rules) + errors = validate(schema._schema, doc, rules) if errors: name = re.search(r"(query|mutation|subscription)\s+(\w+)", doc_text) failures.append( From 72e9d1794bec6ebbcb8f235be27325a43f7238ef Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 7 Sep 2026 22:13:52 -0500 Subject: [PATCH 2/4] Fix embedded Python imports in WebSocket E2E helpers --- frontend/tests/e2e/helpers.ts | 4 ++-- .../architecture/test_graphql_dependencies.py | 24 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index 5ffea6604a..95f56d47f1 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -1348,7 +1348,7 @@ export function markDocumentPublicViaDocker(documentTitle: string): void { export function getDocumentGlobalIdViaDocker(documentTitle: string): string { const out = runDjangoShell(` from opencontractserver.documents.models import Document - from graphql_relay import to_global_id + from opencontractserver.utils.ids import to_global_id d = Document.objects.get(title='${documentTitle}') print(to_global_id('DocumentType', d.id)) `); @@ -1367,7 +1367,7 @@ export function getDocumentGlobalIdViaDocker(documentTitle: string): string { export function issueJwtForUserViaDocker(username: string): string { const out = runDjangoShell(` from django.contrib.auth import get_user_model - from graphql_jwt.shortcuts import get_token + from config.jwt_auth.shortcuts import get_token User = get_user_model() u = User.objects.get(username='${username}') print(get_token(u)) diff --git a/opencontractserver/tests/architecture/test_graphql_dependencies.py b/opencontractserver/tests/architecture/test_graphql_dependencies.py index 3d59f73c33..be00eaba42 100644 --- a/opencontractserver/tests/architecture/test_graphql_dependencies.py +++ b/opencontractserver/tests/architecture/test_graphql_dependencies.py @@ -1,6 +1,7 @@ """Prevent the removed schema frameworks from returning via runtime or tests.""" import ast +import re import sys from pathlib import Path @@ -25,6 +26,27 @@ def test_no_imports_of_removed_graphql_dependencies(): assert not violations, violations +def test_embedded_python_does_not_import_removed_graphql_dependencies(): + # E2E helpers and workflows execute Python stored in TypeScript/YAML + # strings. The Python AST scan above cannot see those imports. + imports = re.compile( + r"(?m)^[ \t]*(?:from|import)[ \t]+(" + "|".join(sorted(REMOVED)) + r")\b" + ) + paths = [ + *ROOT.glob("frontend/tests/**/*.ts"), + *ROOT.glob("frontend/tests/**/*.tsx"), + *ROOT.glob(".github/workflows/*.yml"), + *ROOT.glob(".github/workflows/*.yaml"), + ] + violations = [] + for path in paths: + source = path.read_text() + for match in imports.finditer(source): + line = source.count("\n", 0, match.start()) + 1 + violations.append(f"{path.relative_to(ROOT)}:{line}") + assert not violations, violations + + def test_served_schema_does_not_load_removed_frameworks(): from config.graphql.schema import schema @@ -46,8 +68,6 @@ def test_validation_extensions_are_fresh_for_each_request(): def test_dependency_manifests_do_not_reintroduce_removed_packages(): - import re - paths = [*ROOT.glob("requirements/**/*.txt"), ROOT / ".pre-commit-config.yaml"] for path in paths: for line in path.read_text().splitlines(): From b34fe2d16ca6197f4d99a2dd3b9b71475c437df8 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 7 Sep 2026 23:46:47 -0500 Subject: [PATCH 3/4] Fix Extract E2E badge obstruction and preserve failure diagnostics --- .github/workflows/frontend-e2e-extract.yml | 21 +++++++++++++------ changelog.d/extract-e2e-badge-dialog.fixed.md | 4 ++++ .../badges/BadgeCelebrationModal.tsx | 3 +++ .../tests/e2e/extract-pdf-workflow.spec.ts | 12 +++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 changelog.d/extract-e2e-badge-dialog.fixed.md diff --git a/.github/workflows/frontend-e2e-extract.yml b/.github/workflows/frontend-e2e-extract.yml index ee2f91d168..0d2a325345 100644 --- a/.github/workflows/frontend-e2e-extract.yml +++ b/.github/workflows/frontend-e2e-extract.yml @@ -48,7 +48,9 @@ jobs: e2e-extract: name: Extract pipeline (PDF upload → run → CSV) runs-on: ubuntu-latest - timeout-minutes: 30 + # Allow image/service startup, Playwright's 25-minute global budget, + # and diagnostic uploads to finish before the job is terminated. + timeout-minutes: 45 permissions: contents: read @@ -201,6 +203,7 @@ jobs: # merge those JSON files into an lcov report for Codecov. # ──────────────────────────────────────────────────────────────── - name: Run Playwright extract spec with coverage + timeout-minutes: 30 working-directory: ./frontend env: CI: "true" @@ -214,7 +217,11 @@ jobs: E2E_TEST_PASSWORD: Openc0ntracts_def@ult run: | set +e - yarn playwright test --grep "Extract PDF workflow" --reporter=list + # The spec already polls slow ingestion/extraction steps. Replaying + # the whole pipeline on failure can consume the job budget before + # Playwright reports the first error. Keep the configured reporters + # and retain the first attempt's trace for the failure upload below. + yarn playwright test --grep "Extract PDF workflow" --retries=0 --trace=retain-on-failure E2E_EXIT=$? mkdir -p coverage/e2e/.nyc_output yarn nyc report --all \ @@ -246,25 +253,27 @@ jobs: ls -la coverage-backend-e2e-extract.xml || true - name: Capture backend logs on failure - if: failure() + if: failure() || cancelled() run: | mkdir -p artifacts docker compose -f local.yml ps > artifacts/docker-ps.txt || true docker compose -f local.yml logs --no-color > artifacts/docker-compose-logs.txt || true - name: Upload backend logs on failure - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: e2e-extract-backend-logs path: artifacts/ - name: Upload Playwright HTML report on failure - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: e2e-extract-playwright-report - path: frontend/playwright-report-e2e/ + path: | + frontend/playwright-report-e2e/ + frontend/test-results/ if-no-files-found: ignore # ──────────────────────────────────────────────────────────────── diff --git a/changelog.d/extract-e2e-badge-dialog.fixed.md b/changelog.d/extract-e2e-badge-dialog.fixed.md new file mode 100644 index 0000000000..a8c1d893d0 --- /dev/null +++ b/changelog.d/extract-e2e-badge-dialog.fixed.md @@ -0,0 +1,4 @@ +- Fixed the Extract PDF end-to-end test stalling behind badge celebrations by + labeling the celebration as a dialog and dismissing it through its Close + button. The Extract CI job now retains HTML reports and failure artifacts, + runs one complete attempt, and reserves time for diagnostics before timeout. diff --git a/frontend/src/components/badges/BadgeCelebrationModal.tsx b/frontend/src/components/badges/BadgeCelebrationModal.tsx index 40bbdf36e3..96e8c50570 100644 --- a/frontend/src/components/badges/BadgeCelebrationModal.tsx +++ b/frontend/src/components/badges/BadgeCelebrationModal.tsx @@ -194,6 +194,9 @@ export function BadgeCelebrationModal({ onClick={onClose} > { test.setTimeout(30 * 60 * 1000); test("uploads two PDFs, runs an extract, exports CSV", async ({ page }) => { + // Background corpus actions can award badges at any point in this flow. + // Dismiss the celebration as a user would before continuing an action; + // its modal overlay otherwise blocks clicks such as "New Extract". + await page.addLocatorHandler( + page.getByRole("dialog", { name: "Badge awarded", exact: true }), + async (dialog) => { + await dialog + .getByRole("button", { name: "Close", exact: true }) + .click(); + } + ); + await test.step("login", async () => { await loginViaUI(page, TEST_USER.username, TEST_USER.password); }); From cc547259f059cee5b579cfec65c9d8d33be423dd Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 7 Sep 2026 23:50:40 -0500 Subject: [PATCH 4/4] chore: prepare OpenContracts v3.1.0 --- CHANGELOG.md | 367 ++++++++++++++++++ changelog.d/2247-agent-persona.fixed.md | 4 - changelog.d/2250-domain-pack-review.fixed.md | 19 - .../2253-case-reporter-bare-f.fixed.md | 1 - .../2261-vector-store-debug-counts.fixed.md | 1 - ...6-authority-provider-lookup-scope.fixed.md | 10 - ...2266-pack-provider-trust-controls.added.md | 24 -- .../2276-regulation-citation-shapes.fixed.md | 4 - .../anthropic-1x-drop-temperature.fixed.md | 28 -- .../auth0-stale-refresh-token.fixed.md | 1 - .../authority-pack-test-isolation.fixed.md | 12 - changelog.d/backend-ci-required-gate.added.md | 17 - changelog.d/branch-protection-union.fixed.md | 13 - changelog.d/cla-allowlist-claude.changed.md | 1 - changelog.d/claude-review-fork-skip.fixed.md | 21 - changelog.d/corpus-default-agent.added.md | 44 --- changelog.d/django-image-budget.changed.md | 24 -- changelog.d/embedder-batch-timeout.fixed.md | 17 - changelog.d/extract-e2e-badge-dialog.fixed.md | 4 - changelog.d/flake8-isort-pin.fixed.md | 12 - .../frontend-auth-session-hardening.fixed.md | 5 - changelog.d/house-bills-core-seams.added.md | 39 -- changelog.d/install-domain-pack.added.md | 22 -- changelog.d/mypy-hook-stub-parity.changed.md | 23 -- .../pin-mcp-precommit-mypy-hook.fixed.md | 1 - .../remove-graphene-dependencies.changed.md | 8 - .../src/assets/configurations/constants.ts | 5 +- 27 files changed, 369 insertions(+), 358 deletions(-) delete mode 100644 changelog.d/2247-agent-persona.fixed.md delete mode 100644 changelog.d/2250-domain-pack-review.fixed.md delete mode 100644 changelog.d/2253-case-reporter-bare-f.fixed.md delete mode 100644 changelog.d/2261-vector-store-debug-counts.fixed.md delete mode 100644 changelog.d/2266-authority-provider-lookup-scope.fixed.md delete mode 100644 changelog.d/2266-pack-provider-trust-controls.added.md delete mode 100644 changelog.d/2276-regulation-citation-shapes.fixed.md delete mode 100644 changelog.d/anthropic-1x-drop-temperature.fixed.md delete mode 100644 changelog.d/auth0-stale-refresh-token.fixed.md delete mode 100644 changelog.d/authority-pack-test-isolation.fixed.md delete mode 100644 changelog.d/backend-ci-required-gate.added.md delete mode 100644 changelog.d/branch-protection-union.fixed.md delete mode 100644 changelog.d/cla-allowlist-claude.changed.md delete mode 100644 changelog.d/claude-review-fork-skip.fixed.md delete mode 100644 changelog.d/corpus-default-agent.added.md delete mode 100644 changelog.d/django-image-budget.changed.md delete mode 100644 changelog.d/embedder-batch-timeout.fixed.md delete mode 100644 changelog.d/extract-e2e-badge-dialog.fixed.md delete mode 100644 changelog.d/flake8-isort-pin.fixed.md delete mode 100644 changelog.d/frontend-auth-session-hardening.fixed.md delete mode 100644 changelog.d/house-bills-core-seams.added.md delete mode 100644 changelog.d/install-domain-pack.added.md delete mode 100644 changelog.d/mypy-hook-stub-parity.changed.md delete mode 100644 changelog.d/pin-mcp-precommit-mypy-hook.fixed.md delete mode 100644 changelog.d/remove-graphene-dependencies.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cefef7cc..5f316cf52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,373 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0] - 2026-09-08 + +### Added + +- **An operator can decline to execute in-pack provider code.** Installing an + authority pack that ships `/providers/*.py` or + `/discovery_providers/*.py` imports that Python into the web and + worker processes — `tar.extract(..., filter="data")` refuses path traversal + and setuid bits, but it cannot refuse code. The new + `AUTHORITY_PACK_LOAD_PROVIDERS` setting (`config/settings/base.py`, default + `True` — no behavior change for existing installs) lets an operator turn + this off; when disabled, `opencontractserver/pipeline/registry.py` skips + the modules without importing them and logs the skip once with a count and + the module names. Turning it off costs re-fetch and nothing else — the pack + contract (authority-packs `SOURCE_PROVIDERS.md`, clause P5) requires a pack + to install and serve its sections with `providers/` deleted. +- **`get_authority_source_provider(class_name)`** (`opencontractserver/pipeline/registry.py`) + is the supported delegation seam for an in-pack provider that reuses a core + one (CFR, U.S. Code, Federal Register) instead of shipping its own scraper. + Accepts a bare class name or a dotted path, refuses an ambiguous leaf rather + than picking one, and returns `None` instead of raising so a pack degrades + to "cannot re-fetch" rather than crashing registry build. +- **`install_authority_pack` reports a pack's provider code surface before any + DB write.** Lists the provider modules a pack ships and, when present, the + classes/prefixes its optional `providers:` manifest declares — by reading + files only, never by importing them — and notes when + `AUTHORITY_PACK_LOAD_PROVIDERS` is off so `--check` shows that the modules + will not run. +- **Added `backend-ci-gate`, a Backend CI check that branch protection can + actually require.** `main` has no `required_status_checks` object at all, so + nothing gates a merge on CI having run — PR #2262 merged with Backend CI + never having run on its head at all, and the push that merged it then failed + its linter, skipping `pytest` entirely. Requiring the `pytest` job is not the + fix: GitHub reports a job skipped by its own `if:` as **Success** to branch + protection, so a red linter (which skips `pytest`) still reads green — + PRs #2260, #2264 and #2265 sit in exactly that state. And requiring any job + in this workflow while its `pull_request` trigger carried + `paths-ignore: [docs/**]` would have left docs-only PRs permanently Pending, + since a workflow skipped by path filtering never reports at all. The new + `gate` job always runs and inspects the other jobs' results itself, + distinguishing "skipped because this PR touches no backend code" from + "skipped because something upstream broke". Its decision table lives in + `.github/scripts/backend_ci_gate.sh` and is self-tested (`--self-test`) on + every run; `.github/scripts/require_backend_ci_gate.sh` applies the branch + protection without clobbering the rest of the object. +- **`Corpus.default_agent`, plus the two changes that make it mean anything.** + A corpus could not have a default agent. A chat opened with no `agent_id` + always resolved to the GLOBAL `default-corpus-agent` slug, so an + `AgentConfiguration` scoped to a corpus could never be its default however it + was configured — and even when one *was* resolved, its `system_instructions` + and `available_tools` were discarded, because those apply only on the + explicit-`?agent_id=` path. Three things follow: + - `Corpus.default_agent` (FK, mirroring `CorpusGroup.default_agent`). An + explicit pointer, not "pick a CORPUS-scoped agent": corpora already carry + scoped agents for other purposes (the inline moderator), and choosing + positionally would silently hand chat to whichever sorted first. + `Corpus.save` refuses a pointer at an agent scoped to a *different* + corpus — that would serve one corpus's private instructions to another's + users. + - Resolution priority 3 consults it before the global slug. An inactive + target falls back rather than failing, which is what switching an agent + off is asking for. + - Fallback-resolved configs are applied when `system_instructions_mode` is + `EXTEND`. The exclusion was written when REPLACE was the only mode, and + its stated reason — a generic default would clobber the corpus persona — + is specific to REPLACE. EXTEND appends, so there is nothing to clobber; + excluding it only produced a default that resolved and was then silently + ignored. REPLACE keeps the original behaviour on the fallback path. +- **`install_domain_pack --consumer-corpus ` (contract assertion C8).** + A domain pack may now declare a `consumer_agent` — instructions, tools, and + `mode: EXTEND` — for the corpus that *consumes* the domain. The pack supplies + the text, because the group slug it names is the pack's own invention; the + operator supplies the corpus, because which corpus consumes a domain is not + knowable when the pack is written. `EXTEND` is required rather than + preferred: REPLACE would overwrite the consuming corpus's persona with + third-party text, which is the coupling `DOMAIN_PACKS.md` forbids. Declaring + one without binding it reports that it was not applied (C5) instead of + passing silently, and the same group-slug check the orchestrator is held to + applies here — `search_across_corpora` takes the slug as a required + argument, so an agent never told it cannot call it. `consumer_agent.preferred_llm` + gets the same C3-style pre-validation as the orchestrator's, so an unusable + model spec fails cleanly instead of surfacing as a bare `ValidationError` + traceback from inside `consumer.save()`. And every part of C8 that is + decidable from the files — `mode`, `instructions_file`, `tools`, the + group-slug check, `preferred_llm` — is now previewed by `--check` too + (`_preflight`, shared with the real-install path via + `_consumer_agent_violations`), matching what C1-C7 already guaranteed; + previously `--check` reported "0 violations" for a `consumer_agent` that + would hard-fail the moment `--consumer-corpus` was supplied. +- **Tier-2a bill-citation grammar** (`opencontractserver/enrichment/grammars.py::_bills`): + "H.R. 1234" / "S. 987" / joint, concurrent, and simple resolution forms now + extract as congress-unqualified shape keys (`hr:1234`, `s:987`, `hjres:44`, …) + with guards against `U.S.` reporter citations, `U.S.C.` cites, `§` sections, + UK-style lowercase `s. 987`, and state abbreviations ending in H/S + (`N.H. Res. 5`, `U.S. Res. 3` are not federal resolutions). Packs fold + these onto congress-qualified + keys via equivalences (the ECCN pattern). New classification vocabulary: + `AUTHORITY_TYPE_BILL`, `GRAMMAR_BILL_PREFIXES` + (`opencontractserver/enrichment/constants.py`) and + `AuthorityWeight.PROPOSED` (`opencontractserver/enrichment/authority_sources.py`) + for introduced-but-not-enacted instruments. `ALL_AUTHORITY_TYPES` backs the + `authority_type` choices on `AuthorityFrontier`/`AuthorityNamespace`/ + `CorpusReference`, so migration `annotations.0103` restates them (choices-only + `AlterField`s — no schema change) to keep migration state in sync. +- **Authority-section push endpoint** for external harvesters: + `POST /api/worker-uploads/authority-sections/` (+ status/list routes) accepts + a `parse_section_spec`-shaped JSON batch (+ optional equivalence rows) under + an existing `CorpusAccessToken`, stages it in the new + `WorkerAuthoritySectionBatch` model (migration `worker_uploads.0005`), and a + drain task (`process_pending_section_batches`) feeds + `bootstrap_authority_corpus` with `relink_async=True` and upserts + equivalences under source `worker:`. Target corpus always comes from + the token (IDOR-safe by construction), and the capability is an explicit + off-by-default grant — `CorpusAccessToken.can_push_authority_sections` + (migration `worker_uploads.0006`, `mint_worker_token + --allow-authority-sections`) — so pre-existing document-upload tokens do not + silently gain the larger blast radius. Payload cap via new + `MAX_AUTHORITY_SECTION_PAYLOAD_BYTES` setting; per-execution drain cap via + new `WORKER_AUTHORITY_SECTION_BATCH_CAP` (default 5, self-re-enqueues while + more remain, mirroring `process_pending_uploads`) so a backlog cannot + monopolise the `worker_uploads` queue; the token is re-validated at drain + time (active, unexpired, still capability-bearing), so revoking a token + stops batches already staged under it; stalled batches recovered by + the existing `recover_stalled_uploads` sweep; periodic drain added to + `CELERY_BEAT_SCHEDULE`. Docs: `docs/guides/ingesting-authorities.md` Part 3. + This gives any external harvester (legislation feeds, eCFR watchers, caselaw + feeds) a continuous remote update path that previously required a full pack + re-install or in-container management commands. +`install_domain_pack` installs a **domain pack**: a named set of base packs plus +the wiring that belongs to none of them — a corpus group, an orchestrator agent +bound to it, and cross-pack equivalences. + +A pack spanning several bodies of law used to install its content correctly and +land inert, because three things could not be expressed in a pack manifest and +had to be created by hand afterwards: the corpus group (without which there is +no cross-corpus retrieval at all), an orchestrator carrying +`search_across_corpora`, and the group slug somewhere the agent would read it — +the tool takes it as a required argument. The install reported success either +way. + +``` +python manage.py install_domain_pack --creator admin --public +python manage.py install_domain_pack --check # plan, writes nothing +python manage.py install_domain_pack --list +``` + +The install contract (C1–C7) is defined in the pack registry's +`DOMAIN_PACKS.md`, so both sides build to one spec. Everything decidable from +the files is decided before anything is written, on the `--check` path and the +install path alike, and all wiring runs in one transaction. + +### Changed + +- **Exempted Claude Code's cloud-agent commit identity from the CLA gate.** `.github/workflows/cla.yml`'s `CLAAssistant` check blocked PRs whose commits are authored by GitHub user `claude` (the identity Claude Code's cloud/background agent uses when it pushes directly to `claude/*` branches, e.g. PR #2248) — that account had never signed the CLA and wasn't in the `allowlist` (previously `dependabot[bot]` only). Added `claude` to the allowlist alongside `dependabot[bot]`: copyright in those commits vests in whoever directed the tool (the maintainer, who has already signed), not in the automation account, so it isn't a third party asserting authorship. `CLA.md`'s "How to sign" section now documents both exemptions. +- **Release image publishing: raise the Django image size budget and add a + manual re-publish path** (`.github/workflows/docker-build-release.yml`). The + v3.0.0 release build was the first since v3.0.0.b1 to get past the runner's + disk limits and actually finish the Django image — and it then failed the + `Enforce Django image size budget` gate at 2.4 GiB against the 1.5 GiB + acceptance criterion from issue #1494. The gate runs before the push step, so + `ghcr.io/.../opencontractserver_django:v3.0.0` was never published while + frontend, postgres, and traefik were. Two changes: + - `DJANGO_IMAGE_BUDGET_BYTES` raised 1.5 GiB → 3 GiB, leaving ~25% headroom + over the measured size so the gate still catches a real regression. This is + a stopgap so releases can publish; the regression itself is tracked in + issue #2236. + - New `workflow_dispatch` trigger taking an existing `tag` input. A `release` + event runs the workflow file *as it existed at the tag*, so re-running a + failed release build can never pick up a workflow fix; dispatching from the + default branch builds the tag's source with the current workflow. All + `docker/metadata-action` tag patterns now derive from a single `BUILD_TAG` + (dispatch input, else the released tag) so a dispatched re-publish emits + byte-identical image tags to the release event it stands in for. + - Image provenance on a dispatched re-publish: `docker/metadata-action` + derives its sha-suffixed tag and `org.opencontainers.image.revision` label + from `github.sha`, which on a dispatch is the default branch rather than the + tag being built. Both now come from the actually-checked-out commit. No + change on a release event, where the two are the same commit. +- Synced the `mypy` pre-commit hook's stub pins with `requirements/local.txt` + (`.pre-commit-config.yaml`: `django-stubs` 6.0.6 → 6.1.0, + `djangorestframework-stubs` 3.17.0 → 3.18.0). The hook's own comment requires + these to match the requirements pins; they had drifted, so the type-checking + CI runs against different stubs than the dev/test image installs. +- Fixed the 7 type errors `django-stubs` 6.1.0 surfaces, none of which change + runtime behavior: + - `opencontractserver/shared/QuerySets.py` (6 errors, lines 466/487/655/656/672/673): + six `permitted_ids`-style variables are assigned a lazy `values_list` queryset + in a `try` arm and a plain `[]` in the matching `except LookupError` arm. 6.1.0 + types `values_list(..., flat=True)` precisely enough that the two arms no longer + unify, so each variable now carries an explicit `Iterable[Any]` declaration — + the honest common type, since every one of them is only ever fed to an `__in` + lookup. + - `opencontractserver/tests/test_corpus_canonical_caml_migration.py:157`: + `apps.get_model("corpuses", "CorpusDescriptionRevision")` is deliberately + unresolvable (the test asserts the model was dropped), but 6.1.0's plugin + resolves *literal* `get_model()` string pairs at type-check time and errors + on a miss. Whether it fires is interpreter-dependent (it does on 3.11, does + not on CI's 3.12), so a `# type: ignore` would itself be flagged unused on + one of them under `warn_unused_ignores`. The model name now lives in a + `str`-annotated local, denying the plugin a literal to match on either. + Runtime behavior and the assertion are unchanged. +- Removed Graphene, graphene-django, django-graphql-jwt, and graphql-relay + dependencies. JWT authentication now uses the shared Django/PyJWT runtime in + `config/jwt_auth/`; global IDs and cursor pagination preserve the existing API + format. Deployments using the optional refresh-token app should replace + `graphql_jwt.refresh_token` with `config.jwt_auth.refresh_token` in + `INSTALLED_APPS`; existing tables and migration history are preserved. +- Removed test-only resolver methods and the Graphene schema accessor, expanded + SDL parity checks, and made validation extensions independent per request. + +### Fixed + +- **Corpus/document agent instructions no longer silently discarded** (#2247). `UnifiedAgentFactory` appended its computed blocks — corpus memory (`llms/agents/agent_factory.py::_inject_corpus_memory`) and temporal grounding (`::_inject_temporal_grounding`) — directly onto `config.system_prompt`, which made the field non-`None` before `CoreDocumentAgentFactory.create_context` / `CoreCorpusAgentFactory.create_context` read `system_prompt is None` as "the caller supplied no prompt, resolve the configured persona". The signal was consumed before it was read, so `Corpus.corpus_agent_instructions` / `.document_agent_instructions` (and the `DEFAULT_*_AGENT_INSTRUCTIONS` fallbacks) never reached the model: every agent ran on the ~650-character temporal block alone. The value round-tripped correctly through the DB and GraphQL, so "the persona is set" verified fine while the agent never received it — making persona changes unmeasurable and leaving tools that depend on an instruction-supplied identifier unreachable. +- Computed context is now queued on the new `AgentConfig.computed_context` list via `AgentConfig.add_computed_context()` and folded in by `AgentConfig.resolve_system_prompt()` (`llms/agents/core_agents.py`), which resolves the persona first and appends the queued blocks in order. Callers that pass an explicit `system_prompt` are unaffected — they still get the computed blocks appended. `resolve_system_prompt()` drains the queue, so repeated context creation cannot duplicate a block. +- **Expect system-prompt token usage to rise**, by the size of the persona that was previously being dropped. `estimate_token_count(config.system_prompt)` feeds `system_prompt_tokens` into the compaction thresholds in `llms/context_guardrails.py`, so an install with a large `corpus_agent_instructions` (14k characters ≈ 3.5k tokens is realistic) will now see compaction trigger earlier in a conversation than it did before. The old figure was not an under-count — it accurately measured the truncated prompt the model was actually receiving. Anyone who tuned compaction thresholds against those numbers should re-check them. +- Regression coverage: `opencontractserver/tests/test_agent_system_prompt_assembly.py` asserts the configured persona appears in the assembled prompt (document + corpus paths, ordering, explicit-prompt precedence, settings fallback, idempotency). `test_agent_memory.py` and `test_agent_temporal_grounding.py` now drive the real `AgentConfig` instead of local stand-ins exposing only `system_prompt` — the stand-ins are how the injectors and persona resolution drifted apart unnoticed. +`install_domain_pack`'s C4 preflight (`opencontractserver/corpuses/management/commands/install_domain_pack.py::Command._preflight`) +validated `equivalences[].from_key`/`to_key` without stripping whitespace, while +the shared writer (`upsert_equivalence`) strips before validating — a manifest +row with incidental leading/trailing whitespace could fail preflight and be +refused even though the writer would have accepted it. Preflight now strips +before validating, matching the writer exactly. + +A base pack that materialised into `AUTHORITY_PACK_INSTALL_DIR` but then failed +its real (non-`--check`) `load_authority_pack` call left its directory behind +unloaded. Since pipeline discovery unions every discoverable directory's +`source_hosts` into the SSRF allowlist regardless of DB-load state, the failed +pack's hosts stayed live in the trust boundary until the next re-run overwrote +it. The install loop now removes the failed pack's own materialised directory +before re-raising; packs that installed earlier in the same run are untouched. + +A plain install (no `--check`) missing `--creator` printed a `--check`-flavoured +"C1 pack validity not checked" hint immediately before the actual +"--creator is required to install" refusal. The hint is now gated to `--check` +runs, where it is accurate. +- Fixed the case-reporter citation grammar (`opencontractserver/enrichment/grammars.py`) so a bare `F.` (Federal Reporter, 1st series, 1880-1924) actually matches — the Circuits alternative's `(?:2d|3d|4th)` group was mandatory, so `"160 F. 903"` never matched even though the PR description listed `F.` as a covered reporter shape. Made the series suffix optional (matching the existing `F. Supp.` branch), which does not reintroduce ambiguity since the `F. Supp.` alternative is tried first. +- Fixed vector search paying for discarded debug diagnostics in `opencontractserver/llms/vector_stores/core_vector_stores.py`: several `_logger.debug(...)`/`_logger.info(...)` calls passed a `_safe_queryset_info(...)`/`_safe_queryset_info_sync(...)` string (each running one or more `COUNT(*)` queries, one of them unfiltered over the whole annotation table) or a stringified `queryset.query` as an argument, and Python evaluates logging-call arguments before checking whether the level is enabled — so every search paid for the counts regardless of log level, and four sites logged them at `INFO` in production. All such call sites are now guarded by `_diagnostics_enabled()` (an `isEnabledFor(logging.DEBUG)` check) and the four `INFO` sites were demoted to `DEBUG`; on an 18-corpus fan-out search this cut wall-clock from 277.4s to 45.3s (6.1x). Diagnostics still emit when `DEBUG` logging is enabled — deferred, not deleted. Covered by `opencontractserver/tests/test_vector_store_diagnostics.py`. +- **`get_authority_source_provider()` could resolve to the wrong component type.** + `opencontractserver/pipeline/registry.py` — its primary lookup went through + `PipelineComponentRegistry.get_by_name` / `get_by_class_name`, dicts shared by + every component family (parsers, embedders, LLM providers, ...). A class-name + collision with an unrelated component would silently return an instance of + the wrong type, which would only fail later and outside this function's + `try/except`, when the pack called `.can_handle(...)` on it. The lookup is + now scoped to `get_all_authority_source_providers_cached()` throughout, + matching the type-safety guarantee already stated in the docstring and + already applied on the ambiguous-leaf fallback path. +- Fixed Tier-1 extraction of regulation-style citations (`opencontractserver/enrichment/extractor.py`). Section numbers could not contain dots and the `§` symbol was not accepted where the word "Section" was, so `"Section 120.10 of the ITAR"` matched nothing and `"ITAR Section 120.41"` matched the prefix and produced `itar:120` — a wrong document rather than a missing one. Roman-numeral divisions (`"Category XI of the United States Munitions List"`) had no pattern at all. Adds dotted section numbers, `§`/`§§`, part-level citation and a narrow `Category` division pattern, all gated on a registered authority alias so `"Part 3 of the Agreement"` still matches nothing. Affects every CFR-, state-admin-code- and municipal-code-style corpus, not just the one it was found on. +- Added ECCN citation extraction as a Tier-2a shape grammar (`opencontractserver/enrichment/grammars.py`), emitting the shape-level key `eccn:3a611` rather than any pack's own prefix. `"ECCN 3A611"` carries no Section/Part/`§` token, so no existing pattern could match it. Anchored on the literal "ECCN" so bare alphanumerics in prose cannot become citations, and lowercased because authority-key matching is case-sensitive on the section part and ECCNs are conventionally written uppercase. A pack carrying the Commerce Control List folds these onto its own keys with generated per-key `equivalences` rows (`eccn:3a611 -> ccl:3a611`, one per ECCN), the same way it already folds the LLM-extracted `act:eccn-*` forms — per-key rows are the only pack-side mechanism, since prefix-level rewrite rules are deliberately never loaded from packs. +- Fixed renamed authority sections stranding their previous document (`opencontractserver/enrichment/authorities.py`). The write derived the corpus path from the section TITLE, so a changed heading landed the new text at a new path while the old document stayed current — both carrying the same `canonical_key`. `find_authority_target` orders by `id` and takes the first, so the superseded document won every lookup and the key silently served stale text; on one rebuild all 21 USML categories served `[STUB]` bodies at IMPLEMENTING weight, with `21 created, 0 updated` as the only signal. A rename now versions up at the existing path instead of forking. +- Fixed the local `warp-ingest` stack sending an empty API key: the Django setting defaulted to `""` while the service defaulted to `abc123`, so a stock `--profile warp-ingest` run 401'd every PDF parse. Both sides now resolve from the same variable with the same default. +- **`anthropic` 1.0.0 removed the sampling parameters, breaking both direct + `messages.create()` calls.** `temperature` is gone from the SDK's runtime + signature and from all three `create()` overloads, and there is no `**kwargs` + passthrough — so `temperature=0` now raises + `TypeError: Messages.create() got an unexpected keyword argument + 'temperature'` **before the request is built**. That is a hard crash, not an + API-level 400. It reached `main` with no commit touching the code: + `requirements/analyzers/claude_highlighter.txt` declares `anthropic>=0.45.2` + with **no upper bound**, both Dockerfiles glob every + `requirements/*/*.txt` into the image, and there is no lock file — so a + major release published upstream is picked up by the next resolve, in CI + and in the production image alike. Both call sites in + `doc_analysis_tasks.py` (chunked text extraction and the Claude PII scanner) + drop the parameter. The API removed `temperature` on Opus 4.7+ and rejects + it on Sonnet 5, so this is the forward-correct fix rather than a + client-only shim; determinism, where it matters, comes from the prompt, and + `temperature=0` never guaranteed identical outputs in any case. The + `temperature=0` guard in the pydantic-ai layer (issue #1381) is a different + code path and is unaffected. +- **Pinned `anthropic` to `>=0.45.2,<1` as a stopgap, in both the analyzer + requirements and the mypy hook's `additional_dependencies`.** Note the + ceiling is `<1`, not `<2` — `1.0.0` satisfies `<2`, so that bound would be a + no-op (verified: `>=0.45.2` and `>=0.45.2,<2` both resolve to `1.0.0`; + `>=0.45.2,<1` resolves to `0.125.0`). Mirroring the pin into the pre-commit + hook matters because that hook resolves its own dependency set — without it + CI type-checks against a different major than the image ships, which is how + this broke `main` with no commit touching the code. Migration onto the 1.x + line is tracked in #2273. +- **Auth0 stale refresh token loop on production** (`frontend/src/components/auth/AuthGate.tsx`): when Auth0 rejected the stored rotating refresh token (expired past its idle/absolute lifetime, revoked, or lost to rotation reuse-detection — tenant log event `fertft` "Token could not be decoded or is missing in DB", surfaced by auth0-spa-js as `invalid_grant` "Unknown or invalid refresh token." / `missing_refresh_token`), AuthGate treated it as an unexpected error: it showed a scary "Authentication failed" toast, and because the SDK cache (`cacheLocation="localstorage"`) was never cleared, `isAuthenticated` stayed `true` and the identical failed token exchange + toast replayed on **every page load** until the user manually logged in again. AuthGate now classifies these as expected session expiry: it clears the SDK cache locally (`logout({ openUrl: false })`), drops the `oc_has_authenticated` fast-path flag, shows a one-time "Your session has expired" info toast, and falls back to anonymous quietly. The `isAuthenticated:false` verification path gets the same handling (no more console noise or doomed re-verification on subsequent boots). Transient/unknown errors still keep the cache and surface an error toast. Docs updated in `frontend/src/docs/AUTHENTICATION_PATTERN.md`. +- **Test suite: a developer's authority-pack fetch cache no longer leaks into tests.** + `config/settings/test.py` inherited the default + `AUTHORITY_PACK_INSTALL_DIR = ROOT_DIR/.authority_packs`, which is a real fetch + cache on any machine where `install_authority_pack` has been run — and which + `pipeline/registry.py::authority_pack_dirs` scans as an implicit discovery + root. Every test touching pack discovery was therefore environment-dependent: + `test_authority_pack_sideload.py::AuthorityPackDiscoveryTests` (3 tests) failed + locally while passing in CI, and the SSRF allowlist union, pack catalog, and + in-pack provider imports were silently exposed the same way. Test settings now + pin the install dir to a path that does not exist, so the third discovery + source contributes nothing unless a test opts in via `override_settings`; the + tests that assert an exact pack set also state that precondition themselves. +- **`require_backend_ci_gate.sh` replaced the required-checks list instead of + adding to it.** Adding a second required context — which + `docs/development/test-suite.md` explicitly tells you to do once another + workflow grows its own gate — would have silently *unrequired* + `backend-ci-gate`, with no error and a verification print that looked + correct. It also hardcoded `strict: false` (reverting a maintainer who had + turned it on) and dropped the `app_id` GitHub pins each check to, widening + the requirement to any app. The merge now unions contexts, preserves + `strict` and `app_id`, migrates a legacy `contexts` list, and is idempotent. + It moved to `.github/scripts/branch_protection_body.py` with a 13-case + `--self-test`, reachable as `require_backend_ci_gate.sh --self-test`. + `.github/scripts/**` is now in Backend CI's path filter, so a change to the + code that decides whether a merge is allowed runs the full suite. +- **`claude-review` reported a red X on every fork PR that no contributor + could clear.** `.github/workflows/claude-code-review.yml` triggers on + `pull_request`, but GitHub clamps runs whose head is a fork: the declared + `id-token: write` is silently dropped and the secret store is withheld + (`Secret source: None`, `claude_code_oauth_token: ""`). The action failed + with *"Could not fetch an OIDC token. Did you remember to add `id-token: + write` to your workflow permissions?"* — an error that accuses the workflow + file even though the permission is declared on line 43, sending anyone who + debugs from the message alone to edit code that is already correct + (run 33611167884, PR #2296). Re-running with "Approve and run" does not + restore secrets, and `GITHUB_TOKEN` is read-only on fork PRs, so the + `gh pr comment` the job is asked to make could not have posted either — three + independent blockers, any one of them fatal. The job now carries + `if: github.event.pull_request.head.repo.full_name == github.repository` + and reports **skipped** on forks, which is what actually happened. Dependabot + is unaffected: its branches live in this repository, so the guard passes and + the OIDC exchange works as before (run 33788653880). The comment above the + guard records why `pull_request_target` is not the fix — it would put the + OAuth secret and a write-capable token in a job checking out untrusted head + code — and points at the `@claude` mention path in + `.github/workflows/claude.yml` for reviewing a fork PR on demand. +- Stopped large embedding backlogs from stalling indefinitely on the CPU-only + microservice embedder (`opencontractserver/constants/document_processing.py`). + `EMBEDDER_BATCH_REQUEST_TIMEOUT_SECONDS` was 60s while a batch of 100 + long texts — whole ordinance sections or statute chapters, as produced by + authority-pack ingestion — routinely takes longer than that on CPU. The + client retried the *entire* batch three times, so each task burned ~3 minutes + and requeued without progress. Observed on a real ingest: a ~2,800-task queue + draining at ~1 task/min with a continuous retry storm, and semantic search + returning "no results from either arm" because no annotation ever got an + embedding. + + The timeout is now 300s, and the microservice batch cap drops from 100 to 32 + (`MICROSERVICE_EMBEDDER_MAX_BATCH_SIZE`, with `EMBEDDING_API_BATCH_SIZE` + lowered from 50 to 32 to keep the `documents.E001` system check satisfied), so + a slow batch finishes once instead of being redone three times. Measured on + the same queue after the change: retries dropped from continuous to **zero** + and throughput rose from ~4 to ~117 completed tasks per 4 minutes. +- Fixed the Extract PDF end-to-end test stalling behind badge celebrations by + labeling the celebration as a dialog and dismissing it through its Close + button. The Extract CI job now retains HTML reports and failure artifacts, + runs one complete attempt, and reserves time for diagnostics before timeout. +- Pinned the `flake8` pre-commit hook's `additional_dependencies` + (`.pre-commit-config.yaml`: `flake8-isort==7.0.0`, `isort==6.0.1`). They were + unpinned, and `additional_dependencies` are re-resolved every time a hook env + is rebuilt, so `flake8-isort` floated up to **isort 9.0.1** while the + standalone `isort` hook stayed `rev`-pinned to 6.0.1. The two versions + disagree about repeated `from X import (...)` statements — the shape isort 6 + itself emits for aliased imports — so `isort` kept files in a layout `flake8` + then rejected. The result was the `linter` job failing on `main` and on every + open PR with 10 `I001`/`I005` findings in + `opencontractserver/llms/agents/pydantic_ai_agents.py` and + `opencontractserver/utils/compact_pawls.py`, neither of which any of those PRs + touched. `isort` here must track the `isort` hook's `rev`. +- Replace popup authentication with same-tab Auth0 Universal Login through the + official React SDK and its memory cache. Validate restored sessions against the + backend, recover from failed identity checks, preserve sessions on permission + denials, and prevent stale requests from restoring logged-out users. Local + username/password sessions survive tab refreshes without persisting profiles. +- Pinned the `mcp` `additional_dependencies` entry in the mypy pre-commit hook (`.pre-commit-config.yaml`) to `>=1.28.1,<2`, matching the runtime pin in `requirements/base.txt`. The previous unbounded `mcp>=1.0.0` let the hook resolve `mcp` 2.x, whose SDK renamed/removed fields and methods (`Server.list_resources`, `uriTemplate`→`uri_template`, `inputSchema`→`input_schema`) that `opencontractserver/mcp/server.py` (written against the 1.x API) still uses — producing 48 unrelated mypy errors on every PR's `linter`/`backend-ci-gate` checks regardless of what the PR actually changed. + + ## [3.0.0] - 2026-08-09 ### Changed diff --git a/changelog.d/2247-agent-persona.fixed.md b/changelog.d/2247-agent-persona.fixed.md deleted file mode 100644 index a2d2acf637..0000000000 --- a/changelog.d/2247-agent-persona.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- **Corpus/document agent instructions no longer silently discarded** (#2247). `UnifiedAgentFactory` appended its computed blocks — corpus memory (`llms/agents/agent_factory.py::_inject_corpus_memory`) and temporal grounding (`::_inject_temporal_grounding`) — directly onto `config.system_prompt`, which made the field non-`None` before `CoreDocumentAgentFactory.create_context` / `CoreCorpusAgentFactory.create_context` read `system_prompt is None` as "the caller supplied no prompt, resolve the configured persona". The signal was consumed before it was read, so `Corpus.corpus_agent_instructions` / `.document_agent_instructions` (and the `DEFAULT_*_AGENT_INSTRUCTIONS` fallbacks) never reached the model: every agent ran on the ~650-character temporal block alone. The value round-tripped correctly through the DB and GraphQL, so "the persona is set" verified fine while the agent never received it — making persona changes unmeasurable and leaving tools that depend on an instruction-supplied identifier unreachable. -- Computed context is now queued on the new `AgentConfig.computed_context` list via `AgentConfig.add_computed_context()` and folded in by `AgentConfig.resolve_system_prompt()` (`llms/agents/core_agents.py`), which resolves the persona first and appends the queued blocks in order. Callers that pass an explicit `system_prompt` are unaffected — they still get the computed blocks appended. `resolve_system_prompt()` drains the queue, so repeated context creation cannot duplicate a block. -- **Expect system-prompt token usage to rise**, by the size of the persona that was previously being dropped. `estimate_token_count(config.system_prompt)` feeds `system_prompt_tokens` into the compaction thresholds in `llms/context_guardrails.py`, so an install with a large `corpus_agent_instructions` (14k characters ≈ 3.5k tokens is realistic) will now see compaction trigger earlier in a conversation than it did before. The old figure was not an under-count — it accurately measured the truncated prompt the model was actually receiving. Anyone who tuned compaction thresholds against those numbers should re-check them. -- Regression coverage: `opencontractserver/tests/test_agent_system_prompt_assembly.py` asserts the configured persona appears in the assembled prompt (document + corpus paths, ordering, explicit-prompt precedence, settings fallback, idempotency). `test_agent_memory.py` and `test_agent_temporal_grounding.py` now drive the real `AgentConfig` instead of local stand-ins exposing only `system_prompt` — the stand-ins are how the injectors and persona resolution drifted apart unnoticed. diff --git a/changelog.d/2250-domain-pack-review.fixed.md b/changelog.d/2250-domain-pack-review.fixed.md deleted file mode 100644 index e45fc95a6c..0000000000 --- a/changelog.d/2250-domain-pack-review.fixed.md +++ /dev/null @@ -1,19 +0,0 @@ -`install_domain_pack`'s C4 preflight (`opencontractserver/corpuses/management/commands/install_domain_pack.py::Command._preflight`) -validated `equivalences[].from_key`/`to_key` without stripping whitespace, while -the shared writer (`upsert_equivalence`) strips before validating — a manifest -row with incidental leading/trailing whitespace could fail preflight and be -refused even though the writer would have accepted it. Preflight now strips -before validating, matching the writer exactly. - -A base pack that materialised into `AUTHORITY_PACK_INSTALL_DIR` but then failed -its real (non-`--check`) `load_authority_pack` call left its directory behind -unloaded. Since pipeline discovery unions every discoverable directory's -`source_hosts` into the SSRF allowlist regardless of DB-load state, the failed -pack's hosts stayed live in the trust boundary until the next re-run overwrote -it. The install loop now removes the failed pack's own materialised directory -before re-raising; packs that installed earlier in the same run are untouched. - -A plain install (no `--check`) missing `--creator` printed a `--check`-flavoured -"C1 pack validity not checked" hint immediately before the actual -"--creator is required to install" refusal. The hint is now gated to `--check` -runs, where it is accurate. diff --git a/changelog.d/2253-case-reporter-bare-f.fixed.md b/changelog.d/2253-case-reporter-bare-f.fixed.md deleted file mode 100644 index 4b6d5b91f7..0000000000 --- a/changelog.d/2253-case-reporter-bare-f.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed the case-reporter citation grammar (`opencontractserver/enrichment/grammars.py`) so a bare `F.` (Federal Reporter, 1st series, 1880-1924) actually matches — the Circuits alternative's `(?:2d|3d|4th)` group was mandatory, so `"160 F. 903"` never matched even though the PR description listed `F.` as a covered reporter shape. Made the series suffix optional (matching the existing `F. Supp.` branch), which does not reintroduce ambiguity since the `F. Supp.` alternative is tried first. diff --git a/changelog.d/2261-vector-store-debug-counts.fixed.md b/changelog.d/2261-vector-store-debug-counts.fixed.md deleted file mode 100644 index 8ba2ea0f12..0000000000 --- a/changelog.d/2261-vector-store-debug-counts.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed vector search paying for discarded debug diagnostics in `opencontractserver/llms/vector_stores/core_vector_stores.py`: several `_logger.debug(...)`/`_logger.info(...)` calls passed a `_safe_queryset_info(...)`/`_safe_queryset_info_sync(...)` string (each running one or more `COUNT(*)` queries, one of them unfiltered over the whole annotation table) or a stringified `queryset.query` as an argument, and Python evaluates logging-call arguments before checking whether the level is enabled — so every search paid for the counts regardless of log level, and four sites logged them at `INFO` in production. All such call sites are now guarded by `_diagnostics_enabled()` (an `isEnabledFor(logging.DEBUG)` check) and the four `INFO` sites were demoted to `DEBUG`; on an 18-corpus fan-out search this cut wall-clock from 277.4s to 45.3s (6.1x). Diagnostics still emit when `DEBUG` logging is enabled — deferred, not deleted. Covered by `opencontractserver/tests/test_vector_store_diagnostics.py`. diff --git a/changelog.d/2266-authority-provider-lookup-scope.fixed.md b/changelog.d/2266-authority-provider-lookup-scope.fixed.md deleted file mode 100644 index a7ba3bfa9a..0000000000 --- a/changelog.d/2266-authority-provider-lookup-scope.fixed.md +++ /dev/null @@ -1,10 +0,0 @@ -- **`get_authority_source_provider()` could resolve to the wrong component type.** - `opencontractserver/pipeline/registry.py` — its primary lookup went through - `PipelineComponentRegistry.get_by_name` / `get_by_class_name`, dicts shared by - every component family (parsers, embedders, LLM providers, ...). A class-name - collision with an unrelated component would silently return an instance of - the wrong type, which would only fail later and outside this function's - `try/except`, when the pack called `.can_handle(...)` on it. The lookup is - now scoped to `get_all_authority_source_providers_cached()` throughout, - matching the type-safety guarantee already stated in the docstring and - already applied on the ambiguous-leaf fallback path. diff --git a/changelog.d/2266-pack-provider-trust-controls.added.md b/changelog.d/2266-pack-provider-trust-controls.added.md deleted file mode 100644 index c1a8972c97..0000000000 --- a/changelog.d/2266-pack-provider-trust-controls.added.md +++ /dev/null @@ -1,24 +0,0 @@ -- **An operator can decline to execute in-pack provider code.** Installing an - authority pack that ships `/providers/*.py` or - `/discovery_providers/*.py` imports that Python into the web and - worker processes — `tar.extract(..., filter="data")` refuses path traversal - and setuid bits, but it cannot refuse code. The new - `AUTHORITY_PACK_LOAD_PROVIDERS` setting (`config/settings/base.py`, default - `True` — no behavior change for existing installs) lets an operator turn - this off; when disabled, `opencontractserver/pipeline/registry.py` skips - the modules without importing them and logs the skip once with a count and - the module names. Turning it off costs re-fetch and nothing else — the pack - contract (authority-packs `SOURCE_PROVIDERS.md`, clause P5) requires a pack - to install and serve its sections with `providers/` deleted. -- **`get_authority_source_provider(class_name)`** (`opencontractserver/pipeline/registry.py`) - is the supported delegation seam for an in-pack provider that reuses a core - one (CFR, U.S. Code, Federal Register) instead of shipping its own scraper. - Accepts a bare class name or a dotted path, refuses an ambiguous leaf rather - than picking one, and returns `None` instead of raising so a pack degrades - to "cannot re-fetch" rather than crashing registry build. -- **`install_authority_pack` reports a pack's provider code surface before any - DB write.** Lists the provider modules a pack ships and, when present, the - classes/prefixes its optional `providers:` manifest declares — by reading - files only, never by importing them — and notes when - `AUTHORITY_PACK_LOAD_PROVIDERS` is off so `--check` shows that the modules - will not run. diff --git a/changelog.d/2276-regulation-citation-shapes.fixed.md b/changelog.d/2276-regulation-citation-shapes.fixed.md deleted file mode 100644 index cbc1872b4c..0000000000 --- a/changelog.d/2276-regulation-citation-shapes.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- Fixed Tier-1 extraction of regulation-style citations (`opencontractserver/enrichment/extractor.py`). Section numbers could not contain dots and the `§` symbol was not accepted where the word "Section" was, so `"Section 120.10 of the ITAR"` matched nothing and `"ITAR Section 120.41"` matched the prefix and produced `itar:120` — a wrong document rather than a missing one. Roman-numeral divisions (`"Category XI of the United States Munitions List"`) had no pattern at all. Adds dotted section numbers, `§`/`§§`, part-level citation and a narrow `Category` division pattern, all gated on a registered authority alias so `"Part 3 of the Agreement"` still matches nothing. Affects every CFR-, state-admin-code- and municipal-code-style corpus, not just the one it was found on. -- Added ECCN citation extraction as a Tier-2a shape grammar (`opencontractserver/enrichment/grammars.py`), emitting the shape-level key `eccn:3a611` rather than any pack's own prefix. `"ECCN 3A611"` carries no Section/Part/`§` token, so no existing pattern could match it. Anchored on the literal "ECCN" so bare alphanumerics in prose cannot become citations, and lowercased because authority-key matching is case-sensitive on the section part and ECCNs are conventionally written uppercase. A pack carrying the Commerce Control List folds these onto its own keys with generated per-key `equivalences` rows (`eccn:3a611 -> ccl:3a611`, one per ECCN), the same way it already folds the LLM-extracted `act:eccn-*` forms — per-key rows are the only pack-side mechanism, since prefix-level rewrite rules are deliberately never loaded from packs. -- Fixed renamed authority sections stranding their previous document (`opencontractserver/enrichment/authorities.py`). The write derived the corpus path from the section TITLE, so a changed heading landed the new text at a new path while the old document stayed current — both carrying the same `canonical_key`. `find_authority_target` orders by `id` and takes the first, so the superseded document won every lookup and the key silently served stale text; on one rebuild all 21 USML categories served `[STUB]` bodies at IMPLEMENTING weight, with `21 created, 0 updated` as the only signal. A rename now versions up at the existing path instead of forking. -- Fixed the local `warp-ingest` stack sending an empty API key: the Django setting defaulted to `""` while the service defaulted to `abc123`, so a stock `--profile warp-ingest` run 401'd every PDF parse. Both sides now resolve from the same variable with the same default. diff --git a/changelog.d/anthropic-1x-drop-temperature.fixed.md b/changelog.d/anthropic-1x-drop-temperature.fixed.md deleted file mode 100644 index df34d0c67b..0000000000 --- a/changelog.d/anthropic-1x-drop-temperature.fixed.md +++ /dev/null @@ -1,28 +0,0 @@ -- **`anthropic` 1.0.0 removed the sampling parameters, breaking both direct - `messages.create()` calls.** `temperature` is gone from the SDK's runtime - signature and from all three `create()` overloads, and there is no `**kwargs` - passthrough — so `temperature=0` now raises - `TypeError: Messages.create() got an unexpected keyword argument - 'temperature'` **before the request is built**. That is a hard crash, not an - API-level 400. It reached `main` with no commit touching the code: - `requirements/analyzers/claude_highlighter.txt` declares `anthropic>=0.45.2` - with **no upper bound**, both Dockerfiles glob every - `requirements/*/*.txt` into the image, and there is no lock file — so a - major release published upstream is picked up by the next resolve, in CI - and in the production image alike. Both call sites in - `doc_analysis_tasks.py` (chunked text extraction and the Claude PII scanner) - drop the parameter. The API removed `temperature` on Opus 4.7+ and rejects - it on Sonnet 5, so this is the forward-correct fix rather than a - client-only shim; determinism, where it matters, comes from the prompt, and - `temperature=0` never guaranteed identical outputs in any case. The - `temperature=0` guard in the pydantic-ai layer (issue #1381) is a different - code path and is unaffected. -- **Pinned `anthropic` to `>=0.45.2,<1` as a stopgap, in both the analyzer - requirements and the mypy hook's `additional_dependencies`.** Note the - ceiling is `<1`, not `<2` — `1.0.0` satisfies `<2`, so that bound would be a - no-op (verified: `>=0.45.2` and `>=0.45.2,<2` both resolve to `1.0.0`; - `>=0.45.2,<1` resolves to `0.125.0`). Mirroring the pin into the pre-commit - hook matters because that hook resolves its own dependency set — without it - CI type-checks against a different major than the image ships, which is how - this broke `main` with no commit touching the code. Migration onto the 1.x - line is tracked in #2273. diff --git a/changelog.d/auth0-stale-refresh-token.fixed.md b/changelog.d/auth0-stale-refresh-token.fixed.md deleted file mode 100644 index 1d62cfda2e..0000000000 --- a/changelog.d/auth0-stale-refresh-token.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- **Auth0 stale refresh token loop on production** (`frontend/src/components/auth/AuthGate.tsx`): when Auth0 rejected the stored rotating refresh token (expired past its idle/absolute lifetime, revoked, or lost to rotation reuse-detection — tenant log event `fertft` "Token could not be decoded or is missing in DB", surfaced by auth0-spa-js as `invalid_grant` "Unknown or invalid refresh token." / `missing_refresh_token`), AuthGate treated it as an unexpected error: it showed a scary "Authentication failed" toast, and because the SDK cache (`cacheLocation="localstorage"`) was never cleared, `isAuthenticated` stayed `true` and the identical failed token exchange + toast replayed on **every page load** until the user manually logged in again. AuthGate now classifies these as expected session expiry: it clears the SDK cache locally (`logout({ openUrl: false })`), drops the `oc_has_authenticated` fast-path flag, shows a one-time "Your session has expired" info toast, and falls back to anonymous quietly. The `isAuthenticated:false` verification path gets the same handling (no more console noise or doomed re-verification on subsequent boots). Transient/unknown errors still keep the cache and surface an error toast. Docs updated in `frontend/src/docs/AUTHENTICATION_PATTERN.md`. diff --git a/changelog.d/authority-pack-test-isolation.fixed.md b/changelog.d/authority-pack-test-isolation.fixed.md deleted file mode 100644 index 2e71b7ec66..0000000000 --- a/changelog.d/authority-pack-test-isolation.fixed.md +++ /dev/null @@ -1,12 +0,0 @@ -- **Test suite: a developer's authority-pack fetch cache no longer leaks into tests.** - `config/settings/test.py` inherited the default - `AUTHORITY_PACK_INSTALL_DIR = ROOT_DIR/.authority_packs`, which is a real fetch - cache on any machine where `install_authority_pack` has been run — and which - `pipeline/registry.py::authority_pack_dirs` scans as an implicit discovery - root. Every test touching pack discovery was therefore environment-dependent: - `test_authority_pack_sideload.py::AuthorityPackDiscoveryTests` (3 tests) failed - locally while passing in CI, and the SSRF allowlist union, pack catalog, and - in-pack provider imports were silently exposed the same way. Test settings now - pin the install dir to a path that does not exist, so the third discovery - source contributes nothing unless a test opts in via `override_settings`; the - tests that assert an exact pack set also state that precondition themselves. diff --git a/changelog.d/backend-ci-required-gate.added.md b/changelog.d/backend-ci-required-gate.added.md deleted file mode 100644 index d46d4927fb..0000000000 --- a/changelog.d/backend-ci-required-gate.added.md +++ /dev/null @@ -1,17 +0,0 @@ -- **Added `backend-ci-gate`, a Backend CI check that branch protection can - actually require.** `main` has no `required_status_checks` object at all, so - nothing gates a merge on CI having run — PR #2262 merged with Backend CI - never having run on its head at all, and the push that merged it then failed - its linter, skipping `pytest` entirely. Requiring the `pytest` job is not the - fix: GitHub reports a job skipped by its own `if:` as **Success** to branch - protection, so a red linter (which skips `pytest`) still reads green — - PRs #2260, #2264 and #2265 sit in exactly that state. And requiring any job - in this workflow while its `pull_request` trigger carried - `paths-ignore: [docs/**]` would have left docs-only PRs permanently Pending, - since a workflow skipped by path filtering never reports at all. The new - `gate` job always runs and inspects the other jobs' results itself, - distinguishing "skipped because this PR touches no backend code" from - "skipped because something upstream broke". Its decision table lives in - `.github/scripts/backend_ci_gate.sh` and is self-tested (`--self-test`) on - every run; `.github/scripts/require_backend_ci_gate.sh` applies the branch - protection without clobbering the rest of the object. diff --git a/changelog.d/branch-protection-union.fixed.md b/changelog.d/branch-protection-union.fixed.md deleted file mode 100644 index e41e1c280f..0000000000 --- a/changelog.d/branch-protection-union.fixed.md +++ /dev/null @@ -1,13 +0,0 @@ -- **`require_backend_ci_gate.sh` replaced the required-checks list instead of - adding to it.** Adding a second required context — which - `docs/development/test-suite.md` explicitly tells you to do once another - workflow grows its own gate — would have silently *unrequired* - `backend-ci-gate`, with no error and a verification print that looked - correct. It also hardcoded `strict: false` (reverting a maintainer who had - turned it on) and dropped the `app_id` GitHub pins each check to, widening - the requirement to any app. The merge now unions contexts, preserves - `strict` and `app_id`, migrates a legacy `contexts` list, and is idempotent. - It moved to `.github/scripts/branch_protection_body.py` with a 13-case - `--self-test`, reachable as `require_backend_ci_gate.sh --self-test`. - `.github/scripts/**` is now in Backend CI's path filter, so a change to the - code that decides whether a merge is allowed runs the full suite. diff --git a/changelog.d/cla-allowlist-claude.changed.md b/changelog.d/cla-allowlist-claude.changed.md deleted file mode 100644 index 9b61032314..0000000000 --- a/changelog.d/cla-allowlist-claude.changed.md +++ /dev/null @@ -1 +0,0 @@ -- **Exempted Claude Code's cloud-agent commit identity from the CLA gate.** `.github/workflows/cla.yml`'s `CLAAssistant` check blocked PRs whose commits are authored by GitHub user `claude` (the identity Claude Code's cloud/background agent uses when it pushes directly to `claude/*` branches, e.g. PR #2248) — that account had never signed the CLA and wasn't in the `allowlist` (previously `dependabot[bot]` only). Added `claude` to the allowlist alongside `dependabot[bot]`: copyright in those commits vests in whoever directed the tool (the maintainer, who has already signed), not in the automation account, so it isn't a third party asserting authorship. `CLA.md`'s "How to sign" section now documents both exemptions. diff --git a/changelog.d/claude-review-fork-skip.fixed.md b/changelog.d/claude-review-fork-skip.fixed.md deleted file mode 100644 index b86aa3d50c..0000000000 --- a/changelog.d/claude-review-fork-skip.fixed.md +++ /dev/null @@ -1,21 +0,0 @@ -- **`claude-review` reported a red X on every fork PR that no contributor - could clear.** `.github/workflows/claude-code-review.yml` triggers on - `pull_request`, but GitHub clamps runs whose head is a fork: the declared - `id-token: write` is silently dropped and the secret store is withheld - (`Secret source: None`, `claude_code_oauth_token: ""`). The action failed - with *"Could not fetch an OIDC token. Did you remember to add `id-token: - write` to your workflow permissions?"* — an error that accuses the workflow - file even though the permission is declared on line 43, sending anyone who - debugs from the message alone to edit code that is already correct - (run 33611167884, PR #2296). Re-running with "Approve and run" does not - restore secrets, and `GITHUB_TOKEN` is read-only on fork PRs, so the - `gh pr comment` the job is asked to make could not have posted either — three - independent blockers, any one of them fatal. The job now carries - `if: github.event.pull_request.head.repo.full_name == github.repository` - and reports **skipped** on forks, which is what actually happened. Dependabot - is unaffected: its branches live in this repository, so the guard passes and - the OIDC exchange works as before (run 33788653880). The comment above the - guard records why `pull_request_target` is not the fix — it would put the - OAuth secret and a write-capable token in a job checking out untrusted head - code — and points at the `@claude` mention path in - `.github/workflows/claude.yml` for reviewing a fork PR on demand. diff --git a/changelog.d/corpus-default-agent.added.md b/changelog.d/corpus-default-agent.added.md deleted file mode 100644 index 2a872da9dc..0000000000 --- a/changelog.d/corpus-default-agent.added.md +++ /dev/null @@ -1,44 +0,0 @@ -- **`Corpus.default_agent`, plus the two changes that make it mean anything.** - A corpus could not have a default agent. A chat opened with no `agent_id` - always resolved to the GLOBAL `default-corpus-agent` slug, so an - `AgentConfiguration` scoped to a corpus could never be its default however it - was configured — and even when one *was* resolved, its `system_instructions` - and `available_tools` were discarded, because those apply only on the - explicit-`?agent_id=` path. Three things follow: - - `Corpus.default_agent` (FK, mirroring `CorpusGroup.default_agent`). An - explicit pointer, not "pick a CORPUS-scoped agent": corpora already carry - scoped agents for other purposes (the inline moderator), and choosing - positionally would silently hand chat to whichever sorted first. - `Corpus.save` refuses a pointer at an agent scoped to a *different* - corpus — that would serve one corpus's private instructions to another's - users. - - Resolution priority 3 consults it before the global slug. An inactive - target falls back rather than failing, which is what switching an agent - off is asking for. - - Fallback-resolved configs are applied when `system_instructions_mode` is - `EXTEND`. The exclusion was written when REPLACE was the only mode, and - its stated reason — a generic default would clobber the corpus persona — - is specific to REPLACE. EXTEND appends, so there is nothing to clobber; - excluding it only produced a default that resolved and was then silently - ignored. REPLACE keeps the original behaviour on the fallback path. -- **`install_domain_pack --consumer-corpus ` (contract assertion C8).** - A domain pack may now declare a `consumer_agent` — instructions, tools, and - `mode: EXTEND` — for the corpus that *consumes* the domain. The pack supplies - the text, because the group slug it names is the pack's own invention; the - operator supplies the corpus, because which corpus consumes a domain is not - knowable when the pack is written. `EXTEND` is required rather than - preferred: REPLACE would overwrite the consuming corpus's persona with - third-party text, which is the coupling `DOMAIN_PACKS.md` forbids. Declaring - one without binding it reports that it was not applied (C5) instead of - passing silently, and the same group-slug check the orchestrator is held to - applies here — `search_across_corpora` takes the slug as a required - argument, so an agent never told it cannot call it. `consumer_agent.preferred_llm` - gets the same C3-style pre-validation as the orchestrator's, so an unusable - model spec fails cleanly instead of surfacing as a bare `ValidationError` - traceback from inside `consumer.save()`. And every part of C8 that is - decidable from the files — `mode`, `instructions_file`, `tools`, the - group-slug check, `preferred_llm` — is now previewed by `--check` too - (`_preflight`, shared with the real-install path via - `_consumer_agent_violations`), matching what C1-C7 already guaranteed; - previously `--check` reported "0 violations" for a `consumer_agent` that - would hard-fail the moment `--consumer-corpus` was supplied. diff --git a/changelog.d/django-image-budget.changed.md b/changelog.d/django-image-budget.changed.md deleted file mode 100644 index 9fd1f0da30..0000000000 --- a/changelog.d/django-image-budget.changed.md +++ /dev/null @@ -1,24 +0,0 @@ -- **Release image publishing: raise the Django image size budget and add a - manual re-publish path** (`.github/workflows/docker-build-release.yml`). The - v3.0.0 release build was the first since v3.0.0.b1 to get past the runner's - disk limits and actually finish the Django image — and it then failed the - `Enforce Django image size budget` gate at 2.4 GiB against the 1.5 GiB - acceptance criterion from issue #1494. The gate runs before the push step, so - `ghcr.io/.../opencontractserver_django:v3.0.0` was never published while - frontend, postgres, and traefik were. Two changes: - - `DJANGO_IMAGE_BUDGET_BYTES` raised 1.5 GiB → 3 GiB, leaving ~25% headroom - over the measured size so the gate still catches a real regression. This is - a stopgap so releases can publish; the regression itself is tracked in - issue #2236. - - New `workflow_dispatch` trigger taking an existing `tag` input. A `release` - event runs the workflow file *as it existed at the tag*, so re-running a - failed release build can never pick up a workflow fix; dispatching from the - default branch builds the tag's source with the current workflow. All - `docker/metadata-action` tag patterns now derive from a single `BUILD_TAG` - (dispatch input, else the released tag) so a dispatched re-publish emits - byte-identical image tags to the release event it stands in for. - - Image provenance on a dispatched re-publish: `docker/metadata-action` - derives its sha-suffixed tag and `org.opencontainers.image.revision` label - from `github.sha`, which on a dispatch is the default branch rather than the - tag being built. Both now come from the actually-checked-out commit. No - change on a release event, where the two are the same commit. diff --git a/changelog.d/embedder-batch-timeout.fixed.md b/changelog.d/embedder-batch-timeout.fixed.md deleted file mode 100644 index 0b2c41d249..0000000000 --- a/changelog.d/embedder-batch-timeout.fixed.md +++ /dev/null @@ -1,17 +0,0 @@ -- Stopped large embedding backlogs from stalling indefinitely on the CPU-only - microservice embedder (`opencontractserver/constants/document_processing.py`). - `EMBEDDER_BATCH_REQUEST_TIMEOUT_SECONDS` was 60s while a batch of 100 - long texts — whole ordinance sections or statute chapters, as produced by - authority-pack ingestion — routinely takes longer than that on CPU. The - client retried the *entire* batch three times, so each task burned ~3 minutes - and requeued without progress. Observed on a real ingest: a ~2,800-task queue - draining at ~1 task/min with a continuous retry storm, and semantic search - returning "no results from either arm" because no annotation ever got an - embedding. - - The timeout is now 300s, and the microservice batch cap drops from 100 to 32 - (`MICROSERVICE_EMBEDDER_MAX_BATCH_SIZE`, with `EMBEDDING_API_BATCH_SIZE` - lowered from 50 to 32 to keep the `documents.E001` system check satisfied), so - a slow batch finishes once instead of being redone three times. Measured on - the same queue after the change: retries dropped from continuous to **zero** - and throughput rose from ~4 to ~117 completed tasks per 4 minutes. diff --git a/changelog.d/extract-e2e-badge-dialog.fixed.md b/changelog.d/extract-e2e-badge-dialog.fixed.md deleted file mode 100644 index a8c1d893d0..0000000000 --- a/changelog.d/extract-e2e-badge-dialog.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- Fixed the Extract PDF end-to-end test stalling behind badge celebrations by - labeling the celebration as a dialog and dismissing it through its Close - button. The Extract CI job now retains HTML reports and failure artifacts, - runs one complete attempt, and reserves time for diagnostics before timeout. diff --git a/changelog.d/flake8-isort-pin.fixed.md b/changelog.d/flake8-isort-pin.fixed.md deleted file mode 100644 index 1d358f31b4..0000000000 --- a/changelog.d/flake8-isort-pin.fixed.md +++ /dev/null @@ -1,12 +0,0 @@ -- Pinned the `flake8` pre-commit hook's `additional_dependencies` - (`.pre-commit-config.yaml`: `flake8-isort==7.0.0`, `isort==6.0.1`). They were - unpinned, and `additional_dependencies` are re-resolved every time a hook env - is rebuilt, so `flake8-isort` floated up to **isort 9.0.1** while the - standalone `isort` hook stayed `rev`-pinned to 6.0.1. The two versions - disagree about repeated `from X import (...)` statements — the shape isort 6 - itself emits for aliased imports — so `isort` kept files in a layout `flake8` - then rejected. The result was the `linter` job failing on `main` and on every - open PR with 10 `I001`/`I005` findings in - `opencontractserver/llms/agents/pydantic_ai_agents.py` and - `opencontractserver/utils/compact_pawls.py`, neither of which any of those PRs - touched. `isort` here must track the `isort` hook's `rev`. diff --git a/changelog.d/frontend-auth-session-hardening.fixed.md b/changelog.d/frontend-auth-session-hardening.fixed.md deleted file mode 100644 index ce114bf5f8..0000000000 --- a/changelog.d/frontend-auth-session-hardening.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -- Replace popup authentication with same-tab Auth0 Universal Login through the - official React SDK and its memory cache. Validate restored sessions against the - backend, recover from failed identity checks, preserve sessions on permission - denials, and prevent stale requests from restoring logged-out users. Local - username/password sessions survive tab refreshes without persisting profiles. diff --git a/changelog.d/house-bills-core-seams.added.md b/changelog.d/house-bills-core-seams.added.md deleted file mode 100644 index e5a889d95a..0000000000 --- a/changelog.d/house-bills-core-seams.added.md +++ /dev/null @@ -1,39 +0,0 @@ -- **Tier-2a bill-citation grammar** (`opencontractserver/enrichment/grammars.py::_bills`): - "H.R. 1234" / "S. 987" / joint, concurrent, and simple resolution forms now - extract as congress-unqualified shape keys (`hr:1234`, `s:987`, `hjres:44`, …) - with guards against `U.S.` reporter citations, `U.S.C.` cites, `§` sections, - UK-style lowercase `s. 987`, and state abbreviations ending in H/S - (`N.H. Res. 5`, `U.S. Res. 3` are not federal resolutions). Packs fold - these onto congress-qualified - keys via equivalences (the ECCN pattern). New classification vocabulary: - `AUTHORITY_TYPE_BILL`, `GRAMMAR_BILL_PREFIXES` - (`opencontractserver/enrichment/constants.py`) and - `AuthorityWeight.PROPOSED` (`opencontractserver/enrichment/authority_sources.py`) - for introduced-but-not-enacted instruments. `ALL_AUTHORITY_TYPES` backs the - `authority_type` choices on `AuthorityFrontier`/`AuthorityNamespace`/ - `CorpusReference`, so migration `annotations.0103` restates them (choices-only - `AlterField`s — no schema change) to keep migration state in sync. -- **Authority-section push endpoint** for external harvesters: - `POST /api/worker-uploads/authority-sections/` (+ status/list routes) accepts - a `parse_section_spec`-shaped JSON batch (+ optional equivalence rows) under - an existing `CorpusAccessToken`, stages it in the new - `WorkerAuthoritySectionBatch` model (migration `worker_uploads.0005`), and a - drain task (`process_pending_section_batches`) feeds - `bootstrap_authority_corpus` with `relink_async=True` and upserts - equivalences under source `worker:`. Target corpus always comes from - the token (IDOR-safe by construction), and the capability is an explicit - off-by-default grant — `CorpusAccessToken.can_push_authority_sections` - (migration `worker_uploads.0006`, `mint_worker_token - --allow-authority-sections`) — so pre-existing document-upload tokens do not - silently gain the larger blast radius. Payload cap via new - `MAX_AUTHORITY_SECTION_PAYLOAD_BYTES` setting; per-execution drain cap via - new `WORKER_AUTHORITY_SECTION_BATCH_CAP` (default 5, self-re-enqueues while - more remain, mirroring `process_pending_uploads`) so a backlog cannot - monopolise the `worker_uploads` queue; the token is re-validated at drain - time (active, unexpired, still capability-bearing), so revoking a token - stops batches already staged under it; stalled batches recovered by - the existing `recover_stalled_uploads` sweep; periodic drain added to - `CELERY_BEAT_SCHEDULE`. Docs: `docs/guides/ingesting-authorities.md` Part 3. - This gives any external harvester (legislation feeds, eCFR watchers, caselaw - feeds) a continuous remote update path that previously required a full pack - re-install or in-container management commands. diff --git a/changelog.d/install-domain-pack.added.md b/changelog.d/install-domain-pack.added.md deleted file mode 100644 index 41ff4910ce..0000000000 --- a/changelog.d/install-domain-pack.added.md +++ /dev/null @@ -1,22 +0,0 @@ -`install_domain_pack` installs a **domain pack**: a named set of base packs plus -the wiring that belongs to none of them — a corpus group, an orchestrator agent -bound to it, and cross-pack equivalences. - -A pack spanning several bodies of law used to install its content correctly and -land inert, because three things could not be expressed in a pack manifest and -had to be created by hand afterwards: the corpus group (without which there is -no cross-corpus retrieval at all), an orchestrator carrying -`search_across_corpora`, and the group slug somewhere the agent would read it — -the tool takes it as a required argument. The install reported success either -way. - -``` -python manage.py install_domain_pack --creator admin --public -python manage.py install_domain_pack --check # plan, writes nothing -python manage.py install_domain_pack --list -``` - -The install contract (C1–C7) is defined in the pack registry's -`DOMAIN_PACKS.md`, so both sides build to one spec. Everything decidable from -the files is decided before anything is written, on the `--check` path and the -install path alike, and all wiring runs in one transaction. diff --git a/changelog.d/mypy-hook-stub-parity.changed.md b/changelog.d/mypy-hook-stub-parity.changed.md deleted file mode 100644 index 80cee381f4..0000000000 --- a/changelog.d/mypy-hook-stub-parity.changed.md +++ /dev/null @@ -1,23 +0,0 @@ -- Synced the `mypy` pre-commit hook's stub pins with `requirements/local.txt` - (`.pre-commit-config.yaml`: `django-stubs` 6.0.6 → 6.1.0, - `djangorestframework-stubs` 3.17.0 → 3.18.0). The hook's own comment requires - these to match the requirements pins; they had drifted, so the type-checking - CI runs against different stubs than the dev/test image installs. -- Fixed the 7 type errors `django-stubs` 6.1.0 surfaces, none of which change - runtime behavior: - - `opencontractserver/shared/QuerySets.py` (6 errors, lines 466/487/655/656/672/673): - six `permitted_ids`-style variables are assigned a lazy `values_list` queryset - in a `try` arm and a plain `[]` in the matching `except LookupError` arm. 6.1.0 - types `values_list(..., flat=True)` precisely enough that the two arms no longer - unify, so each variable now carries an explicit `Iterable[Any]` declaration — - the honest common type, since every one of them is only ever fed to an `__in` - lookup. - - `opencontractserver/tests/test_corpus_canonical_caml_migration.py:157`: - `apps.get_model("corpuses", "CorpusDescriptionRevision")` is deliberately - unresolvable (the test asserts the model was dropped), but 6.1.0's plugin - resolves *literal* `get_model()` string pairs at type-check time and errors - on a miss. Whether it fires is interpreter-dependent (it does on 3.11, does - not on CI's 3.12), so a `# type: ignore` would itself be flagged unused on - one of them under `warn_unused_ignores`. The model name now lives in a - `str`-annotated local, denying the plugin a literal to match on either. - Runtime behavior and the assertion are unchanged. diff --git a/changelog.d/pin-mcp-precommit-mypy-hook.fixed.md b/changelog.d/pin-mcp-precommit-mypy-hook.fixed.md deleted file mode 100644 index 84a5f80b76..0000000000 --- a/changelog.d/pin-mcp-precommit-mypy-hook.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Pinned the `mcp` `additional_dependencies` entry in the mypy pre-commit hook (`.pre-commit-config.yaml`) to `>=1.28.1,<2`, matching the runtime pin in `requirements/base.txt`. The previous unbounded `mcp>=1.0.0` let the hook resolve `mcp` 2.x, whose SDK renamed/removed fields and methods (`Server.list_resources`, `uriTemplate`→`uri_template`, `inputSchema`→`input_schema`) that `opencontractserver/mcp/server.py` (written against the 1.x API) still uses — producing 48 unrelated mypy errors on every PR's `linter`/`backend-ci-gate` checks regardless of what the PR actually changed. diff --git a/changelog.d/remove-graphene-dependencies.changed.md b/changelog.d/remove-graphene-dependencies.changed.md deleted file mode 100644 index fc960d5a92..0000000000 --- a/changelog.d/remove-graphene-dependencies.changed.md +++ /dev/null @@ -1,8 +0,0 @@ -- Removed Graphene, graphene-django, django-graphql-jwt, and graphql-relay - dependencies. JWT authentication now uses the shared Django/PyJWT runtime in - `config/jwt_auth/`; global IDs and cursor pagination preserve the existing API - format. Deployments using the optional refresh-token app should replace - `graphql_jwt.refresh_token` with `config.jwt_auth.refresh_token` in - `INSTALLED_APPS`; existing tables and migration history are preserved. -- Removed test-only resolver methods and the Graphene schema accessor, expanded - SDL parity checks, and made validation extensions independent per request. diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index 44ce6bbe8e..89b48eabab 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -1,8 +1,7 @@ import { OS_LEGAL_COLORS } from "./osLegalStyles"; -// Rendered in the NavBar version pill. v3.0.0.rc1 ships the cite rebrand -// as a release candidate; the GA cut will drop the suffix. -export const VERSION_TAG = "v3.0.0.rc1"; +// Rendered in the NavBar version pill. Keep in sync with the release tag. +export const VERSION_TAG = "v3.1.0"; // Small-mobile breakpoint - used by chat widget styles for the tightest // viewports (very small phones), where chat-bubble arrows are removed and // font sizes shrink. Distinct from MOBILE_VIEW_BREAKPOINT (600).