From dd6ca2958d418ff283fcc7dcb61803c801f32345 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Sat, 26 Sep 2026 20:38:32 +0530 Subject: [PATCH] Add unit tests for organization admin checks and saved views provider - Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role. - Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views. - Enhance ticket tree tests to accurately reflect inactive and merged ticket handling. - Update CSV import tests to verify error handling and file saving functionality. - Introduce tests for deal cards to ensure correct display of next steps and task statuses. --- backend/.env.example | 11 + backend/accounts/access.py | 15 +- .../tests/test_account_access_and_rollups.py | 20 +- .../tests/test_account_detail_visibility.py | 3 +- .../test_account_edit_keeps_what_it_omits.py | 2 +- .../tests/test_account_existence_oracle.py | 149 ++++++ .../test_account_superuser_and_lead_picker.py | 4 +- backend/accounts/tests/test_accounts_api.py | 28 +- backend/accounts/tests/test_create_mail.py | 10 +- .../tests/test_nested_account_and_pickers.py | 2 +- backend/accounts/views.py | 93 ++-- backend/cases/access.py | 49 +- backend/cases/analytics_views.py | 25 +- backend/cases/approval_views.py | 11 +- backend/cases/approvals.py | 5 + backend/cases/bulk_views.py | 36 +- backend/cases/help_center_views.py | 15 +- backend/cases/kanban_views.py | 49 +- backend/cases/kb_views.py | 8 +- backend/cases/merge_views.py | 16 +- backend/cases/parent_views.py | 44 +- backend/cases/serializer.py | 12 +- backend/cases/signals.py | 10 +- backend/cases/tests/test_approval_authz.py | 3 +- .../cases/tests/test_case_access_and_sla.py | 14 +- .../test_case_comment_attachment_scope.py | 172 ++++++ backend/cases/tests/test_case_export.py | 6 +- backend/cases/tests/test_case_merge.py | 5 +- ..._case_read_rule_superuser_and_analytics.py | 128 +++++ backend/cases/tests/test_cases_api.py | 65 ++- backend/cases/tests/test_cases_bulk.py | 14 +- backend/cases/tests/test_cases_kanban.py | 4 +- .../cases/tests/test_help_center_public.py | 39 +- .../cases/tests/test_hidden_ticket_is_404.py | 169 ++++++ .../tests/test_kanban_move_write_rule.py | 34 +- backend/cases/tests/test_merge_targets.py | 12 +- .../cases/tests/test_merged_from_read_rule.py | 2 +- .../tests/test_merged_redirect_read_rule.py | 4 +- .../cases/tests/test_merged_status_locked.py | 238 +++++++++ .../cases/tests/test_parent_summary_access.py | 2 +- .../cases/tests/test_parent_views_access.py | 10 +- backend/cases/tests/test_solution_access.py | 6 +- .../cases/tests/test_time_entry_write_rule.py | 88 ++++ backend/cases/tests/test_time_tracking.py | 1 + backend/cases/time_views.py | 24 +- backend/cases/unmerge_views.py | 7 +- backend/cases/views.py | 54 +- backend/cases/workflow.py | 25 + backend/common/audit_log.py | 60 ++- backend/common/lookups.py | 21 + backend/common/middleware/get_company.py | 2 +- backend/common/migrations/0044_saved_view.py | 139 +++++ .../0045_securityauditlog_webhook_events.py | 38 ++ backend/common/models.py | 55 +- backend/common/packs/schema.py | 12 + backend/common/permissions.py | 51 +- backend/common/request_meta.py | 68 +-- backend/common/rls/__init__.py | 11 +- backend/common/saved_views.py | 303 +++++++++++ backend/common/scopes.py | 6 + backend/common/serializer.py | 104 +++- .../tests/test_api_settings_tags_assignees.py | 140 ++++- backend/common/tests/test_audit_log.py | 6 +- backend/common/tests/test_audit_log_api.py | 231 ++++++++ .../tests/test_audit_log_not_exposed.py | 14 +- backend/common/tests/test_comment_authz.py | 9 +- .../tests/test_endpoint_index_generator.py | 52 ++ backend/common/tests/test_file_downloads.py | 76 ++- .../tests/test_list_read_rule_parity.py | 39 +- .../tests/test_login_email_read_only.py | 205 +++++++ .../tests/test_packs_duplicate_stage.py | 56 ++ backend/common/tests/test_request_meta.py | 44 +- backend/common/tests/test_saved_views.py | 354 +++++++++++++ .../tests/test_superuser_is_org_admin.py | 290 ++++++++++ backend/common/urls.py | 10 + backend/common/views/attachment_views.py | 97 ++-- backend/common/views/audit_log_views.py | 133 +++++ backend/common/views/auth_views.py | 26 +- backend/common/views/dashboard_views.py | 4 +- backend/common/views/document_views.py | 6 +- backend/common/views/org_settings_views.py | 4 +- backend/common/views/organization_views.py | 14 +- backend/common/views/pack_views.py | 9 +- backend/common/views/portal_auth_views.py | 3 +- backend/common/views/saved_view_views.py | 107 ++++ backend/common/views/settings_views.py | 94 ++-- backend/common/views/tags_views.py | 10 +- backend/common/views/user_views.py | 34 +- backend/contacts/access.py | 19 +- .../tests/test_contact_access_and_links.py | 14 +- .../tests/test_contact_access_superuser.py | 4 +- .../tests/test_contact_existence_oracle.py | 133 +++++ .../test_contact_link_requires_access.py | 10 +- backend/contacts/tests/test_contacts_api.py | 34 +- .../tests/test_nested_contact_fields.py | 2 +- backend/contacts/views.py | 76 ++- backend/crm/settings.py | 13 +- backend/invoices/admin.py | 113 +++- backend/invoices/api_views.py | 333 +++++++----- .../0012_tax_rate_and_shipping_validators.py | 91 ++++ backend/invoices/models.py | 67 ++- backend/invoices/permissions.py | 47 +- backend/invoices/public_views.py | 15 +- backend/invoices/serializer.py | 80 ++- backend/invoices/tasks.py | 19 +- .../invoices/tests/test_admin_issued_lock.py | 139 +++++ .../tests/test_empty_schedule_auto_send.py | 164 ++++++ .../invoices/tests/test_estimate_accept_ip.py | 57 ++ ...test_estimate_recurring_list_visibility.py | 87 +++ .../tests/test_hidden_document_is_404.py | 149 ++++++ .../test_invoice_attachment_detail_access.py | 122 +++++ .../test_invoice_comment_detail_access.py | 134 +++++ .../tests/test_invoice_object_permissions.py | 22 +- backend/invoices/tests/test_invoices_api.py | 51 +- .../tests/test_issued_documents_locked.py | 389 ++++++++++++++ .../tests/test_issued_lock_row_locking.py | 123 +++++ .../tests/test_issued_totals_frozen.py | 19 +- .../tests/test_tax_and_shipping_bounds.py | 243 +++++++++ backend/leads/access.py | 22 +- .../tests/test_attachment_delete_authz.py | 8 +- .../test_convert_requires_account_access.py | 2 +- .../leads/tests/test_create_lead_from_site.py | 2 +- .../tests/test_lead_access_and_totals.py | 10 +- .../leads/tests/test_lead_existence_oracle.py | 157 ++++++ .../leads/tests/test_lead_move_wip_limit.py | 111 ++++ backend/leads/tests/test_leads_api.py | 20 +- backend/leads/views/kanban_views.py | 46 +- backend/leads/views/lead_interactions.py | 38 +- backend/leads/views/lead_views.py | 83 +-- backend/opportunity/access.py | 26 +- backend/opportunity/next_activity.py | 109 ++++ backend/opportunity/serializer.py | 27 + .../tests/test_attachment_delete_authz.py | 8 +- .../tests/test_deal_access_and_totals.py | 9 +- .../tests/test_deal_existence_oracle.py | 242 +++++++++ .../tests/test_deal_next_activity.py | 241 +++++++++ .../tests/test_opportunities_api.py | 35 +- .../tests/test_opportunity_move.py | 4 +- backend/opportunity/tests/test_sales_goals.py | 46 +- backend/opportunity/views/aging_views.py | 2 +- backend/opportunity/views/goal_views.py | 66 ++- backend/opportunity/views/kanban_views.py | 57 +- backend/opportunity/views/line_item_views.py | 95 +--- .../views/opportunity_interactions.py | 38 +- .../opportunity/views/opportunity_views.py | 57 +- backend/opportunity/views/pipeline_views.py | 2 +- backend/pyproject.toml | 2 +- backend/scripts/generate_endpoint_index.py | 47 +- backend/tasks/apps.py | 3 + ...4_unassign_non_members_from_board_cards.py | 59 +++ backend/tasks/signals.py | 32 ++ ...nt_and_comment_reach_only_visible_tasks.py | 164 ++++++ .../tasks/tests/test_board_member_removal.py | 162 ++++++ .../tests/test_kanban_move_write_rule.py | 19 +- backend/tasks/tests/test_task_access.py | 7 +- .../test_task_contact_link_requires_access.py | 4 +- backend/tasks/tests/test_tasks_api.py | 40 +- backend/tasks/views/kanban_views.py | 14 +- backend/tasks/views/task_views.py | 54 +- backend/uv.lock | 2 +- backend/webforms/dynamic_serializer.py | 17 + backend/webforms/models.py | 4 +- .../webforms/tests/test_phone_validation.py | 97 ++++ backend/webforms/tests/test_submit.py | 41 +- backend/webforms/throttles.py | 11 +- backend/webforms/views.py | 2 +- backend/webhooks/apps.py | 8 +- backend/webhooks/ownership.py | 198 +++++++ backend/webhooks/serializers.py | 10 + backend/webhooks/tasks.py | 14 +- backend/webhooks/tests/conftest.py | 5 +- backend/webhooks/tests/test_api.py | 4 +- backend/webhooks/tests/test_ownership.py | 381 ++++++++++++++ backend/webhooks/views.py | 55 +- docs/api/audit-log.md | 86 +++ docs/api/endpoint-index.md | 16 +- docs/api/exports.md | 88 ++++ docs/api/saved-views.md | 118 +++++ docs/reference/environment-variables.md | 1 + frontend/src/hooks.server.js | 13 +- frontend/src/lib/admin.js | 23 + frontend/src/lib/admin.test.js | 30 ++ .../contacts/ContactImportDrawer.svelte | 21 +- .../components/leads/LeadImportDrawer.svelte | 21 +- .../tickets/LinkParentDialog.svelte | 186 ------- .../tickets/TicketImportDrawer.svelte | 21 +- .../components/tickets/TicketTreePanel.svelte | 191 ------- frontend/src/lib/components/tickets/index.js | 2 - frontend/src/lib/server/v2/accounts.js | 12 +- frontend/src/lib/server/v2/audit-log.js | 74 +++ frontend/src/lib/server/v2/audit-log.test.js | 54 ++ frontend/src/lib/server/v2/business-hours.js | 6 +- frontend/src/lib/server/v2/contacts.js | 14 +- frontend/src/lib/server/v2/csv-export.test.js | 13 + frontend/src/lib/server/v2/custom-fields.js | 4 +- frontend/src/lib/server/v2/deal-pipelines.js | 4 +- frontend/src/lib/server/v2/deals.js | 47 +- frontend/src/lib/server/v2/deals.test.js | 58 ++ .../lib/server/v2/detail-not-found.test.js | 37 ++ frontend/src/lib/server/v2/documents.js | 24 +- frontend/src/lib/server/v2/escalation.js | 4 +- frontend/src/lib/server/v2/files.js | 6 +- frontend/src/lib/server/v2/filter-params.js | 49 +- .../src/lib/server/v2/filter-params.test.js | 23 +- frontend/src/lib/server/v2/goals.js | 30 +- frontend/src/lib/server/v2/goals.test.js | 19 +- frontend/src/lib/server/v2/inbound-email.js | 4 +- frontend/src/lib/server/v2/invoices.js | 10 +- frontend/src/lib/server/v2/lead-pipelines.js | 4 +- frontend/src/lib/server/v2/leads.js | 13 +- frontend/src/lib/server/v2/list-queries.js | 9 +- frontend/src/lib/server/v2/macros.js | 4 +- frontend/src/lib/server/v2/macros.test.js | 19 +- frontend/src/lib/server/v2/organization.js | 28 +- .../src/lib/server/v2/organization.test.js | 30 ++ frontend/src/lib/server/v2/products.js | 27 +- frontend/src/lib/server/v2/recurring.js | 19 +- frontend/src/lib/server/v2/recurring.test.js | 25 +- frontend/src/lib/server/v2/reopen.js | 4 +- frontend/src/lib/server/v2/routing.js | 4 +- frontend/src/lib/server/v2/saved-views.js | 216 ++++++++ .../src/lib/server/v2/saved-views.test.js | 249 +++++++++ frontend/src/lib/server/v2/tags.js | 23 +- frontend/src/lib/server/v2/templates.js | 4 +- frontend/src/lib/server/v2/templates.test.js | 27 +- .../src/lib/server/v2/ticket-approvals.js | 4 +- .../src/lib/server/v2/tickets-bulk.test.js | 4 +- .../src/lib/server/v2/tickets-parent.test.js | 72 +++ .../server/v2/tickets-status-choices.test.js | 17 +- frontend/src/lib/server/v2/tickets.js | 58 +- frontend/src/lib/server/v2/web-forms.js | 6 +- frontend/src/lib/server/v2/web-forms.test.js | 2 +- frontend/src/lib/utils/csv.js | 54 ++ frontend/src/lib/utils/csv.test.js | 61 +++ frontend/src/lib/v2/audit-log.js | 48 ++ frontend/src/lib/v2/audit-log.test.js | 56 ++ .../src/lib/v2/components/FilterBar.svelte | 31 +- .../lib/v2/components/LineItemsEditor.svelte | 12 +- .../src/lib/v2/components/SavedViews.svelte | 269 ++++++++++ .../src/lib/v2/components/SectionTabs.svelte | 11 +- frontend/src/lib/v2/components/Sidebar.svelte | 11 +- frontend/src/lib/v2/deal-next-step.js | 26 + frontend/src/lib/v2/deal-next-step.test.js | 42 ++ frontend/src/lib/v2/filters.js | 52 +- frontend/src/lib/v2/filters.test.js | 24 + frontend/src/lib/v2/line-items.js | 66 +++ frontend/src/lib/v2/line-items.test.js | 57 +- frontend/src/lib/v2/saved-views.js | 114 ++++ frontend/src/lib/v2/saved-views.test.js | 101 ++++ frontend/src/routes/(app)/+layout.server.js | 12 +- frontend/src/routes/(app)/+layout.svelte | 7 +- .../src/routes/(app)/accounts/+page.server.js | 10 +- .../src/routes/(app)/accounts/+page.svelte | 1 + .../src/routes/(app)/contacts/+page.server.js | 10 +- .../src/routes/(app)/contacts/+page.svelte | 1 + .../documents/[id]/edit/save-action.test.js | 4 +- .../(app)/goals/[id]/edit/+page.server.js | 2 +- .../src/routes/(app)/invoices/+page.server.js | 15 +- .../src/routes/(app)/invoices/+page.svelte | 1 + .../(app)/invoices/[id]/+page.server.js | 28 +- .../routes/(app)/invoices/[id]/pdf/+server.js | 2 +- .../(app)/invoices/estimates/+page.server.js | 17 +- .../(app)/invoices/estimates/new/+page.svelte | 52 +- .../routes/(app)/invoices/new/+page.svelte | 65 ++- .../(app)/invoices/recurring/+page.server.js | 9 +- .../(app)/invoices/recurring/new/+page.svelte | 254 +++------ .../(app)/invoices/templates/+page.server.js | 3 +- .../invoices/templates/new/+page.server.js | 5 +- .../src/routes/(app)/leads/+page.server.js | 10 +- frontend/src/routes/(app)/leads/+page.svelte | 1 + .../routes/(app)/leads/[id]/+page.server.js | 3 +- .../src/routes/(app)/pipeline/+page.server.js | 16 +- .../src/routes/(app)/pipeline/+page.svelte | 51 +- .../src/routes/(app)/settings/+page.svelte | 13 +- .../(app)/settings/audit-log/+page.server.js | 15 + .../(app)/settings/audit-log/+page.svelte | 228 ++++++++ .../settings/deal-pipelines/actions.test.js | 9 +- .../settings/lead-pipelines/actions.test.js | 9 +- .../(app)/settings/webhooks/+page.svelte | 20 +- .../(app)/settings/webhooks/[id]/+page.svelte | 17 +- .../routes/(app)/solutions/+page.server.js | 3 +- .../(app)/solutions/[id]/+page.server.js | 3 +- .../(app)/solutions/[id]/edit/+page.server.js | 3 +- .../(app)/solutions/new/+page.server.js | 3 +- .../src/routes/(app)/tasks/+page.server.js | 3 +- .../routes/(app)/tasks/[id]/+page.server.js | 3 +- .../src/routes/(app)/tickets/+page.server.js | 8 +- .../src/routes/(app)/tickets/+page.svelte | 2 + .../routes/(app)/tickets/[id]/+page.server.js | 88 +++- .../routes/(app)/tickets/[id]/+page.svelte | 404 +++++++++++--- .../src/routes/(app)/tickets/[id]/close.js | 13 +- .../routes/(app)/tickets/[id]/close.test.js | 18 +- .../src/routes/(app)/tickets/[id]/tree.js | 112 ++++ .../routes/(app)/tickets/[id]/tree.test.js | 138 +++++ .../src/routes/api/cases/[id]/link/+server.js | 23 - .../src/routes/api/cases/[id]/tree/+server.js | 14 - mkdocs.yml | 3 + mobile/docs/PARITY.md | 10 +- mobile/lib/config/api_config.dart | 13 + mobile/lib/data/models/audit_entry.dart | 101 ++++ mobile/lib/data/models/auth_response.dart | 38 ++ mobile/lib/data/models/deal.dart | 37 ++ mobile/lib/data/models/invoice.dart | 17 + mobile/lib/data/models/webhook.dart | 25 +- mobile/lib/providers/accounts_provider.dart | 17 +- mobile/lib/providers/analytics_provider.dart | 118 +++-- mobile/lib/providers/audit_log_provider.dart | 85 +++ mobile/lib/providers/auth_provider.dart | 21 +- mobile/lib/providers/contacts_provider.dart | 9 +- mobile/lib/providers/csv_import_provider.dart | 68 +++ mobile/lib/providers/deals_provider.dart | 48 +- mobile/lib/providers/invoices_provider.dart | 10 + mobile/lib/providers/leads_provider.dart | 50 +- .../lib/providers/saved_views_provider.dart | 201 +++++++ mobile/lib/providers/tickets_provider.dart | 116 +++- mobile/lib/routes/app_router.dart | 11 + .../accounts/account_detail_screen.dart | 17 +- .../accounts/accounts_list_screen.dart | 31 ++ .../contacts/contact_detail_screen.dart | 17 +- .../contacts/contacts_list_screen.dart | 20 + .../lib/screens/deals/deal_detail_screen.dart | 24 +- .../lib/screens/deals/deals_list_screen.dart | 53 ++ .../invoices/document_adjustments.dart | 222 ++++++++ .../invoices/invoice_detail_screen.dart | 8 + .../invoices/invoices_list_screen.dart | 25 + .../lib/screens/invoices/line_item_sheet.dart | 46 +- .../screens/invoices/new_estimate_screen.dart | 30 +- .../screens/invoices/new_invoice_screen.dart | 35 +- .../invoices/new_recurring_screen.dart | 152 ++---- .../lib/screens/leads/lead_detail_screen.dart | 24 +- .../lib/screens/leads/leads_list_screen.dart | 45 ++ .../screens/settings/audit_log_screen.dart | 490 +++++++++++++++++ .../screens/settings/settings_hub_screen.dart | 13 + .../settings/webhook_detail_screen.dart | 23 +- .../lib/screens/settings/webhooks_screen.dart | 15 +- .../tickets/ticket_analytics_screen.dart | 134 +++-- .../screens/tickets/ticket_detail_screen.dart | 223 +++++--- .../screens/tickets/tickets_list_screen.dart | 37 +- mobile/lib/services/auth_service.dart | 9 +- mobile/lib/widgets/cards/deal_card.dart | 47 ++ .../widgets/common/saved_views_button.dart | 404 ++++++++++++++ .../lib/widgets/forms/csv_import_sheet.dart | 203 ++++++- .../tickets/ticket_approval_panel.dart | 30 +- .../tickets/ticket_solutions_panel.dart | 37 +- .../widgets/tickets/ticket_time_panel.dart | 20 +- mobile/test/screens/account_rollups_test.dart | 13 +- .../contacts/contact_form_account_test.dart | 3 +- .../invoices/document_adjustments_test.dart | 482 +++++++++++++++++ .../estimate_and_deal_billing_test.dart | 5 +- .../screens/leads/lead_form_status_test.dart | 6 +- .../screens/leads/lead_form_tags_test.dart | 6 +- mobile/test/screens/phone_viewport_test.dart | 13 +- .../test/screens/saved_views_button_test.dart | 439 +++++++++++++++ .../settings/audit_log_screen_test.dart | 174 ++++++ .../settings/webhooks_screen_test.dart | 71 ++- .../solution_detail_screen_test.dart | 8 +- .../ticket_detail_redirect_and_gate_test.dart | 498 ++++++++++++++++++ .../analytics_provider_sections_test.dart | 203 +++++++ .../test/unit/csv_import_provider_test.dart | 91 ++++ mobile/test/unit/detail_not_found_test.dart | 123 +++++ mobile/test/unit/is_org_admin_test.dart | 130 +++++ .../test/unit/saved_views_provider_test.dart | 369 +++++++++++++ mobile/test/unit/ticket_tree_close_test.dart | 27 +- .../test/widgets/csv_import_sheet_test.dart | 100 +++- mobile/test/widgets/deal_next_step_test.dart | 162 ++++++ 365 files changed, 18884 insertions(+), 2879 deletions(-) create mode 100644 backend/accounts/tests/test_account_existence_oracle.py create mode 100644 backend/cases/tests/test_case_comment_attachment_scope.py create mode 100644 backend/cases/tests/test_case_read_rule_superuser_and_analytics.py create mode 100644 backend/cases/tests/test_hidden_ticket_is_404.py create mode 100644 backend/cases/tests/test_merged_status_locked.py create mode 100644 backend/cases/tests/test_time_entry_write_rule.py create mode 100644 backend/common/migrations/0044_saved_view.py create mode 100644 backend/common/migrations/0045_securityauditlog_webhook_events.py create mode 100644 backend/common/saved_views.py create mode 100644 backend/common/tests/test_audit_log_api.py create mode 100644 backend/common/tests/test_endpoint_index_generator.py create mode 100644 backend/common/tests/test_login_email_read_only.py create mode 100644 backend/common/tests/test_packs_duplicate_stage.py create mode 100644 backend/common/tests/test_saved_views.py create mode 100644 backend/common/tests/test_superuser_is_org_admin.py create mode 100644 backend/common/views/audit_log_views.py create mode 100644 backend/common/views/saved_view_views.py create mode 100644 backend/contacts/tests/test_contact_existence_oracle.py create mode 100644 backend/invoices/migrations/0012_tax_rate_and_shipping_validators.py create mode 100644 backend/invoices/tests/test_admin_issued_lock.py create mode 100644 backend/invoices/tests/test_empty_schedule_auto_send.py create mode 100644 backend/invoices/tests/test_estimate_accept_ip.py create mode 100644 backend/invoices/tests/test_estimate_recurring_list_visibility.py create mode 100644 backend/invoices/tests/test_hidden_document_is_404.py create mode 100644 backend/invoices/tests/test_invoice_attachment_detail_access.py create mode 100644 backend/invoices/tests/test_invoice_comment_detail_access.py create mode 100644 backend/invoices/tests/test_issued_documents_locked.py create mode 100644 backend/invoices/tests/test_issued_lock_row_locking.py create mode 100644 backend/invoices/tests/test_tax_and_shipping_bounds.py create mode 100644 backend/leads/tests/test_lead_existence_oracle.py create mode 100644 backend/leads/tests/test_lead_move_wip_limit.py create mode 100644 backend/opportunity/next_activity.py create mode 100644 backend/opportunity/tests/test_deal_existence_oracle.py create mode 100644 backend/opportunity/tests/test_deal_next_activity.py create mode 100644 backend/tasks/migrations/0014_unassign_non_members_from_board_cards.py create mode 100644 backend/tasks/signals.py create mode 100644 backend/tasks/tests/test_attachment_and_comment_reach_only_visible_tasks.py create mode 100644 backend/tasks/tests/test_board_member_removal.py create mode 100644 backend/webforms/tests/test_phone_validation.py create mode 100644 backend/webhooks/ownership.py create mode 100644 backend/webhooks/tests/test_ownership.py create mode 100644 docs/api/audit-log.md create mode 100644 docs/api/exports.md create mode 100644 docs/api/saved-views.md create mode 100644 frontend/src/lib/admin.js create mode 100644 frontend/src/lib/admin.test.js delete mode 100644 frontend/src/lib/components/tickets/LinkParentDialog.svelte delete mode 100644 frontend/src/lib/components/tickets/TicketTreePanel.svelte create mode 100644 frontend/src/lib/server/v2/audit-log.js create mode 100644 frontend/src/lib/server/v2/audit-log.test.js create mode 100644 frontend/src/lib/server/v2/detail-not-found.test.js create mode 100644 frontend/src/lib/server/v2/organization.test.js create mode 100644 frontend/src/lib/server/v2/saved-views.js create mode 100644 frontend/src/lib/server/v2/saved-views.test.js create mode 100644 frontend/src/lib/utils/csv.js create mode 100644 frontend/src/lib/utils/csv.test.js create mode 100644 frontend/src/lib/v2/audit-log.js create mode 100644 frontend/src/lib/v2/audit-log.test.js create mode 100644 frontend/src/lib/v2/components/SavedViews.svelte create mode 100644 frontend/src/lib/v2/deal-next-step.js create mode 100644 frontend/src/lib/v2/deal-next-step.test.js create mode 100644 frontend/src/lib/v2/saved-views.js create mode 100644 frontend/src/lib/v2/saved-views.test.js create mode 100644 frontend/src/routes/(app)/settings/audit-log/+page.server.js create mode 100644 frontend/src/routes/(app)/settings/audit-log/+page.svelte create mode 100644 frontend/src/routes/(app)/tickets/[id]/tree.js create mode 100644 frontend/src/routes/(app)/tickets/[id]/tree.test.js delete mode 100644 frontend/src/routes/api/cases/[id]/link/+server.js delete mode 100644 frontend/src/routes/api/cases/[id]/tree/+server.js create mode 100644 mobile/lib/data/models/audit_entry.dart create mode 100644 mobile/lib/providers/audit_log_provider.dart create mode 100644 mobile/lib/providers/saved_views_provider.dart create mode 100644 mobile/lib/screens/invoices/document_adjustments.dart create mode 100644 mobile/lib/screens/settings/audit_log_screen.dart create mode 100644 mobile/lib/widgets/common/saved_views_button.dart create mode 100644 mobile/test/screens/invoices/document_adjustments_test.dart create mode 100644 mobile/test/screens/saved_views_button_test.dart create mode 100644 mobile/test/screens/settings/audit_log_screen_test.dart create mode 100644 mobile/test/screens/tickets/ticket_detail_redirect_and_gate_test.dart create mode 100644 mobile/test/unit/analytics_provider_sections_test.dart create mode 100644 mobile/test/unit/detail_not_found_test.dart create mode 100644 mobile/test/unit/is_org_admin_test.dart create mode 100644 mobile/test/unit/saved_views_provider_test.dart create mode 100644 mobile/test/widgets/deal_next_step_test.dart diff --git a/backend/.env.example b/backend/.env.example index d5fa5482..8d09513f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -53,6 +53,17 @@ CSRF_TRUSTED_ORIGINS=http://localhost:5181 # or the header becomes something any caller can forge. TRUST_PROXY_SSL_HEADER=False +# Reverse proxies in front of Django that append to X-Forwarded-For (nginx with +# $proxy_add_x_forwarded_for is one). Every recorded client IP (audit log, web +# form submissions, estimate acceptance, sign-in tokens) and every per-IP +# throttle trusts only that many entries from the right; unset uses REMOTE_ADDR. +# Leave it unset if the app server already resolves the client from trusted +# proxies (uvicorn --proxy-headers --forwarded-allow-ips=127.0.0.1). Set it to 1 +# when REMOTE_ADDR is the proxy itself (gunicorn behind one nginx), or every +# visitor shares the proxy's throttle bucket. Never set it on top of uvicorn's +# proxy handling: SvelteKit-relayed requests would then all count as the relay. +NUM_PROXIES= + # Shared cache. Required in production for DRF throttling to be enforced across # workers rather than per process. Without it the public web form rate limits # are per gunicorn worker and reset on restart. diff --git a/backend/accounts/access.py b/backend/accounts/access.py index a07e5c28..56a646dc 100644 --- a/backend/accounts/access.py +++ b/backend/accounts/access.py @@ -1,18 +1,15 @@ """Who may open an account. -Extracted from ``AccountDetailView.assert_account_access`` so the attachment -download view asks the same question rather than carrying a second copy of the -answer. The detail view still calls it. +One definition, asked by the detail and mail views (through +``visible_accounts_qs``, in the lookup itself, so a hidden account is the same +404 as a missing one) and by the attachment download (``has_account_access``). """ from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from accounts.models import Account from common.permissions import is_org_admin -_DENIED = "You do not have Permission to perform this action" - def has_account_access(profile, user, account): """Admins, superusers, the creator, and anyone assigned. Else refused. @@ -45,9 +42,3 @@ def visible_accounts_qs(profile, user): if is_org_admin(profile) or user.is_superuser: return qs return qs.filter(Q(created_by=profile.user) | Q(assigned_to=profile)).distinct() - - -def assert_account_access(profile, user, account): - """Raise 403 unless ``profile`` may open ``account``.""" - if not has_account_access(profile, user, account): - raise PermissionDenied(_DENIED) diff --git a/backend/accounts/tests/test_account_access_and_rollups.py b/backend/accounts/tests/test_account_access_and_rollups.py index d2851ad1..9e77a1b4 100644 --- a/backend/accounts/tests/test_account_access_and_rollups.py +++ b/backend/accounts/tests/test_account_access_and_rollups.py @@ -119,7 +119,7 @@ class TestAccountDetailAccess: def test_unrelated_non_admin_is_refused(self, user_client, account, admin_user): _created_by(account, admin_user) response = user_client.get(f"/api/accounts/{account.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_another_org_gets_404_not_403(self, org_b_client, account): """Wrong tenant is 'no such account', not 'not allowed'. @@ -139,7 +139,7 @@ class TestAccountDetailAccess: """ assert account.created_by is None response = user_client.get(f"/api/accounts/{account.id}/") - assert response.status_code == 403 + assert response.status_code == 404 @pytest.mark.django_db @@ -194,7 +194,7 @@ class TestAccountVerbsAgree: {"city": "Nowhere"}, content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 account.refresh_from_db() assert account.city is None @@ -205,7 +205,7 @@ class TestAccountVerbsAgree: {"name": "Renamed By A Stranger"}, content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 account.refresh_from_db() assert account.name == "Northwind Traders" @@ -222,7 +222,7 @@ class TestAccountVerbsAgree: {"name": ""}, content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_comment_on_missing_account_is_404(self, admin_client): response = admin_client.post( @@ -236,7 +236,7 @@ class TestAccountVerbsAgree: response = user_client.post( f"/api/accounts/{account.id}/", {"comment": "hello"} ) - assert response.status_code == 403 + assert response.status_code == 404 @pytest.mark.django_db @@ -283,6 +283,7 @@ class TestAttachmentDeleteIsScoped: was quietly admin-only. """ mine = self._attachment(org_a, account) + account.assigned_to.add(user_profile) # can open it Attachments.objects.filter(pk=mine.pk).update(created_by=user_profile.user) response = user_client.delete(f"/api/accounts/attachment/{mine.id}/") @@ -291,9 +292,10 @@ class TestAttachmentDeleteIsScoped: assert not Attachments.objects.filter(pk=mine.pk).exists() def test_other_users_attachment_is_refused( - self, user_client, admin_user, org_a, account + self, user_profile, user_client, admin_user, org_a, account ): theirs = self._attachment(org_a, account) + account.assigned_to.add(user_profile) # can open it Attachments.objects.filter(pk=theirs.pk).update(created_by=admin_user) response = user_client.delete(f"/api/accounts/attachment/{theirs.id}/") @@ -774,13 +776,13 @@ class TestSidePayloadsRespectRole: """The two doors must agree. This is the actual defect: the same lead id, refused by its own - endpoint and handed over by this one. Asserting the 403 here rather + endpoint and handed over by this one. Asserting the 404 here rather than only the absence keeps the test honest if the detail rule moves. """ stranger = self._lead(org_a, "Stranger") Lead.objects.filter(pk=stranger.pk).update(created_by=admin_user) - assert user_client.get(f"/api/leads/{stranger.id}/").status_code == 403 + assert user_client.get(f"/api/leads/{stranger.id}/").status_code == 404 payload = user_client.get("/api/accounts/").json() assert str(stranger.id) not in {row["id"] for row in payload["leads"]} diff --git a/backend/accounts/tests/test_account_detail_visibility.py b/backend/accounts/tests/test_account_detail_visibility.py index d0bd6a98..9f6003ef 100644 --- a/backend/accounts/tests/test_account_detail_visibility.py +++ b/backend/accounts/tests/test_account_detail_visibility.py @@ -110,7 +110,8 @@ def test_hidden_records_are_refused_by_their_own_endpoints(user_client, account) module's own detail route refuses too.""" hidden_deal = Opportunity.objects.get(name="Hidden deal") hidden_task = Task.objects.get(title="Hidden task") - assert user_client.get(f"/api/opportunities/{hidden_deal.pk}/").status_code == 403 + # 404 for a deal: a hidden one answers as a missing one (1.11.0). + assert user_client.get(f"/api/opportunities/{hidden_deal.pk}/").status_code == 404 assert user_client.get(f"/api/tasks/{hidden_task.pk}/").status_code == 403 diff --git a/backend/accounts/tests/test_account_edit_keeps_what_it_omits.py b/backend/accounts/tests/test_account_edit_keeps_what_it_omits.py index 88aa40fd..7510c42b 100644 --- a/backend/accounts/tests/test_account_edit_keeps_what_it_omits.py +++ b/backend/accounts/tests/test_account_edit_keeps_what_it_omits.py @@ -64,7 +64,7 @@ class TestDetailListsVisibleContacts: """The filter mirrors the contact endpoint's own rule, not a new one.""" hidden = user_client.get(f"/api/contacts/{world['hidden'].id}/") mine = user_client.get(f"/api/contacts/{world['mine'].id}/") - assert hidden.status_code == 403 + assert hidden.status_code == 404 assert mine.status_code == 200 def test_admin_sees_every_contact(self, admin_client, world): diff --git a/backend/accounts/tests/test_account_existence_oracle.py b/backend/accounts/tests/test_account_existence_oracle.py new file mode 100644 index 00000000..95346085 --- /dev/null +++ b/backend/accounts/tests/test_account_existence_oracle.py @@ -0,0 +1,149 @@ +"""An account the caller may not open answers exactly like one that does not exist. + +Owner decision for 1.11.0: the account detail, mail, comment and attachment +endpoints look the account up through `accounts.access.visible_accounts_qs` +in one step. They used to fetch org-wide and then check, answering 403 for a +hidden account and 404 for a missing one. Each test compares the two +responses WHOLE. Writes narrower than reads keep their 403, only for a caller +who can open the account. +""" + +from unittest import mock + +import pytest +from django.contrib.contenttypes.models import ContentType + +from accounts.models import Account +from common.models import Attachments, Comment + +MISSING = "11111111-1111-1111-1111-111111111111" + + +@pytest.fixture +def account(org_a): + """In the caller's org, created by nobody, assigned to nobody: hidden.""" + return Account.objects.create(name="Hidden Co", org=org_a) + + +@pytest.fixture +def comment(account, admin_profile, org_a): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(Account), + object_id=account.id, + comment="Not yours", + commented_by=admin_profile, + org=org_a, + ) + + +@pytest.fixture +def attachment(account, admin_user, org_a): + return Attachments.objects.create( + content_type=ContentType.objects.get_for_model(Account), + object_id=account.id, + file_name="x.txt", + attachment="attachments/x.txt", + created_by=admin_user, + org=org_a, + ) + + +def _same(client, method, hidden_url, missing_url, body=None): + call = getattr(client, method) + kwargs = {"format": "json"} if body is not None else {} + args = (body,) if body is not None else () + hidden = call(hidden_url, *args, **kwargs) + missing = call(missing_url, *args, **kwargs) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + + +@pytest.mark.django_db +class TestAccountIdEndpoints: + @pytest.mark.parametrize( + "method,body", + [ + ("get", None), + ("put", {"name": "Mine now"}), + ("patch", {"name": "Mine now"}), + ("post", {"comment": "hello"}), + ("delete", None), + ], + ) + def test_detail(self, user_client, account, method, body): + _same( + user_client, + method, + f"/api/accounts/{account.id}/", + f"/api/accounts/{MISSING}/", + body, + ) + account.refresh_from_db() + assert account.name == "Hidden Co" + + def test_create_mail(self, user_client, account): + body = { + "from_email": "sales@example.com", + "message_subject": "Hello", + "message_body": "Body", + } + with mock.patch("accounts.views.send_email.delay") as dispatch: + _same( + user_client, + "post", + f"/api/accounts/{account.id}/create_mail/", + f"/api/accounts/{MISSING}/create_mail/", + body, + ) + dispatch.assert_not_called() + + +@pytest.mark.django_db +class TestCommentsAndFiles: + @pytest.mark.parametrize( + "method,body", + [("put", {"comment": "x"}), ("patch", {"comment": "x"}), ("delete", None)], + ) + def test_comment_on_hidden_account(self, user_client, comment, method, body): + _same( + user_client, + method, + f"/api/accounts/comment/{comment.id}/", + f"/api/accounts/comment/{MISSING}/", + body, + ) + assert Comment.objects.filter(id=comment.id).exists() + + def test_attachment_on_hidden_account(self, user_client, attachment): + _same( + user_client, + "delete", + f"/api/accounts/attachment/{attachment.id}/", + f"/api/accounts/attachment/{MISSING}/", + ) + assert Attachments.objects.filter(id=attachment.id).exists() + + def test_readable_account_non_author_is_403( + self, user_client, user_profile, account, comment, attachment + ): + account.assigned_to.add(user_profile) + url = f"/api/accounts/comment/{comment.id}/" + assert user_client.put(url, {"comment": "x"}, format="json").status_code == 403 + assert user_client.delete(url).status_code == 403 + attachment_url = f"/api/accounts/attachment/{attachment.id}/" + assert user_client.delete(attachment_url).status_code == 403 + + +@pytest.mark.django_db +class TestDeleteStaysNarrower: + def test_assignee_who_is_not_creator_gets_403( + self, user_client, user_profile, account + ): + account.assigned_to.add(user_profile) + assert user_client.get(f"/api/accounts/{account.id}/").status_code == 200 + assert user_client.delete(f"/api/accounts/{account.id}/").status_code == 403 + assert Account.objects.filter(id=account.id).exists() + + def test_creator_may_delete(self, user_client, regular_user, account): + Account.objects.filter(id=account.id).update(created_by=regular_user) + assert user_client.delete(f"/api/accounts/{account.id}/").status_code == 200 diff --git a/backend/accounts/tests/test_account_superuser_and_lead_picker.py b/backend/accounts/tests/test_account_superuser_and_lead_picker.py index e53ae869..0008503a 100644 --- a/backend/accounts/tests/test_account_superuser_and_lead_picker.py +++ b/backend/accounts/tests/test_account_superuser_and_lead_picker.py @@ -47,7 +47,7 @@ class TestSuperuserAccountAccess: assert has_account_access(user_profile, superuser, admins_account) is True def test_plain_member_is_refused_the_detail(self, user_client, admins_account): - assert user_client.get(f"/api/accounts/{admins_account.id}/").status_code == 403 + assert user_client.get(f"/api/accounts/{admins_account.id}/").status_code == 404 def test_superuser_may_open_the_detail( self, superuser, user_client, admins_account @@ -111,7 +111,7 @@ class TestLeadCatalogue: } def test_plain_member_does_not_get_a_lead_they_cannot_open(self, user_client, lead): - assert user_client.get(f"/api/leads/{lead.id}/").status_code == 403 + assert user_client.get(f"/api/leads/{lead.id}/").status_code == 404 assert user_client.get("/api/accounts/").json()["leads"] == [] def test_superuser_gets_the_lead_their_detail_route_serves( diff --git a/backend/accounts/tests/test_accounts_api.py b/backend/accounts/tests/test_accounts_api.py index 7cd4f74e..d15e6ff7 100644 --- a/backend/accounts/tests/test_accounts_api.py +++ b/backend/accounts/tests/test_accounts_api.py @@ -388,17 +388,17 @@ class TestAccountDetailView: name="Not My Account", org=org_a, created_by=admin_user ) response = user_client.delete(f"/api/accounts/{account.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_get_account_non_admin_not_assigned( self, user_client, org_a, admin_user, user_profile ): - """Non-admin user not assigned to account gets 403.""" + """Non-admin user not assigned to account gets 404, as for a missing id.""" account = Account.objects.create( name="Private Account", org=org_a, created_by=admin_user ) response = user_client.get(f"/api/accounts/{account.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_get_account_non_admin_assigned( self, user_client, org_a, admin_user, user_profile @@ -423,7 +423,7 @@ class TestAccountDetailView: {"name": "Not Assigned"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_patch_account(self, admin_client, org_a): """Test partial update via PATCH.""" @@ -478,7 +478,7 @@ class TestAccountDetailView: assert account.assigned_to.count() == 0 def test_patch_account_non_admin_forbidden(self, user_client, org_a, admin_user): - """Non-admin user not assigned gets 403 on PATCH.""" + """Non-admin user not assigned gets 404 on PATCH, as on GET.""" account = Account.objects.create( name="Patch Forbidden", org=org_a, created_by=admin_user ) @@ -487,7 +487,7 @@ class TestAccountDetailView: {"city": "Nope"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_add_comment_via_post(self, admin_client, org_a, admin_profile): """Test adding a comment to an account via POST on account detail.""" @@ -519,7 +519,7 @@ class TestAccountDetailView: {"comment": "Not allowed"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 @pytest.mark.django_db @@ -559,6 +559,8 @@ class TestAccountCommentView: ): """Non-admin user who didn't create the comment cannot edit.""" account = Account.objects.create(name="Comment Perm Account", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, admin_profile, org_a) response = user_client.put( f"/api/accounts/comment/{comment.id}/", @@ -572,6 +574,8 @@ class TestAccountCommentView: ): """Non-admin user who didn't create the comment cannot delete.""" account = Account.objects.create(name="Comment Del Perm", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, admin_profile, org_a) response = user_client.delete(f"/api/accounts/comment/{comment.id}/") assert response.status_code == 403 @@ -619,6 +623,8 @@ class TestAccountCommentView: ): """Authorization is still checked before the body is.""" account = Account.objects.create(name="Empty Body Perm", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, admin_profile, org_a) response = user_client.put( f"/api/accounts/comment/{comment.id}/", @@ -644,6 +650,8 @@ class TestAccountCommentView: ): """Non-admin user who didn't create the comment cannot PATCH.""" account = Account.objects.create(name="Patch Perm Account", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, admin_profile, org_a) response = user_client.patch( f"/api/accounts/comment/{comment.id}/", @@ -655,6 +663,8 @@ class TestAccountCommentView: def test_own_comment_can_edit(self, user_client, org_a, user_profile): """User who created the comment can edit it.""" account = Account.objects.create(name="Own Comment Account", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, user_profile, org_a) response = user_client.put( f"/api/accounts/comment/{comment.id}/", @@ -666,6 +676,8 @@ class TestAccountCommentView: def test_own_comment_can_delete(self, user_client, org_a, user_profile): """User who created the comment can delete it.""" account = Account.objects.create(name="Own Del Comment Account", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) comment = self._create_comment(account, user_profile, org_a) response = user_client.delete(f"/api/accounts/comment/{comment.id}/") assert response.status_code == 200 @@ -697,6 +709,8 @@ class TestAccountAttachmentView: ): """Non-admin who didn't create the attachment cannot delete.""" account = Account.objects.create(name="Att Perm Account", org=org_a) + # Can open the account, so the author check is what answers. + account.assigned_to.add(user_profile) attachment = self._create_attachment(account, admin_profile.user, org_a) response = user_client.delete(f"/api/accounts/attachment/{attachment.id}/") assert response.status_code == 403 diff --git a/backend/accounts/tests/test_create_mail.py b/backend/accounts/tests/test_create_mail.py index 4c1157e3..64653813 100644 --- a/backend/accounts/tests/test_create_mail.py +++ b/backend/accounts/tests/test_create_mail.py @@ -183,11 +183,15 @@ class TestCreateMailAccountAccess: def test_member_who_cannot_open_the_account_is_refused( self, user_client, admins_account ): - """403, matching `GET` on the same account, and no mail row left.""" - assert user_client.get(f"/api/accounts/{admins_account.id}/").status_code == 403 + """404, matching `GET` on the same account, and no mail row left.""" + assert user_client.get(f"/api/accounts/{admins_account.id}/").status_code == 404 with mock.patch("accounts.views.send_email.delay") as dispatch: resp = user_client.post(_url(admins_account.id), _payload()) - assert resp.status_code == 403 + assert resp.status_code == 404 + missing = user_client.post( + _url("11111111-1111-1111-1111-111111111111"), _payload() + ) + assert resp.json() == missing.json() assert not AccountEmail.objects.filter(from_account=admins_account).exists() dispatch.assert_not_called() diff --git a/backend/accounts/tests/test_nested_account_and_pickers.py b/backend/accounts/tests/test_nested_account_and_pickers.py index 3763c244..a0c71f96 100644 --- a/backend/accounts/tests/test_nested_account_and_pickers.py +++ b/backend/accounts/tests/test_nested_account_and_pickers.py @@ -81,7 +81,7 @@ class TestNestedAccountOverTheApi: def test_case_viewer_without_account_access_gets_id_and_name( self, user_client, account, case ): - assert user_client.get(f"/api/accounts/{account.id}/").status_code == 403 + assert user_client.get(f"/api/accounts/{account.id}/").status_code == 404 response = user_client.get(f"/api/cases/{case.id}/") assert response.status_code == 200, response.content _only_id_and_name(response.json()["cases_obj"]["account"], account) diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 594760c7..ff2c7575 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -2,7 +2,6 @@ import json import uuid from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import ( Count, DateField, @@ -16,7 +15,6 @@ from django.db.models import ( ) from django.db.models.deletion import ProtectedError from django.db.models.functions import Coalesce -from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.utils import extend_schema @@ -44,7 +42,7 @@ from cases.models import Case from cases.serializer import CaseSerializer, parent_access_context from cases.workflow import TERMINAL_STATUSES from common.custom_fields import validate_payload as validate_custom_fields_payload -from common.lookups import get_scoped_or_404 +from common.lookups import get_on_visible_record_or_404 from common.models import ( Attachments, Comment, @@ -509,30 +507,22 @@ class AccountDetailView(APIView): serializer_class = AccountSerializer def get_object(self, pk): - # A malformed id is 404, not 500. The URL pattern is ``, so an - # old bookmark or a typo reaches the UUID field as text, and - # `UUIDField.to_python` raises `ValidationError`, which - # `get_object_or_404` does not catch, because it only knows about - # `DoesNotExist`. "That is not an id" and "no such account" are the - # same answer to whoever asked. - try: - account = get_object_or_404( - annotate_rollups(Account.objects.all(), self.request.profile), - id=pk, - org=self.request.profile.org, - ) - except (DjangoValidationError, ValueError): - raise Http404("No such account.") - attach_money_rollups([account], self.request.profile) - return account + """The account, or 404 when it is missing or the caller may not open it. - def assert_account_access(self, account): - """Delegates to `accounts.access`, which holds the one definition. - - The attachment download view asks the same question, and four inline - copies is how the creator branch came to be dead in all four verbs. + Looked up through the read rule (`accounts.access.visible_accounts_qs`) + in the same query, so a same-org account the caller cannot open + answers every verb exactly as an id that does not exist. Fetching + org-wide and checking afterwards answered 403, which confirmed it. """ - access.assert_account_access(self.request.profile, self.request.user, account) + profile = self.request.profile + account = get_object_or_404( + annotate_rollups(Account.objects.all(), profile), + id=pk, + org=profile.org, + id__in=access.visible_accounts_qs(profile, self.request.user).values("id"), + ) + attach_money_rollups([account], profile) + return account @extend_schema( tags=["Accounts"], @@ -543,10 +533,6 @@ class AccountDetailView(APIView): def put(self, request, pk, format=None): data = request.data account_object = self.get_object(pk=pk) - # Authorise before validating. The check used to sit inside - # `is_valid()`, so somebody with no right to touch the account learned - # whether their payload was well-formed before being turned away. - self.assert_account_access(account_object) # Parsed before the first write: a malformed id used to be a 400 after # the fields were saved and the contacts replaced. contact_ids = payload_id_list(data.get("contacts"), "contacts") @@ -643,12 +629,10 @@ class AccountDetailView(APIView): def delete(self, request, pk, format=None): self.object = self.get_object(pk) # Admins, superusers and the creator: the deal delete rule. Assignees - # may open and edit an account but not delete it. Superusers were - # refused here alone, while every sibling rule let them through. - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): + # may open and edit an account but not delete it, and since they can + # open it, that refusal is an honest 403. Superusers were refused here + # alone, while every sibling rule let them through. + if not is_org_admin(self.request.profile): if self.request.profile.user_id != self.object.created_by_id: return Response( { @@ -684,7 +668,6 @@ class AccountDetailView(APIView): ) def get(self, request, pk, format=None): self.account = self.get_object(pk=pk) - self.assert_account_access(self.account) context = {} # Every related list below applies its own module's read rule, the one # that module's detail endpoint enforces. Being able to open the @@ -808,7 +791,6 @@ class AccountDetailView(APIView): # simply is not there, while GET on the same id answered 404. Commenting # on a deleted account is a normal race, not a server fault. self.account_obj = self.get_object(pk=pk) - self.assert_account_access(self.account_obj) # Before the comment is saved, so a refused file does not leave it posted. validate_attachment(self.request.FILES.get("account_attachment")) # This block never created a comment. `object_id` and `org` were @@ -872,7 +854,6 @@ class AccountDetailView(APIView): """Handle partial updates to an account.""" data = request.data account_object = self.get_object(pk=pk) - self.assert_account_access(account_object) # Parsed before the first write, as in PUT. An absent key parses to []. contact_ids = payload_id_list(data.get("contacts"), "contacts") tag_ids = payload_id_list(data.get("tags"), "tags") @@ -965,7 +946,14 @@ class AccountCommentView(APIView): serializer_class = AccountCommentEditSwaggerSerializer def get_object(self, pk): - return get_scoped_or_404(self.model, pk, self.request.profile.org) + # 404, like a missing id, for a comment on an account the caller + # cannot open. + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + access.visible_accounts_qs(self.request.profile, self.request.user), + ) @extend_schema( tags=["Accounts"], @@ -1068,12 +1056,15 @@ class AccountAttachmentView(APIView): # `created_by` is a User FK, so the ownership branch compares against # `profile.user_id`; comparing the Profile itself was never true, which # made this admin-only by accident. - try: - self.object = get_object_or_404( - self.model, pk=pk, org=self.request.profile.org - ) - except (DjangoValidationError, ValueError): - raise Http404("No such attachment.") + # + # A file on an account the caller cannot open is the same 404 as a + # missing id. + self.object = get_on_visible_record_or_404( + self.model, + pk, + request.profile.org, + access.visible_accounts_qs(request.profile, request.user), + ) if ( is_org_admin(request.profile) or request.profile.user_id == self.object.created_by_id @@ -1130,18 +1121,22 @@ class AccountCreateMailView(APIView): name cannot be put on mail to anyone else in the org. Within the org, only someone who may open the account may send from - it. Anyone else is refused with the 403 that `GET` on the same account - gives them, so the two verbs agree on what the caller may know. + it. Anyone else gets the 404 that `GET` on the same account gives + them, identical to an id that does not exist, so the two verbs agree + on what the caller may know. """ params = request.data scheduled_date_time = params.get("scheduled_date_time") - account = Account.objects.filter(id=pk, org=request.profile.org).first() + account = ( + access.visible_accounts_qs(request.profile, request.user) + .filter(id=pk) + .first() + ) if account is None: return Response( {"error": True, "errors": "Account not found"}, status=status.HTTP_404_NOT_FOUND, ) - access.assert_account_access(request.profile, request.user, account) serializer = EmailSerializer(data=params, request_obj=request) if not serializer.is_valid(): diff --git a/backend/cases/access.py b/backend/cases/access.py index 2cec1303..584399bd 100644 --- a/backend/cases/access.py +++ b/backend/cases/access.py @@ -45,6 +45,10 @@ _DENIED = "You do not have Permission to perform this action" def visible_cases_qs(profile): """Cases ``profile`` is allowed to open. The queryset form of `read`. + An org admin sees every case in the org, and `is_org_admin` counts a + superuser's profile as one, as the contact, lead, account and deal + helpers do. It used to be the one read rule that ignored superusers. + The watcher clause is what lets somebody keep following a ticket after they are unassigned, and it is the clause the detail view was missing. """ @@ -69,8 +73,17 @@ def writable_cases_qs(profile): return qs.filter(Q(created_by=profile.user) | Q(assigned_to=profile)).distinct() +_NOT_FOUND = "No such case." + + def get_case_or_404(profile, pk): - """Fetch a case in the requester's org, or raise ``Http404``. + """A case ``profile`` may open, or ``Http404``. + + Looked up through `visible_cases_qs`, so a ticket in the caller's org that + they may not open answers exactly like one that does not exist: same + status, same body. Answering 403 for it confirmed the ticket existed. A + caller who may open the ticket but not change it still gets 403 from + `assert_case_write_access`; knowing it exists is theirs already. ``Case.id`` is a UUID column, so ``filter(id="nobody")`` raises Django's ``ValidationError`` rather than returning nothing, which surfaced as a @@ -78,11 +91,27 @@ def get_case_or_404(profile, pk): a request for a case that does not exist; that is a 404. """ try: - case = Case.objects.filter(pk=pk, org=profile.org).first() + case = visible_cases_qs(profile).filter(pk=pk).first() except (DjangoValidationError, ValueError): - raise Http404("No such case.") + raise Http404(_NOT_FOUND) if case is None: - raise Http404("No such case.") + raise Http404(_NOT_FOUND) + return case + + +def lock_case_or_404(profile, pk): + """`get_case_or_404` with the row locked for the caller's transaction. + + Postgres refuses ``SELECT ... FOR UPDATE`` with ``DISTINCT``, which + `visible_cases_qs` adds for a non-admin, so the row is locked by id and + the read rule is asked of it after. The answer is the same 404. + """ + try: + case = Case.objects.select_for_update().filter(pk=pk, org=profile.org).first() + except (DjangoValidationError, ValueError): + raise Http404(_NOT_FOUND) + if case is None or not has_case_read_access(profile, case): + raise Http404(_NOT_FOUND) return case @@ -102,14 +131,12 @@ def has_case_write_access(profile, case): return profile.id in {p.id for p in case.assigned_to.all()} -def assert_case_read_access(profile, case): - """Raise 403 unless ``profile`` may open ``case``.""" - if not has_case_read_access(profile, case): - raise PermissionDenied(_DENIED) - - def assert_case_write_access(profile, case): - """Raise 403 unless ``profile`` may change ``case`` or reply on it.""" + """Raise 403 unless ``profile`` may change ``case`` or reply on it. + + Call it on a case from `get_case_or_404`, so a caller who may not even + open the case has already had the 404. + """ if not has_case_write_access(profile, case): raise PermissionDenied(_DENIED) diff --git a/backend/cases/analytics_views.py b/backend/cases/analytics_views.py index e9c9fb9e..48d64917 100644 --- a/backend/cases/analytics_views.py +++ b/backend/cases/analytics_views.py @@ -14,7 +14,6 @@ from datetime import datetime, timedelta from typing import Optional from uuid import UUID -from django.db.models import Q from django.utils import timezone from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.exceptions import ValidationError @@ -23,7 +22,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from cases import analytics -from cases.access import is_org_admin +from cases.access import is_org_admin, visible_cases_qs from cases.analytics import DEFAULT_SERVICE_DAYS from cases.models import Case from cases.serializer import CaseSerializer, parent_access_context @@ -95,23 +94,19 @@ def _parse_days(value) -> int: def _filtered_qs(request) -> tuple[object, datetime, datetime]: """Build the org-scoped Case queryset + (from, to) window from query params. - Visibility: admins see every case; non-admins see only those they - created, are assigned to, or watch. Mirrors `CaseListView.get_queryset`. + Visibility is `visible_cases_qs`, the ticket read rule: admins and + superusers see every case; everyone else only those they created, are + assigned to, or watch. This used to carry its own copy of that rule. """ profile = request.profile params = request.query_params qs = ( - Case.objects.filter(org=profile.org, is_active=True) - .filter(merged_into__isnull=True) + visible_cases_qs(profile) + .filter(is_active=True, merged_into__isnull=True) .exclude(status="Duplicate") ) - if not is_org_admin(profile): - qs = qs.filter( - Q(created_by=profile.user) | Q(assigned_to=profile) | Q(watchers=profile) - ).distinct() - if priority := params.get("priority"): qs = qs.filter(priority=priority) if team_id := uuid_param(params, "team"): @@ -216,9 +211,11 @@ class AnalyticsServiceView(_AnalyticsBaseView): {"error": True, "errors": "Admin access required"}, status=403 ) days = _parse_days(request.query_params.get("days")) - qs = Case.objects.filter( - org=request.profile.org, is_active=True, merged_into__isnull=True - ).exclude(status="Duplicate") + qs = ( + visible_cases_qs(request.profile) + .filter(is_active=True, merged_into__isnull=True) + .exclude(status="Duplicate") + ) data = analytics.compute_service_overview(qs, request.profile.org_id, days) return Response(data) diff --git a/backend/cases/approval_views.py b/backend/cases/approval_views.py index 1bdd39d1..4305d5a2 100644 --- a/backend/cases/approval_views.py +++ b/backend/cases/approval_views.py @@ -18,16 +18,18 @@ from __future__ import annotations from django.db import transaction from django.db.models import Count -from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from cases.access import assert_case_write_access, has_case_read_access +from cases.access import ( + assert_case_write_access, + get_case_or_404, + has_case_read_access, +) from cases.approvals import Approval, ApprovalRule, find_matching_rule -from cases.models import Case from cases.serializer import ( ApprovalRequestSerializer, ApprovalRuleSerializer, @@ -238,7 +240,8 @@ class CaseRequestApprovalView(APIView): @transaction.atomic def post(self, request, pk): org = request.profile.org - case = get_object_or_404(Case, id=pk, org=org) + # A case the caller may not open is a 404, as on the detail view. + case = get_case_or_404(request.profile, pk) # Filing an approval acts on the case and returns its summary, so it # needs the same write access as replying on it. Org membership alone # let a member who cannot open a case create a row against it and read diff --git a/backend/cases/approvals.py b/backend/cases/approvals.py index 36da70ba..f3d7a484 100644 --- a/backend/cases/approvals.py +++ b/backend/cases/approvals.py @@ -20,6 +20,7 @@ from django.db import models from common.base import BaseModel from common.models import Org, Profile, Teams +from common.permissions import is_org_admin from common.utils import CASE_TYPE, PRIORITY_CHOICE # Approver roles. Mirrors the spec's ``ADMIN``/``MANAGER`` choices even though @@ -227,6 +228,10 @@ class Approval(BaseModel): return False if self.rule.approvers.filter(id=profile.id).exists(): return True + if self.rule.approver_role == "ADMIN": + # "Admin" means `is_org_admin`, which also admits a superuser's + # profile, not a literal read of `role`. + return is_org_admin(profile) if self.rule.approver_role and profile.role == self.rule.approver_role: return True return False diff --git a/backend/cases/bulk_views.py b/backend/cases/bulk_views.py index 872cd287..886bea5b 100644 --- a/backend/cases/bulk_views.py +++ b/backend/cases/bulk_views.py @@ -9,9 +9,9 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from cases.access import has_case_write_access, is_org_admin +from cases.access import has_case_write_access, is_org_admin, visible_cases_qs from cases.models import Case -from cases.workflow import DUPLICATE_BY_MERGE_ONLY +from cases.workflow import DUPLICATE_BY_MERGE_ONLY, merged_status_refusal from common.models import Activity, Profile, Tags from common.permissions import HasOrgContext @@ -131,16 +131,33 @@ class BulkUpdateCasesView(APIView): org = request.profile.org results = [] updated_count = 0 - for case in Case.objects.filter(pk__in=ids, org=org): + # Only tickets the caller may open. One they may not is left out of + # `results` exactly as a missing id is, so the batch cannot be used to + # learn which ids exist. + for case in Case.objects.filter( + org=org, + pk__in=visible_cases_qs(request.profile).filter(pk__in=ids).values("pk"), + ): # Per-case authorization, mirroring the single-case PUT path # (`CaseDetailView.put` calls `assert_case_write_access`). Without # this any org member could edit, reassign or close any case in - # the org. Cases the caller may not write are reported as + # the org. Cases the caller may open but not write are reported as # `no_access` rather than silently skipped, so the caller can see # which of their selected tickets were denied. if not has_case_write_access(request.profile, case): results.append({"id": str(case.pk), "status": "no_access"}) continue + # The single-case rule, reported per ticket like the close gate. + refusal = merged_status_refusal(case, fields.get("status", case.status)) + if refusal: + results.append( + { + "id": str(case.pk), + "status": "merged", + "detail": refusal["status"], + } + ) + continue try: # A savepoint per case, so a blocked close rolls back only # itself and the rest of the batch still commits. @@ -199,10 +216,13 @@ class BulkDeleteCasesView(APIView): results = [] deletable = [] # Deleting is admin-or-creator only (`assert_case_delete_access`); an - # assignee may work a ticket but not erase it. - for row in Case.objects.filter(pk__in=ids, org=org, is_active=True).values( - "id", "name", "created_by_id" - ): + # assignee may work a ticket but not erase it. A ticket the caller may + # not open is left out of `results`, as a missing id is. + for row in Case.objects.filter( + org=org, + pk__in=visible_cases_qs(request.profile).filter(pk__in=ids).values("pk"), + is_active=True, + ).values("id", "name", "created_by_id"): if admin or row["created_by_id"] == request.profile.user_id: deletable.append(row) else: diff --git a/backend/cases/help_center_views.py b/backend/cases/help_center_views.py index 06613eb9..bdd8d89a 100644 --- a/backend/cases/help_center_views.py +++ b/backend/cases/help_center_views.py @@ -48,12 +48,13 @@ NOT_FOUND = {"error": "Not found"} class HelpCenterIPThrottle(SimpleRateThrottle): - """Per-visitor rate limit, bucketed on the forwarded client IP. + """Per-visitor rate limit, bucketed on `client_ip`. - The web app renders these pages server-side and forwards the visitor's - address, so the bucket is the visitor rather than the web server. The - header is spoofable, which makes this a brake on casual scraping, not a - wall; the data behind it is public by the org's own choice. + `client_ip` believes only the `X-Forwarded-For` entries our own proxies + appended (`NUM_PROXIES`), so rotating the header no longer buys a fresh + bucket. These pages are rendered by the web app's server, which calls the + API itself: whether the bucket is the visitor or that server depends on + how many trusted hops sit between the two (see `client_ip`). """ scope = "help_center_ip" @@ -68,8 +69,8 @@ class HelpCenterIPThrottle(SimpleRateThrottle): class HelpCenterGlobalThrottle(SimpleRateThrottle): """Per-help-center rate limit across every visitor. - The backstop for `HelpCenterIPThrottle`: rotating `X-Forwarded-For` buys a - fresh per-visitor bucket every request but cannot touch this one. Bucketed + The backstop for `HelpCenterIPThrottle`, for a caller spread across many + addresses. Bucketed on the slug, as the web form limit is bucketed on the form, so a scraper hammering one org's help center cannot lock readers out of another's. """ diff --git a/backend/cases/kanban_views.py b/backend/cases/kanban_views.py index fe35fbcf..b1443a26 100644 --- a/backend/cases/kanban_views.py +++ b/backend/cases/kanban_views.py @@ -13,7 +13,11 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from cases.access import assert_case_write_access, visible_cases_qs +from cases.access import ( + assert_case_write_access, + lock_case_or_404, + visible_cases_qs, +) from cases.approvals import close_refusal from cases.models import Case, CasePipeline, CaseStage from cases.serializer import ( @@ -23,7 +27,7 @@ from cases.serializer import ( CasePipelineSerializer, CaseStageSerializer, ) -from cases.workflow import duplicate_refusal +from cases.workflow import duplicate_refusal, merged_status_refusal from common.kanban import place_in_column from common.permissions import HasOrgContext, is_org_admin from common.utils import STATUS_CHOICE @@ -246,13 +250,14 @@ class CaseMoveView(APIView): """Move case to different column and/or position.""" org = request.profile.org # Locked for the transaction: the move saves the whole row, so an - # edit committing between this read and that save would be lost. - case = get_object_or_404(Case.objects.select_for_update(), pk=pk, org=org) + # edit committing between this read and that save would be lost. A + # ticket the caller may not open is a 404, as on the detail view. + case = lock_case_or_404(request.profile, pk) # A move rewrites the ticket's status, stage and order, so it takes the - # ticket's own write rule and nothing wider. This used to add a Django - # superuser clause, which let a superuser who is a plain member of the - # org move any ticket here that `CaseDetailView.patch` refuses them. + # ticket's own write rule and nothing wider. It used to add its own + # superuser clause; superusers now pass through `is_org_admin`, the + # same way on this path as on `CaseDetailView.patch`. assert_case_write_access(request.profile, case) serializer = CaseMoveSerializer(data=request.data) @@ -303,12 +308,16 @@ class CaseMoveView(APIView): closed_on = case.closed_on if new_status == "Closed" and case.status != "Closed": closed_on = timezone.localdate() - refusal = duplicate_refusal(case.status, new_status) or close_refusal( - case, - status=new_status, - closed_on=closed_on, - priority=case.priority, - case_type=case.case_type, + refusal = ( + merged_status_refusal(case, new_status) + or duplicate_refusal(case.status, new_status) + or close_refusal( + case, + status=new_status, + closed_on=closed_on, + priority=case.priority, + case_type=case.case_type, + ) ) if refusal: return Response( @@ -383,7 +392,7 @@ class CasePipelineListCreateView(APIView): """Create a new pipeline.""" org = request.profile.org - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Only admins can create pipelines"}, status=status.HTTP_403_FORBIDDEN, @@ -471,7 +480,7 @@ class CasePipelineDetailView(APIView): responses={200: CasePipelineSerializer}, ) def put(self, request, pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -492,7 +501,7 @@ class CasePipelineDetailView(APIView): @extend_schema(tags=["Case Pipelines"], responses={204: None}) def delete(self, request, pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -524,7 +533,7 @@ class CaseStageCreateView(APIView): responses={201: CaseStageSerializer}, ) def post(self, request, pipeline_pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -557,7 +566,7 @@ class CaseStageDetailView(APIView): responses={200: CaseStageSerializer}, ) def put(self, request, pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -576,7 +585,7 @@ class CaseStageDetailView(APIView): @extend_schema(tags=["Case Stages"], responses={204: None}) def delete(self, request, pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -611,7 +620,7 @@ class CaseStageReorderView(APIView): ) @transaction.atomic def post(self, request, pipeline_pk): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) diff --git a/backend/cases/kb_views.py b/backend/cases/kb_views.py index 8d6d0288..ccf2377a 100644 --- a/backend/cases/kb_views.py +++ b/backend/cases/kb_views.py @@ -17,6 +17,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from cases.access import get_case_or_404 from cases.kb_text import snippet, text_match from cases.models import Case, Solution from common.permissions import HasOrgContext @@ -65,9 +66,10 @@ class SolutionSuggestionsView(APIView): ) def get(self, request, pk): org = request.profile.org - case = Case.objects.filter(pk=pk, org=org).first() - if case is None: - return Response({"error": "Case not found"}, status=404) + # Read access, not org alone: the suggestions are seeded from the + # ticket's own name and description, so a hidden ticket's subject + # would come back as the articles it matched. + case = get_case_or_404(request.profile, pk) try: limit = int(request.query_params.get("limit", _DEFAULT_LIMIT)) diff --git a/backend/cases/merge_views.py b/backend/cases/merge_views.py index aeecf9ea..953d0918 100644 --- a/backend/cases/merge_views.py +++ b/backend/cases/merge_views.py @@ -18,7 +18,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from cases.access import assert_case_read_access, get_case_or_404, visible_cases_qs +from cases.access import get_case_or_404, has_case_read_access, visible_cases_qs from cases.models import Case, EmailMessage from cases.notifications import case_link from cases.serializer import CaseSerializer, parent_access_context @@ -52,8 +52,8 @@ class CaseMergeTargetsView(APIView): Answers what the merge endpoint would accept and nothing wider: tickets the caller can open, that `can_merge_case` allows (so a non-admin sees only tickets they raised), that are live and not themselves merged, never the - ticket itself. The source takes the ticket's read rule first, so a ticket - the caller cannot open answers here exactly as its detail page does. + ticket itself. The source is looked up through the read rule, so a ticket + the caller cannot open answers here exactly as its detail page does: 404. """ permission_classes = (IsAuthenticated, HasOrgContext) @@ -73,7 +73,6 @@ class CaseMergeTargetsView(APIView): def get(self, request, pk: str, format=None): profile = request.profile source = get_case_or_404(profile, pk) - assert_case_read_access(profile, source) if not can_merge_case(profile, source): return Response( { @@ -159,7 +158,14 @@ class CaseMergeView(APIView): source = by_id.get(str(pk)) target = by_id.get(str(into_id)) - if not source or not target: + # A ticket the caller may not open, on either side, answers as a + # missing one does. The 403 below would confirm it exists. + if ( + not source + or not target + or not has_case_read_access(request.profile, source) + or not has_case_read_access(request.profile, target) + ): return Response( {"error": True, "errors": "Ticket not found."}, status=status.HTTP_404_NOT_FOUND, diff --git a/backend/cases/parent_views.py b/backend/cases/parent_views.py index 4b9c9d25..ce159735 100644 --- a/backend/cases/parent_views.py +++ b/backend/cases/parent_views.py @@ -24,7 +24,6 @@ Who may call them follows `cases.access`, the same as the ticket detail view: from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction -from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -32,14 +31,16 @@ from rest_framework.response import Response from rest_framework.views import APIView from cases.access import ( - assert_case_read_access, assert_case_write_access, + get_case_or_404, has_case_read_access, has_case_write_access, + lock_case_or_404, ) from cases.approvals import close_refusal from cases.models import Case from cases.parent_guards import check_parent_link +from cases.workflow import MERGED_STATUS_LOCKED from common.models import Activity from common.permissions import HasOrgContext @@ -114,12 +115,9 @@ class CaseTreeView(APIView): def get(self, request, pk): org = request.profile.org - case = get_object_or_404( - Case.objects.prefetch_related("assigned_to"), id=pk, org=org - ) - # The same answer the ticket detail GET gives: 404 outside the org, - # 403 for a ticket in it that the caller may not open. - assert_case_read_access(request.profile, case) + # The same answer the ticket detail GET gives: 404 outside the org and + # for a ticket in it that the caller may not open. + case = get_case_or_404(request.profile, pk) # Return the root of the visible tree: walk up to the highest ancestor # in the same org so a child URL still shows the full incident. root = case @@ -144,9 +142,7 @@ class CaseLinkParentView(APIView): parent_id = request.data.get("parent_id") # Lock the case row so a parallel link from another agent cannot # race on the cycle check. We look up the parent under the same lock. - case = Case.objects.select_for_update().filter(id=pk, org=org).first() - if case is None: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + case = lock_case_or_404(request.profile, pk) # Moving a ticket in or out of a tree is a change to it, so it takes # the write rule, as the detail PUT does. Unlinking included. assert_case_write_access(request.profile, case) @@ -229,12 +225,18 @@ class CaseLinkParentView(APIView): def _open_descendants(case, out=None, seen=None): - """Collect all open (status != Closed) active descendants, depth-first. + """Collect all open (neither Closed nor Duplicate) active descendants, + depth-first. Recursion goes through closed children too, so an open grandchild under a - closed child is still collected. ``seen`` stops the walk on a stored - cycle, which would otherwise recurse until the worker fell over. Rows are - locked for the length of the caller's transaction. + closed child is still collected. A merged child is passed over the same + way: its status is not the cascade's to change (`merged_status_refusal`), + and what it held now lives on the ticket it was merged into. The + ``merged_into`` test is not redundant with Duplicate: a merged ticket + edited to another status before that rule existed is still merged. ``seen`` + stops the walk on a stored cycle, which would otherwise recurse until the + worker fell over. Rows are locked for the length of the caller's + transaction. """ if out is None: out = [] @@ -244,7 +246,11 @@ def _open_descendants(case, out=None, seen=None): if child.id in seen: continue seen.add(child.id) - if child.status != "Closed" and child.is_active: + if ( + child.status not in ("Closed", "Duplicate") + and child.is_active + and not child.merged_into_id + ): out.append(child) _open_descendants(child, out, seen) return out @@ -287,10 +293,10 @@ class CaseCloseWithChildrenView(APIView): @transaction.atomic def post(self, request, pk): org = request.profile.org - case = Case.objects.select_for_update().filter(id=pk, org=org).first() - if case is None: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + case = lock_case_or_404(request.profile, pk) assert_case_write_access(request.profile, case) + if case.merged_into_id: + return _refused({"status": MERGED_STATUS_LOCKED}) if case.status == "Duplicate": return Response( {"detail": "Cannot close a merged case."}, diff --git a/backend/cases/serializer.py b/backend/cases/serializer.py index 12a51f6b..22ee64de 100644 --- a/backend/cases/serializer.py +++ b/backend/cases/serializer.py @@ -17,7 +17,11 @@ from cases.models import ( TimeEntry, ) from cases.parent_guards import check_parent_link -from cases.workflow import DUPLICATE_BY_MERGE_ONLY, duplicate_refusal +from cases.workflow import ( + DUPLICATE_BY_MERGE_ONLY, + duplicate_refusal, + merged_status_refusal, +) from common.models import Profile, Teams from common.permissions import is_org_admin from common.serializer import ( @@ -267,6 +271,12 @@ class CaseCreateSerializer(serializers.ModelSerializer): new_status = attrs.get("status", getattr(self.instance, "status", None)) + # First, so a merged ticket hears "unmerge first" and not a close-gate + # message about a transition it may not make at all. + refusal = merged_status_refusal(self.instance, new_status) + if refusal: + raise serializers.ValidationError(refusal) + # Parent linking, only when this request carries `parent`. Judging the # stored parent on every save would reject an ordinary rename of a case # that sits in a tree, and would make a record uneditable if its stored diff --git a/backend/cases/signals.py b/backend/cases/signals.py index 58edd6c7..be57e3ec 100644 --- a/backend/cases/signals.py +++ b/backend/cases/signals.py @@ -562,13 +562,17 @@ def _evaluate_reopen(case, comment): Returns: "reopened" if the case was reopened (and a REOPENED Activity emitted), "out_of_window" if it would have reopened but closed_on is too old, - None otherwise (agent comment, internal note, non-Closed status, policy off). + None otherwise (agent comment, internal note, non-Closed status, merged + ticket, policy off). """ if getattr(comment, "is_internal", False): return None if comment.commented_by_id is not None: return None - if case.status != REOPEN_TRIGGER_STATUS: + # A merged ticket's status is not changed by anything but an unmerge + # (`merged_status_refusal`). One can still be Closed if it was edited + # there before that rule existed. + if case.status != REOPEN_TRIGGER_STATUS or case.merged_into_id: return None if not case.closed_on: return None @@ -628,7 +632,7 @@ def maybe_reopen_for_inbound_email(case, email_message): Mirrors _evaluate_reopen but records the trigger as email_message_id. Returns "reopened", "out_of_window", or None. """ - if case.status != REOPEN_TRIGGER_STATUS: + if case.status != REOPEN_TRIGGER_STATUS or case.merged_into_id: return None if not case.closed_on: return None diff --git a/backend/cases/tests/test_approval_authz.py b/backend/cases/tests/test_approval_authz.py index fe57d178..dad52c9d 100644 --- a/backend/cases/tests/test_approval_authz.py +++ b/backend/cases/tests/test_approval_authz.py @@ -167,11 +167,12 @@ class TestInboxStillShowsTheFourWhoMaySee: @pytest.mark.django_db class TestRequestingApprovalNeedsCaseAccess: def test_an_unrelated_member_is_refused(self, outsider_client, org_a, hidden_case): + """404, as for a missing case: the member cannot open this one.""" _rule(org_a, approver_role="ADMIN") response = outsider_client.post( f"/api/cases/{hidden_case.id}/request-approval/", {}, format="json" ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert not Approval.objects.filter(case=hidden_case).exists() def test_the_response_does_not_leak_the_case_name( diff --git a/backend/cases/tests/test_case_access_and_sla.py b/backend/cases/tests/test_case_access_and_sla.py index cd0f69c0..5817b029 100644 --- a/backend/cases/tests/test_case_access_and_sla.py +++ b/backend/cases/tests/test_case_access_and_sla.py @@ -130,8 +130,14 @@ class TestWhoMayOpenACase: assert response.status_code == status.HTTP_200_OK def test_unrelated_member_of_the_org_is_refused(self, user_client, case_a): + """404 with a missing id's body, not 403, which confirmed it exists.""" response = user_client.get(_detail(case_a.id)) - assert response.status_code == status.HTTP_403_FORBIDDEN + missing = user_client.get(_detail("00000000-0000-0000-0000-000000000000")) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert (response.status_code, response.json()) == ( + missing.status_code, + missing.json(), + ) def test_another_org_gets_404_not_403(self, org_b_client, case_a): """404, not 403. Confirming a record exists is itself a disclosure.""" @@ -254,9 +260,11 @@ class TestAttachmentDeleteIsScoped: assert not Attachments.objects.filter(pk=mine.id).exists() def test_someone_elses_upload_is_refused( - self, user_client, case_a, org_a, admin_user + self, user_client, watched_case, org_a, admin_user ): - theirs = self._attach(case_a, org_a, "theirs-in-org.txt") + """403 for a reader of the ticket; one who cannot open it gets 404 + (`test_case_comment_attachment_scope`).""" + theirs = self._attach(watched_case, org_a, "theirs-in-org.txt") _created_by(theirs, admin_user) response = user_client.delete(_attachment(theirs.id)) assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/cases/tests/test_case_comment_attachment_scope.py b/backend/cases/tests/test_case_comment_attachment_scope.py new file mode 100644 index 00000000..96dce046 --- /dev/null +++ b/backend/cases/tests/test_case_comment_attachment_scope.py @@ -0,0 +1,172 @@ +"""`/api/cases/comment//` and `/api/cases/attachment//` resolve +through a ticket the caller may open. + +Both looked the row up across the whole org, in the generic tables every module +shares, and then answered 403 to a non-author. So the 403 confirmed that a +comment or attachment existed on a ticket (or a lead, or a deal) the caller +cannot open, and an author who had lost the ticket could still edit or delete +there. Now a row on a hidden ticket, or on another module's record, answers +exactly like an id that does not exist (compared whole: status and body), and +the author-or-admin 403 is reserved for readers of the ticket. +""" + +import pytest +from crum import impersonate +from django.contrib.contenttypes.models import ContentType +from django.core.files.uploadedfile import SimpleUploadedFile + +from cases.models import Case, CaseWatcher +from common.models import Attachments, Comment +from leads.models import Lead + +MISSING = "00000000-0000-0000-0000-00000000c0de" +COMMENT = "/api/cases/comment/{}/" +ATTACHMENT = "/api/cases/attachment/{}/" + + +@pytest.fixture +def ticket(admin_user, org_a): + return Case.objects.create( + name="Payroll dispute", + status="New", + priority="Normal", + org=org_a, + created_by=admin_user, + ) + + +@pytest.fixture +def lead(org_a): + return Lead.objects.create(title="A lead", first_name="Ay", org=org_a) + + +def _comment(record, author_profile, org): + return Comment.objects.create( + comment="Secret note", + content_type=ContentType.objects.get_for_model(record), + object_id=record.id, + commented_by=author_profile, + org=org, + ) + + +def _attachment(record, creator, org): + # `BaseModel.save()` stamps `created_by` from crum's current user. + with impersonate(creator): + return Attachments.objects.create( + file_name="payslip.pdf", + attachment=SimpleUploadedFile("payslip.pdf", b"pdf"), + content_type=ContentType.objects.get_for_model(record), + object_id=record.id, + org=org, + ) + + +def _whole(response): + return response.status_code, response.content + + +def _comment_calls(client, pk): + return [ + client.put(COMMENT.format(pk), {"comment": "edited"}, format="json"), + client.patch(COMMENT.format(pk), {"comment": "edited"}, format="json"), + client.delete(COMMENT.format(pk)), + ] + + +class TestHiddenAnswersLikeMissing: + def test_a_comment_on_a_ticket_the_caller_cannot_open( + self, user_client, user_profile, ticket, org_a + ): + # Even their own comment, left before they lost the ticket. + note = _comment(ticket, user_profile, org_a) + for got, missing in zip( + _comment_calls(user_client, note.id), _comment_calls(user_client, MISSING) + ): + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + note.refresh_from_db() + assert note.comment == "Secret note" + + def test_a_comment_on_another_modules_record( + self, admin_client, admin_profile, lead, org_a + ): + """Even an admin: this route is for ticket comments only.""" + note = _comment(lead, admin_profile, org_a) + for got, missing in zip( + _comment_calls(admin_client, note.id), + _comment_calls(admin_client, MISSING), + ): + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + assert Comment.objects.filter(id=note.id).exists() + + def test_an_attachment_on_a_ticket_the_caller_cannot_open( + self, user_client, regular_user, ticket, org_a + ): + att = _attachment(ticket, regular_user, org_a) + got = user_client.delete(ATTACHMENT.format(att.id)) + missing = user_client.delete(ATTACHMENT.format(MISSING)) + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + assert Attachments.objects.filter(id=att.id).exists() + + def test_an_attachment_on_another_modules_record( + self, admin_client, admin_user, lead, org_a + ): + att = _attachment(lead, admin_user, org_a) + got = admin_client.delete(ATTACHMENT.format(att.id)) + missing = admin_client.delete(ATTACHMENT.format(MISSING)) + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + assert Attachments.objects.filter(id=att.id).exists() + + +class TestReadersOfTheTicket: + @pytest.fixture + def reader(self, user_profile, ticket, org_a): + CaseWatcher.objects.create(case=ticket, profile=user_profile, org=org_a) + return user_profile + + def test_a_reader_may_not_edit_someone_elses_comment( + self, user_client, reader, admin_profile, ticket, org_a + ): + note = _comment(ticket, admin_profile, org_a) + for response in _comment_calls(user_client, note.id): + assert response.status_code == 403 + assert Comment.objects.get(id=note.id).comment == "Secret note" + + def test_a_reader_edits_and_deletes_their_own_comment( + self, user_client, reader, ticket, org_a + ): + note = _comment(ticket, reader, org_a) + response = user_client.patch( + COMMENT.format(note.id), {"comment": "edited"}, format="json" + ) + assert response.status_code == 200, response.content + assert Comment.objects.get(id=note.id).comment == "edited" + assert user_client.delete(COMMENT.format(note.id)).status_code == 200 + assert not Comment.objects.filter(id=note.id).exists() + + def test_an_admin_edits_anyones_comment_on_a_ticket( + self, admin_client, user_profile, ticket, org_a + ): + note = _comment(ticket, user_profile, org_a) + response = admin_client.put( + COMMENT.format(note.id), {"comment": "tidied"}, format="json" + ) + assert response.status_code == 200, response.content + + def test_a_reader_may_not_delete_someone_elses_attachment( + self, user_client, reader, admin_user, ticket, org_a + ): + att = _attachment(ticket, admin_user, org_a) + assert user_client.delete(ATTACHMENT.format(att.id)).status_code == 403 + assert Attachments.objects.filter(id=att.id).exists() + + def test_a_reader_deletes_their_own_attachment( + self, user_client, reader, regular_user, ticket, org_a + ): + att = _attachment(ticket, regular_user, org_a) + assert user_client.delete(ATTACHMENT.format(att.id)).status_code == 200 + assert not Attachments.objects.filter(id=att.id).exists() diff --git a/backend/cases/tests/test_case_export.py b/backend/cases/tests/test_case_export.py index dbd4254e..f589e085 100644 --- a/backend/cases/tests/test_case_export.py +++ b/backend/cases/tests/test_case_export.py @@ -60,9 +60,9 @@ EXPECTED = { "creator": {"Created"}, # A watcher may open the ticket, so it is theirs to export too. "unrelated": {"Watched"}, - # `visible_cases_qs` reads the role only; a superuser holding a member - # profile gets a member's queue, in the list and in the file alike. - "superuser": set(), + # A superuser is an org admin (`is_org_admin`) even on a member profile, + # so they get the whole queue, in the list and in the file alike. + "superuser": EVERYONE, } diff --git a/backend/cases/tests/test_case_merge.py b/backend/cases/tests/test_case_merge.py index 89270253..b3e31038 100644 --- a/backend/cases/tests/test_case_merge.py +++ b/backend/cases/tests/test_case_merge.py @@ -207,10 +207,13 @@ class TestCaseMergePermissions: assert response.status_code == 200 def test_creator_of_only_one_forbidden( - self, user_client, regular_user, admin_user, org_a + self, user_client, regular_user, user_profile, admin_user, org_a ): a = _make_case(org_a, regular_user, name="A. User-created") b = _make_case(org_a, admin_user, name="B, admin-created") + # Readable, so the refusal is the merge rule's 403 and not the 404 a + # ticket the caller cannot open gets. + b.assigned_to.add(user_profile) response = user_client.post(f"/api/cases/{a.id}/merge/{b.id}/") assert response.status_code == 403 diff --git a/backend/cases/tests/test_case_read_rule_superuser_and_analytics.py b/backend/cases/tests/test_case_read_rule_superuser_and_analytics.py new file mode 100644 index 00000000..13505aad --- /dev/null +++ b/backend/cases/tests/test_case_read_rule_superuser_and_analytics.py @@ -0,0 +1,128 @@ +"""`visible_cases_qs` admits superusers, and the analytics read through it. + +Two follow-ups from the 1.10.0 pass, pinned both ways: + +* `visible_cases_qs` ignored ``is_superuser`` while its siblings admitted it. + It goes through `is_org_admin`, which now admits a superuser's profile, so a + superuser on a USER profile sees every ticket in the org and a plain member + still sees only their own. +* `cases/analytics_views.py` carried an inline copy of the read rule (D40). It + now uses `visible_cases_qs`, so a member's figures count only tickets they + can open, and an admin's or a superuser's count them all. +""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from cases.access import visible_cases_qs +from cases.models import Case, CaseWatcher +from conftest import rls_org + + +@pytest.fixture +def superuser(regular_user): + """The `user_client` caller, still on a USER profile, made superuser.""" + regular_user.is_superuser = True + regular_user.save(update_fields=["is_superuser"]) + return regular_user + + +@pytest.fixture +def tickets(admin_user, regular_user, user_profile, org_a): + """Four tickets answered yesterday: the member created one, is assigned + one, watches one, and has nothing to do with the fourth.""" + yesterday = timezone.now() - timedelta(days=1) + + def make(name, creator): + case = Case.objects.create( + name=name, + status="New", + priority="Normal", + org=org_a, + created_by=creator, + sla_first_response_hours=4, + ) + Case.objects.filter(pk=case.pk).update( + created_at=yesterday, first_response_at=yesterday + timedelta(hours=1) + ) + return case + + created = make("Created", regular_user) + assigned = make("Assigned", admin_user) + assigned.assigned_to.add(user_profile) + watched = make("Watched", admin_user) + CaseWatcher.objects.create(case=watched, profile=user_profile, org=org_a) + hidden = make("Hidden", admin_user) + return { + "created": created, + "assigned": assigned, + "watched": watched, + "hidden": hidden, + } + + +def _names(qs): + return set(qs.values_list("name", flat=True)) + + +ALL = {"Created", "Assigned", "Watched", "Hidden"} +MEMBERS = {"Created", "Assigned", "Watched"} + + +class TestVisibleCasesQs: + def test_a_plain_member_sees_only_their_own(self, user_profile, tickets): + assert _names(visible_cases_qs(user_profile)) == MEMBERS + + def test_a_superuser_on_a_member_profile_sees_every_ticket( + self, superuser, user_profile, tickets + ): + user_profile.refresh_from_db() + assert _names(visible_cases_qs(user_profile)) == ALL + + def test_the_detail_view_agrees(self, user_client, tickets, request): + hidden = tickets["hidden"] + assert user_client.get(f"/api/cases/{hidden.id}/").status_code == 404 + request.getfixturevalue("superuser") + assert user_client.get(f"/api/cases/{hidden.id}/").status_code == 200 + + def test_another_orgs_ticket_stays_out_for_a_superuser( + self, superuser, user_profile, user_b, org_b, tickets + ): + with rls_org(org_b): + Case.objects.create( + name="Foreign", status="New", priority="Normal", org=org_b + ) + assert "Foreign" not in _names(visible_cases_qs(user_profile)) + + +FRT = "/api/cases/analytics/frt/" +DRILL = "/api/cases/analytics/drilldown/" + + +class TestAnalyticsFollowTheReadRule: + def test_a_members_figures_leave_out_what_they_cannot_open( + self, user_client, tickets + ): + assert user_client.get(FRT).data["count"] == 3 + ids = { + row["id"] + for row in user_client.get(DRILL, {"metric": "frt"}).data["results"] + } + assert ids == {str(tickets[k].id) for k in ("created", "assigned", "watched")} + + def test_an_admin_counts_everything(self, admin_client, tickets): + assert admin_client.get(FRT).data["count"] == 4 + + def test_a_superuser_counts_everything(self, superuser, user_client, tickets): + assert user_client.get(FRT).data["count"] == 4 + drilled = user_client.get(DRILL, {"metric": "frt"}).data["results"] + assert {row["name"] for row in drilled} == ALL + + def test_the_service_overview_admits_a_superuser_and_refuses_a_member( + self, user_client, tickets, request + ): + assert user_client.get("/api/cases/analytics/service/").status_code == 403 + request.getfixturevalue("superuser") + assert user_client.get("/api/cases/analytics/service/").status_code == 200 diff --git a/backend/cases/tests/test_cases_api.py b/backend/cases/tests/test_cases_api.py index 78b50eb0..fd94e3f7 100644 --- a/backend/cases/tests/test_cases_api.py +++ b/backend/cases/tests/test_cases_api.py @@ -16,7 +16,7 @@ from django.utils import timezone from rest_framework import status from accounts.models import Account -from cases.models import Case, CasePipeline, CaseStage, Solution +from cases.models import Case, CasePipeline, CaseStage, CaseWatcher, Solution from common.models import Attachments, Comment, Tags, Teams from contacts.models import Contact @@ -514,31 +514,26 @@ class TestCaseDetailView: ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_update_case_non_admin_forbidden(self, user_client, case_a): - """Non-admin who is not creator/assignee should get 403.""" + def test_update_case_non_admin_who_cannot_open_it_is_404(self, user_client, case_a): + """Non-admin who is not creator/assignee/watcher cannot open it: 404.""" response = user_client.put( _detail_url(case_a.pk), {"name": "Hacked", "status": "New", "priority": "Normal"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_delete_case_non_admin_not_creator(self, user_client, case_a): - """Non-admin who is not creator should get 403 on delete.""" + """Non-admin who cannot open the case gets 404 on delete.""" response = user_client.delete(_detail_url(case_a.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND - def test_get_detail_non_admin_forbidden(self, user_client, case_a): - """Non-admin who is not creator/assignee/watcher should get 403. - - The status is unchanged; the body is now DRF's standard - `{"detail": ...}` because the view raises `PermissionDenied` from the - one rule in `cases.access` instead of hand-building an envelope in - five places. Same shape as leads, opportunity, accounts and contacts. - """ + def test_get_detail_non_admin_who_cannot_open_it_is_404(self, user_client, case_a): + """Non-admin who is not creator/assignee/watcher gets a missing + ticket's 404. A 403 here confirmed the ticket existed.""" response = user_client.get(_detail_url(case_a.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN - assert "Permission" in response.data["detail"] + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data["detail"] == "No such case." def test_get_detail_non_admin_as_assignee( self, user_client, admin_user, user_profile, org_a @@ -618,23 +613,25 @@ class TestCaseDetailView: assert case_a.assigned_to.count() == 0 assert case_a.tags.count() == 0 - def test_patch_case_non_admin_forbidden(self, user_client, case_a): - """Non-admin who is not creator/assignee should get 403 on PATCH.""" + def test_patch_case_non_admin_who_cannot_open_it_is_404(self, user_client, case_a): + """Non-admin who cannot open the case gets 404 on PATCH.""" response = user_client.patch( _detail_url(case_a.pk), {"status": "Assigned"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND - def test_post_comment_non_admin_forbidden(self, user_client, case_a): - """Non-admin who is not creator/assignee should get 403 on POST (comment).""" + def test_post_comment_non_admin_who_cannot_open_it_is_404( + self, user_client, case_a + ): + """Non-admin who cannot open the case gets 404 on POST (comment).""" response = user_client.post( _detail_url(case_a.pk), {"comment": "Should not work"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_post_comment_non_admin_as_assignee( self, user_client, admin_user, user_profile, org_a @@ -728,12 +725,15 @@ class TestCaseCommentView: assert not Comment.objects.filter(id=comment.id).exists() def test_update_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who did not create comment should get 403.""" _case, comment = self._create_case_with_comment( admin_user, admin_profile, org_a ) + # A reader of the ticket (a watcher): someone who cannot open it + # gets 404 (`test_case_comment_attachment_scope`). + CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a) response = user_client.put( f"/api/cases/comment/{comment.id}/", {"comment": "Should fail"}, @@ -742,12 +742,15 @@ class TestCaseCommentView: assert response.status_code == status.HTTP_403_FORBIDDEN def test_delete_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who did not create the comment should get 403.""" _case, comment = self._create_case_with_comment( admin_user, admin_profile, org_a ) + # A reader of the ticket (a watcher): someone who cannot open it + # gets 404 (`test_case_comment_attachment_scope`). + CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a) response = user_client.delete(f"/api/cases/comment/{comment.id}/") assert response.status_code == status.HTTP_403_FORBIDDEN @@ -790,7 +793,7 @@ class TestCaseCommentView: assert comment.comment == "Original comment" def test_a_stranger_still_gets_403_for_an_empty_body( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Authorization is still checked first, and still wins. @@ -801,6 +804,9 @@ class TestCaseCommentView: _case, comment = self._create_case_with_comment( admin_user, admin_profile, org_a ) + # A reader of the ticket (a watcher): someone who cannot open it + # gets 404 (`test_case_comment_attachment_scope`). + CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a) response = user_client.put( f"/api/cases/comment/{comment.id}/", {}, @@ -839,7 +845,7 @@ class TestCaseAttachmentView: assert not Attachments.objects.filter(id=attachment.id).exists() def test_delete_attachment_non_admin_forbidden( - self, user_client, admin_user, org_a + self, user_client, user_profile, admin_user, org_a ): """Non-admin who did not create attachment should get 403.""" from django.core.files.uploadedfile import SimpleUploadedFile @@ -860,6 +866,8 @@ class TestCaseAttachmentView: created_by=admin_user, org=org_a, ) + # A reader of the ticket; one who cannot open it gets 404. + CaseWatcher.objects.create(case=case, profile=user_profile, org=org_a) response = user_client.delete(f"/api/cases/attachment/{attachment.id}/") assert response.status_code == status.HTTP_403_FORBIDDEN @@ -1361,12 +1369,15 @@ class TestCaseCommentPatchCoverage: return case, comment def test_patch_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who didn't create comment gets 403 on PATCH (line 824-830).""" _case, comment = self._create_case_with_comment( admin_user, admin_profile, org_a ) + # A reader of the ticket (a watcher): someone who cannot open it + # gets 404 (`test_case_comment_attachment_scope`). + CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a) response = user_client.patch( f"/api/cases/comment/{comment.id}/", {"comment": "Patched by non-admin"}, diff --git a/backend/cases/tests/test_cases_bulk.py b/backend/cases/tests/test_cases_bulk.py index 970b4e22..a902813f 100644 --- a/backend/cases/tests/test_cases_bulk.py +++ b/backend/cases/tests/test_cases_bulk.py @@ -3,7 +3,7 @@ import pytest from cases.approvals import ApprovalRule -from cases.models import Case +from cases.models import Case, CaseWatcher from conftest import rls_org @@ -219,9 +219,13 @@ class TestBulkUpdateCasesCloseGate: @pytest.mark.django_db class TestBulkUpdatePerRecord: - def test_mixed_access_reports_both(self, user_client, regular_user, org_a, case_a): + def test_mixed_access_reports_both( + self, user_client, regular_user, user_profile, org_a, case_a + ): # `case_a` was created by admin_user, so the regular member cannot write - # it. A case they own can be written. + # it; they watch it, so they may open it and hear `no_access`. A case + # they own can be written. + CaseWatcher.objects.create(case=case_a, profile=user_profile, org=org_a) mine = Case.objects.create( name="Mine", status="New", @@ -384,8 +388,10 @@ class TestBulkDeleteCasesAuthz: @pytest.mark.django_db class TestBulkDeletePerRecord: def test_reports_deleted_and_no_access( - self, user_client, regular_user, org_a, case_a + self, user_client, regular_user, user_profile, org_a, case_a ): + # Watched, so readable: `no_access` is for a ticket the caller may open. + CaseWatcher.objects.create(case=case_a, profile=user_profile, org=org_a) mine = Case.objects.create( name="Mine", status="New", diff --git a/backend/cases/tests/test_cases_kanban.py b/backend/cases/tests/test_cases_kanban.py index f6cf07d2..f044725a 100644 --- a/backend/cases/tests/test_cases_kanban.py +++ b/backend/cases/tests/test_cases_kanban.py @@ -567,7 +567,7 @@ class TestCaseMove: def test_move_case_non_admin_permission_denied( self, user_client, admin_user, org_a ): - """Non-admin who is not creator/assignee should get 403.""" + """Non-admin who cannot even open the case gets its detail page's 404.""" case = Case.objects.create( name="Move Deny Case", status="New", @@ -580,7 +580,7 @@ class TestCaseMove: {"status": "Assigned"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_move_case_non_admin_as_assignee_allowed( self, user_client, admin_user, user_profile, org_a diff --git a/backend/cases/tests/test_help_center_public.py b/backend/cases/tests/test_help_center_public.py index 37a0e43a..c1ca759b 100644 --- a/backend/cases/tests/test_help_center_public.py +++ b/backend/cases/tests/test_help_center_public.py @@ -264,26 +264,51 @@ class TestThrottle: assert anon.get(list_url()).status_code == 200 assert anon.get(list_url()).status_code == 429 - def test_buckets_are_per_forwarded_client(self, anon, acme, monkeypatch): + def test_buckets_are_per_client(self, anon, acme, monkeypatch): + monkeypatch.setattr( + HelpCenterIPThrottle, "THROTTLE_RATES", {"help_center_ip": "1/hour"} + ) + first = anon.get(list_url(), REMOTE_ADDR="198.51.100.1") + second = anon.get(list_url(), REMOTE_ADDR="198.51.100.2") + assert (first.status_code, second.status_code) == (200, 200) + + def test_a_forged_forwarded_address_is_not_a_new_bucket( + self, anon, acme, monkeypatch + ): monkeypatch.setattr( HelpCenterIPThrottle, "THROTTLE_RATES", {"help_center_ip": "1/hour"} ) first = anon.get(list_url(), HTTP_X_FORWARDED_FOR="203.0.113.1") second = anon.get(list_url(), HTTP_X_FORWARDED_FOR="203.0.113.2") - assert (first.status_code, second.status_code) == (200, 200) + assert (first.status_code, second.status_code) == (200, 429) - def test_rotating_the_forwarded_address_hits_the_global_limit( - self, anon, acme, monkeypatch + def test_behind_one_proxy_its_entry_is_the_bucket( + self, anon, acme, monkeypatch, settings ): - """X-Forwarded-For is the caller's to set, so each request below gets a - fresh per-visitor bucket; the per-help-center one still fills.""" + """With NUM_PROXIES=1 the entry the proxy appended decides the bucket; + what the caller put to its left does not.""" + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} + monkeypatch.setattr( + HelpCenterIPThrottle, "THROTTLE_RATES", {"help_center_ip": "1/hour"} + ) + proxy = {"REMOTE_ADDR": "127.0.0.1"} + a = anon.get(list_url(), HTTP_X_FORWARDED_FOR="6.6.6.6, 198.51.100.1", **proxy) + b = anon.get(list_url(), HTTP_X_FORWARDED_FOR="198.51.100.2", **proxy) + a_forged = anon.get( + list_url(), HTTP_X_FORWARDED_FOR="7.7.7.7, 198.51.100.1", **proxy + ) + assert [a.status_code, b.status_code, a_forged.status_code] == [200, 200, 429] + + def test_many_real_clients_hit_the_global_limit(self, anon, acme, monkeypatch): + """Each address below gets its own per-visitor bucket; the + per-help-center one still fills.""" monkeypatch.setattr( HelpCenterGlobalThrottle, "THROTTLE_RATES", {"help_center_global": "2/hour"}, ) codes = [ - anon.get(list_url(), HTTP_X_FORWARDED_FOR=f"203.0.113.{n}").status_code + anon.get(list_url(), REMOTE_ADDR=f"198.51.100.{n}").status_code for n in range(1, 4) ] assert codes == [200, 200, 429] diff --git a/backend/cases/tests/test_hidden_ticket_is_404.py b/backend/cases/tests/test_hidden_ticket_is_404.py new file mode 100644 index 00000000..ab243969 --- /dev/null +++ b/backend/cases/tests/test_hidden_ticket_is_404.py @@ -0,0 +1,169 @@ +"""A same-org ticket the caller cannot open answers like a missing one. + +Owner decision (1.11.0): on every endpoint that takes a ticket id, a ticket in +the caller's org that they may not read answers 404 with a body identical to +the one a missing id gets. A 403 confirmed the ticket existed. The responses +are compared whole, status and body, never by status alone, and the hidden +ticket is checked untouched afterwards. + +The caller is a plain member who did not create the hidden ticket, is not +assigned to it and does not watch it. `test_case_access_and_sla` pins the +other direction: a watcher, who may read, still gets 403 on a write. +""" + +import pytest + +from cases.models import Case, CaseWatcher, TimeEntry +from common.models import Comment + +MISSING = "00000000-0000-0000-0000-00000000abcd" +OTHER = "00000000-0000-0000-0000-00000000ef01" + +# (method, path under /api/cases//, body) +ENDPOINTS = [ + ("get", "", None), + ("put", "", {"name": "Renamed", "status": "New", "priority": "Normal"}), + ("patch", "", {"priority": "Low"}), + ("post", "", {"comment": "hello"}), + ("delete", "", None), + ("get", "activities/", None), + ("post", "solutions/", {"solution_id": OTHER}), + ("delete", f"solutions/{OTHER}/", None), + ("get", "solution-suggestions/", None), + ("post", "watch/", None), + ("delete", "watch/", None), + ("get", "watchers/", None), + ("get", "merge-targets/", None), + ("post", "unmerge/", None), + ("get", "tree/", None), + ("post", "link/", {"parent_id": None}), + ("post", "close-with-children/", {"cascade": False}), + ("get", "time-entries/", None), + ("post", "time-entries/", {"duration_minutes": 30, "description": "x"}), + ("post", "time-entries/start/", None), + ("get", "time-summary/", None), + ("post", "request-approval/", {}), + ("patch", "move/", {"status": "Assigned"}), +] + + +@pytest.fixture +def hidden(admin_user, org_a): + return Case.objects.create( + name="Payroll dispute", + status="New", + priority="Normal", + org=org_a, + created_by=admin_user, + ) + + +@pytest.fixture +def mine(regular_user, org_a): + return Case.objects.create( + name="My ticket", + status="New", + priority="Normal", + org=org_a, + created_by=regular_user, + ) + + +def _call(client, method, url, body): + return getattr(client, method)(url, body, format="json") + + +def _whole(response): + return response.status_code, response.content + + +def _untouched(case): + case.refresh_from_db() + assert case.status == "New" + assert case.name == "Payroll dispute" + assert case.priority == "Normal" + assert case.is_active is True + assert case.parent_id is None + assert not CaseWatcher.objects.filter(case=case).exists() + assert not TimeEntry.objects.filter(case=case).exists() + assert not Comment.objects.filter(object_id=case.id).exists() + + +@pytest.mark.parametrize(("method", "path", "body"), ENDPOINTS) +def test_hidden_answers_exactly_like_missing(method, path, body, user_client, hidden): + got = _call(user_client, method, f"/api/cases/{hidden.id}/{path}", body) + missing = _call(user_client, method, f"/api/cases/{MISSING}/{path}", body) + assert got.status_code == 404, got.content + assert _whole(got) == _whole(missing) + assert b"Payroll dispute" not in got.content + _untouched(hidden) + + +@pytest.mark.parametrize(("method", "path", "body"), ENDPOINTS) +def test_a_reader_is_not_told_404( + method, path, body, user_client, user_profile, hidden, org_a +): + """The other side of the rule: once the member may read the ticket (here, + as its assignee) no endpoint above answers what a missing ticket gets. + (Two of them still 404, about the solution id in the path or body.)""" + hidden.assigned_to.add(user_profile) + got = _call(user_client, method, f"/api/cases/{hidden.id}/{path}", body) + missing = _call(user_client, method, f"/api/cases/{MISSING}/{path}", body) + assert _whole(got) != _whole(missing), (path, got.content) + + +def test_merging_a_hidden_source_answers_like_a_missing_one(user_client, hidden, mine): + got = user_client.post(f"/api/cases/{hidden.id}/merge/{mine.id}/") + missing = user_client.post(f"/api/cases/{MISSING}/merge/{mine.id}/") + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + _untouched(hidden) + + +def test_merging_into_a_hidden_target_answers_like_a_missing_one( + user_client, hidden, mine +): + got = user_client.post(f"/api/cases/{mine.id}/merge/{hidden.id}/") + missing = user_client.post(f"/api/cases/{mine.id}/merge/{MISSING}/") + assert got.status_code == 404 + assert _whole(got) == _whole(missing) + mine.refresh_from_db() + assert mine.merged_into_id is None + + +class TestBulk: + """A batch leaves a hidden id out of `results`, exactly as a missing one.""" + + def test_update_reports_nothing_for_a_hidden_id(self, user_client, hidden, mine): + response = user_client.post( + "/api/cases/bulk/update/", + { + "ids": [str(hidden.id), MISSING, str(mine.id)], + "fields": {"priority": "Low"}, + }, + format="json", + ) + assert response.json()["results"] == [{"id": str(mine.id), "status": "updated"}] + _untouched(hidden) + + def test_delete_reports_nothing_for_a_hidden_id(self, user_client, hidden, mine): + response = user_client.post( + "/api/cases/bulk/delete/", + {"ids": [str(hidden.id), MISSING, str(mine.id)]}, + format="json", + ) + assert response.json()["results"] == [{"id": str(mine.id), "status": "deleted"}] + _untouched(hidden) + + def test_a_readable_but_unwritable_ticket_still_reports_no_access( + self, user_client, user_profile, hidden, org_a + ): + CaseWatcher.objects.create(case=hidden, profile=user_profile, org=org_a) + response = user_client.post( + "/api/cases/bulk/update/", + {"ids": [str(hidden.id)], "fields": {"priority": "Low"}}, + format="json", + ) + assert response.json()["results"] == [ + {"id": str(hidden.id), "status": "no_access"} + ] diff --git a/backend/cases/tests/test_kanban_move_write_rule.py b/backend/cases/tests/test_kanban_move_write_rule.py index c8002842..20057bc4 100644 --- a/backend/cases/tests/test_kanban_move_write_rule.py +++ b/backend/cases/tests/test_kanban_move_write_rule.py @@ -2,10 +2,10 @@ `CaseMoveView.patch` rewrites a ticket's status, stage and kanban order. It used to gate on ``is_org_admin(...) or request.user.is_superuser`` plus creator or -assignee, while `cases.access` gives a Django superuser nothing and -`CaseDetailView.patch` refuses them. So a superuser who is a plain member could -move, through the board, any ticket they could not edit through the detail -endpoint. These tests pin the move to `assert_case_write_access`. +assignee, a superuser clause of its own that `CaseDetailView.patch` did not +have. The move now asks `assert_case_write_access`, and a superuser is an org +admin through `is_org_admin` on both paths alike (owner decision, 1.11.0). A +ticket the caller may not even open answers 404, as its detail page does. """ import pytest @@ -46,18 +46,21 @@ def _assert_unchanged(case): class TestSuperuserPlainMember: - def test_refused_on_a_ticket_they_cannot_edit( + def test_admin_on_the_board_as_on_the_detail( self, superuser, user_client, others_case ): + """A superuser on a USER profile may move a ticket they neither + created nor hold, and the detail PATCH agrees.""" + response = _move(user_client, others_case) + assert response.status_code == 200, response.content + others_case.refresh_from_db() + assert others_case.status == "Assigned" assert ( user_client.patch( - f"/api/cases/{others_case.id}/", {"status": "Assigned"}, format="json" + f"/api/cases/{others_case.id}/", {"status": "New"}, format="json" ).status_code - == 403 + == 200 ) - response = _move(user_client, others_case) - assert response.status_code == 403, response.content - _assert_unchanged(others_case) def test_allowed_on_a_ticket_they_created( self, superuser, user_client, regular_user, org_a @@ -107,9 +110,16 @@ class TestOrdinaryRoles: others_case.refresh_from_db() assert others_case.status == "Assigned" - def test_member_without_write_access_is_refused(self, user_client, others_case): + def test_member_who_cannot_open_it_gets_a_missing_tickets_answer( + self, user_client, others_case + ): response = _move(user_client, others_case) - assert response.status_code == 403, response.content + missing = _move(user_client, Case(id="00000000-0000-0000-0000-000000000000")) + assert response.status_code == 404, response.content + assert (response.status_code, response.json()) == ( + missing.status_code, + missing.json(), + ) _assert_unchanged(others_case) def test_watcher_reads_but_may_not_move( diff --git a/backend/cases/tests/test_merge_targets.py b/backend/cases/tests/test_merge_targets.py index c044ea1f..82adf925 100644 --- a/backend/cases/tests/test_merge_targets.py +++ b/backend/cases/tests/test_merge_targets.py @@ -157,7 +157,7 @@ class TestMergeTargets: ): hidden = _case(org_a, admin_user, "Hidden") detail = user_client.get(f"/api/cases/{hidden.id}/").status_code - assert _targets(user_client, hidden).status_code == detail == 403 + assert _targets(user_client, hidden).status_code == detail == 404 def test_another_orgs_source_is_404(self, admin_client, user_b, org_b): with rls_org(org_b): @@ -175,9 +175,9 @@ class TestMergeTargets: def test_board_move_of_an_unwritable_ticket_answers_like_its_detail( who, user_client, user_profile, admin_user, org_a ): - """A hidden ticket's move answers what its detail GET answers (403 inside - the org, `test_kanban_move_write_rule` pins 404 across orgs), and a - watcher, who may read it, is still refused the move. Nothing is written.""" + """A hidden ticket's move answers what its detail GET answers (404, inside + the org as across it), and a watcher, who may read it, is refused the move + with a 403. Nothing is written.""" case = _case(org_a, admin_user, "Not mine") if who == "watcher": CaseWatcher.objects.create(case=case, profile=user_profile, org=org_a) @@ -185,7 +185,7 @@ def test_board_move_of_an_unwritable_ticket_answers_like_its_detail( move = user_client.patch( f"/api/cases/{case.id}/move/", {"status": "Closed"}, format="json" ) - assert move.status_code == 403 - assert detail == (200 if who == "watcher" else 403) + assert move.status_code == (403 if who == "watcher" else 404) + assert detail == (200 if who == "watcher" else 404) case.refresh_from_db() assert case.status == "New" diff --git a/backend/cases/tests/test_merged_from_read_rule.py b/backend/cases/tests/test_merged_from_read_rule.py index 04c28be1..d4b111d5 100644 --- a/backend/cases/tests/test_merged_from_read_rule.py +++ b/backend/cases/tests/test_merged_from_read_rule.py @@ -53,7 +53,7 @@ class TestMergedFrom: # The source really is hidden from this member. assert ( user_client.get(f"/api/cases/{hidden.id}/?show_merged=true").status_code - == 403 + == 404 ) resp = user_client.get(f"/api/cases/{target.id}/") diff --git a/backend/cases/tests/test_merged_redirect_read_rule.py b/backend/cases/tests/test_merged_redirect_read_rule.py index 0c68c3b7..7fd92264 100644 --- a/backend/cases/tests/test_merged_redirect_read_rule.py +++ b/backend/cases/tests/test_merged_redirect_read_rule.py @@ -34,7 +34,9 @@ class TestMergedRedirect: def test_member_who_cannot_open_it_learns_nothing(self, user_client, merged_pair): duplicate, target = merged_pair response = user_client.get(f"/api/cases/{duplicate.id}/") - assert response.status_code == 403 + missing = user_client.get("/api/cases/00000000-0000-0000-0000-000000000000/") + assert response.status_code == 404 + assert response.json() == missing.json() body = response.content.decode() assert "Secret duplicate" not in body assert str(target.id) not in body diff --git a/backend/cases/tests/test_merged_status_locked.py b/backend/cases/tests/test_merged_status_locked.py new file mode 100644 index 00000000..76b45af8 --- /dev/null +++ b/backend/cases/tests/test_merged_status_locked.py @@ -0,0 +1,238 @@ +"""A merged ticket's status changes only by unmerging it (owner decision, 1.11.0). + +Editing a merged ticket (``merged_into`` set) to another status used to keep +``merged_into``, so the ticket was open and merged at once. Every path that +writes a ticket's status now refuses that with the same sentence, and each +refusal here is paired with the same request on an unmerged ticket going +through. +""" + +from datetime import timedelta + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from cases.models import Case +from cases.workflow import MERGED_STATUS_LOCKED +from common.models import Comment +from conftest import rls_org + + +def _case(org, creator, name, **extra): + fields = {"status": "New", "priority": "Normal"} + fields.update(extra) + return Case.objects.create(name=name, org=org, created_by=creator, **fields) + + +@pytest.fixture +def merged(admin_client, admin_user, org_a): + """A ticket merged into another through the real endpoint.""" + target = _case(org_a, admin_user, "Survivor") + source = _case(org_a, admin_user, "Duplicate of survivor") + response = admin_client.post(f"/api/cases/{source.id}/merge/{target.id}/") + assert response.status_code == 200, response.content + source.refresh_from_db() + assert source.merged_into_id == target.id and source.status == "Duplicate" + return source + + +@pytest.fixture +def plain(admin_user, org_a): + return _case(org_a, admin_user, "Ordinary ticket") + + +def _detail(case): + return f"/api/cases/{case.id}/" + + +def _unchanged(case): + before = case.status + case.refresh_from_db() + assert case.status == before + assert case.merged_into_id is not None + + +class TestDetailEdit: + def test_patch_status_on_a_merged_ticket_is_refused(self, admin_client, merged): + response = admin_client.patch( + _detail(merged), {"status": "Assigned"}, format="json" + ) + assert response.status_code == 400 + assert response.json()["errors"] == {"status": [MERGED_STATUS_LOCKED]} + _unchanged(merged) + + def test_put_status_on_a_merged_ticket_is_refused(self, admin_client, merged): + response = admin_client.put( + _detail(merged), + {"name": merged.name, "status": "Closed", "priority": "Normal"}, + format="json", + ) + assert response.status_code == 400 + assert MERGED_STATUS_LOCKED in str(response.json()) + _unchanged(merged) + + def test_other_fields_on_a_merged_ticket_still_save(self, admin_client, merged): + """Re-sending the status it has is not a change of status.""" + response = admin_client.put( + _detail(merged), + { + "name": merged.name, + "status": "Duplicate", + "priority": "High", + "description": "Kept for the record.", + }, + format="json", + ) + assert response.status_code == 200, response.content + merged.refresh_from_db() + assert merged.priority == "High" + assert merged.status == "Duplicate" + + def test_an_unmerged_ticket_changes_status(self, admin_client, plain): + response = admin_client.patch( + _detail(plain), {"status": "Assigned"}, format="json" + ) + assert response.status_code == 200, response.content + plain.refresh_from_db() + assert plain.status == "Assigned" + + def test_after_unmerge_the_status_changes_again(self, admin_client, merged): + response = admin_client.post(f"/api/cases/{merged.id}/unmerge/") + assert response.status_code == 200, response.content + response = admin_client.patch( + _detail(merged), {"status": "Assigned"}, format="json" + ) + assert response.status_code == 200, response.content + merged.refresh_from_db() + assert merged.status == "Assigned" and merged.merged_into_id is None + + +class TestBoardMove: + def test_move_of_a_merged_ticket_is_refused(self, admin_client, merged): + response = admin_client.patch( + f"/api/cases/{merged.id}/move/", {"status": "Assigned"}, format="json" + ) + assert response.status_code == 400 + assert response.json()["errors"] == {"status": [MERGED_STATUS_LOCKED]} + _unchanged(merged) + + def test_move_of_an_unmerged_ticket_goes_through(self, admin_client, plain): + response = admin_client.patch( + f"/api/cases/{plain.id}/move/", {"status": "Assigned"}, format="json" + ) + assert response.status_code == 200, response.content + plain.refresh_from_db() + assert plain.status == "Assigned" + + +class TestBulkUpdate: + def test_merged_ticket_reported_and_left_alone(self, admin_client, merged, plain): + response = admin_client.post( + "/api/cases/bulk/update/", + {"ids": [str(merged.id), str(plain.id)], "fields": {"status": "Pending"}}, + format="json", + ) + assert response.status_code == 200, response.content + body = response.json() + assert body["updated"] == 1 + by_id = {r["id"]: r for r in body["results"]} + assert by_id[str(merged.id)] == { + "id": str(merged.id), + "status": "merged", + "detail": MERGED_STATUS_LOCKED, + } + assert by_id[str(plain.id)]["status"] == "updated" + _unchanged(merged) + plain.refresh_from_db() + assert plain.status == "Pending" + + def test_a_non_status_bulk_edit_reaches_a_merged_ticket(self, admin_client, merged): + response = admin_client.post( + "/api/cases/bulk/update/", + {"ids": [str(merged.id)], "fields": {"priority": "Urgent"}}, + format="json", + ) + assert response.json()["results"] == [ + {"id": str(merged.id), "status": "updated"} + ] + merged.refresh_from_db() + assert merged.priority == "Urgent" and merged.status == "Duplicate" + + +class TestCloseWithChildren: + def test_closing_a_merged_ticket_is_refused(self, admin_client, merged): + response = admin_client.post( + f"/api/cases/{merged.id}/close-with-children/", + {"cascade": False}, + format="json", + ) + assert response.status_code == 400 + assert response.json()["errors"] == {"status": [MERGED_STATUS_LOCKED]} + _unchanged(merged) + + def test_the_cascade_passes_over_a_merged_child( + self, admin_client, admin_user, org_a + ): + parent = _case(org_a, admin_user, "Incident") + open_child = _case(org_a, admin_user, "Open child", parent=parent) + survivor = _case(org_a, admin_user, "Survivor") + merged_child = _case(org_a, admin_user, "Merged child", parent=parent) + assert ( + admin_client.post( + f"/api/cases/{merged_child.id}/merge/{survivor.id}/" + ).status_code + == 200 + ) + + response = admin_client.post( + f"/api/cases/{parent.id}/close-with-children/", + {"cascade": True}, + format="json", + ) + assert response.status_code == 200, response.content + assert response.json()["cascaded_case_ids"] == [str(open_child.id)] + merged_child.refresh_from_db() + assert merged_child.status == "Duplicate" + open_child.refresh_from_db() + assert open_child.status == "Closed" + + +class TestReopen: + """A merged ticket left Closed by an edit before this rule existed is not + reopened by a customer reply; an ordinary closed ticket still is.""" + + def _closed(self, case, **extra): + with rls_org(case.org): + Case.objects.filter(pk=case.pk).update( + status="Closed", + closed_on=timezone.localdate() - timedelta(days=1), + **extra, + ) + case.refresh_from_db() + return case + + def _customer_reply(self, case): + with rls_org(case.org): + Comment.objects.create( + comment="Still broken", + content_type=ContentType.objects.get_for_model(Case), + object_id=case.pk, + commented_by=None, + is_internal=False, + org=case.org, + ) + case.refresh_from_db() + + def test_merged_and_closed_stays_closed(self, admin_user, org_a): + survivor = _case(org_a, admin_user, "Survivor") + legacy = self._closed( + _case(org_a, admin_user, "Legacy merged"), merged_into=survivor + ) + self._customer_reply(legacy) + assert legacy.status == "Closed" + + def test_unmerged_and_closed_reopens(self, admin_user, org_a): + closed = self._closed(_case(org_a, admin_user, "Closed")) + self._customer_reply(closed) + assert closed.status != "Closed" diff --git a/backend/cases/tests/test_parent_summary_access.py b/backend/cases/tests/test_parent_summary_access.py index fd451579..81807d33 100644 --- a/backend/cases/tests/test_parent_summary_access.py +++ b/backend/cases/tests/test_parent_summary_access.py @@ -52,7 +52,7 @@ class TestDetail: parent = _case(org_a, admin_user, SECRET) child = _case(org_a, regular_user, "My sub-ticket", parent=parent) # The parent really is hidden from this member. - assert user_client.get(f"/api/cases/{parent.id}/").status_code == 403 + assert user_client.get(f"/api/cases/{parent.id}/").status_code == 404 resp = user_client.get(f"/api/cases/{child.id}/") assert resp.status_code == 200, resp.content diff --git a/backend/cases/tests/test_parent_views_access.py b/backend/cases/tests/test_parent_views_access.py index b3761ea0..56fe9fae 100644 --- a/backend/cases/tests/test_parent_views_access.py +++ b/backend/cases/tests/test_parent_views_access.py @@ -65,14 +65,14 @@ class TestTreeAccess: root = _case(org_a, admin_user, "Admin root") _case(org_a, admin_user, "Admin child", parent=root) resp = user_client.get(f"/api/cases/{root.id}/tree/") - # The ticket detail GET answers a hidden in-org ticket 403 too. - assert resp.status_code == 403 + # The ticket detail GET answers a hidden in-org ticket 404 too. + assert resp.status_code == 404 assert b"Admin child" not in resp.content assert b"Admin root" not in resp.content def test_detail_get_answers_the_same(self, user_client, admin_user, org_a): root = _case(org_a, admin_user, "Admin root") - assert user_client.get(f"/api/cases/{root.id}/").status_code == 403 + assert user_client.get(f"/api/cases/{root.id}/").status_code == 404 def test_watcher_may_read_the_tree( self, user_client, admin_user, user_profile, org_a @@ -146,10 +146,12 @@ class TestLinkAccess: ) def test_member_cannot_unlink_a_ticket_they_cannot_write( - self, user_client, admin_user, org_a + self, user_client, admin_user, user_profile, org_a ): parent = _case(org_a, admin_user, "Admin parent") child = _case(org_a, admin_user, "Admin child", parent=parent) + # Watching grants read, not write, so the refusal is the 403. + _watch(child, user_profile) resp = user_client.post( f"/api/cases/{child.id}/link/", {"parent_id": None}, format="json" ) diff --git a/backend/cases/tests/test_solution_access.py b/backend/cases/tests/test_solution_access.py index 48087d3a..2bb4fc4a 100644 --- a/backend/cases/tests/test_solution_access.py +++ b/backend/cases/tests/test_solution_access.py @@ -342,7 +342,7 @@ class TestLinkedCasesIsNotAWayIntoCases: def test_the_member_really_cannot_open_that_case(self, user_client, hidden_case): """The premise. Without this the rest proves nothing.""" response = user_client.get(f"/api/cases/{hidden_case.pk}/") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_member_may_not_link_an_article_to_it( self, user_client, hidden_case, member_article @@ -352,7 +352,7 @@ class TestLinkedCasesIsNotAWayIntoCases: {"solution_id": str(member_article.pk)}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert hidden_case.solutions.count() == 0 def test_member_may_not_unlink_from_it( @@ -360,7 +360,7 @@ class TestLinkedCasesIsNotAWayIntoCases: ): hidden_case.solutions.add(member_article) response = user_client.delete(_unlink(hidden_case.pk, member_article.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert hidden_case.solutions.count() == 1 def test_linked_cases_hides_the_case_from_the_member( diff --git a/backend/cases/tests/test_time_entry_write_rule.py b/backend/cases/tests/test_time_entry_write_rule.py new file mode 100644 index 00000000..f0f25cb7 --- /dev/null +++ b/backend/cases/tests/test_time_entry_write_rule.py @@ -0,0 +1,88 @@ +"""Logging time and starting a timer take the ticket's write rule. + +Listing a ticket's time and reading its summary need read access. Logging time +and starting a timer are work on the ticket, so they take the rule replying +takes (`comment_permission` on the detail payload): an assignee or creator may, +a watcher (who may read) gets 403, and a member who cannot open the ticket gets +the 404 a missing ticket gets. Stopping stays the entry owner's. +""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from cases.models import Case, CaseWatcher, TimeEntry + + +@pytest.fixture +def ticket(admin_user, org_a): + return Case.objects.create( + name="Printer on fire", + status="New", + priority="Normal", + org=org_a, + created_by=admin_user, + ) + + +def _log(client, case): + end = timezone.now() - timedelta(minutes=5) + return client.post( + f"/api/cases/{case.id}/time-entries/", + { + "started_at": (end - timedelta(minutes=30)).isoformat(), + "ended_at": end.isoformat(), + "description": "Replaced the toner", + }, + format="json", + ) + + +def _start(client, case): + return client.post(f"/api/cases/{case.id}/time-entries/start/", {}, format="json") + + +WRITES = [_log, _start] + + +@pytest.mark.parametrize("write", WRITES) +def test_an_assignee_may(write, user_client, user_profile, ticket): + ticket.assigned_to.add(user_profile) + assert write(user_client, ticket).status_code == 201 + assert TimeEntry.objects.filter(case=ticket, profile=user_profile).count() == 1 + + +@pytest.mark.parametrize("write", WRITES) +def test_a_watcher_reads_but_may_not_write( + write, user_client, user_profile, ticket, org_a +): + CaseWatcher.objects.create(case=ticket, profile=user_profile, org=org_a) + assert user_client.get(f"/api/cases/{ticket.id}/time-entries/").status_code == 200 + assert user_client.get(f"/api/cases/{ticket.id}/time-summary/").status_code == 200 + # The detail payload tells the clients the same thing. + detail = user_client.get(f"/api/cases/{ticket.id}/").json() + assert detail["comment_permission"] is False + + assert write(user_client, ticket).status_code == 403 + assert not TimeEntry.objects.filter(case=ticket).exists() + + +@pytest.mark.parametrize("write", WRITES) +def test_a_member_who_cannot_open_it_gets_404(write, user_client, ticket): + assert write(user_client, ticket).status_code == 404 + assert not TimeEntry.objects.filter(case=ticket).exists() + + +def test_the_owner_still_stops_their_timer_after_losing_the_ticket( + user_client, user_profile, ticket +): + """Stopping is not gated on the ticket: refusing it would strand the one + running timer a person may have, org-wide.""" + ticket.assigned_to.add(user_profile) + entry_id = _start(user_client, ticket).json()["id"] + ticket.assigned_to.remove(user_profile) + + response = user_client.post(f"/api/time-entries/{entry_id}/stop/") + assert response.status_code == 200, response.content + assert TimeEntry.objects.get(id=entry_id).ended_at is not None diff --git a/backend/cases/tests/test_time_tracking.py b/backend/cases/tests/test_time_tracking.py index 2c503c39..fd5bbf3e 100644 --- a/backend/cases/tests/test_time_tracking.py +++ b/backend/cases/tests/test_time_tracking.py @@ -148,6 +148,7 @@ class TestListEndpoint: org_a, ): case = _make_case(org_a, admin_user) + case.assigned_to.add(user_profile) _entry(case, admin_profile, ended_at=timezone.now()) _entry(case, user_profile, ended_at=timezone.now()) resp = user_client.get(f"/api/cases/{case.id}/time-entries/") diff --git a/backend/cases/time_views.py b/backend/cases/time_views.py index 71b33f58..8eebf462 100644 --- a/backend/cases/time_views.py +++ b/backend/cases/time_views.py @@ -8,6 +8,16 @@ Case-scoped: * ``POST /api/cases//time-entries/start/``: start a running timer (409 if one active) * ``GET /api/cases//time-summary/``: totals + by-profile breakdown +All four need read access to the ticket (`get_case_or_404`), and a ticket the +caller may not open is a 404. They used to check the org alone, so any member +could log time on, start a timer against, or read the per-person breakdown of +a ticket the detail view refuses them. Logging time and starting a timer are +work on the ticket, so they also take its write rule (the one replying takes, +`comment_permission` on the detail payload): a watcher reads, and gets 403. +Stopping a timer is not gated on the ticket: it is the entry owner's (or an +admin's) and only ends their own clock, and refusing it to someone since +unassigned would strand their one running timer. + Entry-scoped (registered at the project root under ``/api/time-entries/``): * ``POST /api/time-entries//stop/``: stop a running timer @@ -23,7 +33,6 @@ from datetime import datetime, timedelta from django.db import IntegrityError, transaction from django.db.models import Sum -from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -31,7 +40,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from cases import time_reports -from cases.models import Case, TimeEntry +from cases.access import assert_case_write_access, get_case_or_404 +from cases.models import TimeEntry from cases.serializer import ( TimeEntryCreateSerializer, TimeEntrySerializer, @@ -128,14 +138,15 @@ class TimeEntryListCreateView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get(self, request, pk): - case = get_object_or_404(Case, id=pk, org=request.profile.org) + case = get_case_or_404(request.profile, pk) entries = ( _visible_entry_qs(request.profile).filter(case=case).order_by("-started_at") ) return Response(TimeEntrySerializer(entries, many=True).data) def post(self, request, pk): - case = get_object_or_404(Case, id=pk, org=request.profile.org) + case = get_case_or_404(request.profile, pk) + assert_case_write_access(request.profile, case) serializer = TimeEntryCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: @@ -160,7 +171,8 @@ class TimeEntryStartView(APIView): @transaction.atomic def post(self, request, pk): - case = get_object_or_404(Case, id=pk, org=request.profile.org) + case = get_case_or_404(request.profile, pk) + assert_case_write_access(request.profile, case) # Reject if this profile already has a running timer (anywhere). The # one_active_timer_per_profile partial unique would also catch this @@ -202,7 +214,7 @@ class TimeSummaryView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get(self, request, pk): - case = get_object_or_404(Case, id=pk, org=request.profile.org) + case = get_case_or_404(request.profile, pk) qs = case.time_entries.filter(ended_at__isnull=False) total = qs.aggregate(total=Sum("duration_minutes"))["total"] or 0 billable = ( diff --git a/backend/cases/unmerge_views.py b/backend/cases/unmerge_views.py index 3de648de..051d5eb5 100644 --- a/backend/cases/unmerge_views.py +++ b/backend/cases/unmerge_views.py @@ -21,6 +21,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from cases.access import has_case_read_access from cases.merge_views import _can_merge from cases.models import Case, EmailMessage from cases.serializer import CaseSerializer, parent_access_context @@ -50,9 +51,9 @@ class CaseUnmergeView(APIView): org = request.profile.org with transaction.atomic(): - try: - source = Case.objects.select_for_update().get(id=pk, org=org) - except Case.DoesNotExist: + source = Case.objects.select_for_update().filter(id=pk, org=org).first() + # A ticket the caller may not open answers as a missing one does. + if source is None or not has_case_read_access(request.profile, source): return Response( {"error": True, "errors": "Ticket not found."}, status=status.HTTP_404_NOT_FOUND, diff --git a/backend/cases/views.py b/backend/cases/views.py index 64996047..efbb5053 100644 --- a/backend/cases/views.py +++ b/backend/cases/views.py @@ -6,7 +6,6 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import Q -from django.http import Http404 from django.utils import timezone from drf_spectacular.utils import ( extend_schema, @@ -23,7 +22,6 @@ from accounts.serializer import AccountPickerSerializer from cases import swagger_params from cases.access import ( assert_case_delete_access, - assert_case_read_access, assert_case_write_access, get_case_or_404, has_case_write_access, @@ -47,6 +45,7 @@ from cases.signals import route_after_relations from cases.solution_serializers import SolutionSerializer from cases.tasks import send_email_to_assigned_user from common.custom_fields import validate_payload as validate_custom_fields_payload +from common.lookups import get_on_visible_record_or_404 from common.models import ( Activity, Attachments, @@ -445,7 +444,7 @@ class CaseDetailView(APIView): model = Case def get_object(self, pk): - """A case in the requester's org, or 404. + """A case the requester may open, or 404 (see `get_case_or_404`). This used to return ``None`` for a missing case and leave every caller to notice. Only `get` did; `put`, `patch` and `delete` went straight @@ -596,11 +595,11 @@ class CaseDetailView(APIView): }, ) def get(self, request, pk, format=None): + # Authorised by the lookup itself, before anything about the case + # leaves: the merge redirect below used to answer first, handing any + # member the name of a ticket they cannot open and the id of the one + # it was merged into. self.cases = self.get_object(pk=pk) - # Authorise before anything about the case leaves: the merge redirect - # below used to answer first, handing any member the name of a ticket - # they cannot open and the id of the one it was merged into. - assert_case_read_access(request.profile, self.cases) # Merged duplicate: tell the client to redirect. JSON form (200) keeps # the SvelteKit route's error handling simple. The query param # `?show_merged=true` lets agents view the duplicate directly via @@ -948,17 +947,21 @@ class CaseCommentView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - """Org-scoped already; `.get()` was the problem. A comment id that - does not exist raised DoesNotExist and answered 500 instead of 404.""" - try: - comment = self.model.objects.filter( - pk=pk, org=self.request.profile.org - ).first() - except (DjangoValidationError, ValueError): - raise Http404("No such comment.") - if comment is None: - raise Http404("No such comment.") - return comment + """A comment on a ticket the caller may open, or 404. + + It used to be fetched from the whole org, across every module's + comments, and then refused with 403 when the caller was not its author: + an oracle for comments on tickets (and leads and deals) they cannot + open, and a way for an author who had lost the ticket to keep editing + and deleting there. A comment on a hidden ticket, or on another + module's record, now answers exactly like an id that does not exist. + """ + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + visible_cases_qs(self.request.profile), + ) @extend_schema( tags=["Cases"], @@ -1110,14 +1113,12 @@ class CaseAttachmentView(APIView): The same one-line lookup bug is still open in `leads`, `tasks` and `opportunity`. """ - try: - self.object = self.model.objects.filter( - pk=pk, org=request.profile.org - ).first() - except (DjangoValidationError, ValueError): - raise Http404("No such attachment.") - if self.object is None: - raise Http404("No such attachment.") + # On a ticket the caller may open, or the 404 a missing id gets: an + # org-wide fetch answered 403 for an attachment on a hidden ticket or + # on any other module's record, which confirmed it existed. + self.object = get_on_visible_record_or_404( + self.model, pk, request.profile.org, visible_cases_qs(request.profile) + ) if ( is_org_admin(request.profile) @@ -1237,7 +1238,6 @@ class CaseActivityListView(APIView, LimitOffsetPagination): ) def get(self, request, pk): case = get_case_or_404(request.profile, pk) - assert_case_read_access(request.profile, case) queryset = Activity.objects.filter( entity_type="Case", diff --git a/backend/cases/workflow.py b/backend/cases/workflow.py index a8f9c1fe..84c8cc16 100644 --- a/backend/cases/workflow.py +++ b/backend/cases/workflow.py @@ -124,3 +124,28 @@ def duplicate_refusal(old_status, new_status): if new_status == "Duplicate" and old_status != "Duplicate": return {"status": DUPLICATE_BY_MERGE_ONLY} return None + + +MERGED_STATUS_LOCKED = ( + "This ticket is merged into another. Unmerge it first to change its status." +) + + +def merged_status_refusal(case, new_status): + """Refuse changing the status of a merged ticket. + + A merged ticket (``merged_into`` set) is Duplicate because the merge made + it so, and its comments, attachments and email thread now live on the + ticket it was merged into. Edited to another status it kept ``merged_into``, + so it was open and merged at once: back on the list and the board, still + redirecting to the target, and holding none of its own history. Unmerge is + the way back; it restores the status the ticket had before the merge. + + Re-sending the status the ticket already has is not a change, so a full + PUT that edits only the description still goes through. + + Returns ``{"status": message}`` or None, the shape ``close_refusal`` uses. + """ + if case is not None and case.merged_into_id and new_status != case.status: + return {"status": MERGED_STATUS_LOCKED} + return None diff --git a/backend/common/audit_log.py b/backend/common/audit_log.py index f3139938..6de6e74e 100644 --- a/backend/common/audit_log.py +++ b/backend/common/audit_log.py @@ -21,6 +21,7 @@ import logging from django.db import models from common.base import BaseModel +from common.request_meta import client_ip logger = logging.getLogger("security.audit") @@ -47,6 +48,9 @@ class SecurityAuditLog(BaseModel): ("MEMBERSHIP_REVOKED", "Membership Revoked"), ("SUSPICIOUS_ACTIVITY", "Suspicious Activity"), ("SAMPLE_DATA_CLEARED", "Vertical Pack Sample Data Cleared"), + ("WEBHOOK_PAUSED", "Webhook Paused"), + ("WEBHOOK_REENABLED", "Webhook Re-enabled"), + ("WEBHOOK_CHANGED", "Webhook Destination Changed"), ) event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) @@ -107,15 +111,10 @@ class AuditLogger: if not request: return {} - # Get IP address - ip = request.META.get("HTTP_X_FORWARDED_FOR") - if ip: - ip = ip.split(",")[0].strip() - else: - ip = request.META.get("REMOTE_ADDR") - + # Never the first X-Forwarded-For entry: the caller writes that, and + # admins read this column in the audit log viewer as fact. return { - "ip_address": ip, + "ip_address": client_ip(request), "user_agent": request.META.get("HTTP_USER_AGENT", "")[:500], "request_path": request.path[:500], "request_method": request.method, @@ -313,6 +312,51 @@ class AuditLogger: request=request, ) + def webhook_paused(self, endpoint, reason, creator=None, request=None): + """Log a webhook paused because its creator lost the standing to own + it (see `webhooks.ownership`). `user` is that creator, or None when + their user row is being deleted; `creator_id` survives either way. + + The URL is left out on purpose: hook URLs often carry a secret. + """ + self._log( + "WEBHOOK_PAUSED", + user=creator, + org=endpoint.org, + description=f"Webhook paused: {reason}", + metadata={ + "endpoint_id": str(endpoint.id), + "creator_id": ( + str(endpoint.created_by_id) if endpoint.created_by_id else None + ), + "pause_reason": reason, + }, + request=request, + ) + + def webhook_taken_over( + self, user, endpoint, previous_creator_id, changed, request=None + ): + """Log an admin becoming the creator of a webhook, which happens when + they turn it back on or change what it sends or where (`changed` + names the fields, `is_active` for a re-enable). `previous_creator_id` + is who answered for it before. See `webhooks.views`. + """ + self._log( + "WEBHOOK_REENABLED" if changed == ["is_active"] else "WEBHOOK_CHANGED", + user=user, + org=endpoint.org, + description=f"Webhook taken over: {', '.join(changed)}", + metadata={ + "endpoint_id": str(endpoint.id), + "previous_creator_id": ( + str(previous_creator_id) if previous_creator_id else None + ), + "changed": list(changed), + }, + request=request, + ) + def suspicious_activity(self, user, org, activity_type, details, request=None): """Log suspicious activity for security review.""" self._log( diff --git a/backend/common/lookups.py b/backend/common/lookups.py index e66c34da..a826ad78 100644 --- a/backend/common/lookups.py +++ b/backend/common/lookups.py @@ -40,3 +40,24 @@ def get_scoped_or_404(model, pk, org, **extra): if obj is None: raise Http404(f"No such {model._meta.verbose_name}.") return obj + + +def get_on_visible_record_or_404(model, pk, org, visible): + """A comment or attachment by pk, only if it hangs off a record in ``visible``. + + ``model`` is `Comment` or `Attachments`, one generic table per kind shared + by every module. ``visible`` is the module's read-rule queryset (for + example `visible_leads_qs(profile, user)`), so a comment on a record the + caller cannot open, or on another module's record, raises the same 404 as + an id that does not exist. Checking the author after an org-wide fetch + answered 403 there, which confirmed the id was real. + """ + from django.contrib.contenttypes.models import ContentType + + return get_scoped_or_404( + model, + pk, + org, + content_type=ContentType.objects.get_for_model(visible.model), + object_id__in=visible.values("id"), + ) diff --git a/backend/common/middleware/get_company.py b/backend/common/middleware/get_company.py index ca7213d8..0c170e6b 100644 --- a/backend/common/middleware/get_company.py +++ b/backend/common/middleware/get_company.py @@ -232,7 +232,7 @@ class GetProfileAndOrg: # Validate user membership in the org try: - profile = Profile.objects.select_related("org").get( + profile = Profile.objects.select_related("org", "user").get( user_id=user_id, org_id=org_id, is_active=True ) request.profile = profile diff --git a/backend/common/migrations/0044_saved_view.py b/backend/common/migrations/0044_saved_view.py new file mode 100644 index 00000000..fa033479 --- /dev/null +++ b/backend/common/migrations/0044_saved_view.py @@ -0,0 +1,139 @@ +# Generated by Django 6.1.1 on 2026-09-26 13:17 + +import uuid + +import django.db.models.deletion +import django.db.models.functions.text +from django.conf import settings +from django.db import connection, migrations, models + +from common.rls import get_disable_policy_sql, get_enable_policy_sql + +# Registered in common/rls/__init__.py:ORG_SCOPED_TABLES. +TABLE = "saved_view" + + +def enable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_enable_policy_sql(TABLE)) + + +def disable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_disable_policy_sql(TABLE)) + + +class Migration(migrations.Migration): + # RLS policy creation cannot run inside an atomic block. + atomic = False + + dependencies = [ + ("common", "0043_org_help_center"), + ] + + operations = [ + migrations.CreateModel( + name="SavedView", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "module", + models.CharField( + choices=[ + ("leads", "Leads"), + ("contacts", "Contacts"), + ("accounts", "Accounts"), + ("opportunities", "Deals"), + ("cases", "Tickets"), + ("invoices", "Invoices"), + ], + max_length=20, + ), + ), + ("name", models.CharField(max_length=100)), + ("filters", models.JSONField(blank=True, default=dict)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + help_text="Organization this record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="common.org", + ), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="saved_views", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "db_table": "saved_view", + "ordering": ("name",), + "indexes": [ + models.Index( + fields=["org", "-created_at"], + name="saved_view_org_id_77a76d_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + models.F("profile"), + models.F("module"), + django.db.models.functions.text.Lower("name"), + name="saved_view_name_ci_unique", + ) + ], + }, + ), + migrations.RunPython(enable_rls, reverse_code=disable_rls), + ] diff --git a/backend/common/migrations/0045_securityauditlog_webhook_events.py b/backend/common/migrations/0045_securityauditlog_webhook_events.py new file mode 100644 index 00000000..72bdc3a6 --- /dev/null +++ b/backend/common/migrations/0045_securityauditlog_webhook_events.py @@ -0,0 +1,38 @@ +# Generated by Django 6.1.1 on 2026-09-26 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("common", "0044_saved_view"), + ] + + operations = [ + migrations.AlterField( + model_name="securityauditlog", + name="event_type", + field=models.CharField( + choices=[ + ("LOGIN_SUCCESS", "Login Success"), + ("LOGIN_FAILURE", "Login Failure"), + ("LOGOUT", "Logout"), + ("ORG_SWITCH", "Organization Switch"), + ("TOKEN_REFRESH", "Token Refresh"), + ("TOKEN_REVOKED", "Token Revoked"), + ("PERMISSION_DENIED", "Permission Denied"), + ("CROSS_ORG_ATTEMPT", "Cross-Org Access Attempt"), + ("API_KEY_USED", "API Key Used"), + ("API_KEY_INVALID", "Invalid API Key"), + ("MEMBERSHIP_REVOKED", "Membership Revoked"), + ("SUSPICIOUS_ACTIVITY", "Suspicious Activity"), + ("SAMPLE_DATA_CLEARED", "Vertical Pack Sample Data Cleared"), + ("WEBHOOK_PAUSED", "Webhook Paused"), + ("WEBHOOK_REENABLED", "Webhook Re-enabled"), + ("WEBHOOK_CHANGED", "Webhook Destination Changed"), + ], + db_index=True, + max_length=50, + ), + ), + ] diff --git a/backend/common/models.py b/backend/common/models.py index 6d7c6d77..0fc13b53 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -355,12 +355,15 @@ class Profile(BaseModel): @property def is_admin(self): - """Alias kept for the API response and for existing callers. + """`common.permissions.is_org_admin` for this profile. - Reads `role`, not the column, so it answers the same as - `is_org_admin` even on an unsaved instance. + Delegates rather than restating the rule, so it counts a superuser's + profile as an admin too, and reads `role` rather than the column, so it + answers correctly on an unsaved instance. """ - return self.role == "ADMIN" + from common.permissions import is_org_admin + + return is_org_admin(self) @property def user_details(self): @@ -1208,3 +1211,47 @@ class PackApplication(BaseOrgModel): def __str__(self): return f"{self.pack_id} v{self.pack_version} → {self.org.name}" + + +class SavedView(BaseOrgModel): + """A profile's named filters for one list, to put back with one tap (G29). + + Private to ``profile``: every read and write is filtered on it as well as on + the org, and another profile's id answers 404. ``filters`` holds the list's + own query parameters as ``{param: [value, ...]}``; which parameters a list + takes, and how they are checked, lives in ``common/saved_views.py``. + """ + + MODULE_CHOICES = ( + ("leads", "Leads"), + ("contacts", "Contacts"), + ("accounts", "Accounts"), + ("opportunities", "Deals"), + ("cases", "Tickets"), + ("invoices", "Invoices"), + ) + + profile = models.ForeignKey( + "common.Profile", on_delete=models.CASCADE, related_name="saved_views" + ) + module = models.CharField(max_length=20, choices=MODULE_CHOICES) + name = models.CharField(max_length=100) + filters = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "saved_view" + ordering = ("name",) + indexes = [ + models.Index(fields=["org", "-created_at"]), + ] + constraints = [ + # Case-insensitive, so "Hot leads" and "hot leads" cannot both + # appear in one menu. Leading with the profile makes this the + # index the per-profile lists read through as well. + models.UniqueConstraint( + "profile", "module", Lower("name"), name="saved_view_name_ci_unique" + ), + ] + + def __str__(self): + return f"{self.module}: {self.name}" diff --git a/backend/common/packs/schema.py b/backend/common/packs/schema.py index e4bda02d..907d9b27 100644 --- a/backend/common/packs/schema.py +++ b/backend/common/packs/schema.py @@ -272,6 +272,18 @@ def _validate_pipeline( valid_stage_types=valid_stage_types, valid_maps_to_status=valid_maps_to_status, ) + # A board move into such a stage would set Duplicate without a merge. + # `CaseStageSerializer` refuses it for a stage made through the API; + # a pack writes stages without that serializer, so it is refused here. + if pipeline_key == "case_pipeline" and stage.get("maps_to_status") == ( + "Duplicate" + ): + from cases.workflow import DUPLICATE_BY_MERGE_ONLY + + raise PackValidationError( + f"{where}.stages[{i}]: maps_to_status 'Duplicate' is not " + f"allowed. {DUPLICATE_BY_MERGE_ONLY}" + ) def _validate_custom_field(where: str, field: dict) -> None: diff --git a/backend/common/permissions.py b/backend/common/permissions.py index b641d89a..26a90c2c 100644 --- a/backend/common/permissions.py +++ b/backend/common/permissions.py @@ -9,38 +9,48 @@ from rest_framework import permissions def is_org_admin(profile): - """Whether ``profile`` administers its org. ``role`` is the only source. + """Whether ``profile`` administers its org: ``role == "ADMIN"``, or the + profile belongs to a Django superuser. + + Superusers are org admins everywhere (owner decision, 1.11.0). Before that, + this read ``role`` only while dozens of call sites added ``or + request.user.is_superuser`` by hand and the rest did not, so a superuser + holding the USER role could change a settings page at one endpoint and was + refused at the next, and both clients showed it read-only. One rule, here. + + Membership is still required. This takes a ``Profile``, which is one + user's membership of one org, and every request's profile is resolved by + the middleware from an active ``Profile`` row for the org in the signed + token. A superuser with no profile in an org gets no profile there, so + this function is never asked about that org. ``is_superuser`` itself is + not writable through any API serializer; it is granted with + ``createsuperuser`` or the Django admin. A plain function and not only the ``IsOrgAdmin`` class below, because most callers are views that read wide and write narrow: the same endpoint is open to every member on GET and admin-only on POST, so the check has to happen inside the method rather than in ``permission_classes``. - That is why this rule had been written out nine times, as a private - ``_is_admin`` in nine modules, in three different spellings. Five of them - raised ``AttributeError`` on a ``None`` profile where two returned - ``False``. All nine agreed for a real profile, so nothing was broken; one - edit that missed eight copies is how that stops being true. + **This deliberately does not consult ``is_organization_admin``.** That + column mirrors ``role`` (``Profile.save`` derives it) and is not an input + anywhere; it used to be, and an admin could ``PATCH`` a colleague to + ``{"is_organization_admin": true, "role": "USER"}`` and grant an admin the + UI could neither show nor revoke. The API field of the same name is + computed from this function (see ``common.serializer``), not read from the + column. - **This deliberately does not consult ``is_organization_admin``.** ``ROLES`` - has exactly two members, so that column and ``role == "ADMIN"`` are two - spellings of one binary fact, and the backend used to read them - inconsistently: 82 checks looked only at ``role`` while 43 also accepted - the flag, with both spellings appearing inside the same file. The flag is - the dangerous half of that pair, because no frontend surface displays or - sets it: an admin could ``PATCH`` a colleague to - ``{"is_organization_admin": true, "role": "USER"}`` and grant admin over - accounts, contacts, team management and the org record itself, invisibly - and irrevocably from the UI. ``Profile.save`` now derives the column from - ``role`` so the two cannot disagree, and ``ProfileSerializer`` no longer - accepts it as an input. + ``is_superuser`` must be literally ``True``. A test double or a partially + loaded object answering some other truthy value is not an admin. ``None`` is not an admin. A view with no org context has no profile, and answering ``False`` gives it a clean 403 instead of a 500. """ if profile is None: return False - return getattr(profile, "role", None) == "ADMIN" + if getattr(profile, "role", None) == "ADMIN": + return True + user = getattr(profile, "user", None) + return getattr(user, "is_superuser", False) is True def can_mass_import(profile): @@ -94,7 +104,8 @@ class HasOrgContext(permissions.BasePermission): class IsOrgAdmin(permissions.BasePermission): """ - Permission class that requires user to be an organization admin. + Permission class that requires an org admin, as ``is_org_admin`` defines it + (ADMIN role, or a superuser's profile in this org). Usage: class AdminOnlyView(APIView): diff --git a/backend/common/request_meta.py b/backend/common/request_meta.py index 6d3a9883..0cb582c8 100644 --- a/backend/common/request_meta.py +++ b/backend/common/request_meta.py @@ -1,51 +1,53 @@ """Request metadata derived server-side. -Shared by the views that record it and the throttles that bucket on it, so both -agree on what "the client" means. A throttle keyed on one definition of the -client and a stored value using another is a bug that only shows up during an -incident. - -`common/audit_log.py` carries an unvalidated inline version of the same idea. -It is deliberately left alone: changing it is outside the scope of the web -forms work and would need its own tests. +Shared by every view that records a client IP (the security audit log, web form +submissions, estimate acceptance, magic-link and portal sign-in tokens) and by +the throttles that bucket on it, so all of them agree on what "the client" +means. One function: a second definition is how one of them ended up trusting a +header the caller writes. """ from django.core.exceptions import ValidationError from django.core.validators import validate_ipv46_address +from rest_framework.settings import api_settings REFERER_MAX_LENGTH = 512 +def _valid_ip(candidate): + """`candidate` if it is an IPv4 or IPv6 address, else None. Callers write + the result into a GenericIPAddressField, and Django does not run field + validators on save(), so junk would reach the `inet` column as a 500.""" + try: + validate_ipv46_address(candidate) + except ValidationError: + return None + return candidate + + def client_ip(request): - """Best-effort originating client IP, or None when nothing validates. + """The client IP as far as the proxies we run can vouch for it, or None. - A reverse proxy makes the socket peer the proxy rather than the visitor, so - every visitor would otherwise collapse into a single throttle bucket. - `X-Forwarded-For` carries the original client as its first entry. + Each proxy appends the address it received the request from to + `X-Forwarded-For`, so only the last `NUM_PROXIES` entries were written by + infrastructure; anything to their left is whatever the caller sent, and + trusting it let a caller pick their own throttle bucket and their own + recorded address. With `REST_FRAMEWORK["NUM_PROXIES"] = n` the answer is + the n-th entry from the right, the address the outermost proxy saw. Unset + or 0 means no proxy is trusted: the header is ignored and the socket peer + (`REMOTE_ADDR`) is the answer. DRF's own setting, so its `get_ident` and + this cannot disagree. - That header is submitter-controlled for anything that can reach the - endpoint directly, so the result is informational: it is stored for triage - and used to bucket the per-IP throttle, and it is never an input to an - authorization decision. The global throttle is the control a forged header - cannot evade. - - Every candidate is validated before it is returned, because the caller - writes this into a GenericIPAddressField and Django does not run field - validators on save(). + Behind a proxy with `NUM_PROXIES` unset, every visitor is the proxy, so + the per-IP throttles become one shared bucket. Production sets it. """ + remote = request.META.get("REMOTE_ADDR", "") + num_proxies = api_settings.NUM_PROXIES or 0 forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") - candidates = [part.strip() for part in forwarded.split(",") if part.strip()] - candidates.append(request.META.get("REMOTE_ADDR", "")) - - for candidate in candidates: - if not candidate: - continue - try: - validate_ipv46_address(candidate) - except ValidationError: - continue - return candidate - return None + if num_proxies <= 0 or not forwarded: + return _valid_ip(remote) + hops = [part.strip() for part in forwarded.split(",")] + return _valid_ip(hops[-min(num_proxies, len(hops))]) def referer(request): diff --git a/backend/common/rls/__init__.py b/backend/common/rls/__init__.py index d8b142be..9acb9f54 100644 --- a/backend/common/rls/__init__.py +++ b/backend/common/rls/__init__.py @@ -112,10 +112,10 @@ ORG_SCOPED_TABLES = [ # a `logger.error`, so the audit trail was silently absent in exactly the # deployments configured correctly, for exactly the hostile events. It was # invisible because the dev database user is a superuser and CI ran SQLite. - # `common/0036` drops the policies. Nothing outside a test reads the model, - # and `common/tests/test_audit_log_not_exposed.py` keeps it that way: if an - # endpoint ever needs to serve these rows, it must do the org filtering in - # the ORM, because there is no policy underneath it. + # `common/0036` drops the policies. The one endpoint that serves the rows, + # `common/views/audit_log_views.py`, does the org filtering in the ORM, + # because there is no policy underneath it, and + # `common/tests/test_audit_log_not_exposed.py` fails on any new reader. # # Notifications "notification", @@ -148,6 +148,9 @@ ORG_SCOPED_TABLES = [ # context from the org it was queued for. See webhooks/tasks.py. "webhook_endpoint", "webhook_delivery", + # Saved list views (G29). Private to one profile as well; the views filter + # on it, this keeps another org's rows out even if one forgets. + "saved_view", # Programmatic API access # NOTE: personal_access_token is intentionally NOT RLS-protected. It is an # auth-bootstrap table (looked up by token_hash before any tenant context diff --git a/backend/common/saved_views.py b/backend/common/saved_views.py new file mode 100644 index 00000000..f3f005fb --- /dev/null +++ b/backend/common/saved_views.py @@ -0,0 +1,303 @@ +"""Saved list views (G29): which lists can have one, and what a view may hold. + +A saved view is a name and a set of list query parameters, stored per profile +so a person can put a filtered list back with one tap. Only the six lists that +also export to CSV take one. + +**What a view may hold is what its list reads.** Each entry in :data:`LISTS` +pairs the function the list view (and its CSV export) builds its queryset +with, and the query parameters that function reads. A view is checked two +ways before it is stored: + +* every key is one of those parameters, or a ``cf_`` custom-field filter, + which every one of these lists takes. Anything else is refused, so a view + cannot become a place to park arbitrary data; +* the values are handed to that same function, so a malformed id, date or + number is refused with the list's own 400 now, rather than taking the list + down every time the view is applied. + +The parameter sets are written out rather than discovered, so a change to what +a view can hold shows up in a diff. ``common/tests/test_saved_views.py`` reads +each list function with a recording mapping and fails when the two disagree, +in either direction. +""" + +import re + +from django.db import IntegrityError, transaction +from django.http import QueryDict +from rest_framework import serializers + +from accounts.views import account_list_queryset +from cases.views import case_list_queryset +from common.models import SavedView +from contacts.views import contact_list_queryset +from invoices.api_views import filter_invoices +from invoices.permissions import visible_invoices_qs +from leads.views.lead_views import lead_list_queryset +from opportunity.views.opportunity_views import deal_list_queryset + +MAX_VIEWS_PER_LIST = 25 +MAX_PARAMS = 30 +MAX_VALUES_PER_PARAM = 50 +MAX_VALUE_LENGTH = 200 + +CUSTOM_FIELD_PARAM = re.compile(r"^cf_[A-Za-z0-9_]{1,100}$") + +_DATES = ("created_at__gte", "created_at__lte") + +LISTS = { + "leads": ( + lambda request, params: lead_list_queryset( + request.profile, request.user, params + ), + frozenset( + { + "name", + "salutation", + "source", + "assigned_to", + "status", + "tags", + "city", + "email", + "rating", + "search", + *_DATES, + "close_date__gte", + "close_date__lte", + "next_follow_up", + "open", + } + ), + ), + "contacts": ( + lambda request, params: contact_list_queryset(request.profile, params)[1], + frozenset( + { + "name", + "city", + "phone", + "email", + "assigned_to", + "tags", + "search", + *_DATES, + "is_active", + } + ), + ), + "accounts": ( + lambda request, params: account_list_queryset( + request.profile, request.user, params + ), + frozenset( + { + "name", + "city", + "industry", + "tags", + "assigned_to", + "search", + *_DATES, + "is_active", + } + ), + ), + "opportunities": ( + lambda request, params: deal_list_queryset( + request.profile, request.user, params + ), + frozenset( + { + "name", + "account", + "pipeline", + "stage", + "lead_source", + "tags", + "assigned_to", + "search", + *_DATES, + "closed_on__gte", + "closed_on__lte", + "amount__gte", + "amount__lte", + "open", + "rotten", + } + ), + ), + "cases": ( + lambda request, params: case_list_queryset(request.profile, params), + frozenset( + { + "include_deleted", + "show_merged", + "name", + "status", + "priority", + "account", + "case_type", + "assigned_to", + "tags", + "search", + *_DATES, + "sla_breached", + "ordering", + } + ), + ), + "invoices": ( + lambda request, params: filter_invoices( + visible_invoices_qs(request.profile, request.user), params + ), + frozenset( + { + "search", + "status", + "account", + "contact", + "opportunity", + "assigned_to", + "created_by", + "issue_date_gte", + "issue_date_lte", + "due_date_gte", + "due_date_lte", + "sort", + } + ), + ), +} + + +def _clean_filters(module, value): + """``value`` as ``{param: [text, ...]}``, or raise with what is wrong. + + A value may arrive as one string or a list of them; blanks are dropped, and + a parameter left with nothing is dropped with them. + """ + if not isinstance(value, dict): + raise serializers.ValidationError("Send the filters as an object.") + if len(value) > MAX_PARAMS: + raise serializers.ValidationError( + f"A view can hold at most {MAX_PARAMS} filters." + ) + accepted = LISTS[module][1] + unknown = sorted( + str(key)[:50] + for key in value + if not isinstance(key, str) + or (key not in accepted and not CUSTOM_FIELD_PARAM.match(key)) + ) + if unknown: + raise serializers.ValidationError( + f"This list does not filter by: {', '.join(unknown[:5])}." + ) + cleaned = {} + for key, raw in value.items(): + values = raw if isinstance(raw, list) else [raw] + if not all(isinstance(v, str) for v in values): + raise serializers.ValidationError(f"{key}: every value must be text.") + values = [v.strip() for v in values if v.strip()] + if len(values) > MAX_VALUES_PER_PARAM: + raise serializers.ValidationError( + f"{key}: at most {MAX_VALUES_PER_PARAM} values." + ) + if any(len(v) > MAX_VALUE_LENGTH for v in values): + raise serializers.ValidationError( + f"{key}: a value can be at most {MAX_VALUE_LENGTH} characters." + ) + if values: + cleaned[key] = values + return cleaned + + +def _check_values(request, module, filters): + """Run the list's own parameter parsing over ``filters``. + + The queryset is built, never evaluated. A malformed id, date or number + raises the list's own ``ValidationError`` while it is being built. + """ + params = QueryDict(mutable=True) + for key, values in filters.items(): + params.setlist(key, values) + try: + LISTS[module][0](request, params) + except serializers.ValidationError as exc: + # The list names the parameter it refused; kept as one flat sentence + # per parameter, which is what both clients show. + detail = exc.detail + if isinstance(detail, dict): + detail = [ + f"{key}: {value[0] if isinstance(value, list) else value}" + for key, value in detail.items() + ] + raise serializers.ValidationError({"filters": detail}) from None + + +class SavedViewSerializer(serializers.ModelSerializer): + """One saved view, as its owner reads and writes it. + + ``org`` and ``profile`` are set by the view from the request, and a body + naming either is refused rather than ignored. ``module`` is fixed once the + view exists: a view's filters only mean something to the list they came + from. + """ + + class Meta: + model = SavedView + fields = ("id", "module", "name", "filters", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") + + def validate_name(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Give the view a name.") + return value + + def validate(self, attrs): + for field in ("org", "profile"): + if field in self.initial_data: + raise serializers.ValidationError({field: "This is set by the server."}) + instance = self.instance + if instance is not None and attrs.get("module", instance.module) != ( + instance.module + ): + raise serializers.ValidationError( + {"module": "A saved view cannot move to another list."} + ) + module = attrs.get("module") or instance.module + if "filters" in attrs: + try: + attrs["filters"] = _clean_filters(module, attrs["filters"]) + except serializers.ValidationError as exc: + raise serializers.ValidationError({"filters": exc.detail}) from None + _check_values(self.context["request"], module, attrs["filters"]) + + profile = self.context["request"].profile + mine = SavedView.objects.filter(org=profile.org, profile=profile, module=module) + if instance is not None: + mine = mine.exclude(pk=instance.pk) + elif mine.count() >= MAX_VIEWS_PER_LIST: + raise serializers.ValidationError( + f"You can save at most {MAX_VIEWS_PER_LIST} views for one list." + ) + name = attrs.get("name") + if name is not None and mine.filter(name__iexact=name).exists(): + raise serializers.ValidationError( + {"name": "You already have a view with this name for this list."} + ) + return attrs + + def save(self, **kwargs): + # Two saves racing past the check above meet the unique constraint; + # answered as the same 400, not a 500. + try: + with transaction.atomic(): + return super().save(**kwargs) + except IntegrityError: + raise serializers.ValidationError( + {"name": "You already have a view with this name for this list."} + ) from None diff --git a/backend/common/scopes.py b/backend/common/scopes.py index 0d15df88..05671ec0 100644 --- a/backend/common/scopes.py +++ b/backend/common/scopes.py @@ -83,6 +83,8 @@ API_RESOURCES = frozenset( "portal", "profile", "public", + # A profile's own saved list filters (G29). + "saved-views", "schema", "search", "tags", @@ -126,6 +128,10 @@ CREDENTIAL_PATHS = ( # is revoked. Same persistence argument as minting a token, so webhooks are # managed from a signed-in session only. "/api/webhooks/", + # The security audit log records who signed in from where and what was + # refused. Read from a signed-in admin session only, like webhooks: a + # leaked token should not also reveal what the org has noticed about it. + "/api/org/audit-log/", ) # What the organization API key is worth once this module is enforcing. It reads, diff --git a/backend/common/serializer.py b/backend/common/serializer.py index 64247039..114c5707 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -49,7 +49,13 @@ class OrgAwareRefreshToken(RefreshToken): - org_id: Organization UUID - org_name: Organization name (for display) - role: User's role in the org (ADMIN/USER) + - is_organization_admin: ``is_org_admin(profile)``, the one admin fact + both clients gate admin UI on (the role, or a superuser's profile) - org_settings: Currency and locale settings + + The clients read these claims for display and to hide controls only. The + API never trusts them: every request re-resolves the profile from the + database, and ``OrgAwareTokenRefreshView`` re-derives them on each refresh. """ @classmethod @@ -94,6 +100,7 @@ class OrgAwareRefreshToken(RefreshToken): # Add role if profile provided (avoids /api/auth/profile call) if profile: token["role"] = profile.role + token["is_organization_admin"] = is_org_admin(profile) return token @@ -584,6 +591,13 @@ class ShowOrganizationListSerializer(serializers.ModelSerializer): """ org = OrganizationSerializer() + # Computed, not the column: the column mirrors `role` only, and this is + # the fact the clients gate admin UI on. Read-only by construction. + is_organization_admin = serializers.SerializerMethodField() + + @extend_schema_field(bool) + def get_is_organization_admin(self, obj): + return is_org_admin(obj) class Meta: model = Profile @@ -626,6 +640,10 @@ class CreateUserSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): self.org = kwargs.pop("org", None) + # Whether the caller is editing their own account. Defaults to False so + # a caller that forgets to pass it fails closed: the account fields + # below are then refused rather than written. + self._editing_self = kwargs.pop("editing_self", False) super().__init__(*args, **kwargs) self.fields["email"].required = True # Membership is per-org: one account can belong to several orgs, so the @@ -641,25 +659,39 @@ class CreateUserSerializer(serializers.ModelSerializer): def validate_email(self, email): if self.instance: + # The login email is read-only through this API, for an admin + # editing someone else and for the user themselves (owner decision, + # 1.11.0). `User.email` is global: an org admin who could rename a + # member's email could then sign in as that person in every org + # they belong to, and as a platform superadmin if they are one. + # Echoing the stored address back (any case) is not a change, so + # forms that resend the whole record keep working; the stored + # spelling is kept rather than the echoed one. if self.instance.email.lower() == email.lower(): - return email - # Renaming an account: the address must be free globally, since - # there is no way to fold two existing accounts together. - if ( - User.objects.filter(email__iexact=email) - .exclude(pk=self.instance.pk) - .exists() - ): - raise serializers.ValidationError("Email already exists") - if Profile.objects.filter(user__email__iexact=email, org=self.org).exists(): - raise serializers.ValidationError("Email already exists") - return email + return self.instance.email + raise serializers.ValidationError("Email cannot be changed here.") # Creating a membership: an account owned elsewhere is reused by the # view, so only same-org duplicates are rejected here. if not Profile.objects.filter(user__email__iexact=email, org=self.org).exists(): return email.lower() raise serializers.ValidationError("Given Email id already exists") + def validate_profile_pic(self, value): + """Only the account's owner may change it once the account exists. + + `User` is one row shared by every org the person belongs to, so an org + admin editing a member here would be rewriting that person's account + for their other orgs too (owner decision, 1.11.0). An admin resending + the stored value, as a whole-record form does, is not a change. + """ + if self.instance is None or self._editing_self: + return value + if (value or "") == (self.instance.profile_pic or ""): + return self.instance.profile_pic + raise serializers.ValidationError( + "Only the account's owner can change their picture." + ) + class CreateProfileSerializer(serializers.ModelSerializer): class Meta: @@ -1024,15 +1056,33 @@ class APISettingsListSerializer(serializers.ModelSerializer): return data -class APISettingsSwaggerSerializer(serializers.ModelSerializer): - class Meta: - model = APISettings - fields = [ - "title", - "website", - "lead_assigned_to", - "tags", - ] +class APISettingsSwaggerSerializer(serializers.Serializer): + """The request body `common/views/settings_views.py` actually reads. + + A ModelSerializer here documented both relations as lists of ids, while the + views have always taken `tags` as names. Written out by hand so the schema + says what the endpoint does. On PATCH every field is optional, and an + absent `tags` or `lead_assigned_to` is left unchanged. + """ + + title = serializers.CharField() + website = serializers.URLField(max_length=255) + tags = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text=( + "Tag NAMES, not ids. Each is matched by slug within the caller's " + "org and created there if the org has none by that name." + ), + ) + lead_assigned_to = serializers.ListField( + child=serializers.UUIDField(), + required=False, + help_text=( + "Profile ids. Each must be an active member of the caller's org, " + "or the request is refused with a 400." + ), + ) class DocumentCreateSwaggerSerializer(serializers.ModelSerializer): @@ -1128,13 +1178,15 @@ class UserDetailSerializer(serializers.ModelSerializer): @extend_schema_field(list) def get_organizations(self, obj): """Get all organizations the user belongs to""" - profiles = Profile.objects.filter(user=obj, is_active=True) + profiles = Profile.objects.filter(user=obj, is_active=True).select_related( + "org", "user" + ) return [ { "id": str(profile.org.id), "name": profile.org.name, "role": profile.role, - "is_organization_admin": profile.is_organization_admin, + "is_organization_admin": is_org_admin(profile), "has_sales_access": profile.has_sales_access, "has_marketing_access": profile.has_marketing_access, } @@ -1162,11 +1214,17 @@ class ProfileDetailSerializer(serializers.ModelSerializer): org = OrganizationSerializer(read_only=True) teams = serializers.SerializerMethodField() last_login = serializers.DateTimeField(source="user.last_login", read_only=True) + # `is_org_admin`, not the column; see `ShowOrganizationListSerializer`. + is_organization_admin = serializers.SerializerMethodField() @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_teams(self, obj): return list(obj.user_teams.values_list("name", flat=True)) + @extend_schema_field(bool) + def get_is_organization_admin(self, obj): + return is_org_admin(obj) + class Meta: model = Profile fields = [ diff --git a/backend/common/tests/test_api_settings_tags_assignees.py b/backend/common/tests/test_api_settings_tags_assignees.py index 769a0b7a..c003efda 100644 --- a/backend/common/tests/test_api_settings_tags_assignees.py +++ b/backend/common/tests/test_api_settings_tags_assignees.py @@ -7,8 +7,10 @@ with no org, which cannot be saved, so that branch always answered 500. ``lead_assigned_to`` was ``.add(*ids)`` straight from the body: another org's profile id attached, and ``webforms/legacy.py`` then hands leads to it. -The request contract is unchanged: ``tags`` is a list of tag names and -``lead_assigned_to`` a list of profile ids. +The request contract: ``tags`` is a list of tag names and ``lead_assigned_to`` +a list of profile ids, on POST, PUT and PATCH alike (Swagger used to say ids +for both, and PATCH used to ignore both). An assignee that is not an active +member of the caller's org is a 400, not silently dropped. """ import pytest @@ -123,24 +125,43 @@ class TestLeadAssignedTo: assert response.status_code in (200, 201), response.data assert list(setting.lead_assigned_to.all()) == [admin_profile] - def test_another_orgs_profile_is_not_attached( - self, write, admin_profile, profile_b - ): + def test_another_orgs_profile_is_refused(self, write, admin_profile, profile_b): response, setting = write( - _body(lead_assigned_to=[str(admin_profile.id), str(profile_b.id)]) + _body( + title="Foreign", + lead_assigned_to=[str(admin_profile.id), str(profile_b.id)], + ) ) - assert response.status_code in (200, 201), response.data - assert list(setting.lead_assigned_to.all()) == [admin_profile] + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "lead_assigned_to" in response.data["errors"] + if write.method == "post": + assert setting is None + else: + assert setting.title == "Existing" + assert setting.lead_assigned_to.count() == 0 - def test_inactive_profile_is_not_attached(self, write, user_profile): + def test_inactive_profile_is_refused(self, write, user_profile): user_profile.is_active = False user_profile.save() - response, setting = write(_body(lead_assigned_to=[str(user_profile.id)])) + response, setting = write( + _body(title="Inactive", lead_assigned_to=[str(user_profile.id)]) + ) - assert response.status_code in (200, 201), response.data - assert setting.lead_assigned_to.count() == 0 + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "lead_assigned_to" in response.data["errors"] + + def test_unknown_id_is_refused(self, write): + response, _setting_obj = write( + _body( + title="Unknown", + lead_assigned_to=["00000000-0000-0000-0000-000000000000"], + ) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "lead_assigned_to" in response.data["errors"] @pytest.mark.parametrize( "assignees", [["not-a-uuid"], "[not json", {"id": 1}, 5, [5]] @@ -170,3 +191,98 @@ def test_put_on_another_orgs_setting_is_404(admin_client, org_b, user_b): assert response.status_code == status.HTTP_404_NOT_FOUND setting.refresh_from_db() assert setting.title == "Existing" + + +@pytest.mark.django_db +class TestPatch: + """PATCH used to save `title`/`website` and drop both relations unread.""" + + def _patch(self, client, setting, body): + response = client.patch(_detail_url(setting.pk), body, format="json") + setting.refresh_from_db() + return response + + def test_tags_by_name_replace_inside_the_callers_org( + self, admin_client, org_a, org_b, admin_user + ): + setting = _setting(org_a, admin_user) + old = Tags.objects.create(name="Old", org=org_a) + setting.tags.add(old) + foreign = Tags.objects.create(name="VIP", org=org_b) + + response = self._patch(admin_client, setting, {"tags": ["VIP"]}) + + assert response.status_code == status.HTTP_200_OK, response.data + attached = list(setting.tags.all()) + assert len(attached) == 1 + assert attached[0].org_id == org_a.id + assert attached[0].pk != foreign.pk + + def test_malformed_tags_are_400_and_change_nothing( + self, admin_client, org_a, admin_user + ): + setting = _setting(org_a, admin_user) + + response = self._patch(admin_client, setting, {"title": "New", "tags": [5]}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "tags" in response.data["errors"] + assert setting.title == "Existing" + + def test_assignees_replace_with_an_active_org_member( + self, admin_client, org_a, admin_user, admin_profile, user_profile + ): + setting = _setting(org_a, admin_user) + setting.lead_assigned_to.add(admin_profile) + + response = self._patch( + admin_client, setting, {"lead_assigned_to": [str(user_profile.id)]} + ) + + assert response.status_code == status.HTTP_200_OK, response.data + assert list(setting.lead_assigned_to.all()) == [user_profile] + + def test_another_orgs_profile_is_refused( + self, admin_client, org_a, admin_user, admin_profile, profile_b + ): + setting = _setting(org_a, admin_user) + setting.lead_assigned_to.add(admin_profile) + + response = self._patch( + admin_client, setting, {"lead_assigned_to": [str(profile_b.id)]} + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "lead_assigned_to" in response.data["errors"] + assert list(setting.lead_assigned_to.all()) == [admin_profile] + + def test_absent_relations_are_left_alone( + self, admin_client, org_a, admin_user, admin_profile + ): + setting = _setting(org_a, admin_user) + tag = Tags.objects.create(name="Keep", org=org_a) + setting.tags.add(tag) + setting.lead_assigned_to.add(admin_profile) + + response = self._patch(admin_client, setting, {"title": "Renamed"}) + + assert response.status_code == status.HTTP_200_OK, response.data + assert setting.title == "Renamed" + assert list(setting.tags.all()) == [tag] + assert list(setting.lead_assigned_to.all()) == [admin_profile] + + def test_member_is_refused(self, user_client, org_a, admin_user): + setting = _setting(org_a, admin_user) + + response = self._patch(user_client, setting, {"tags": ["VIP"]}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert setting.tags.count() == 0 + + +def test_swagger_documents_tags_as_names_and_assignees_as_ids(): + from common.serializer import APISettingsSwaggerSerializer + + fields = APISettingsSwaggerSerializer().fields + assert fields["tags"].child.__class__.__name__ == "CharField" + assert fields["lead_assigned_to"].child.__class__.__name__ == "UUIDField" diff --git a/backend/common/tests/test_audit_log.py b/backend/common/tests/test_audit_log.py index 59059ce7..8db84397 100644 --- a/backend/common/tests/test_audit_log.py +++ b/backend/common/tests/test_audit_log.py @@ -28,14 +28,16 @@ class TestAuditLoggerMethods: self.factory = RequestFactory() def test_get_request_info_with_forwarded_for(self): - """X-Forwarded-For should be parsed for the first IP.""" + """The first X-Forwarded-For entry is the caller's to write, so it is + not recorded. With no proxy configured the socket peer is.""" request = self.factory.get( "/api/test/", HTTP_X_FORWARDED_FOR="203.0.113.50, 70.41.3.18, 150.172.238.178", HTTP_USER_AGENT="TestAgent/1.0", + REMOTE_ADDR="192.0.2.10", ) info = self.logger._get_request_info(request) - assert info["ip_address"] == "203.0.113.50" + assert info["ip_address"] == "192.0.2.10" assert info["user_agent"] == "TestAgent/1.0" assert info["request_path"] == "/api/test/" assert info["request_method"] == "GET" diff --git a/backend/common/tests/test_audit_log_api.py b/backend/common/tests/test_audit_log_api.py new file mode 100644 index 00000000..4d2e1905 --- /dev/null +++ b/backend/common/tests/test_audit_log_api.py @@ -0,0 +1,231 @@ +"""GET /api/org/audit-log/: an admin reads their own org's security audit log. + +`security_audit_log` has no RLS policy, so the view's explicit org filter is +the only thing keeping one org's rows from another. These tests plant rows in +two orgs and with no org at all, and check only the caller's come back. +""" + +from datetime import timedelta + +import pytest +from django.test import Client +from django.utils import timezone + +from common.audit_log import SecurityAuditLog +from common.models import PersonalAccessToken, Profile, User +from common.testing import _make_authenticated_client + +URL = "/api/org/audit-log/" + + +def _row(org, event_type="LOGIN_SUCCESS", user=None, days_ago=0, **extra): + row = SecurityAuditLog.objects.create( + org=org, event_type=event_type, user=user, **extra + ) + if days_ago: + SecurityAuditLog.objects.filter(pk=row.pk).update( + created_at=timezone.now() - timedelta(days=days_ago) + ) + return row + + +def _ids(response): + return [entry["id"] for entry in response.json()["results"]] + + +class TestWhoMayRead: + def test_an_admin_reads_their_own_org_only( + self, admin_client, admin_user, org_a, org_b + ): + ours = _row(org_a, user=admin_user) + _row(org_b, user=admin_user) + _row(None, event_type="LOGIN_FAILURE", success=False) + response = admin_client.get(URL) + assert response.status_code == 200 + assert _ids(response) == [str(ours.id)] + assert response.json()["count"] == 1 + + def test_another_orgs_admin_never_sees_them(self, org_b_client, org_a, org_b): + _row(org_a) + theirs = _row(org_b) + assert _ids(org_b_client.get(URL)) == [str(theirs.id)] + + def test_a_member_is_refused(self, user_client, org_a): + _row(org_a) + assert user_client.get(URL).status_code == 403 + + def test_a_superuser_holding_the_user_role_is_an_admin(self, org_a): + user = User.objects.create_user( + email="root@test.com", password="x", is_superuser=True + ) + profile = Profile.objects.create(user=user, org=org_a, role="USER") + client = _make_authenticated_client(user, org_a, profile) + assert client.get(URL).status_code == 200 + + def test_an_admins_api_token_is_refused(self, admin_profile, org_a): + _row(org_a) + raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli") + auth = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} + client = Client() + assert client.get(URL, **auth).status_code == 403 + # The same token still reaches an ordinary endpoint. + assert client.get("/api/leads/", **auth).status_code == 200 + + def test_the_org_api_key_is_refused(self, admin_profile, org_a): + _row(org_a) + client = Client() + assert client.get(URL, HTTP_TOKEN=org_a.api_key).status_code == 403 + # The key itself works: it still reads an ordinary endpoint. + assert client.get("/api/leads/", HTTP_TOKEN=org_a.api_key).status_code == 200 + + def test_anonymous_is_refused(self, unauthenticated_client): + assert unauthenticated_client.get(URL).status_code in (401, 403) + + +class TestFilters: + def test_newest_first_and_paginated(self, admin_client, org_a): + old = _row(org_a, days_ago=3) + new = _row(org_a) + body = admin_client.get(f"{URL}?limit=1").json() + assert body["count"] == 2 + assert [e["id"] for e in body["results"]] == [str(new.id)] + body = admin_client.get(f"{URL}?limit=1&offset=1").json() + assert [e["id"] for e in body["results"]] == [str(old.id)] + + def test_limit_is_capped(self, admin_client, org_a): + for _ in range(3): + _row(org_a) + assert len(admin_client.get(f"{URL}?limit=100000").json()["results"]) == 3 + + def test_by_event_type(self, admin_client, org_a): + _row(org_a, event_type="LOGIN_SUCCESS") + switch = _row(org_a, event_type="ORG_SWITCH") + assert _ids(admin_client.get(f"{URL}?event_type=ORG_SWITCH")) == [ + str(switch.id) + ] + + def test_by_actor(self, admin_client, admin_user, regular_user, org_a): + _row(org_a, user=admin_user) + theirs = _row(org_a, user=regular_user) + assert _ids(admin_client.get(f"{URL}?actor={regular_user.id}")) == [ + str(theirs.id) + ] + + def test_by_date_range(self, admin_client, org_a): + _row(org_a, days_ago=10) + middle = _row(org_a, days_ago=5) + _row(org_a) + today = timezone.localdate() + start = (today - timedelta(days=6)).isoformat() + end = (today - timedelta(days=4)).isoformat() + assert _ids(admin_client.get(f"{URL}?from={start}&to={end}")) == [ + str(middle.id) + ] + + @pytest.mark.parametrize( + "query", + [ + "event_type=NOT_A_TYPE", + "actor=not-a-uuid", + "from=yesterday", + "to=2026-02-30", + "from=2026-09-10&to=2026-09-01", + ], + ) + def test_a_malformed_filter_is_400(self, admin_client, org_a, query): + assert admin_client.get(f"{URL}?{query}").status_code == 400 + + def test_the_filter_catalogue_comes_with_the_page(self, admin_client): + types = admin_client.get(URL).json()["event_types"] + assert {"value": "WEBHOOK_PAUSED", "label": "Webhook Paused"} in types + + +class TestWhatIsShown: + def test_an_entry_carries_only_allow_listed_fields( + self, admin_client, admin_user, org_a + ): + _row( + org_a, + event_type="SUSPICIOUS_ACTIVITY", + user=admin_user, + description="Switched from Other Tenant Ltd to here", + metadata={ + "details": "Bearer bcrm_pat_secret", + "api_key_prefix": "abcd", + "email": "someone@else.com", + "action": "export", + "endpoint_id": "e1", + }, + ip_address="203.0.113.9", + request_method="GET", + request_path="/api/leads/", + success=False, + ) + entry = admin_client.get(URL).json()["results"][0] + assert set(entry) == { + "id", + "event_type", + "event_label", + "created_at", + "success", + "actor", + "ip_address", + "user_agent", + "request_method", + "request_path", + "details", + } + assert entry["details"] == {"action": "export", "endpoint_id": "e1"} + assert entry["actor"]["email"] == admin_user.email + assert entry["event_label"] == "Suspicious Activity" + assert "Other Tenant" not in str(entry) + assert "bcrm_pat_secret" not in str(entry) + + def test_a_public_path_is_cut_to_its_prefix(self, admin_client, org_a): + _row(org_a, request_path="/api/public/csat/tok_abc123/") + entry = admin_client.get(URL).json()["results"][0] + assert entry["request_path"] == "/api/public/" + + +class TestTheRecordedIp: + """The viewer shows `ip_address` as fact, so the caller cannot choose it. + + A client can send any `X-Forwarded-For`; only the entries the proxies we + run appended (`REST_FRAMEWORK["NUM_PROXIES"]`, counted from the right) are + believed, and with no proxy configured the header is ignored. + """ + + def _logged_ip(self, org, **meta): + from django.test import RequestFactory + + from common.audit_log import audit_log + + request = RequestFactory().get("/api/auth/logout/", **meta) + audit_log.logout(None, org, request) + return ( + SecurityAuditLog.objects.filter(event_type="LOGOUT") + .latest("created_at") + .ip_address + ) + + def test_a_forged_header_is_ignored_with_no_proxy_configured(self, org_a): + ip = self._logged_ip( + org_a, HTTP_X_FORWARDED_FOR="6.6.6.6", REMOTE_ADDR="198.51.100.7" + ) + assert ip == "198.51.100.7" + + def test_behind_one_proxy_only_its_entry_is_believed(self, org_a, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} + ip = self._logged_ip( + org_a, + HTTP_X_FORWARDED_FOR="6.6.6.6, 203.0.113.9", + REMOTE_ADDR="127.0.0.1", + ) + assert ip == "203.0.113.9" + + def test_a_junk_entry_records_nothing_rather_than_junk(self, org_a, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} + ip = self._logged_ip( + org_a, HTTP_X_FORWARDED_FOR="not-an-ip", REMOTE_ADDR="127.0.0.1" + ) + assert ip is None diff --git a/backend/common/tests/test_audit_log_not_exposed.py b/backend/common/tests/test_audit_log_not_exposed.py index b0cde913..1a497914 100644 --- a/backend/common/tests/test_audit_log_not_exposed.py +++ b/backend/common/tests/test_audit_log_not_exposed.py @@ -5,12 +5,12 @@ the failed logins, invalid API keys and cross-org attempts the ledger exists to record. Full reasoning is in that migration. -Removing a safety net is only safe while nothing depends on it. Today -`SecurityAuditLog` is write-only: `AuditLogger` creates rows, `common/models.py` -re-exports the name, and one pack test reads it. The moment an endpoint serves -these rows, a missing `org=` filter becomes a cross-tenant disclosure of +Removing a safety net means every reader has to filter for itself. One +endpoint serves these rows, `common/views/audit_log_views.py` (G33), and it +filters on the caller's org explicitly; `test_audit_log_api.py` pins that. +Anywhere else, a missing `org=` filter would be a cross-tenant disclosure of usernames, IP addresses and authentication failures with no policy underneath -to catch it. These tests fail when that day arrives, next to the reason. +to catch it, so a new reference fails here, next to the reason. """ import re @@ -30,6 +30,10 @@ ALLOWED = { "common/models.py", # re-exports the name for existing imports "common/migrations", # schema history "common/tests", # this guard, plus one pack test asserting a row was written + "webhooks/tests", # asserts the pause and re-enable rows are written + # Serves rows to admins. Filters on `org=request.profile.org` explicitly and + # returns an allow-list of fields; pinned by common/tests/test_audit_log_api.py. + "common/views/audit_log_views.py", } diff --git a/backend/common/tests/test_comment_authz.py b/backend/common/tests/test_comment_authz.py index cbca4d4f..b6329beb 100644 --- a/backend/common/tests/test_comment_authz.py +++ b/backend/common/tests/test_comment_authz.py @@ -121,8 +121,13 @@ class TestOrdinaryEditStillWorks: comment_on_task.refresh_from_db() assert comment_on_task.comment == "Fixed my typo" - def test_a_third_party_still_cannot_edit(self, user_client, comment_on_task): - """Author-or-admin gating is untouched by the field change.""" + def test_a_third_party_still_cannot_edit( + self, user_client, user_profile, task_a, comment_on_task + ): + """Author-or-admin gating is untouched by the field change. The third + party can read the task: one who cannot gets the 404 a missing comment + gets (`tasks/tests/test_attachment_and_comment_reach_only_visible_tasks.py`).""" + task_a.assigned_to.add(user_profile) response = user_client.put( f"/api/tasks/comment/{comment_on_task.id}/", {"comment": "Not mine to edit"}, diff --git a/backend/common/tests/test_endpoint_index_generator.py b/backend/common/tests/test_endpoint_index_generator.py new file mode 100644 index 00000000..6d14a4cf --- /dev/null +++ b/backend/common/tests/test_endpoint_index_generator.py @@ -0,0 +1,52 @@ +"""`scripts/generate_endpoint_index.py` keeps the deprecation notes. + +The index is regenerated whenever routes change, so a note typed into the +generated file is lost on the next run. The deprecation is read from the +schema instead, and the script's own mapping only says what to use in its +place. This renders rows without writing the file. +""" + +import importlib.util +from pathlib import Path + +import pytest +from django.conf import settings +from drf_spectacular.generators import SchemaGenerator + +SCRIPT = Path(settings.BASE_DIR) / "scripts" / "generate_endpoint_index.py" + + +@pytest.fixture(scope="module") +def generator(): + spec = importlib.util.spec_from_file_location("generate_endpoint_index", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def rows(generator): + return generator.render(SchemaGenerator().get_schema(request=None, public=True)) + + +def test_the_lead_upload_note_survives_regeneration(rows): + assert ( + "| `/api/leads/upload/` | POST (deprecated; use `import/preview/` + " + "`import/commit/`) |" in rows + ) + + +def test_every_deprecated_operation_is_marked(rows): + row = next(r for r in rows if r.startswith("| `/api/leads/create-from-site/`")) + assert "POST (deprecated; use `/api/public/forms/" in row + + +def test_a_live_operation_is_not_marked(rows): + assert "| `/api/leads/export/` | GET |" in rows + assert "| `/api/saved-views/` | GET, POST |" in rows + + +def test_a_note_on_an_operation_the_code_no_longer_deprecates_is_refused(generator): + schema = {"paths": {"/api/leads/upload/": {"post": {"deprecated": False}}}} + with pytest.raises(SystemExit, match="POST /api/leads/upload/"): + generator.render(schema) diff --git a/backend/common/tests/test_file_downloads.py b/backend/common/tests/test_file_downloads.py index a8b85ae8..1459ceec 100644 --- a/backend/common/tests/test_file_downloads.py +++ b/backend/common/tests/test_file_downloads.py @@ -11,6 +11,8 @@ Every check asserts BOTH directions. A permission test that only ever sees one answer cannot tell a working check from a constant. """ +import uuid + import pytest from django.core.files.uploadedfile import SimpleUploadedFile @@ -18,7 +20,10 @@ from accounts.models import Account from cases.models import Case, CaseWatcher from common.models import Attachments, Document, Teams from common.utils import create_attachment +from contacts.models import Contact +from invoices.models import Invoice from leads.models import Lead +from opportunity.models import Opportunity from tasks.models import Task FILE_BYTES = b"the quick brown fox" @@ -149,8 +154,9 @@ class TestAttachmentDownload: lead = Lead.objects.create(title="Not mine", org=org_a) Lead.objects.filter(pk=lead.pk).update(created_by=admin_user) attachment = _attach(lead, admin_profile) - assert user_client.get(f"/api/leads/{lead.pk}/").status_code == 403 - assert user_client.get(_attachment_url(attachment.pk)).status_code == 403 + # The lead answers as if missing, and so does its file. + assert user_client.get(f"/api/leads/{lead.pk}/").status_code == 404 + assert user_client.get(_attachment_url(attachment.pk)).status_code == 404 def test_assignment_to_the_lead_is_enough( self, user_client, admin_user, admin_profile, user_profile, org_a @@ -168,7 +174,7 @@ class TestAttachmentDownload: case = Case.objects.create(name="Watched", org=org_a, status="New") Case.objects.filter(pk=case.pk).update(created_by=admin_user) attachment = _attach(case, admin_profile) - assert user_client.get(_attachment_url(attachment.pk)).status_code == 403 + assert user_client.get(_attachment_url(attachment.pk)).status_code == 404 # `watchers` goes through CaseWatcher, which carries its own org FK. CaseWatcher.objects.create(case=case, profile=user_profile, org=org_a) assert user_client.get(_attachment_url(attachment.pk)).status_code == 200 @@ -181,7 +187,7 @@ class TestAttachmentDownload: ) Task.objects.filter(pk=task.pk).update(created_by=admin_user) attachment = _attach(task, admin_profile) - assert user_client.get(_attachment_url(attachment.pk)).status_code == 403 + assert user_client.get(_attachment_url(attachment.pk)).status_code == 404 task.assigned_to.add(user_profile) assert user_client.get(_attachment_url(attachment.pk)).status_code == 200 @@ -222,7 +228,7 @@ class TestAttachmentDownload: Lead.objects.filter(pk=lead.pk).update(created_by=admin_user) attachment = _attach(lead, admin_profile) Lead.objects.filter(pk=lead.pk).delete() - assert admin_client.get(_attachment_url(attachment.pk)).status_code == 403 + assert admin_client.get(_attachment_url(attachment.pk)).status_code == 404 def test_an_unmapped_content_type_is_refused( self, admin_client, admin_profile, org_a @@ -235,7 +241,7 @@ class TestAttachmentDownload: team = Teams.objects.create(name="Support", org=org_a) attachment = _attach(team, admin_profile) assert attachment.content_type.model == "teams" - assert admin_client.get(_attachment_url(attachment.pk)).status_code == 403 + assert admin_client.get(_attachment_url(attachment.pk)).status_code == 404 def test_the_download_is_named_after_the_stored_file_name( self, admin_client, admin_user, admin_profile, org_a @@ -254,3 +260,61 @@ class TestAttachmentDownload: attachment = _attach(lead, admin_profile) Attachments.objects.filter(pk=attachment.pk).update(attachment="") assert admin_client.get(_attachment_url(attachment.pk)).status_code == 404 + + +def _record(kind, org): + """One record of each attachable kind, owned by nobody in particular.""" + if kind == "lead": + return Lead.objects.create(title="Hidden", org=org) + if kind == "opportunity": + return Opportunity.objects.create(name="Hidden", stage="QUALIFICATION", org=org) + if kind == "contact": + return Contact.objects.create(first_name="Hidden", last_name="Person", org=org) + if kind == "account": + return Account.objects.create(name="Hidden", org=org) + if kind == "case": + return Case.objects.create(name="Hidden", org=org, status="New") + if kind == "task": + return Task.objects.create( + title="Hidden", org=org, status="New", priority="Low" + ) + account = Account.objects.create(name="Billed", org=org) + return Invoice.objects.create(org=org, account=account, invoice_title="Hidden") + + +KINDS = ["lead", "opportunity", "contact", "account", "case", "task", "invoice"] + + +@pytest.mark.django_db +class TestHiddenFileAnswersLikeAMissingOne: + """A file on a record the caller cannot open is indistinguishable from an id + that does not exist: same status, same body, on every module's records.""" + + @pytest.mark.parametrize("kind", KINDS) + def test_whole_response_matches_a_missing_id( + self, kind, user_client, admin_client, admin_user, admin_profile, org_a + ): + record = _record(kind, org_a) + type(record).objects.filter(pk=record.pk).update(created_by=admin_user) + attachment = _attach(record, admin_profile) + + hidden = user_client.get(_attachment_url(attachment.pk)) + missing = user_client.get(_attachment_url(uuid.uuid4())) + assert hidden.status_code == missing.status_code == 404 + assert hidden.content == missing.content + assert hidden["Content-Type"] == missing["Content-Type"] + + # The check refuses the member, not everyone: the admin gets the bytes. + allowed = admin_client.get(_attachment_url(attachment.pk)) + assert allowed.status_code == 200 + assert _body(allowed) == FILE_BYTES + + def test_another_orgs_file_matches_a_missing_id( + self, org_b_client, admin_user, admin_profile, org_a + ): + record = _record("case", org_a) + attachment = _attach(record, admin_profile) + other = org_b_client.get(_attachment_url(attachment.pk)) + missing = org_b_client.get(_attachment_url(uuid.uuid4())) + assert other.status_code == missing.status_code == 404 + assert other.content == missing.content diff --git a/backend/common/tests/test_list_read_rule_parity.py b/backend/common/tests/test_list_read_rule_parity.py index a86b698c..0d113f3e 100644 --- a/backend/common/tests/test_list_read_rule_parity.py +++ b/backend/common/tests/test_list_read_rule_parity.py @@ -17,9 +17,9 @@ search or dashboard hid, or be shown one that answered 403: the ticket board left watchers out, and the task board repeated a card once per assignee on a task its caller created. -Rules differ on purpose. Accounts, contacts, deals, leads and invoices admit a -Django superuser; tickets and tasks do not, because their detail views do not. -Each test here pins one path against the detail answer for the same caller. +Since 1.11.0 a Django superuser is an org admin everywhere (``is_org_admin`` +admits them), so tickets and tasks admit one too. Each test here pins one path +against the detail answer for the same caller. """ import pytest @@ -138,7 +138,7 @@ class TestAccountsFollowTheDetailRule: @pytest.mark.parametrize("path", ACCOUNT_PATHS) def test_plain_member_is_not_shown_it(self, path, user_client, others_account): response = user_client.get(f"/api/accounts/{others_account.pk}/") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert str(others_account.id) not in _account_ids(path, user_client) @pytest.mark.parametrize("path", ACCOUNT_PATHS) @@ -214,11 +214,18 @@ class TestDashboardAccountCount: class TestDashboardTasks: - """Tasks have no superuser clause, so the dashboard must not invent one.""" + """The dashboard counts exactly the tasks the detail view opens.""" - def test_superuser_does_not_get_a_task_they_cannot_open( + def test_superuser_is_an_admin_so_list_and_detail_both_admit_the_task( self, superuser, user_client, others_task ): + response = user_client.get(f"/api/tasks/{others_task.pk}/") + assert response.status_code == status.HTTP_200_OK + data = _home(user_client) + assert str(others_task.id) in {str(t["id"]) for t in data["tasks"]} + assert data["urgent_counts"]["tasks_due_today"] == 1 + + def test_plain_member_gets_neither(self, user_client, others_task): response = user_client.get(f"/api/tasks/{others_task.pk}/") assert response.status_code == status.HTTP_403_FORBIDDEN data = _home(user_client) @@ -265,16 +272,14 @@ class TestTodayFollowsTheDetailRule: def test_plain_member_does_not_see_it(self, user_client, others_case): assert f"case-{others_case.id}" not in _queue_ids(user_client) - def test_superuser_does_not_get_rows_they_cannot_open( + def test_superuser_is_an_admin_so_opens_the_rows_it_is_shown( self, superuser, user_client, others_case, others_task ): - assert ( - user_client.get(f"/api/cases/{others_case.pk}/").status_code - == status.HTTP_403_FORBIDDEN - ) ids = _queue_ids(user_client) - assert f"case-{others_case.id}" not in ids - assert f"task-{others_task.id}" not in ids + assert f"case-{others_case.id}" in ids + assert f"task-{others_task.id}" in ids + for url in (f"/api/cases/{others_case.pk}/", f"/api/tasks/{others_task.pk}/"): + assert user_client.get(url).status_code == status.HTTP_200_OK def test_another_orgs_rows_never_appear( self, user_profile, user_client, others_case, foreign_case, foreign_task @@ -311,10 +316,10 @@ class TestCaseBoard: def test_plain_member_does_not(self, user_client, others_case): assert str(others_case.id) not in _case_board(user_client) - def test_superuser_does_not_see_a_ticket_they_cannot_open( + def test_superuser_is_an_admin_and_sees_it( self, superuser, user_client, others_case ): - assert str(others_case.id) not in _case_board(user_client) + assert str(others_case.id) in _case_board(user_client) def test_another_orgs_ticket_never_appears( self, admin_client, others_case, foreign_case @@ -341,10 +346,10 @@ class TestTaskBoard: def test_plain_member_does_not(self, user_client, others_task): assert str(others_task.id) not in _task_board(user_client) - def test_superuser_does_not_see_a_task_they_cannot_open( + def test_superuser_is_an_admin_and_sees_it( self, superuser, user_client, others_task ): - assert str(others_task.id) not in _task_board(user_client) + assert str(others_task.id) in _task_board(user_client) def test_another_orgs_task_never_appears( self, admin_client, others_task, foreign_task diff --git a/backend/common/tests/test_login_email_read_only.py b/backend/common/tests/test_login_email_read_only.py new file mode 100644 index 00000000..9731a827 --- /dev/null +++ b/backend/common/tests/test_login_email_read_only.py @@ -0,0 +1,205 @@ +"""The login email cannot be changed through the user API (owner decision, 1.11.0). + +``User.email`` is the account's global sign-in identity. ``PUT`` and ``PATCH +/api/user//`` let an org admin rename another member's email, answered 200: +the admin could then take a magic link to the new address and sign in as that +person in every org they belong to, and as platform superadmin if they were +one. Now a changed email is a 400 for everyone, self included. Echoing the +stored address (any case) is accepted so forms that resend the record work. +""" + +import pytest +from rest_framework import status + + +def _url(user): + return f"/api/user/{user.id}/" + + +VERBS = ["put", "patch"] + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_admin_cannot_change_another_members_email( + verb, admin_client, regular_user, user_profile +): + response = getattr(admin_client, verb)( + _url(regular_user), + {"email": "attacker@evil.test", "role": "USER", "phone": "+14155550100"}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Email cannot be changed here." in str(response.data) + regular_user.refresh_from_db() + user_profile.refresh_from_db() + assert regular_user.email == "user@test.com" + # Refused as a whole: nothing else in the body was written either. + assert user_profile.phone != "+14155550100" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_member_cannot_change_their_own_email( + verb, user_client, regular_user, user_profile +): + response = getattr(user_client, verb)( + _url(regular_user), {"email": "me-now@test.com"}, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + regular_user.refresh_from_db() + assert regular_user.email == "user@test.com" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_admin_cannot_change_their_own_email(verb, admin_client, admin_user): + response = getattr(admin_client, verb)( + _url(admin_user), {"email": "boss-now@test.com"}, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + admin_user.refresh_from_db() + assert admin_user.email == "admin@test.com" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +@pytest.mark.parametrize("echoed", ["user@test.com", "USER@Test.com"]) +def test_unchanged_email_is_accepted_and_other_fields_update( + verb, echoed, admin_client, regular_user, user_profile +): + response = getattr(admin_client, verb)( + _url(regular_user), + {"email": echoed, "role": "ADMIN", "phone": "+14155550100"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.data + regular_user.refresh_from_db() + user_profile.refresh_from_db() + # The stored spelling is kept, not the echoed one. + assert regular_user.email == "user@test.com" + assert user_profile.phone == "+14155550100" + assert user_profile.role == "ADMIN" + + +@pytest.mark.django_db +def test_patch_without_email_still_updates(admin_client, regular_user, user_profile): + response = admin_client.patch( + _url(regular_user), {"phone": "+14155550100"}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + user_profile.refresh_from_db() + regular_user.refresh_from_db() + assert user_profile.phone == "+14155550100" + assert regular_user.email == "user@test.com" + + +@pytest.mark.django_db +def test_self_profile_update_ignores_an_email(user_client, regular_user): + """`PATCH /api/profile/` names only phone and name; an email is not written.""" + user_client.patch("/api/profile/", {"email": "me-now@test.com"}, format="json") + + regular_user.refresh_from_db() + assert regular_user.email == "user@test.com" + + +@pytest.mark.django_db +def test_inviting_a_new_member_still_sets_their_email(admin_client, org_a): + from common.models import Profile + + response = admin_client.post( + "/api/users/", {"email": "New.Person@test.com", "role": "USER"}, format="json" + ) + + assert response.status_code == status.HTTP_201_CREATED, response.data + assert Profile.objects.filter(org=org_a, user__email="new.person@test.com").exists() + + +# ── the rest of the account row: profile_pic ───────────────────────────── +# +# `User` is shared by every org a person belongs to, so an admin editing +# another member may not change any field on it (coordinator decision, +# 1.11.0). `CreateUserSerializer` names two account fields, email (above) and +# profile_pic (below). Profile fields on the same request are unaffected. + +PIC = "https://cdn.test/me.png" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_admin_cannot_change_another_members_picture( + verb, admin_client, regular_user, user_profile +): + regular_user.profile_pic = PIC + regular_user.save(update_fields=["profile_pic"]) + + response = getattr(admin_client, verb)( + _url(regular_user), + { + "email": regular_user.email, + "profile_pic": "https://evil.test/x.png", + "phone": "+14155550100", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "profile_pic" in str(response.data) + regular_user.refresh_from_db() + user_profile.refresh_from_db() + assert regular_user.profile_pic == PIC + assert user_profile.phone != "+14155550100" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_admin_resending_the_stored_picture_is_accepted( + verb, admin_client, regular_user, user_profile +): + regular_user.profile_pic = PIC + regular_user.save(update_fields=["profile_pic"]) + + response = getattr(admin_client, verb)( + _url(regular_user), + {"email": regular_user.email, "profile_pic": PIC, "role": "ADMIN"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.data + regular_user.refresh_from_db() + user_profile.refresh_from_db() + assert regular_user.profile_pic == PIC + assert user_profile.role == "ADMIN" + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb", VERBS) +def test_a_member_may_change_their_own_picture(verb, user_client, regular_user): + response = getattr(user_client, verb)( + _url(regular_user), + {"email": regular_user.email, "profile_pic": PIC}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.data + regular_user.refresh_from_db() + assert regular_user.profile_pic == PIC + + +@pytest.mark.django_db +def test_the_serializer_fails_closed_when_the_caller_is_not_named(regular_user, org_a): + from common.serializer import CreateUserSerializer + + serializer = CreateUserSerializer( + data={"email": regular_user.email, "profile_pic": PIC}, + instance=regular_user, + org=org_a, + ) + + assert not serializer.is_valid() + assert "profile_pic" in serializer.errors diff --git a/backend/common/tests/test_packs_duplicate_stage.py b/backend/common/tests/test_packs_duplicate_stage.py new file mode 100644 index 00000000..79a0b18c --- /dev/null +++ b/backend/common/tests/test_packs_duplicate_stage.py @@ -0,0 +1,56 @@ +"""A vertical pack may not define a ticket stage that maps to Duplicate. + +A ticket becomes Duplicate only by being merged. `CaseStageSerializer` refuses +such a stage when one is made through the API, but a pack writes its stages +without that serializer, so the manifest is refused at validation instead. +""" + +import pytest + +from cases.workflow import DUPLICATE_BY_MERGE_ONLY +from common.packs.schema import PackValidationError, validate_manifest + + +def _case_pack(maps_to_status): + return { + "id": "demo", + "version": 1, + "name": "Demo", + "case_pipeline": { + "name": "Support", + "stages": [ + {"name": "Open"}, + {"name": "Dupes", "maps_to_status": maps_to_status}, + ], + }, + } + + +def test_a_case_stage_mapped_to_duplicate_is_refused(): + with pytest.raises(PackValidationError) as exc: + validate_manifest(_case_pack("Duplicate")) + message = str(exc.value) + assert ( + "case_pipeline.stages[1]: maps_to_status 'Duplicate' is not allowed" in message + ) + assert DUPLICATE_BY_MERGE_ONLY in message + + +@pytest.mark.parametrize("status", ["Closed", "Pending", None]) +def test_a_case_stage_mapped_to_any_other_status_is_accepted(status): + raw = _case_pack(status) + assert validate_manifest(raw) is raw + + +def test_a_task_stage_named_duplicate_is_not_this_rule(): + """Task stages have no status vocabulary; the rule is about tickets.""" + raw = { + "id": "demo", + "version": 1, + "name": "Demo", + "task_pipeline": { + "name": "Work", + "stages": [{"name": "Dupes", "maps_to_status": "Duplicate"}], + }, + } + assert validate_manifest(raw) is raw diff --git a/backend/common/tests/test_request_meta.py b/backend/common/tests/test_request_meta.py index 4e106d3b..89014a5e 100644 --- a/backend/common/tests/test_request_meta.py +++ b/backend/common/tests/test_request_meta.py @@ -1,5 +1,5 @@ """`client_ip` has to return an IP or nothing, never a string that merely -looks like one. +looks like one, and never an address the caller chose. `WebFormSubmission.submitted_ip` is a GenericIPAddressField, which maps to a Postgres `inet` column. Django does not run field validators on `save()`, so an @@ -17,24 +17,49 @@ def _request(**meta): class TestClientIp: - def test_prefers_the_first_forwarded_entry(self): + """Only the entries our own proxies appended are believed. See + `common.request_meta.client_ip`.""" + + def test_with_no_proxy_configured_the_header_is_ignored(self): request = _request( HTTP_X_FORWARDED_FOR="203.0.113.50, 70.41.3.18", REMOTE_ADDR="10.0.0.1" ) + assert client_ip(request) == "10.0.0.1" + + def test_behind_one_proxy_the_entry_it_appended_is_used(self, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} + request = _request( + HTTP_X_FORWARDED_FOR="6.6.6.6, 203.0.113.50", REMOTE_ADDR="127.0.0.1" + ) assert client_ip(request) == "203.0.113.50" - def test_falls_back_to_remote_addr(self): + def test_behind_two_proxies_the_outer_ones_entry_is_used(self, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 2} + request = _request( + HTTP_X_FORWARDED_FOR="6.6.6.6, 203.0.113.50, 10.0.0.2", + REMOTE_ADDR="127.0.0.1", + ) + assert client_ip(request) == "203.0.113.50" + + def test_a_short_header_uses_its_leftmost_entry(self, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 2} + request = _request(HTTP_X_FORWARDED_FOR="203.0.113.50", REMOTE_ADDR="10.0.0.2") + assert client_ip(request) == "203.0.113.50" + + def test_behind_a_proxy_with_no_header_the_peer_is_used(self, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} assert client_ip(_request(REMOTE_ADDR="10.0.0.1")) == "10.0.0.1" def test_accepts_ipv6(self): - request = _request(HTTP_X_FORWARDED_FOR="2001:db8::1", REMOTE_ADDR="10.0.0.1") - assert client_ip(request) == "2001:db8::1" + assert client_ip(_request(REMOTE_ADDR="2001:db8::1")) == "2001:db8::1" - def test_skips_a_junk_forwarded_entry_and_uses_the_next_candidate(self): + def test_a_junk_trusted_entry_is_none_not_a_fallback(self, settings): + """Falling back to another entry would let the caller's text through.""" + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} request = _request( - HTTP_X_FORWARDED_FOR="not-an-ip, 203.0.113.50", REMOTE_ADDR="10.0.0.1" + HTTP_X_FORWARDED_FOR="203.0.113.50, not-an-ip", REMOTE_ADDR="10.0.0.1" ) - assert client_ip(request) == "203.0.113.50" + assert client_ip(request) is None def test_returns_none_when_nothing_validates(self): request = _request(HTTP_X_FORWARDED_FOR="drop table students", REMOTE_ADDR="") @@ -45,9 +70,10 @@ class TestClientIp: request.META.pop("REMOTE_ADDR", None) assert client_ip(request) is None - def test_a_sql_injection_payload_never_reaches_the_caller(self): + def test_a_sql_injection_payload_never_reaches_the_caller(self, settings): """The return value is written straight into an `inet` column, so a non-IP getting through here is a 500 at best.""" + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} request = _request( HTTP_X_FORWARDED_FOR="1.1.1.1'; DROP TABLE lead; --", REMOTE_ADDR="" ) diff --git a/backend/common/tests/test_saved_views.py b/backend/common/tests/test_saved_views.py new file mode 100644 index 00000000..ff25f3c6 --- /dev/null +++ b/backend/common/tests/test_saved_views.py @@ -0,0 +1,354 @@ +"""Saved list views (G29): private to their owner, and only what the list reads. + +Run with: pytest common/tests/test_saved_views.py -v +""" + +import uuid +from types import SimpleNamespace + +import pytest +from django.http import QueryDict +from rest_framework import status as http + +from common.models import Profile, SavedView, User +from common.saved_views import LISTS, MAX_VIEWS_PER_LIST +from common.testing import _make_authenticated_client + +URL = "/api/saved-views/" + + +def detail(pk): + return f"{URL}{pk}/" + + +def create(client, **body): + payload = {"module": "leads", "name": "Hot", "filters": {}} + payload.update(body) + return client.post(URL, payload, format="json") + + +@pytest.fixture +def colleague(org_a): + """A second member of org A, with a client of their own.""" + user = User.objects.create_user(email="colleague@test.com", password="x") + profile = Profile.objects.create(user=user, org=org_a, role="USER", is_active=True) + return SimpleNamespace( + profile=profile, client=_make_authenticated_client(user, org_a, profile) + ) + + +# ---------------------------------------------------------------- owner CRUD + + +def test_owner_creates_lists_reads_renames_and_deletes(user_client, user_profile): + tag = str(uuid.uuid4()) + created = create( + user_client, + name=" Hot leads ", + filters={"status": ["assigned", "in process"], "tags": tag, "search": ""}, + ) + assert created.status_code == http.HTTP_201_CREATED, created.data + view_id = created.data["id"] + assert created.data["name"] == "Hot leads" + # One value becomes a list of one; a blank is dropped with its key. + assert created.data["filters"] == { + "status": ["assigned", "in process"], + "tags": [tag], + } + stored = SavedView.objects.get(pk=view_id) + assert stored.profile == user_profile + assert stored.org == user_profile.org + + listed = user_client.get(URL, {"module": "leads"}) + assert listed.status_code == 200 + assert [v["id"] for v in listed.data["saved_views"]] == [view_id] + assert listed.data["limit"] == MAX_VIEWS_PER_LIST + + assert user_client.get(detail(view_id)).data["name"] == "Hot leads" + + renamed = user_client.patch(detail(view_id), {"name": "Warm"}, format="json") + assert renamed.status_code == 200, renamed.data + assert renamed.data["name"] == "Warm" + assert renamed.data["filters"]["tags"] == [tag] + + refiltered = user_client.patch( + detail(view_id), {"filters": {"source": "call"}}, format="json" + ) + assert refiltered.status_code == 200, refiltered.data + assert refiltered.data["filters"] == {"source": ["call"]} + + assert user_client.delete(detail(view_id)).status_code == 204 + assert not SavedView.objects.filter(pk=view_id).exists() + + +def test_list_without_module_returns_every_list_and_bad_module_is_400(user_client): + create(user_client, module="leads", name="A") + create(user_client, module="cases", name="B") + everything = user_client.get(URL) + assert sorted(v["module"] for v in everything.data["saved_views"]) == [ + "cases", + "leads", + ] + assert user_client.get(URL, {"module": "tasks"}).status_code == 400 + assert create(user_client, module="tasks").status_code == 400 + + +def test_every_one_of_the_six_lists_takes_a_view(user_client): + for module in LISTS: + response = create(user_client, module=module, name=f"{module} view") + assert response.status_code == 201, (module, response.data) + + +def test_anonymous_is_refused(unauthenticated_client): + assert unauthenticated_client.get(URL).status_code in (401, 403) + assert create(unauthenticated_client).status_code in (401, 403) + assert not SavedView.objects.exists() + + +# ------------------------------------------------------------- private views + + +def test_another_members_view_is_404_on_every_verb(user_client, colleague): + theirs = create(colleague.client, name="Theirs").data["id"] + missing = str(uuid.uuid4()) + + # Hidden from the list, and every verb answers exactly as for a missing id. + assert user_client.get(URL).data["saved_views"] == [] + for verb, kwargs in ( + ("get", {}), + ("patch", {"data": {"name": "Mine now"}, "format": "json"}), + ("delete", {}), + ): + hidden = getattr(user_client, verb)(detail(theirs), **kwargs) + absent = getattr(user_client, verb)(detail(missing), **kwargs) + assert hidden.status_code == absent.status_code == 404, verb + assert hidden.data == absent.data, verb + + view = SavedView.objects.get(pk=theirs) + assert view.name == "Theirs" + # The owner still reads it: the check refuses one person, not everyone. + assert colleague.client.get(detail(theirs)).status_code == 200 + + +def test_an_admin_cannot_read_a_members_view_either(admin_client, user_client): + member_view = create(user_client, name="Private").data["id"] + assert admin_client.get(detail(member_view)).status_code == 404 + assert admin_client.get(URL).data["saved_views"] == [] + + +def test_another_orgs_view_is_invisible(user_client, org_b_client): + other = create(org_b_client, name="Org B view").data["id"] + assert user_client.get(URL).data["saved_views"] == [] + assert user_client.get(detail(other)).status_code == 404 + assert user_client.delete(detail(other)).status_code == 404 + assert SavedView.objects.filter(pk=other).exists() + + +def test_org_and_profile_in_the_body_are_refused(user_client, colleague, org_b): + response = create(user_client, profile=str(colleague.profile.id)) + assert response.status_code == 400 + assert "profile" in response.data + response = create(user_client, org=str(org_b.id)) + assert response.status_code == 400 + assert "org" in response.data + assert not SavedView.objects.exists() + + mine = create(user_client).data["id"] + moved = user_client.patch( + detail(mine), {"profile": str(colleague.profile.id)}, format="json" + ) + assert moved.status_code == 400 + assert SavedView.objects.get(pk=mine).profile.user.email == "user@test.com" + + +def test_a_view_cannot_move_to_another_list(user_client): + mine = create(user_client).data["id"] + response = user_client.patch(detail(mine), {"module": "cases"}, format="json") + assert response.status_code == 400 + assert "module" in response.data + # Naming its own list again is not a move. + same = user_client.patch(detail(mine), {"module": "leads"}, format="json") + assert same.status_code == 200 + + +# ------------------------------------------------------------ what it holds + + +@pytest.mark.parametrize( + "module, filters", + [ + ("leads", {"limit": "10"}), + ("leads", {"junk": "x"}), + ("leads", {"pipeline": str(uuid.uuid4())}), # a deal param, not a lead one + ("invoices", {"due_date__gte": "2026-01-01"}), # invoices use one underscore + ("cases", {"cf_": "x"}), + ("cases", {"cf_bad-key": "x"}), + ("leads", ["status", "assigned"]), + ("leads", "status=assigned"), + ("leads", {"status": 5}), + ("leads", {"status": ["assigned", None]}), + ("leads", {"status": {"nested": "x"}}), + ("leads", {"search": "x" * 201}), + ("leads", {"status": ["assigned"] * 51}), + ("leads", {f"cf_k{i}": "x" for i in range(31)}), + # Malformed values are refused by the list's own parsing. + ("leads", {"assigned_to": "not-a-uuid"}), + ("contacts", {"tags": ["x"]}), + ("accounts", {"created_at__gte": "2026-02-30"}), + ("opportunities", {"amount__gte": "lots"}), + ("opportunities", {"amount__lte": "NaN"}), + ("cases", {"account": "nope"}), + ("invoices", {"due_date_gte": "yesterday"}), + ], +) +def test_junk_filters_are_400(user_client, module, filters): + response = create(user_client, module=module, filters=filters) + assert response.status_code == 400, response.data + assert "filters" in response.data + assert not SavedView.objects.exists() + + +def test_junk_filters_are_400_on_update_too(user_client): + mine = create(user_client, filters={"source": "call"}).data["id"] + response = user_client.patch( + detail(mine), {"filters": {"assigned_to": "x"}}, format="json" + ) + assert response.status_code == 400 + assert SavedView.objects.get(pk=mine).filters == {"source": ["call"]} + + +def _sample(key): + """A well-formed value for list parameter ``key``.""" + if key in { + "assigned_to", + "tags", + "account", + "contact", + "opportunity", + "pipeline", + "created_by", + }: + return str(uuid.uuid4()) + if key.startswith("amount"): + return "10.5" + if "date" in key or "created_at" in key or "closed_on" in key: + return "2026-01-02" + if key == "next_follow_up": + return "2026-01-02" + return "true" + + +def test_every_param_the_lists_read_is_accepted(user_client): + for module, (_build, accepted) in LISTS.items(): + filters = {key: _sample(key) for key in accepted} + filters["cf_region"] = "EMEA" + response = create(user_client, module=module, name=module, filters=filters) + assert response.status_code == 201, (module, response.data) + + +class _Recorder(QueryDict): + """A query string that remembers every key it was asked for.""" + + def __init__(self): + super().__init__(mutable=True) + # Non-empty, so a list that returns early on no params still reads on. + self.setlist("__probe__", ["x"]) + self.read = set() + + def __getitem__(self, key): + self.read.add(key) + return super().__getitem__(key) + + def get(self, key, default=None): + self.read.add(key) + return super().get(key, default) + + def getlist(self, key, default=None): + self.read.add(key) + return super().getlist(key, default) + + +@pytest.mark.parametrize("module", sorted(LISTS)) +def test_accepted_params_match_what_the_list_reads(module, admin_user, admin_profile): + """The written-out sets drift from the list functions the day one of them + grows a filter. Asked both ways, so a param a list stops reading is caught + as well as one it starts reading.""" + build, accepted = LISTS[module] + params = _Recorder() + build(SimpleNamespace(profile=admin_profile, user=admin_user), params) + assert params.read - {"__probe__"} == accepted + + +# ------------------------------------------------------------ names and cap + + +def test_duplicate_name_is_400_case_insensitively(user_client, colleague): + assert create(user_client, name="Hot").status_code == 201 + duplicate = create(user_client, name=" hot ") + assert duplicate.status_code == 400 + assert "name" in duplicate.data + # Another list, or another person, may use the same name. + assert create(user_client, module="cases", name="Hot").status_code == 201 + assert create(colleague.client, name="Hot").status_code == 201 + + other = create(user_client, name="Cold").data["id"] + clash = user_client.patch(detail(other), {"name": "HOT"}, format="json") + assert clash.status_code == 400 + # Renaming a view to its own name, in another case, is not a clash. + assert ( + user_client.patch(detail(other), {"name": "COLD"}, format="json").status_code + == 200 + ) + + +def test_name_is_required_and_bounded(user_client): + assert create(user_client, name=" ").status_code == 400 + assert create(user_client, name="x" * 101).status_code == 400 + assert create(user_client, name="x" * 100).status_code == 201 + body = {"module": "leads", "filters": {}} + assert user_client.post(URL, body, format="json").status_code == 400 + + +def test_cap_per_list_is_enforced(user_client, colleague): + for i in range(MAX_VIEWS_PER_LIST): + assert create(user_client, name=f"View {i}").status_code == 201 + over = create(user_client, name="One too many") + assert over.status_code == 400 + assert SavedView.objects.filter(module="leads").count() == MAX_VIEWS_PER_LIST + # The cap is per person and per list. + assert create(user_client, module="cases", name="Fine").status_code == 201 + assert create(colleague.client, name="Fine").status_code == 201 + # A full list can still be renamed. + first = SavedView.objects.filter(name="View 0").first() + assert ( + user_client.patch( + detail(first.id), {"name": "Renamed"}, format="json" + ).status_code + == 200 + ) + + +def test_a_save_that_loses_the_race_is_a_400_not_a_500(user_profile, regular_user): + """Two saves can both pass the name check; the constraint catches the second.""" + from rest_framework.exceptions import ValidationError + + from common.saved_views import SavedViewSerializer + + request = SimpleNamespace(profile=user_profile, user=regular_user) + serializer = SavedViewSerializer( + data={"module": "leads", "name": "Hot"}, context={"request": request} + ) + assert serializer.is_valid(), serializer.errors + SavedView.objects.create( + org=user_profile.org, profile=user_profile, module="leads", name="HOT" + ) + with pytest.raises(ValidationError) as caught: + serializer.save(org=user_profile.org, profile=user_profile) + assert "name" in caught.value.detail + assert SavedView.objects.count() == 1 + + +def test_a_refused_value_reads_as_one_sentence_per_param(user_client): + response = create(user_client, filters={"assigned_to": "nope"}) + assert response.status_code == 400 + assert response.data["filters"] == ["assigned_to: 'nope' is not a valid id."] diff --git a/backend/common/tests/test_superuser_is_org_admin.py b/backend/common/tests/test_superuser_is_org_admin.py new file mode 100644 index 00000000..0b2935e0 --- /dev/null +++ b/backend/common/tests/test_superuser_is_org_admin.py @@ -0,0 +1,290 @@ +"""A Django superuser is an org admin in every org they are a member of. + +Owner decision for 1.11.0. ``is_org_admin`` read ``role`` alone while dozens of +call sites added ``or request.user.is_superuser`` by hand and the rest did not, +and both clients gated admin UI on ``role == "ADMIN"``, so a superuser holding +the USER role saw read-only settings pages the API would have let them change. + +What is pinned here: + +* the rule itself, both ways, on real profiles; +* the one fact the clients gate on (``is_organization_admin`` in the JWT, the + org lists and the profile payloads) agrees with the rule; +* a representative admin-only endpoint admits the superuser and refuses a + plain member; +* membership is still required, and neither ``is_superuser``, ``is_staff`` nor + ``role`` can be written by the caller through the API. +""" + +from unittest.mock import MagicMock + +import pytest +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from common.models import Profile, User +from common.permissions import IsOrgAdmin, is_org_admin +from common.serializer import OrgAwareRefreshToken +from common.testing import _make_authenticated_client +from common.views.auth_views import _org_payload + + +@pytest.fixture +def super_user(): + user = User.objects.create_user(email="root@test.com", password="testpass123") + user.is_superuser = True + user.is_staff = True + user.save() + return user + + +@pytest.fixture +def super_profile(super_user, org_a): + return Profile.objects.create( + user=super_user, org=org_a, role="USER", is_active=True + ) + + +@pytest.fixture +def super_client(super_user, org_a, super_profile): + return _make_authenticated_client(super_user, org_a, super_profile) + + +@pytest.mark.django_db +class TestTheRule: + def test_admin_role_is_admin(self, admin_profile): + assert is_org_admin(admin_profile) is True + + def test_user_role_superuser_is_admin(self, super_profile): + assert is_org_admin(super_profile) is True + + def test_user_role_member_is_not(self, user_profile): + assert is_org_admin(user_profile) is False + + def test_permission_class_agrees(self, super_profile, user_profile): + class _Request: + def __init__(self, profile): + self.profile = profile + + assert IsOrgAdmin().has_permission(_Request(super_profile), None) is True + assert IsOrgAdmin().has_permission(_Request(user_profile), None) is False + + def test_profile_is_admin_property_delegates(self, super_profile, user_profile): + assert super_profile.is_admin is True + assert user_profile.is_admin is False + + def test_a_truthy_non_bool_is_not_a_superuser(self): + """A test double answers every attribute with a truthy Mock.""" + profile = MagicMock() + profile.role = "USER" + assert is_org_admin(profile) is False + + def test_the_column_still_mirrors_the_role_only(self, super_profile): + """`is_organization_admin` the column is not the fact; the API field is.""" + super_profile.refresh_from_db() + assert super_profile.is_organization_admin is False + + +@pytest.mark.django_db +class TestThePayloadFactMatchesTheRule: + def _claim(self, user, org, profile): + token = OrgAwareRefreshToken.for_user_and_org(user, org, profile) + return AccessToken(str(token.access_token)).get("is_organization_admin") + + def test_jwt_claim( + self, + org_a, + super_user, + super_profile, + admin_user, + admin_profile, + regular_user, + user_profile, + ): + assert self._claim(super_user, org_a, super_profile) is True + assert self._claim(admin_user, org_a, admin_profile) is True + assert self._claim(regular_user, org_a, user_profile) is False + + def test_org_payload(self, org_a, super_profile, user_profile): + assert _org_payload(org_a, profile=super_profile)["is_organization_admin"] + member = _org_payload(org_a, profile=user_profile) + assert member["is_organization_admin"] is False + assert member["role"] == "USER" + assert "is_organization_admin" not in _org_payload(org_a) + + def test_profile_endpoint(self, super_client, user_client): + assert super_client.get("/api/auth/profile/").data["is_organization_admin"] + member = user_client.get("/api/auth/profile/").data + assert member["is_organization_admin"] is False + + def test_me_endpoint(self, super_client, user_client, org_a): + orgs = super_client.get("/api/auth/me/").data["organizations"] + assert [o["is_organization_admin"] for o in orgs] == [True] + orgs = user_client.get("/api/auth/me/").data["organizations"] + assert [o["is_organization_admin"] for o in orgs] == [False] + + def test_org_list_endpoint(self, super_client, user_client): + rows = super_client.get("/api/org/").data["profile_org_list"] + assert [r["is_organization_admin"] for r in rows] == [True] + rows = user_client.get("/api/org/").data["profile_org_list"] + assert [r["is_organization_admin"] for r in rows] == [False] + + def test_switch_org_response_and_new_token( + self, super_user, super_profile, org_a, regular_user, user_profile + ): + for user, expected in ((super_user, True), (regular_user, False)): + client = APIClient() + token = OrgAwareRefreshToken.for_user_and_org(user, None) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") + response = client.post( + "/api/auth/switch-org/", {"org_id": str(org_a.id)}, format="json" + ) + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["profile"]["is_organization_admin"] is expected + claims = AccessToken(response.data["access_token"]) + assert claims["is_organization_admin"] is expected + + +@pytest.mark.django_db +class TestAnAdminOnlyEndpoint: + def test_api_settings_create_admits_a_user_role_superuser(self, super_client): + response = super_client.post( + "/api/api-settings/", + {"title": "Site", "website": "https://site.com"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, response.data + + def test_api_settings_create_refuses_a_member(self, user_client): + response = user_client.post( + "/api/api-settings/", + {"title": "Site", "website": "https://site.com"}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_pack_sample_data_gate_admits_superuser_and_refuses_member( + self, super_client, user_client + ): + """`_require_admin` in pack_views used to compare `role` inline.""" + assert ( + super_client.delete("/api/packs/sample-data/").status_code + != status.HTTP_403_FORBIDDEN + ) + assert ( + user_client.delete("/api/packs/sample-data/").status_code + == status.HTTP_403_FORBIDDEN + ) + + +@pytest.mark.django_db +class TestNoEscalation: + def test_superuser_without_a_profile_cannot_enter_another_org( + self, super_user, super_profile, org_b + ): + """Superuser is admin in orgs they belong to; it is not a pass into others.""" + client = APIClient() + token = OrgAwareRefreshToken.for_user_and_org(super_user, None) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") + response = client.post( + "/api/auth/switch-org/", {"org_id": str(org_b.id)}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_an_inactive_superuser_membership_gets_no_org_context( + self, super_user, super_profile, org_a + ): + client = _make_authenticated_client(super_user, org_a, super_profile) + super_profile.is_active = False + super_profile.save() + response = client.post( + "/api/api-settings/", + {"title": "Site", "website": "https://site.com"}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_member_cannot_make_themselves_superuser_or_admin( + self, user_client, regular_user, user_profile + ): + body = { + "is_superuser": True, + "is_staff": True, + "role": "ADMIN", + "is_organization_admin": True, + "name": "Still Me", + } + user_client.patch("/api/profile/", body, format="json") + user_client.put(f"/api/user/{regular_user.id}/", body, format="json") + + regular_user.refresh_from_db() + user_profile.refresh_from_db() + assert regular_user.is_superuser is False + assert regular_user.is_staff is False + assert user_profile.role == "USER" + assert is_org_admin(user_profile) is False + + def test_admin_cannot_make_a_member_superuser( + self, admin_client, regular_user, user_profile + ): + admin_client.put( + f"/api/user/{regular_user.id}/", + { + "email": regular_user.email, + "role": "USER", + "is_superuser": True, + "is_staff": True, + }, + format="json", + ) + regular_user.refresh_from_db() + assert regular_user.is_superuser is False + assert regular_user.is_staff is False + + def test_superuser_cannot_change_their_own_role( + self, super_client, super_user, super_profile + ): + """Being an admin does not lift the no-self-role-change rule.""" + response = super_client.put( + f"/api/user/{super_user.id}/", + {"email": super_user.email, "role": "ADMIN"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + super_profile.refresh_from_db() + assert super_profile.role == "USER" + + +@pytest.mark.django_db +def test_an_admin_approval_rule_counts_a_superuser_as_an_admin( + org_a, super_profile, user_profile, admin_profile +): + """`Approval.can_be_acted_on_by` compared `role` to the rule's role inline.""" + from cases.approvals import Approval, ApprovalRule + + rule = ApprovalRule.objects.create(org=org_a, name="Admins", approver_role="ADMIN") + approval = Approval(org=org_a, rule=rule) + + assert approval.can_be_acted_on_by(admin_profile) is True + assert approval.can_be_acted_on_by(super_profile) is True + assert approval.can_be_acted_on_by(user_profile) is False + + +@pytest.mark.django_db +def test_org_create_response_carries_the_creators_admin_fact(regular_user): + """Mobile caches the created org straight from this response; without the + fact the creator saw no admin controls until they signed in again.""" + client = APIClient() + token = OrgAwareRefreshToken.for_user_and_org(regular_user, None) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") + + response = client.post( + "/api/org/", {"name": "Brand New Org", "is_organization_admin": False} + ) + + assert response.status_code == status.HTTP_200_OK, response.data + org = response.data["org"] + assert org["role"] == "ADMIN" + assert org["is_organization_admin"] is True + assert Profile.objects.get(user=regular_user, org_id=org["id"]).role == "ADMIN" diff --git a/backend/common/urls.py b/backend/common/urls.py index a97c0c0e..2b7f2556 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -1,6 +1,7 @@ from django.urls import path from common.views.attachment_views import AttachmentDownloadView +from common.views.audit_log_views import SecurityAuditLogListView from common.views.auth_views import ( GoogleIdTokenView, GoogleOAuthCallbackView, @@ -47,6 +48,7 @@ from common.views.pat_views import ( PersonalAccessTokenDetailView, PersonalAccessTokenListCreateView, ) +from common.views.saved_view_views import SavedViewDetailView, SavedViewListView from common.views.settings_views import DomainDetailView, DomainList from common.views.tags_views import ( TagsDetailView, @@ -117,6 +119,7 @@ urlpatterns = [ name="help_center_settings", ), path("org/tokens/", OrgAccessTokenListView.as_view(), name="org_pat_list"), + path("org/audit-log/", SecurityAuditLogListView.as_view(), name="org_audit_log"), path( "org/tokens//", OrgAccessTokenDetailView.as_view(), @@ -188,6 +191,13 @@ urlpatterns = [ NotificationDetailView.as_view(), name="notifications_detail", ), + # A profile's own saved list filters (G29). + path("saved-views/", SavedViewListView.as_view(), name="saved_view_list"), + path( + "saved-views//", + SavedViewDetailView.as_view(), + name="saved_view_detail", + ), # Vertical packs: any member may list; apply/clear are ADMIN-only (see # common/views/pack_views.py). sample-data/ must precede # /apply/ so it is never captured as a pack id. diff --git a/backend/common/views/attachment_views.py b/backend/common/views/attachment_views.py index 8e0b2908..49f4bb5d 100644 --- a/backend/common/views/attachment_views.py +++ b/backend/common/views/attachment_views.py @@ -13,78 +13,67 @@ therefore offering a download that could not work and, where it did work, worked for the wrong people. **Reading an attachment is reading the record it hangs off**, so this view -asks that record's own read predicate rather than inventing a second one. -Every one of the seven predicates below already exists and is already used by -that model's detail view. A content type not in the map is refused: a new -attachable model must opt in here deliberately, because the failure mode of -the other default is handing out somebody's file. +asks that record's own read rule rather than inventing a second one: each +module's `visible_*_qs`, the queryset its list and detail views use. A file on +a record the caller cannot open answers the same 404 as an id that does not +exist, so the download cannot confirm the record or the file is there. A +content type not in the map, and an attachment whose record is gone, answer +that 404 too: a new attachable model must opt in here deliberately, because +the failure mode of the other default is handing out somebody's file. """ from django.http import FileResponse, Http404 -from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema -from rest_framework import status from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from rest_framework.views import APIView from common import swagger_params +from common.lookups import get_on_visible_record_or_404, get_scoped_or_404 from common.models import Attachments from common.permissions import HasOrgContext -def _readers(): - """content_type.model -> a predicate answering "may this caller read it". +def _visible(): + """content_type.model -> the records of that kind the caller may open. Built lazily inside the function because these modules import from `common`, and importing them at `common.views` module level would close the circle. """ - from accounts.access import has_account_access - from cases.access import has_case_read_access - from contacts.access import has_contact_access - from invoices.permissions import has_object_access - from leads.access import has_lead_access - from opportunity.access import has_deal_access - from tasks.access import has_task_access + from accounts.access import visible_accounts_qs + from cases.access import visible_cases_qs + from contacts.access import visible_contacts_qs + from invoices.permissions import visible_invoices_qs + from leads.access import visible_leads_qs + from opportunity.access import visible_deals_qs + from tasks.access import visible_tasks_qs return { - "lead": lambda request, obj: has_lead_access( - request.profile, request.user, obj - ), - "opportunity": lambda request, obj: has_deal_access( - request.profile, request.user, obj - ), - "contact": lambda request, obj: has_contact_access(request.profile, obj), - "account": lambda request, obj: has_account_access( - request.profile, request.user, obj - ), - "case": lambda request, obj: has_case_read_access(request.profile, obj), - "task": lambda request, obj: has_task_access(request.profile, obj), - "invoice": has_object_access, + "lead": lambda request: visible_leads_qs(request.profile, request.user), + "opportunity": lambda request: visible_deals_qs(request.profile, request.user), + "contact": lambda request: visible_contacts_qs(request.profile), + "account": lambda request: visible_accounts_qs(request.profile, request.user), + "case": lambda request: visible_cases_qs(request.profile), + "task": lambda request: visible_tasks_qs(request.profile), + "invoice": lambda request: visible_invoices_qs(request.profile, request.user), } -def may_read_attachment(request, attachment): - """True when the caller may read the record this file is attached to. +def get_readable_attachment_or_404(request, pk): + """The attachment ``pk`` if the caller may open the record it hangs off. - Deny by default, twice over. An unmapped content type is refused, and so - is an attachment whose parent row has been deleted out from under it: a - dangling `object_id` is not an absence of a rule, it is a record nobody - can be checked against. + Raises the same 404 for a missing id, another org's file, a file on a + record the caller cannot read, an unmapped content type and a record that + has been deleted: the second lookup only finds the row through the + module's read-rule queryset, which holds none of those. """ - reader = _readers().get(attachment.content_type.model) - if reader is None: - return False - parent = attachment.content_object - if parent is None: - return False - # The org filter on the attachment does not cover the parent, and a - # ContentType lookup crosses tenants freely, so check it here as well. - if getattr(parent, "org_id", None) != request.profile.org_id: - return False - return reader(request, parent) + org = request.profile.org + found = get_scoped_or_404(Attachments, pk, org) + visible = _visible().get(found.content_type.model) + if visible is None: + raise Http404(f"No such {Attachments._meta.verbose_name}.") + return get_on_visible_record_or_404(Attachments, pk, org, visible(request)) class AttachmentDownloadView(APIView): @@ -99,19 +88,7 @@ class AttachmentDownloadView(APIView): responses={(200, "application/octet-stream"): OpenApiTypes.BINARY}, ) def get(self, request, pk, format=None): - attachment = get_object_or_404( - Attachments.objects.select_related("content_type"), - id=pk, - org=request.profile.org, - ) - if not may_read_attachment(request, attachment): - return Response( - { - "error": True, - "errors": "You do not have Permission to perform this action", - }, - status=status.HTTP_403_FORBIDDEN, - ) + attachment = get_readable_attachment_or_404(request, pk) if not attachment.attachment: raise Http404("That attachment has no file.") attachment.attachment.open("rb") diff --git a/backend/common/views/audit_log_views.py b/backend/common/views/audit_log_views.py new file mode 100644 index 00000000..31267ea1 --- /dev/null +++ b/backend/common/views/audit_log_views.py @@ -0,0 +1,133 @@ +"""The security audit log, read by an org's admins (G33). + +GET /api/org/audit-log/ newest first, paginated (limit/offset, at most 100) + ?event_type= + ?actor= + ?from=YYYY-MM-DD ?to=YYYY-MM-DD inclusive, in the org's timezone + +`security_audit_log` has no RLS policy, on purpose (`common/0036`, and +`common/tests/test_audit_log_not_exposed.py`): its `org` is nullable because +the events it exists for, failed logins and invalid API keys, often have none. +So the `org=request.profile.org` filter below is the only tenant barrier, and a +row with no org never matches it. + +What leaves the server is chosen field by field rather than dumped: + +* `description` is not returned. An ORG_SWITCH row's text names the org the + user switched from, which is another tenant. +* `metadata` is cut down to `SAFE_DETAILS`, keys whose values are ids, counts + or sentences the server wrote. Other keys can hold what a caller supplied: + `suspicious_activity(details=...)`, the email a failed login tried, an API + key prefix. +* A path under `/api/public/` can carry a bearer token in the URL (the CSAT + link does), so only that prefix is shown. + +Admins only. `/api/org/audit-log/` is on the credential deny-list in +`common/scopes.py`, so a personal access token or the org API key is refused +before it gets here, the same way `/api/webhooks/` is. +""" + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import ValidationError +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from common.audit_log import SecurityAuditLog +from common.permissions import HasOrgContext, IsOrgAdmin +from common.validators import date_param, uuid_param + +SAFE_DETAILS = ( + "action", + "resource", + "deleted_count", + "endpoint_id", + "creator_id", + "pause_reason", + "previous_creator_id", + "changed", +) + +PUBLIC_PREFIX = "/api/public/" + +EVENT_LABELS = dict(SecurityAuditLog.EVENT_TYPES) + + +class AuditLogPagination(LimitOffsetPagination): + default_limit = 25 + max_limit = 100 + + +def _entry(row): + user = row.user + path = row.request_path + if path.startswith(PUBLIC_PREFIX): + path = PUBLIC_PREFIX + return { + "id": str(row.id), + "event_type": row.event_type, + "event_label": EVENT_LABELS.get(row.event_type, row.event_type), + "created_at": row.created_at.isoformat(), + "success": row.success, + "actor": ( + {"id": str(user.id), "name": user.name, "email": user.email} + if user is not None + else None + ), + "ip_address": row.ip_address, + "user_agent": row.user_agent, + "request_method": row.request_method, + "request_path": path, + "details": { + key: row.metadata[key] + for key in SAFE_DETAILS + if isinstance(row.metadata, dict) and key in row.metadata + }, + } + + +class SecurityAuditLogListView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdmin) + + @extend_schema( + tags=["Audit log"], + operation_id="org_audit_log", + parameters=[ + OpenApiParameter("event_type", str, OpenApiParameter.QUERY), + OpenApiParameter("actor", OpenApiTypes.UUID, OpenApiParameter.QUERY), + OpenApiParameter("from", OpenApiTypes.DATE, OpenApiParameter.QUERY), + OpenApiParameter("to", OpenApiTypes.DATE, OpenApiParameter.QUERY), + ], + responses=OpenApiTypes.OBJECT, + ) + def get(self, request): + params = request.query_params + rows = SecurityAuditLog.objects.filter(org=request.profile.org) + + event_type = params.get("event_type") + if event_type: + if event_type not in EVENT_LABELS: + raise ValidationError({"event_type": ["Unknown event type."]}) + rows = rows.filter(event_type=event_type) + actor = uuid_param(params, "actor") + if actor: + rows = rows.filter(user_id=actor) + start = date_param(params, "from") + end = date_param(params, "to") + if start and end and start > end: + raise ValidationError({"from": ["Must be on or before 'to'."]}) + if start: + rows = rows.filter(created_at__date__gte=start) + if end: + rows = rows.filter(created_at__date__lte=end) + + rows = rows.select_related("user").order_by("-created_at", "-id") + paginator = AuditLogPagination() + page = paginator.paginate_queryset(rows, request, view=self) + response = paginator.get_paginated_response([_entry(row) for row in page]) + response.data["event_types"] = [ + {"value": value, "label": label} + for value, label in SecurityAuditLog.EVENT_TYPES + ] + return response diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index e5408309..4350859f 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -17,13 +17,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication from common import serializer from common.models import Org, Profile, User +from common.permissions import is_org_admin +from common.request_meta import client_ip from common.serializer import OrgAwareRefreshToken from common.utils import CURRENCY_SYMBOLS logger = logging.getLogger(__name__) -def _org_payload(org, role=None): +def _org_payload(org, profile=None): """The org record every auth response hands a client. Currency belongs here because a client picks it up at sign-in and holds it @@ -35,6 +37,11 @@ def _org_payload(org, role=None): ``default_currency`` and ``timezone`` are normalised rather than passed through, so a blank column answers with the same "USD" and "UTC" the rest of the codebase assumes. + + With ``profile`` (the caller's membership of ``org``) it also carries + ``role`` and ``is_organization_admin``. The mobile client gates admin UI on + the latter, which is ``is_org_admin`` and so admits a superuser's profile; + ``role`` is for display. """ currency = org.default_currency or "USD" payload = { @@ -47,8 +54,9 @@ def _org_payload(org, role=None): # computed it instead of guessing from the device. "timezone": org.timezone or "UTC", } - if role is not None: - payload["role"] = role + if profile is not None: + payload["role"] = profile.role + payload["is_organization_admin"] = is_org_admin(profile) return payload @@ -323,9 +331,9 @@ class GoogleIdTokenView(APIView): # requires `is_active=True`, so listing a deactivated membership here # offered an org that answers 403 the moment it is chosen. profiles = Profile.objects.filter(user=user, is_active=True).select_related( - "org" + "org", "user" ) - organizations = [_org_payload(p.org, role=p.role) for p in profiles] + organizations = [_org_payload(p.org, profile=p) for p in profiles] # Generate JWT token token = OrgAwareRefreshToken.for_user_and_org(user, None) @@ -685,7 +693,7 @@ class OrgSwitchView(APIView): "profile": { "id": str(profile.id), "role": profile.role, - "is_organization_admin": profile.is_organization_admin, + "is_organization_admin": is_org_admin(profile), }, }, status=status.HTTP_200_OK, @@ -754,7 +762,7 @@ class MagicLinkRequestView(APIView): delivery=delivery, code_hash=code_hash, expires_at=timezone.now() + timedelta(minutes=10), - ip_address=request.META.get("REMOTE_ADDR"), + ip_address=client_ip(request), ) # Send email via Celery. Pass raw_code only when delivery is "code". @@ -981,12 +989,12 @@ class MagicLinkVerifyCodeView(APIView): profiles = list( Profile.objects.filter(user=user, is_active=True) - .select_related("org") + .select_related("org", "user") .order_by("org__name") ) # Same shape the Google flow returns, so a client can offer the same # picker whichever way the user signed in. - organizations = [_org_payload(p.org, role=p.role) for p in profiles] + organizations = [_org_payload(p.org, profile=p) for p in profiles] # Bind the session to an org only when there is no choice to make. # Picking `profiles.first()` out of several was an arbitrary answer to diff --git a/backend/common/views/dashboard_views.py b/backend/common/views/dashboard_views.py index e7546477..65f71540 100644 --- a/backend/common/views/dashboard_views.py +++ b/backend/common/views/dashboard_views.py @@ -153,7 +153,7 @@ class ApiHomeView(APIView): # Each one is its module's read rule, so every figure below counts the # rows the matching list shows. They differ on purpose: contacts add - # account assignment, and tasks have no superuser clause. + # account assignment. accounts = _readable( Account.objects.filter(is_active=True, org=org), visible_accounts_qs(profile, user), @@ -173,7 +173,7 @@ class ApiHomeView(APIView): tasks = _readable(Task.objects.filter(org=org), visible_tasks_qs(profile)) # Decides only whether the goal summary below includes org-wide goals. - is_admin = is_org_admin(profile) or user.is_superuser + is_admin = is_org_admin(profile) # Counts only. This used to serialize every account, contact, lead and # opportunity in the org in full beside them: 372 KB of a 384 KB diff --git a/backend/common/views/document_views.py b/backend/common/views/document_views.py index 3987da33..444879e7 100644 --- a/backend/common/views/document_views.py +++ b/backend/common/views/document_views.py @@ -80,7 +80,7 @@ class DocumentListView(APIView, LimitOffsetPagination): queryset = self.model.objects.filter(org=self.request.profile.org).order_by( "-id" ) - if not (self.request.user.is_superuser or is_org_admin(self.request.profile)): + if not (is_org_admin(self.request.profile)): queryset = queryset.filter(_visible_to(self.request.profile)).distinct() request_post = params @@ -255,7 +255,7 @@ class DocumentDetailView(APIView): who uploaded it, or an admin. `created_by` is a User FK. Comparing it to a Profile is the bug that made this branch unreachable. """ - if is_org_admin(self.request.profile) or self.request.user.is_superuser: + if is_org_admin(self.request.profile): return True return document.created_by_id == self.request.profile.user_id @@ -307,7 +307,7 @@ class DocumentDetailView(APIView): if not self._may_read(self.object): return self._forbidden() profile_list = Profile.objects.filter(org=self.request.profile.org) - if is_org_admin(request.profile) or request.user.is_superuser: + if is_org_admin(request.profile): profiles = profile_list.order_by("user__email") else: profiles = profile_list.filter(role="ADMIN").order_by("user__email") diff --git a/backend/common/views/org_settings_views.py b/backend/common/views/org_settings_views.py index e882a1ce..cf9cbc18 100644 --- a/backend/common/views/org_settings_views.py +++ b/backend/common/views/org_settings_views.py @@ -42,7 +42,7 @@ class OrgSettingsView(APIView): {"error": "Organization context required"}, status=status.HTTP_400_BAD_REQUEST, ) - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Only admins can update organization settings"}, status=status.HTTP_403_FORBIDDEN, @@ -72,7 +72,7 @@ class HelpCenterSettingsView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def _can_edit(self, request): - return is_org_admin(request.profile) or request.user.is_superuser + return is_org_admin(request.profile) def _payload(self, request, org): data = HelpCenterSettingsSerializer(org).data diff --git a/backend/common/views/organization_views.py b/backend/common/views/organization_views.py index fea35c1c..fd021272 100644 --- a/backend/common/views/organization_views.py +++ b/backend/common/views/organization_views.py @@ -55,7 +55,15 @@ class OrgProfileCreateView(APIView): { "error": False, "message": "New Org is Created.", - "org": self.serializer_class(org_obj).data, + # The creator's membership facts ride on the org, in the + # shape the auth endpoints use for each org they list, so a + # client caching this org gates admin UI correctly at once. + # Server-derived; nothing here is read from the request. + "org": { + **self.serializer_class(org_obj).data, + "role": profile_obj.role, + "is_organization_admin": is_org_admin(profile_obj), + }, "status": status.HTTP_201_CREATED, } ) @@ -75,7 +83,9 @@ class OrgProfileCreateView(APIView): """ here we are passing profile list of the user, where org details also included """ - profile_list = Profile.objects.filter(user=request.user) + profile_list = Profile.objects.filter(user=request.user).select_related( + "org", "user" + ) serializer = ShowOrganizationListSerializer(profile_list, many=True) return Response( { diff --git a/backend/common/views/pack_views.py b/backend/common/views/pack_views.py index b9b77dfe..af7e176c 100644 --- a/backend/common/views/pack_views.py +++ b/backend/common/views/pack_views.py @@ -1,7 +1,8 @@ """Vertical-pack endpoints. -Authorization: any authenticated member may list packs; only an org ADMIN may -apply one or clear sample data. The org is always request.profile.org, never a +Authorization: any authenticated member may list packs; only an org admin +(``common.permissions.is_org_admin``: the ADMIN role, or a superuser) may apply +one or clear sample data. The org is always request.profile.org, never a body field. Org.vertical is descriptive and is never read here for access control. """ @@ -12,12 +13,12 @@ from rest_framework.views import APIView from common.packs.applier import apply_pack, clear_sample_data from common.packs.loader import get_pack, get_registry -from common.permissions import HasOrgContext +from common.permissions import HasOrgContext, is_org_admin def _require_admin(request): """Return a 403 Response if the caller is not an org admin, else None.""" - if getattr(request.profile, "role", None) != "ADMIN": + if not is_org_admin(getattr(request, "profile", None)): return Response( {"error": True, "errors": "Only an organization admin can do this."}, status=status.HTTP_403_FORBIDDEN, diff --git a/backend/common/views/portal_auth_views.py b/backend/common/views/portal_auth_views.py index 30674245..ce031e88 100644 --- a/backend/common/views/portal_auth_views.py +++ b/backend/common/views/portal_auth_views.py @@ -22,6 +22,7 @@ from rest_framework.views import APIView from common.models import Org, PortalLoginToken from common.portal_auth import mint_portal_token +from common.request_meta import client_ip from common.tasks import set_rls_context from contacts.models import Contact @@ -105,7 +106,7 @@ class PortalLoginRequestView(APIView): contact=contact, code_hash=make_password(raw_code), expires_at=timezone.now() + TOKEN_TTL, - ip_address=request.META.get("REMOTE_ADDR"), + ip_address=client_ip(request), ) from common.tasks import send_portal_login_email diff --git a/backend/common/views/saved_view_views.py b/backend/common/views/saved_view_views.py new file mode 100644 index 00000000..d05ef13c --- /dev/null +++ b/backend/common/views/saved_view_views.py @@ -0,0 +1,107 @@ +"""Saved list views (G29): a profile's named filters for one list. + +Routes (under /api/saved-views/): + GET /?module= the caller's own views, for one list or for all six + POST / save one + GET // one of the caller's views + PATCH // rename it, or replace its filters + DELETE // delete it + +A view is private to the profile that saved it, admins included: every lookup +filters on the org and the profile, so another person's id answers 404 on every +verb, the same body as an id that does not exist. What a view may hold is +checked in ``common/saved_views.py``. +""" + +from django.db.models.functions import Lower +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.lookups import get_scoped_or_404 +from common.models import SavedView +from common.permissions import HasOrgContext +from common.saved_views import LISTS, MAX_VIEWS_PER_LIST, SavedViewSerializer + + +class SavedViewListView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext) + + @extend_schema( + tags=["Saved views"], + operation_id="saved_views_list", + parameters=[ + OpenApiParameter( + "module", str, enum=sorted(LISTS), description="Only this list's views." + ) + ], + responses=OpenApiTypes.OBJECT, + ) + def get(self, request): + profile = request.profile + views = SavedView.objects.filter(org=profile.org, profile=profile) + module = request.query_params.get("module") + if module is not None: + if module not in LISTS: + return Response( + {"module": [f"Choose one of: {', '.join(sorted(LISTS))}."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + views = views.filter(module=module) + views = views.order_by("module", Lower("name")) + return Response( + { + "saved_views": SavedViewSerializer(views, many=True).data, + "limit": MAX_VIEWS_PER_LIST, + } + ) + + @extend_schema( + tags=["Saved views"], + request=SavedViewSerializer, + responses={201: SavedViewSerializer}, + ) + def post(self, request): + serializer = SavedViewSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + view = serializer.save(org=request.profile.org, profile=request.profile) + return Response(SavedViewSerializer(view).data, status=status.HTTP_201_CREATED) + + +class SavedViewDetailView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext) + + def get_view(self, request, pk): + return get_scoped_or_404( + SavedView, pk, request.profile.org, profile=request.profile + ) + + @extend_schema(tags=["Saved views"], responses=SavedViewSerializer) + def get(self, request, pk): + return Response(SavedViewSerializer(self.get_view(request, pk)).data) + + @extend_schema( + tags=["Saved views"], + request=SavedViewSerializer, + responses=SavedViewSerializer, + ) + def patch(self, request, pk): + serializer = SavedViewSerializer( + self.get_view(request, pk), + data=request.data, + partial=True, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @extend_schema(tags=["Saved views"], responses={204: None}) + def delete(self, request, pk): + self.get_view(request, pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/common/views/settings_views.py b/backend/common/views/settings_views.py index 7a567d67..5a2bf1dd 100644 --- a/backend/common/views/settings_views.py +++ b/backend/common/views/settings_views.py @@ -1,4 +1,5 @@ import json +import uuid from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, inline_serializer @@ -17,7 +18,7 @@ from common.serializer import ( APISettingsSwaggerSerializer, ProfileSerializer, ) -from common.utils import get_or_create_tags, handle_m2m_assignment +from common.utils import get_or_create_tags from common.validators import payload_id_list @@ -57,15 +58,42 @@ def _tag_names(value): return names -def _parse_relations(params): - """(tag names, profile ids) from the body, or a 400 response. +def _active_members(value, org): + """The org's active profiles named by `value`, or a DRF 400. - Parsed before anything is saved, so a malformed value leaves no - half-written setting behind. + Every id has to be an active profile in `org`. Another org's profile, an + inactive one, or an id that matches nothing is refused rather than quietly + dropped: `webforms/legacy.py` hands leads to these profiles, so the caller + has to learn that an assignee they named was not attached. + """ + ids = payload_id_list(value, "lead_assigned_to") + wanted = {uuid.UUID(str(pk)) for pk in ids} + profiles = list(Profile.objects.filter(id__in=wanted, org=org, is_active=True)) + if len(profiles) != len(wanted): + raise DRFValidationError( + { + "lead_assigned_to": [ + "Each assignee must be an active member of this organization." + ] + } + ) + return profiles + + +def _parse_relations(params, org, partial=False): + """(tag names, assignee profiles, error response) from the body. + + Resolved before anything is saved, so a bad value leaves no half-written + setting behind. A key absent from a PATCH (`partial`) comes back as + `None`, meaning "leave it as it is"; on POST and PUT an absent key is an + empty list, since those replace the whole setting. """ try: - names = _tag_names(params.get("tags")) - ids = payload_id_list(params.get("lead_assigned_to"), "lead_assigned_to") + names = profiles = None + if not partial or "tags" in params: + names = _tag_names(params.get("tags")) + if not partial or "lead_assigned_to" in params: + profiles = _active_members(params.get("lead_assigned_to"), org) except DRFValidationError as exc: return ( None, @@ -75,26 +103,20 @@ def _parse_relations(params): status=status.HTTP_400_BAD_REQUEST, ), ) - return names, ids, None + return names, profiles, None -def _attach_relations(setting, names, ids, org): - """Attach tags and assignees, both resolved only inside `org`. +def _set_relations(setting, names, profiles, org): + """Replace tags and assignees; `None` leaves that relation untouched. - Tags are found or created by `Tags.slug_for` within the org. Assignees are - the org's active profiles among `ids`; any other id is ignored, as on every - other M2M write. `webforms/legacy.py` hands leads to these profiles, so a - profile from another org here would receive that org's leads. + Tags are found or created by `Tags.slug_for` within `org` only, so a name + another org also uses never reaches that org's tag. `profiles` has already + been confined to the org's active members by `_active_members`. """ - setting.tags.add(*get_or_create_tags(names, org)) - handle_m2m_assignment( - setting, - "lead_assigned_to", - ids, - Profile, - org, - extra_filters={"is_active": True}, - ) + if names is not None: + setting.tags.set(get_or_create_tags(names, org)) + if profiles is not None: + setting.lead_assigned_to.set(profiles) class DomainList(APIView): @@ -157,17 +179,14 @@ class DomainList(APIView): if not is_org_admin(request.profile): return _admin_required() params = request.data - tag_names, assignee_ids, error = _parse_relations(params) + org = request.profile.org + tag_names, assignees, error = _parse_relations(params, org) if error: return error serializer = APISettingsSerializer(data=params) if serializer.is_valid(): - settings_obj = serializer.save( - created_by=request.profile.user, org=request.profile.org - ) - _attach_relations( - settings_obj, tag_names, assignee_ids, request.profile.org - ) + settings_obj = serializer.save(created_by=request.profile.user, org=org) + _set_relations(settings_obj, tag_names, assignees, org) return Response( {"error": False, "message": "API key added sucessfully"}, status=status.HTTP_201_CREATED, @@ -231,15 +250,14 @@ class DomainDetailView(APIView): return _admin_required() api_setting = self.get_object(pk) params = request.data - tag_names, assignee_ids, error = _parse_relations(params) + org = request.profile.org + tag_names, assignees, error = _parse_relations(params, org) if error: return error serializer = APISettingsSerializer(data=params, instance=api_setting) if serializer.is_valid(): api_setting = serializer.save() - api_setting.tags.clear() - api_setting.lead_assigned_to.clear() - _attach_relations(api_setting, tag_names, assignee_ids, request.profile.org) + _set_relations(api_setting, tag_names, assignees, org) return Response( {"error": False, "message": "API setting Updated sucessfully"}, status=status.HTTP_200_OK, @@ -265,16 +283,22 @@ class DomainDetailView(APIView): }, ) def patch(self, request, pk, **kwargs): - """Handle partial updates to API settings.""" + """Partial update: `tags` and `lead_assigned_to` are replaced when sent + and left alone when absent, with the same org scoping as PUT.""" if not is_org_admin(request.profile): return _admin_required() api_setting = self.get_object(pk) params = request.data + org = request.profile.org + tag_names, assignees, error = _parse_relations(params, org, partial=True) + if error: + return error serializer = APISettingsSerializer( data=params, instance=api_setting, partial=True ) if serializer.is_valid(): api_setting = serializer.save() + _set_relations(api_setting, tag_names, assignees, org) return Response( {"error": False, "message": "API setting Updated successfully"}, status=status.HTTP_200_OK, diff --git a/backend/common/views/tags_views.py b/backend/common/views/tags_views.py index f4bd9f78..9fb89f73 100644 --- a/backend/common/views/tags_views.py +++ b/backend/common/views/tags_views.py @@ -184,7 +184,7 @@ class TagsListView(APIView, LimitOffsetPagination): def post(self, request, *args, **kwargs): """Create a new tag (admin only).""" # Admin only for create - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can create tags"}, status=status.HTTP_403_FORBIDDEN, @@ -313,7 +313,7 @@ class TagsDetailView(APIView): def put(self, request, pk, *args, **kwargs): """Update a tag (admin only).""" # Admin only - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can update tags"}, status=status.HTTP_403_FORBIDDEN, @@ -404,7 +404,7 @@ class TagsDetailView(APIView): def delete(self, request, pk, **kwargs): """Archive a tag - soft delete (admin only).""" # Admin only - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can archive tags"}, status=status.HTTP_403_FORBIDDEN, @@ -452,7 +452,7 @@ class TagsRestoreView(APIView): def post(self, request, pk, **kwargs): """Restore an archived tag (admin only).""" # Admin only - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can restore tags"}, status=status.HTTP_403_FORBIDDEN, @@ -519,7 +519,7 @@ class TagsMergeView(APIView): ) def post(self, request, pk, **kwargs): """Merge the tag at `pk` into the tag named by `into` (admin only).""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can merge tags"}, status=status.HTTP_403_FORBIDDEN, diff --git a/backend/common/views/user_views.py b/backend/common/views/user_views.py index 9378dc30..54123a85 100644 --- a/backend/common/views/user_views.py +++ b/backend/common/views/user_views.py @@ -97,10 +97,7 @@ class UsersListView(APIView, LimitOffsetPagination): }, ) def post(self, request, format=None): - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): + if not is_org_admin(self.request.profile): return Response( {"error": True, "errors": "Permission Denied"}, status=status.HTTP_403_FORBIDDEN, @@ -192,10 +189,7 @@ class UsersListView(APIView, LimitOffsetPagination): status=status.HTTP_400_BAD_REQUEST, ) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): + if not is_org_admin(self.request.profile): return Response( {"error": True, "errors": "Permission Denied"}, status=status.HTTP_403_FORBIDDEN, @@ -296,7 +290,7 @@ class UserDetailView(APIView): Editing your own profile for contact details stays allowed; only the privileged fields are withheld, by making them read_only downstream. """ - actor_is_admin = is_org_admin(request.profile) or request.user.is_superuser + actor_is_admin = is_org_admin(request.profile) editing_self = request.profile.id == target_profile.id return actor_is_admin and not editing_self @@ -375,7 +369,6 @@ class UserDetailView(APIView): address_obj = profile.address if ( not is_org_admin(self.request.profile) - and not self.request.user.is_superuser and self.request.profile.id != profile.id ): return Response( @@ -389,7 +382,10 @@ class UserDetailView(APIView): status=status.HTTP_403_FORBIDDEN, ) serializer = CreateUserSerializer( - data=params, instance=profile.user, org=request.profile.org + data=params, + instance=profile.user, + org=request.profile.org, + editing_self=request.profile.id == profile.id, ) address_serializer = BillingAddressSerializer(data=params, instance=address_obj) # Role and the access flags may be set only by an admin editing someone @@ -417,9 +413,7 @@ class UserDetailView(APIView): ) if address_serializer.is_valid(): address_obj = address_serializer.save(org=request.profile.org) - user = serializer.save() - user.email = user.email - user.save() + serializer.save() if profile_serializer.is_valid(): profile = profile_serializer.save() return Response( @@ -452,7 +446,6 @@ class UserDetailView(APIView): profile = self.get_object(pk) if ( not is_org_admin(self.request.profile) - and not self.request.user.is_superuser and self.request.profile.id != profile.id ): return Response( @@ -470,7 +463,11 @@ class UserDetailView(APIView): ) serializer = CreateUserSerializer( - data=params, instance=profile.user, org=request.profile.org, partial=True + data=params, + instance=profile.user, + org=request.profile.org, + partial=True, + editing_self=request.profile.id == profile.id, ) profile_serializer = CreateProfileSerializer( data=params, @@ -562,10 +559,7 @@ class UserStatusView(APIView): }, ) def post(self, request, pk, format=None): - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): + if not is_org_admin(self.request.profile): return Response( { "error": True, diff --git a/backend/contacts/access.py b/backend/contacts/access.py index 5d595dc5..584c48db 100644 --- a/backend/contacts/access.py +++ b/backend/contacts/access.py @@ -1,18 +1,15 @@ """Who may open a contact, and so who may link one to a record. -Extracted from ``ContactDetailView.assert_contact_access`` so the attachment -download view asks the same question rather than carrying a second copy of the -answer. The detail view still calls it. +One definition, asked by the detail view (through ``visible_contacts_qs``, in +the lookup itself, so a hidden contact is the same 404 as a missing one) and by +the attachment download and the link checks (``has_contact_access``). """ from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from common.permissions import is_org_admin from contacts.models import Contact -_DENIED = "You do not have Permission to perform this action" - def contact_account_ids(contact): """Every account this contact is joined to, by either route.""" @@ -45,7 +42,7 @@ def has_contact_access(profile, contact): the admin branch, it does not check the org itself: every caller fetches ``contact`` with ``org=profile.org`` first. """ - if is_org_admin(profile) or profile.user.is_superuser: + if is_org_admin(profile): return True if profile.user_id == contact.created_by_id: return True @@ -58,7 +55,7 @@ def has_contact_access(profile, contact): def visible_contacts_qs(profile): """Contacts ``profile`` may open, the queryset form of `has_contact_access`.""" qs = Contact.objects.filter(org=profile.org) - if is_org_admin(profile) or profile.user.is_superuser: + if is_org_admin(profile): return qs my_accounts = profile.account_assigned_users.values_list("id", flat=True) return qs.filter( @@ -92,9 +89,3 @@ def replace_visible_contacts(related, contact_ids, profile): kept = related.exclude(id__in=visible.values("id")) wanted = visible.filter(id__in=contact_ids) if contact_ids else [] related.set([*kept, *wanted]) - - -def assert_contact_access(profile, contact): - """Raise 403 unless ``profile`` may open ``contact``.""" - if not has_contact_access(profile, contact): - raise PermissionDenied(_DENIED) diff --git a/backend/contacts/tests/test_contact_access_and_links.py b/backend/contacts/tests/test_contact_access_and_links.py index 214c8bc0..8430156b 100644 --- a/backend/contacts/tests/test_contact_access_and_links.py +++ b/backend/contacts/tests/test_contact_access_and_links.py @@ -168,7 +168,7 @@ class TestContactDetailAccess: def test_stranger_is_refused(self, user_client, contact): """The predicate has to be able to say no, too.""" response = user_client.get(_detail(contact.id)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_another_orgs_contact_is_not_found(self, org_b_client, contact): response = org_b_client.get(_detail(contact.id)) @@ -240,14 +240,14 @@ class TestTheVerbsAgree: def test_put_refuses_before_it_validates(self, _email, user_client, contact): """A rejected payload must not double as a permission oracle. - Sending garbage as somebody with no access should say 403, not report + Sending garbage as somebody with no access should say 404, not report which fields were wrong -- otherwise the error body tells a stranger what the record's shape is. """ response = user_client.put( _detail(contact.id), {"email": "nope"}, format="json" ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.django_db @@ -340,7 +340,7 @@ class TestCommentsAreRecorded: response = user_client.post( _detail(contact.id), {"comment": "hello"}, format="json" ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert Comment.objects.filter(object_id=contact.id).count() == 0 def test_editing_a_comment_that_is_gone_is_not_found(self, admin_client): @@ -364,13 +364,14 @@ class TestAttachmentDeleteIsScoped: assert Attachments.objects.filter(id=their_attachment.id).exists() def test_the_uploader_can_delete_their_own( - self, user_client, contact, regular_user, org_a + self, user_profile, user_client, contact, regular_user, org_a ): attachment = Attachments( file_name="mine.txt", content_object=contact, org=org_a ) attachment.attachment = SimpleUploadedFile("mine.txt", b"mine") attachment.save() + contact.assigned_to.add(user_profile) # can open it _created_by(attachment, regular_user) response = user_client.delete(_attachment(attachment.id)) @@ -378,13 +379,14 @@ class TestAttachmentDeleteIsScoped: assert not Attachments.objects.filter(id=attachment.id).exists() def test_somebody_elses_upload_is_refused( - self, user_client, contact, admin_user, org_a + self, user_profile, user_client, contact, admin_user, org_a ): attachment = Attachments( file_name="theirs.txt", content_object=contact, org=org_a ) attachment.attachment = SimpleUploadedFile("theirs.txt", b"theirs") attachment.save() + contact.assigned_to.add(user_profile) # can open it _created_by(attachment, admin_user) response = user_client.delete(_attachment(attachment.id)) diff --git a/backend/contacts/tests/test_contact_access_superuser.py b/backend/contacts/tests/test_contact_access_superuser.py index 5270d5d5..16fd1e81 100644 --- a/backend/contacts/tests/test_contact_access_superuser.py +++ b/backend/contacts/tests/test_contact_access_superuser.py @@ -88,7 +88,7 @@ class TestQueryset: class TestDetail: def test_plain_member_is_refused(self, user_client, others_contact): response = user_client.get(f"/api/contacts/{others_contact.pk}/") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_superuser_may_open_it(self, superuser, user_client, others_contact): response = user_client.get(f"/api/contacts/{others_contact.pk}/") @@ -132,7 +132,7 @@ class TestList: class TestDelete: def test_plain_member_is_refused(self, user_client, others_contact): response = user_client.delete(f"/api/contacts/{others_contact.pk}/") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert Contact.objects.filter(pk=others_contact.pk).exists() def test_superuser_may_delete_it(self, superuser, user_client, others_contact): diff --git a/backend/contacts/tests/test_contact_existence_oracle.py b/backend/contacts/tests/test_contact_existence_oracle.py new file mode 100644 index 00000000..28cd84e2 --- /dev/null +++ b/backend/contacts/tests/test_contact_existence_oracle.py @@ -0,0 +1,133 @@ +"""A contact the caller may not open answers exactly like one that does not exist. + +Owner decision for 1.11.0: the contact detail, comment and attachment +endpoints look the contact up through `contacts.access.visible_contacts_qs` +in one step. They used to fetch org-wide and then check, answering 403 for a +hidden contact and 404 for a missing one. Each test compares the two +responses WHOLE. Writes narrower than reads keep their 403, only for a caller +who can open the contact. +""" + +import pytest +from django.contrib.contenttypes.models import ContentType + +from common.models import Attachments, Comment +from contacts.models import Contact + +MISSING = "11111111-1111-1111-1111-111111111111" + + +@pytest.fixture +def contact(org_a): + """In the caller's org, created by nobody, assigned to nobody: hidden.""" + return Contact.objects.create( + first_name="Hidden", last_name="Person", email="hidden@example.com", org=org_a + ) + + +@pytest.fixture +def comment(contact, admin_profile, org_a): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(Contact), + object_id=contact.id, + comment="Not yours", + commented_by=admin_profile, + org=org_a, + ) + + +@pytest.fixture +def attachment(contact, admin_user, org_a): + return Attachments.objects.create( + content_type=ContentType.objects.get_for_model(Contact), + object_id=contact.id, + file_name="x.txt", + attachment="attachments/x.txt", + created_by=admin_user, + org=org_a, + ) + + +def _same(client, method, hidden_url, missing_url, body=None): + call = getattr(client, method) + kwargs = {"format": "json"} if body is not None else {} + args = (body,) if body is not None else () + hidden = call(hidden_url, *args, **kwargs) + missing = call(missing_url, *args, **kwargs) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + + +@pytest.mark.django_db +class TestContactIdEndpoints: + @pytest.mark.parametrize( + "method,body", + [ + ("get", None), + ("put", {"first_name": "Mine", "last_name": "Now"}), + ("patch", {"first_name": "Mine"}), + ("post", {"comment": "hello"}), + ("delete", None), + ], + ) + def test_detail(self, user_client, contact, method, body): + _same( + user_client, + method, + f"/api/contacts/{contact.id}/", + f"/api/contacts/{MISSING}/", + body, + ) + contact.refresh_from_db() + assert contact.first_name == "Hidden" + + +@pytest.mark.django_db +class TestCommentsAndFiles: + @pytest.mark.parametrize( + "method,body", + [("put", {"comment": "x"}), ("patch", {"comment": "x"}), ("delete", None)], + ) + def test_comment_on_hidden_contact(self, user_client, comment, method, body): + _same( + user_client, + method, + f"/api/contacts/comment/{comment.id}/", + f"/api/contacts/comment/{MISSING}/", + body, + ) + assert Comment.objects.filter(id=comment.id).exists() + + def test_attachment_on_hidden_contact(self, user_client, attachment): + _same( + user_client, + "delete", + f"/api/contacts/attachment/{attachment.id}/", + f"/api/contacts/attachment/{MISSING}/", + ) + assert Attachments.objects.filter(id=attachment.id).exists() + + def test_readable_contact_non_author_is_403( + self, user_client, user_profile, contact, comment, attachment + ): + contact.assigned_to.add(user_profile) + url = f"/api/contacts/comment/{comment.id}/" + assert user_client.put(url, {"comment": "x"}, format="json").status_code == 403 + assert user_client.delete(url).status_code == 403 + attachment_url = f"/api/contacts/attachment/{attachment.id}/" + assert user_client.delete(attachment_url).status_code == 403 + + +@pytest.mark.django_db +class TestDeleteStaysNarrower: + def test_assignee_who_is_not_creator_gets_403( + self, user_client, user_profile, contact + ): + contact.assigned_to.add(user_profile) + assert user_client.get(f"/api/contacts/{contact.id}/").status_code == 200 + assert user_client.delete(f"/api/contacts/{contact.id}/").status_code == 403 + assert Contact.objects.filter(id=contact.id).exists() + + def test_creator_may_delete(self, user_client, regular_user, contact): + Contact.objects.filter(id=contact.id).update(created_by=regular_user) + assert user_client.delete(f"/api/contacts/{contact.id}/").status_code == 200 diff --git a/backend/contacts/tests/test_contact_link_requires_access.py b/backend/contacts/tests/test_contact_link_requires_access.py index 741c42f0..25b2e286 100644 --- a/backend/contacts/tests/test_contact_link_requires_access.py +++ b/backend/contacts/tests/test_contact_link_requires_access.py @@ -130,7 +130,7 @@ class TestAccountCreate: account = self._create(user_client, people, user_profile, "mine", "hidden") assert _names(account) == {"Mine"} opened = user_client.get(f"/api/contacts/{people['hidden'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_member_links_a_contact_they_can_open( self, user_client, user_profile, people @@ -161,7 +161,7 @@ class TestCreate: obj = self._post(user_client, kind, people, "mine", "hidden") assert _names(obj) == {"Mine"} opened = user_client.get(f"/api/contacts/{people['hidden'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_member_links_a_contact_they_can_open(self, user_client, people, kind): obj = self._post(user_client, kind, people, "mine") @@ -209,7 +209,7 @@ class TestReplace: linked = self._send(user_client, verb, record, people, "mine", "stranger") assert linked == {"Mine", "Hidden"} opened = user_client.get(f"/api/contacts/{people['stranger'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 @pytest.mark.parametrize("verb", ["put", "patch"]) def test_member_links_a_contact_they_can_open( @@ -277,7 +277,7 @@ class TestLeadConversion: assert "Hidden" not in _names(account) assert "Mine" in _names(account) opened = user_client.get(f"/api/contacts/{people['hidden'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_a_hidden_contact_matched_by_email_is_not_linked( self, user_client, lead, people @@ -290,7 +290,7 @@ class TestLeadConversion: assert _names(account) == set() assert hidden.account_id is None opened = user_client.get(f"/api/contacts/{hidden.id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_a_visible_contact_matched_by_email_is_linked( self, user_client, lead, people diff --git a/backend/contacts/tests/test_contacts_api.py b/backend/contacts/tests/test_contacts_api.py index 11c44775..db9af84e 100644 --- a/backend/contacts/tests/test_contacts_api.py +++ b/backend/contacts/tests/test_contacts_api.py @@ -474,7 +474,7 @@ class TestContactDetailView: created_by=admin_user, ) response = user_client.delete(_detail_url(contact.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_detail_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile @@ -489,16 +489,12 @@ class TestContactDetailView: created_by=admin_user, ) response = user_client.get(_detail_url(contact.pk)) - assert response.status_code in (status.HTTP_200_OK, status.HTTP_403_FORBIDDEN) - if response.status_code == status.HTTP_200_OK: - data = response.data - if "error" in data: - assert data["error"] is True + assert response.status_code == status.HTTP_404_NOT_FOUND def test_add_comment_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin user not assigned/creator gets 403 when adding comment.""" + """Non-admin user not assigned/creator gets 404 when adding comment.""" _set_rls(org_a) contact = Contact.objects.create( first_name="CommentForbid", @@ -512,7 +508,7 @@ class TestContactDetailView: {"comment": "Should fail"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_patch_contact_partial_update(self, admin_client, contact_a): """PATCH allows partial field updates.""" @@ -586,7 +582,7 @@ class TestContactDetailView: def test_patch_contact_non_admin_not_creator_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin not assigned/creator gets 403 on PATCH.""" + """Non-admin not assigned/creator gets 404 on PATCH, as on GET.""" _set_rls(org_a) contact = Contact.objects.create( first_name="ForbidPatch", @@ -600,7 +596,7 @@ class TestContactDetailView: {"first_name": "Hacked"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_patch_contact_invalid_data(self, admin_client, contact_a): """PATCH with invalid data returns 400.""" @@ -678,7 +674,8 @@ class TestContactCommentView: self, user_client, comment_fixture, user_profile ): """Non-admin non-author gets 403 on PUT.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the contact response = user_client.put( _comment_url(comment.id), {"comment": "Hacked"}, @@ -690,7 +687,8 @@ class TestContactCommentView: self, user_client, comment_fixture, user_profile ): """Non-admin non-author gets 403 on DELETE.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the contact response = user_client.delete(_comment_url(comment.id)) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -735,6 +733,7 @@ class TestContactAttachmentView: org=org_a, created_by=admin_user, ) + contact.assigned_to.add(user_profile) # can open it ct = ContentType.objects.get_for_model(Contact) attachment = Attachments.objects.create( content_type=ct, @@ -1047,7 +1046,7 @@ class TestContactDetailUpdateM2M: def test_update_contact_non_admin_not_creator_forbidden( self, mock_email, user_client, admin_user, org_a, user_profile ): - """Non-admin non-creator gets 403 on PUT (lines 261-266).""" + """Non-admin non-creator gets 404 on PUT, as on GET.""" _set_rls(org_a) contact = Contact.objects.create( first_name="ForbidUpdate", @@ -1065,7 +1064,7 @@ class TestContactDetailUpdateM2M: }, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND @patch("contacts.views.send_email_to_assigned_user.delay") def test_update_contact_invalid_data(self, mock_email, admin_client, contact_a): @@ -1207,7 +1206,7 @@ class TestContactDetailGetNonAdmin: def test_detail_non_admin_not_assigned_not_creator_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin non-assigned non-creator gets 403 (lines 380-388).""" + """Non-admin non-assigned non-creator gets 404, as for a missing id.""" _set_rls(org_a) contact = self._create_contact_with_creator( admin_user, @@ -1217,7 +1216,7 @@ class TestContactDetailGetNonAdmin: email="forbiddendetailc@example.com", ) response = user_client.get(_detail_url(contact.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.django_db @@ -1352,7 +1351,8 @@ class TestContactCommentPatchView: self, user_client, comment_fix, user_profile ): """Non-admin non-author gets 403 on PATCH comment (lines 735-739).""" - _, comment = comment_fix + record, comment = comment_fix + record.assigned_to.add(user_profile) # can open the contact response = user_client.patch( _comment_url(comment.id), {"comment": "Hacked patch"}, diff --git a/backend/contacts/tests/test_nested_contact_fields.py b/backend/contacts/tests/test_nested_contact_fields.py index 74a63bb8..527af1bc 100644 --- a/backend/contacts/tests/test_nested_contact_fields.py +++ b/backend/contacts/tests/test_nested_contact_fields.py @@ -68,7 +68,7 @@ class TestLinkedContactsOverTheApi: """A non-admin assignee of the parent, who cannot open the contact.""" def test_the_viewer_cannot_open_the_contact(self, user_client, person): - assert user_client.get(f"/api/contacts/{person.id}/").status_code == 403 + assert user_client.get(f"/api/contacts/{person.id}/").status_code == 404 def test_ticket_carries_name_and_email_only( self, user_client, org_a, user_profile, person diff --git a/backend/contacts/views.py b/backend/contacts/views.py index d52f5b30..e7c538e5 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -1,9 +1,7 @@ import json from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Count, Q, Sum -from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( extend_schema, @@ -17,6 +15,7 @@ from rest_framework.views import APIView from cases.access import visible_cases_qs from common.custom_fields import validate_payload as validate_custom_fields_payload +from common.lookups import get_on_visible_record_or_404 from common.models import ( Attachments, Comment, @@ -307,29 +306,22 @@ class ContactDetailView(APIView): model = Contact def get_object(self, pk): - try: - return get_object_or_404( - Contact.objects.select_related("account").prefetch_related( - "account_contacts", "assigned_to__user", "teams", "tags" - ), - pk=pk, - org=self.request.profile.org, - ) - except (DjangoValidationError, ValueError): - # The route matches , so anything at all can arrive here. - # UUIDField.to_python raises Django's ValidationError, which - # get_object_or_404 does not catch, so /api/contacts/banana/ was a - # 500 -- an error report for a URL somebody simply mistyped. - raise Http404("No such contact.") + """The contact, or 404 when it is missing or the caller may not open it. - def assert_contact_access(self, contact): - """Delegates to `contacts.access`, which holds the one definition. - - The attachment download view asks the same question. Four verbs once - carried four copies of this check and all four had the same dead - branch, which is the reason it lives in exactly one place now. + Looked up through the read rule (`contacts.access.visible_contacts_qs`) + in the same query, so a same-org contact the caller cannot open + answers every verb exactly as an id that does not exist. Fetching + org-wide and checking afterwards answered 403, which confirmed it. """ - access.assert_contact_access(self.request.profile, contact) + profile = self.request.profile + return get_object_or_404( + Contact.objects.select_related("account").prefetch_related( + "account_contacts", "assigned_to__user", "teams", "tags" + ), + pk=pk, + org=profile.org, + id__in=access.visible_contacts_qs(profile).values("id"), + ) @staticmethod def account_ids(contact): @@ -449,7 +441,6 @@ class ContactDetailView(APIView): def put(self, request, pk, format=None): data = request.data contact_obj = self.get_object(pk=pk) - self.assert_contact_access(contact_obj) contact_serializer = CreateContactSerializer( data=data, instance=contact_obj, request_obj=request @@ -546,7 +537,6 @@ class ContactDetailView(APIView): def get(self, request, pk, format=None): context = {} contact_obj = self.get_object(pk) - self.assert_contact_access(contact_obj) context["contact_obj"] = ContactSerializer(contact_obj).data assigned_data = [] for each in contact_obj.assigned_to.all(): @@ -659,13 +649,13 @@ class ContactDetailView(APIView): ) def delete(self, request, pk, format=None): self.object = self.get_object(pk) - # Deliberately narrower than `assert_contact_access`: an assignee may - # work on a contact, only an admin, a superuser or the person who - # entered it may destroy the record. The account and deal delete + # Deliberately narrower than reading: an assignee may work on a + # contact, only an admin, a superuser or the person who entered it may + # destroy the record. The caller can open it by now, so this refusal + # is an honest 403. The account and deal delete # rules let superusers through; this one alone refused them. if ( not is_org_admin(self.request.profile) - and not self.request.user.is_superuser and self.request.profile.user_id != self.object.created_by_id ): return Response( @@ -705,7 +695,6 @@ class ContactDetailView(APIView): # contact's name, email, phone and address back in the response. It # also raised DoesNotExist -- a 500 -- for an id that was merely gone. self.contact_obj = self.get_object(pk) - self.assert_contact_access(self.contact_obj) # Before the comment is saved, so a refused file does not leave it posted. validate_attachment(self.request.FILES.get("contact_attachment")) if params.get("comment"): @@ -786,7 +775,6 @@ class ContactDetailView(APIView): """Handle partial updates to a contact.""" data = request.data contact_obj = self.get_object(pk=pk) - self.assert_contact_access(contact_obj) contact_serializer = CreateContactSerializer( data=data, instance=contact_obj, request_obj=request, partial=True @@ -864,12 +852,13 @@ class ContactCommentView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - # `.get()` raised DoesNotExist for a comment somebody else had already - # deleted, which reached the client as a 500. - try: - return get_object_or_404(self.model, pk=pk, org=self.request.profile.org) - except (DjangoValidationError, ValueError): - raise Http404("No such comment.") + """404, like a missing id, for a comment on a contact the caller cannot open.""" + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + access.visible_contacts_qs(self.request.profile), + ) @extend_schema( tags=["contacts"], @@ -1008,10 +997,15 @@ class ContactAttachmentView(APIView): # And `request.profile == self.object.created_by` compares a Profile to # a User, so it was always False: the person who uploaded a file could # not delete it unless they were an admin. - try: - self.object = get_object_or_404(self.model, pk=pk, org=request.profile.org) - except (DjangoValidationError, ValueError): - raise Http404("No such attachment.") + # + # A file on a contact the caller cannot open is the same 404 as a + # missing id. + self.object = get_on_visible_record_or_404( + self.model, + pk, + request.profile.org, + access.visible_contacts_qs(request.profile), + ) if ( is_org_admin(request.profile) or request.profile.user_id == self.object.created_by_id diff --git a/backend/crm/settings.py b/backend/crm/settings.py index 7f692654..2d1876a9 100644 --- a/backend/crm/settings.py +++ b/backend/crm/settings.py @@ -355,6 +355,17 @@ REST_FRAMEWORK = { # "rest_framework.authentication.SessionAuthentication", # "rest_framework.authentication.BasicAuthentication", ), + # How many reverse proxies in front of Django append to X-Forwarded-For. + # `common.request_meta.client_ip` (every recorded IP and every per-IP + # throttle) believes only that many entries from the right. Unset means + # none: REMOTE_ADDR is used. Leave it unset when the app server already + # resolves the client from trusted proxies (uvicorn --proxy-headers + # --forwarded-allow-ips, as hosted production runs); counting the proxy + # again here would make every SvelteKit-relayed request the relay itself. + # Set it only when REMOTE_ADDR is the proxy (e.g. gunicorn behind nginx). + "NUM_PROXIES": int(os.environ["NUM_PROXIES"]) + if os.environ.get("NUM_PROXIES") + else None, "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "PAGE_SIZE": 10, "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", @@ -370,7 +381,7 @@ REST_FRAMEWORK = { # clicks through several articles and a crawler walks the sitemap. "help_center_ip": os.environ.get("HELP_CENTER_THROTTLE_IP", "600/hour"), # Public help center pages, per help center across all visitors. The - # per-visitor bucket trusts X-Forwarded-For; this one does not. + # backstop for a scraper spread across many addresses. "help_center_global": os.environ.get( "HELP_CENTER_THROTTLE_GLOBAL", "10000/hour" ), diff --git a/backend/invoices/admin.py b/backend/invoices/admin.py index dc1e4a7f..03a5bf8f 100644 --- a/backend/invoices/admin.py +++ b/backend/invoices/admin.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib import admin from invoices.models import ( @@ -12,9 +13,105 @@ from invoices.models import ( RecurringInvoice, RecurringInvoiceLineItem, ) +from invoices.serializer import issued_lock_message + +# The money on an issued invoice or estimate, read-only in the admin as it is +# in the API (`issued_lock_message`, owner decision 1.11.0). Status, dates, +# notes and payments stay editable. +INVOICE_AMOUNT_FIELDS = ( + "currency", + "subtotal", + "discount_type", + "discount_value", + "discount_amount", + "tax_rate", + "tax_amount", + "shipping_amount", + "total_amount", + "amount_paid", + "amount_due", +) +ESTIMATE_AMOUNT_FIELDS = ( + "currency", + "subtotal", + "discount_type", + "discount_value", + "discount_amount", + "tax_rate", + "tax_amount", + "total_amount", +) -class InvoiceLineItemInline(admin.TabularInline): +class IssuedAmountsReadOnlyMixin: + """A document admin whose amount fields lock once it leaves Draft.""" + + amount_fields = () + + def get_readonly_fields(self, request, obj=None): + readonly = tuple(super().get_readonly_fields(request, obj)) + if issued_lock_message(obj): + return readonly + self.amount_fields + return readonly + + +class IssuedLinesInlineMixin: + """A line inline that can be read, not added, edited or deleted, under an + issued document. ``obj`` is the parent document here.""" + + def has_add_permission(self, request, obj=None): + return not issued_lock_message(obj) and super().has_add_permission(request, obj) + + def has_change_permission(self, request, obj=None): + return not issued_lock_message(obj) and super().has_change_permission( + request, obj + ) + + def has_delete_permission(self, request, obj=None): + return not issued_lock_message(obj) and super().has_delete_permission( + request, obj + ) + + +class IssuedLineAdminMixin: + """The standalone line admin: a line of an issued document cannot be + changed or deleted, a new line cannot be put on one, and the bulk delete + action (which would skip the per-line check) is not offered.""" + + document_field = "" + + def _issued(self, line): + return line is not None and issued_lock_message( + getattr(line, self.document_field) + ) + + def has_change_permission(self, request, obj=None): + return not self._issued(obj) and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return not self._issued(obj) and super().has_delete_permission(request, obj) + + def get_actions(self, request): + actions = super().get_actions(request) + actions.pop("delete_selected", None) + return actions + + def get_form(self, request, obj=None, **kwargs): + base = super().get_form(request, obj, **kwargs) + document_field = self.document_field + + class Form(base): + def clean(self): + cleaned = super().clean() + message = issued_lock_message(cleaned.get(document_field)) + if message: + raise forms.ValidationError(message) + return cleaned + + return Form + + +class InvoiceLineItemInline(IssuedLinesInlineMixin, admin.TabularInline): model = InvoiceLineItem extra = 1 fields = ( @@ -38,7 +135,8 @@ class PaymentInline(admin.TabularInline): @admin.register(Invoice) -class InvoiceAdmin(admin.ModelAdmin): +class InvoiceAdmin(IssuedAmountsReadOnlyMixin, admin.ModelAdmin): + amount_fields = INVOICE_AMOUNT_FIELDS list_display = ( "invoice_number", "invoice_title", @@ -234,7 +332,8 @@ class ProductAdmin(admin.ModelAdmin): @admin.register(InvoiceLineItem) -class InvoiceLineItemAdmin(admin.ModelAdmin): +class InvoiceLineItemAdmin(IssuedLineAdminMixin, admin.ModelAdmin): + document_field = "invoice" list_display = ( "invoice", "description", @@ -285,7 +384,7 @@ class InvoiceTemplateAdmin(admin.ModelAdmin): # Estimate Admin -class EstimateLineItemInline(admin.TabularInline): +class EstimateLineItemInline(IssuedLinesInlineMixin, admin.TabularInline): model = EstimateLineItem extra = 1 fields = ( @@ -303,7 +402,8 @@ class EstimateLineItemInline(admin.TabularInline): @admin.register(Estimate) -class EstimateAdmin(admin.ModelAdmin): +class EstimateAdmin(IssuedAmountsReadOnlyMixin, admin.ModelAdmin): + amount_fields = ESTIMATE_AMOUNT_FIELDS list_display = ( "estimate_number", "title", @@ -337,7 +437,8 @@ class EstimateAdmin(admin.ModelAdmin): @admin.register(EstimateLineItem) -class EstimateLineItemAdmin(admin.ModelAdmin): +class EstimateLineItemAdmin(IssuedLineAdminMixin, admin.ModelAdmin): + document_field = "estimate" list_display = ( "estimate", "description", diff --git a/backend/invoices/api_views.py b/backend/invoices/api_views.py index ce18d56c..9210daa9 100644 --- a/backend/invoices/api_views.py +++ b/backend/invoices/api_views.py @@ -11,6 +11,7 @@ from django.db import transaction from django.db.models import Count, Q, Sum from django.http import HttpResponse from django.utils import timezone +from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.exceptions import ValidationError @@ -20,6 +21,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from common.custom_fields import validate_payload as validate_custom_fields_payload +from common.lookups import get_on_visible_record_or_404 from common.models import Attachments, Comment, CustomFieldDefinition from common.money import currency_block, group_by_currency from common.permissions import HasOrgContext, is_org_admin @@ -39,7 +41,6 @@ from invoices.models import ( InvoiceTemplate, Payment, Product, - RecurringInvoice, ) from invoices.pdf import ( generate_estimate_filename, @@ -51,7 +52,9 @@ from invoices.permissions import ( get_estimate_or_error, get_invoice_or_error, get_recurring_or_error, + visible_estimates_qs, visible_invoices_qs, + visible_recurring_qs, ) from invoices.serializer import ( EstimateCreateSerializer, @@ -73,6 +76,7 @@ from invoices.serializer import ( RecurringInvoiceCreateSerializer, RecurringInvoiceListSerializer, RecurringInvoiceSerializer, + issued_lock_message, validate_document_account, ) from invoices.tasks import create_invoice_history, send_email, send_invoice_to_client @@ -346,9 +350,27 @@ class InvoiceDetailView(APIView): @extend_schema(tags=["Invoices"], operation_id="invoices_update") def put(self, request, pk, format=None): - invoice, error = get_invoice_or_error(request, pk) + with transaction.atomic(): + response, invoice = self._update(request, pk) + if invoice is not None: + # After the commit, so the history task reads the saved row. + create_invoice_history.delay( + str(invoice.id), + str(request.profile.id), + [], + str(request.profile.org.id), + ) + return response + + def _update(self, request, pk): + """``(response, saved_invoice_or_None)``, run inside the transaction.""" + # Locked from the read to the save: the issued-document check sees the + # status this save writes back, and a send cannot land in between. + invoice, error = get_invoice_or_error( + request, pk, queryset=Invoice.objects.select_for_update() + ) if error: - return error + return error, None save_kwargs = {} if "custom_fields" in request.data: @@ -368,7 +390,7 @@ class InvoiceDetailView(APIView): return Response( {"error": True, "errors": {"custom_fields": cf_errors}}, status=status.HTTP_400_BAD_REQUEST, - ) + ), None save_kwargs["custom_fields"] = cleaned_cf serializer = InvoiceCreateSerializer( @@ -379,29 +401,20 @@ class InvoiceDetailView(APIView): partial=True, ) - if serializer.is_valid(): - invoice = serializer.save(**save_kwargs) - - # Create history entry - create_invoice_history.delay( - str(invoice.id), - str(request.profile.id), - [], - str(request.profile.org.id), - ) - + if not serializer.is_valid(): return Response( - { - "error": False, - "message": "Invoice updated successfully", - "invoice": InvoiceSerializer(invoice).data, - } - ) + {"error": True, "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ), None + invoice = serializer.save(**save_kwargs) return Response( - {"error": True, "errors": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST, - ) + { + "error": False, + "message": "Invoice updated successfully", + "invoice": InvoiceSerializer(invoice).data, + } + ), invoice class InvoiceSendView(APIView): @@ -411,27 +424,32 @@ class InvoiceSendView(APIView): @extend_schema(tags=["Invoices"], operation_id="invoices_send") def post(self, request, pk): - invoice, error = get_invoice_or_error(request, pk) - if error: - return error - - # A settled invoice must not be re-sent: doing so would overwrite - # sent_at/is_email_sent and mail the client about a closed invoice. - if invoice.status in ("Paid", "Cancelled"): - return Response( - { - "error": True, - "message": f"Cannot send a {invoice.status.lower()} invoice", - }, - status=status.HTTP_400_BAD_REQUEST, + with transaction.atomic(): + # Locked, so this full save cannot write back a copy read before + # an edit or payment committed. + invoice, error = get_invoice_or_error( + request, pk, queryset=Invoice.objects.select_for_update() ) + if error: + return error - # Update status and sent_at - if invoice.status == "Draft": - invoice.status = "Sent" - invoice.sent_at = timezone.now() - invoice.is_email_sent = True - invoice.save() + # A settled invoice must not be re-sent: doing so would overwrite + # sent_at/is_email_sent and mail the client about a closed invoice. + if invoice.status in ("Paid", "Cancelled"): + return Response( + { + "error": True, + "message": f"Cannot send a {invoice.status.lower()} invoice", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Update status and sent_at + if invoice.status == "Draft": + invoice.status = "Sent" + invoice.sent_at = timezone.now() + invoice.is_email_sent = True + invoice.save() # Send invoice email to client send_invoice_to_client.delay( @@ -640,6 +658,20 @@ class InvoicePDFView(APIView): # ============================================================================= +def _issued_lock_response(invoice): + """The 400 for a line change on an issued invoice, or None on a Draft. + + Checked after `get_invoice_or_error`, so a caller who cannot open the + invoice learns nothing about its status. + """ + message = issued_lock_message(invoice) + if message is None: + return None + return Response( + {"error": True, "message": message}, status=status.HTTP_400_BAD_REQUEST + ) + + class InvoiceLineItemListView(APIView): """Manage line items for an invoice""" @@ -655,10 +687,18 @@ class InvoiceLineItemListView(APIView): return Response(InvoiceLineItemSerializer(line_items, many=True).data) @extend_schema(tags=["Invoice Line Items"], operation_id="line_items_create") + @method_decorator(transaction.atomic) def post(self, request, invoice_id): - invoice, error = get_invoice_or_error(request, invoice_id) + # Locked from the read to the save: the issued-document check sees the + # status this save writes back, and a send cannot land in between. + invoice, error = get_invoice_or_error( + request, invoice_id, queryset=Invoice.objects.select_for_update() + ) if error: return error + locked = _issued_lock_response(invoice) + if locked: + return locked serializer = InvoiceLineItemCreateSerializer( data=request.data, request_obj=request @@ -696,10 +736,18 @@ class InvoiceLineItemDetailView(APIView): ).first() @extend_schema(tags=["Invoice Line Items"], operation_id="line_items_update") + @method_decorator(transaction.atomic) def put(self, request, invoice_id, pk): - _, error = get_invoice_or_error(request, invoice_id) + # Locked from the read to the save: the issued-document check sees the + # status this save writes back, and a send cannot land in between. + invoice, error = get_invoice_or_error( + request, invoice_id, queryset=Invoice.objects.select_for_update() + ) if error: return error + locked = _issued_lock_response(invoice) + if locked: + return locked line_item = self.get_object(invoice_id, pk) if not line_item: @@ -732,10 +780,18 @@ class InvoiceLineItemDetailView(APIView): ) @extend_schema(tags=["Invoice Line Items"], operation_id="line_items_destroy") + @method_decorator(transaction.atomic) def delete(self, request, invoice_id, pk): - _, error = get_invoice_or_error(request, invoice_id) + # Locked from the read to the save: the issued-document check sees the + # status this save writes back, and a send cannot land in between. + invoice, error = get_invoice_or_error( + request, invoice_id, queryset=Invoice.objects.select_for_update() + ) if error: return error + locked = _issued_lock_response(invoice) + if locked: + return locked line_item = self.get_object(invoice_id, pk) if not line_item: @@ -744,7 +800,6 @@ class InvoiceLineItemDetailView(APIView): status=status.HTTP_404_NOT_FOUND, ) - invoice = line_item.invoice line_item.delete() # Recalculate invoice totals @@ -891,7 +946,7 @@ class ProductListView(APIView, LimitOffsetPagination): # need it to build line items), but changing it is an admin act, the # same posture Organization and Team settings take. A non-admin who # curls this endpoint is refused here, not just hidden from in the UI. - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( { "error": True, @@ -940,7 +995,7 @@ class ProductDetailView(APIView): def _require_admin(self, request): """Editing the shared catalog is admin-only; see ProductListView.post.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( { "error": True, @@ -1012,26 +1067,13 @@ class EstimateListView(APIView, LimitOffsetPagination): permission_classes = (IsAuthenticated, HasOrgContext) def get_queryset(self): - org = self.request.profile.org - - queryset = Estimate.objects.filter(org=org).select_related( + """Every estimate the caller may open, before the query string.""" + return visible_estimates_qs( + self.request.profile, self.request.user + ).select_related( "account", "contact", "opportunity", "created_by", "converted_to_invoice" ) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - # created_by is a User FK; comparing it to a Profile does not just - # silently fail here, it reaches the DB as Q(created_by=) - # and raised ValueError -- a 500 on every non-admin estimate list. - queryset = queryset.filter( - Q(created_by=self.request.profile.user) - | Q(assigned_to=self.request.profile) - ).distinct() - - return queryset - @extend_schema(tags=["Estimates"], operation_id="estimates_list") def get(self, request): queryset = self.get_queryset() @@ -1040,8 +1082,11 @@ class EstimateListView(APIView, LimitOffsetPagination): params = request.query_params if params.get("status"): queryset = queryset.filter(status=params.get("status")) - if params.get("account"): - queryset = queryset.filter(account_id=params.get("account")) + # Parsed first, as the invoice list does: raw text reached the UUID + # lookup and a malformed id answered 500. + account_id = uuid_param(params, "account") + if account_id: + queryset = queryset.filter(account_id=account_id) if params.get("search"): search = params.get("search") queryset = queryset.filter( @@ -1133,8 +1178,13 @@ class EstimateDetailView(APIView): ) @extend_schema(tags=["Estimates"], operation_id="estimates_update") + @method_decorator(transaction.atomic) def put(self, request, pk): - estimate, error = get_estimate_or_error(request, pk) + # Locked from the read to the save: the issued-document check sees the + # status this save writes back, and a send cannot land in between. + estimate, error = get_estimate_or_error( + request, pk, queryset=Estimate.objects.select_for_update() + ) if error: return error @@ -1200,8 +1250,13 @@ class EstimateConvertView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) @extend_schema(tags=["Estimates"], operation_id="estimates_convert") + @method_decorator(transaction.atomic) def post(self, request, pk): - estimate, error = get_estimate_or_error(request, pk) + # Locked, so two converts at once cannot both pass the check below and + # raise two invoices, and the save cannot write back a stale copy. + estimate, error = get_estimate_or_error( + request, pk, queryset=Estimate.objects.select_for_update() + ) if error: return error @@ -1281,7 +1336,25 @@ class EstimateSendView(APIView): @extend_schema(tags=["Estimates"], operation_id="estimates_send") def post(self, request, pk): - estimate, error = get_estimate_or_error(request, pk) + with transaction.atomic(): + refused = self._mark_sent(request, pk) + if refused is not None: + return refused + + # Trigger async email task, after the commit so it reads the saved row + from invoices.tasks import send_estimate_to_client + + send_estimate_to_client.delay(str(pk), str(request.profile.org.id)) + + return Response({"error": False, "message": "Estimate sent successfully"}) + + def _mark_sent(self, request, pk): + """Mark it sent and return None, or return the refusal. Runs inside the + transaction, on a locked row, so this full save cannot write back a + copy read before an edit committed.""" + estimate, error = get_estimate_or_error( + request, pk, queryset=Estimate.objects.select_for_update() + ) if error: return error @@ -1323,13 +1396,7 @@ class EstimateSendView(APIView): estimate.status = "Sent" estimate.sent_at = timezone.now() estimate.save() - - # Trigger async email task - from invoices.tasks import send_estimate_to_client - - send_estimate_to_client.delay(str(estimate.id), str(request.profile.org.id)) - - return Response({"error": False, "message": "Estimate sent successfully"}) + return None class EstimatePDFView(APIView): @@ -1380,21 +1447,14 @@ class RecurringInvoiceListView(APIView, LimitOffsetPagination): @extend_schema(tags=["Recurring Invoices"], operation_id="recurring_list") def get(self, request): + # Non-admins see only schedules they created or are assigned to, the + # same rule as the invoice and estimate lists. queryset = ( - RecurringInvoice.objects.filter(org=request.profile.org) + visible_recurring_qs(request.profile, request.user) .select_related("account", "contact") .order_by("-created_at") ) - # Non-admins see only schedules they created or are assigned to -- the - # same scoping as the invoice and estimate lists. created_by is a User - # FK, so it is matched against request.profile.user; assigned_to holds - # Profiles, matched against request.profile. - if not is_org_admin(request.profile) and not request.user.is_superuser: - queryset = queryset.filter( - Q(created_by=request.profile.user) | Q(assigned_to=request.profile) - ).distinct() - # Apply filters params = request.query_params if params.get("is_active"): @@ -1589,7 +1649,7 @@ def _forbid_non_admin_template( reason, so it is the same gate rather than a second copy of the role check that could drift away from this one. """ - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "message": message}, status=status.HTTP_403_FORBIDDEN, @@ -1802,32 +1862,50 @@ class InvoiceCommentView(APIView): class InvoiceCommentDetailView(APIView): - """Update or delete a comment""" + """Update or delete a comment on an invoice. + + The comment is found through its invoice: only a comment on an invoice, + in the caller's org, on an invoice the caller may open. Anything else (a + missing id, another module's comment, a comment on an invoice this + caller cannot open) is the same 404, so the answer says nothing about + which ids exist. Among callers who can read the invoice, only the + comment's author or an admin may change it (403). + """ permission_classes = (IsAuthenticated, HasOrgContext) - def get_object(self, pk): - return Comment.objects.filter(id=pk, org=self.request.profile.org).first() - - @extend_schema(tags=["Invoice Comments"], operation_id="comments_update") - def put(self, request, pk): - comment = self.get_object(pk) - if not comment: - return Response( - {"error": True, "message": "Comment not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - - # Only comment author or admin can edit - if comment.commented_by != request.profile and not is_org_admin( + def _resolve(self, request, pk): + """``(comment, None)``, or ``(None, response)`` with the 403. A comment + the caller may not reach raises the same 404 as a missing id.""" + comment = get_on_visible_record_or_404( + Comment, + pk, + request.profile.org, + visible_invoices_qs(request.profile, request.user), + ) + if comment.commented_by_id != request.profile.id and not is_org_admin( request.profile ): - return Response( + return None, Response( {"error": True, "message": "Permission denied"}, status=status.HTTP_403_FORBIDDEN, ) + return comment, None - comment.comment = request.data.get("comment", comment.comment) + @extend_schema(tags=["Invoice Comments"], operation_id="comments_update") + def put(self, request, pk): + comment, error = self._resolve(request, pk) + if error: + return error + + # The create path refuses an empty comment; an edit may not make one. + text = request.data.get("comment", comment.comment) + if not text: + return Response( + {"error": True, "message": "Comment text required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + comment.comment = text comment.save() return Response( @@ -1840,20 +1918,9 @@ class InvoiceCommentDetailView(APIView): @extend_schema(tags=["Invoice Comments"], operation_id="comments_destroy") def delete(self, request, pk): - comment = self.get_object(pk) - if not comment: - return Response( - {"error": True, "message": "Comment not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - - if comment.commented_by != request.profile and not is_org_admin( - request.profile - ): - return Response( - {"error": True, "message": "Permission denied"}, - status=status.HTTP_403_FORBIDDEN, - ) + comment, error = self._resolve(request, pk) + if error: + return error comment.delete() return Response( @@ -1894,18 +1961,26 @@ class InvoiceAttachmentView(APIView): class InvoiceAttachmentDetailView(APIView): - """Delete an attachment""" + """Delete an attachment on an invoice. + + Found through its invoice, as the comment view above is: an attachment on + another module's record, in another org, or on an invoice this caller + cannot open raises the same 404 as a missing id. It used to be looked up + org-wide across every module, so an uploader who had lost access to a + lead could still delete that lead's file through this route, and anyone + else got a 403 confirming the id existed. + """ permission_classes = (IsAuthenticated, HasOrgContext) @extend_schema(tags=["Invoice Attachments"], operation_id="attachments_destroy") def delete(self, request, pk): - attachment = Attachments.objects.filter(id=pk, org=request.profile.org).first() - if not attachment: - return Response( - {"error": True, "message": "Attachment not found"}, - status=status.HTTP_404_NOT_FOUND, - ) + attachment = get_on_visible_record_or_404( + Attachments, + pk, + request.profile.org, + visible_invoices_qs(request.profile, request.user), + ) # created_by is a User FK, so it must be compared against request.user -- # comparing it to request.profile is always unequal and locks the @@ -1942,7 +2017,7 @@ def _forbid_non_admin_reports(request): admin-only (Django superusers included), the same bar as the org and team settings. """ - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( { "error": True, @@ -2485,7 +2560,7 @@ class InvoiceFromTimeEntriesView(APIView): build_invoice_lines_from_entries, ) - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( { "error": True, diff --git a/backend/invoices/migrations/0012_tax_rate_and_shipping_validators.py b/backend/invoices/migrations/0012_tax_rate_and_shipping_validators.py new file mode 100644 index 00000000..4bc8d3d9 --- /dev/null +++ b/backend/invoices/migrations/0012_tax_rate_and_shipping_validators.py @@ -0,0 +1,91 @@ +# Generated by Django 6.1.1 on 2026-09-26 14:51 + +from django.db import migrations, models + +import invoices.models + + +class Migration(migrations.Migration): + dependencies = [ + ("invoices", "0011_document_number_unique_per_org"), + ] + + operations = [ + migrations.AlterField( + model_name="estimate", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + migrations.AlterField( + model_name="estimatelineitem", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + migrations.AlterField( + model_name="invoice", + name="shipping_amount", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[invoices.models.validate_shipping_amount], + verbose_name="Shipping", + ), + ), + migrations.AlterField( + model_name="invoice", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + migrations.AlterField( + model_name="invoicelineitem", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + migrations.AlterField( + model_name="recurringinvoice", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + migrations.AlterField( + model_name="recurringinvoicelineitem", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + validators=[invoices.models.validate_tax_rate], + verbose_name="Tax Rate (%)", + ), + ), + ] diff --git a/backend/invoices/models.py b/backend/invoices/models.py index 34ffb153..a066ecf0 100644 --- a/backend/invoices/models.py +++ b/backend/invoices/models.py @@ -2,6 +2,7 @@ import datetime import secrets from decimal import ROUND_HALF_UP, Decimal +from django.core.exceptions import ValidationError from django.db import connection, models, transaction from django.db.models import IntegerField, Max from django.db.models.functions import Cast, Substr @@ -79,6 +80,27 @@ DISCOUNT_TYPES = ( CENT = Decimal("0.01") +def validate_tax_rate(value): + """A tax rate is a percentage, 0 to 100. A negative one takes tax off the + bill, and the column holds up to 999.99, so both ends need saying. + + On the model field so the Django admin enforces it as well as the API: + DRF runs a model field's validators on the serializer field it builds. + A plain function rather than `MinValueValidator`/`MaxValueValidator`, + which DRF swaps for its own bounds and generic messages. + """ + if value < 0: + raise ValidationError("Tax rate cannot be negative.") + if value > 100: + raise ValidationError("Tax rate cannot exceed 100.") + + +def validate_shipping_amount(value): + """Shipping is never negative: a negative one would lower the bill.""" + if value < 0: + raise ValidationError("Shipping cannot be negative.") + + def line_gross(quantity, unit_price): """Quantity x unit price, to the cent.""" return (Decimal(quantity) * Decimal(unit_price)).quantize(CENT, ROUND_HALF_UP) @@ -122,7 +144,8 @@ def totals_follow_lines(document, update_fields): portal's first view, a send, the overdue and expiry sweeps and a reminder all full-save one, and none of them may move what the customer was billed. An explicit edit through the API (the document serializers and - the line-item views) calls ``recalculate_totals()`` itself. A save + the line-item views) calls ``recalculate_totals()`` itself, and is + refused on an issued document (``serializer.issued_lock_message``). A save limited to ``update_fields`` never writes the totals, so recomputing them there only let a payment write an ``amount_due`` from a new total beside the old stored ``total_amount``. @@ -360,13 +383,21 @@ class Invoice(AssignableMixin, BaseModel): _("Discount Amount"), max_digits=12, decimal_places=2, default=0 ) tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) tax_amount = models.DecimalField( _("Tax Amount"), max_digits=12, decimal_places=2, default=0 ) shipping_amount = models.DecimalField( - _("Shipping"), max_digits=12, decimal_places=2, default=0 + _("Shipping"), + max_digits=12, + decimal_places=2, + default=0, + validators=[validate_shipping_amount], ) total_amount = models.DecimalField( _("Total Amount"), max_digits=12, decimal_places=2, default=0 @@ -625,7 +656,11 @@ class InvoiceLineItem(LineAmounts, BaseModel): # Per-item tax tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) tax_amount = models.DecimalField( _("Tax Amount"), max_digits=12, decimal_places=2, default=0 @@ -842,7 +877,11 @@ class Estimate(AssignableMixin, BaseModel): _("Discount Amount"), max_digits=12, decimal_places=2, default=0 ) tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) tax_amount = models.DecimalField( _("Tax Amount"), max_digits=12, decimal_places=2, default=0 @@ -1023,7 +1062,11 @@ class EstimateLineItem(LineAmounts, BaseModel): ) tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) tax_amount = models.DecimalField( _("Tax Amount"), max_digits=12, decimal_places=2, default=0 @@ -1140,7 +1183,11 @@ class RecurringInvoice(AssignableMixin, BaseModel): _("Discount Value"), max_digits=12, decimal_places=2, default=0 ) tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) total_amount = models.DecimalField( _("Total Amount"), max_digits=12, decimal_places=2, default=0 @@ -1239,7 +1286,11 @@ class RecurringInvoiceLineItem(LineAmounts, BaseModel): _("Discount Value"), max_digits=12, decimal_places=2, default=0 ) tax_rate = models.DecimalField( - _("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0 + _("Tax Rate (%)"), + max_digits=5, + decimal_places=2, + default=0, + validators=[validate_tax_rate], ) order = models.PositiveIntegerField(_("Order"), default=0) diff --git a/backend/invoices/permissions.py b/backend/invoices/permissions.py index 6db2f7a7..64ccc36c 100644 --- a/backend/invoices/permissions.py +++ b/backend/invoices/permissions.py @@ -2,11 +2,14 @@ Centralised object-level authorization for invoice- and estimate-scoped endpoints. -Every such endpoint must answer the same two questions in the same order: +Every such endpoint must answer the same two questions: -1. Does this record exist *in the caller's org*? -> 404 if not, so that a - caller in another org cannot probe for record IDs. -2. May this caller act on it? -> 403 if not. +1. Does this record exist *in the caller's org*? +2. May this caller open it? + +A no to either is the same 404 with the same body (owner decision, 1.11.0). +A 403 for the second would tell a member that a record they cannot open +exists, which is an oracle for probing ids. Before this module each view hand-rolled that logic, and several (``send``, ``mark-paid``, ``duplicate``, line items, payments, comments, @@ -28,7 +31,6 @@ from invoices.models import Estimate, Invoice, RecurringInvoice INVOICE_NOT_FOUND = "Invoice not found" ESTIMATE_NOT_FOUND = "Estimate not found" RECURRING_NOT_FOUND = "Recurring invoice not found" -PERMISSION_DENIED = "Permission denied" def has_object_access(request, obj): @@ -60,19 +62,38 @@ def has_object_access(request, obj): return obj.assigned_to.filter(id=profile.id).exists() -def visible_invoices_qs(profile, user): - """Invoices ``profile`` may open, the queryset form of `has_object_access`.""" - qs = Invoice.objects.filter(org=profile.org) +def _visible_qs(model, profile, user): + """Records of ``model`` that ``profile`` may open: the queryset form of + `has_object_access`, for the three documents that share its ownership + shape. The lists and the CSV export call this rather than restating it.""" + qs = model.objects.filter(org=profile.org) if is_org_admin(profile) or user.is_superuser: return qs return qs.filter(Q(created_by=profile.user) | Q(assigned_to=profile)).distinct() +def visible_invoices_qs(profile, user): + """Invoices ``profile`` may open.""" + return _visible_qs(Invoice, profile, user) + + +def visible_estimates_qs(profile, user): + """Estimates ``profile`` may open.""" + return _visible_qs(Estimate, profile, user) + + +def visible_recurring_qs(profile, user): + """Recurring invoices ``profile`` may open.""" + return _visible_qs(RecurringInvoice, profile, user) + + def _get_or_error(request, model, pk, not_found, queryset=None): """Fetch an org-scoped record and authorize the caller against it. Returns ``(obj, None)`` on success, or ``(None, response)`` holding the - 404/403 the view should return. Callers must check the error first:: + 404 the view should return. A record in another org, one this caller + cannot open and a missing id all get that identical 404, so the answer + does not say which. Callers must check the error first:: obj, error = _get_or_error(request, Invoice, pk, INVOICE_NOT_FOUND) if error: @@ -81,18 +102,12 @@ def _get_or_error(request, model, pk, not_found, queryset=None): qs = model.objects.all() if queryset is None else queryset obj = qs.filter(id=pk, org=request.profile.org).first() - if not obj: + if obj is None or not has_object_access(request, obj): return None, Response( {"error": True, "message": not_found}, status=status.HTTP_404_NOT_FOUND, ) - if not has_object_access(request, obj): - return None, Response( - {"error": True, "message": PERMISSION_DENIED}, - status=status.HTTP_403_FORBIDDEN, - ) - return obj, None diff --git a/backend/invoices/public_views.py b/backend/invoices/public_views.py index b50e6d1f..945e8239 100644 --- a/backend/invoices/public_views.py +++ b/backend/invoices/public_views.py @@ -15,6 +15,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from common.portal_tokens import resolve_portal_org +from common.request_meta import client_ip from common.tasks import set_rls_context from invoices.models import Estimate, Invoice, InvoiceTemplate from invoices.pdf import ( @@ -46,18 +47,6 @@ def _resolve_org_context(token, resource_type): return org_id -def _client_ip(request): - """Best-effort client IP for the acceptance record. - - ``X-Forwarded-For`` is client-spoofable, so this is evidence, not proof; - the leftmost hop is recorded when a proxy set the header, else REMOTE_ADDR. - """ - forwarded = request.META.get("HTTP_X_FORWARDED_FOR") - if forwarded: - return forwarded.split(",")[0].strip() - return request.META.get("REMOTE_ADDR") - - class PublicInvoiceView(APIView): """ Public view for invoice - accessible via public token. @@ -425,7 +414,7 @@ class PublicEstimateAcceptView(APIView): estimate.accepted_at = timezone.now() estimate.accepted_by_name = name[:255] estimate.accepted_by_email = email[:254] - estimate.accepted_ip = _client_ip(request) + estimate.accepted_ip = client_ip(request) estimate.accepted_user_agent = (request.META.get("HTTP_USER_AGENT") or "")[ :1024 ] diff --git a/backend/invoices/serializer.py b/backend/invoices/serializer.py index 56d34946..435252cb 100644 --- a/backend/invoices/serializer.py +++ b/backend/invoices/serializer.py @@ -333,12 +333,14 @@ class InvoiceLineItemSerializer(serializers.ModelSerializer): class LineAmountsMixin: - """The two numbers on a written line that both clients already constrain. + """The numbers on a written line that both clients already constrain. The web builder and the mobile line sheet refuse a quantity of zero or less, and neither offers a negative price, but the API took both, so a direct call could write a line that silently reduces the bill. Shared by - the three line-item create serializers below. + the three line-item create serializers below and the deal line + serializer. Tax rate and shipping bounds live on the model fields + (`invoices.models.validate_tax_rate`), which DRF runs here too. """ def validate_quantity(self, value): @@ -492,14 +494,61 @@ def validate_line_item_products(line_items, org): ) -# The document fields its totals are computed from, besides its lines. An edit -# that sends none of them and no lines leaves an issued document's stored -# totals alone. +# The document fields its totals are computed from, besides its lines. Only an +# edit that sends one of them or the lines recomputes the totals, and on an +# issued document `refuse_issued_amount_changes` lets neither through. TOTALS_INPUTS = frozenset( ("discount_type", "discount_value", "tax_rate", "shipping_amount") ) +# What an issued invoice or estimate can no longer change, besides its lines: +# everything its totals are computed from, and the currency they are in. +LOCKED_WHEN_ISSUED = TOTALS_INPUTS | {"currency"} + + +def issued_lock_message(document): + """Why ``document``'s lines and amounts cannot change, or None while it is + a Draft. + + The one rule for issued invoices and estimates (owner decision, 1.11.0): + once a document leaves Draft the customer has been shown its figures, so + its lines, discounts, tax, shipping, currency and totals are fixed. Its + status, payments, notes, dates and other fields stay editable. Called by + `refuse_issued_amount_changes` for the document serializers and by the + invoice line-item views. + """ + if document is None or document.status == "Draft": + return None + kind = document._meta.verbose_name.lower() + return ( + f"This {kind} is {document.get_status_display()}. Its lines and " + "amounts can only be changed while it is a Draft." + ) + + +def refuse_issued_amount_changes(attrs, instance): + """Refuse an edit to an issued document's lines or amounts. + + ``line_items`` sent at all is a change, since it replaces every line. A + locked field sent with the value already stored is not a change, so it is + dropped from ``attrs`` rather than refused: a client that sends the + document back to edit its notes is not turned away, and the update does + not recompute the totals the document was issued with. + """ + message = issued_lock_message(instance) + if message is None: + return + changed = ["line_items"] if "line_items" in attrs else [] + for name in LOCKED_WHEN_ISSUED & attrs.keys(): + if attrs[name] == getattr(instance, name): + del attrs[name] + else: + changed.append(name) + if changed: + raise serializers.ValidationError({name: message for name in sorted(changed)}) + + def validate_document_discount(attrs, instance): """A document's own discount: not negative, not over 100%, and a flat one not more than the subtotal it comes off. @@ -958,6 +1007,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): def validate(self, attrs): """Cross-field validation""" + refuse_issued_amount_changes(attrs, self.instance) account_id = attrs.get("account_id") contact_id = attrs.get("contact_id") @@ -1250,6 +1300,7 @@ class EstimateCreateSerializer(serializers.ModelSerializer): def validate(self, attrs): """Cross-field validation""" + refuse_issued_amount_changes(attrs, self.instance) account_id = attrs.get("account_id") contact_id = attrs.get("contact_id") @@ -1512,6 +1563,25 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer): } ) + # Lines are optional on a schedule, but not one that mails each + # invoice it raises: that would send the client a blank 0.00 invoice. + # Checked only when the request touches either side, so an older row + # with both can still have its other fields edited. + if {"auto_send", "line_items"} & attrs.keys(): + auto_send = attrs.get( + "auto_send", getattr(self.instance, "auto_send", False) + ) + if "line_items" in attrs: + has_lines = bool(attrs["line_items"]) + else: + has_lines = ( + self.instance is not None and self.instance.line_items.exists() + ) + if auto_send and not has_lines: + raise serializers.ValidationError( + {"auto_send": "Add at least one line before turning on auto-send."} + ) + validate_line_item_products(attrs.get("line_items"), self.org) validate_document_discount(attrs, self.instance) return attrs diff --git a/backend/invoices/tasks.py b/backend/invoices/tasks.py index 48a33c9a..c0d83ba9 100644 --- a/backend/invoices/tasks.py +++ b/backend/invoices/tasks.py @@ -262,10 +262,23 @@ def generate_recurring_invoices(): continue try: + lines = list(recurring.line_items.all()) + # The serializers refuse auto-send on a schedule with no + # lines, but a row saved before that rule could still have + # both. Mailing the client a blank 0.00 invoice is worse + # than a draft nobody sent, so it is raised as a draft. + auto_send = recurring.auto_send and bool(lines) + if recurring.auto_send and not lines: + logger.warning( + "Recurring invoice %s has auto-send on and no lines; " + "raising a draft instead of sending it", + recurring.id, + ) + # Create new invoice invoice = Invoice.objects.create( invoice_title=recurring.title, - status="Draft" if not recurring.auto_send else "Sent", + status="Sent" if auto_send else "Draft", account=recurring.account, contact=recurring.contact, client_name=recurring.client_name, @@ -282,7 +295,7 @@ def generate_recurring_invoices(): ) # Copy line items - for item in recurring.line_items.all(): + for item in lines: InvoiceLineItem.objects.create( invoice=invoice, product=item.product, @@ -311,7 +324,7 @@ def generate_recurring_invoices(): ) # Auto-send if enabled - if recurring.auto_send: + if auto_send: send_invoice_to_client.delay( str(invoice.id), str(recurring.org.id), diff --git a/backend/invoices/tests/test_admin_issued_lock.py b/backend/invoices/tests/test_admin_issued_lock.py new file mode 100644 index 00000000..86fe4abc --- /dev/null +++ b/backend/invoices/tests/test_admin_issued_lock.py @@ -0,0 +1,139 @@ +"""The Django admin holds the issued-document lock too. + +On a non-Draft invoice or estimate the amount fields are read-only, the line +inlines cannot add, change or delete, and the standalone line admins refuse +to change, delete or add a line on one. A Draft keeps every one of those. +""" + +import pytest +from django.contrib.admin.sites import site +from django.test import RequestFactory + +from accounts.models import Account +from common.models import User +from invoices.admin import ( + ESTIMATE_AMOUNT_FIELDS, + INVOICE_AMOUNT_FIELDS, + EstimateLineItemInline, + InvoiceLineItemInline, +) +from invoices.models import Estimate, EstimateLineItem, Invoice, InvoiceLineItem + +LOCKED = "Its lines and amounts can only be changed while it is a Draft." + + +@pytest.fixture +def request_(db): + request = RequestFactory().get("/admin/") + request.user = User.objects.create_superuser( + email="staff@test.com", password="pw-not-used" + ) + return request + + +def _invoice(org, status): + account = Account.objects.create(name=f"Acme {status}", org=org) + invoice = Invoice.objects.create(org=org, account=account, invoice_title="Work") + line = InvoiceLineItem.objects.create( + invoice=invoice, org=org, name="Design", quantity=1, unit_price=100 + ) + Invoice.objects.filter(pk=invoice.pk).update(status=status) + invoice.refresh_from_db() + return invoice, line + + +def _estimate(org, status): + account = Account.objects.create(name=f"Acme {status}", org=org) + estimate = Estimate.objects.create(org=org, account=account, title="Quote") + line = EstimateLineItem.objects.create( + estimate=estimate, org=org, name="Design", quantity=1, unit_price=100 + ) + Estimate.objects.filter(pk=estimate.pk).update(status=status) + estimate.refresh_from_db() + return estimate, line + + +CASES = [ + (Invoice, _invoice, InvoiceLineItemInline, INVOICE_AMOUNT_FIELDS, "Paid"), + (Invoice, _invoice, InvoiceLineItemInline, INVOICE_AMOUNT_FIELDS, "Sent"), + (Estimate, _estimate, EstimateLineItemInline, ESTIMATE_AMOUNT_FIELDS, "Sent"), + (Estimate, _estimate, EstimateLineItemInline, ESTIMATE_AMOUNT_FIELDS, "Accepted"), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("model, make, inline, amounts, status", CASES) +def test_issued_document_is_locked_in_the_admin( + request_, org_a, model, make, inline, amounts, status +): + document, line = make(org_a, status) + document_admin = site._registry[model] + line_admin = site._registry[type(line)] + inline_admin = inline(model, site) + + assert set(amounts) <= set(document_admin.get_readonly_fields(request_, document)) + assert "notes" not in document_admin.get_readonly_fields(request_, document) + assert not inline_admin.has_add_permission(request_, document) + assert not inline_admin.has_change_permission(request_, document) + assert not inline_admin.has_delete_permission(request_, document) + assert not line_admin.has_change_permission(request_, line) + assert not line_admin.has_delete_permission(request_, line) + assert "delete_selected" not in line_admin.get_actions(request_) + + +@pytest.mark.django_db +@pytest.mark.parametrize("model, make, inline, amounts, status", CASES) +def test_draft_document_stays_editable_in_the_admin( + request_, org_a, model, make, inline, amounts, status +): + document, line = make(org_a, "Draft") + document_admin = site._registry[model] + line_admin = site._registry[type(line)] + inline_admin = inline(model, site) + + assert not set(amounts) & set( + document_admin.get_readonly_fields(request_, document) + ) + assert inline_admin.has_add_permission(request_, document) + assert inline_admin.has_change_permission(request_, document) + assert inline_admin.has_delete_permission(request_, document) + assert line_admin.has_change_permission(request_, line) + assert line_admin.has_delete_permission(request_, line) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "make, field", [(_invoice, "invoice"), (_estimate, "estimate")] +) +def test_standalone_line_admin_refuses_a_new_line_on_an_issued_document( + request_, org_a, make, field +): + issued, line = make(org_a, "Sent") + draft, _ = make(org_a, "Draft") + line_admin = site._registry[type(line)] + Form = line_admin.get_form(request_, None) + + def submit(document): + return Form( + data={ + field: document.pk, + "org": org_a.pk, + "name": "Extra", + "quantity": "1", + "unit_price": "10", + "discount_value": "0", + "discount_amount": "0", + "tax_rate": "0", + "tax_amount": "0", + "subtotal": "0", + "total": "0", + "order": "0", + } + ) + + refused = submit(issued) + assert not refused.is_valid() + assert LOCKED in str(refused.errors) + + allowed = submit(draft) + assert allowed.is_valid(), allowed.errors diff --git a/backend/invoices/tests/test_empty_schedule_auto_send.py b/backend/invoices/tests/test_empty_schedule_auto_send.py new file mode 100644 index 00000000..4d8674dc --- /dev/null +++ b/backend/invoices/tests/test_empty_schedule_auto_send.py @@ -0,0 +1,164 @@ +"""A schedule that mails its invoices needs something to bill (1.11.0). + +Lines stay optional on a schedule, but auto-send with no lines is refused on +create and update, and generation never mails an invoice with no lines from +a row saved before that rule: it raises a draft and logs why. +""" + +import logging +from unittest.mock import patch + +import pytest +from django.utils import timezone + +from accounts.models import Account +from contacts.models import Contact +from invoices.models import Invoice, RecurringInvoice, RecurringInvoiceLineItem +from invoices.tasks import generate_recurring_invoices + +URL = "/api/invoices/recurring/" +MESSAGE = "Add at least one line before turning on auto-send." +LINE = {"name": "Hosting", "quantity": "1", "unit_price": "50"} + + +@pytest.fixture +def account(org_a): + return Account.objects.create(name="Acme", org=org_a) + + +@pytest.fixture +def contact(org_a): + return Contact.objects.create(first_name="Ada", last_name="Buyer", org=org_a) + + +def _body(account, contact, **extra): + today = str(timezone.localdate()) + return { + "title": "Monthly", + "account_id": str(account.id), + "contact_id": str(contact.id), + "frequency": "MONTHLY", + "start_date": today, + "next_generation_date": today, + **extra, + } + + +@pytest.mark.django_db +class TestCreate: + def test_auto_send_with_no_lines_is_refused(self, admin_client, account, contact): + response = admin_client.post( + URL, _body(account, contact, auto_send=True), format="json" + ) + + assert response.status_code == 400, response.content + assert response.json()["errors"]["auto_send"] == [MESSAGE] + assert not RecurringInvoice.objects.exists() + + def test_auto_send_with_a_line_is_allowed(self, admin_client, account, contact): + response = admin_client.post( + URL, + _body(account, contact, auto_send=True, line_items=[LINE]), + format="json", + ) + + assert response.status_code == 201, response.content + + def test_no_lines_without_auto_send_is_allowed( + self, admin_client, account, contact + ): + response = admin_client.post(URL, _body(account, contact), format="json") + + assert response.status_code == 201, response.content + + +@pytest.mark.django_db +class TestUpdate: + def _create(self, client, account, contact, **extra): + client.post(URL, _body(account, contact, **extra), format="json") + return RecurringInvoice.objects.get() + + def test_turning_auto_send_on_with_no_lines_is_refused( + self, admin_client, account, contact + ): + schedule = self._create(admin_client, account, contact) + + response = admin_client.put( + f"{URL}{schedule.id}/", {"auto_send": True}, format="json" + ) + + assert response.status_code == 400, response.content + assert response.json()["errors"]["auto_send"] == [MESSAGE] + schedule.refresh_from_db() + assert schedule.auto_send is False + + def test_removing_every_line_while_auto_send_is_on_is_refused( + self, admin_client, account, contact + ): + schedule = self._create( + admin_client, account, contact, auto_send=True, line_items=[LINE] + ) + + response = admin_client.put( + f"{URL}{schedule.id}/", {"line_items": []}, format="json" + ) + + assert response.status_code == 400, response.content + assert schedule.line_items.count() == 1 + + def test_an_older_row_can_still_have_other_fields_edited( + self, admin_client, account, contact + ): + schedule = self._create(admin_client, account, contact) + RecurringInvoice.objects.filter(pk=schedule.pk).update(auto_send=True) + + response = admin_client.put( + f"{URL}{schedule.id}/", {"title": "Renamed"}, format="json" + ) + + assert response.status_code == 200, response.content + + +@pytest.mark.django_db +class TestGeneration: + def _schedule(self, org, account, *, lines): + today = timezone.localdate() + schedule = RecurringInvoice.objects.create( + org=org, + account=account, + title="Monthly", + start_date=today, + next_generation_date=today, + auto_send=True, + client_email="buyer@example.com", + ) + if lines: + RecurringInvoiceLineItem.objects.create( + recurring_invoice=schedule, org=org, **LINE + ) + return schedule + + def test_no_lines_is_raised_as_a_draft_and_not_sent(self, org_a, account, caplog): + schedule = self._schedule(org_a, account, lines=False) + + with ( + patch("invoices.tasks.send_invoice_to_client.delay") as send, + caplog.at_level(logging.WARNING, logger="invoices.tasks"), + ): + generate_recurring_invoices() + + invoice = Invoice.objects.get() + assert invoice.status == "Draft" + send.assert_not_called() + assert str(schedule.id) in caplog.text + + def test_with_lines_it_is_sent(self, org_a, account): + self._schedule(org_a, account, lines=True) + + with patch("invoices.tasks.send_invoice_to_client.delay") as send: + generate_recurring_invoices() + + invoice = Invoice.objects.get() + assert invoice.status == "Sent" + assert invoice.line_items.count() == 1 + send.assert_called_once() diff --git a/backend/invoices/tests/test_estimate_accept_ip.py b/backend/invoices/tests/test_estimate_accept_ip.py new file mode 100644 index 00000000..cc9867a1 --- /dev/null +++ b/backend/invoices/tests/test_estimate_accept_ip.py @@ -0,0 +1,57 @@ +"""The IP recorded when a customer accepts an estimate is evidence of who +authorised it, so the customer cannot choose it with `X-Forwarded-For`. + +`common.request_meta.client_ip` believes only the entries our own proxies +appended (`REST_FRAMEWORK["NUM_PROXIES"]`), counted from the right. +""" + +import datetime + +import pytest +from django.utils import timezone +from rest_framework.test import APIClient + +from invoices.models import Estimate + + +@pytest.fixture +def sent_estimate(org_a): + return Estimate.objects.create( + title="Fit-out", + client_name="Dana Buyer", + client_email="dana@buyer.example", + currency="USD", + status="Sent", + issue_date=timezone.localdate(), + expiry_date=timezone.localdate() + datetime.timedelta(days=30), + org=org_a, + ) + + +def _accept(estimate, **meta): + response = APIClient().post( + f"/api/public/estimate/{estimate.public_token}/accept/", + {"name": "Dana Buyer", "email": "dana@buyer.example"}, + format="json", + **meta, + ) + assert response.status_code == 200, response.content + estimate.refresh_from_db() + return estimate.accepted_ip + + +def test_a_forged_header_is_not_recorded(sent_estimate): + ip = _accept( + sent_estimate, HTTP_X_FORWARDED_FOR="6.6.6.6", REMOTE_ADDR="198.51.100.7" + ) + assert ip == "198.51.100.7" + + +def test_behind_one_proxy_its_entry_is_recorded(sent_estimate, settings): + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": 1} + ip = _accept( + sent_estimate, + HTTP_X_FORWARDED_FOR="6.6.6.6, 203.0.113.9", + REMOTE_ADDR="127.0.0.1", + ) + assert ip == "203.0.113.9" diff --git a/backend/invoices/tests/test_estimate_recurring_list_visibility.py b/backend/invoices/tests/test_estimate_recurring_list_visibility.py new file mode 100644 index 00000000..d1f56e01 --- /dev/null +++ b/backend/invoices/tests/test_estimate_recurring_list_visibility.py @@ -0,0 +1,87 @@ +"""The estimate and recurring-invoice lists read through the shared helpers. + +Both lists used to carry inline copies of the read rule (D40). They now call +`visible_estimates_qs` and `visible_recurring_qs`, beside `visible_invoices_qs`, +and these tests pin that the rows each caller sees did not change: an admin +and a superuser see the whole org, a member sees what they created or are +assigned, and nobody sees another org's records. +""" + +import datetime + +import pytest + +from accounts.models import Account +from common.tests.export_personas import make_personas, stamp_creator +from invoices.models import Estimate, RecurringInvoice + +ESTIMATES_URL = "/api/invoices/estimates/" +RECURRING_URL = "/api/invoices/recurring/" + +EXPECTED = { + "admin": {"Assigned", "Created", "Nobody's"}, + "superuser": {"Assigned", "Created", "Nobody's"}, + "assignee": {"Assigned"}, + "creator": {"Created"}, + "unrelated": set(), +} + + +def _seed(model, org, org_b, people, **fields): + account = Account.objects.create(name="Acme", org=org) + + def make(title, owner_org=org, owner_account=account): + return model.objects.create( + org=owner_org, account=owner_account, title=title, **fields + ) + + make("Assigned").assigned_to.add(people["assignee"].profile) + stamp_creator(model, make("Created"), people["creator"].user) + make("Nobody's") + make( + "Other org", + owner_org=org_b, + owner_account=Account.objects.create(name="Elsewhere", org=org_b), + ) + + +@pytest.fixture +def people(org_a): + return make_personas(org_a) + + +@pytest.mark.django_db +@pytest.mark.parametrize("who", sorted(EXPECTED)) +def test_estimate_list_rows_per_caller(org_a, org_b, people, who): + _seed(Estimate, org_a, org_b, people) + + response = people[who].client().get(ESTIMATES_URL, {"limit": 50}) + + assert response.status_code == 200 + assert {e["title"] for e in response.data["results"]} == EXPECTED[who] + + +@pytest.mark.django_db +@pytest.mark.parametrize("who", sorted(EXPECTED)) +def test_recurring_list_rows_per_caller(org_a, org_b, people, who): + today = datetime.date(2026, 9, 1) + _seed( + RecurringInvoice, + org_a, + org_b, + people, + start_date=today, + next_generation_date=today, + ) + + response = people[who].client().get(RECURRING_URL, {"limit": 50}) + + assert response.status_code == 200 + assert {r["title"] for r in response.data["results"]} == EXPECTED[who] + + +@pytest.mark.django_db +def test_estimate_list_refuses_a_malformed_account_filter(people): + response = people["admin"].client().get(ESTIMATES_URL, {"account": "not-a-uuid"}) + + assert response.status_code == 400 diff --git a/backend/invoices/tests/test_hidden_document_is_404.py b/backend/invoices/tests/test_hidden_document_is_404.py new file mode 100644 index 00000000..739681d0 --- /dev/null +++ b/backend/invoices/tests/test_hidden_document_is_404.py @@ -0,0 +1,149 @@ +"""A same-org invoice, estimate or schedule the caller cannot open answers +exactly like a missing id, on every verb (owner decision, 1.11.0). + +It used to answer 403 "Permission denied", which told a member the record +exists. Each case below sends the same request twice, once at a record the +member cannot open and once at an id that does not exist, and requires the +two responses to be identical: status and whole body. The allowed side is +pinned too, so the check is shown to say yes as well as no. +""" + +import uuid +from unittest.mock import patch + +import pytest + +from accounts.models import Account +from invoices.models import Estimate, Invoice, InvoiceLineItem, RecurringInvoice + +INVOICE_CALLS = [ + ("get", "{id}/"), + ("put", "{id}/"), + ("post", "{id}/send/"), + ("post", "{id}/mark-paid/"), + ("post", "{id}/duplicate/"), + ("post", "{id}/cancel/"), + ("get", "{id}/pdf/"), + ("get", "{id}/line-items/"), + ("post", "{id}/line-items/"), + ("put", "{id}/line-items/{child}/"), + ("delete", "{id}/line-items/{child}/"), + ("get", "{id}/payments/"), + ("post", "{id}/payments/"), + ("delete", "{id}/payments/{child}/"), + ("post", "{id}/comments/"), + ("post", "{id}/attachments/"), +] +ESTIMATE_CALLS = [ + ("get", "estimates/{id}/"), + ("put", "estimates/{id}/"), + ("delete", "estimates/{id}/"), + ("post", "estimates/{id}/convert/"), + ("post", "estimates/{id}/send/"), + ("get", "estimates/{id}/pdf/"), +] +RECURRING_CALLS = [ + ("get", "recurring/{id}/"), + ("put", "recurring/{id}/"), + ("delete", "recurring/{id}/"), + ("post", "recurring/{id}/toggle/"), +] + + +@pytest.fixture(autouse=True) +def _no_background_work(): + with ( + patch("invoices.api_views.create_invoice_history"), + patch("invoices.api_views.send_email"), + patch("invoices.api_views.send_invoice_to_client"), + patch("invoices.tasks.send_estimate_to_client.delay"), + ): + yield + + +@pytest.fixture +def documents(org_a, admin_user): + """One of each, created by the admin and assigned to nobody, so the + member behind `user_client` cannot open any of them.""" + account = Account.objects.create(name="Acme", org=org_a) + invoice = Invoice.objects.create(org=org_a, account=account, invoice_title="Hidden") + line = InvoiceLineItem.objects.create( + invoice=invoice, org=org_a, name="Work", quantity=1, unit_price=10 + ) + estimate = Estimate.objects.create(org=org_a, account=account, title="Hidden") + recurring = RecurringInvoice.objects.create( + org=org_a, + account=account, + title="Hidden", + start_date="2026-09-01", + next_generation_date="2026-09-01", + ) + # Set without save(), which stamps created_by from the request user. + for obj in (invoice, estimate, recurring): + type(obj).objects.filter(pk=obj.pk).update(created_by=admin_user) + return { + "invoice": invoice, + "line": line, + "estimate": estimate, + "recurring": recurring, + } + + +def _call(client, verb, template, record_id, child_id): + path = "/api/invoices/" + template.format(id=record_id, child=child_id) + return getattr(client, verb)(path, {}, format="json") + + +def _same_as_missing(client, verb, template, hidden_id, child_id): + hidden = _call(client, verb, template, hidden_id, child_id) + missing = _call(client, verb, template, uuid.uuid4(), child_id) + assert hidden.status_code == missing.status_code == 404, template + assert hidden.json() == missing.json(), template + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb, template", INVOICE_CALLS) +def test_hidden_invoice_answers_like_a_missing_one( + user_client, documents, verb, template +): + _same_as_missing( + user_client, verb, template, documents["invoice"].id, documents["line"].id + ) + assert Invoice.objects.filter(invoice_title__startswith="Copy of").count() == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb, template", ESTIMATE_CALLS) +def test_hidden_estimate_answers_like_a_missing_one( + user_client, documents, verb, template +): + _same_as_missing(user_client, verb, template, documents["estimate"].id, None) + assert Estimate.objects.filter(id=documents["estimate"].id).exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb, template", RECURRING_CALLS) +def test_hidden_schedule_answers_like_a_missing_one( + user_client, documents, verb, template +): + _same_as_missing(user_client, verb, template, documents["recurring"].id, None) + assert RecurringInvoice.objects.get(id=documents["recurring"].id).is_active + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "key, template", + [ + ("invoice", "{id}/"), + ("estimate", "estimates/{id}/"), + ("recurring", "recurring/{id}/"), + ], +) +def test_the_owner_side_still_opens_them( + admin_client, user_client, user_profile, documents, key, template +): + record = documents[key] + assert _call(admin_client, "get", template, record.id, None).status_code == 200 + + record.assigned_to.add(user_profile) + assert _call(user_client, "get", template, record.id, None).status_code == 200 diff --git a/backend/invoices/tests/test_invoice_attachment_detail_access.py b/backend/invoices/tests/test_invoice_attachment_detail_access.py new file mode 100644 index 00000000..3c6d4a68 --- /dev/null +++ b/backend/invoices/tests/test_invoice_attachment_detail_access.py @@ -0,0 +1,122 @@ +"""`DELETE /api/invoices/attachments//` reaches a file only through an +invoice the caller may open. + +It used to look the attachment up org-wide across every module. An uploader +who had lost access to a lead could still delete that lead's file through +this route, and anyone else got a 403 that confirmed the id. Now a file that +is missing, in another org, on another module's record, or on an invoice +this caller cannot open answers one identical 404, and is left in place. +""" + +import uuid + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + +from accounts.models import Account +from common.models import Attachments +from invoices.models import Invoice +from leads.models import Lead + + +def _attach(target, uploader): + attachment = Attachments( + file_name="notes.txt", + content_object=target, + org=target.org, + attachment=SimpleUploadedFile("notes.txt", b"hello"), + ) + attachment.save() + # Set without save(), which stamps created_by from the request user. + Attachments.objects.filter(pk=attachment.pk).update(created_by=uploader) + return attachment + + +def _hidden(model, pk, admin_user): + """Owned by the admin and assigned to nobody.""" + model.objects.filter(pk=pk).update(created_by=admin_user) + + +@pytest.fixture +def invoice(org_a, admin_user): + invoice = Invoice.objects.create( + org=org_a, + account=Account.objects.create(name="Acme", org=org_a), + invoice_title="Work", + ) + _hidden(Invoice, invoice.pk, admin_user) + return invoice + + +def _url(pk): + return f"/api/invoices/attachments/{pk}/" + + +def _like_missing(client, attachment): + hidden = client.delete(_url(attachment.id)) + missing = client.delete(_url(uuid.uuid4())) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + assert Attachments.objects.filter(pk=attachment.pk).exists() + + +@pytest.mark.django_db +class TestWhoCanReachIt: + def test_the_uploader_of_a_hidden_leads_file_cannot_delete_it_here( + self, user_client, regular_user, admin_user, org_a + ): + """The reported bypass: the member uploaded it, then lost the lead.""" + lead = Lead.objects.create(first_name="Private", last_name="Lead", org=org_a) + _hidden(Lead, lead.pk, admin_user) + attachment = _attach(lead, regular_user) + + _like_missing(user_client, attachment) + + def test_another_modules_file_even_for_an_admin( + self, admin_client, admin_user, org_a + ): + lead = Lead.objects.create(first_name="Any", last_name="Lead", org=org_a) + _like_missing(admin_client, _attach(lead, admin_user)) + + def test_a_file_on_an_invoice_the_member_cannot_open( + self, user_client, admin_user, invoice + ): + _like_missing(user_client, _attach(invoice, admin_user)) + + def test_another_orgs_invoice_file(self, admin_client, user_b, org_b): + theirs = Invoice.objects.create( + org=org_b, + account=Account.objects.create(name="Elsewhere", org=org_b), + invoice_title="Theirs", + ) + _like_missing(admin_client, _attach(theirs, user_b)) + + +@pytest.mark.django_db +class TestWhoCanDeleteIt: + def test_a_reader_who_did_not_upload_it_is_refused( + self, user_client, user_profile, admin_user, invoice + ): + invoice.assigned_to.add(user_profile) + attachment = _attach(invoice, admin_user) + + response = user_client.delete(_url(attachment.id)) + + assert response.status_code == 403 + assert Attachments.objects.filter(pk=attachment.pk).exists() + + def test_the_uploader_who_can_read_it_may( + self, user_client, user_profile, regular_user, invoice + ): + invoice.assigned_to.add(user_profile) + attachment = _attach(invoice, regular_user) + + response = user_client.delete(_url(attachment.id)) + + assert response.status_code == 200, response.content + assert not Attachments.objects.filter(pk=attachment.pk).exists() + + def test_an_admin_may(self, admin_client, regular_user, invoice): + attachment = _attach(invoice, regular_user) + + assert admin_client.delete(_url(attachment.id)).status_code == 200 diff --git a/backend/invoices/tests/test_invoice_comment_detail_access.py b/backend/invoices/tests/test_invoice_comment_detail_access.py new file mode 100644 index 00000000..546ed9ad --- /dev/null +++ b/backend/invoices/tests/test_invoice_comment_detail_access.py @@ -0,0 +1,134 @@ +"""`PUT`/`DELETE /api/invoices/comments//` reach a comment only through an +invoice the caller may open. + +It used to look up any comment in the org by id: another module's comments +included, and it answered 403 for one the caller could not touch, which +confirmed the id existed. Now a comment that is missing, in another org, on +another module's record, or on an invoice this caller cannot open answers +one identical 404. Among callers who can read the invoice, the author rule +still answers 403. +""" + +import uuid + +import pytest +from django.contrib.contenttypes.models import ContentType + +from accounts.models import Account +from common.models import Comment +from invoices.models import Invoice + + +@pytest.fixture +def account(org_a): + return Account.objects.create(name="Acme", org=org_a) + + +@pytest.fixture +def invoice(org_a, account): + return Invoice.objects.create(org=org_a, account=account, invoice_title="Work") + + +def _comment(target, author, text="Original"): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(type(target)), + object_id=target.id, + comment=text, + commented_by=author, + org=target.org, + ) + + +def _url(comment_id): + return f"/api/invoices/comments/{comment_id}/" + + +def _like_missing(client, comment_id): + for verb in ("put", "delete"): + hidden = getattr(client, verb)( + _url(comment_id), {"comment": "x"}, format="json" + ) + missing = getattr(client, verb)( + _url(uuid.uuid4()), {"comment": "x"}, format="json" + ) + assert hidden.status_code == missing.status_code == 404, verb + assert hidden.json() == missing.json(), verb + + +@pytest.mark.django_db +class TestWhoCanReachTheComment: + def test_comment_on_an_invoice_the_member_cannot_open( + self, user_client, admin_profile, invoice + ): + comment = _comment(invoice, admin_profile) + + _like_missing(user_client, comment.id) + comment.refresh_from_db() + assert comment.comment == "Original" + + def test_another_modules_comment_even_for_an_admin( + self, admin_client, admin_profile, account + ): + comment = _comment(account, admin_profile) + + _like_missing(admin_client, comment.id) + assert Comment.objects.filter(id=comment.id).exists() + + def test_another_orgs_invoice_comment(self, admin_client, org_b, profile_b): + other = Invoice.objects.create( + org=org_b, + account=Account.objects.create(name="Elsewhere", org=org_b), + invoice_title="Theirs", + ) + comment = _comment(other, profile_b) + + _like_missing(admin_client, comment.id) + assert Comment.objects.filter(id=comment.id).exists() + + +@pytest.mark.django_db +class TestWhoCanChangeIt: + def test_a_reader_who_is_not_the_author_is_refused( + self, user_client, user_profile, admin_profile, invoice + ): + invoice.assigned_to.add(user_profile) + comment = _comment(invoice, admin_profile) + + for verb in ("put", "delete"): + response = getattr(user_client, verb)( + _url(comment.id), {"comment": "Hacked"}, format="json" + ) + assert response.status_code == 403, verb + comment.refresh_from_db() + assert comment.comment == "Original" + + def test_the_author_may_edit_and_delete(self, user_client, user_profile, invoice): + invoice.assigned_to.add(user_profile) + comment = _comment(invoice, user_profile) + + edited = user_client.put(_url(comment.id), {"comment": "Fixed"}, format="json") + assert edited.status_code == 200, edited.content + comment.refresh_from_db() + assert comment.comment == "Fixed" + + deleted = user_client.delete(_url(comment.id)) + assert deleted.status_code == 200, deleted.content + assert not Comment.objects.filter(id=comment.id).exists() + + def test_an_admin_may_edit_anyones(self, admin_client, user_profile, invoice): + comment = _comment(invoice, user_profile) + + response = admin_client.put( + _url(comment.id), {"comment": "Tidied"}, format="json" + ) + + assert response.status_code == 200, response.content + + def test_an_edit_cannot_blank_it(self, admin_client, admin_profile, invoice): + comment = _comment(invoice, admin_profile) + + response = admin_client.put(_url(comment.id), {"comment": ""}, format="json") + + assert response.status_code == 400, response.content + comment.refresh_from_db() + assert comment.comment == "Original" diff --git a/backend/invoices/tests/test_invoice_object_permissions.py b/backend/invoices/tests/test_invoice_object_permissions.py index 94533819..d5bb27ce 100644 --- a/backend/invoices/tests/test_invoice_object_permissions.py +++ b/backend/invoices/tests/test_invoice_object_permissions.py @@ -94,7 +94,7 @@ class TestReportedEndpointsRejectNonOwner: @patch("invoices.api_views.send_invoice_to_client.delay") def test_send_denied_for_non_owner(self, mock_send, user_client, foreign_invoice): response = user_client.post(f"/api/invoices/{foreign_invoice.id}/send/") - assert response.status_code == 403 + assert response.status_code == 404 foreign_invoice.refresh_from_db() assert foreign_invoice.is_email_sent is False assert foreign_invoice.sent_at is None @@ -110,14 +110,14 @@ class TestReportedEndpointsRejectNonOwner: }, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 assert Payment.objects.filter(invoice=foreign_invoice).count() == 0 foreign_invoice.refresh_from_db() assert foreign_invoice.status != "Paid" def test_duplicate_denied_for_non_owner(self, user_client, foreign_invoice): response = user_client.post(f"/api/invoices/{foreign_invoice.id}/duplicate/") - assert response.status_code == 403 + assert response.status_code == 404 assert Invoice.objects.filter(invoice_title__startswith="Copy of").count() == 0 @@ -130,7 +130,7 @@ class TestReportedEndpointsRejectNonOwner: class TestLineItemEndpointsRejectNonOwner: def test_list_line_items_denied_for_non_owner(self, user_client, foreign_invoice): response = user_client.get(f"/api/invoices/{foreign_invoice.id}/line-items/") - assert response.status_code == 403 + assert response.status_code == 404 def test_create_line_item_denied_for_non_owner(self, user_client, foreign_invoice): response = user_client.post( @@ -138,7 +138,7 @@ class TestLineItemEndpointsRejectNonOwner: {"name": "Injected", "quantity": "1", "unit_price": "10.00"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 assert not foreign_invoice.line_items.filter(name="Injected").exists() def test_update_line_item_denied_for_non_owner( @@ -149,7 +149,7 @@ class TestLineItemEndpointsRejectNonOwner: {"name": "Tampered"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 foreign_line_item.refresh_from_db() assert foreign_line_item.name == "Widget" @@ -159,7 +159,7 @@ class TestLineItemEndpointsRejectNonOwner: response = user_client.delete( f"/api/invoices/{foreign_invoice.id}/line-items/{foreign_line_item.id}/" ) - assert response.status_code == 403 + assert response.status_code == 404 assert InvoiceLineItem.objects.filter(id=foreign_line_item.id).exists() @@ -167,7 +167,7 @@ class TestLineItemEndpointsRejectNonOwner: class TestPaymentEndpointsRejectNonOwner: def test_list_payments_denied_for_non_owner(self, user_client, foreign_invoice): response = user_client.get(f"/api/invoices/{foreign_invoice.id}/payments/") - assert response.status_code == 403 + assert response.status_code == 404 def test_create_payment_denied_for_non_owner(self, user_client, foreign_invoice): response = user_client.post( @@ -179,7 +179,7 @@ class TestPaymentEndpointsRejectNonOwner: }, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 assert Payment.objects.filter(invoice=foreign_invoice).count() == 0 @@ -191,7 +191,7 @@ class TestCommentAndAttachmentRejectNonOwner: {"comment": "injected"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_create_attachment_denied_for_non_owner(self, user_client, foreign_invoice): upload = SimpleUploadedFile("evil.txt", b"payload", content_type="text/plain") @@ -200,7 +200,7 @@ class TestCommentAndAttachmentRejectNonOwner: {"file": upload}, format="multipart", ) - assert response.status_code == 403 + assert response.status_code == 404 @pytest.mark.django_db diff --git a/backend/invoices/tests/test_invoices_api.py b/backend/invoices/tests/test_invoices_api.py index f0527a77..dd53df3e 100644 --- a/backend/invoices/tests/test_invoices_api.py +++ b/backend/invoices/tests/test_invoices_api.py @@ -958,7 +958,7 @@ class TestInvoiceDetailPermissions: def test_regular_user_cannot_see_unassigned_invoice(self, user_client, invoice): """Non-admin user who is neither creator nor assigned cannot see the invoice.""" response = user_client.get(f"/api/invoices/{invoice.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_get_nonexistent_invoice(self, admin_client): fake_id = uuid.uuid4() @@ -984,7 +984,7 @@ class TestInvoiceDetailPermissions: {"invoice_title": "Hacked"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_detail_includes_attachments_comments_history(self, admin_client, invoice): response = admin_client.get(f"/api/invoices/{invoice.id}/") @@ -1072,7 +1072,7 @@ class TestInvoiceActionsEdgeCases: self, user_client, invoice ): response = user_client.post(f"/api/invoices/{invoice.id}/cancel/") - assert response.status_code == 403 + assert response.status_code == 404 def test_duplicate_invoice_with_line_items(self, admin_client, invoice, line_item): """Duplicate should copy line items.""" @@ -1141,7 +1141,7 @@ class TestInvoicePDF: self, mock_filename, mock_pdf, user_client, invoice ): response = user_client.get(f"/api/invoices/{invoice.id}/pdf/") - assert response.status_code == 403 + assert response.status_code == 404 # --------------------------------------------------------------------------- @@ -1253,7 +1253,7 @@ class TestPaymentDetail: response = user_client.delete( f"/api/invoices/{invoice.id}/payments/{payment.id}/" ) - assert response.status_code == 403 + assert response.status_code == 404 def test_create_payment_invalid_data(self, admin_client, invoice): response = admin_client.post( @@ -1903,7 +1903,7 @@ class TestEstimateActions: self, mock_filename, mock_pdf, user_client, estimate ): response = user_client.get(f"/api/invoices/estimates/{estimate.id}/pdf/") - assert response.status_code == 403 + assert response.status_code == 404 @patch( "invoices.api_views.generate_estimate_pdf", @@ -1964,7 +1964,7 @@ class TestEstimateAuthorization: def test_member_not_owner_cannot_get(self, user_client, estimate): response = user_client.get(f"/api/invoices/estimates/{estimate.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_member_not_owner_cannot_update(self, user_client, estimate): response = user_client.put( @@ -1972,27 +1972,27 @@ class TestEstimateAuthorization: {"title": "Hijacked"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 estimate.refresh_from_db() assert estimate.title == "Test Estimate" def test_member_not_owner_cannot_delete(self, user_client, estimate): response = user_client.delete(f"/api/invoices/estimates/{estimate.id}/") - assert response.status_code == 403 + assert response.status_code == 404 assert Estimate.objects.filter(id=estimate.id).exists() def test_member_not_owner_cannot_convert(self, user_client, estimate): response = user_client.post(f"/api/invoices/estimates/{estimate.id}/convert/") - assert response.status_code == 403 + assert response.status_code == 404 estimate.refresh_from_db() assert estimate.converted_to_invoice is None @patch("invoices.tasks.send_estimate_to_client.delay") def test_member_not_owner_cannot_send(self, mock_send, user_client, estimate): # send_estimate_to_client is imported inside the view, so patch it at - # its source. The 403 short-circuits before the import is even reached. + # its source. The 404 short-circuits before the import is even reached. response = user_client.post(f"/api/invoices/estimates/{estimate.id}/send/") - assert response.status_code == 403 + assert response.status_code == 404 mock_send.assert_not_called() # -- the positive direction: assignee and creator do have access ---------- @@ -2379,7 +2379,7 @@ class TestRecurringInvoiceAuthorization: the same shape as Invoice and Estimate, so it now follows the same rule: admins and superusers see all; everyone else only what they created or are assigned to. The ``recurring_invoice`` fixture has no creator and no - assignee, so ``user_client`` is neither -- every block below is a 403. + assignee, so ``user_client`` is neither, and every block below is a 404. """ def test_member_can_list_recurring(self, user_client, recurring_invoice): @@ -2398,7 +2398,7 @@ class TestRecurringInvoiceAuthorization: def test_member_not_owner_cannot_get(self, user_client, recurring_invoice): response = user_client.get(f"/api/invoices/recurring/{recurring_invoice.id}/") - assert response.status_code == 403 + assert response.status_code == 404 def test_member_not_owner_cannot_update(self, user_client, recurring_invoice): response = user_client.put( @@ -2406,7 +2406,7 @@ class TestRecurringInvoiceAuthorization: {"title": "Hijacked"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 recurring_invoice.refresh_from_db() assert recurring_invoice.title == "Monthly Hosting" @@ -2414,7 +2414,7 @@ class TestRecurringInvoiceAuthorization: response = user_client.delete( f"/api/invoices/recurring/{recurring_invoice.id}/" ) - assert response.status_code == 403 + assert response.status_code == 404 assert RecurringInvoice.objects.filter(id=recurring_invoice.id).exists() def test_member_not_owner_cannot_toggle(self, user_client, recurring_invoice): @@ -2422,7 +2422,7 @@ class TestRecurringInvoiceAuthorization: response = user_client.post( f"/api/invoices/recurring/{recurring_invoice.id}/toggle/" ) - assert response.status_code == 403 + assert response.status_code == 404 recurring_invoice.refresh_from_db() assert recurring_invoice.is_active is was_active @@ -2991,8 +2991,11 @@ class TestInvoiceComments: assert response.status_code == 404 def test_regular_user_cannot_edit_others_comment( - self, admin_client, user_client, invoice + self, admin_client, user_client, user_profile, invoice ): + # The member can read the invoice, so the author rule is what refuses + # them; one who cannot read it gets the 404 a missing comment gets. + invoice.assigned_to.add(user_profile) # Admin creates comment resp = admin_client.post( f"/api/invoices/{invoice.id}/comments/", @@ -3009,8 +3012,11 @@ class TestInvoiceComments: assert response.status_code == 403 def test_regular_user_cannot_delete_others_comment( - self, admin_client, user_client, invoice + self, admin_client, user_client, user_profile, invoice ): + # The member can read the invoice, so the author rule is what refuses + # them; one who cannot read it gets the 404 a missing comment gets. + invoice.assigned_to.add(user_profile) resp = admin_client.post( f"/api/invoices/{invoice.id}/comments/", {"comment": "Admin's comment"}, @@ -4219,7 +4225,10 @@ class TestPublicEstimateAcceptDecline: estimate.refresh_from_db() assert estimate.status == "Sent" - def test_accept_ip_prefers_forwarded_for(self, estimate): + def test_accept_ip_ignores_a_forwarded_for_no_proxy_vouches_for(self, estimate): + """The first X-Forwarded-For entry is the caller's to write. With no + trusted proxy configured the socket peer is recorded; the proxy case + is in test_estimate_accept_ip.py.""" estimate.status = "Sent" estimate.save() response = self._post_accept( @@ -4230,7 +4239,7 @@ class TestPublicEstimateAcceptDecline: ) assert response.status_code == 200 estimate.refresh_from_db() - assert estimate.accepted_ip == "198.51.100.7" + assert estimate.accepted_ip == "10.0.0.1" def test_decline_estimate(self, estimate): estimate.status = "Sent" diff --git a/backend/invoices/tests/test_issued_documents_locked.py b/backend/invoices/tests/test_issued_documents_locked.py new file mode 100644 index 00000000..588ce5a9 --- /dev/null +++ b/backend/invoices/tests/test_issued_documents_locked.py @@ -0,0 +1,389 @@ +"""An issued invoice or estimate keeps its lines and amounts (owner decision, +1.11.0). + +Once a document leaves Draft, every write path refuses a change to its lines, +discounts, tax, shipping or currency with a 400: the document update (PUT and +its nested ``line_items``) and the three invoice line-item endpoints. Status +changes, payments, notes and dates stay editable. One rule, +`invoices.serializer.issued_lock_message`, backs every path. +""" + +import datetime +from decimal import Decimal +from unittest.mock import patch + +import pytest + +from accounts.models import Account +from contacts.models import Contact +from invoices.models import ( + ESTIMATE_STATUS, + INVOICE_STATUS, + Estimate, + EstimateLineItem, + Invoice, + InvoiceLineItem, + Payment, +) + +LOCKED = "Its lines and amounts can only be changed while it is a Draft." + +ISSUED_INVOICE = [code for code, _ in INVOICE_STATUS if code != "Draft"] +ISSUED_ESTIMATE = [code for code, _ in ESTIMATE_STATUS if code != "Draft"] + +# One changed value for every locked field. `line_items` replaces the lines. +NEW_LINES = [{"name": "Other", "quantity": "1", "unit_price": "10.00"}] +INVOICE_CHANGES = { + "line_items": NEW_LINES, + "discount_type": "PERCENTAGE", + "discount_value": "5", + "tax_rate": "7.5", + "shipping_amount": "4", + "currency": "EUR", +} +ESTIMATE_CHANGES = { + key: value for key, value in INVOICE_CHANGES.items() if key != "shipping_amount" +} + + +@pytest.fixture(autouse=True) +def _no_background_work(): + with ( + patch("invoices.api_views.create_invoice_history"), + patch("invoices.api_views.send_email"), + patch("invoices.api_views.send_invoice_to_client"), + patch("invoices.tasks.send_estimate_to_client.delay"), + ): + yield + + +@pytest.fixture +def account(org_a, admin_user): + return Account.objects.create(name="Buyer Co", org=org_a, created_by=admin_user) + + +@pytest.fixture +def contact(org_a, admin_user): + return Contact.objects.create( + first_name="Ada", last_name="Buyer", org=org_a, created_by=admin_user + ) + + +def _invoice(org, account, status): + invoice = Invoice.objects.create( + invoice_title="Work", + account=account, + client_email="buyer@example.com", + currency="USD", + org=org, + ) + InvoiceLineItem.objects.create( + invoice=invoice, org=org, name="Design", quantity=2, unit_price=100 + ) + invoice.recalculate_totals() + invoice.save() + Invoice.objects.filter(id=invoice.id).update(status=status) + invoice.refresh_from_db() + return invoice + + +def _estimate(org, account, status): + estimate = Estimate.objects.create( + title="Quote", + account=account, + client_email="buyer@example.com", + currency="USD", + org=org, + ) + EstimateLineItem.objects.create( + estimate=estimate, org=org, name="Design", quantity=2, unit_price=100 + ) + estimate.recalculate_totals() + estimate.save() + Estimate.objects.filter(id=estimate.id).update(status=status) + estimate.refresh_from_db() + return estimate + + +def _snapshot(document): + document.refresh_from_db() + return ( + [ + (line.name, line.quantity, line.unit_price) + for line in document.line_items.order_by("order", "name") + ], + document.discount_type, + document.discount_value, + document.tax_rate, + getattr(document, "shipping_amount", None), + document.currency, + document.subtotal, + document.total_amount, + ) + + +def _invoice_url(invoice): + return f"/api/invoices/{invoice.id}/" + + +def _estimate_url(estimate): + return f"/api/invoices/estimates/{estimate.id}/" + + +@pytest.mark.django_db +class TestInvoiceUpdate: + @pytest.mark.parametrize("status", ISSUED_INVOICE) + @pytest.mark.parametrize("field", INVOICE_CHANGES) + def test_issued_invoice_refuses_a_change( + self, admin_client, org_a, account, status, field + ): + invoice = _invoice(org_a, account, status) + before = _snapshot(invoice) + + response = admin_client.put( + _invoice_url(invoice), {field: INVOICE_CHANGES[field]}, format="json" + ) + + assert response.status_code == 400, response.content + assert LOCKED in str(response.json()["errors"][field]) + assert _snapshot(invoice) == before + + @pytest.mark.parametrize("field", INVOICE_CHANGES) + def test_draft_invoice_takes_the_change(self, admin_client, org_a, account, field): + invoice = _invoice(org_a, account, "Draft") + before = _snapshot(invoice) + + response = admin_client.put( + _invoice_url(invoice), {field: INVOICE_CHANGES[field]}, format="json" + ) + + assert response.status_code == 200, response.content + assert _snapshot(invoice) != before + + def test_issued_invoice_accepts_its_stored_values_back( + self, admin_client, org_a, account + ): + """Resending what is stored changes nothing, so it is not refused and + the issued totals are not recomputed.""" + invoice = _invoice(org_a, account, "Sent") + Invoice.objects.filter(id=invoice.id).update(total_amount=Decimal("999.00")) + before = _snapshot(invoice) + + response = admin_client.put( + _invoice_url(invoice), + { + "currency": "USD", + "tax_rate": "0.00", + "shipping_amount": "0", + "discount_type": "", + "discount_value": "0", + "notes": "Resent with the form", + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert _snapshot(invoice) == before + assert invoice.notes == "Resent with the form" + + @pytest.mark.parametrize("status", ISSUED_INVOICE) + def test_issued_invoice_takes_notes_and_due_date( + self, admin_client, org_a, account, status + ): + invoice = _invoice(org_a, account, status) + due = datetime.date(2030, 1, 31) + + response = admin_client.put( + _invoice_url(invoice), + {"notes": "Paid by wire", "due_date": str(due), "po_number": "PO-7"}, + format="json", + ) + + assert response.status_code == 200, response.content + invoice.refresh_from_db() + assert (invoice.notes, invoice.due_date, invoice.po_number) == ( + "Paid by wire", + due, + "PO-7", + ) + + +@pytest.mark.django_db +class TestInvoiceLineItemEndpoints: + def _add(self, client, invoice): + return client.post( + f"/api/invoices/{invoice.id}/line-items/", + {"name": "Extra", "quantity": "1", "unit_price": "50"}, + format="json", + ) + + def _edit(self, client, invoice): + line = invoice.line_items.get() + return client.put( + f"/api/invoices/{invoice.id}/line-items/{line.id}/", + {"quantity": "3"}, + format="json", + ) + + def _reorder(self, client, invoice): + line = invoice.line_items.get() + return client.put( + f"/api/invoices/{invoice.id}/line-items/{line.id}/", + {"order": 5}, + format="json", + ) + + def _delete(self, client, invoice): + line = invoice.line_items.get() + return client.delete(f"/api/invoices/{invoice.id}/line-items/{line.id}/") + + WRITES = ["_add", "_edit", "_reorder", "_delete"] + + @pytest.mark.parametrize("status", ISSUED_INVOICE) + @pytest.mark.parametrize("write", WRITES) + def test_issued_invoice_refuses_line_writes( + self, admin_client, org_a, account, status, write + ): + invoice = _invoice(org_a, account, status) + before = _snapshot(invoice) + order = invoice.line_items.get().order + + response = getattr(self, write)(admin_client, invoice) + + assert response.status_code == 400, response.content + assert LOCKED in response.json()["message"] + assert _snapshot(invoice) == before + assert invoice.line_items.get().order == order + + @pytest.mark.parametrize("write", WRITES) + def test_draft_invoice_takes_line_writes(self, admin_client, org_a, account, write): + invoice = _invoice(org_a, account, "Draft") + + response = getattr(self, write)(admin_client, invoice) + + assert response.status_code in (200, 201), response.content + + def test_draft_line_write_recomputes_the_totals(self, admin_client, org_a, account): + invoice = _invoice(org_a, account, "Draft") + + self._add(admin_client, invoice) + + invoice.refresh_from_db() + assert invoice.total_amount == Decimal("250.00") + + def test_hidden_issued_invoice_answers_as_before_not_with_its_status( + self, user_client, org_a, account + ): + """The lock is checked after the access check, so a member who cannot + open the invoice is not told that it has been issued.""" + invoice = _invoice(org_a, account, "Paid") + + response = self._add(user_client, invoice) + + assert response.status_code == 404 + assert LOCKED not in str(response.content) + assert invoice.line_items.count() == 1 + + +@pytest.mark.django_db +class TestIssuedInvoiceStillMoves: + def test_payment_and_payment_delete(self, admin_client, org_a, account): + invoice = _invoice(org_a, account, "Sent") + + paid = admin_client.post( + f"/api/invoices/{invoice.id}/payments/", + { + "amount": "50.00", + "payment_date": "2026-09-01", + "payment_method": "CASH", + }, + format="json", + ) + assert paid.status_code == 201, paid.content + invoice.refresh_from_db() + assert invoice.amount_paid == Decimal("50.00") + assert invoice.total_amount == Decimal("200.00") + + payment = Payment.objects.get(invoice=invoice) + removed = admin_client.delete( + f"/api/invoices/{invoice.id}/payments/{payment.id}/" + ) + assert removed.status_code == 200, removed.content + + def test_mark_paid_and_cancel(self, admin_client, org_a, account): + sent = _invoice(org_a, account, "Sent") + response = admin_client.post(f"/api/invoices/{sent.id}/mark-paid/") + assert response.status_code == 200, response.content + sent.refresh_from_db() + assert sent.status == "Paid" + + overdue = _invoice(org_a, account, "Overdue") + response = admin_client.post(f"/api/invoices/{overdue.id}/cancel/") + assert response.status_code == 200, response.content + overdue.refresh_from_db() + assert overdue.status == "Cancelled" + + def test_resend(self, admin_client, org_a, account): + invoice = _invoice(org_a, account, "Viewed") + + response = admin_client.post(f"/api/invoices/{invoice.id}/send/") + + assert response.status_code == 200, response.content + + +@pytest.mark.django_db +class TestEstimateUpdate: + @pytest.mark.parametrize("status", ISSUED_ESTIMATE) + @pytest.mark.parametrize("field", ESTIMATE_CHANGES) + def test_issued_estimate_refuses_a_change( + self, admin_client, org_a, account, status, field + ): + estimate = _estimate(org_a, account, status) + before = _snapshot(estimate) + + response = admin_client.put( + _estimate_url(estimate), {field: ESTIMATE_CHANGES[field]}, format="json" + ) + + assert response.status_code == 400, response.content + assert LOCKED in str(response.json()["errors"][field]) + assert _snapshot(estimate) == before + + @pytest.mark.parametrize("field", ESTIMATE_CHANGES) + def test_draft_estimate_takes_the_change(self, admin_client, org_a, account, field): + estimate = _estimate(org_a, account, "Draft") + before = _snapshot(estimate) + + response = admin_client.put( + _estimate_url(estimate), {field: ESTIMATE_CHANGES[field]}, format="json" + ) + + assert response.status_code == 200, response.content + assert _snapshot(estimate) != before + + @pytest.mark.parametrize("status", ISSUED_ESTIMATE) + def test_issued_estimate_takes_notes_and_expiry( + self, admin_client, org_a, account, status + ): + estimate = _estimate(org_a, account, status) + expiry = datetime.date(2030, 1, 31) + + response = admin_client.put( + _estimate_url(estimate), + {"notes": "Valid for a month", "expiry_date": str(expiry)}, + format="json", + ) + + assert response.status_code == 200, response.content + estimate.refresh_from_db() + assert (estimate.notes, estimate.expiry_date) == ("Valid for a month", expiry) + + def test_issued_estimate_still_converts(self, admin_client, org_a, account): + estimate = _estimate(org_a, account, "Sent") + + response = admin_client.post(f"/api/invoices/estimates/{estimate.id}/convert/") + + assert response.status_code == 201, response.content + estimate.refresh_from_db() + assert estimate.status == "Accepted" + assert estimate.converted_to_invoice.total_amount == Decimal("200.00") diff --git a/backend/invoices/tests/test_issued_lock_row_locking.py b/backend/invoices/tests/test_issued_lock_row_locking.py new file mode 100644 index 00000000..9cc5d21d --- /dev/null +++ b/backend/invoices/tests/test_issued_lock_row_locking.py @@ -0,0 +1,123 @@ +"""The issued-document check and the write it guards run on a locked row. + +Every path that checks `issued_lock_message` or full-saves a document after +reading it (the invoice and estimate updates, the three invoice line-item +endpoints, both sends and estimate conversion) reads the row with +`select_for_update()` inside one transaction. Without it, a send landing +between the check and the save let a line change onto a Sent invoice, and +the edit's full save wrote the stale "Draft" back over "Sent". + +A real race needs two connections to PostgreSQL. What is deterministic on +the SQLite test database is that each path asks for the lock; the query-level +test below proves the SQL on PostgreSQL. +""" + +from unittest.mock import patch + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from accounts.models import Account +from invoices import api_views +from invoices.models import Estimate, EstimateLineItem, Invoice, InvoiceLineItem + + +@pytest.fixture(autouse=True) +def _no_background_work(): + with ( + patch("invoices.api_views.create_invoice_history"), + patch("invoices.api_views.send_email"), + patch("invoices.api_views.send_invoice_to_client"), + patch("invoices.tasks.send_estimate_to_client.delay"), + ): + yield + + +@pytest.fixture +def docs(org_a): + account = Account.objects.create(name="Acme", org=org_a) + invoice = Invoice.objects.create( + org=org_a, account=account, invoice_title="Work", client_email="a@b.co" + ) + line = InvoiceLineItem.objects.create( + invoice=invoice, org=org_a, name="Design", quantity=1, unit_price=10 + ) + estimate = Estimate.objects.create( + org=org_a, account=account, title="Quote", client_email="a@b.co" + ) + EstimateLineItem.objects.create( + estimate=estimate, org=org_a, name="Design", quantity=1, unit_price=10 + ) + return {"invoice": invoice, "line": line, "estimate": estimate} + + +INVOICE_CALLS = [ + ("put", "{invoice}/", {"notes": "x"}), + ("post", "{invoice}/send/", {}), + ("post", "{invoice}/line-items/", {"name": "B", "quantity": 1, "unit_price": 5}), + ("put", "{invoice}/line-items/{line}/", {"quantity": 2}), + ("delete", "{invoice}/line-items/{line}/", {}), +] +ESTIMATE_CALLS = [ + ("put", "estimates/{estimate}/", {"notes": "x"}), + ("post", "estimates/{estimate}/send/", {}), + ("post", "estimates/{estimate}/convert/", {}), +] + + +def _run(client, docs, verb, template, body): + url = "/api/invoices/" + template.format( + invoice=docs["invoice"].id, line=docs["line"].id, estimate=docs["estimate"].id + ) + return getattr(client, verb)(url, body, format="json") + + +def _spy(name): + """Wrap an `api_views` fetch helper, recording whether each call asked + for a locked read.""" + real = getattr(api_views, name) + locked = [] + + def spy(request, pk, queryset=None): + locked.append(queryset is not None and queryset.query.select_for_update) + return real(request, pk, queryset) + + return patch.object(api_views, name, spy), locked + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb, template, body", INVOICE_CALLS) +def test_invoice_writes_read_a_locked_row(admin_client, docs, verb, template, body): + patcher, locked = _spy("get_invoice_or_error") + with patcher: + response = _run(admin_client, docs, verb, template, body) + + assert response.status_code in (200, 201), response.content + assert locked == [True] + + +@pytest.mark.django_db +@pytest.mark.parametrize("verb, template, body", ESTIMATE_CALLS) +def test_estimate_writes_read_a_locked_row(admin_client, docs, verb, template, body): + patcher, locked = _spy("get_estimate_or_error") + with patcher: + response = _run(admin_client, docs, verb, template, body) + + assert response.status_code in (200, 201), response.content + assert locked == [True] + + +@pytest.mark.django_db +def test_the_invoice_read_is_for_update_on_postgres(admin_client, docs): + if not connection.features.has_select_for_update: + pytest.skip("SQLite has no row locks; this runs against PostgreSQL in CI") + with CaptureQueriesContext(connection) as queries: + admin_client.put( + f"/api/invoices/{docs['invoice'].id}/", {"notes": "x"}, format="json" + ) + + assert any( + "FOR UPDATE" in q["sql"] and 'FROM "invoice"' in q["sql"] + for q in queries.captured_queries + ) diff --git a/backend/invoices/tests/test_issued_totals_frozen.py b/backend/invoices/tests/test_issued_totals_frozen.py index 5384a24b..14ad1263 100644 --- a/backend/invoices/tests/test_issued_totals_frozen.py +++ b/backend/invoices/tests/test_issued_totals_frozen.py @@ -10,7 +10,8 @@ from the new total beside the old stored total_amount. Now ``save()`` recomputes only a new document or one still a Draft in the database (`totals_follow_lines`), and an explicit edit through the API -recomputes because it asks to. +recomputes because it asks to. Since 1.11.0 that edit is only possible on a +Draft: an issued document refuses it (`test_issued_documents_locked.py`). Every invoice here is stored the way the old code left it: total 200.00 from the gross, with a line whose own discount makes the net 180.00. @@ -247,12 +248,10 @@ class TestWhatStillRecomputes: assert invoice.status == "Sent" assert _totals(invoice) == [NET, NET, NET] - def test_a_line_edit_on_an_issued_invoice_recomputes( - self, admin_client, org_a, account - ): - """Editing a non-Draft invoice's lines was allowed before and still - is; the edit asks for the new totals.""" - invoice = _issued_invoice(org_a, account, "Sent") + def test_a_line_edit_on_a_draft_recomputes(self, admin_client, org_a, account): + """The edit asks for the new totals. On an issued invoice it is + refused instead (`test_issued_documents_locked.py`).""" + invoice = _issued_invoice(org_a, account, "Draft") line = invoice.line_items.get() response = admin_client.put( @@ -265,10 +264,8 @@ class TestWhatStillRecomputes: # 3 x 100 less 10%. assert _totals(invoice) == [Decimal("270.00")] * 3 - def test_a_totals_edit_on_an_issued_invoice_recomputes( - self, admin_client, org_a, account - ): - invoice = _issued_invoice(org_a, account, "Sent") + def test_a_totals_edit_on_a_draft_recomputes(self, admin_client, org_a, account): + invoice = _issued_invoice(org_a, account, "Draft") response = admin_client.put( f"/api/invoices/{invoice.id}/", {"shipping_amount": "5"}, format="json" diff --git a/backend/invoices/tests/test_tax_and_shipping_bounds.py b/backend/invoices/tests/test_tax_and_shipping_bounds.py new file mode 100644 index 00000000..1b3fd5ce --- /dev/null +++ b/backend/invoices/tests/test_tax_and_shipping_bounds.py @@ -0,0 +1,243 @@ +"""Tax rates are 0 to 100 and shipping is never negative, on every document +and every line (1.11.0), through the API and the Django admin alike. + +Before this the API took a negative tax rate or shipping amount, either of +which lowers the bill, and a tax rate up to the column's 999.99. +""" + +from decimal import Decimal +from unittest.mock import patch + +import pytest +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils import timezone + +from accounts.models import Account +from contacts.models import Contact +from invoices.models import ( + Estimate, + EstimateLineItem, + Invoice, + InvoiceLineItem, + RecurringInvoice, + RecurringInvoiceLineItem, +) + + +@pytest.fixture(autouse=True) +def _no_background_work(): + with ( + patch("invoices.api_views.create_invoice_history"), + patch("invoices.api_views.send_email"), + ): + yield + + +@pytest.fixture +def account(org_a): + return Account.objects.create(name="Buyer Co", org=org_a) + + +@pytest.fixture +def contact(org_a): + return Contact.objects.create(first_name="Ada", last_name="Buyer", org=org_a) + + +LINE = {"name": "Work", "quantity": "1", "unit_price": "100"} + + +def _body(kind, account, contact, **extra): + body = { + "account_id": str(account.id), + "contact_id": str(contact.id), + "line_items": [dict(LINE)], + } + if kind == "invoice": + body["invoice_title"] = "Bill" + else: + body["title"] = "Bill" + if kind == "recurring": + today = str(timezone.localdate()) + body.update(frequency="MONTHLY", start_date=today, next_generation_date=today) + body.update(extra) + return body + + +DOCUMENTS = [ + ("invoice", "/api/invoices/", Invoice), + ("estimate", "/api/invoices/estimates/", Estimate), + ("recurring", "/api/invoices/recurring/", RecurringInvoice), +] + +REFUSED = [ + ({"tax_rate": "-0.01"}, "tax_rate", "Tax rate cannot be negative."), + ({"tax_rate": "100.01"}, "tax_rate", "Tax rate cannot exceed 100."), +] + + +@pytest.mark.django_db +class TestDocumentTaxRate: + @pytest.mark.parametrize("kind, url, model", DOCUMENTS) + @pytest.mark.parametrize("extra, field, message", REFUSED) + def test_out_of_range_is_a_400( + self, admin_client, account, contact, kind, url, model, extra, field, message + ): + response = admin_client.post( + url, _body(kind, account, contact, **extra), format="json" + ) + + assert response.status_code == 400, response.content + assert response.json()["errors"][field] == [message] + assert not model.objects.exists() + + @pytest.mark.parametrize("kind, url, model", DOCUMENTS) + @pytest.mark.parametrize("rate", ["0", "100"]) + def test_the_ends_of_the_range_are_allowed( + self, admin_client, account, contact, kind, url, model, rate + ): + response = admin_client.post( + url, _body(kind, account, contact, tax_rate=rate), format="json" + ) + + assert response.status_code == 201, response.content + + @pytest.mark.parametrize("kind, url, model", DOCUMENTS) + def test_an_update_is_held_to_it_too( + self, admin_client, account, contact, kind, url, model + ): + admin_client.post(url, _body(kind, account, contact), format="json") + detail = f"{url}{model.objects.get().id}/" + + refused = admin_client.put(detail, {"tax_rate": "-5"}, format="json") + allowed = admin_client.put(detail, {"tax_rate": "5"}, format="json") + + assert refused.status_code == 400, refused.content + assert allowed.status_code == 200, allowed.content + + +@pytest.mark.django_db +class TestShipping: + def test_negative_shipping_is_a_400(self, admin_client, account, contact): + response = admin_client.post( + "/api/invoices/", + _body("invoice", account, contact, shipping_amount="-1"), + format="json", + ) + + assert response.status_code == 400, response.content + assert response.json()["errors"]["shipping_amount"] == [ + "Shipping cannot be negative." + ] + assert not Invoice.objects.exists() + + def test_zero_and_positive_shipping_are_allowed( + self, admin_client, account, contact + ): + for amount in ("0", "12.50"): + response = admin_client.post( + "/api/invoices/", + _body("invoice", account, contact, shipping_amount=amount), + format="json", + ) + assert response.status_code == 201, response.content + + +@pytest.mark.django_db +class TestLineTaxRate: + @pytest.mark.parametrize("kind, url, model", DOCUMENTS) + @pytest.mark.parametrize("rate, ok", [("-1", False), ("101", False), ("20", True)]) + def test_nested_line_tax_rate( + self, admin_client, account, contact, kind, url, model, rate, ok + ): + body = _body(kind, account, contact) + body["line_items"][0]["tax_rate"] = rate + + response = admin_client.post(url, body, format="json") + + assert response.status_code == (201 if ok else 400), response.content + + def test_line_item_endpoint_tax_rate(self, admin_client, account, contact): + admin_client.post( + "/api/invoices/", _body("invoice", account, contact), format="json" + ) + invoice = Invoice.objects.get() + line = invoice.line_items.get() + url = f"/api/invoices/{invoice.id}/line-items/{line.id}/" + + refused = admin_client.put(url, {"tax_rate": "-1"}, format="json") + assert refused.status_code == 400, refused.content + assert refused.json()["errors"]["tax_rate"] == ["Tax rate cannot be negative."] + + added = admin_client.post( + f"/api/invoices/{invoice.id}/line-items/", + {**LINE, "tax_rate": "100.5"}, + format="json", + ) + assert added.status_code == 400, added.content + assert InvoiceLineItem.objects.filter(invoice=invoice).count() == 1 + + allowed = admin_client.put(url, {"tax_rate": "18"}, format="json") + assert allowed.status_code == 200, allowed.content + + +MODELS_WITH_TAX = [ + Invoice, + Estimate, + RecurringInvoice, + InvoiceLineItem, + EstimateLineItem, + RecurringInvoiceLineItem, +] + + +@pytest.mark.parametrize("model", MODELS_WITH_TAX) +@pytest.mark.parametrize( + "rate, message", + [ + ("-0.01", "Tax rate cannot be negative."), + ("100.01", "Tax rate cannot exceed 100."), + ("100", None), + ("0", None), + ], +) +def test_the_model_field_holds_the_bound(model, rate, message): + """The model field's own validator, which the Django admin's forms run.""" + field = model._meta.get_field("tax_rate") + if message is None: + field.run_validators(Decimal(rate)) + return + with pytest.raises(DjangoValidationError) as refused: + field.run_validators(Decimal(rate)) + assert refused.value.messages == [message] + + +def test_the_model_field_refuses_negative_shipping(): + field = Invoice._meta.get_field("shipping_amount") + with pytest.raises(DjangoValidationError) as refused: + field.run_validators(Decimal("-1")) + assert refused.value.messages == ["Shipping cannot be negative."] + field.run_validators(Decimal("0")) + + +@pytest.mark.django_db +def test_the_admin_form_refuses_it(org_a, account, admin_user): + from django.contrib.admin.sites import site + from django.test import RequestFactory + + invoice = Invoice.objects.create(org=org_a, account=account, invoice_title="W") + request = RequestFactory().get("/admin/") + request.user = admin_user + Form = site._registry[Invoice].get_form(request, invoice) + data = { + name: getattr(invoice, name) + for name in Form.base_fields + if getattr(invoice, name, None) is not None + and name not in ("assigned_to", "teams") + } + data.update(account=account.pk, org=org_a.pk, tax_rate="150", shipping_amount="-2") + + form = Form(data=data, instance=invoice) + + assert not form.is_valid() + assert form.errors["tax_rate"] == ["Tax rate cannot exceed 100."] + assert form.errors["shipping_amount"] == ["Shipping cannot be negative."] diff --git a/backend/leads/access.py b/backend/leads/access.py index 7acf43c3..f1645911 100644 --- a/backend/leads/access.py +++ b/backend/leads/access.py @@ -1,19 +1,17 @@ """Who may see a lead. One rule, asked two ways: ``visible_leads_qs`` narrows a queryset (the lead list, -the board, and the pipeline lead counts), and ``has_lead_access`` answers for -one lead (the detail view and the attachment download). Both live here so the -answers cannot drift apart. +the board, the pipeline lead counts, and the detail, move, comment and +attachment lookups, so a hidden lead is the same 404 as a missing one), and +``has_lead_access`` answers for one lead (the attachment download). Both live +here so the answers cannot drift apart. """ from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from common.permissions import is_org_admin from leads.models import Lead -_DENIED = "You do not have Permission to perform this action" - def has_lead_access(profile, user, lead): """Admins and superusers see every lead in the org; everyone else sees @@ -52,15 +50,3 @@ def visible_leads_qs(profile, user): Q(created_by=profile.user) | Q(pk__in=Lead.objects.filter(assigned_to=profile).values("pk")) ) - - -def assert_lead_access(profile, user, lead): - """Raise 403 unless ``profile`` may open ``lead``. - - It raises rather than returning a Response: ``get_context_data`` returns - the dict that ``get()`` passes to ``Response(...)``, so a Response returned - from in there was wrapped in a second one and rendered as a 500 instead of - the intended 403. - """ - if not has_lead_access(profile, user, lead): - raise PermissionDenied(_DENIED) diff --git a/backend/leads/tests/test_attachment_delete_authz.py b/backend/leads/tests/test_attachment_delete_authz.py index d44bde0f..3901a852 100644 --- a/backend/leads/tests/test_attachment_delete_authz.py +++ b/backend/leads/tests/test_attachment_delete_authz.py @@ -63,16 +63,20 @@ class TestLeadAttachmentDelete: assert not Attachments.objects.filter(id=att.id).exists() def test_uploader_deletes_own_attachment( - self, org_a, user_client, regular_user, lead_a + self, user_profile, org_a, user_client, regular_user, lead_a ): """A non-admin may delete what they uploaded. The `created_by` branch.""" att = _attachment(org_a, lead_a, regular_user) + lead_a.assigned_to.add(user_profile) # can open it assert user_client.delete(self.URL.format(att.id)).status_code == 200 assert not Attachments.objects.filter(id=att.id).exists() - def test_other_member_cannot_delete(self, org_a, user_client, admin_user, lead_a): + def test_other_member_cannot_delete( + self, user_profile, org_a, user_client, admin_user, lead_a + ): """The same check must be able to return False, or it is not a check.""" att = _attachment(org_a, lead_a, admin_user) + lead_a.assigned_to.add(user_profile) # can open it assert user_client.delete(self.URL.format(att.id)).status_code == 403 assert Attachments.objects.filter(id=att.id).exists() diff --git a/backend/leads/tests/test_convert_requires_account_access.py b/backend/leads/tests/test_convert_requires_account_access.py index 41d6b17f..f87e31ef 100644 --- a/backend/leads/tests/test_convert_requires_account_access.py +++ b/backend/leads/tests/test_convert_requires_account_access.py @@ -206,7 +206,7 @@ class TestRefusedWithoutAccountAccess: ): _send(user_client, "patch", _url(lead), {"status": "converted"}) opened = user_client.get(f"/api/accounts/{hidden_account.id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_the_service_refuses_on_its_own( self, lead, hidden_account, admin_profile, user_profile, regular_user diff --git a/backend/leads/tests/test_create_lead_from_site.py b/backend/leads/tests/test_create_lead_from_site.py index 04d3339d..33dc01cd 100644 --- a/backend/leads/tests/test_create_lead_from_site.py +++ b/backend/leads/tests/test_create_lead_from_site.py @@ -357,7 +357,7 @@ class TestExistingContactKeepsItsAssignees: admin_client.post(URL, payload(api_setting), format="json") - assert user_client.get(f"/api/contacts/{existing.id}/").status_code == 403 + assert user_client.get(f"/api/contacts/{existing.id}/").status_code == 404 def test_a_new_contact_takes_the_forms_assignee( self, admin_client, org_a, user_profile, api_setting diff --git a/backend/leads/tests/test_lead_access_and_totals.py b/backend/leads/tests/test_lead_access_and_totals.py index 70b51359..acbf9e75 100644 --- a/backend/leads/tests/test_lead_access_and_totals.py +++ b/backend/leads/tests/test_lead_access_and_totals.py @@ -193,10 +193,10 @@ class TestLeadDetailRead: assert admin_client.get(_detail_url(lead.id)).status_code == 200 def test_unrelated_non_admin_is_refused(self, user_client, org_a): - """Neither assigned nor creator, a real 403, not a 500.""" + """Neither assigned nor creator: 404, as for a missing id, not a 500.""" lead = _lead(org_a) - assert user_client.get(_detail_url(lead.id)).status_code == 403 + assert user_client.get(_detail_url(lead.id)).status_code == 404 def test_another_org_cannot_read(self, org_b_client, org_a): lead = _lead(org_a) @@ -386,7 +386,7 @@ class TestLeadDetailWrite: content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 lead.refresh_from_db() assert lead.first_name == "Ada" @@ -403,7 +403,7 @@ class TestLeadDetailWrite: content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 assert user_profile not in lead.assigned_to.all() def test_unrelated_non_admin_cannot_force_conversion(self, user_client, org_a): @@ -417,7 +417,7 @@ class TestLeadDetailWrite: content_type="application/json", ) - assert response.status_code == 403 + assert response.status_code == 404 lead.refresh_from_db() assert lead.status == "in process" diff --git a/backend/leads/tests/test_lead_existence_oracle.py b/backend/leads/tests/test_lead_existence_oracle.py new file mode 100644 index 00000000..56b5febf --- /dev/null +++ b/backend/leads/tests/test_lead_existence_oracle.py @@ -0,0 +1,157 @@ +"""A lead the caller may not open answers exactly like a lead that does not exist. + +Owner decision for 1.11.0: every endpoint that takes a lead id, and every +comment or attachment endpoint whose record is a lead, looks it up through the +read rule (`leads.access.visible_leads_qs`) in one step. The detail view used +to fetch org-wide and then check, answering 403 for a hidden lead and 404 for +a missing one, so any member could tell which ids were real. Each test +compares the two responses WHOLE, status and body. + +Writes narrower than reads still refuse with 403, but only a caller who can +open the lead: an assignee cannot delete it, a non-author cannot edit a +comment on it. +""" + +import pytest +from django.contrib.contenttypes.models import ContentType + +from common.models import Attachments, Comment +from leads.models import Lead, LeadPipeline, LeadStage + +MISSING = "11111111-1111-1111-1111-111111111111" + + +@pytest.fixture +def lead(org_a): + """In the caller's org, created by nobody, assigned to nobody: hidden.""" + return Lead.objects.create( + first_name="Hidden", last_name="Lead", email="hidden@example.com", org=org_a + ) + + +@pytest.fixture +def comment(lead, admin_profile, org_a): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(Lead), + object_id=lead.id, + comment="Not yours", + commented_by=admin_profile, + org=org_a, + ) + + +@pytest.fixture +def attachment(lead, admin_user, org_a): + return Attachments.objects.create( + content_type=ContentType.objects.get_for_model(Lead), + object_id=lead.id, + file_name="x.txt", + attachment="attachments/x.txt", + created_by=admin_user, + org=org_a, + ) + + +def _same(client, method, hidden_url, missing_url, body=None): + call = getattr(client, method) + kwargs = {"format": "json"} if body is not None else {} + args = (body,) if body is not None else () + hidden = call(hidden_url, *args, **kwargs) + missing = call(missing_url, *args, **kwargs) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + + +DETAIL = [ + ("get", None), + ("put", {"first_name": "Mine", "last_name": "Now"}), + ("patch", {"first_name": "Mine"}), + ("post", {"comment": "hello"}), + ("delete", None), +] + + +@pytest.mark.django_db +class TestLeadIdEndpoints: + @pytest.mark.parametrize("method,body", DETAIL) + def test_detail(self, user_client, lead, method, body): + _same( + user_client, + method, + f"/api/leads/{lead.id}/", + f"/api/leads/{MISSING}/", + body, + ) + lead.refresh_from_db() + assert lead.first_name == "Hidden" + + def test_move(self, user_client, lead, org_a): + pipeline = LeadPipeline.objects.create(name="P", org=org_a) + stage = LeadStage.objects.create( + pipeline=pipeline, name="S", order=1, org=org_a + ) + _same( + user_client, + "patch", + f"/api/leads/{lead.id}/move/", + f"/api/leads/{MISSING}/move/", + {"stage_id": str(stage.id)}, + ) + lead.refresh_from_db() + assert lead.stage_id is None + + +@pytest.mark.django_db +class TestCommentsAndFiles: + @pytest.mark.parametrize( + "method,body", + [("put", {"comment": "x"}), ("patch", {"comment": "x"}), ("delete", None)], + ) + def test_comment_on_hidden_lead(self, user_client, comment, method, body): + _same( + user_client, + method, + f"/api/leads/comment/{comment.id}/", + f"/api/leads/comment/{MISSING}/", + body, + ) + assert Comment.objects.filter(id=comment.id).exists() + + def test_attachment_on_hidden_lead(self, user_client, attachment): + _same( + user_client, + "delete", + f"/api/leads/attachment/{attachment.id}/", + f"/api/leads/attachment/{MISSING}/", + ) + assert Attachments.objects.filter(id=attachment.id).exists() + + def test_readable_lead_non_author_is_403( + self, user_client, user_profile, lead, comment, attachment + ): + lead.assigned_to.add(user_profile) + url = f"/api/leads/comment/{comment.id}/" + assert user_client.put(url, {"comment": "x"}, format="json").status_code == 403 + assert user_client.delete(url).status_code == 403 + assert ( + user_client.delete(f"/api/leads/attachment/{attachment.id}/").status_code + == 403 + ) + + +@pytest.mark.django_db +class TestDeleteStaysNarrower: + def test_assignee_who_is_not_creator_gets_403( + self, user_client, user_profile, lead + ): + lead.assigned_to.add(user_profile) + assert user_client.get(f"/api/leads/{lead.id}/").status_code == 200 + assert user_client.delete(f"/api/leads/{lead.id}/").status_code == 403 + assert Lead.objects.filter(id=lead.id).exists() + + def test_creator_may_delete(self, user_client, regular_user, lead): + Lead.objects.filter(id=lead.id).update(created_by=regular_user) + assert user_client.delete(f"/api/leads/{lead.id}/").status_code == 200 + + def test_admin_opens_any_lead(self, admin_client, lead): + assert admin_client.get(f"/api/leads/{lead.id}/").status_code == 200 diff --git a/backend/leads/tests/test_lead_move_wip_limit.py b/backend/leads/tests/test_lead_move_wip_limit.py new file mode 100644 index 00000000..baad703a --- /dev/null +++ b/backend/leads/tests/test_lead_move_wip_limit.py @@ -0,0 +1,111 @@ +"""D9: the WIP limit on a lead board move. + +The limit is a capacity of the stage, shared by everyone who works it, so it +counts every lead on the stage's lane, the same basis the ticket and task +board moves use, and not only the leads the mover can open. Counting per +caller would let each member fill the stage to the limit on their own. + +What it must not do is tell a member anything about the leads they cannot +see: the refusal names the limit and never a count, so its body is the same +however many hidden leads fill the stage. Leads that sit on no lane for +anyone (inactive or converted) do not count against it. +""" + +import pytest + +from leads.models import Lead, LeadPipeline, LeadStage + + +@pytest.fixture +def stage(org_a): + pipeline = LeadPipeline.objects.create(name="WIP", org=org_a) + return LeadStage.objects.create( + pipeline=pipeline, name="Limited", order=1, org=org_a, wip_limit=2 + ) + + +def _lead(org, name, **kw): + kw.setdefault("status", "assigned") + return Lead.objects.create( + first_name=name, + last_name="Lead", + email=f"{name.lower()}@example.com", + org=org, + **kw, + ) + + +def _mine(org, profile, name): + lead = _lead(org, name) + lead.assigned_to.add(profile) + return lead + + +def _move(client, lead, stage): + return client.patch( + f"/api/leads/{lead.id}/move/", {"stage_id": str(stage.id)}, format="json" + ) + + +@pytest.mark.django_db +class TestWipLimit: + def test_room_under_the_limit_allows_the_move( + self, user_client, user_profile, org_a, stage + ): + _lead(org_a, "Hidden", stage=stage) # one of two slots, not the caller's + lead = _mine(org_a, user_profile, "Mover") + + response = _move(user_client, lead, stage) + + assert response.status_code == 200 + lead.refresh_from_db() + assert lead.stage_id == stage.id + + def test_hidden_leads_count_toward_the_limit( + self, user_client, user_profile, org_a, stage + ): + _lead(org_a, "HiddenOne", stage=stage) + _lead(org_a, "HiddenTwo", stage=stage) + lead = _mine(org_a, user_profile, "Mover") + + response = _move(user_client, lead, stage) + + assert response.status_code == 400 + lead.refresh_from_db() + assert lead.stage_id is None + + def test_refusal_does_not_reveal_how_many_are_hidden( + self, user_client, user_profile, org_a, stage + ): + """Two hidden leads or five, the member gets the same bytes.""" + for n in range(2): + _lead(org_a, f"First{n}", stage=stage) + first = _move(user_client, _mine(org_a, user_profile, "MoverA"), stage) + + for n in range(3): + _lead(org_a, f"Second{n}", stage=stage) + second = _move(user_client, _mine(org_a, user_profile, "MoverB"), stage) + + assert first.status_code == second.status_code == 400 + assert first.json() == second.json() + assert first.json() == { + "error": True, + "errors": "Stage 'Limited' has reached its WIP limit of 2", + } + + def test_off_board_leads_do_not_count(self, admin_client, org_a, stage): + """Inactive and converted leads are on no lane, even for an admin.""" + _lead(org_a, "Gone", stage=stage, is_active=False) + _lead(org_a, "Won", stage=stage, status="converted") + _lead(org_a, "Present", stage=stage) + lead = _lead(org_a, "Mover") + + assert _move(admin_client, lead, stage).status_code == 200 + + def test_the_moving_lead_is_not_counted_against_itself( + self, admin_client, org_a, stage + ): + _lead(org_a, "Present", stage=stage) + lead = _lead(org_a, "Mover", stage=stage) + + assert _move(admin_client, lead, stage).status_code == 200 diff --git a/backend/leads/tests/test_leads_api.py b/backend/leads/tests/test_leads_api.py index 496279f3..b420ca7e 100644 --- a/backend/leads/tests/test_leads_api.py +++ b/backend/leads/tests/test_leads_api.py @@ -626,7 +626,7 @@ class TestLeadDetailView: org=org_a, ) response = user_client.delete(_detail_url(lead.id)) - assert response.status_code == 403 + assert response.status_code == 404 def test_get_lead_detail_admin_with_assigned_users( self, admin_client, admin_user, admin_profile, org_a @@ -761,7 +761,7 @@ class TestLeadDetailView: def test_patch_lead_non_admin_not_creator_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin non-creator/non-assignee gets 403 on PATCH.""" + """Non-admin non-creator/non-assignee gets 404 on PATCH, as on GET.""" lead = Lead.objects.create( first_name="ForbidPatch", last_name="Lead", @@ -774,7 +774,7 @@ class TestLeadDetailView: {"first_name": "Hacked"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_patch_lead_invalid_data(self, admin_client, admin_user, org_a): """PATCH with invalid data returns 400.""" @@ -836,7 +836,7 @@ class TestLeadCommentView: def test_add_comment_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin user not assigned/creator gets 403 when adding comment.""" + """Non-admin user not assigned/creator gets 404 when adding comment.""" lead = Lead.objects.create( first_name="Forbidden", last_name="Comment", @@ -849,7 +849,7 @@ class TestLeadCommentView: {"comment": "Should be forbidden"}, format="json", ) - assert response.status_code == 403 + assert response.status_code == 404 def test_update_comment_put(self, admin_client, admin_user, admin_profile, org_a): """Admin can update a comment via PUT.""" @@ -938,6 +938,7 @@ class TestLeadCommentView: created_by=admin_user, org=org_a, ) + lead.assigned_to.add(user_profile) # can open it ct = ContentType.objects.get_for_model(Lead) comment = Comment.objects.create( content_type=ct, @@ -965,6 +966,7 @@ class TestLeadCommentView: created_by=admin_user, org=org_a, ) + lead.assigned_to.add(user_profile) # can open it ct = ContentType.objects.get_for_model(Lead) comment = Comment.objects.create( content_type=ct, @@ -1017,6 +1019,7 @@ class TestLeadAttachmentView: created_by=admin_user, org=org_a, ) + lead.assigned_to.add(user_profile) # can open it ct = ContentType.objects.get_for_model(Lead) attachment = Attachments.objects.create( content_type=ct, @@ -1393,13 +1396,14 @@ class TestLeadDetailViewNonAdmin: def test_detail_non_admin_not_assigned_not_creator_gets_error( self, user_client, admin_user, org_a, user_profile ): - """A non-admin who is neither assigned nor the creator gets a 403. + """A non-admin who is neither assigned nor the creator gets a 404. This used to assert `pytest.raises(TypeError, match="not JSON serializable")`, and the docstring described the mechanism as though it were intended: `get_context_data` returned a `Response` on refusal and `get()` wrapped it in a second one, so the 403 rendered as a 500. The - refusal is now raised, so it arrives as the status it always meant. + lead is now looked up through the read rule, so a lead the caller + cannot open answers exactly as a missing one. """ lead = self._create_lead_with_creator( admin_user, @@ -1409,7 +1413,7 @@ class TestLeadDetailViewNonAdmin: email="forbiddendetail@example.com", ) - assert user_client.get(_detail_url(lead.id)).status_code == 403 + assert user_client.get(_detail_url(lead.id)).status_code == 404 def test_detail_non_admin_creator_assigned_exercises_creator_branch( self, user_client, regular_user, org_a, user_profile diff --git a/backend/leads/views/kanban_views.py b/backend/leads/views/kanban_views.py index 9f4750cd..38a955d8 100644 --- a/backend/leads/views/kanban_views.py +++ b/backend/leads/views/kanban_views.py @@ -5,7 +5,6 @@ Supports both status-based (default) and custom pipeline-based kanban boards. from django.db import transaction from django.db.models import Max, Q -from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import serializers, status @@ -17,7 +16,7 @@ from common.kanban import place_in_column from common.permissions import HasOrgContext, is_org_admin from common.utils import LEAD_STATUS from common.validators import date_param, uuid_param -from leads.access import has_lead_access, visible_leads_qs +from leads.access import visible_leads_qs from leads.models import Lead, LeadPipeline, LeadStage from leads.serializer import ( LeadKanbanCardSerializer, @@ -261,20 +260,18 @@ class LeadMoveView(APIView): org = request.profile.org # Locked for the transaction: the move saves the whole row, so an # edit committing between this read and that save would be lost. + # Looked up through the lead read rule, from the one place it is + # defined, so a lead the caller may not open is the same 404, body + # and all, as an id that does not exist. A separate check raising a + # bare 404 afterwards answered with a different body. lead = get_object_or_404( - Lead.objects.select_for_update(of=("self",)).select_related( - "stage__pipeline" - ), + visible_leads_qs(request.profile, request.user) + .select_for_update(of=("self",)) + .select_related("stage__pipeline"), pk=pk, org=org, ) - # The lead write rule, from the one place it is defined. A lead the - # caller may not open is a 404, as if it did not exist, so a move - # cannot confirm the id of a lead the board withholds. - if not has_lead_access(request.profile, request.user, lead): - raise Http404 - serializer = LeadMoveSerializer(data=request.data) if not serializer.is_valid(): return Response( @@ -341,9 +338,18 @@ class LeadMoveView(APIView): status=status.HTTP_400_BAD_REQUEST, ) - # Check WIP limit + # WIP limit: a capacity of the stage, shared by everyone who + # works it, so it counts every lead on the stage's lane, as + # the ticket and task moves do, not only the ones this caller + # can open. Counting per caller would let each member fill the + # stage to the limit. Off-board leads (inactive or converted) + # sit on no lane for anyone and are not counted. The refusal + # names the limit, never the count, so it says nothing about + # how many leads the caller cannot see. if stage.wip_limit: - current_count = stage.leads.exclude(pk=lead.pk).count() + current_count = ( + _board_leads(stage.leads).exclude(pk=lead.pk).count() + ) if current_count >= stage.wip_limit: return Response( { @@ -433,7 +439,7 @@ class LeadPipelineListCreateView(APIView): org = request.profile.org # Only admins can create pipelines - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Only admins can create pipelines"}, status=status.HTTP_403_FORBIDDEN, @@ -537,7 +543,7 @@ class LeadPipelineDetailView(APIView): @transaction.atomic def put(self, request, pk): """Update pipeline.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -565,7 +571,7 @@ class LeadPipelineDetailView(APIView): @extend_schema(tags=["Lead Pipelines"], responses={204: None}) def delete(self, request, pk): """Delete pipeline (soft delete by setting is_active=False).""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -603,7 +609,7 @@ class LeadStageCreateView(APIView): ) def post(self, request, pipeline_pk): """Add a new stage to pipeline.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -648,7 +654,7 @@ class LeadStageDetailView(APIView): ) def put(self, request, pk): """Update stage.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -672,7 +678,7 @@ class LeadStageDetailView(APIView): @extend_schema(tags=["Lead Stages"], responses={204: None}) def delete(self, request, pk): """Delete stage.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -712,7 +718,7 @@ class LeadStageReorderView(APIView): @transaction.atomic def post(self, request, pipeline_pk): """Reorder stages by providing ordered list of stage IDs.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) diff --git a/backend/leads/views/lead_interactions.py b/backend/leads/views/lead_interactions.py index a275c3e3..5a1e6915 100644 --- a/backend/leads/views/lead_interactions.py +++ b/backend/leads/views/lead_interactions.py @@ -8,13 +8,14 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from common.lookups import get_scoped_or_404 +from common.lookups import get_on_visible_record_or_404 from common.models import APISettings, Attachments, Comment from common.permissions import HasOrgContext, is_org_admin from common.request_meta import client_ip, referer from common.serializer import LeadCommentSerializer from contacts.models import Contact from leads import swagger_params +from leads.access import visible_leads_qs from leads.serializer import ( CreateLeadFromSiteSwaggerSerializer, LeadCommentEditSwaggerSerializer, @@ -32,7 +33,13 @@ class LeadCommentView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - return get_scoped_or_404(self.model, pk, self.request.profile.org) + """404, like a missing id, for a comment on a lead the caller cannot open.""" + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + visible_leads_qs(self.request.profile, self.request.user), + ) @extend_schema( tags=["Leads"], @@ -51,11 +58,7 @@ class LeadCommentView(APIView): def put(self, request, pk, format=None): params = request.data obj = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == obj.commented_by - ): + if is_org_admin(request.profile) or request.profile == obj.commented_by: serializer = LeadCommentSerializer(obj, data=params) if serializer.is_valid(): serializer.save() @@ -94,11 +97,7 @@ class LeadCommentView(APIView): """Handle partial updates to a comment.""" params = request.data obj = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == obj.commented_by - ): + if is_org_admin(request.profile) or request.profile == obj.commented_by: serializer = LeadCommentSerializer(obj, data=params, partial=True) if serializer.is_valid(): serializer.save() @@ -133,11 +132,7 @@ class LeadCommentView(APIView): ) def delete(self, request, pk, format=None): self.object = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == self.object.commented_by - ): + if is_org_admin(request.profile) or request.profile == self.object.commented_by: self.object.delete() return Response( {"error": False, "message": "Comment Deleted Successfully"}, @@ -176,10 +171,15 @@ class LeadAttachmentView(APIView): # id raised out of the view as a 500 instead of answering 404. The # comment view above already scopes its lookup, as do the accounts, # contacts, cases, tasks, tags and teams equivalents. - self.object = get_scoped_or_404(self.model, pk, request.profile.org) + # And on a lead the caller cannot open, the same 404 as a missing id. + self.object = get_on_visible_record_or_404( + self.model, + pk, + request.profile.org, + visible_leads_qs(request.profile, request.user), + ) if ( is_org_admin(request.profile) - or request.user.is_superuser or request.profile.user == self.object.created_by ): self.object.delete() diff --git a/backend/leads/views/lead_views.py b/backend/leads/views/lead_views.py index c8718512..e7400c41 100644 --- a/backend/leads/views/lead_views.py +++ b/backend/leads/views/lead_views.py @@ -455,22 +455,19 @@ class LeadDetailView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - return get_object_or_404(Lead, id=pk, org=self.request.profile.org) + """The lead, or 404 when it is missing or the caller may not open it. - def assert_lead_access(self): - """Delegates to `leads.access`, which holds the one definition. - - The attachment download view has to ask the same question, and a - second copy of the answer is how the two copies this replaced drifted - apart in the first place. + One lookup through the read rule (`leads.access.visible_leads_qs`), so + a same-org lead the caller cannot open answers every verb exactly as + an id that does not exist. Fetching org-wide and checking afterwards + answered 403 instead, which confirmed the id was real. """ - access.assert_lead_access( - self.request.profile, self.request.user, self.lead_obj + return get_object_or_404( + access.visible_leads_qs(self.request.profile, self.request.user), id=pk ) def get_context_data(self, **kwargs): context = {} - self.assert_lead_access() lead_content_type = ContentType.objects.get_for_model(Lead) comments = Comment.objects.filter( @@ -486,7 +483,7 @@ class LeadDetailView(APIView): assigned_dict["name"] = each.user.email assigned_data.append(assigned_dict) - if self.request.user.is_superuser or is_org_admin(self.request.profile): + if is_org_admin(self.request.profile): users_mention = list( Profile.objects.filter( is_active=True, org=self.request.profile.org @@ -524,7 +521,7 @@ class LeadDetailView(APIView): object_id=self.lead_obj.id, org=self.request.profile.org, ).order_by("-id") - if is_org_admin(self.request.profile) or self.request.user.is_superuser: + if is_org_admin(self.request.profile): users = Profile.objects.filter( is_active=True, org=self.request.profile.org ).order_by("user__email") @@ -632,26 +629,9 @@ class LeadDetailView(APIView): params = request.data context = {} - # `get_object` is what the other five methods on this view use, and it - # answers both failures this handler used to get wrong: an id matching - # no lead raised `DoesNotExist` out of the handler as a 500, and a lead - # in another tenant got a 403 that confirmed the id was real. + # `get_object` is what the other methods on this view use: a missing + # lead and one the caller may not open are the same 404. self.lead_obj = self.get_object(pk) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - if not ( - (self.request.profile.user == self.lead_obj.created_by) - or (self.request.profile in self.lead_obj.assigned_to.all()) - ): - return Response( - { - "error": True, - "errors": "You do not have Permission to perform this action", - }, - status=status.HTTP_403_FORBIDDEN, - ) # Before the comment is saved, so a refused file does not leave it posted. validate_attachment(self.request.FILES.get("lead_attachment")) if params.get("comment"): @@ -723,14 +703,10 @@ class LeadDetailView(APIView): def put(self, request, pk, **kwargs): """Fully update a lead, optionally converting it to an account.""" params = request.data + # Through the read rule: this method once had no ownership test at + # all, so any member could rewrite any lead by id, including the ones + # the list hides from them. Now those are a 404, like a missing id. self.lead_obj = self.get_object(pk) - # `get_object` scopes to the org, so a lead belonging to another - # tenant is already a 404 by the time we get here. What was missing is - # the check *within* the org: this method had no role or ownership test - # at all, so any authenticated member could rewrite any lead by id, - # reassign it, change its value, or push it through conversion, - # including the ones the list view deliberately hides from them. - self.assert_lead_access() previous_assigned_to_users = list( self.lead_obj.assigned_to.all().values_list("id", flat=True) ) @@ -880,27 +856,10 @@ class LeadDetailView(APIView): def patch(self, request, pk, **kwargs): """Handle partial updates to a lead, including conversion.""" params = request.data - # No org comparison after this. `get_object` filters on - # `org=self.request.profile.org`, so a mismatch is already a 404 and - # the 403 branch that used to sit here could never run. + # Through the read rule: another org's lead, a hidden one and a + # missing id are the same 404. self.lead_obj = self.get_object(pk) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - if not ( - (self.request.profile.user == self.lead_obj.created_by) - or (self.request.profile in self.lead_obj.assigned_to.all()) - ): - return Response( - { - "error": True, - "errors": "You do not have Permission to perform this action", - }, - status=status.HTTP_403_FORBIDDEN, - ) - # A conversion rides on an ordinary partial save. The fields sent with # it are saved first and the lead is converted afterwards, which is the # order PUT has always used. This branch used to convert straight away @@ -1084,14 +1043,12 @@ class LeadDetailView(APIView): ) def delete(self, request, pk, **kwargs): self.object = self.get_object(pk) - # The `and self.object.org == request.profile.org` conjunct that used - # to close this condition was always True: `get_object` already scoped - # the lookup to the caller's org. What remains is the role and - # ownership test, which is the only part that can answer False. + # Narrower than reading: an assignee may open the lead but only an + # admin, a superuser or its creator may delete it. The caller can see + # this lead by now, so the refusal below is an honest 403. if ( is_org_admin(request.profile) - or request.user.is_superuser - or request.profile.user == self.object.created_by + or request.profile.user_id == self.object.created_by_id ): self.object.delete() return Response( diff --git a/backend/opportunity/access.py b/backend/opportunity/access.py index 719346b1..53d4c216 100644 --- a/backend/opportunity/access.py +++ b/backend/opportunity/access.py @@ -1,18 +1,21 @@ """Who may open a deal. -Extracted from ``OpportunityDetailView.assert_deal_access`` so the attachment -download view asks the same question rather than carrying a second copy of the -answer. The detail view still calls it. +One definition, asked by the detail, line-item, move and attachment views +rather than carried inline by each: four inline copies is how the creator +branch came to be dead in all four of them. + +A deal the caller may not open answers exactly as an id that does not exist +(owner decision for 1.11.0). So the views look a deal up through +`visible_deals_qs` in one step, never an org-wide fetch followed by a check: +that shape answered 403 for a hidden deal and 404 for a missing one, and the +difference confirmed the id was real. """ from django.db.models import Q -from rest_framework.exceptions import PermissionDenied from common.permissions import is_org_admin from opportunity.models import Opportunity -_DENIED = "You do not have Permission to perform this action" - def has_deal_access(profile, user, opportunity): """Admins, the creator, and anyone assigned. Everyone else is refused. @@ -40,11 +43,10 @@ def visible_deals_qs(profile, user): return qs.filter(Q(created_by=profile.user) | Q(assigned_to=profile)).distinct() -def assert_deal_access(profile, user, opportunity): - """Raise 403 unless ``profile`` may open ``opportunity``. +def get_visible_deal(profile, user, pk): + """The deal ``pk`` if ``profile`` may open it, else ``None``. - Raising beats returning a Response: a returned Response from a helper gets - wrapped in ``Response(...)`` by the caller and renders as a 500. + ``None`` for a hidden deal and for a missing one alike, so the caller + cannot answer the two differently. """ - if not has_deal_access(profile, user, opportunity): - raise PermissionDenied(_DENIED) + return visible_deals_qs(profile, user).filter(pk=pk).first() diff --git a/backend/opportunity/next_activity.py b/backend/opportunity/next_activity.py new file mode 100644 index 00000000..2f18a4c8 --- /dev/null +++ b/backend/opportunity/next_activity.py @@ -0,0 +1,109 @@ +"""A deal's next activity: its earliest open task, among the tasks the caller can open. + +Shown on the deal list and the deal board so a deal with nothing scheduled +stands out. Only tasks the caller may read are considered, through +`tasks.access.visible_tasks_qs`, the rule the task list and detail use. A +deal is readable by people who cannot open every task on it, and counting a +hidden task would put its title and due date on their card. + +It is returned for every deal, closed ones included, but "no next step" is a +flag only on an open deal: a won or lost deal has no next step to take. The +clients decide that from `stage_kind`, which travels in the same payload and +is derived from the deal's own pipeline stage, so neither re-derives it from +the stage code. + +"Open" is every status but Completed, the line the task list's totals draw. +Earliest means the soonest due date, undated tasks after dated ones, then the +oldest task, then the id, so a tie still has one answer. + +Each field is its own correlated subquery with that same ordering, so joining +the tasks never multiplies the deal rows. They are computed only for the deals +a response actually serializes (`attach_next_activity`, one query for a page +or a whole board), never on the queryset a paginator or a column counts: a +COUNT(*) over an annotated queryset would run all three subqueries for every +visible deal. The subqueries select from `Task` by id rather than from +`visible_tasks_qs` directly: for a non-admin that queryset is DISTINCT, and a +DISTINCT subquery ordered by a column it does not select is refused by +Postgres. +""" + +from django.db.models import DateField, F, OuterRef, Subquery, UUIDField +from rest_framework import serializers + +from opportunity.models import Opportunity +from tasks.access import visible_tasks_qs +from tasks.models import Task + +DONE = "Completed" + + +def annotate_next_activity(queryset, profile): + """``queryset`` of deals with ``next_activity_id``, ``_title`` and ``_due``. + + All three are ``None`` on a deal with no open task the caller can open. + """ + tasks = ( + Task.objects.filter( + org=profile.org, + opportunity=OuterRef("pk"), + id__in=visible_tasks_qs(profile).values("id"), + ) + .exclude(status=DONE) + .order_by(F("due_date").asc(nulls_last=True), "created_at", "id") + ) + return queryset.annotate( + next_activity_id=Subquery(tasks.values("id")[:1], output_field=UUIDField()), + next_activity_title=Subquery(tasks.values("title")[:1]), + next_activity_due=Subquery( + tasks.values("due_date")[:1], output_field=DateField() + ), + ) + + +def attach_next_activity(deals, profile): + """Set ``next_activity_id``, ``_title`` and ``_due`` on each deal in ``deals``. + + ``deals`` is an evaluated page or board, not a queryset: one query for all + of them, keyed by id, so the serializer reads the same attributes as it + would from an annotated row and nothing was counted with them. + """ + deals = list(deals) + rows = { + row["pk"]: row + for row in annotate_next_activity( + Opportunity.objects.filter( + org=profile.org, pk__in=[deal.pk for deal in deals] + ), + profile, + ).values("pk", "next_activity_id", "next_activity_title", "next_activity_due") + } + for deal in deals: + row = rows.get(deal.pk, {}) + deal.next_activity_id = row.get("next_activity_id") + deal.next_activity_title = row.get("next_activity_title") + deal.next_activity_due = row.get("next_activity_due") + return deals + + +class NextActivitySerializer(serializers.Serializer): + id = serializers.UUIDField() + title = serializers.CharField() + due_date = serializers.DateField(allow_null=True) + + +def next_activity(deal): + """The annotated next activity as a dict, or ``None`` when the deal has none. + + Reads the attributes directly: a deal that was not passed through + `attach_next_activity` raises here rather than reporting "nothing + scheduled" for a deal nobody checked. + """ + if deal.next_activity_id is None: + return None + return NextActivitySerializer( + { + "id": deal.next_activity_id, + "title": deal.next_activity_title, + "due_date": deal.next_activity_due, + } + ).data diff --git a/backend/opportunity/serializer.py b/backend/opportunity/serializer.py index 5e2a0137..b0a9d563 100644 --- a/backend/opportunity/serializer.py +++ b/backend/opportunity/serializer.py @@ -22,6 +22,7 @@ from opportunity.models import ( OpportunityLineItem, SalesGoal, ) +from opportunity.next_activity import NextActivitySerializer, next_activity from opportunity.stages import stage_index from opportunity.workflow import CLOSED_KINDS, OPEN, STAGE_KINDS, WON @@ -268,6 +269,25 @@ class OpportunitySerializer(DealStageFieldsMixin, serializers.ModelSerializer): ) +class OpportunityListSerializer(OpportunitySerializer): + """A deal list row: the full deal plus its next activity. + + A separate class because only the list annotates the queryset (see + `opportunity.next_activity`); the detail and the account and user pages + reuse `OpportunitySerializer` without it, and a key that said "nothing + scheduled" there would be a claim nobody had checked. + """ + + next_activity = serializers.SerializerMethodField() + + class Meta(OpportunitySerializer.Meta): + fields = OpportunitySerializer.Meta.fields + ("next_activity",) + + @extend_schema_field(NextActivitySerializer(allow_null=True)) + def get_next_activity(self, obj): + return next_activity(obj) + + class OpportunityCreateSerializer(serializers.ModelSerializer): """Serializer for creating/updating Opportunity data""" @@ -481,6 +501,12 @@ class OpportunityKanbanCardSerializer( allow_null=True, read_only=True, ) + # Needs a queryset passed through `annotate_next_activity`. + next_activity = serializers.SerializerMethodField() + + @extend_schema_field(NextActivitySerializer(allow_null=True)) + def get_next_activity(self, obj): + return next_activity(obj) class Meta: model = Opportunity @@ -501,6 +527,7 @@ class OpportunityKanbanCardSerializer( "assigned_to", "days_in_stage", "aging_status", + "next_activity", "created_at", ) diff --git a/backend/opportunity/tests/test_attachment_delete_authz.py b/backend/opportunity/tests/test_attachment_delete_authz.py index 7f3b32bc..b7e24023 100644 --- a/backend/opportunity/tests/test_attachment_delete_authz.py +++ b/backend/opportunity/tests/test_attachment_delete_authz.py @@ -61,15 +61,19 @@ class TestOpportunityAttachmentDelete: assert not Attachments.objects.filter(id=att.id).exists() def test_uploader_deletes_own_attachment( - self, org_a, user_client, regular_user, opp_a + self, user_profile, org_a, user_client, regular_user, opp_a ): """The branch the `Profile` vs `User` mismatch had permanently False.""" att = _attachment(org_a, opp_a, regular_user) + opp_a.assigned_to.add(user_profile) # can open it assert user_client.delete(self.URL.format(att.id)).status_code == 200 assert not Attachments.objects.filter(id=att.id).exists() - def test_other_member_cannot_delete(self, org_a, user_client, admin_user, opp_a): + def test_other_member_cannot_delete( + self, user_profile, org_a, user_client, admin_user, opp_a + ): att = _attachment(org_a, opp_a, admin_user) + opp_a.assigned_to.add(user_profile) # can open it assert user_client.delete(self.URL.format(att.id)).status_code == 403 assert Attachments.objects.filter(id=att.id).exists() diff --git a/backend/opportunity/tests/test_deal_access_and_totals.py b/backend/opportunity/tests/test_deal_access_and_totals.py index 1d1cac74..7f3e4f9b 100644 --- a/backend/opportunity/tests/test_deal_access_and_totals.py +++ b/backend/opportunity/tests/test_deal_access_and_totals.py @@ -93,12 +93,13 @@ class TestDealDetailAccess: assert response.status_code == status.HTTP_200_OK assert response.data["comment_permission"] is True - def test_unrelated_non_admin_is_refused( + def test_unrelated_non_admin_is_not_found( self, user_client, user_profile, admin_user, org_a ): + """404, not 403: a deal they may not open reads as one that is not there.""" deal = _created_by(_deal(org_a, name="Not Mine"), admin_user) response = user_client.get(_detail_url(deal.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_other_org_is_not_found(self, org_b_client, org_a): """404, not 403. The reply must not confirm the id exists.""" @@ -157,7 +158,7 @@ class TestDealWriteAccess: response = user_client.patch( _detail_url(deal.pk), {"stage": "PROPOSAL"}, format="json" ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND deal.refresh_from_db() assert deal.stage == "QUALIFICATION" @@ -172,7 +173,7 @@ class TestDealWriteAccess: {"assigned_to": [str(user_profile.id)]}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND assert deal.assigned_to.count() == 0 def test_comment_on_missing_deal_is_404(self, admin_client): diff --git a/backend/opportunity/tests/test_deal_existence_oracle.py b/backend/opportunity/tests/test_deal_existence_oracle.py new file mode 100644 index 00000000..6958f997 --- /dev/null +++ b/backend/opportunity/tests/test_deal_existence_oracle.py @@ -0,0 +1,242 @@ +"""A deal the caller may not open answers exactly like a deal that does not exist. + +Owner decision for 1.11.0: every endpoint that takes a deal id looks the deal +up through the read rule (`opportunity.access.visible_deals_qs`), and so does +every comment or attachment endpoint whose record is a deal. The detail +view and the line-item views used to fetch org-wide and then check, which +answered 403 for a hidden deal and 404 for a missing one, so any member could +tell which ids were real. Each test below compares the two responses WHOLE, +status and body, because a 404 whose body differs is the same oracle. + +Writes stay narrower than reads where they already were: an assignee can open +a deal but only its creator or an admin may delete it, and that refusal is an +honest 403 because the caller can see the deal. +""" + +import uuid + +import pytest +from django.contrib.contenttypes.models import ContentType + +from common.models import Attachments, Comment +from opportunity.models import Opportunity, OpportunityLineItem + +MISSING = uuid.UUID("11111111-1111-1111-1111-111111111111") + + +def _deal(org, creator, name="Hidden deal"): + deal = Opportunity.objects.create(org=org, name=name, stage="PROSPECTING") + # `BaseModel.save()` stamps `created_by` from the request thread-local, + # which is empty in a test, so it is set with `.update()`. + Opportunity.objects.filter(pk=deal.pk).update(created_by=creator) + deal.refresh_from_db() + return deal + + +def _same(hidden, missing): + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + + +@pytest.fixture +def hidden_deal(org_a, admin_user): + """In the caller's org, created by an admin, assigned to nobody.""" + return _deal(org_a, admin_user) + + +@pytest.mark.django_db +class TestDetailVerbs: + URL = "/api/opportunities/{}/" + + def test_get(self, user_client, hidden_deal): + _same( + user_client.get(self.URL.format(hidden_deal.pk)), + user_client.get(self.URL.format(MISSING)), + ) + + def test_put(self, user_client, hidden_deal): + body = {"name": "Mine now", "stage": "PROSPECTING"} + _same( + user_client.put(self.URL.format(hidden_deal.pk), body, format="json"), + user_client.put(self.URL.format(MISSING), body, format="json"), + ) + hidden_deal.refresh_from_db() + assert hidden_deal.name == "Hidden deal" + + def test_patch(self, user_client, hidden_deal): + body = {"name": "Mine now"} + _same( + user_client.patch(self.URL.format(hidden_deal.pk), body, format="json"), + user_client.patch(self.URL.format(MISSING), body, format="json"), + ) + hidden_deal.refresh_from_db() + assert hidden_deal.name == "Hidden deal" + + def test_post_comment(self, user_client, hidden_deal): + body = {"comment": "hello"} + _same( + user_client.post(self.URL.format(hidden_deal.pk), body, format="json"), + user_client.post(self.URL.format(MISSING), body, format="json"), + ) + + def test_delete(self, user_client, hidden_deal): + _same( + user_client.delete(self.URL.format(hidden_deal.pk)), + user_client.delete(self.URL.format(MISSING)), + ) + assert Opportunity.objects.filter(pk=hidden_deal.pk).exists() + + +@pytest.mark.django_db +class TestDeleteStaysNarrower: + """Reading and deleting are different rules; the 404 is only for hidden deals.""" + + URL = "/api/opportunities/{}/" + + def test_assignee_who_is_not_creator_gets_403( + self, user_client, user_profile, hidden_deal + ): + hidden_deal.assigned_to.add(user_profile) + assert user_client.get(self.URL.format(hidden_deal.pk)).status_code == 200 + response = user_client.delete(self.URL.format(hidden_deal.pk)) + assert response.status_code == 403 + assert Opportunity.objects.filter(pk=hidden_deal.pk).exists() + + def test_creator_may_delete(self, user_client, regular_user, org_a): + deal = _deal(org_a, regular_user, name="Mine") + response = user_client.delete(self.URL.format(deal.pk)) + assert response.status_code == 200 + assert not Opportunity.objects.filter(pk=deal.pk).exists() + + def test_admin_still_opens_any_deal(self, admin_client, hidden_deal): + assert admin_client.get(self.URL.format(hidden_deal.pk)).status_code == 200 + + +@pytest.mark.django_db +class TestLineItems: + def _item(self, deal, org): + return OpportunityLineItem.objects.create( + opportunity=deal, name="Widget", quantity=1, unit_price=10, org=org + ) + + def test_list_and_create(self, user_client, hidden_deal): + url = "/api/opportunities/{}/line-items/" + _same( + user_client.get(url.format(hidden_deal.pk)), + user_client.get(url.format(MISSING)), + ) + body = {"name": "Sneaky", "quantity": 1, "unit_price": "5.00"} + _same( + user_client.post(url.format(hidden_deal.pk), body, format="json"), + user_client.post(url.format(MISSING), body, format="json"), + ) + assert not OpportunityLineItem.objects.filter(name="Sneaky").exists() + + def test_detail_verbs(self, user_client, hidden_deal, org_a): + item = self._item(hidden_deal, org_a) + url = "/api/opportunities/{}/line-items/{}/" + real = url.format(hidden_deal.pk, item.pk) + fake = url.format(MISSING, MISSING) + _same(user_client.get(real), user_client.get(fake)) + body = {"name": "Renamed"} + _same( + user_client.put(real, body, format="json"), + user_client.put(fake, body, format="json"), + ) + _same(user_client.delete(real), user_client.delete(fake)) + item.refresh_from_db() + assert item.name == "Widget" + + def test_assignee_can_still_price_the_deal( + self, user_client, user_profile, hidden_deal + ): + hidden_deal.assigned_to.add(user_profile) + response = user_client.post( + f"/api/opportunities/{hidden_deal.pk}/line-items/", + {"name": "Allowed", "quantity": 1, "unit_price": "5.00"}, + format="json", + ) + assert response.status_code == 201 + + +@pytest.mark.django_db +class TestMove: + URL = "/api/opportunities/{}/move/" + + def test_hidden_deal_moves_like_a_missing_one(self, user_client, hidden_deal): + body = {"column_id": "NEGOTIATION"} + _same( + user_client.patch(self.URL.format(hidden_deal.pk), body, format="json"), + user_client.patch(self.URL.format(MISSING), body, format="json"), + ) + hidden_deal.refresh_from_db() + assert hidden_deal.stage == "PROSPECTING" + + def test_assignee_may_move(self, user_client, user_profile, hidden_deal): + hidden_deal.assigned_to.add(user_profile) + response = user_client.patch( + self.URL.format(hidden_deal.pk), {"column_id": "NEGOTIATION"}, format="json" + ) + assert response.status_code == 200 + hidden_deal.refresh_from_db() + assert hidden_deal.stage == "NEGOTIATION" + + +@pytest.mark.django_db +class TestCommentsAndFiles: + """A comment or file on a hidden deal is the same 404 as a missing id.""" + + def _comment(self, deal, author, org): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(Opportunity), + object_id=deal.id, + comment="Not yours", + commented_by=author, + org=org, + ) + + def _file(self, deal, uploader, org): + return Attachments.objects.create( + content_type=ContentType.objects.get_for_model(Opportunity), + object_id=deal.id, + file_name="x.txt", + attachment="attachments/x.txt", + created_by=uploader, + org=org, + ) + + @pytest.mark.parametrize("method", ["put", "patch", "delete"]) + def test_comment_on_hidden_deal( + self, user_client, hidden_deal, admin_profile, org_a, method + ): + comment = self._comment(hidden_deal, admin_profile, org_a) + call = getattr(user_client, method) + args = () if method == "delete" else ({"comment": "x"},) + kwargs = {} if method == "delete" else {"format": "json"} + _same( + call(f"/api/opportunities/comment/{comment.id}/", *args, **kwargs), + call(f"/api/opportunities/comment/{MISSING}/", *args, **kwargs), + ) + assert Comment.objects.filter(id=comment.id).exists() + + def test_attachment_on_hidden_deal( + self, user_client, hidden_deal, admin_user, org_a + ): + attachment = self._file(hidden_deal, admin_user, org_a) + _same( + user_client.delete(f"/api/opportunities/attachment/{attachment.id}/"), + user_client.delete(f"/api/opportunities/attachment/{MISSING}/"), + ) + assert Attachments.objects.filter(id=attachment.id).exists() + + def test_readable_deal_non_author_is_403( + self, user_client, user_profile, hidden_deal, admin_profile, admin_user, org_a + ): + hidden_deal.assigned_to.add(user_profile) + comment = self._comment(hidden_deal, admin_profile, org_a) + attachment = self._file(hidden_deal, admin_user, org_a) + url = f"/api/opportunities/comment/{comment.id}/" + assert user_client.put(url, {"comment": "x"}, format="json").status_code == 403 + assert user_client.delete(url).status_code == 403 + attachment_url = f"/api/opportunities/attachment/{attachment.id}/" + assert user_client.delete(attachment_url).status_code == 403 diff --git a/backend/opportunity/tests/test_deal_next_activity.py b/backend/opportunity/tests/test_deal_next_activity.py new file mode 100644 index 00000000..967c2c7b --- /dev/null +++ b/backend/opportunity/tests/test_deal_next_activity.py @@ -0,0 +1,241 @@ +"""G31: each deal's next open task, on the deal list and the deal board. + +`next_activity` is the earliest open task linked to the deal, among the tasks +the CALLER can open. A member who can open a deal cannot necessarily open +every task on it, and a hidden task counted here would put its title and due +date on their card. Both endpoints are checked because they build their +querysets separately. +""" + +from datetime import date, timedelta + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from opportunity.models import Opportunity +from tasks.models import Task + +LIST = "/api/opportunities/" +BOARD = "/api/opportunities/kanban/" + + +def _deal(org, profile, name="Deal"): + deal = Opportunity.objects.create(org=org, name=name, stage="PROSPECTING") + deal.assigned_to.add(profile) + return deal + + +def _task(deal, title, *, visible_to=None, status="New", due=None): + task = Task.objects.create( + title=title, + status=status, + priority="Low", + org=deal.org, + opportunity=deal, + due_date=due, + ) + if visible_to is not None: + task.assigned_to.add(visible_to) + return task + + +def _from_list(client, deal): + rows = client.get(LIST).data["opportunities"] + return next(r for r in rows if r["id"] == str(deal.id))["next_activity"] + + +def _from_board(client, deal): + columns = client.get(BOARD).data["columns"] + cards = [card for column in columns for card in column["items"]] + return next(c for c in cards if c["id"] == str(deal.id))["next_activity"] + + +READERS = [_from_list, _from_board] + + +@pytest.mark.django_db +@pytest.mark.parametrize("read", READERS) +class TestNextActivity: + def test_visible_open_task_is_shown(self, read, user_client, user_profile, org_a): + deal = _deal(org_a, user_profile) + due = date.today() + timedelta(days=3) + task = _task(deal, "Send proposal", visible_to=user_profile, due=due) + + assert read(user_client, deal) == { + "id": str(task.id), + "title": "Send proposal", + "due_date": due.isoformat(), + } + + def test_hidden_task_is_never_shown(self, read, user_client, user_profile, org_a): + """The hidden task is due sooner, so a leak would pick it first.""" + deal = _deal(org_a, user_profile) + _task(deal, "Secret call", due=date.today()) + _task( + deal, + "My follow-up", + visible_to=user_profile, + due=date.today() + timedelta(days=5), + ) + + assert read(user_client, deal)["title"] == "My follow-up" + + def test_only_a_hidden_task_reads_as_none( + self, read, user_client, user_profile, org_a + ): + deal = _deal(org_a, user_profile) + _task(deal, "Secret call", due=date.today()) + + assert read(user_client, deal) is None + + def test_admin_sees_every_task(self, read, admin_client, user_profile, org_a): + deal = _deal(org_a, user_profile) + _task(deal, "Secret call", due=date.today()) + + assert read(admin_client, deal)["title"] == "Secret call" + + def test_deal_with_no_open_task_is_flagged( + self, read, user_client, user_profile, org_a + ): + deal = _deal(org_a, user_profile) + + assert read(user_client, deal) is None + + def test_completed_tasks_are_ignored(self, read, user_client, user_profile, org_a): + deal = _deal(org_a, user_profile) + _task( + deal, + "Done already", + visible_to=user_profile, + status="Completed", + due=date.today() - timedelta(days=1), + ) + assert read(user_client, deal) is None + + _task(deal, "Still to do", visible_to=user_profile, status="In Progress") + assert read(user_client, deal)["title"] == "Still to do" + + def test_earliest_due_wins_and_undated_comes_last( + self, read, user_client, user_profile, org_a + ): + deal = _deal(org_a, user_profile) + _task(deal, "Undated", visible_to=user_profile) + _task( + deal, + "Later", + visible_to=user_profile, + due=date.today() + timedelta(days=9), + ) + _task( + deal, + "Sooner", + visible_to=user_profile, + due=date.today() + timedelta(days=1), + ) + + assert read(user_client, deal)["title"] == "Sooner" + + def test_undated_task_still_counts(self, read, user_client, user_profile, org_a): + deal = _deal(org_a, user_profile) + _task(deal, "Undated", visible_to=user_profile) + + assert read(user_client, deal) == { + "id": str(Task.objects.get(title="Undated").id), + "title": "Undated", + "due_date": None, + } + + +@pytest.mark.django_db +def test_tasks_do_not_multiply_deal_rows(user_client, user_profile, org_a): + """Several tasks on several deals: one row per deal, and the totals agree.""" + deals = [_deal(org_a, user_profile, name=f"Deal {i}") for i in range(3)] + for deal in deals: + for n in range(3): + _task(deal, f"{deal.name} task {n}", visible_to=user_profile) + + data = user_client.get(LIST).data + assert sorted(r["id"] for r in data["opportunities"]) == sorted( + str(d.id) for d in deals + ) + assert data["totals"]["count"] == 3 + assert data["opportunities_count"] == 3 + + +@pytest.mark.django_db +def test_move_response_carries_next_activity(user_client, user_profile, org_a): + deal = _deal(org_a, user_profile) + _task(deal, "Call back", visible_to=user_profile) + + response = user_client.patch( + f"/api/opportunities/{deal.id}/move/", + {"column_id": "NEGOTIATION"}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["opportunity"]["next_activity"]["title"] == "Call back" + + +@pytest.mark.django_db +def test_next_activity_is_read_only(user_client, user_profile, org_a): + """A client cannot write it: PATCH ignores the key and creates no task.""" + deal = _deal(org_a, user_profile) + response = user_client.patch( + f"/api/opportunities/{deal.id}/", + {"next_activity": {"title": "Forged"}}, + format="json", + ) + assert response.status_code == 200 + assert not Task.objects.filter(title="Forged").exists() + assert _from_list(user_client, deal) is None + + +@pytest.mark.django_db +@pytest.mark.parametrize("read", READERS) +@pytest.mark.parametrize("code,kind", [("CLOSED_WON", "won"), ("CLOSED_LOST", "lost")]) +def test_closed_deal_carries_its_kind_beside_next_activity( + read, code, kind, user_client, user_profile, org_a +): + """The "none" flag is for open deals only, and the clients tell them apart + by `stage_kind` in the same row, so both payloads must carry it.""" + deal = _deal(org_a, user_profile) + Opportunity.objects.filter(pk=deal.pk).update(stage=code, amount=100) + + rows = ( + user_client.get(LIST).data["opportunities"] + if read is _from_list + else [ + card + for column in user_client.get(BOARD).data["columns"] + for card in column["items"] + ] + ) + row = next(r for r in rows if r["id"] == str(deal.id)) + + assert row["stage_kind"] == kind + assert row["next_activity"] is None + + +def _task_queries(client, url): + with CaptureQueriesContext(connection) as ctx: + assert client.get(url).status_code == 200 + return [q["sql"] for q in ctx.captured_queries if '"task"' in q["sql"]] + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", [LIST, BOARD]) +def test_counts_never_carry_the_task_subqueries(url, user_client, user_profile, org_a): + """The paginator's and each column's COUNT(*) ran the three correlated + task subqueries over every visible deal. They are now computed once, for + the rows actually returned, and never inside a count.""" + for i in range(4): + deal = _deal(org_a, user_profile, name=f"Deal {i}") + _task(deal, f"Task {i}", visible_to=user_profile) + + queries = _task_queries(user_client, url) + + assert not [q for q in queries if "COUNT(" in q.upper()] + # One query for the whole page or board, not one per deal or column. + assert len(queries) == 1 diff --git a/backend/opportunity/tests/test_opportunities_api.py b/backend/opportunity/tests/test_opportunities_api.py index b9f86131..8060fc7b 100644 --- a/backend/opportunity/tests/test_opportunities_api.py +++ b/backend/opportunity/tests/test_opportunities_api.py @@ -541,10 +541,10 @@ class TestOpportunityDetailView: ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_delete_non_admin_not_creator_forbidden( + def test_delete_non_admin_not_creator_not_found( self, user_client, admin_user, org_a, user_profile ): - """Non-admin user who did not create the opp cannot delete it.""" + """A deal the member cannot open is a 404 on DELETE, as on GET.""" _set_rls(org_a) opp = Opportunity.objects.create( name="Protected Opp", @@ -553,12 +553,12 @@ class TestOpportunityDetailView: created_by=admin_user, ) response = user_client.delete(_detail_url(opp.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_update_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin not assigned/creator gets 403 on PUT.""" + """Non-admin not assigned/creator gets 404 on PUT, as on GET.""" _set_rls(org_a) opp = Opportunity.objects.create( name="Forbidden Update Opp", @@ -571,12 +571,12 @@ class TestOpportunityDetailView: {"name": "Hacked Opp", "stage": "PROPOSAL"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_add_comment_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin user not assigned/creator gets 403 when adding comment.""" + """Non-admin user not assigned/creator gets 404 when adding comment.""" _set_rls(org_a) opp = Opportunity.objects.create( name="No Comment Opp", @@ -589,7 +589,7 @@ class TestOpportunityDetailView: {"comment": "Should fail"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_patch_opportunity_partial_update(self, admin_client, opportunity): """PATCH allows partial field updates.""" @@ -707,7 +707,7 @@ class TestOpportunityDetailView: def test_patch_opportunity_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin not assigned/creator gets 403 on PATCH.""" + """Non-admin not assigned/creator gets 404 on PATCH, as on GET.""" _set_rls(org_a) opp = Opportunity.objects.create( name="ForbidPatch Opp", @@ -720,7 +720,7 @@ class TestOpportunityDetailView: {"stage": "PROPOSAL"}, format="json", ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_patch_opportunity_invalid_data(self, admin_client, opportunity): """PATCH with invalid data returns 400.""" @@ -797,7 +797,8 @@ class TestOpportunityCommentView: self, user_client, comment_fixture, user_profile ): """Non-admin non-author gets 403 on PUT.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the deal response = user_client.put( _comment_url(comment.id), {"comment": "Hacked"}, @@ -809,7 +810,8 @@ class TestOpportunityCommentView: self, user_client, comment_fixture, user_profile ): """Non-admin non-author gets 403 on PATCH.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the deal response = user_client.patch( _comment_url(comment.id), {"comment": "Hacked"}, @@ -821,7 +823,8 @@ class TestOpportunityCommentView: self, user_client, comment_fixture, user_profile ): """Non-admin non-author gets 403 on DELETE.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the deal response = user_client.delete(_comment_url(comment.id)) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -855,7 +858,8 @@ class TestOpportunityCommentView: self, user_client, comment_fixture, user_profile ): """Authorization is still checked before the body is.""" - _, comment = comment_fixture + record, comment = comment_fixture + record.assigned_to.add(user_profile) # can open the deal response = user_client.put(_comment_url(comment.id), {}, format="json") assert response.status_code == status.HTTP_403_FORBIDDEN @@ -898,6 +902,7 @@ class TestOpportunityAttachmentView: org=org_a, created_by=admin_user, ) + opp.assigned_to.add(user_profile) # can open it ct = ContentType.objects.get_for_model(Opportunity) attachment = Attachments.objects.create( content_type=ct, @@ -1068,7 +1073,7 @@ class TestOpportunityLineItems: def test_line_item_non_admin_not_assigned_forbidden( self, user_client, admin_user, org_a, user_profile ): - """Non-admin not assigned/creator gets 403 on line items.""" + """Non-admin not assigned/creator gets 404 on line items, as on GET.""" _set_rls(org_a) opp = Opportunity.objects.create( name="Forbidden LI Opp", @@ -1077,7 +1082,7 @@ class TestOpportunityLineItems: created_by=admin_user, ) response = user_client.get(_line_items_url(opp.pk)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND # --------------------------------------------------------------------------- diff --git a/backend/opportunity/tests/test_opportunity_move.py b/backend/opportunity/tests/test_opportunity_move.py index af3c7725..f569bb53 100644 --- a/backend/opportunity/tests/test_opportunity_move.py +++ b/backend/opportunity/tests/test_opportunity_move.py @@ -52,12 +52,12 @@ class TestMoveRBAC: assert _move(user_client, deal).status_code == 200 - def test_unrelated_non_admin_is_refused(self, user_client, admin_user, org_a): + def test_unrelated_non_admin_is_not_found(self, user_client, admin_user, org_a): deal = _deal(org_a, created_by=admin_user) res = _move(user_client, deal) - assert res.status_code == 403 + assert res.status_code == 404 deal.refresh_from_db() assert deal.stage == "PROSPECTING" diff --git a/backend/opportunity/tests/test_sales_goals.py b/backend/opportunity/tests/test_sales_goals.py index 0d260857..c59f3754 100644 --- a/backend/opportunity/tests/test_sales_goals.py +++ b/backend/opportunity/tests/test_sales_goals.py @@ -1,5 +1,6 @@ """Tests for Sales Goals / Quotas feature.""" +import uuid from datetime import timedelta from decimal import Decimal from unittest.mock import patch @@ -318,10 +319,53 @@ class TestSalesGoalAPI: assert response.status_code == 200 assert not SalesGoal.objects.filter(id=goal_revenue.id).exists() - def test_delete_goal_non_admin_forbidden(self, user_client, goal_revenue): + def test_delete_goal_non_admin_forbidden( + self, user_client, user_profile, goal_revenue + ): + """A member who can see the goal (it is theirs) is refused with 403.""" + goal_revenue.assigned_to = user_profile + goal_revenue.save() url = f"{self.GOALS_URL}{goal_revenue.id}/" response = user_client.delete(url) assert response.status_code == 403 + assert SalesGoal.objects.filter(id=goal_revenue.id).exists() + + def test_update_goal_non_admin_forbidden( + self, user_client, user_profile, goal_revenue + ): + goal_revenue.assigned_to = user_profile + goal_revenue.save() + url = f"{self.GOALS_URL}{goal_revenue.id}/" + response = user_client.put(url, {"name": "Mine now"}, format="json") + assert response.status_code == 403 + goal_revenue.refresh_from_db() + assert goal_revenue.name == "Monthly Revenue" + + @pytest.mark.parametrize("method", ["get", "put", "delete"]) + def test_a_hidden_goal_answers_like_a_missing_one( + self, user_client, goal_revenue, method + ): + """Someone else's goal is 404 with the missing-id body on every verb, + so a member cannot tell it exists.""" + body = {"name": "x"} if method == "put" else None + hidden = getattr(user_client, method)( + f"{self.GOALS_URL}{goal_revenue.id}/", body, format="json" + ) + missing = getattr(user_client, method)( + f"{self.GOALS_URL}{uuid.uuid4()}/", body, format="json" + ) + assert hidden.status_code == missing.status_code == 404 + assert hidden.json() == missing.json() + goal_revenue.refresh_from_db() + assert goal_revenue.name == "Monthly Revenue" + + def test_a_member_reads_their_own_goal( + self, user_client, user_profile, goal_revenue + ): + goal_revenue.assigned_to = user_profile + goal_revenue.save() + response = user_client.get(f"{self.GOALS_URL}{goal_revenue.id}/") + assert response.status_code == 200 def test_org_isolation(self, org_b_client, goal_revenue): """org_b client should not see org_a's goals.""" diff --git a/backend/opportunity/views/aging_views.py b/backend/opportunity/views/aging_views.py index ae19938a..9bbc932b 100644 --- a/backend/opportunity/views/aging_views.py +++ b/backend/opportunity/views/aging_views.py @@ -47,7 +47,7 @@ class StageAgingConfigView(APIView): or carrying an unusable `expected_days`, are skipped, as they always were. """ - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can update aging config"}, status=status.HTTP_403_FORBIDDEN, diff --git a/backend/opportunity/views/goal_views.py b/backend/opportunity/views/goal_views.py index 865ed816..93678a97 100644 --- a/backend/opportunity/views/goal_views.py +++ b/backend/opportunity/views/goal_views.py @@ -22,8 +22,8 @@ def _visible_to(profile): teams' while the leaderboard declared only `IsAuthenticated` and `HasOrgContext` and scoped nothing. A member whose own list came back empty could still read every colleague's target, attainment and email off - `/goals/leaderboard/`. `SalesGoalDetailView.get` refuses the same person the - same goal with a 403, so all three now agree. + `/goals/leaderboard/`. `SalesGoalDetailView` answers the same person a 404 + for the same goal, on every verb, so all of them now agree. Scope: this only ever narrows within one org. Every caller has already filtered on `org=request.profile.org`, and RLS is underneath that. It is not @@ -33,7 +33,7 @@ def _visible_to(profile): def _sees_every_goal(request): - return is_org_admin(request.profile) or request.user.is_superuser + return is_org_admin(request.profile) class SalesGoalListView(APIView, LimitOffsetPagination): @@ -89,7 +89,7 @@ class SalesGoalListView(APIView, LimitOffsetPagination): ) def post(self, request, *args, **kwargs): - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can create goals."}, status=status.HTTP_403_FORBIDDEN, @@ -114,45 +114,44 @@ class SalesGoalListView(APIView, LimitOffsetPagination): class SalesGoalDetailView(APIView): + """One goal. A goal the caller cannot read answers exactly as a missing id + does (404, same body) on every verb, so ids cannot be probed. Only then is + a write checked for admin: a member editing a goal they can see gets 403. + """ + permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk, request): - return SalesGoal.objects.filter(id=pk, org=request.profile.org).first() + # The same predicate the list and the leaderboard use, applied as a + # queryset filter so the three cannot drift apart again. + goals = SalesGoal.objects.filter(org=request.profile.org) + if not _sees_every_goal(request): + goals = goals.filter(_visible_to(request.profile)).distinct() + return goals.filter(id=pk).first() + + @staticmethod + def _not_found(): + return Response( + {"error": True, "errors": "Goal not found."}, + status=status.HTTP_404_NOT_FOUND, + ) def get(self, request, pk, *args, **kwargs): goal = self.get_object(pk, request) if not goal: - return Response( - {"error": True, "errors": "Goal not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - # Evaluated as a queryset filter rather than by hand in Python so that - # it is literally the same predicate the list and the leaderboard use. - # Writing it out separately is how the three drifted apart. - if not _sees_every_goal(request) and not ( - SalesGoal.objects.filter(pk=goal.pk) - .filter(_visible_to(request.profile)) - .exists() - ): - return Response( - {"error": True, "errors": "You do not have permission."}, - status=status.HTTP_403_FORBIDDEN, - ) + return self._not_found() serializer = SalesGoalSerializer(goal) return Response(serializer.data) def put(self, request, pk, *args, **kwargs): - if not is_org_admin(request.profile) and not request.user.is_superuser: + goal = self.get_object(pk, request) + if not goal: + return self._not_found() + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can update goals."}, status=status.HTTP_403_FORBIDDEN, ) - goal = self.get_object(pk, request) - if not goal: - return Response( - {"error": True, "errors": "Goal not found."}, - status=status.HTTP_404_NOT_FOUND, - ) serializer = SalesGoalCreateSerializer( goal, data=request.data, partial=True, context={"request": request} ) @@ -168,17 +167,14 @@ class SalesGoalDetailView(APIView): ) def delete(self, request, pk, *args, **kwargs): - if not is_org_admin(request.profile) and not request.user.is_superuser: + goal = self.get_object(pk, request) + if not goal: + return self._not_found() + if not is_org_admin(request.profile): return Response( {"error": True, "errors": "Only admins can delete goals."}, status=status.HTTP_403_FORBIDDEN, ) - goal = self.get_object(pk, request) - if not goal: - return Response( - {"error": True, "errors": "Goal not found."}, - status=status.HTTP_404_NOT_FOUND, - ) goal.delete() return Response( {"error": False, "message": "Goal Deleted Successfully"}, diff --git a/backend/opportunity/views/kanban_views.py b/backend/opportunity/views/kanban_views.py index ab579651..a8e73ba0 100644 --- a/backend/opportunity/views/kanban_views.py +++ b/backend/opportunity/views/kanban_views.py @@ -7,7 +7,6 @@ consume both with the same shape. """ from django.db import transaction -from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -17,10 +16,11 @@ from rest_framework.response import Response from rest_framework.views import APIView from common.kanban import place_in_column -from common.permissions import HasOrgContext, is_org_admin +from common.permissions import HasOrgContext from common.validators import date_param, uuid_list_param, uuid_param -from opportunity.access import assert_deal_access +from opportunity.access import visible_deals_qs from opportunity.models import DealPipeline, DealStage, Opportunity +from opportunity.next_activity import attach_next_activity from opportunity.serializer import ( OpportunityKanbanCardSerializer, OpportunityMoveSerializer, @@ -63,19 +63,15 @@ class OpportunityKanbanView(APIView): else: pipeline = DealPipeline.default_for(org) + # The list's read rule, from the one place it is defined, so the board + # never shows a deal the table would not. queryset = ( - Opportunity.objects.filter(org=org, pipeline=pipeline) + visible_deals_qs(request.profile, request.user) + .filter(pipeline=pipeline) .select_related("account") .prefetch_related("assigned_to", "tags") ) - # Match the list view's RBAC scoping so users only see opps they own - # or are assigned to. Kanban shouldn't reveal more than the table. - if not is_org_admin(request.profile) and not request.user.is_superuser: - queryset = queryset.filter( - Q(created_by=request.profile.user) | Q(assigned_to=request.profile) - ).distinct() - queryset = self._apply_filters(queryset, request.query_params) # The org's stages read once and handed to every card, so no card @@ -87,6 +83,9 @@ class OpportunityKanbanView(APIView): opps = queryset.filter(stage=stage.code).order_by( "kanban_order", "-created_at" ) + # Cap at 100 per column to keep the payload bounded. Same cap + # tasks uses. + cards = list(opps[:100]) columns.append( { "id": stage.code, @@ -102,14 +101,20 @@ class OpportunityKanbanView(APIView): "is_status_column": True, "wip_limit": None, "item_count": opps.count(), - # Cap at 100 per column to keep the payload bounded. Same - # cap tasks uses. - "items": OpportunityKanbanCardSerializer( - opps[:100], many=True, context=context - ).data, + "items": cards, } ) + # One next-activity query for every card on the board, never inside + # a column's count. + attach_next_activity( + [card for column in columns for card in column["items"]], request.profile + ) + for column in columns: + column["items"] = OpportunityKanbanCardSerializer( + column["items"], many=True, context=context + ).data + return Response( { "mode": "status", @@ -165,16 +170,16 @@ class OpportunityMoveView(APIView): org = request.profile.org # Locked for the transaction: the move rewrites the whole row, so an # edit committing between the read and the save would be overwritten. + # Looked up through the deal read rule (admin/superuser, creator, + # assignee), so a deal the caller may not open is the same 404 as a + # missing id. The rule is a subquery because FOR UPDATE refuses the + # DISTINCT that `visible_deals_qs` carries for a non-admin. opportunity = get_object_or_404( - Opportunity.objects.select_for_update(), pk=pk, org=org + Opportunity.objects.select_for_update(), + pk=pk, + org=org, + id__in=visible_deals_qs(request.profile, request.user).values("id"), ) - # Same policy PR #747 fixed inline (admin/superuser, creator, assignee), - # asked once. That PR's one-line change was `request.profile == - # opportunity.created_by` -> `profile.user_id == created_by_id`, and - # has_deal_access already compares it that way. Keeping the helper - # keeps there being one copy: the inline version is what let the - # creator branch sit dead long enough to need a PR. - assert_deal_access(request.profile, request.user, opportunity) serializer = OpportunityMoveSerializer(data=request.data) if not serializer.is_valid(): @@ -258,6 +263,8 @@ class OpportunityMoveView(APIView): { "error": False, "message": "Opportunity moved successfully", - "opportunity": OpportunityKanbanCardSerializer(opportunity).data, + "opportunity": OpportunityKanbanCardSerializer( + attach_next_activity([opportunity], request.profile)[0] + ).data, } ) diff --git a/backend/opportunity/views/line_item_views.py b/backend/opportunity/views/line_item_views.py index c596535f..36bf6c89 100644 --- a/backend/opportunity/views/line_item_views.py +++ b/backend/opportunity/views/line_item_views.py @@ -4,16 +4,28 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from common.permissions import HasOrgContext, is_org_admin +from common.permissions import HasOrgContext from invoices.models import Product from invoices.serializer import ProductSerializer -from opportunity.models import Opportunity, OpportunityLineItem +from opportunity.access import get_visible_deal +from opportunity.models import OpportunityLineItem from opportunity.serializer import ( OpportunityLineItemCreateSerializer, OpportunityLineItemSerializer, ) +def _deal_not_found(): + """One answer for a missing deal and for one the caller may not open. + + Writes take the read rule here: anyone who may open the deal may price it. + """ + return Response( + {"error": True, "message": "Opportunity not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + class OpportunityLineItemListView(APIView): """ List and create line items for an opportunity. @@ -24,32 +36,7 @@ class OpportunityLineItemListView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_opportunity(self, pk): - """Get opportunity and verify access""" - return Opportunity.objects.filter(id=pk, org=self.request.profile.org).first() - - def check_opportunity_access(self, opportunity): - """Check if user has access to the opportunity""" - if not opportunity: - return Response( - {"error": True, "message": "Opportunity not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - if not ( - (self.request.profile.user == opportunity.created_by) - or (self.request.profile in opportunity.assigned_to.all()) - ): - return Response( - { - "error": True, - "message": "You do not have permission to access this opportunity", - }, - status=status.HTTP_403_FORBIDDEN, - ) - return None + return get_visible_deal(self.request.profile, self.request.user, pk) @extend_schema( operation_id="opportunity_line_items_list", @@ -71,9 +58,8 @@ class OpportunityLineItemListView(APIView): def get(self, request, opportunity_id, *args, **kwargs): """List all line items for an opportunity""" opportunity = self.get_opportunity(opportunity_id) - error_response = self.check_opportunity_access(opportunity) - if error_response: - return error_response + if opportunity is None: + return _deal_not_found() line_items = OpportunityLineItem.objects.filter( opportunity=opportunity, org=request.profile.org @@ -114,9 +100,8 @@ class OpportunityLineItemListView(APIView): def post(self, request, opportunity_id, *args, **kwargs): """Create a new line item for an opportunity""" opportunity = self.get_opportunity(opportunity_id) - error_response = self.check_opportunity_access(opportunity) - if error_response: - return error_response + if opportunity is None: + return _deal_not_found() serializer = OpportunityLineItemCreateSerializer( data=request.data, context={"opportunity": opportunity} @@ -154,8 +139,7 @@ class OpportunityLineItemDetailView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_opportunity(self, pk): - """Get opportunity and verify access""" - return Opportunity.objects.filter(id=pk, org=self.request.profile.org).first() + return get_visible_deal(self.request.profile, self.request.user, pk) def get_line_item(self, opportunity, line_item_id): """Get line item""" @@ -165,30 +149,6 @@ class OpportunityLineItemDetailView(APIView): org=self.request.profile.org, ).first() - def check_opportunity_access(self, opportunity): - """Check if user has access to the opportunity""" - if not opportunity: - return Response( - {"error": True, "message": "Opportunity not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - if not ( - (self.request.profile.user == opportunity.created_by) - or (self.request.profile in opportunity.assigned_to.all()) - ): - return Response( - { - "error": True, - "message": "You do not have permission to access this opportunity", - }, - status=status.HTTP_403_FORBIDDEN, - ) - return None - @extend_schema( operation_id="opportunity_line_items_retrieve", tags=["Opportunity Line Items"], @@ -197,9 +157,8 @@ class OpportunityLineItemDetailView(APIView): def get(self, request, opportunity_id, line_item_id, *args, **kwargs): """Retrieve a single line item""" opportunity = self.get_opportunity(opportunity_id) - error_response = self.check_opportunity_access(opportunity) - if error_response: - return error_response + if opportunity is None: + return _deal_not_found() line_item = self.get_line_item(opportunity, line_item_id) if not line_item: @@ -231,9 +190,8 @@ class OpportunityLineItemDetailView(APIView): def put(self, request, opportunity_id, line_item_id, *args, **kwargs): """Update a line item""" opportunity = self.get_opportunity(opportunity_id) - error_response = self.check_opportunity_access(opportunity) - if error_response: - return error_response + if opportunity is None: + return _deal_not_found() line_item = self.get_line_item(opportunity, line_item_id) if not line_item: @@ -285,9 +243,8 @@ class OpportunityLineItemDetailView(APIView): def delete(self, request, opportunity_id, line_item_id, *args, **kwargs): """Delete a line item""" opportunity = self.get_opportunity(opportunity_id) - error_response = self.check_opportunity_access(opportunity) - if error_response: - return error_response + if opportunity is None: + return _deal_not_found() line_item = self.get_line_item(opportunity, line_item_id) if not line_item: diff --git a/backend/opportunity/views/opportunity_interactions.py b/backend/opportunity/views/opportunity_interactions.py index ba5f4bab..6aa562fd 100644 --- a/backend/opportunity/views/opportunity_interactions.py +++ b/backend/opportunity/views/opportunity_interactions.py @@ -4,11 +4,12 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from common.lookups import get_scoped_or_404 +from common.lookups import get_on_visible_record_or_404 from common.models import Attachments, Comment from common.permissions import HasOrgContext, is_org_admin from common.serializer import CommentSerializer from opportunity import swagger_params +from opportunity.access import visible_deals_qs from opportunity.serializer import OpportunityCommentEditSwaggerSerializer @@ -17,7 +18,13 @@ class OpportunityCommentView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - return get_scoped_or_404(self.model, pk, self.request.profile.org) + """404, like a missing id, for a comment on a deal the caller cannot open.""" + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + visible_deals_qs(self.request.profile, self.request.user), + ) @extend_schema( tags=["Opportunities"], @@ -36,11 +43,7 @@ class OpportunityCommentView(APIView): def put(self, request, pk, format=None): params = request.data obj = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == obj.commented_by - ): + if is_org_admin(request.profile) or request.profile == obj.commented_by: # No `if params.get("comment")` guard here: a body with a blank or # absent `comment` used to fall out of this branch and hit the 403 # below, which told an author they may not edit their own comment. @@ -84,11 +87,7 @@ class OpportunityCommentView(APIView): """Handle partial updates to a comment.""" params = request.data obj = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == obj.commented_by - ): + if is_org_admin(request.profile) or request.profile == obj.commented_by: serializer = CommentSerializer(obj, data=params, partial=True) if serializer.is_valid(): serializer.save() @@ -123,11 +122,7 @@ class OpportunityCommentView(APIView): ) def delete(self, request, pk, format=None): self.object = self.get_object(pk) - if ( - is_org_admin(request.profile) - or request.user.is_superuser - or request.profile == self.object.commented_by - ): + if is_org_admin(request.profile) or request.profile == self.object.commented_by: self.object.delete() return Response( {"error": False, "message": "Comment Deleted Successfully"}, @@ -174,10 +169,15 @@ class OpportunityAttachmentView(APIView): # uploaded. The leads twin already spells it `request.profile.user`. # Repairing the lookup without repairing the comparison would have left # the fix looking complete while the branch stayed unreachable. - self.object = get_scoped_or_404(self.model, pk, request.profile.org) + # And on a deal the caller cannot open, the same 404 as a missing id. + self.object = get_on_visible_record_or_404( + self.model, + pk, + request.profile.org, + visible_deals_qs(request.profile, request.user), + ) if ( is_org_admin(request.profile) - or request.user.is_superuser or request.profile.user == self.object.created_by ): self.object.delete() diff --git a/backend/opportunity/views/opportunity_views.py b/backend/opportunity/views/opportunity_views.py index b2e3f67d..0e9e0b65 100644 --- a/backend/opportunity/views/opportunity_views.py +++ b/backend/opportunity/views/opportunity_views.py @@ -47,11 +47,13 @@ from contacts.access import replace_visible_contacts, visible_contacts_qs from contacts.serializer import ContactPickerSerializer from opportunity import access, swagger_params from opportunity.models import DealPipeline, DealStage, Opportunity, stage_kind_q +from opportunity.next_activity import attach_next_activity from opportunity.serializer import ( DealContactSerializer, OpportunityCreateSerializer, OpportunityCreateSwaggerSerializer, OpportunityDetailEditSwaggerSerializer, + OpportunityListSerializer, OpportunitySerializer, ) from opportunity.stages import aging_q, stage_choices, stage_index @@ -194,10 +196,7 @@ class OpportunityListView(APIView, LimitOffsetPagination): accounts = Account.objects.filter(org=self.request.profile.org) # The contact read rule itself, which is what the save path accepts. contacts = visible_contacts_qs(self.request.profile) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): + if not is_org_admin(self.request.profile): accounts = accounts.filter( Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile) @@ -206,11 +205,14 @@ class OpportunityListView(APIView, LimitOffsetPagination): context = {} context["totals"] = self.get_totals(queryset) org = self.request.profile.org - results_opportunities = self.paginate_queryset( - queryset.distinct(), self.request, view=self + # Paginated, and so counted, without the next-activity subqueries; + # they run for this page's rows only. + results_opportunities = attach_next_activity( + self.paginate_queryset(queryset.distinct(), self.request, view=self), + self.request.profile, ) # The org's stages once for the page, not per row. - opportunities = OpportunitySerializer( + opportunities = OpportunityListSerializer( results_opportunities, many=True, context={"stages": stage_index(org.id)} ).data if results_opportunities: @@ -270,7 +272,7 @@ class OpportunityListView(APIView, LimitOffsetPagination): "offset": serializers.IntegerField(allow_null=True), "per_page": serializers.IntegerField(), "page_number": serializers.IntegerField(), - "opportunities": OpportunitySerializer(many=True), + "opportunities": OpportunityListSerializer(many=True), "accounts_list": AccountPickerSerializer(many=True), "contacts_list": ContactPickerSerializer(many=True), "tags": TagsSerializer(many=True), @@ -399,16 +401,12 @@ class OpportunityDetailView(APIView): model = Opportunity def get_object(self, pk): - return self.model.objects.filter(id=pk, org=self.request.profile.org).first() + """The deal, or ``None`` when it is missing or the caller may not open it. - def assert_deal_access(self, opportunity): - """Delegates to `opportunity.access`, which holds the one definition. - - The attachment download view asks the same question, and four inline - copies of this check is exactly how the creator branch came to be dead - in all four of them. + Every verb answers ``None`` with the same 404, so a same-org deal the + caller cannot open reads exactly like an id that does not exist. """ - access.assert_deal_access(self.request.profile, self.request.user, opportunity) + return access.get_visible_deal(self.request.profile, self.request.user, pk) @extend_schema( operation_id="opportunities_update", @@ -433,7 +431,6 @@ class OpportunityDetailView(APIView): {"error": True, "errors": "Opportunity not found."}, status=status.HTTP_404_NOT_FOUND, ) - self.assert_deal_access(opportunity_object) serializer = OpportunityCreateSerializer( opportunity_object, @@ -548,16 +545,10 @@ class OpportunityDetailView(APIView): {"error": True, "errors": "Opportunity not found."}, status=status.HTTP_404_NOT_FOUND, ) - if self.object.org != request.profile.org: - return Response( - {"error": True, "errors": "User company doesnot match with header...."}, - status=status.HTTP_403_FORBIDDEN, - ) - if ( - not is_org_admin(self.request.profile) - and not self.request.user.is_superuser - ): - if self.request.profile.user != self.object.created_by: + # Narrower than reading: an assignee may open and edit the deal but not + # erase it. They can see it, so this refusal is an honest 403. + if not is_org_admin(self.request.profile): + if self.request.profile.user_id != self.object.created_by_id: return Response( { "error": True, @@ -600,18 +591,16 @@ class OpportunityDetailView(APIView): {"error": True, "errors": "Opportunity not found."}, status=status.HTTP_404_NOT_FOUND, ) - self.assert_deal_access(self.opportunity) context = {} context["opportunity_obj"] = OpportunitySerializer(self.opportunity).data comment_permission = ( self.request.profile.user_id == self.opportunity.created_by_id - or self.request.user.is_superuser or is_org_admin(self.request.profile) ) - if self.request.user.is_superuser or is_org_admin(self.request.profile): + if is_org_admin(self.request.profile): users_mention = list( Profile.objects.filter( is_active=True, org=self.request.profile.org @@ -621,7 +610,7 @@ class OpportunityDetailView(APIView): # `created_by` IS the User. The old code read `created_by.user.email`, # which raised AttributeError and returned a 500 for every non-admin # assignee opening a deal somebody else had created, the common - # case, and invisible until the 403 above stopped firing wrongly. + # case, and invisible until the read check stopped refusing wrongly. # Key is `user__email` to match the admin branch above; the two # returned different key names for the same list. users_mention = [{"user__email": self.opportunity.created_by.email}] @@ -698,15 +687,14 @@ class OpportunityDetailView(APIView): params = request.data context = {} # `.get()` here raised DoesNotExist (a 500) for a deal that had been - # deleted or belongs to another org. `get_object` is the same lookup - # with the org filter and answers 404, which is what the other verbs do. + # deleted or belongs to another org. `get_object` goes through the read + # rule and answers 404 for a missing or hidden deal, as every verb does. self.opportunity_obj = self.get_object(pk=pk) if not self.opportunity_obj: return Response( {"error": True, "errors": "Opportunity not found."}, status=status.HTTP_404_NOT_FOUND, ) - self.assert_deal_access(self.opportunity_obj) # Before the comment is saved, so a refused file does not leave it posted. validate_attachment(self.request.FILES.get("opportunity_attachment")) @@ -778,7 +766,6 @@ class OpportunityDetailView(APIView): {"error": True, "errors": "Opportunity not found."}, status=status.HTTP_404_NOT_FOUND, ) - self.assert_deal_access(opportunity_object) serializer = OpportunityCreateSerializer( opportunity_object, diff --git a/backend/opportunity/views/pipeline_views.py b/backend/opportunity/views/pipeline_views.py index df9fb580..d94d39de 100644 --- a/backend/opportunity/views/pipeline_views.py +++ b/backend/opportunity/views/pipeline_views.py @@ -27,7 +27,7 @@ _ADMIN_ONLY = "Only admins can change deal pipelines." def _require_admin(request): - if not (is_org_admin(request.profile) or request.user.is_superuser): + if not (is_org_admin(request.profile)): raise PermissionDenied(_ADMIN_ONLY) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d76abbfb..34218721 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "django-crm" -version = "1.10.0" +version = "1.11.0" # The one-line summary PyPI shows under the project title and in search results, # so it carries the terms people actually search for ("open source CRM", # "self-hosted CRM", "Django CRM") and names the modules rather than the diff --git a/backend/scripts/generate_endpoint_index.py b/backend/scripts/generate_endpoint_index.py index 90aeb5d0..06063b3a 100644 --- a/backend/scripts/generate_endpoint_index.py +++ b/backend/scripts/generate_endpoint_index.py @@ -44,13 +44,50 @@ Interactive versions of the same schema are served by a running backend at """ -def main() -> None: - schema = SchemaGenerator().get_schema(request=None, public=True) +# What to use instead of a deprecated operation. Whether an operation is +# deprecated is read from the schema (`deprecated=True` in the view's +# `extend_schema`), so a note here only ever decorates what the code already +# says; `render` refuses a note on an operation the code no longer deprecates, +# rather than printing advice about a route that has moved on. +REPLACEMENTS = { + ("/api/leads/upload/", "post"): "use `import/preview/` + `import/commit/`", + ( + "/api/leads/create-from-site/", + "post", + ): "use `/api/public/forms/{org_id}/{form_id}/submit/`", +} + + +def render(schema) -> list[str]: + """One table row per path, each method marked when it is deprecated.""" + deprecated = { + (path, method) + for path, operations in schema["paths"].items() + for method, operation in operations.items() + if method in METHODS and operation.get("deprecated") + } + stale = sorted(set(REPLACEMENTS) - deprecated) + if stale: + raise SystemExit( + "REPLACEMENTS names operations the schema does not mark deprecated: " + + ", ".join(f"{m.upper()} {p}" for p, m in stale) + ) rows = [] for path in sorted(schema["paths"]): - methods = sorted(m.upper() for m in schema["paths"][path] if m in METHODS) - if methods: - rows.append(f"| `{path}` | {', '.join(methods)} |") + labels = [] + for method in sorted(m for m in schema["paths"][path] if m in METHODS): + label = method.upper() + if (path, method) in deprecated: + note = REPLACEMENTS.get((path, method)) + label += f" (deprecated; {note})" if note else " (deprecated)" + labels.append(label) + if labels: + rows.append(f"| `{path}` | {', '.join(labels)} |") + return rows + + +def main() -> None: + rows = render(SchemaGenerator().get_schema(request=None, public=True)) OUTPUT.write_text(HEADER + "\n".join(rows) + "\n") print(f"wrote {OUTPUT}, {len(rows)} paths") diff --git a/backend/tasks/apps.py b/backend/tasks/apps.py index 5aadae04..b5b3f1da 100644 --- a/backend/tasks/apps.py +++ b/backend/tasks/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class TasksConfig(AppConfig): name = "tasks" + + def ready(self): + import tasks.signals # noqa: F401 # pylint: disable=unused-import diff --git a/backend/tasks/migrations/0014_unassign_non_members_from_board_cards.py b/backend/tasks/migrations/0014_unassign_non_members_from_board_cards.py new file mode 100644 index 00000000..bcbf1bfa --- /dev/null +++ b/backend/tasks/migrations/0014_unassign_non_members_from_board_cards.py @@ -0,0 +1,59 @@ +"""Take card assignees off boards they cannot open. + +A card may be assigned only to someone who can open its board: the owner or a +member (`tasks.views.board_views._set_card_assignees`). That was checked when +an assignee was written and never again, so two kinds of stale row exist: cards +assigned before the check existed, and members removed from a board after +being assigned. `tasks.signals.unassign_removed_member` stops the second kind +from here on; this clears what is already stored. + +The org loop is required, not decoration. `board`, `board_column`, +`board_task` and `board_member` are in ORG_SCOPED_TABLES with FORCE ROW LEVEL +SECURITY, and `app.current_org` is empty during a migration, so an unscoped +query sees no rows in production (see `common/0042` and `webforms/0002`). + +Reverse is a no-op: the removed assignments named people who could not open +the card, and putting them back would restore the defect. +""" + +from django.db import migrations +from django.db.models import Exists, F, OuterRef + + +def unassign_non_members(apps, schema_editor): + connection = schema_editor.connection + is_postgres = connection.vendor == "postgresql" + + Org = apps.get_model("common", "Org") + BoardMember = apps.get_model("tasks", "BoardMember") + BoardTask = apps.get_model("tasks", "BoardTask") + Assignment = BoardTask.assigned_to.through + + def set_context(value): + if not is_postgres: + return + with connection.cursor() as cursor: + cursor.execute("SELECT set_config('app.current_org', %s, false)", [value]) + + member = BoardMember.objects.filter( + board_id=OuterRef("boardtask__column__board_id"), + profile_id=OuterRef("profile_id"), + ) + try: + for org_id in Org.objects.values_list("id", flat=True).iterator(): + set_context(str(org_id)) + Assignment.objects.filter(boardtask__org_id=org_id).exclude( + profile_id=F("boardtask__column__board__owner_id") + ).exclude(Exists(member)).delete() + finally: + set_context("") + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0013_alter_taskpipeline_is_default"), + ] + + operations = [ + migrations.RunPython(unassign_non_members, migrations.RunPython.noop), + ] diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py new file mode 100644 index 00000000..dba901d4 --- /dev/null +++ b/backend/tasks/signals.py @@ -0,0 +1,32 @@ +"""Board membership receivers. Connected by `TasksConfig.ready`.""" + +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from tasks.models import Board, BoardMember, BoardTask + + +@receiver(post_delete, sender=BoardMember) +def unassign_removed_member(sender, instance, **kwargs): + """Take someone off a board's cards when they leave the board. + + `_set_card_assignees` only lets a card be assigned to someone who can open + its board (the owner or a member), but that was checked at write time, so + a member removed later stayed on every card they held until the next + assignee write, and kept being shown as its assignee. `post_delete` fires + for every way the row goes: an instance or queryset delete, the admin, + `board.members.remove()`/`.clear()`/`.set()`, and the cascade from + deleting the profile or the board. It runs inside the delete's own + transaction, so the membership and the assignments go together. + + The board's owner can open it without a membership row, so losing that + row leaves their cards alone. + """ + if Board.objects.filter( + pk=instance.board_id, owner_id=instance.profile_id + ).exists(): + return + BoardTask.assigned_to.through.objects.filter( + profile_id=instance.profile_id, + boardtask__column__board_id=instance.board_id, + ).delete() diff --git a/backend/tasks/tests/test_attachment_and_comment_reach_only_visible_tasks.py b/backend/tasks/tests/test_attachment_and_comment_reach_only_visible_tasks.py new file mode 100644 index 00000000..4ed65923 --- /dev/null +++ b/backend/tasks/tests/test_attachment_and_comment_reach_only_visible_tasks.py @@ -0,0 +1,164 @@ +"""Task comments and attachments are reached only through a task the caller +may open. + +Both endpoints used to look the row up org-wide across every module and then +answer 403 to a non-author, which reached other modules' comments and files +and confirmed a hidden id. A missing id, another module's row, and one on a +task this caller cannot open now answer one identical 404. +""" + +import uuid + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.core.files.uploadedfile import SimpleUploadedFile + +from common.models import Attachments, Comment +from leads.models import Lead +from tasks.models import Task + + +def _owned(model, obj, user): + model.objects.filter(pk=obj.pk).update(created_by=user) + return obj + + +@pytest.fixture +def hidden_task(org_a, admin_user): + task = Task.objects.create( + title="Private", status="New", priority="Medium", org=org_a + ) + return _owned(Task, task, admin_user) + + +@pytest.fixture +def hidden_lead(org_a, admin_user): + lead = Lead.objects.create(first_name="Private", last_name="Lead", org=org_a) + return _owned(Lead, lead, admin_user) + + +def _comment(target, author): + return Comment.objects.create( + content_type=ContentType.objects.get_for_model(type(target)), + object_id=target.id, + comment="Original", + commented_by=author, + org=target.org, + ) + + +def _attach(target, uploader): + attachment = Attachments( + file_name="notes.txt", + content_object=target, + org=target.org, + attachment=SimpleUploadedFile("notes.txt", b"hello"), + ) + attachment.save() + return _owned(Attachments, attachment, uploader) + + +def _same_as_missing(client, verb, url_of, row): + body = {"comment": "x"} + hidden = getattr(client, verb)(url_of(row.id), body, format="json") + missing = getattr(client, verb)(url_of(uuid.uuid4()), body, format="json") + assert hidden.status_code == missing.status_code == 404, verb + assert hidden.json() == missing.json(), verb + + +def comment_url(pk): + return f"/api/tasks/comment/{pk}/" + + +def attachment_url(pk): + return f"/api/tasks/attachment/{pk}/" + + +@pytest.mark.django_db +class TestComments: + @pytest.mark.parametrize("verb", ["put", "patch", "delete"]) + def test_comment_on_a_task_the_member_cannot_open( + self, user_client, admin_profile, hidden_task, verb + ): + comment = _comment(hidden_task, admin_profile) + + _same_as_missing(user_client, verb, comment_url, comment) + comment.refresh_from_db() + assert comment.comment == "Original" + + @pytest.mark.parametrize("verb", ["put", "delete"]) + def test_the_members_own_comment_on_a_lead_they_lost( + self, user_client, user_profile, hidden_lead, verb + ): + comment = _comment(hidden_lead, user_profile) + + _same_as_missing(user_client, verb, comment_url, comment) + assert Comment.objects.filter(pk=comment.pk).exists() + + def test_another_modules_comment_even_for_an_admin( + self, admin_client, admin_profile, hidden_lead + ): + comment = _comment(hidden_lead, admin_profile) + + _same_as_missing(admin_client, "delete", comment_url, comment) + assert Comment.objects.filter(pk=comment.pk).exists() + + def test_a_reader_who_did_not_write_it_is_refused( + self, user_client, user_profile, admin_profile, hidden_task + ): + hidden_task.assigned_to.add(user_profile) + comment = _comment(hidden_task, admin_profile) + + response = user_client.delete(comment_url(comment.id)) + + assert response.status_code == 403 + assert Comment.objects.filter(pk=comment.pk).exists() + + def test_the_author_who_can_read_it_may_edit( + self, user_client, user_profile, hidden_task + ): + hidden_task.assigned_to.add(user_profile) + comment = _comment(hidden_task, user_profile) + + response = user_client.put( + comment_url(comment.id), {"comment": "Fixed"}, format="json" + ) + + assert response.status_code == 200, response.content + + +@pytest.mark.django_db +class TestAttachments: + def test_a_file_on_a_task_the_member_cannot_open( + self, user_client, admin_user, hidden_task + ): + attachment = _attach(hidden_task, admin_user) + + _same_as_missing(user_client, "delete", attachment_url, attachment) + assert Attachments.objects.filter(pk=attachment.pk).exists() + + def test_the_uploader_of_a_hidden_leads_file_cannot_delete_it_here( + self, user_client, regular_user, hidden_lead + ): + attachment = _attach(hidden_lead, regular_user) + + _same_as_missing(user_client, "delete", attachment_url, attachment) + assert Attachments.objects.filter(pk=attachment.pk).exists() + + def test_another_modules_file_even_for_an_admin( + self, admin_client, admin_user, hidden_lead + ): + attachment = _attach(hidden_lead, admin_user) + + _same_as_missing(admin_client, "delete", attachment_url, attachment) + assert Attachments.objects.filter(pk=attachment.pk).exists() + + def test_the_uploader_who_can_read_the_task_may( + self, user_client, user_profile, regular_user, hidden_task + ): + hidden_task.assigned_to.add(user_profile) + attachment = _attach(hidden_task, regular_user) + + response = user_client.delete(attachment_url(attachment.id)) + + assert response.status_code == 200, response.content diff --git a/backend/tasks/tests/test_board_member_removal.py b/backend/tasks/tests/test_board_member_removal.py new file mode 100644 index 00000000..daf4e708 --- /dev/null +++ b/backend/tasks/tests/test_board_member_removal.py @@ -0,0 +1,162 @@ +"""D33: leaving a board takes you off its cards, in the same transaction. + +A card may only be assigned to someone who can open its board (the owner or a +member), but that was checked only when assignees were written, so a removed +member stayed on every card they held and kept being shown as its assignee. +`tasks.signals.unassign_removed_member` runs on every way a membership row is +deleted; each path below is pinned, along with what must be left alone. +""" + +import importlib +from types import SimpleNamespace + +import pytest +from django.apps import apps as django_apps +from django.db import connection, transaction + +from common.models import Profile, User +from common.testing import set_rls_context +from tasks.models import Board, BoardColumn, BoardMember, BoardTask + + +@pytest.fixture +def board(org_a, admin_profile): + board = Board.objects.create(name="Launch", owner=admin_profile, org=org_a) + BoardMember.objects.create(board=board, profile=admin_profile, role="owner") + return board + + +@pytest.fixture +def column(board, org_a): + return BoardColumn.objects.create(board=board, name="To Do", org=org_a) + + +def _card(column, *assignees, title="Card"): + card = BoardTask.objects.create(column=column, title=title, org=column.org) + card.assigned_to.add(*assignees) + return card + + +def _join(board, profile, role="member"): + return BoardMember.objects.create(board=board, profile=profile, role=role) + + +def _assignees(card): + return set(card.assigned_to.values_list("id", flat=True)) + + +@pytest.mark.django_db +class TestRemovalPaths: + def test_instance_delete(self, board, column, user_profile, admin_profile): + membership = _join(board, user_profile) + card = _card(column, user_profile, admin_profile) + + membership.delete() + + assert _assignees(card) == {admin_profile.id} + + def test_queryset_delete(self, board, column, user_profile): + _join(board, user_profile) + card = _card(column, user_profile) + + BoardMember.objects.filter(board=board, profile=user_profile).delete() + + assert _assignees(card) == set() + + def test_members_remove(self, board, column, user_profile): + _join(board, user_profile) + card = _card(column, user_profile) + + board.members.remove(user_profile) + + assert _assignees(card) == set() + + def test_members_clear_spares_the_owner( + self, board, column, user_profile, admin_profile + ): + """The owner opens the board without a membership row, so keeps cards.""" + _join(board, user_profile) + card = _card(column, user_profile, admin_profile) + + board.members.clear() + + assert _assignees(card) == {admin_profile.id} + + def test_rolled_back_removal_keeps_the_assignment( + self, board, column, user_profile + ): + """Same transaction: if the removal does not commit, neither does this.""" + membership = _join(board, user_profile) + membership_pk = membership.pk + card = _card(column, user_profile) + + with pytest.raises(RuntimeError): + with transaction.atomic(): + membership.delete() + raise RuntimeError + + assert BoardMember.objects.filter(pk=membership_pk).exists() + assert _assignees(card) == {user_profile.id} + + +@pytest.mark.django_db +class TestLeftAlone: + def test_other_boards_keep_the_person( + self, board, column, user_profile, admin_profile, org_a + ): + other = Board.objects.create(name="Other", owner=admin_profile, org=org_a) + _join(other, user_profile) + other_card = _card( + BoardColumn.objects.create(board=other, name="To Do", org=org_a), + user_profile, + ) + membership = _join(board, user_profile) + _card(column, user_profile) + + membership.delete() + + assert _assignees(other_card) == {user_profile.id} + + def test_other_members_keep_their_cards( + self, board, column, user_profile, admin_profile + ): + membership = _join(board, user_profile) + card = _card(column, admin_profile) + + membership.delete() + + assert _assignees(card) == {admin_profile.id} + + +@pytest.mark.django_db +class TestExistingStaleRows: + """The migration clears what the signal cannot: rows stored before it.""" + + def _run(self, org): + migration = importlib.import_module( + "tasks.migrations.0014_unassign_non_members_from_board_cards" + ) + # The function reads only `schema_editor.connection`. + migration.unassign_non_members( + django_apps, SimpleNamespace(connection=connection) + ) + # The migration clears the RLS context when it finishes. + set_rls_context(org) + + def test_non_member_is_removed_member_and_owner_stay( + self, board, column, user_profile, admin_profile, org_a + ): + # A same-org profile who was never a member, stored directly as the + # pre-D33 code allowed. + stranger = Profile.objects.create( + user=User.objects.create_user(email="stranger@test.com", password="x"), + org=org_a, + role="USER", + is_active=True, + ) + _join(board, user_profile) + card = _card(column, user_profile, admin_profile, stranger) + + self._run(org_a) + + assert _assignees(card) == {user_profile.id, admin_profile.id} diff --git a/backend/tasks/tests/test_kanban_move_write_rule.py b/backend/tasks/tests/test_kanban_move_write_rule.py index 8c2722ef..1c19ade3 100644 --- a/backend/tasks/tests/test_kanban_move_write_rule.py +++ b/backend/tasks/tests/test_kanban_move_write_rule.py @@ -6,6 +6,9 @@ assignee, while `tasks.access` gives a Django superuser nothing and `TaskDetailView.patch` refuses them. So a superuser who is a plain member could move, through the board, any task they could not edit through the detail endpoint. These tests pin the move to `assert_task_access`. + +Since 1.11.0 a superuser is an org admin everywhere (`is_org_admin` admits +them), so the move and the detail endpoint both admit one; they still agree. """ import pytest @@ -51,17 +54,15 @@ def _assert_unchanged(task): class TestSuperuserPlainMember: - def test_refused_on_a_task_they_cannot_edit( + def test_is_an_admin_so_may_move_any_task( self, superuser, user_client, others_task ): - assert ( - user_client.patch( - f"/api/tasks/{others_task.id}/", - {"status": "In Progress"}, - format="json", - ).status_code - == 403 - ) + """Superusers are org admins everywhere since 1.11.0 (`is_org_admin`).""" + response = _move(user_client, others_task) + assert response.status_code == 200, response.content + _assert_moved(others_task) + + def test_a_plain_member_is_still_refused(self, user_client, others_task): response = _move(user_client, others_task) assert response.status_code == 403, response.content _assert_unchanged(others_task) diff --git a/backend/tasks/tests/test_task_access.py b/backend/tasks/tests/test_task_access.py index fbde7fc1..64266f72 100644 --- a/backend/tasks/tests/test_task_access.py +++ b/backend/tasks/tests/test_task_access.py @@ -472,8 +472,13 @@ class TestAttachmentDeleteIsScopedToTheOrg: == 200 ) - def test_another_member_cannot_delete_it(self, other_client, regular_user, org_a): + def test_another_member_cannot_delete_it( + self, other_client, other_profile, regular_user, org_a + ): attachment = self._attachment(org_a, regular_user) + # A reader of the task who did not upload it; a non-reader gets 404 + # (`test_attachment_and_comment_reach_only_visible_tasks.py`). + attachment.content_object.assigned_to.add(other_profile) assert ( other_client.delete(f"/api/tasks/attachment/{attachment.id}/").status_code == 403 diff --git a/backend/tasks/tests/test_task_contact_link_requires_access.py b/backend/tasks/tests/test_task_contact_link_requires_access.py index 67013659..b08cb945 100644 --- a/backend/tasks/tests/test_task_contact_link_requires_access.py +++ b/backend/tasks/tests/test_task_contact_link_requires_access.py @@ -76,7 +76,7 @@ class TestCreate: task = self._post(user_client, people, "mine", "hidden") assert _names(task) == {"Mine"} opened = user_client.get(f"/api/contacts/{people['hidden'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 def test_member_links_a_contact_they_can_open(self, user_client, people): task = self._post(user_client, people, "mine") @@ -118,7 +118,7 @@ class TestReplace: linked = self._send(user_client, verb, task, people, "mine", "stranger") assert linked == {"Mine", "Hidden"} opened = user_client.get(f"/api/contacts/{people['stranger'].id}/") - assert opened.status_code == 403 + assert opened.status_code == 404 @pytest.mark.parametrize("verb", ["put", "patch"]) def test_member_links_a_contact_they_can_open( diff --git a/backend/tasks/tests/test_tasks_api.py b/backend/tasks/tests/test_tasks_api.py index c058fe3f..763a6f91 100644 --- a/backend/tasks/tests/test_tasks_api.py +++ b/backend/tasks/tests/test_tasks_api.py @@ -919,12 +919,13 @@ class TestTaskCommentView: assert not Comment.objects.filter(id=comment.id).exists() def test_update_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who did not create the comment should get 403.""" - _task, comment = self._create_task_with_comment( - admin_user, admin_profile, org_a - ) + task, comment = self._create_task_with_comment(admin_user, admin_profile, org_a) + # A reader of the task: the author rule, not the read rule, refuses. + # One who cannot open the task gets the 404 a missing comment gets. + task.assigned_to.add(user_profile) response = user_client.put( f"/api/tasks/comment/{comment.id}/", {"comment": "Should fail"}, @@ -933,12 +934,13 @@ class TestTaskCommentView: assert response.status_code == 403 def test_delete_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who did not create the comment should get 403 on delete.""" - _task, comment = self._create_task_with_comment( - admin_user, admin_profile, org_a - ) + task, comment = self._create_task_with_comment(admin_user, admin_profile, org_a) + # A reader of the task: the author rule, not the read rule, refuses. + # One who cannot open the task gets the 404 a missing comment gets. + task.assigned_to.add(user_profile) response = user_client.delete(f"/api/tasks/comment/{comment.id}/") assert response.status_code == 403 @@ -982,12 +984,13 @@ class TestTaskCommentView: assert comment.comment == original def test_stranger_still_gets_403_for_an_empty_body( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Authorization is still checked before the body is.""" - _task, comment = self._create_task_with_comment( - admin_user, admin_profile, org_a - ) + task, comment = self._create_task_with_comment(admin_user, admin_profile, org_a) + # A reader of the task: the author rule, not the read rule, refuses. + # One who cannot open the task gets the 404 a missing comment gets. + task.assigned_to.add(user_profile) response = user_client.put( f"/api/tasks/comment/{comment.id}/", {}, @@ -1026,7 +1029,7 @@ class TestTaskAttachmentView: assert not Attachments.objects.filter(id=attachment.id).exists() def test_delete_attachment_non_admin_forbidden( - self, user_client, admin_user, org_a + self, user_client, user_profile, admin_user, org_a ): """Non-admin who did not create the attachment should get 403.""" from django.core.files.uploadedfile import SimpleUploadedFile @@ -1047,6 +1050,8 @@ class TestTaskAttachmentView: created_by=admin_user, org=org_a, ) + # A reader of the task who did not upload the file. + task.assigned_to.add(user_profile) response = user_client.delete(f"/api/tasks/attachment/{attachment.id}/") assert response.status_code == 403 @@ -2087,12 +2092,13 @@ class TestTaskCommentPatchCoverage: return task, comment def test_patch_comment_non_admin_forbidden( - self, user_client, admin_user, admin_profile, org_a + self, user_client, user_profile, admin_user, admin_profile, org_a ): """Non-admin who didn't create comment gets 403 on PATCH (line 827).""" - _task, comment = self._create_task_with_comment( - admin_user, admin_profile, org_a - ) + task, comment = self._create_task_with_comment(admin_user, admin_profile, org_a) + # A reader of the task: the author rule, not the read rule, refuses. + # One who cannot open the task gets the 404 a missing comment gets. + task.assigned_to.add(user_profile) response = user_client.patch( f"/api/tasks/comment/{comment.id}/", {"comment": "Patched by non-admin"}, diff --git a/backend/tasks/views/kanban_views.py b/backend/tasks/views/kanban_views.py index fdd3419d..ed29838e 100644 --- a/backend/tasks/views/kanban_views.py +++ b/backend/tasks/views/kanban_views.py @@ -381,7 +381,7 @@ class TaskPipelineListCreateView(APIView): org = request.profile.org # Only admins can create pipelines - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Only admins can create pipelines"}, status=status.HTTP_403_FORBIDDEN, @@ -460,7 +460,7 @@ class TaskPipelineDetailView(APIView): ) def put(self, request, pk): """Update pipeline.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -482,7 +482,7 @@ class TaskPipelineDetailView(APIView): @extend_schema(tags=["Task Pipelines"], responses={204: None}) def delete(self, request, pk): """Delete pipeline (soft delete by setting is_active=False).""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -516,7 +516,7 @@ class TaskStageCreateView(APIView): ) def post(self, request, pipeline_pk): """Add a new stage to pipeline.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -550,7 +550,7 @@ class TaskStageDetailView(APIView): ) def put(self, request, pk): """Update stage.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -570,7 +570,7 @@ class TaskStageDetailView(APIView): @extend_schema(tags=["Task Stages"], responses={204: None}) def delete(self, request, pk): """Delete stage.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) @@ -607,7 +607,7 @@ class TaskStageReorderView(APIView): @transaction.atomic def post(self, request, pipeline_pk): """Reorder stages by providing ordered list of stage IDs.""" - if not is_org_admin(request.profile) and not request.user.is_superuser: + if not is_org_admin(request.profile): return Response( {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN ) diff --git a/backend/tasks/views/task_views.py b/backend/tasks/views/task_views.py index 77cb0e78..a17503e9 100644 --- a/backend/tasks/views/task_views.py +++ b/backend/tasks/views/task_views.py @@ -4,7 +4,6 @@ from datetime import timedelta from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Count, Q -from django.http import Http404 from django.utils import timezone from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework import serializers, status @@ -16,6 +15,7 @@ from rest_framework.views import APIView from accounts.access import visible_accounts_qs from accounts.serializer import AccountPickerSerializer from common.custom_fields import validate_payload as validate_custom_fields_payload +from common.lookups import get_on_visible_record_or_404 from common.models import ( Attachments, Comment, @@ -763,21 +763,20 @@ class TaskCommentView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk): - """A comment in the requester's org, or 404. + """A comment on a task the requester may open, or 404. - Was `objects.get(...)`, so a comment id that had been deleted, belonged - to another org, or was not a UUID at all answered 500 rather than - "there is no such comment". + Was an org-wide lookup across every module's comments, then a 403 + for a non-author: that reached another module's comments and + confirmed a hidden comment's id. A missing id, a malformed one, + another module's comment and one on a task this caller cannot open + now raise the same 404. """ - try: - comment = self.model.objects.filter( - pk=pk, org=self.request.profile.org - ).first() - except (DjangoValidationError, ValueError): - raise Http404("No such comment.") - if comment is None: - raise Http404("No such comment.") - return comment + return get_on_visible_record_or_404( + self.model, + pk, + self.request.profile.org, + visible_tasks_qs(self.request.profile), + ) @extend_schema( tags=["Tasks"], @@ -909,21 +908,18 @@ class TaskAttachmentView(APIView): }, ) def delete(self, request, pk, format=None): - # `Attachments` is one generic table shared by every module, so a - # lookup by pk alone reaches every attachment in the database. Without - # `org=`, this endpoint was a "delete any attachment anywhere by UUID" - # primitive for any org admin: proven live, an admin of one org - # destroyed a file belonging to another org, and it was attached to a - # lead, not even to a task. The org filter is the fix; the uploader - # clause below is a separate bug in the same three lines. - try: - self.object = self.model.objects.filter( - pk=pk, org=request.profile.org - ).first() - except (DjangoValidationError, ValueError): - raise Http404("No such attachment.") - if self.object is None: - raise Http404("No such attachment.") + # `Attachments` is one generic table shared by every module. A lookup + # by pk alone once let an admin of one org delete another org's lead + # file; an org-wide lookup still reached every module's files in the + # org and answered 403 for a hidden one. Only an attachment on a task + # this caller may open is found; anything else is the same 404 as a + # missing id. The uploader clause below is a separate check. + self.object = get_on_visible_record_or_404( + self.model, + pk, + request.profile.org, + visible_tasks_qs(request.profile), + ) # `created_by` is a `User`; `request.profile` is a `Profile`. Comparing # them is always False, so the person who uploaded the file could not # remove it unless they were an admin. diff --git a/backend/uv.lock b/backend/uv.lock index a5cf388b..5046689c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -680,7 +680,7 @@ wheels = [ [[package]] name = "django-crm" -version = "1.10.0" +version = "1.11.0" source = { editable = "." } dependencies = [ { name = "cairocffi" }, diff --git a/backend/webforms/dynamic_serializer.py b/backend/webforms/dynamic_serializer.py index 9f535af7..5c376c33 100644 --- a/backend/webforms/dynamic_serializer.py +++ b/backend/webforms/dynamic_serializer.py @@ -15,6 +15,7 @@ from disposable_email_domains import blocklist as disposable_domains from rest_framework import serializers from common.custom_fields import validate_payload +from common.validators import flexible_phone_validator from webforms.constants import ( LEAD_FIELD_CHOICE_SOURCES, LEAD_FIELD_MAX_LENGTHS, @@ -49,6 +50,22 @@ def _field_for(row): if row.builtin_field == "email": return serializers.EmailField(required=required, allow_blank=not required) + if row.builtin_field == "phone": + # The rule the Lead and Contact `phone` columns carry. Those model + # validators only run on `full_clean`, which the create paths never + # call, so without this a stranger's text reached the record as is. + max_length = ( + TICKET_FIELD_MAX_LENGTHS + if row.source == WebFormField.SOURCE_TICKET + else LEAD_FIELD_MAX_LENGTHS + ).get("phone", 25) + return serializers.CharField( + required=required, + allow_blank=not required, + max_length=max_length, + validators=[flexible_phone_validator], + ) + if row.source == WebFormField.SOURCE_TICKET: return serializers.CharField( required=required, diff --git a/backend/webforms/models.py b/backend/webforms/models.py index e4b994db..4f56d68c 100644 --- a/backend/webforms/models.py +++ b/backend/webforms/models.py @@ -310,8 +310,8 @@ class WebFormSubmission(BaseOrgModel): # Internal triage detail. Never echoed to the submitter: telling a bot # which control caught it is how it learns to get past that control. reject_reason = models.CharField(max_length=255, blank=True, default="") - # Informational and throttle bucketing only. X-Forwarded-For is - # submitter-controlled, so this is never an input to an authorization + # Informational and throttle bucketing only, from + # `common.request_meta.client_ip`, and never an input to an authorization # decision. submitted_ip = models.GenericIPAddressField(null=True, blank=True) referer = models.CharField(max_length=512, blank=True, default="") diff --git a/backend/webforms/tests/test_phone_validation.py b/backend/webforms/tests/test_phone_validation.py new file mode 100644 index 00000000..a8f0e3ab --- /dev/null +++ b/backend/webforms/tests/test_phone_validation.py @@ -0,0 +1,97 @@ +"""A web form's phone field carries `flexible_phone_validator`, on both targets. + +`_field_for` gave a phone row a bare CharField, so any 25 characters a stranger +typed reached `Lead.phone` or the auto-created `Contact.phone`. Both columns +declare the validator, but only `full_clean` runs it and the create paths do +not call that, so the form serializer is the one place it can be enforced. +""" + +import pytest + +from webforms.dynamic_serializer import build_serializer +from webforms.models import WebForm, WebFormField + + +def _form(org, target, phone_required=False): + form = WebForm.objects.create( + name=f"{target} form", org=org, is_published=True, target=target + ) + key = "ticket_field" if target == WebForm.TARGET_TICKET else "lead_field" + WebFormField.objects.create( + form=form, + org=org, + order=0, + source=( + WebFormField.SOURCE_TICKET + if target == WebForm.TARGET_TICKET + else WebFormField.SOURCE_LEAD + ), + label="Email", + is_required=True, + **{key: "email"}, + ) + WebFormField.objects.create( + form=form, + org=org, + order=1, + source=( + WebFormField.SOURCE_TICKET + if target == WebForm.TARGET_TICKET + else WebFormField.SOURCE_LEAD + ), + label="Phone", + is_required=phone_required, + **{key: "phone"}, + ) + return form + + +TARGETS = [WebForm.TARGET_LEAD, WebForm.TARGET_TICKET] + + +@pytest.mark.django_db +@pytest.mark.parametrize("target", TARGETS) +@pytest.mark.parametrize("phone", ["+1 (555) 010-2000", "020 7946 0958", "555.0100"]) +def test_a_real_phone_number_is_accepted(org_a, target, phone): + serializer = build_serializer(_form(org_a, target))( + data={"email": "pat@example.com", "phone": phone} + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.field_values()["phone"] == phone + + +@pytest.mark.django_db +@pytest.mark.parametrize("target", TARGETS) +@pytest.mark.parametrize( + "phone", ["call me maybe", "", "123", "=HYPERLINK(1)"] +) +def test_junk_is_refused(org_a, target, phone): + serializer = build_serializer(_form(org_a, target))( + data={"email": "pat@example.com", "phone": phone} + ) + + assert not serializer.is_valid() + assert "phone" in serializer.errors + + +@pytest.mark.django_db +@pytest.mark.parametrize("target", TARGETS) +def test_an_optional_phone_may_be_left_blank(org_a, target): + serializer = build_serializer(_form(org_a, target))( + data={"email": "pat@example.com", "phone": ""} + ) + + assert serializer.is_valid(), serializer.errors + assert "phone" not in serializer.field_values() + + +@pytest.mark.django_db +@pytest.mark.parametrize("target", TARGETS) +def test_a_required_phone_may_not_be_blank(org_a, target): + serializer = build_serializer(_form(org_a, target, phone_required=True))( + data={"email": "pat@example.com", "phone": ""} + ) + + assert not serializer.is_valid() + assert "phone" in serializer.errors diff --git a/backend/webforms/tests/test_submit.py b/backend/webforms/tests/test_submit.py index 9ed11aac..874cf396 100644 --- a/backend/webforms/tests/test_submit.py +++ b/backend/webforms/tests/test_submit.py @@ -341,27 +341,38 @@ class TestThrottling: ) assert response.status_code == 200 - def test_rotating_the_forwarded_header_does_not_evade_the_global_limit( + def test_rotating_the_forwarded_header_does_not_reset_the_ip_bucket( self, unauthenticated_client, org_a, form, tight_rates ): - """X-Forwarded-For is submitter-controlled, so the per-IP bucket is - trivially reset by an attacker. The global cap is the layer that - rotation cannot evade, and this is the test that says so.""" - for index in range(3): - response = unauthenticated_client.post( + """X-Forwarded-For is the submitter's to write. With no trusted proxy + configured it is ignored, so a fresh value per request is still the + same client and the per-IP limit (2/hour here) still bites.""" + codes = [ + unauthenticated_client.post( submit_url(org_a, form), {"email": f"pat{index}@example.com"}, format="json", HTTP_X_FORWARDED_FOR=f"203.0.113.{index}", - ) - assert response.status_code == 200 - response = unauthenticated_client.post( - submit_url(org_a, form), - {"email": "fourth@example.com"}, - format="json", - HTTP_X_FORWARDED_FOR="203.0.113.99", - ) - assert response.status_code == 429 + ).status_code + for index in range(3) + ] + assert codes == [200, 200, 429] + + def test_many_real_clients_still_hit_the_global_limit( + self, unauthenticated_client, org_a, form, tight_rates + ): + """Distinct addresses each get their own per-IP bucket; the per-form + cap (3/day here) is what stops a sender spread across many.""" + codes = [ + unauthenticated_client.post( + submit_url(org_a, form), + {"email": f"pat{index}@example.com"}, + format="json", + REMOTE_ADDR=f"198.51.100.{index + 1}", + ).status_code + for index in range(4) + ] + assert codes == [200, 200, 200, 429] @pytest.mark.django_db diff --git a/backend/webforms/throttles.py b/backend/webforms/throttles.py index 6254b3b0..ee65729d 100644 --- a/backend/webforms/throttles.py +++ b/backend/webforms/throttles.py @@ -2,11 +2,12 @@ Two layers, because neither is sufficient alone: -- `WebFormIPThrottle` buckets on the validated forwarded client IP. It stops - one visitor or one bot from flooding, but `X-Forwarded-For` is spoofable, so - an attacker who rotates the header gets a fresh bucket every request. -- `WebFormGlobalThrottle` caps submissions per form across all clients. Header - rotation cannot evade it, so this is the real backstop. +- `WebFormIPThrottle` buckets on `common.request_meta.client_ip`, which + believes only the `X-Forwarded-For` entries our own proxies appended, so + rotating the header does not buy a fresh bucket. It stops one visitor or one + bot from flooding. +- `WebFormGlobalThrottle` caps submissions per form across all clients, the + backstop for a sender spread across many real addresses. Both need a shared cache to mean anything across workers. `crm/settings.py` configures one from `CACHE_URL` and says what happens when it is not set. diff --git a/backend/webforms/views.py b/backend/webforms/views.py index 8ced5c85..5eaefa01 100644 --- a/backend/webforms/views.py +++ b/backend/webforms/views.py @@ -45,7 +45,7 @@ def _may_write(request): """Org admins and Django superusers, as the lead pipeline admin views decide it (`leads/views/kanban_views.py`). A superuser still needs an active profile in this org, which `HasOrgContext` has already required.""" - return is_org_admin(request.profile) or request.user.is_superuser + return is_org_admin(request.profile) def _admin_required(): diff --git a/backend/webhooks/apps.py b/backend/webhooks/apps.py index 3e113eca..b2a26798 100644 --- a/backend/webhooks/apps.py +++ b/backend/webhooks/apps.py @@ -6,7 +6,7 @@ class WebhooksConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" def ready(self): - # Importing the module is what connects its receivers. They live here - # rather than beside the models they watch, so no other app has to know - # webhooks exist. - from webhooks import signals # noqa: F401 + # Importing the modules is what connects their receivers. They live + # here rather than beside the models they watch, so no other app has to + # know webhooks exist. + from webhooks import ownership, signals # noqa: F401 diff --git a/backend/webhooks/ownership.py b/backend/webhooks/ownership.py new file mode 100644 index 00000000..2bc3530f --- /dev/null +++ b/backend/webhooks/ownership.py @@ -0,0 +1,198 @@ +"""Pausing a webhook whose creator can no longer answer for it. + +A webhook is a standing export of the org's records to a URL its creator +chose, and it keeps sending after that person has lost the standing to choose +it. So when the creator stops being an org admin, is deactivated (their +profile or their user), or leaves the org, every endpoint they created is +paused: turned off, with the reason shown beside it, and a `WEBHOOK_PAUSED` +row written to the security audit log. Any admin can turn it back on, and +doing so makes that admin its creator (`webhooks.views.WebhookDetailView`). + +TWO LAYERS, ONE QUESTION +The receivers below pause at the moment of the change, so the settings page +shows it at once. Signals cannot see a queryset `update()`, raw SQL, or a +`created_by` nulled by a user delete, so `webhooks.tasks.attempt_delivery` +asks the same question (`creator_problem`) before every send and pauses there +too. Whichever path changed the creator, nothing more is sent. + +RLS +A user can be deactivated from the Django admin, which runs with no org +context, and a user can belong to several orgs, only one of which is the +request's. Each lookup therefore runs as the endpoint's own org and puts the +previous context back afterwards (`_as_org`), and filters on that org as well. +""" + +from contextlib import contextmanager + +from crum import get_current_request +from django.db import connection +from django.db.models.signals import post_save, pre_delete + +from common.audit_log import audit_log +from common.models import Org, Profile, User +from common.permissions import is_org_admin +from common.tasks import set_rls_context +from webhooks.models import WebhookEndpoint + +NOT_ADMIN = "Paused because the admin who created it is no longer an admin." +DEACTIVATED = "Paused because the admin who created it was deactivated." +LEFT = "Paused because the admin who created it left the organization." +NOT_MEMBER = ( + "Paused because the person who created it is not a member of this organization." +) +DELETED_USER = "Paused because the user who created it was deleted." + + +def _problem(user, profile): + """Why `user`, holding `profile` in the endpoint's org, cannot own it, or + None when they can.""" + if user is None: + return DELETED_USER + if profile is None: + return NOT_MEMBER + if not user.is_active or not profile.is_active: + return DEACTIVATED + if not is_org_admin(profile): + return NOT_ADMIN + return None + + +def creator_problem(endpoint): + """Why the endpoint's creator can no longer own it, or None. + + No creator means that user was deleted: nothing else empties the column. + A creator with no profile here was never a member, or their profile went + by a path no receiver saw. + """ + user = endpoint.created_by + profile = None + if user is not None: + profile = Profile.objects.filter(user=user, org_id=endpoint.org_id).first() + if profile is not None: + profile.user = user + return _problem(user, profile) + + +def pause(endpoints, reason, creator_gone=False): + """Turn each still-active endpoint off with `reason` and audit it. + + The conditional update means two paths pausing the same endpoint at once + write one audit row, not two. `creator_gone` is set while the creator's + user row is being deleted: an audit row pointing at it would break that + delete, so the row names them by id in its metadata instead. + """ + request = get_current_request() + for endpoint in endpoints: + paused = WebhookEndpoint.objects.filter(pk=endpoint.pk, is_active=True).update( + is_active=False, disabled_reason=reason + ) + if paused: + audit_log.webhook_paused( + endpoint, + reason, + creator=None if creator_gone else endpoint.created_by, + request=request, + ) + + +@contextmanager +def _as_org(org_id): + """Run the block with the RLS context set to `org_id`, then restore it.""" + if connection.vendor != "postgresql": + yield + return + with connection.cursor() as cursor: + cursor.execute("SELECT current_setting('app.current_org', true)") + previous = cursor.fetchone()[0] or "" + set_rls_context(org_id) + try: + yield + finally: + with connection.cursor() as cursor: + cursor.execute( + "SELECT set_config('app.current_org', %s, false)", [previous] + ) + + +def _pause_created_by(user_id, org_id, reason, creator_gone=False): + with _as_org(org_id): + endpoints = list( + WebhookEndpoint.objects.filter( + org_id=org_id, created_by_id=user_id, is_active=True + ).select_related("org", "created_by") + ) + pause(endpoints, reason, creator_gone=creator_gone) + + +def _deleting(origin, model): + """Whether the delete that sent this signal was called on `model`, one + instance or a queryset of them.""" + return isinstance(origin, model) or getattr(origin, "model", None) is model + + +def profile_saved(sender, instance, created, raw=False, **kwargs): + """A role change or a deactivation, from any view, serializer or script.""" + if raw or created: + return + reason = _problem(instance.user, instance) + if reason: + _pause_created_by(instance.user_id, instance.org_id, reason) + + +def profile_deleting(sender, instance, **kwargs): + """Removed from the org, directly or as part of deleting the user. + + `pre_delete`, not `post_delete`: deleting a user nulls `created_by` on + their endpoints before any `post_delete` is sent, and by then there is + nothing left to find them by. Inside the delete's transaction, so a delete + that fails leaves the endpoints as they were. + + Deleting the org deletes its endpoints too, so there is nothing to pause, + and an audit row naming that org would break the delete. + """ + origin = kwargs.get("origin") + if _deleting(origin, Org): + return + user_deleted = _deleting(origin, User) + _pause_created_by( + instance.user_id, + instance.org_id, + DELETED_USER if user_deleted else LEFT, + creator_gone=user_deleted, + ) + + +def user_saved(sender, instance, created, raw=False, update_fields=None, **kwargs): + """A user deactivated, or stripped of superuser, in every org at once. + + `is_org_admin` may admit a superuser whatever their role, so losing the + flag can end their standing as an admin. + """ + if raw or created: + return + if update_fields is not None and not {"is_active", "is_superuser"} & set( + update_fields + ): + return + for profile in Profile.objects.filter(user=instance): + profile.user = instance + reason = _problem(instance, profile) + if reason: + _pause_created_by(instance.pk, profile.org_id, reason) + + +def connect(): + post_save.connect( + profile_saved, sender="common.Profile", dispatch_uid="webhooks_profile_saved" + ) + pre_delete.connect( + profile_deleting, + sender="common.Profile", + dispatch_uid="webhooks_profile_deleting", + ) + post_save.connect( + user_saved, sender="common.User", dispatch_uid="webhooks_user_saved" + ) + + +connect() diff --git a/backend/webhooks/serializers.py b/backend/webhooks/serializers.py index 03c87e82..cf364b33 100644 --- a/backend/webhooks/serializers.py +++ b/backend/webhooks/serializers.py @@ -9,6 +9,9 @@ class WebhookEndpointSerializer(serializers.ModelSerializer): the create and rotate views add the full value to their own response.""" secret_hint = serializers.CharField(read_only=True) + # Who answers for the endpoint: whoever added it, or the admin who last + # turned it back on after a pause. Never taken from a request body. + created_by = serializers.SerializerMethodField() class Meta: model = WebhookEndpoint @@ -21,11 +24,18 @@ class WebhookEndpointSerializer(serializers.ModelSerializer): "is_active", "disabled_reason", "secret_hint", + "created_by", "created_at", "updated_at", ) read_only_fields = ("id", "disabled_reason", "created_at", "updated_at") + def get_created_by(self, obj): + user = obj.created_by + if user is None: + return None + return {"id": str(user.id), "name": user.name, "email": user.email} + def validate(self, attrs): # The secret is minted by the server. Refused loudly rather than # dropped, so a caller who thinks they chose one learns they did not. diff --git a/backend/webhooks/tasks.py b/backend/webhooks/tasks.py index e8369579..af3a6515 100644 --- a/backend/webhooks/tasks.py +++ b/backend/webhooks/tasks.py @@ -33,7 +33,7 @@ from django.utils import timezone from common.models import Org from common.tasks import clear_rls_context, set_rls_context -from webhooks import events, ssrf +from webhooks import events, ownership, ssrf from webhooks.models import FAILED, PENDING, SLACK, SUCCEEDED, WebhookDelivery logger = logging.getLogger(__name__) @@ -105,9 +105,19 @@ def attempt_delivery(delivery_id, org_id): ).update(next_attempt_at=now + CLAIM) if not claimed: return None - delivery = WebhookDelivery.objects.select_related("endpoint").get(pk=delivery_id) + delivery = WebhookDelivery.objects.select_related( + "endpoint", "endpoint__org", "endpoint__created_by" + ).get(pk=delivery_id) endpoint = delivery.endpoint + # The creator check the ownership receivers make, asked again here because + # a queryset update or a deleted user reaches no receiver. + if endpoint.is_active: + reason = ownership.creator_problem(endpoint) + if reason: + ownership.pause([endpoint], reason) + endpoint.is_active = False + if not endpoint.is_active: WebhookDelivery.objects.filter(pk=delivery_id).update( status=FAILED, next_attempt_at=None, error="The endpoint is turned off." diff --git a/backend/webhooks/tests/conftest.py b/backend/webhooks/tests/conftest.py index 48a93617..52f13ec6 100644 --- a/backend/webhooks/tests/conftest.py +++ b/backend/webhooks/tests/conftest.py @@ -27,9 +27,12 @@ def public_dns(): @pytest.fixture -def endpoint(org_a): +def endpoint(org_a, admin_profile): + """Created by org A's admin: an endpoint whose creator is not an admin + is paused before its first send (webhooks/ownership.py).""" return WebhookEndpoint.objects.create( org=org_a, url="https://hooks.example.com/in", events=["lead.created", "lead.updated", "lead.deleted"], + created_by=admin_profile.user, ) diff --git a/backend/webhooks/tests/test_api.py b/backend/webhooks/tests/test_api.py index 4373fe9a..ce90f2f5 100644 --- a/backend/webhooks/tests/test_api.py +++ b/backend/webhooks/tests/test_api.py @@ -6,6 +6,7 @@ import pytest from django.test import Client from common.models import PersonalAccessToken, Profile, User +from common.permissions import IsOrgAdmin from common.testing import _make_authenticated_client from webhooks import views from webhooks.emit import envelope, queue_delivery @@ -109,7 +110,8 @@ class TestWhoMayUseIt: assert client.get("/api/leads/", **auth).status_code == 200 def test_permission_class_answers_both_ways(self, admin_profile, user_profile): - check = views.IsOrgAdminOrSuperuser() + assert IsOrgAdmin in views.WebhookBaseView.permission_classes + check = IsOrgAdmin() assert check.has_permission(mock.Mock(profile=admin_profile), None) is True assert check.has_permission(mock.Mock(profile=user_profile), None) is False assert check.has_permission(mock.Mock(profile=None), None) is False diff --git a/backend/webhooks/tests/test_ownership.py b/backend/webhooks/tests/test_ownership.py new file mode 100644 index 00000000..dd20feff --- /dev/null +++ b/backend/webhooks/tests/test_ownership.py @@ -0,0 +1,381 @@ +"""A webhook is paused when its creator stops being an admin of its org. + +Every path that can end that standing is driven here: the role and status +endpoints, removal from the org, the user row (the Django admin's path), a +queryset update that no receiver sees, and deleting the user outright. Each +pause is audited, a paused endpoint sends nothing, and an admin can turn it +back on, which makes them its creator. +""" + +from unittest import mock + +import pytest +from django.db import connection + +from common.audit_log import SecurityAuditLog +from common.models import Org, Profile, User +from common.testing import _make_authenticated_client +from conftest import clear_rls_context, rls_org +from webhooks import ownership +from webhooks.emit import envelope, queue_delivery +from webhooks.models import FAILED, WebhookEndpoint +from webhooks.tasks import attempt_delivery + +BASE = "/api/webhooks/" + + +@pytest.fixture(autouse=True) +def dns_and_broker(public_dns): + with mock.patch("webhooks.emit.deliver_webhook.delay"): + yield + + +@pytest.fixture +def creator(org_a): + """A second admin of org A, who adds the webhook. `admin_client` is the + colleague who then demotes, deactivates or removes them.""" + user = User.objects.create_user(email="creator@test.com", password="x") + return Profile.objects.create(user=user, org=org_a, role="ADMIN", is_active=True) + + +@pytest.fixture +def hook(org_a, creator): + return WebhookEndpoint.objects.create( + org=org_a, + url="https://hooks.example.com/in", + events=["lead.created"], + created_by=creator.user, + ) + + +def _paused_rows(endpoint): + return SecurityAuditLog.objects.filter( + event_type="WEBHOOK_PAUSED", metadata__endpoint_id=str(endpoint.id) + ) + + +def _assert_paused(endpoint, reason): + endpoint.refresh_from_db() + assert endpoint.is_active is False + assert endpoint.disabled_reason == reason + row = _paused_rows(endpoint).get() + assert row.org_id == endpoint.org_id + assert row.metadata["pause_reason"] == reason + return row + + +class TestPauseTriggers: + def test_demoted_through_the_user_endpoint(self, admin_client, creator, hook): + response = admin_client.patch( + f"/api/user/{creator.user.id}/", {"role": "USER"}, format="json" + ) + assert response.status_code == 200 + row = _assert_paused(hook, ownership.NOT_ADMIN) + assert row.user_id == creator.user_id + assert row.metadata["creator_id"] == str(creator.user_id) + + def test_deactivated_through_the_status_endpoint(self, admin_client, creator, hook): + response = admin_client.post( + f"/api/user/{creator.user.id}/status/", {"status": "Inactive"} + ) + assert response.status_code == 200 + _assert_paused(hook, ownership.DEACTIVATED) + + @mock.patch("common.views.user_views.send_email_user_delete") + def test_removed_from_the_org(self, _email, admin_client, creator, hook): + response = admin_client.delete(f"/api/user/{creator.user.id}/") + assert response.status_code == 200 + assert not Profile.objects.filter(pk=creator.pk).exists() + _assert_paused(hook, ownership.LEFT) + + def test_user_deactivated_pauses_in_every_org(self, creator, hook, org_b): + """The Django admin's path: the user row, no request, no org context.""" + Profile.objects.create(user=creator.user, org=org_b, role="ADMIN") + other = WebhookEndpoint.objects.create( + org=org_b, + url="https://hooks.example.com/b", + events=["lead.created"], + created_by=creator.user, + ) + creator.user.is_active = False + creator.user.save() + _assert_paused(hook, ownership.DEACTIVATED) + _assert_paused(other, ownership.DEACTIVATED) + + def test_superuser_flag_removed_from_a_user_role_creator(self, org_a): + user = User.objects.create_user( + email="root@test.com", password="x", is_superuser=True + ) + Profile.objects.create(user=user, org=org_a, role="USER") + endpoint = WebhookEndpoint.objects.create( + org=org_a, + url="https://hooks.example.com/in", + events=["lead.created"], + created_by=user, + ) + user.is_superuser = False + user.save(update_fields=["is_superuser"]) + _assert_paused(endpoint, ownership.NOT_ADMIN) + + def test_user_deleted(self, creator, hook): + user_id = creator.user_id + creator.user.delete() + hook.refresh_from_db() + assert hook.created_by_id is None + row = _assert_paused(hook, ownership.DELETED_USER) + # The user row is gone, so the audit row names them by id. + assert row.user_id is None + assert row.metadata["creator_id"] == str(user_id) + + def test_deleting_the_org_takes_its_endpoints_without_error(self, creator, hook): + org_id = hook.org_id + Org.objects.filter(pk=org_id).delete() + assert not WebhookEndpoint.objects.filter(pk=hook.pk).exists() + assert not _paused_rows(hook).exists() + + def test_a_second_pause_writes_no_second_audit_row(self, creator, hook): + ownership.pause([hook], ownership.NOT_ADMIN) + ownership.pause([hook], ownership.NOT_ADMIN) + assert _paused_rows(hook).count() == 1 + + +class TestNotATrigger: + def test_another_admin_demoted_leaves_the_endpoint_alone( + self, admin_client, org_a, creator, hook + ): + bystander = Profile.objects.create( + user=User.objects.create_user(email="other@test.com", password="x"), + org=org_a, + role="ADMIN", + ) + response = admin_client.patch( + f"/api/user/{bystander.user.id}/", {"role": "USER"}, format="json" + ) + assert response.status_code == 200 + hook.refresh_from_db() + assert hook.is_active is True + assert not _paused_rows(hook).exists() + + def test_the_creator_edited_but_still_an_admin(self, admin_client, creator, hook): + response = admin_client.patch( + f"/api/user/{creator.user.id}/", {"phone": "+14155550100"}, format="json" + ) + assert response.status_code == 200 + hook.refresh_from_db() + assert hook.is_active is True + + def test_problem_answers_both_ways(self, creator, hook, user_profile): + assert ownership.creator_problem(hook) is None + hook.created_by = user_profile.user + assert ownership.creator_problem(hook) == ownership.NOT_ADMIN + hook.created_by = None + assert ownership.creator_problem(hook) == ownership.DELETED_USER + hook.created_by = User.objects.create_user(email="out@test.com", password="x") + assert ownership.creator_problem(hook) == ownership.NOT_MEMBER + + +class TestPausedSendsNothing: + def test_a_queued_retry_for_a_paused_endpoint_is_not_sent( + self, admin_client, creator, hook + ): + delivery = queue_delivery( + hook, "lead.created", envelope(hook.org_id, "lead.created", {}) + ) + admin_client.post( + f"/api/user/{creator.user.id}/status/", {"status": "Inactive"} + ) + with mock.patch("webhooks.tasks.ssrf.post") as post: + result = attempt_delivery(delivery.pk, hook.org_id) + post.assert_not_called() + assert result.status == FAILED + + def test_a_change_no_receiver_saw_is_caught_before_sending(self, creator, hook): + delivery = queue_delivery( + hook, "lead.created", envelope(hook.org_id, "lead.created", {}) + ) + # A queryset update sends no signal. + Profile.objects.filter(pk=creator.pk).update(role="USER") + hook.refresh_from_db() + assert hook.is_active is True + with mock.patch("webhooks.tasks.ssrf.post") as post: + result = attempt_delivery(delivery.pk, hook.org_id) + post.assert_not_called() + assert result.status == FAILED + _assert_paused(hook, ownership.NOT_ADMIN) + + def test_a_qualified_creator_still_sends(self, hook): + delivery = queue_delivery( + hook, "lead.created", envelope(hook.org_id, "lead.created", {}) + ) + with mock.patch("webhooks.tasks.ssrf.post", return_value=200) as post: + attempt_delivery(delivery.pk, hook.org_id) + post.assert_called_once() + + +class TestReEnable: + def _pause(self, admin_client, creator): + admin_client.patch( + f"/api/user/{creator.user.id}/", {"role": "USER"}, format="json" + ) + + def test_an_admin_turns_it_back_on_and_becomes_its_creator( + self, admin_client, admin_user, creator, hook + ): + self._pause(admin_client, creator) + response = admin_client.patch( + f"{BASE}{hook.id}/", {"is_active": True}, format="json" + ) + assert response.status_code == 200 + body = response.json() + assert body["is_active"] is True + assert body["disabled_reason"] == "" + assert body["created_by"]["email"] == admin_user.email + hook.refresh_from_db() + assert hook.created_by_id == admin_user.id + row = SecurityAuditLog.objects.get(event_type="WEBHOOK_REENABLED") + assert row.user_id == admin_user.id + assert row.org_id == hook.org_id + assert row.metadata == { + "endpoint_id": str(hook.id), + "previous_creator_id": str(creator.user_id), + "changed": ["is_active"], + } + + def test_a_member_cannot_turn_it_back_on( + self, admin_client, user_client, creator, hook + ): + self._pause(admin_client, creator) + response = user_client.patch( + f"{BASE}{hook.id}/", {"is_active": True}, format="json" + ) + assert response.status_code == 403 + hook.refresh_from_db() + assert hook.is_active is False + assert hook.created_by_id == creator.user_id + assert not SecurityAuditLog.objects.filter( + event_type="WEBHOOK_REENABLED" + ).exists() + + def test_a_description_edit_does_not_move_the_creator( + self, admin_client, creator, hook + ): + response = admin_client.patch( + f"{BASE}{hook.id}/", {"description": "renamed"}, format="json" + ) + assert response.status_code == 200 + hook.refresh_from_db() + assert hook.created_by_id == creator.user_id + assert not SecurityAuditLog.objects.filter( + event_type__in=["WEBHOOK_CHANGED", "WEBHOOK_REENABLED"] + ).exists() + + def test_resending_the_same_values_moves_nothing(self, admin_client, creator, hook): + admin_client.patch( + f"{BASE}{hook.id}/", + {"url": hook.url, "events": hook.events, "format": hook.format}, + format="json", + ) + hook.refresh_from_db() + assert hook.created_by_id == creator.user_id + + @pytest.mark.parametrize( + "change", + [ + {"url": "https://attacker.example.com/in"}, + {"events": ["lead.created", "deal.won"]}, + {"format": "slack"}, + ], + ) + def test_changing_what_or_where_moves_the_creator( + self, admin_client, admin_user, creator, hook, change + ): + response = admin_client.patch(f"{BASE}{hook.id}/", change, format="json") + assert response.status_code == 200 + hook.refresh_from_db() + assert hook.created_by_id == admin_user.id + row = SecurityAuditLog.objects.get(event_type="WEBHOOK_CHANGED") + assert row.user_id == admin_user.id + assert row.metadata["changed"] == list(change) + assert row.metadata["previous_creator_id"] == str(creator.user_id) + + def test_rotating_the_secret_moves_the_creator( + self, admin_client, admin_user, creator, hook + ): + response = admin_client.post(f"{BASE}{hook.id}/rotate-secret/") + assert response.status_code == 200 + hook.refresh_from_db() + assert hook.created_by_id == admin_user.id + row = SecurityAuditLog.objects.get(event_type="WEBHOOK_CHANGED") + assert row.metadata["changed"] == ["secret"] + + def test_re_pointing_then_being_demoted_pauses_the_endpoint( + self, org_a, admin_client, admin_user, creator, hook + ): + """The attack the move closes. Admin B (`admin_client`) points A's + endpoint at B's own server, then A demotes B. The endpoint is B's + now, so it pauses instead of sending the org's records to B.""" + admin_client.patch( + f"{BASE}{hook.id}/", {"url": "https://b.example.com/in"}, format="json" + ) + as_a = _make_authenticated_client(creator.user, org_a, creator) + response = as_a.patch( + f"/api/user/{admin_user.id}/", {"role": "USER"}, format="json" + ) + assert response.status_code == 200 + _assert_paused(hook, ownership.NOT_ADMIN) + + def test_created_by_cannot_be_set_from_the_body( + self, admin_client, creator, hook, user_profile + ): + admin_client.patch( + f"{BASE}{hook.id}/", + {"created_by": str(user_profile.user_id), "description": "x"}, + format="json", + ) + hook.refresh_from_db() + assert hook.created_by_id == creator.user_id + + def test_the_list_shows_the_creator(self, admin_client, creator, hook): + endpoint = admin_client.get(BASE).json()["endpoints"][0] + assert endpoint["created_by"] == { + "id": str(creator.user_id), + "name": creator.user.name, + "email": creator.user.email, + } + + +@pytest.mark.postgres_only +def test_deactivating_a_user_with_no_org_context_reaches_every_org(creator, org_b): + """The Django admin runs with no `app.current_org`, under which the policy + hides every endpoint. Each org's endpoints are looked up as that org, and + the empty context is put back afterwards. Only meaningful under the CI + role that RLS binds.""" + if connection.vendor != "postgresql": + pytest.skip("RLS requires PostgreSQL") + Profile.objects.create(user=creator.user, org=org_b, role="ADMIN") + ours = WebhookEndpoint.objects.create( + org=creator.org, + url="https://h.example.com/a", + events=["lead.created"], + created_by=creator.user, + ) + with rls_org(org_b): + theirs = WebhookEndpoint.objects.create( + org=org_b, + url="https://h.example.com/b", + events=["lead.created"], + created_by=creator.user, + ) + clear_rls_context() + + creator.user.is_active = False + creator.user.save() + + with connection.cursor() as cursor: + cursor.execute("SELECT current_setting('app.current_org', true)") + assert (cursor.fetchone()[0] or "") == "" + for org, endpoint in ((creator.org, ours), (org_b, theirs)): + with rls_org(org): + endpoint.refresh_from_db() + assert endpoint.is_active is False + assert endpoint.disabled_reason == ownership.DEACTIVATED diff --git a/backend/webhooks/views.py b/backend/webhooks/views.py index a7ada930..7c6359fe 100644 --- a/backend/webhooks/views.py +++ b/backend/webhooks/views.py @@ -11,6 +11,12 @@ Routes (all under /api/webhooks/): GET //deliveries/ paginated delivery log POST /deliveries//redeliver/ send a settled delivery again, as a new row +Whoever turns an endpoint on, or changes what it sends or where (url, events, +format, the secret), becomes its creator and is audited. Otherwise an admin +could re-point a colleague's endpoint at their own server and keep receiving +the org's records after being demoted, since only endpoints a demoted admin +created are paused (webhooks/ownership.py). A description edit moves nothing. + Reads are gated too. A URL often embeds its own credential (Zapier and Slack hook URLs are bearer secrets), and the delivery log holds copies of records a member may not be allowed to see. @@ -20,14 +26,15 @@ Another org's id answers 404, never 403, so ids cannot be probed. from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema -from rest_framework import permissions, status +from rest_framework import status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from common.audit_log import audit_log from common.lookups import get_scoped_or_404 -from common.permissions import HasOrgContext, is_org_admin +from common.permissions import HasOrgContext, IsOrgAdmin from webhooks import events from webhooks.emit import envelope, queue_delivery from webhooks.models import PENDING, WebhookDelivery, WebhookEndpoint, generate_secret @@ -35,15 +42,9 @@ from webhooks.serializers import WebhookDeliverySerializer, WebhookEndpointSeria MAX_ENDPOINTS_PER_ORG = 10 - -class IsOrgAdminOrSuperuser(permissions.BasePermission): - message = "Only an admin can manage webhooks." - - def has_permission(self, request, view): - profile = getattr(request, "profile", None) - return is_org_admin(profile) or bool( - profile is not None and profile.user.is_superuser - ) +# Changing any of these makes the caller the endpoint's creator. See the +# module docstring. +TAKE_OVER_FIELDS = ("url", "events", "format") def _error(message, code=status.HTTP_400_BAD_REQUEST): @@ -51,7 +52,7 @@ def _error(message, code=status.HTTP_400_BAD_REQUEST): class WebhookBaseView(APIView): - permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdminOrSuperuser) + permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdmin) def get_endpoint(self, request, pk): return get_scoped_or_404(WebhookEndpoint, pk, request.profile.org) @@ -62,7 +63,9 @@ class WebhookListCreateView(WebhookBaseView): tags=["Webhooks"], operation_id="webhooks_list", responses=OpenApiTypes.OBJECT ) def get(self, request): - endpoints = WebhookEndpoint.objects.filter(org=request.profile.org) + endpoints = WebhookEndpoint.objects.filter( + org=request.profile.org + ).select_related("created_by") return Response( { "endpoints": WebhookEndpointSerializer(endpoints, many=True).data, @@ -104,11 +107,26 @@ class WebhookDetailView(WebhookBaseView): ) def patch(self, request, pk): endpoint = self.get_endpoint(request, pk) + was_active, previous_creator_id = endpoint.is_active, endpoint.created_by_id serializer = WebhookEndpointSerializer( endpoint, data=request.data, partial=True ) serializer.is_valid(raise_exception=True) - serializer.save() + data = serializer.validated_data + changed = [ + field + for field in TAKE_OVER_FIELDS + if field in data and data[field] != getattr(endpoint, field) + ] + if data.get("is_active") and not was_active: + changed.append("is_active") + if changed: + serializer.save(created_by=request.user) + audit_log.webhook_taken_over( + request.user, endpoint, previous_creator_id, changed, request=request + ) + else: + serializer.save() return Response(serializer.data) @extend_schema(tags=["Webhooks"], responses={204: None}) @@ -136,8 +154,15 @@ class WebhookRotateSecretView(WebhookBaseView): @extend_schema(tags=["Webhooks"], request=None, responses=OpenApiTypes.OBJECT) def post(self, request, pk): endpoint = self.get_endpoint(request, pk) + previous_creator_id = endpoint.created_by_id endpoint.secret = generate_secret() - endpoint.save(update_fields=["secret", "updated_at"]) + endpoint.created_by = request.user + endpoint.save( + update_fields=["secret", "created_by", "updated_by", "updated_at"] + ) + audit_log.webhook_taken_over( + request.user, endpoint, previous_creator_id, ["secret"], request=request + ) return Response( {"secret": endpoint.secret, "secret_hint": endpoint.secret_hint} ) diff --git a/docs/api/audit-log.md b/docs/api/audit-log.md new file mode 100644 index 00000000..acd60b1a --- /dev/null +++ b/docs/api/audit-log.md @@ -0,0 +1,86 @@ +# Security audit log + +`GET /api/org/audit-log/` lets an org's admins read its security audit log: sign-ins, org switches, +refused requests, revoked memberships, paused webhooks and the like. The view is +`SecurityAuditLogListView` (`backend/common/views/audit_log_views.py`), routed in +`common/urls.py`; the events are written by `AuditLogger` in `common/audit_log.py`. See +[Conventions](conventions.md#pagination) for `limit` and `offset`, and [Errors](errors.md) for the +refusal shapes. + +## Who may read it + +Org admins only (`IsOrgAdmin`: the `ADMIN` role, or a Django superuser's profile in the org). A +member is refused with `403`: + +```json +{"detail": "You must be an organization administrator to perform this action."} +``` + +It is read from a signed-in session and nothing else. `/api/org/audit-log/` is on the credential +deny-list in `common/scopes.py`, beside `/api/webhooks/` and the token routes, so a personal access +token or the organization API key is refused before the view runs, whatever scopes it carries (see +[Tokens and API keys](tokens-and-api-keys.md#what-no-token-may-do-whatever-its-scopes)). A leaked +token cannot read what the org has noticed about it. + +Only the caller's own org's rows are returned. The table has no row-level security policy on +purpose (its `org` is empty for events such as a failed login with an unknown email), so the +view's `org=request.profile.org` filter is the barrier, and a row with no org never matches it. + +## Query parameters + +| Parameter | Meaning | +| --- | --- | +| `event_type` | One event type, by value (`LOGIN_SUCCESS`, `WEBHOOK_PAUSED`, ...). An unknown value is a `400`. | +| `actor` | The user id of whoever the event is about. Not a UUID is a `400`. | +| `from`, `to` | Days, `YYYY-MM-DD`, inclusive, in the org's timezone. `from` after `to` is a `400`. | +| `limit`, `offset` | Paging: 25 rows by default, at most 100. | + +Rows come newest first. For example, `GET /api/org/audit-log/?event_type=LOGIN_FAILURE&from=2026-09-01` +lists this month's failed sign-ins that were recorded against the org. + +```json +{"event_type": ["Unknown event type."]} +``` + +## The response + +The usual `limit`/`offset` envelope, plus the event types the log can hold, so a client can offer +them as a filter without a list of its own: + +```json +{ + "count": 213, + "next": "https://api.example.com/api/org/audit-log/?limit=25&offset=25", + "previous": null, + "results": [ + { + "id": "7d0e...", + "event_type": "WEBHOOK_PAUSED", + "event_label": "Webhook Paused", + "created_at": "2026-09-26T09:12:44.018+00:00", + "success": true, + "actor": {"id": "3b1f...", "name": "Dana Reyes", "email": "dana@example.com"}, + "ip_address": null, + "user_agent": "", + "request_method": "", + "request_path": "", + "details": {"endpoint_id": "91ac...", "creator_id": "3b1f...", "pause_reason": "..."} + } + ], + "event_types": [{"value": "LOGIN_SUCCESS", "label": "Login Success"}] +} +``` + +`actor` is `null` for an event with no user. + +What each row returns is chosen field by field rather than copied from the table: + +- **No `description`.** An `ORG_SWITCH` row's text names the org the user switched from, which is + another tenant's name. +- **`details` holds only known-safe keys** (`SAFE_DETAILS`: `action`, `resource`, `deleted_count`, + `endpoint_id`, `creator_id`, `pause_reason`, `previous_creator_id`). Those are ids, counts and + sentences the server wrote. Everything else in the stored metadata can hold what a caller + supplied (the email a failed login tried, an API key prefix, free-text details of suspicious + activity) and is left out. +- **Paths under `/api/public/` are cut to that prefix.** Some public links carry a token in the URL + (a satisfaction-survey link, for one), and the log should not repeat it. diff --git a/docs/api/endpoint-index.md b/docs/api/endpoint-index.md index e2f21f8b..1a574d8e 100644 --- a/docs/api/endpoint-index.md +++ b/docs/api/endpoint-index.md @@ -16,6 +16,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/accounts/` | GET, POST | | `/api/accounts/attachment/{id}/` | DELETE | | `/api/accounts/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/accounts/export/` | GET | | `/api/accounts/{id}/` | DELETE, GET, PATCH, POST, PUT | | `/api/accounts/{id}/create_mail/` | POST | | `/api/activities/` | GET | @@ -48,6 +49,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/cases/analytics/export/` | GET | | `/api/cases/analytics/frt/` | GET | | `/api/cases/analytics/mttr/` | GET | +| `/api/cases/analytics/nrt/` | GET | | `/api/cases/analytics/service/` | GET | | `/api/cases/analytics/sla/` | GET | | `/api/cases/approval-rules/` | GET, POST | @@ -63,6 +65,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/cases/csat/aggregate/` | GET | | `/api/cases/escalation-policies/` | GET, POST | | `/api/cases/escalation-policies/{id}/` | DELETE, GET, PUT | +| `/api/cases/export/` | GET | | `/api/cases/import/commit/` | POST | | `/api/cases/import/preview/` | POST | | `/api/cases/inbound/{mailbox_id}/` | POST | @@ -87,6 +90,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/cases/{id}/activities/` | GET | | `/api/cases/{id}/close-with-children/` | POST | | `/api/cases/{id}/link/` | POST | +| `/api/cases/{id}/merge-targets/` | GET | | `/api/cases/{id}/merge/{into_id}/` | POST | | `/api/cases/{id}/move/` | PATCH | | `/api/cases/{id}/request-approval/` | POST | @@ -103,6 +107,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/contacts/` | GET, POST | | `/api/contacts/attachment/{id}/` | DELETE | | `/api/contacts/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/contacts/export/` | GET | | `/api/contacts/import/commit/` | POST | | `/api/contacts/import/preview/` | POST | | `/api/contacts/{id}/` | DELETE, GET, PATCH, POST, PUT | @@ -121,6 +126,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/invoices/estimates/{id}/convert/` | POST | | `/api/invoices/estimates/{id}/pdf/` | GET | | `/api/invoices/estimates/{id}/send/` | POST | +| `/api/invoices/export/` | GET | | `/api/invoices/from-opportunity/{opportunity_id}/` | POST | | `/api/invoices/from-time-entries/` | POST | | `/api/invoices/products/` | GET, POST | @@ -149,7 +155,8 @@ Interactive versions of the same schema are served by a running backend at | `/api/leads/` | GET, POST | | `/api/leads/attachment/{id}/` | DELETE | | `/api/leads/comment/{id}/` | DELETE, PATCH, PUT | -| `/api/leads/create-from-site/` | POST | +| `/api/leads/create-from-site/` | POST (deprecated; use `/api/public/forms/{org_id}/{form_id}/submit/`) | +| `/api/leads/export/` | GET | | `/api/leads/import/commit/` | POST | | `/api/leads/import/preview/` | POST | | `/api/leads/kanban/` | GET | @@ -172,6 +179,7 @@ Interactive versions of the same schema are served by a running backend at | `/api/opportunities/aging-config/` | GET, PUT | | `/api/opportunities/attachment/{id}/` | DELETE | | `/api/opportunities/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/opportunities/export/` | GET | | `/api/opportunities/goals/` | GET, POST | | `/api/opportunities/goals/history/` | GET | | `/api/opportunities/goals/leaderboard/` | GET | @@ -188,6 +196,8 @@ Interactive versions of the same schema are served by a running backend at | `/api/opportunities/{opportunity_id}/line-items/{line_item_id}/` | DELETE, GET, PUT | | `/api/org/` | GET, POST | | `/api/org/api-key/` | GET, POST | +| `/api/org/audit-log/` | GET | +| `/api/org/help-center/` | GET, PATCH | | `/api/org/settings/` | GET, PATCH | | `/api/org/timezones/` | GET | | `/api/org/tokens/` | GET | @@ -215,8 +225,12 @@ Interactive versions of the same schema are served by a running backend at | `/api/public/forms/{org_id}/{form_id}/embed.js` | GET | | `/api/public/forms/{org_id}/{form_id}/embed/` | GET | | `/api/public/forms/{org_id}/{form_id}/submit/` | POST | +| `/api/public/help/{slug}/` | GET | +| `/api/public/help/{slug}/articles/{id}/` | GET | | `/api/public/invoice/{token}/` | GET | | `/api/public/invoice/{token}/pdf/` | GET | +| `/api/saved-views/` | GET, POST | +| `/api/saved-views/{id}/` | DELETE, GET, PATCH | | `/api/search/` | GET | | `/api/tags/` | GET, POST | | `/api/tags/{id}/` | DELETE, GET, PUT | diff --git a/docs/api/exports.md b/docs/api/exports.md new file mode 100644 index 00000000..00c60441 --- /dev/null +++ b/docs/api/exports.md @@ -0,0 +1,88 @@ +# CSV exports + +Six lists download as a CSV file: leads, contacts, accounts, deals, tickets and invoices. Each is a +`GET` under the list's own root, served by a subclass of `RecordExportView` +(`backend/common/csv_export.py`). See [Conventions](conventions.md) for the query-string rules the +lists share and [Errors](errors.md) for the shapes a refusal comes back in. + +| Endpoint | View | File name | Rows come from | +| --- | --- | --- | --- | +| `GET /api/leads/export/` | `LeadExportView` (`leads/views/export_views.py`) | `leads-YYYY-MM-DD.csv` | `lead_list_queryset` (`leads/views/lead_views.py`) | +| `GET /api/contacts/export/` | `ContactExportView` (`contacts/export_views.py`) | `contacts-YYYY-MM-DD.csv` | `contact_list_queryset` (`contacts/views.py`) | +| `GET /api/accounts/export/` | `AccountExportView` (`accounts/export_views.py`) | `accounts-YYYY-MM-DD.csv` | `account_list_queryset` (`accounts/views.py`) | +| `GET /api/opportunities/export/` | `DealExportView` (`opportunity/views/export_views.py`) | `deals-YYYY-MM-DD.csv` | `deal_list_queryset` (`opportunity/views/opportunity_views.py`) | +| `GET /api/cases/export/` | `CaseExportView` (`cases/export_views.py`) | `tickets-YYYY-MM-DD.csv` | `case_list_queryset` (`cases/views.py`) | +| `GET /api/invoices/export/` | `InvoiceExportView` (`invoices/export_views.py`) | `invoices-YYYY-MM-DD.csv` | `filter_invoices` over `visible_invoices_qs` (`invoices/api_views.py`) | + +The date in the file name is the day in the org's timezone (`export_filename`). + +## Who may export, and which rows + +Any member of the org may export (`permission_classes = (IsAuthenticated, HasOrgContext)`), and +gets exactly the rows the matching list would show them for the same query string, every page of +it. That holds because each export's `get_queryset` calls the function its list view builds its +queryset with, the one named in the table above, and never filters on its own. So the read rule +that hides a colleague's lead from a member on `GET /api/leads/` hides it from their file too, and +a ticket the caller cannot open is not in `tickets-*.csv`. + +For personal access tokens and the organization API key the export is part of the list's resource: +`GET /api/leads/export/` needs `leads:read`, the same scope as `GET /api/leads/` +(see [Tokens and API keys](tokens-and-api-keys.md#scopes)). + +## Query parameters + +An export takes its list's query parameters, with the list's meaning, and ignores paging: `limit` +and `offset` do not narrow a file, which always holds every matching row. For example, +`GET /api/cases/export/?status=New&status=Pending&priority=High` writes every high-priority ticket +in New or Pending that the caller can see. The parameters each list reads are listed on its own page +([Leads](leads.md#list-leads), [Contacts](contacts.md), [Accounts](accounts.md), +[Opportunities](opportunities.md), [Cases](cases.md), [Invoices and estimates](invoices.md)), and +are the same set a [saved view](saved-views.md) may hold. + +Two lists answer in halves that a file does not have, so the half is named in the query instead: + +- Leads: the list returns open and closed leads separately. `?open=true` exports only the open + ones, which is what the web list shows; without it both halves are written. +- Accounts and contacts: `?is_active=true` or `?is_active=false` exports one half. Without it, + active and inactive rows are both written. + +A malformed filter value (an id that is not a UUID, a day that does not exist, a number that is not +a number) is a `400` naming the parameter, returned before any byte of the file: + +```json +{"assigned_to": ["'x' is not a valid id."]} +``` + +## The response + +A `200` is a streamed `text/csv; charset=utf-8` body with +`Content-Disposition: attachment; filename="-.csv"`. Send `Accept: text/csv` +or no `Accept` at all; both are served (`CSV_RENDERERS`, `common/renderers.py`). The file: + +- starts with a UTF-8 byte-order mark, so Excel reads accented names correctly; +- has one header row, then one row per record, in the list's own order; +- writes people as their email address and several values in one cell as a `; `-separated list + (owners, tags, linked accounts); +- writes dates as `YYYY-MM-DD` and timestamps as ISO 8601 in the org's timezone; +- prefixes any text cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return (also + after leading spaces) with `'`, so a record named `=HYPERLINK(...)` is text in a spreadsheet + rather than a formula (`safe_cell`). Numbers the server formats are written as they are. + +The body is generated while it streams, after the request's middleware has finished, so the view +sets the org's row-level security context and timezone again for exactly as long as it runs +(`csv_response`). + +## Columns + +| File | Columns | +| --- | --- | +| Leads | ID, First name, Last name, Company, Job title, Email, Phone, Status, Source, Rating, Industry, Amount, Currency, Probability, Close date, Last contacted, Next follow-up, City, Country, Assigned to, Tags, Created | +| Contacts | ID, First name, Last name, Email, Phone, Title, Department, Organization, Account, Linked accounts, City, State, Country, Do not call, Active, Assigned to, Tags, Created | +| Accounts | ID, Name, Industry, Email, Phone, Website, City, State, Country, Employees, Annual revenue, Currency, Active, Assigned to, Tags, Created | +| Deals | ID, Name, Account, Pipeline, Stage, Amount, Currency, Probability, Close date, Type, Lead source, Assigned to, Tags, Created | +| Tickets | ID, Subject, Status, Priority, Type, Account, Assigned to, Tags, Created, First response, Resolved, Closed on | +| Invoices | ID, Number, Title, Status, Account, Client name, Client email, Issue date, Due date, Total, Paid, Due, Currency, Assigned to, Created | + +Choice fields are written as their labels (`In Process`, not `in process`), and a deal's stage as +the label of that stage in the deal's own pipeline. The column lists are the `columns` method of +each view; those are the source of truth if this table and a file ever disagree. diff --git a/docs/api/saved-views.md b/docs/api/saved-views.md new file mode 100644 index 00000000..8670bd80 --- /dev/null +++ b/docs/api/saved-views.md @@ -0,0 +1,118 @@ +# Saved views + +A saved view is a name and a set of list filters, stored for one person so they can put a filtered +list back with one tap. The six lists that [export to CSV](exports.md) take one: leads, contacts, +accounts, deals, tickets and invoices. Routes are in `backend/common/urls.py`, views in +`common/views/saved_view_views.py`, and what a view may hold in `common/saved_views.py`. + +| Endpoint | Does | +| --- | --- | +| `GET /api/saved-views/` | The caller's own views; `?module=` for one list's | +| `POST /api/saved-views/` | Save one | +| `GET /api/saved-views/{id}/` | One of the caller's views | +| `PATCH /api/saved-views/{id}/` | Rename it, or replace its filters | +| `DELETE /api/saved-views/{id}/` | Delete it (`204`) | + +`PUT` is not offered; a view has two things to change and `PATCH` changes either. + +## Private to the person who saved it + +Every lookup filters on the caller's org and the caller's own profile, admins included: an admin +cannot list or open a member's views. Another person's id answers `404` on every verb, with the +same body as an id that does not exist, so ids cannot be probed (see +[Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden)). The table is also +under row-level security keyed on the org. + +`org` and `profile` are set by the server. A body that names either is refused with a `400`, rather +than having the key quietly dropped: + +```json +{"profile": ["This is set by the server."]} +``` + +For personal access tokens the resource is `saved-views`: `saved-views:read` to list and open, +`saved-views:write` to save, rename and delete (see +[Tokens and API keys](tokens-and-api-keys.md#scopes)). + +## The object + +```json +{ + "id": "5b0c5c1e-6a0e-4f43-9d6e-1f0f3c1a2b3c", + "module": "cases", + "name": "Urgent, mine", + "filters": { + "status": ["New", "Assigned", "Pending"], + "priority": ["Urgent"], + "assigned_to": ["8a4e...-profile-id"] + }, + "created_at": "2026-09-26T10:04:11.201Z", + "updated_at": "2026-09-26T10:04:11.201Z" +} +``` + +`module` is the API name of the list: `leads`, `contacts`, `accounts`, `opportunities` (deals), +`cases` (tickets) or `invoices`. It is fixed once the view exists; a `PATCH` naming another list is +a `400`. + +`filters` holds the list's own query parameters, each as a list of values, so a view is applied by +sending them to the list as they are: the example above is +`GET /api/cases/?status=New&status=Assigned&status=Pending&priority=Urgent&assigned_to=...`, and +the same query string to `GET /api/cases/export/` downloads it. A value sent as one string is stored +as a list of one, and blank values are dropped. + +The list response carries the cap alongside the views: + +```json +{"saved_views": [ { "...": "the object above" } ], "limit": 25} +``` + +## What a view may hold + +A view is checked before it is stored, so a bad one is a `400` now rather than a list that breaks +every time the view is applied: + +- **Only the parameters its list reads.** Each list's accepted set is written out in + `LISTS` (`common/saved_views.py`), beside the function that list builds its queryset with, and a + test reads those functions to prove the two agree. A `cf_` custom-field filter is accepted on + all six. Paging (`limit`, `offset`) and anything else is refused: + `{"filters": ["This list does not filter by: limit."]}`. +- **Values the list can parse.** The filters are run through the list's own parameter parsing + (the queryset is built, never evaluated), so a malformed id, date or number is refused with the + list's own message: `{"filters": ["assigned_to: 'x' is not a valid id."]}`. A value that parses but + matches nothing, a status that does not exist for example, is stored, exactly as the list would + accept it. +- **Bounded.** At most 30 parameters, 50 values per parameter, and 200 characters per value. Every + value must be text. + +The ids a view holds are filter values, not references the server resolves: applying a view sends +them back to the list, which applies its own read rule, so a view cannot show anyone a record the +list would not. + +## Names and the cap + +A name is required, trimmed, and at most 100 characters. Names are unique per person and list, +ignoring case, so `Hot leads` and `hot leads` cannot both be saved for leads; a clash is a `400`, +also when two saves race each other past the check: + +```json +{"name": ["You already have a view with this name for this list."]} +``` + +The same name may be used on another list, or by another person. + +A person may keep at most 25 views per list (`MAX_VIEWS_PER_LIST`). Saving one more is a `400`; +renaming and changing the filters of an existing view still work at the cap: + +```json +{"non_field_errors": ["You can save at most 25 views for one list."]} +``` + +## In the apps + +The web lists and the mobile app both offer a saved-views menu on these six lists, over this API, +so a view saved on one opens on the other. A view means what the list endpoint means: on tickets, +no `status` is every status, so both clients save the open queue as its three statuses and open a +view without a status as All. Each client saves only the filters its own screen can show and set; +when a view holds a filter the screen has no control for, or several values where the screen takes +one, it still opens and the menu says how many values it left out. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 87632438..c8eec443 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -54,6 +54,7 @@ itself, so they aren't in the table below: | `DEBUG` | `False` | No | Django debug mode. Also the sole gate on the `devlogin` management command. See [Management commands](management-commands.md#devlogin). | | `ALLOWED_HOSTS` | `localhost,127.0.0.1` | Yes, once serving traffic under a real hostname | Comma-separated list, split on `,` into Django's `ALLOWED_HOSTS`. | | `TRUST_PROXY_SSL_HEADER` | `False` | Yes, if TLS is terminated by a proxy in front of Django | Set to `true` (case-insensitive) to set Django's `SECURE_PROXY_SSL_HEADER` to `("HTTP_X_FORWARDED_PROTO", "https")`. Without it, a request arriving from nginx or an ALB over plain HTTP makes `request.is_secure()` false, and `SecurityMiddleware` then emits **no** `Strict-Transport-Security` header at all, so the `SECURE_HSTS_SECONDS` / `INCLUDE_SUBDOMAINS` / `PRELOAD` settings are silently inert. Only turn it on when that proxy is the sole route to the app **and** it overwrites `X-Forwarded-Proto` rather than passing through a client-supplied one, because otherwise any caller reaching Django directly can make `request.is_secure()` return true. The http-to-https redirect belongs on the proxy; `SECURE_SSL_REDIRECT` is deliberately not set. | +| `NUM_PROXIES` | unset | Only when `REMOTE_ADDR` is the proxy (gunicorn behind nginx: `1`). Leave unset under uvicorn `--proxy-headers` | How many proxies in front of Django append to `X-Forwarded-For` (nginx with `$proxy_add_x_forwarded_for` is one). Sets DRF's `NUM_PROXIES`, read by `common.request_meta.client_ip`, which supplies every recorded client IP (security audit log, web form submissions, estimate acceptance, magic-link and portal sign-in tokens) and every per-IP throttle (web form submit, help center). It uses the entry that many places from the right, the address the outermost proxy saw; entries to its left are whatever the caller sent and are ignored, so rotating the header neither picks a throttle bucket nor forges a recorded address. Unset or `0` uses `REMOTE_ADDR`. That is correct when the app server already resolves the client from trusted proxies, as uvicorn does with `--proxy-headers --forwarded-allow-ips=127.0.0.1`; setting `NUM_PROXIES` on top of that counts the proxy twice, so a request the SvelteKit server relays for a visitor (help center pages) is recorded and throttled as the relay. When `REMOTE_ADDR` is the proxy itself (gunicorn behind nginx), unset means every visitor shares one throttle bucket, so set it to the number of proxies. Set it higher than the real number of proxies and callers can choose the IP again. | | `DJANGO_ORG_API_KEY_AUTH` | `true` | No | Set to `false`, `0`, `no` or `off` to refuse the organization API key (`Token: `) as an authentication method entirely, on every endpoint. The key is one non-expiring credential per tenant that resolves to an arbitrary active ADMIN, so it cannot be revoked per-integration and it reads every record in the org. It is read-only and barred from the credential endpoints even when enabled (see [Tokens and API keys](../api/tokens-and-api-keys.md)), but a deployment whose integrations have all moved to personal access tokens should turn it off. Left on by default so an upgrade breaks nothing. | ### Database diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js index 71a3c500..977971fc 100644 --- a/frontend/src/hooks.server.js +++ b/frontend/src/hooks.server.js @@ -13,6 +13,7 @@ import * as Sentry from '@sentry/sveltekit'; import { redirect } from '@sveltejs/kit'; import axios from 'axios'; import { env } from '$env/dynamic/public'; +import { isOrgAdmin } from '$lib/admin.js'; import { describeError } from '$lib/server/log-safe.js'; const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`; @@ -20,9 +21,9 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ /** * @typedef {{ default_currency?: string, currency_symbol?: string, default_country?: string|null }} OrgSettingsPayload - * @typedef {{ org_id?: string, org_name?: string, role?: string, user_id?: string, user_name?: string, user_email?: string, user_profile_pic?: string, exp?: number, iat?: number, org_settings?: OrgSettingsPayload }} JWTPayload + * @typedef {{ org_id?: string, org_name?: string, role?: string, is_organization_admin?: boolean, user_id?: string, user_name?: string, user_email?: string, user_profile_pic?: string, exp?: number, iat?: number, org_settings?: OrgSettingsPayload }} JWTPayload * @typedef {{ id: string, name: string }} OrgInfo - * @typedef {{ org: OrgInfo, role?: string }} ProfileInfo + * @typedef {{ org: OrgInfo, role?: string, is_organization_admin?: boolean }} ProfileInfo * @typedef {{ id?: string, organizations?: Array<{ id: string, name: string }> }} UserInfo * @typedef {{ access_token: string, refresh_token: string, current_org?: OrgInfo }} SwitchOrgResult */ @@ -251,7 +252,10 @@ export const handle = sequence(Sentry.sentryHandle(), async function _handle({ e }; /** @type {any} */ (event.locals).profile = { org: event.locals.org, - role: jwtPayload.role || 'USER' + role: jwtPayload.role || 'USER', + // The admin fact every admin gate reads (`$lib/admin.js`). Server + // derived and signed; `role` stays for display only. + is_organization_admin: isOrgAdmin(jwtPayload) }; event.locals.org_name = jwtPayload.org_name || 'Organization'; // Extract org settings for currency/locale @@ -289,7 +293,8 @@ export const handle = sequence(Sentry.sentryHandle(), async function _handle({ e const newPayload = decodeJwtPayload(switchResult.access_token); /** @type {any} */ (event.locals).profile = { org: switchResult.current_org, - role: newPayload?.role || 'USER' + role: newPayload?.role || 'USER', + is_organization_admin: isOrgAdmin(newPayload) }; event.locals.org_name = switchResult.current_org?.name || 'Organization'; event.locals.org_settings = newPayload?.org_settings || { diff --git a/frontend/src/lib/admin.js b/frontend/src/lib/admin.js new file mode 100644 index 00000000..a24c8a82 --- /dev/null +++ b/frontend/src/lib/admin.js @@ -0,0 +1,23 @@ +/** + * Whether the signed-in user administers the current org. + * + * The one place the web app decides this. It reads `is_organization_admin`, a + * fact the API derives with `common.permissions.is_org_admin` (the ADMIN role, + * or a Django superuser's membership) and signs into the access token. It + * never compares `role`: a superuser who holds the USER role is an admin + * everywhere on the server, and a `role === 'ADMIN'` test showed them + * read-only pages the API would have let them change. + * + * A display hint only. It hides or shows controls; every write is refused or + * allowed by the backend, which re-derives the fact on each request. + * + * Only a literal `true` counts, so a missing claim (a token minted before the + * claim existed, which the hourly refresh replaces) reads as "not an admin". + * + * @param {Record | null | undefined} source + * JWT claims, `locals.profile`, or layout data: anything carrying the fact. + * @returns {boolean} + */ +export function isOrgAdmin(source) { + return source?.is_organization_admin === true; +} diff --git a/frontend/src/lib/admin.test.js b/frontend/src/lib/admin.test.js new file mode 100644 index 00000000..5471fecf --- /dev/null +++ b/frontend/src/lib/admin.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { isOrgAdmin } from './admin.js'; + +describe('isOrgAdmin', () => { + it('is true only when the server says so', () => { + expect(isOrgAdmin({ is_organization_admin: true })).toBe(true); + expect(isOrgAdmin({ is_organization_admin: false })).toBe(false); + }); + + it('admits a USER-role superuser, whose token carries the fact', () => { + expect(isOrgAdmin({ role: 'USER', is_organization_admin: true })).toBe(true); + }); + + it('never reads the role', () => { + expect(isOrgAdmin({ role: 'ADMIN' })).toBe(false); + expect(isOrgAdmin({ role: 'ADMIN', is_organization_admin: false })).toBe(false); + }); + + it('refuses anything that is not literally true', () => { + for (const value of ['true', 1, 'yes', {}, null, undefined]) { + expect(isOrgAdmin({ is_organization_admin: value })).toBe(false); + } + }); + + it('refuses a missing source', () => { + expect(isOrgAdmin(null)).toBe(false); + expect(isOrgAdmin(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/lib/components/contacts/ContactImportDrawer.svelte b/frontend/src/lib/components/contacts/ContactImportDrawer.svelte index 2e417ba4..152b1b02 100644 --- a/frontend/src/lib/components/contacts/ContactImportDrawer.svelte +++ b/frontend/src/lib/components/contacts/ContactImportDrawer.svelte @@ -4,6 +4,7 @@ import { Loader2, Upload, FileText, Download, CheckCircle2, AlertCircle } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js'; + import { downloadImportErrors } from '$lib/utils/csv.js'; /** * Two-step CSV import flow: upload → preview → confirm. @@ -112,22 +113,6 @@ URL.revokeObjectURL(url); } - /** @param {{ row: number, field: string, message: string }[]} errs */ - function downloadErrorsList(errs) { - if (!errs?.length) return; - const rows = [['row', 'field', 'message'], ...errs.map((e) => [e.row, e.field, e.message])]; - const csv = rows - .map((r) => r.map((c) => (String(c).includes(',') ? `"${c}"` : String(c))).join(',')) - .join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'contacts-import-errors.csv'; - a.click(); - URL.revokeObjectURL(url); - } - /** @param {Event & { currentTarget: HTMLInputElement }} e */ function onFileChange(e) { const f = e.currentTarget.files?.[0]; @@ -393,7 +378,7 @@ @@ -431,7 +416,7 @@ diff --git a/frontend/src/lib/components/leads/LeadImportDrawer.svelte b/frontend/src/lib/components/leads/LeadImportDrawer.svelte index 2d85fccd..129bfece 100644 --- a/frontend/src/lib/components/leads/LeadImportDrawer.svelte +++ b/frontend/src/lib/components/leads/LeadImportDrawer.svelte @@ -4,6 +4,7 @@ import { Loader2, Upload, FileText, Download, CheckCircle2, AlertCircle } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js'; + import { downloadImportErrors } from '$lib/utils/csv.js'; /** * Two-step CSV import flow: upload → preview → confirm. @@ -108,22 +109,6 @@ URL.revokeObjectURL(url); } - /** @param {{ row: number, field: string, message: string }[]} errs */ - function downloadErrorsList(errs) { - if (!errs?.length) return; - const rows = [['row', 'field', 'message'], ...errs.map((e) => [e.row, e.field, e.message])]; - const csv = rows - .map((r) => r.map((c) => (String(c).includes(',') ? `"${c}"` : String(c))).join(',')) - .join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'leads-import-errors.csv'; - a.click(); - URL.revokeObjectURL(url); - } - /** @param {Event & { currentTarget: HTMLInputElement }} e */ function onFileChange(e) { const f = e.currentTarget.files?.[0]; @@ -388,7 +373,7 @@ @@ -426,7 +411,7 @@ diff --git a/frontend/src/lib/components/tickets/LinkParentDialog.svelte b/frontend/src/lib/components/tickets/LinkParentDialog.svelte deleted file mode 100644 index 0f09eb72..00000000 --- a/frontend/src/lib/components/tickets/LinkParentDialog.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - - onOpenChange?.(v)}> - - - - - Link this ticket under a parent - - - Pick the umbrella problem ticket. Tree depth is limited to three levels; descendants of the - current ticket are not selectable. - - - -
-
- - -
- - {#if searching} -
- Searching… -
- {:else if candidates.length === 0 && query.length >= 2} -

No matches.

- {:else if candidates.length > 0} -
    - {#each candidates as c (c.id)} -
  • - -
  • - {/each} -
- {/if} -
- - - - - -
-
diff --git a/frontend/src/lib/components/tickets/TicketImportDrawer.svelte b/frontend/src/lib/components/tickets/TicketImportDrawer.svelte index fe2985c7..1fcf2890 100644 --- a/frontend/src/lib/components/tickets/TicketImportDrawer.svelte +++ b/frontend/src/lib/components/tickets/TicketImportDrawer.svelte @@ -4,6 +4,7 @@ import { Loader2, Upload, FileText, Download, CheckCircle2, AlertCircle } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js'; + import { downloadImportErrors } from '$lib/utils/csv.js'; /** * Two-step CSV import flow: upload → preview → confirm. @@ -92,22 +93,6 @@ URL.revokeObjectURL(url); } - /** @param {{ row: number, field: string, message: string }[]} errs */ - function downloadErrorsList(errs) { - if (!errs?.length) return; - const rows = [['row', 'field', 'message'], ...errs.map((e) => [e.row, e.field, e.message])]; - const csv = rows - .map((r) => r.map((c) => (String(c).includes(',') ? `"${c}"` : String(c))).join(',')) - .join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'tickets-import-errors.csv'; - a.click(); - URL.revokeObjectURL(url); - } - /** @param {Event & { currentTarget: HTMLInputElement }} e */ function onFileChange(e) { const f = e.currentTarget.files?.[0]; @@ -358,7 +343,7 @@ @@ -396,7 +381,7 @@ diff --git a/frontend/src/lib/components/tickets/TicketTreePanel.svelte b/frontend/src/lib/components/tickets/TicketTreePanel.svelte deleted file mode 100644 index 3538b69d..00000000 --- a/frontend/src/lib/components/tickets/TicketTreePanel.svelte +++ /dev/null @@ -1,191 +0,0 @@ - - -
-
-

- - Ticket tree - {#if isProblem} - - PROBLEM - - {/if} -

-
- {#if parentSummary} - - {:else} - - {/if} -
-
- - {#if !hasGraph} -

- This ticket is standalone. Link it under a parent to coordinate related tickets together. -

- {:else if loading} -
- Loading tree… -
- {:else if tree} -
    - {@render node(tree.root, 0, tree.focus_id)} -
- {/if} -
- -{#snippet node(n, depth, focusId)} -
  • -
    - - - {n.status} - -
    - {#if n.children?.length} -
      - {#each n.children as child (child.id)} - {@render node(child, depth + 1, focusId)} - {/each} -
    - {:else if n.truncated} -

    - Tree truncated at this depth. -

    - {/if} -
  • -{/snippet} - - (linkOpen = v)} {onLinked} /> diff --git a/frontend/src/lib/components/tickets/index.js b/frontend/src/lib/components/tickets/index.js index 03839aac..55d48fba 100644 --- a/frontend/src/lib/components/tickets/index.js +++ b/frontend/src/lib/components/tickets/index.js @@ -6,8 +6,6 @@ export { default as TicketListActions } from './TicketListActions.svelte'; export { default as TicketActivityTimeline } from './TicketActivityTimeline.svelte'; export { default as TicketDiscussion } from './TicketDiscussion.svelte'; export { default as TicketMergeDialog } from './TicketMergeDialog.svelte'; -export { default as TicketTreePanel } from './TicketTreePanel.svelte'; -export { default as LinkParentDialog } from './LinkParentDialog.svelte'; export { default as TicketTimePanel } from './TicketTimePanel.svelte'; export { default as ManualTimeEntryDialog } from './ManualTimeEntryDialog.svelte'; export { default as TicketApprovalPanel } from './TicketApprovalPanel.svelte'; diff --git a/frontend/src/lib/server/v2/accounts.js b/frontend/src/lib/server/v2/accounts.js index 73c0dc0e..0862ef57 100644 --- a/frontend/src/lib/server/v2/accounts.js +++ b/frontend/src/lib/server/v2/accounts.js @@ -500,9 +500,10 @@ export async function createAccount({ cookies }, values) { } /** - * `AccountDetailView.get` answers 404 for another org's account. Deliberately - * not 403, which would confirm the id exists, and 403 for an account inside - * the org that this profile neither created nor is assigned to. + * `AccountDetailView.get` answers 404, with one body, both for an account that + * does not exist and for one this profile may not open (another org's, or one + * in this org they neither created nor are assigned to). Deliberately not + * 403, which would confirm the id exists, so the copy covers both cases. * * @param {import('@sveltejs/kit').Cookies} cookies * @param {string} id @@ -514,10 +515,7 @@ async function fetchDetail(cookies, id) { // On the status, not on the wording. Django answers a missing record with // "No Account matches the given query.", no "404" in it anywhere. if (err?.status === 404) { - error(404, 'That account does not exist, or it belongs to another team.'); - } - if (err?.status === 403) { - error(403, 'This account belongs to somebody else. Ask an admin if you need it.'); + error(404, 'That account does not exist, or you do not have access to it.'); } throw err; } diff --git a/frontend/src/lib/server/v2/audit-log.js b/frontend/src/lib/server/v2/audit-log.js new file mode 100644 index 00000000..6b5ee989 --- /dev/null +++ b/frontend/src/lib/server/v2/audit-log.js @@ -0,0 +1,74 @@ +/** + * The security audit log: the wiring behind `/settings/audit-log`. + * + * Server-only. `GET /api/org/audit-log/` is admin-only + * (`common/views/audit_log_views.py`), so a member's 403 folds into + * `forbidden` and the page says "Admins only" instead of crashing, the shape + * `listWebhooks` uses. A 400 is a filter the API refused (a hand-edited URL); + * the page shows the reason beside an empty list rather than an error page. + * + * Filters travel in the page URL and are passed on as they are. The API is + * what validates them. + */ +import { apiRequest } from '$lib/api-helpers.js'; + +/** Entries per page. */ +export const AUDIT_PAGE = 25; + +/** The query parameters the API filters on. Nothing else is forwarded. */ +export const AUDIT_FILTERS = /** @type {const} */ (['event_type', 'actor', 'from', 'to']); + +/** + * The filters set in the page URL, blank ones left out. + * @param {URLSearchParams} params + * @returns {Record} + */ +export function auditFilters(params) { + /** @type {Record} */ + const out = {}; + for (const key of AUDIT_FILTERS) { + const value = params.get(key)?.trim(); + if (value) out[key] = value; + } + return out; +} + +/** + * One page of the caller's org's audit log. + * @param {{ cookies: import('@sveltejs/kit').Cookies }} event + * @param {Record} filters + * @param {number} offset + */ +export async function listAuditLog({ cookies }, filters, offset = 0) { + const query = new URLSearchParams({ + ...filters, + limit: String(AUDIT_PAGE), + offset: String(offset) + }); + const page = { filters, offset, pageSize: AUDIT_PAGE }; + let resp; + try { + resp = await apiRequest(`/org/audit-log/?${query}`, {}, { cookies }); + } catch (/** @type {any} */ err) { + if (err?.status === 403) return { forbidden: true }; + if (err?.status === 400) { + return { + forbidden: false, + ...page, + error: err.message || 'That filter is not valid.', + entries: [], + count: 0, + eventTypes: [] + }; + } + throw err; + } + return { + forbidden: false, + ...page, + error: null, + entries: resp?.results ?? [], + count: resp?.count ?? 0, + eventTypes: resp?.event_types ?? [] + }; +} diff --git a/frontend/src/lib/server/v2/audit-log.test.js b/frontend/src/lib/server/v2/audit-log.test.js new file mode 100644 index 00000000..87b3bd4c --- /dev/null +++ b/frontend/src/lib/server/v2/audit-log.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiRequest = vi.fn(); +vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) })); + +const { auditFilters, listAuditLog, AUDIT_PAGE } = await import('$lib/server/v2/audit-log.js'); + +const event = /** @type {any} */ ({ cookies: { get: () => 'token' } }); + +describe('auditFilters', () => { + it('forwards only the four filters, blank ones dropped', () => { + const params = new URLSearchParams( + 'event_type=ORG_SWITCH&actor=&from=2026-09-01&org=attacker&to=%20' + ); + expect(auditFilters(params)).toEqual({ event_type: 'ORG_SWITCH', from: '2026-09-01' }); + }); +}); + +describe('listAuditLog', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it('asks for one page with the filters', async () => { + apiRequest.mockResolvedValue({ count: 1, results: [{ id: 'a' }], event_types: [] }); + const out = await listAuditLog(event, { actor: 'u1' }, 25); + const url = apiRequest.mock.calls[0][0]; + expect(url.startsWith('/org/audit-log/?')).toBe(true); + const q = new URLSearchParams(url.split('?')[1]); + expect(q.get('actor')).toBe('u1'); + expect(q.get('limit')).toBe(String(AUDIT_PAGE)); + expect(q.get('offset')).toBe('25'); + expect(out).toMatchObject({ forbidden: false, count: 1, entries: [{ id: 'a' }], error: null }); + }); + + it('folds a 403 into forbidden', async () => { + apiRequest.mockRejectedValue(Object.assign(new Error('no'), { status: 403 })); + expect(await listAuditLog(event, {}, 0)).toEqual({ forbidden: true }); + }); + + it('shows a refused filter beside an empty list', async () => { + apiRequest.mockRejectedValue( + Object.assign(new Error('event_type: Unknown event type.'), { status: 400 }) + ); + const out = /** @type {any} */ (await listAuditLog(event, { event_type: 'X' }, 0)); + expect(out).toMatchObject({ forbidden: false, entries: [], count: 0 }); + expect(out.error).toMatch(/Unknown event type/); + }); + + it('lets any other failure through', async () => { + apiRequest.mockRejectedValue(Object.assign(new Error('boom'), { status: 500 })); + await expect(listAuditLog(event, {}, 0)).rejects.toThrow('boom'); + }); +}); diff --git a/frontend/src/lib/server/v2/business-hours.js b/frontend/src/lib/server/v2/business-hours.js index 9819058f..54e4e054 100644 --- a/frontend/src/lib/server/v2/business-hours.js +++ b/frontend/src/lib/server/v2/business-hours.js @@ -32,7 +32,7 @@ * what actually refuses. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** Monday-first weekday label ↔ the model's field prefix. */ const WEEKDAYS = [ @@ -73,11 +73,11 @@ export async function getBusinessHours({ cookies }) { date: h.date })) }, - // A display hint decoded from the JWT `role` claim, deciding only whether + // A display hint (`viewerIsAdmin`), deciding only whether // the page offers "Edit hours" / "Add holiday". `BusinessCalendarView.put` // and the holiday views re-derive admin status from `request.profile` // server-side, and that is what actually refuses a non-admin's write. - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/contacts.js b/frontend/src/lib/server/v2/contacts.js index 9a5db6b1..275c4d6f 100644 --- a/frontend/src/lib/server/v2/contacts.js +++ b/frontend/src/lib/server/v2/contacts.js @@ -508,10 +508,11 @@ export async function createContact({ cookies }, values) { } /** - * `ContactDetailView.get` answers 404 for another org's contact. Deliberately - * not 403, which would confirm the id exists, and 403 for a contact inside the - * org that this profile neither created, nor is assigned to, nor reaches - * through the account they own. + * `ContactDetailView.get` answers 404, with one body, both for a contact that + * does not exist and for one this profile may not open (another org's, or one + * in this org they neither created, nor are assigned to, nor reach through an + * account they own). Deliberately not 403, which would confirm the id exists, + * so the copy covers both cases. * * @param {import('@sveltejs/kit').Cookies} cookies * @param {string} id @@ -523,10 +524,7 @@ async function fetchDetail(cookies, id) { // On the status, not on the wording. Django answers a missing record with // "No Contact matches the given query.", no "404" in it anywhere. if (err?.status === 404) { - error(404, 'That contact does not exist, or they belong to another team.'); - } - if (err?.status === 403) { - error(403, 'This contact belongs to somebody else. Ask an admin if you need them.'); + error(404, 'That contact does not exist, or you do not have access to them.'); } throw err; } diff --git a/frontend/src/lib/server/v2/csv-export.test.js b/frontend/src/lib/server/v2/csv-export.test.js index 94ce96fb..8b05a5ca 100644 --- a/frontend/src/lib/server/v2/csv-export.test.js +++ b/frontend/src/lib/server/v2/csv-export.test.js @@ -105,6 +105,19 @@ describe('list queries', () => { ]); }); + it('forward every picked status and owner, as a saved view from the phone holds them', () => { + const q = queries.ticketListQuery( + page(`/tickets?status=New&status=Pending&assigned_to=${UUID}&assigned_to=${UUID}`) + ); + expect(q.getAll('status')).toEqual(['New', 'Pending']); + expect(q.getAll('assigned_to')).toEqual([UUID, UUID]); + expect(queries.ticketListQuery(page('/tickets?status=Nope')).getAll('status')).toEqual([ + 'New', + 'Assigned', + 'Pending' + ]); + }); + it('ask for active contacts unless inactive=1', () => { expect(queries.contactListQuery(page('/contacts')).get('is_active')).toBe('true'); expect(queries.contactListQuery(page('/contacts?inactive=1')).has('is_active')).toBe(false); diff --git a/frontend/src/lib/server/v2/custom-fields.js b/frontend/src/lib/server/v2/custom-fields.js index 489fdb59..08e5554e 100644 --- a/frontend/src/lib/server/v2/custom-fields.js +++ b/frontend/src/lib/server/v2/custom-fields.js @@ -23,7 +23,7 @@ * detail view has no separate reactivate endpoint. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * @param {{ cookies: import('@sveltejs/kit').Cookies }} event @@ -39,7 +39,7 @@ export async function getCustomFields({ cookies }) { models_extended: 0, required_with_gaps: 0 }, - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/deal-pipelines.js b/frontend/src/lib/server/v2/deal-pipelines.js index 8af2101d..e9cbe76f 100644 --- a/frontend/src/lib/server/v2/deal-pipelines.js +++ b/frontend/src/lib/server/v2/deal-pipelines.js @@ -15,7 +15,7 @@ */ import { apiRequest } from '$lib/api-helpers.js'; import { listPipelines } from './deals.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * Every pipeline, and the one whose stages are on screen. An absent or @@ -32,7 +32,7 @@ export async function getDealPipelines({ cookies }, pipelineId = null) { pipelines.find((p) => p.is_default) ?? pipelines[0] ?? null; - return { pipelines, pipeline, can_edit: viewerRole(cookies) === 'ADMIN' }; + return { pipelines, pipeline, can_edit: viewerIsAdmin(cookies) }; } /** diff --git a/frontend/src/lib/server/v2/deals.js b/frontend/src/lib/server/v2/deals.js index 139dcd95..5a904ba5 100644 --- a/frontend/src/lib/server/v2/deals.js +++ b/frontend/src/lib/server/v2/deals.js @@ -7,14 +7,15 @@ * cookie; the org is never a parameter, only ever a claim inside the JWT. * * WHAT CHANGED WHEN THE FIXTURES CAME OFF - * Three things the mock showed do not exist on `Opportunity` and are gone - * rather than faked: `next_action` (there is no such field, and nothing - * derives one), `last_activity_at` (aging is measured from `stage_changed_at`, - * which is a different claim), and a contact's `relationship` (Contact has - * `title` and `department`). The related invoices and tickets rail is also - * gone. Those are real models, but attaching them to a deal means querying - * two more modules per page load for a panel nobody asked for; add it back - * deliberately if it earns the round trips. + * Two things the mock showed do not exist on `Opportunity` and are gone + * rather than faked: `last_activity_at` (aging is measured from + * `stage_changed_at`, which is a different claim), and a contact's + * `relationship` (Contact has `title` and `department`). The mock's + * `next_action` is now real as `next_activity` (see `toNextActivity`). The + * related invoices and tickets rail is also gone. Those are real models, but + * attaching them to a deal means querying two more modules per page load for + * a panel nobody asked for; add it back deliberately if it earns the round + * trips. * * NUMBERS ARRIVE AS STRINGS * DRF renders `DecimalField` as a string, so `amount`, `unit_price` and the @@ -46,6 +47,21 @@ function ownerName(deal) { return email || deal.created_by?.email || ''; } +/** + * The deal's earliest open task, as the list and the board get it from the + * API: `{ id, title, due_date }`, or `null` when the caller has no open task + * on the deal. The server counts only tasks this caller can open, so a hidden + * task never reaches here. The deal detail does not carry the field, so there + * it stays `undefined`, which the pages never read as "nothing scheduled". + * + * @param {any} value + */ +function toNextActivity(value) { + if (value === undefined) return undefined; + if (!value) return null; + return { id: value.id, title: value.title ?? '', due_date: value.due_date ?? null }; +} + /** * The subset of a Django opportunity the v2 pages read. * @@ -81,6 +97,7 @@ function toRow(deal) { stage_changed_at: deal.stage_changed_at ?? null, days_in_current_stage: deal.days_in_stage ?? 0, aging_status: deal.aging_status ?? 'green', + next_activity: toNextActivity(deal.next_activity), created_at: deal.created_at }; } @@ -628,10 +645,11 @@ export async function createDeal({ cookies }, values) { } /** - * `OpportunityDetailView.get` answers 404 for another org's deal. Deliberately - * not 403, which would confirm the id exists, and 403 for a deal inside the - * org that this profile neither created nor is assigned to. Both are the - * caller's answer, not a server fault, so neither becomes a 500. + * `OpportunityDetailView.get` answers 404, with one body, both for a deal that + * does not exist and for one this profile may not open. Deliberately not 403, + * which would confirm the id exists. It is the caller's answer, not a server + * fault, so it never becomes a 500, and the page cannot tell the two apart + * either, so the copy covers both. * * @param {import('@sveltejs/kit').Cookies} cookies * @param {string} id @@ -642,10 +660,7 @@ async function fetchDetail(cookies, id) { } catch (/** @type {any} */ err) { // On the status, not on the wording. See `api-helpers.js`. if (err?.status === 404) { - error(404, 'That deal does not exist, or it belongs to another team.'); - } - if (err?.status === 403) { - error(403, 'This deal belongs to somebody else. Ask an admin if you need it.'); + error(404, 'That deal does not exist, or you do not have access to it.'); } throw err; } diff --git a/frontend/src/lib/server/v2/deals.test.js b/frontend/src/lib/server/v2/deals.test.js index fd38aa02..35750f16 100644 --- a/frontend/src/lib/server/v2/deals.test.js +++ b/frontend/src/lib/server/v2/deals.test.js @@ -130,3 +130,61 @@ describe('pipelines', () => { expect(options.pipelines).toHaveLength(2); }); }); + +/** + * G31: the list and the board carry each deal's next open task. `null` is the + * flag (nothing the viewer can see is scheduled); a response that never + * carried the key stays `undefined` so it is not mistaken for that flag. + */ +describe('next activity', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + const task = { id: 't-1', title: 'Send proposal', due_date: '2026-10-01' }; + + it('reaches list rows, including the none flag', async () => { + apiRequest.mockResolvedValue({ + opportunities: [ + { id: 'd-1', amount: '2', next_activity: task }, + { id: 'd-2', amount: '1', next_activity: null } + ], + totals: null + }); + + const { results } = await listDeals(event); + + expect(results[0].next_activity).toEqual(task); + expect(results[1].next_activity).toBeNull(); + }); + + it('reaches board cards', async () => { + apiRequest.mockResolvedValue({ + pipeline: { id: 'p-1' }, + columns: [ + { + id: 'OPEN', + kind: 'open', + item_count: 2, + items: [ + { id: 'd-1', next_activity: { ...task, due_date: null } }, + { id: 'd-2', next_activity: null } + ] + } + ] + }); + + const { lanes } = await listBoard(event); + + expect(lanes[0].rows[0].next_activity).toEqual({ ...task, due_date: null }); + expect(lanes[0].rows[1].next_activity).toBeNull(); + }); + + it('stays undefined when the response did not compute it', async () => { + apiRequest.mockResolvedValue({ opportunities: [{ id: 'd-1' }], totals: null }); + + const { results } = await listDeals(event); + + expect(results[0].next_activity).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/server/v2/detail-not-found.test.js b/frontend/src/lib/server/v2/detail-not-found.test.js new file mode 100644 index 00000000..0173932f --- /dev/null +++ b/frontend/src/lib/server/v2/detail-not-found.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiRequest = vi.fn(); +vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) })); + +const { getLead } = await import('$lib/server/v2/leads.js'); +const { getAccount } = await import('$lib/server/v2/accounts.js'); +const { getContact } = await import('$lib/server/v2/contacts.js'); +const { getDeal } = await import('$lib/server/v2/deals.js'); + +const event = /** @type {any} */ ({ cookies: { get: () => 'token' } }); + +/** + * The API answers a record this profile may not open exactly as one that does + * not exist: 404, same body (owner decision for 1.11.0). The page cannot tell + * the two apart, so its copy has to cover both, and no 403 branch is left to + * suggest the record is real. + */ +describe.each([ + ['lead', getLead], + ['account', getAccount], + ['contact', getContact], + ['deal', getDeal] +])('%s detail', (_kind, load) => { + beforeEach(() => { + apiRequest.mockReset(); + apiRequest.mockRejectedValue(Object.assign(new Error('Not found'), { status: 404 })); + }); + + it('turns a 404 into a page 404 whose copy covers no access', async () => { + await expect(load(event, 'id-1')).rejects.toMatchObject({ + status: 404, + body: { message: expect.stringContaining('do not have access') } + }); + expect(apiRequest.mock.calls.some(([path]) => String(path).includes('id-1'))).toBe(true); + }); +}); diff --git a/frontend/src/lib/server/v2/documents.js b/frontend/src/lib/server/v2/documents.js index e3728302..503a547c 100644 --- a/frontend/src/lib/server/v2/documents.js +++ b/frontend/src/lib/server/v2/documents.js @@ -49,6 +49,7 @@ * properly: a route on this origin that streams from an authenticated * endpoint gated by the same `_may_read` the detail view uses. */ +import { isOrgAdmin } from '$lib/admin.js'; import { apiRequest } from '$lib/api-helpers.js'; import { env } from '$env/dynamic/public'; import { documentHref } from './files.js'; @@ -70,25 +71,26 @@ export const STATUS_CHOICES = ['active', 'inactive']; export const FILTER_FIELDS = ['status']; /** - * The signed-in role and user id, decoded from the JWT. Display hints only: - * `role` decides whether the page offers Upload/Edit, and `user_id` is compared - * to a document's `created_by` to decide the per-row Edit affordance. The - * backend re-derives both and is the thing that actually refuses a write, so - * neither is trusted for authorization. + * The signed-in admin fact and user id, decoded from the JWT. Display hints + * only: `isAdmin` (`isOrgAdmin`, the web app's one admin rule) decides whether + * the page offers Upload/Edit, and `user_id` is compared to a document's + * `created_by` to decide the per-row Edit affordance. The backend re-derives + * both and is the thing that actually refuses a write, so neither is trusted + * for authorization. * * @param {import('@sveltejs/kit').Cookies} cookies - * @returns {{ role: string | null, userId: string | null }} + * @returns {{ isAdmin: boolean, userId: string | null }} */ function viewerClaims(cookies) { const token = cookies.get('jwt_access'); - if (!token) return { role: null, userId: null }; + if (!token) return { isAdmin: false, userId: null }; try { const payload = token.split('.')[1]; const json = Buffer.from(payload, 'base64url').toString('utf-8'); const claims = JSON.parse(json); - return { role: claims.role ?? null, userId: claims.user_id ?? null }; + return { isAdmin: isOrgAdmin(claims), userId: claims.user_id ?? null }; } catch { - return { role: null, userId: null }; + return { isAdmin: false, userId: null }; } } @@ -121,7 +123,7 @@ function fileKind(path = '') { * an action the server would refuse, but the view enforces it regardless. * * @param {any} d - * @param {{ role: string | null, userId: string | null }} viewer + * @param {{ isAdmin: boolean, userId: string | null }} viewer */ function toDoc(d, viewer) { const createdById = d.created_by?.id != null ? String(d.created_by.id) : null; @@ -149,7 +151,7 @@ function toDoc(d, viewer) { name: d.created_by?.name || d.created_by?.email || 'Unknown' }, created_at: d.created_at, - can_write: viewer.role === 'ADMIN' || (createdById != null && createdById === viewer.userId) + can_write: viewer.isAdmin || (createdById != null && createdById === viewer.userId) }; } diff --git a/frontend/src/lib/server/v2/escalation.js b/frontend/src/lib/server/v2/escalation.js index f8988488..0541a1b2 100644 --- a/frontend/src/lib/server/v2/escalation.js +++ b/frontend/src/lib/server/v2/escalation.js @@ -25,7 +25,7 @@ */ import { apiRequest } from '$lib/api-helpers.js'; import { ESCALATION_PRIORITIES } from '$lib/v2/enums.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * Profile → the `{ id, name }` the card renders, or null when unset. @@ -75,7 +75,7 @@ export async function getEscalationPolicies({ cookies }) { // start with `_is_admin(request.profile)` server-side and 403 regardless // of what this says. This only decides whether the page offers the // controls. - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/files.js b/frontend/src/lib/server/v2/files.js index 3fcf6587..c4b45c69 100644 --- a/frontend/src/lib/server/v2/files.js +++ b/frontend/src/lib/server/v2/files.js @@ -45,8 +45,10 @@ export function documentHref(id) { * process's memory, and the upstream's own `Content-Disposition` is passed * through, because the backend is the one that knows what the file is called. * - * Upstream status codes are passed through as they are: a 403 means the - * caller may not read the record, and a 404 means no such file in this org. + * Upstream status codes are passed through as they are: a 404 means no file + * this caller may see (the attachment download answers it for a file on a + * record they cannot open too, so it does not confirm the file exists), and a + * 403 is a refusal the endpoint chose to state. * Flattening either into a 500 would turn a correct refusal into a bug report. * * @param {{ cookies: import('@sveltejs/kit').Cookies, request: Request }} event diff --git a/frontend/src/lib/server/v2/filter-params.js b/frontend/src/lib/server/v2/filter-params.js index 36cf9942..0e3b05a2 100644 --- a/frontend/src/lib/server/v2/filter-params.js +++ b/frontend/src/lib/server/v2/filter-params.js @@ -44,15 +44,19 @@ const ID_TYPES = ['person', 'tag', 'account']; * here. Each page's `load` already interprets its own, and this function * deliberately does not duplicate that logic. * + * A field marked `multi` (the API takes it repeated: owners, tags, lead and + * ticket statuses) keeps every valid value, as an array, so a list opened with + * two owners filters by both rather than silently by the first. + * * @param {URL} url * @param {string} pageKey - * @returns {Record} + * @returns {Record} */ export function readFilters(url, pageKey) { const descriptor = FILTERS[pageKey]; if (!descriptor) return {}; - /** @type {Record} */ + /** @type {Record} */ const out = {}; for (const field of descriptor.fields) { if (field.type === 'date-range' || field.type === 'number-range') { @@ -64,29 +68,40 @@ export function readFilters(url, pageKey) { continue; } - const value = url.searchParams.get(field.key); - if (!value) continue; - - if (field.type === 'select') { - if (field.options?.includes(value)) out[field.key] = value; - } else if (field.type === 'boolean') { - if (BOOLEANS.includes(value)) out[field.key] = value; - } else if (ID_TYPES.includes(field.type)) { - if (UUID.test(value)) out[field.key] = value; - } else if (field.type === 'stage') { - if (STAGE_CODE.test(value)) out[field.key] = value; - } else { - out[field.key] = value; + if (field.multi) { + const values = url.searchParams.getAll(field.key).filter((v) => isValid(field, v)); + if (values.length > 0) out[field.key] = values; + continue; } + + const value = url.searchParams.get(field.key); + if (value && isValid(field, value)) out[field.key] = value; } return out; } +/** + * Whether `value` is one `field` can hold. + * + * @param {any} field + * @param {string} value + */ +function isValid(field, value) { + if (!value) return false; + if (field.type === 'select') return Boolean(field.options?.includes(value)); + if (field.type === 'boolean') return BOOLEANS.includes(value); + if (ID_TYPES.includes(field.type)) return UUID.test(value); + if (field.type === 'stage') return STAGE_CODE.test(value); + return true; +} + /** * The API query for a set of filters, restricted to the module's allow-list. * + * An array value is sent as a repeated parameter. + * * @param {string[]} allowed - * @param {Record} filters + * @param {Record} filters * @returns {URLSearchParams} */ export function buildFilterQuery(allowed, filters) { @@ -94,7 +109,7 @@ export function buildFilterQuery(allowed, filters) { for (const key of allowed) { const value = filters[key]; if (value === undefined || value === null || value === '') continue; - query.set(key, value); + for (const one of Array.isArray(value) ? value : [value]) query.append(key, one); } return query; } diff --git a/frontend/src/lib/server/v2/filter-params.test.js b/frontend/src/lib/server/v2/filter-params.test.js index 39effb5c..baed560d 100644 --- a/frontend/src/lib/server/v2/filter-params.test.js +++ b/frontend/src/lib/server/v2/filter-params.test.js @@ -30,7 +30,21 @@ describe('readFilters', () => { // since deleted is the correct outcome. const id = '11111111-2222-3333-4444-555555555555'; const out = readFilters(url(`assigned_to=${id}`), 'tickets'); - expect(out).toEqual({ assigned_to: id }); + // `assigned_to` is repeatable on the tickets API, so it is kept as a list. + expect(out).toEqual({ assigned_to: [id] }); + }); + + it('keeps every valid value of a repeatable field and drops the rest', () => { + const a = '11111111-2222-3333-4444-555555555555'; + const b = '99999999-2222-3333-4444-555555555555'; + expect(readFilters(url(`tags=${a}&tags=x&tags=${b}`), 'tickets')).toEqual({ tags: [a, b] }); + expect(readFilters(url('status=New&status=Nope&status=Pending'), 'tickets')).toEqual({ + status: ['New', 'Pending'] + }); + }); + + it('a single-valued field still reads its first value only', () => { + expect(readFilters(url('priority=High&priority=Low'), 'tickets')).toEqual({ priority: 'High' }); }); it('normalises a boolean and drops a non-boolean', () => { @@ -57,6 +71,11 @@ describe('buildFilterQuery', () => { const q = buildFilterQuery(['priority'], { priority: '' }); expect(q.has('priority')).toBe(false); }); + + it('sends a list as a repeated parameter', () => { + const q = buildFilterQuery(['status'], { status: ['New', 'Pending'] }); + expect(q.getAll('status')).toEqual(['New', 'Pending']); + }); }); describe('range param names are exact, not derived', () => { @@ -124,7 +143,7 @@ describe('opaque id fields: shape-checked before they reach the API', () => { it('keeps a well formed UUID', () => { const id = '868f58fd-f783-4472-baac-216f5e22484c'; const url = new URL(`http://x/tickets?tags=${id}`); - expect(readFilters(url, 'tickets').tags).toBe(id); + expect(readFilters(url, 'tickets').tags).toEqual([id]); }); it('drops an account id that is not a UUID', () => { diff --git a/frontend/src/lib/server/v2/goals.js b/frontend/src/lib/server/v2/goals.js index 3106f52c..d548b376 100644 --- a/frontend/src/lib/server/v2/goals.js +++ b/frontend/src/lib/server/v2/goals.js @@ -34,6 +34,7 @@ * fields already in the payload, never a second aggregate query. */ import { apiRequest } from '$lib/api-helpers.js'; +import { viewerIsAdmin } from './organization.js'; import { getOrgPeopleAndTeams } from './org-people.js'; /** The three goal kinds the backend accepts (common/utils.py GOAL_TYPES). */ @@ -97,27 +98,6 @@ export const EDITABLE_FIELDS = [ */ const LEADERBOARD_PERIOD = 'MONTHLY'; -/** - * The signed-in role, decoded from the JWT `role` claim. A display hint only. - * It decides whether the page shows an edit affordance. The backend re-derives - * the role and is the thing that actually refuses a non-admin write, so this is - * never trusted for authorization. - * - * @param {import('@sveltejs/kit').Cookies} cookies - * @returns {string | null} - */ -function viewerRole(cookies) { - const token = cookies.get('jwt_access'); - if (!token) return null; - try { - const payload = token.split('.')[1]; - const json = Buffer.from(payload, 'base64url').toString('utf-8'); - return JSON.parse(json).role ?? null; - } catch { - return null; - } -} - /** A person's display name from a nested ProfileSerializer row. */ function personName(detail) { const d = detail?.user_details ?? {}; @@ -277,7 +257,7 @@ export async function listGoals({ cookies, url }) { leaderboard, filters, totals: computeTotals(goals), - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } @@ -361,7 +341,7 @@ export async function getGoalHistory({ cookies }) { percent: period.percent ?? 0, goals: (period.goals ?? []).map(toGoal) })), - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } @@ -373,7 +353,7 @@ export async function getGoalHistory({ cookies }) { * @param {{ cookies: import('@sveltejs/kit').Cookies }} event */ export async function getGoalFormOptions({ cookies }) { - if (viewerRole(cookies) !== 'ADMIN') return { can_edit: false }; + if (!viewerIsAdmin(cookies)) return { can_edit: false }; const { people, teams } = await getOrgPeopleAndTeams(cookies); return { can_edit: true, people, teams }; } @@ -387,7 +367,7 @@ export async function getGoalFormOptions({ cookies }) { * @param {string} id */ export async function getGoalForEdit({ cookies }, id) { - if (viewerRole(cookies) !== 'ADMIN') return { can_edit: false }; + if (!viewerIsAdmin(cookies)) return { can_edit: false }; const [raw, { people, teams }] = await Promise.all([ apiRequest(`/opportunities/goals/${id}/`, {}, { cookies }), diff --git a/frontend/src/lib/server/v2/goals.test.js b/frontend/src/lib/server/v2/goals.test.js index 3ccecc09..92f7ea21 100644 --- a/frontend/src/lib/server/v2/goals.test.js +++ b/frontend/src/lib/server/v2/goals.test.js @@ -8,9 +8,17 @@ vi.mock('./org-people.js', () => ({ const { listGoals } = await import('./goals.js'); -/** A JWT whose payload carries just the role claim `viewerRole` reads. */ -function tokenFor(role) { - const payload = Buffer.from(JSON.stringify({ role })).toString('base64url'); +/** + * A JWT carrying the claims the API signs: `role`, and the admin fact + * `viewerIsAdmin` reads (true for an ADMIN, or for a superuser's membership). + * + * @param {string} role + * @param {boolean} [isAdmin] + */ +function tokenFor(role, isAdmin = role === 'ADMIN') { + const payload = Buffer.from(JSON.stringify({ role, is_organization_admin: isAdmin })).toString( + 'base64url' + ); return `header.${payload}.signature`; } @@ -236,6 +244,11 @@ describe('listGoals can_edit', () => { cookies: /** @type {any} */ ({ get: () => tokenFor('USER') }) }); expect(asMember.can_edit).toBe(false); + + const asSuperuser = await listGoals({ + cookies: /** @type {any} */ ({ get: () => tokenFor('USER', true) }) + }); + expect(asSuperuser.can_edit).toBe(true); }); it('is false when there is no token, rather than throwing', async () => { diff --git a/frontend/src/lib/server/v2/inbound-email.js b/frontend/src/lib/server/v2/inbound-email.js index 26bd47d3..ad8e139f 100644 --- a/frontend/src/lib/server/v2/inbound-email.js +++ b/frontend/src/lib/server/v2/inbound-email.js @@ -27,7 +27,7 @@ * `user_details.name`); the page wants `{ id, name }`. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** Profile → the `{ id, name }` the card reads, or null. */ function shapeAssignee(p) { @@ -68,7 +68,7 @@ export async function getMailboxes({ cookies }) { // `_is_admin(request.profile)` server-side and 403 regardless of what this // says. This only decides whether the page offers the controls. GET itself // is not role-gated, so a member can still see this list. - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/invoices.js b/frontend/src/lib/server/v2/invoices.js index e9c2def7..ee7076a5 100644 --- a/frontend/src/lib/server/v2/invoices.js +++ b/frontend/src/lib/server/v2/invoices.js @@ -225,9 +225,9 @@ export async function listInvoices({ cookies }, params) { /** * One invoice, with its line items. * - * A 404 from the API (the record does not exist, or belongs to another org) - * becomes a 404 here. A 403 (a member who is neither creator nor assignee) - * also surfaces as "not yours" rather than leaking that the invoice exists. + * A 404 from the API becomes a 404 here. The API answers the same 404 for a + * missing invoice, another org's, and one this member cannot open, so the + * page cannot tell them apart either. * * @param {{ cookies: import('@sveltejs/kit').Cookies }} event * @param {string} id @@ -237,8 +237,8 @@ export async function getInvoice({ cookies }, id) { try { response = await apiRequest(`/invoices/${id}/`, {}, { cookies }); } catch (/** @type {any} */ err) { - if (err?.status === 404 || err?.status === 403) { - error(404, 'That invoice does not exist, or it belongs to another team.'); + if (err?.status === 404) { + error(404, 'That invoice does not exist, or you do not have access to it.'); } throw err; } diff --git a/frontend/src/lib/server/v2/lead-pipelines.js b/frontend/src/lib/server/v2/lead-pipelines.js index b0a8ab9d..eaafe6aa 100644 --- a/frontend/src/lib/server/v2/lead-pipelines.js +++ b/frontend/src/lib/server/v2/lead-pipelines.js @@ -13,7 +13,7 @@ * display hint, never the check. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * The page: every active pipeline, and the stages of the selected one. An @@ -32,7 +32,7 @@ export async function getLeadPipelines({ cookies }, pipelineId = null) { leadCount: p.lead_count ?? 0, isDefault: Boolean(p.is_default) })); - const can_edit = viewerRole(cookies) === 'ADMIN'; + const can_edit = viewerIsAdmin(cookies); if (pipelines.length === 0) return { pipelines, pipeline: null, can_edit }; const chosen = pipelines.find((p) => p.id === pipelineId) ?? pipelines[0]; diff --git a/frontend/src/lib/server/v2/leads.js b/frontend/src/lib/server/v2/leads.js index 2e168631..44143535 100644 --- a/frontend/src/lib/server/v2/leads.js +++ b/frontend/src/lib/server/v2/leads.js @@ -435,9 +435,11 @@ export async function updateLead({ cookies }, id, values) { } /** - * `LeadDetailView.get` answers 404 for another org's lead and 403 for a lead - * inside the org that this profile is neither assigned to nor created. Both - * are the caller's answer, not a server fault, so neither becomes a 500. + * `LeadDetailView.get` answers 404, with one body, both for a lead that does + * not exist and for one this profile may not open (another org's, or one in + * this org they neither created nor are assigned to). Deliberately not 403, + * which would confirm the id exists. It is the caller's answer, not a server + * fault, so it never becomes a 500, and the copy covers both cases. * * @param {import('@sveltejs/kit').Cookies} cookies * @param {string} id @@ -450,10 +452,7 @@ async function fetchDetail(cookies, id) { // and Django's message for a missing lead is "No Lead matches the given // query.", so opening a deleted lead answered 500. See `api-helpers.js`. if (err?.status === 404) { - error(404, 'That lead does not exist, or it belongs to another team.'); - } - if (err?.status === 403) { - error(403, 'This lead is assigned to somebody else. Ask an admin if you need it.'); + error(404, 'That lead does not exist, or you do not have access to it.'); } throw err; } diff --git a/frontend/src/lib/server/v2/list-queries.js b/frontend/src/lib/server/v2/list-queries.js index 1ab0ce3c..6544e290 100644 --- a/frontend/src/lib/server/v2/list-queries.js +++ b/frontend/src/lib/server/v2/list-queries.js @@ -67,7 +67,9 @@ export function accountListQuery(url) { } /** - * The open queue unless a status was picked or `?all=1` asked for everything. + * The open queue unless statuses were picked or `?all=1` asked for everything. + * Every picked status is sent: the API takes `status` repeated, so a view of + * "New or Pending" is not narrowed to the first of them. * * @param {URL} url */ @@ -76,10 +78,7 @@ export function ticketListQuery(url) { 'search', 'limit' ]); - const status = url.searchParams.get('status') ?? ''; - if (status) { - params.set('status', status); - } else if (url.searchParams.get('all') !== '1') { + if (!params.has('status') && url.searchParams.get('all') !== '1') { for (const open of OPEN_STATUSES) params.append('status', open); } return params; diff --git a/frontend/src/lib/server/v2/macros.js b/frontend/src/lib/server/v2/macros.js index ee98dcb2..cb90c889 100644 --- a/frontend/src/lib/server/v2/macros.js +++ b/frontend/src/lib/server/v2/macros.js @@ -35,7 +35,7 @@ * the renderer actually expands. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; import { myProfileId } from './leads.js'; /** @@ -62,7 +62,7 @@ export async function getMacros({ cookies }) { // re-derives admin status from `request.profile` server-side, and that // is what actually decides whether an org-scope write succeeds. This // only decides whether the scope select offers "Everyone in the org". - can_create_org: viewerRole(cookies) === 'ADMIN', + can_create_org: viewerIsAdmin(cookies), // So the page can tell its own personal macros apart from a stranger's. // The API already keeps a stranger's personal macros out of `results` // entirely, so this is only ever compared against rows the viewer could diff --git a/frontend/src/lib/server/v2/macros.test.js b/frontend/src/lib/server/v2/macros.test.js index 4ef40d7c..7266721c 100644 --- a/frontend/src/lib/server/v2/macros.test.js +++ b/frontend/src/lib/server/v2/macros.test.js @@ -9,18 +9,21 @@ const { getMacros, createMacro, updateMacro, deleteMacro, activateMacro } = const event = /** @type {any} */ ({ cookies: { get: () => 'token' } }); /** - * `getMacros`'s `can_create_org` comes from `viewerRole`, which decodes the - * `role` claim out of the `jwt_access` cookie directly (no network call), so - * a plain `{ get: () => 'token' }` mock (as `event` above) can't drive it: a - * `'token'` has no `.` to split, `viewerRole` catches that and returns null. - * This builds a JWT-shaped string with a real base64url payload so the - * decode path actually runs, matching how `organization.js`'s `viewerRole` - * reads a real access token. + * `getMacros`'s `can_create_org` comes from `viewerIsAdmin`, which decodes the + * `is_organization_admin` claim out of the `jwt_access` cookie directly (no + * network call), so a plain `{ get: () => 'token' }` mock (as `event` above) + * can't drive it: a `'token'` has no `.` to split, `viewerIsAdmin` catches + * that and returns false. This builds a JWT-shaped string with a real + * base64url payload carrying the claims the API signs, so the decode path + * actually runs. * * @param {string} role */ function eventWithRole(role) { - const payload = Buffer.from(JSON.stringify({ role }), 'utf-8').toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ role, is_organization_admin: role === 'ADMIN' }), + 'utf-8' + ).toString('base64url'); const token = `h.${payload}.s`; return /** @type {any} */ ({ cookies: { get: (/** @type {string} */ name) => (name === 'jwt_access' ? token : null) } diff --git a/frontend/src/lib/server/v2/organization.js b/frontend/src/lib/server/v2/organization.js index 83ee01b1..3351fa88 100644 --- a/frontend/src/lib/server/v2/organization.js +++ b/frontend/src/lib/server/v2/organization.js @@ -26,8 +26,9 @@ * GET is open to any member. The company profile is not a secret, and members * legitimately see their org's address and currency. PATCH is admin-only, * enforced by the backend (403 for a non-admin). `can_edit` here is a display - * hint decoded from the JWT `role` claim; the server is what actually refuses. + * hint (`viewerIsAdmin` below); the server is what actually refuses. */ +import { isOrgAdmin } from '$lib/admin.js'; import { apiRequest } from '$lib/api-helpers.js'; /** @@ -62,26 +63,27 @@ export const EDITABLE_FIELDS = [ export const BOOLEAN_FIELDS = ['csat_enabled', 'auto_close_children_on_parent_close']; /** - * The signed-in user's role, read from the `role` claim of the access token. + * Whether the signed-in user administers the org, from the access token's + * `is_organization_admin` claim through `isOrgAdmin` (`$lib/admin.js`), the + * web app's one admin rule. * - * A display hint only: it decides whether the page shows the "Edit details" - * affordance. It is never the authorization decision: `PATCH /api/org/settings/` - * re-derives the role from the same token server-side and is the thing that - * actually refuses a non-admin. Decoded, not verified; verifying our own - * freshly-read cookie would buy nothing. + * A display hint only: it decides whether a page shows its edit affordances. + * It is never the authorization decision: the API re-derives the fact on every + * request and is the thing that actually refuses a non-admin. Decoded, not + * verified; verifying our own freshly-read cookie would buy nothing. * * @param {import('@sveltejs/kit').Cookies} cookies - * @returns {string | null} + * @returns {boolean} */ -export function viewerRole(cookies) { +export function viewerIsAdmin(cookies) { const token = cookies.get('jwt_access'); - if (!token) return null; + if (!token) return false; try { const payload = token.split('.')[1]; const json = Buffer.from(payload, 'base64url').toString('utf-8'); - return JSON.parse(json).role ?? null; + return isOrgAdmin(JSON.parse(json)); } catch { - return null; + return false; } } @@ -94,7 +96,7 @@ export function viewerRole(cookies) { */ export async function getOrgSettings({ cookies }) { const org = await apiRequest('/org/settings/', {}, { cookies }); - return { org, can_edit: viewerRole(cookies) === 'ADMIN' }; + return { org, can_edit: viewerIsAdmin(cookies) }; } /** diff --git a/frontend/src/lib/server/v2/organization.test.js b/frontend/src/lib/server/v2/organization.test.js new file mode 100644 index 00000000..cb3c28d5 --- /dev/null +++ b/frontend/src/lib/server/v2/organization.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/api-helpers.js', () => ({ apiRequest: vi.fn() })); + +const { viewerIsAdmin } = await import('./organization.js'); + +/** @param {Record | null} claims */ +function cookiesWith(claims) { + const token = + claims === null + ? undefined + : `h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`; + return /** @type {any} */ ({ + get: (/** @type {string} */ name) => (name === 'jwt_access' ? token : undefined) + }); +} + +describe('viewerIsAdmin', () => { + it('reads the signed admin fact, not the role', () => { + expect(viewerIsAdmin(cookiesWith({ role: 'ADMIN', is_organization_admin: true }))).toBe(true); + expect(viewerIsAdmin(cookiesWith({ role: 'USER', is_organization_admin: true }))).toBe(true); + expect(viewerIsAdmin(cookiesWith({ role: 'USER', is_organization_admin: false }))).toBe(false); + expect(viewerIsAdmin(cookiesWith({ role: 'ADMIN' }))).toBe(false); + }); + + it('is false with no token or an undecodable one', () => { + expect(viewerIsAdmin(cookiesWith(null))).toBe(false); + expect(viewerIsAdmin(/** @type {any} */ ({ get: () => 'not-a-jwt' }))).toBe(false); + }); +}); diff --git a/frontend/src/lib/server/v2/products.js b/frontend/src/lib/server/v2/products.js index 6793fc8c..1aee5567 100644 --- a/frontend/src/lib/server/v2/products.js +++ b/frontend/src/lib/server/v2/products.js @@ -34,6 +34,7 @@ * is still listed rather than deleted. */ import { apiRequest } from '$lib/api-helpers.js'; +import { viewerIsAdmin } from './organization.js'; /** * The currency codes the backend accepts (`common/utils.CURRENCY_CODES`). @@ -58,26 +59,6 @@ export const CURRENCY_CHOICES = [ const CURRENCY_CODES = new Set(CURRENCY_CHOICES.map((c) => c.code)); -/** - * The signed-in role, decoded from the JWT. A display hint only: it decides - * whether the page offers New/Edit. The backend re-derives the role and is the - * thing that actually refuses a non-admin write, so it is not trusted here. - * - * @param {import('@sveltejs/kit').Cookies} cookies - * @returns {string | null} - */ -function viewerRole(cookies) { - const token = cookies.get('jwt_access'); - if (!token) return null; - try { - const payload = token.split('.')[1]; - const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); - return claims.role ?? null; - } catch { - return null; - } -} - /** * One product as the pages read it, from a `ProductSerializer` row. `category` * is coalesced to a heading the grouped list can key on (a real product may @@ -130,7 +111,7 @@ export async function listProducts({ cookies }) { return { results, totals: computeTotals(results), - can_manage: viewerRole(cookies) === 'ADMIN' + can_manage: viewerIsAdmin(cookies) }; } @@ -142,7 +123,7 @@ export async function listProducts({ cookies }) { * @param {{ cookies: import('@sveltejs/kit').Cookies }} event */ export function getNewProductOptions({ cookies }) { - return { can_manage: viewerRole(cookies) === 'ADMIN', currencies: CURRENCY_CHOICES }; + return { can_manage: viewerIsAdmin(cookies), currencies: CURRENCY_CHOICES }; } /** @@ -154,7 +135,7 @@ export function getNewProductOptions({ cookies }) { * @param {string} id */ export async function getProductForEdit({ cookies }, id) { - if (viewerRole(cookies) !== 'ADMIN') return { can_edit: false }; + if (!viewerIsAdmin(cookies)) return { can_edit: false }; const raw = await apiRequest(`/invoices/products/${id}/`, {}, { cookies }); const p = toProduct(raw); return { diff --git a/frontend/src/lib/server/v2/recurring.js b/frontend/src/lib/server/v2/recurring.js index ae1a8f7c..1ea2661f 100644 --- a/frontend/src/lib/server/v2/recurring.js +++ b/frontend/src/lib/server/v2/recurring.js @@ -247,17 +247,14 @@ export function buildBody(allowed, values) { * hide the control behind an admin check: that would be a UX regression, not a * security improvement, and the backend is the boundary either way. * - * The four guards below are fast fails for obvious mistakes. Three mirror real - * server rules: `account_id` and `contact_id` are required by + * The five guards below are fast fails for obvious mistakes, and each mirrors a + * server rule: `account_id` and `contact_id` are required by * `RecurringInvoiceCreateSerializer` (and it also cross-checks that the - * contact belongs to the account), and `title` is required because + * contact belongs to the account), `title` is required because * `RecurringInvoice.title` is a `CharField(max_length=100)` with no - * `blank=True`, so DRF makes it required and non-blank there too. The - * `custom_days` guard does not mirror anything: the serializer has no - * cross-validation between `frequency` and `custom_days`, so a CUSTOM schedule - * with no interval is accepted server-side and then silently generates monthly - * via `calculate_next_date()`. That is a backend gap; this check keeps the form - * from walking into it. + * `blank=True`, a CUSTOM frequency needs `custom_days` (the serializer refuses + * one without, which used to bill monthly), and auto-send needs at least one + * line (the serializer refuses a schedule that would mail a blank invoice). * * `RecurringInvoiceListView.post` (`backend/invoices/api_views.py`) wraps its * result in an envelope, `{ error, message, recurring_invoice }`, exactly the @@ -279,6 +276,10 @@ export async function createRecurringInvoice({ cookies }, values) { if (values.frequency === 'CUSTOM' && !values.custom_days) { throw new Error('A custom frequency needs an interval in days.'); } + // The server's rule: a schedule that mails each invoice needs a line. + if (values.auto_send && !values.line_items?.length) { + throw new Error('Add at least one line before turning on auto-send.'); + } const body = buildBody(CREATE_FIELDS, values); const resp = await apiRequest('/invoices/recurring/', { method: 'POST', body }, { cookies }); diff --git a/frontend/src/lib/server/v2/recurring.test.js b/frontend/src/lib/server/v2/recurring.test.js index 5cc882b2..f136a177 100644 --- a/frontend/src/lib/server/v2/recurring.test.js +++ b/frontend/src/lib/server/v2/recurring.test.js @@ -64,7 +64,7 @@ describe('createRecurringInvoice', () => { expect(apiRequest).not.toHaveBeenCalled(); }); - it('requires custom_days when the frequency is CUSTOM, which the server does not', async () => { + it('requires custom_days when the frequency is CUSTOM, as the server does', async () => { await expect( createRecurringInvoice( { cookies }, @@ -74,6 +74,29 @@ describe('createRecurringInvoice', () => { expect(apiRequest).not.toHaveBeenCalled(); }); + it('refuses auto-send with no lines, as the server does', async () => { + await expect( + createRecurringInvoice( + { cookies }, + { account_id: 'a1', contact_id: 'c1', title: 'x', auto_send: true } + ) + ).rejects.toThrow('Add at least one line before turning on auto-send.'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('accepts auto-send with a line, and no lines without auto-send', async () => { + const line = { name: 'Hosting', quantity: 1, unit_price: 50 }; + await createRecurringInvoice( + { cookies }, + { account_id: 'a1', contact_id: 'c1', title: 'x', auto_send: true, line_items: [line] } + ); + await createRecurringInvoice( + { cookies }, + { account_id: 'a1', contact_id: 'c1', title: 'x', auto_send: false } + ); + expect(apiRequest).toHaveBeenCalledTimes(2); + }); + it('accepts CUSTOM with custom_days', async () => { await createRecurringInvoice( { cookies }, diff --git a/frontend/src/lib/server/v2/reopen.js b/frontend/src/lib/server/v2/reopen.js index 2d711757..c3907345 100644 --- a/frontend/src/lib/server/v2/reopen.js +++ b/frontend/src/lib/server/v2/reopen.js @@ -15,7 +15,7 @@ * message the user reads. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; import { REOPEN_TO_STATUSES } from '$lib/v2/enums.js'; /** @@ -24,7 +24,7 @@ import { REOPEN_TO_STATUSES } from '$lib/v2/enums.js'; */ export async function getReopenPolicy({ cookies }) { const policy = await apiRequest('/cases/reopen-policy/', {}, { cookies }); - return { policy, can_edit: viewerRole(cookies) === 'ADMIN' }; + return { policy, can_edit: viewerIsAdmin(cookies) }; } /** The four fields the serializer accepts. Everything else in the GET diff --git a/frontend/src/lib/server/v2/routing.js b/frontend/src/lib/server/v2/routing.js index dcf3142a..97fe11a7 100644 --- a/frontend/src/lib/server/v2/routing.js +++ b/frontend/src/lib/server/v2/routing.js @@ -18,7 +18,7 @@ * flatten here. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; import { CONDITION_FIELD_LABEL, CONDITION_OP_LABEL, ROUTING_STRATEGY_NAME } from '$lib/v2/enums.js'; /** Profile → the `{ id, name, is_active }` the rule card reads. */ @@ -56,7 +56,7 @@ export async function getRoutingRules({ cookies }) { // A display hint: POST/PUT/DELETE on `/cases/routing-rules/` each start // with `_is_admin(request.profile)` server-side and 403 regardless of // what this says. This only decides whether the page offers the controls. - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/saved-views.js b/frontend/src/lib/server/v2/saved-views.js new file mode 100644 index 00000000..8ee4f1ce --- /dev/null +++ b/frontend/src/lib/server/v2/saved-views.js @@ -0,0 +1,216 @@ +/** + * Saved views (G29), the server half: read a person's views for one list, and + * the three form actions (save, rename, delete) every list page spreads into + * its own `actions`. + * + * It never gates. `/api/saved-views/` answers only the caller's own views + * (another person's id is a 404) and checks every filter against what the list + * reads, so a view built here from a hand-edited URL is refused there with a + * 400 whose sentence is shown in the menu. + * + * Every action redirects back to the page URL it was posted from. The action + * URL (`?/saveView`) replaces the query string, so without the redirect a + * browser without JavaScript would land on the unfiltered list; with it, both + * paths end where they started. The query comes back from a hidden input and + * is rebuilt through `URLSearchParams` onto this page's own path, so it cannot + * send anyone anywhere else. + */ +import { fail, redirect } from '@sveltejs/kit'; +import { apiRequest } from '$lib/api-helpers.js'; +import { pickFilters } from '$lib/v2/saved-views.js'; +import { readableError } from './form-errors.js'; +import { FILTER_FIELDS as LEAD_FIELDS } from './leads.js'; +import { FILTER_FIELDS as CONTACT_FIELDS } from './contacts.js'; +import { FILTER_FIELDS as ACCOUNT_FIELDS } from './accounts.js'; +import { FILTER_FIELDS as DEAL_FIELDS, BOARD_FIELDS } from './deals.js'; +import { FILTER_FIELDS as TICKET_FIELDS, OPEN_STATUSES } from './tickets.js'; +import { FILTER_FIELDS as INVOICE_FIELDS } from './invoices.js'; +import { FILTERS } from '$lib/v2/filters.js'; + +/** + * Per API list, the URL parameters its page forwards to the list under the + * same name: the filter-bar fields plus the plain params `list-queries.js` + * copies. Paging and the page's own presets (`inactive`, `all`, `view`) are + * not filters and are not saved. `saved-views.test.js` checks every key here + * really is forwarded by the page's list query. + */ +export const SAVED_VIEW_KEYS = { + leads: [...LEAD_FIELDS, 'search', 'rating'], + contacts: [...CONTACT_FIELDS, 'search', 'name', 'email', 'phone'], + accounts: [...ACCOUNT_FIELDS, 'search', 'name'], + opportunities: [...DEAL_FIELDS, 'search', 'open', 'rotten', 'pipeline'], + cases: [...TICKET_FIELDS, 'search'], + invoices: [...INVOICE_FIELDS] +}; + +/** The filter-bar page each API list is drawn on. */ +const PAGE = { + leads: 'leads', + contacts: 'contacts', + accounts: 'accounts', + opportunities: 'pipeline', + cases: 'tickets', + invoices: 'invoices' +}; + +/** + * How one page's URL maps onto a view (the `Spec` in `$lib/v2/saved-views.js`), + * for the URL it is showing. + * + * `multi` is read off the filter descriptor, the same flag `readFilters` obeys, + * so the page forwards every value of exactly these keys. The pipeline board + * runs a narrower query than the list (`BOARD_FIELDS`), so on the board only + * that query's keys are saved or applied, and `view` is kept so opening a + * view does not drop the board for the list. + * + * @param {keyof typeof SAVED_VIEW_KEYS} module + * @param {URLSearchParams} params the page's query string + * @returns {import('$lib/v2/saved-views.js').Spec} + */ +export function savedViewSpec(module, params) { + const multi = FILTERS[PAGE[module]].fields + .filter((/** @type {any} */ f) => f.multi) + .map((/** @type {any} */ f) => f.key); + if (module === 'opportunities') { + const board = params.get('view') === 'board'; + return { + keys: board ? [...BOARD_FIELDS, 'search', 'pipeline'] : SAVED_VIEW_KEYS.opportunities, + multi, + keep: ['view'] + }; + } + if (module === 'cases') { + return { + keys: SAVED_VIEW_KEYS.cases, + multi, + implicit: { key: 'status', values: OPEN_STATUSES, allParam: 'all' } + }; + } + return { keys: SAVED_VIEW_KEYS[module], multi }; +} + +/** + * The first message in a DRF rejection. The saved-views API writes each one as + * a whole sentence (`{"name": ["You already have a view with this name for + * this list."]}`), so the field name in front of it would only be noise. + * + * @param {any} body + * @returns {string | null} + */ +function firstSentence(body) { + const first = body && typeof body === 'object' ? Object.values(body)[0] : null; + const text = Array.isArray(first) ? first[0] : first; + return typeof text === 'string' && text.trim() ? text : null; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * The caller's views for one list, and the page's spec, for its `load`. + * + * A failed fetch costs the saved-views menu, not the list under it: the same + * degradation the tag picker gets on these pages. + * + * @param {{ cookies: import('@sveltejs/kit').Cookies, url: URL }} event + * @param {keyof typeof SAVED_VIEW_KEYS} module + */ +export async function loadSavedViews({ cookies, url }, module) { + const spec = savedViewSpec(module, url.searchParams); + try { + const resp = await apiRequest(`/saved-views/?module=${module}`, {}, { cookies }); + return { spec, views: resp.saved_views ?? [], limit: resp.limit ?? null }; + } catch { + return { spec, views: [], limit: null }; + } +} + +/** + * The page URL a saved-view action returns to. + * + * @param {URL} url the action's URL; its path is the list page's + * @param {FormData} form + */ +function backTo(url, form) { + const qs = new URLSearchParams(String(form.get('query') ?? '')).toString(); + return qs ? `${url.pathname}?${qs}` : url.pathname; +} + +/** + * `saveView`, `renameView` and `deleteView` for one list page. + * + * @param {keyof typeof SAVED_VIEW_KEYS} module + */ +export function savedViewActions(module) { + /** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {(form: FormData) => { path: string, method: string, body?: any } | string} build + * the API call, or a sentence refusing it + * @param {string} fallback + */ + async function run({ request, cookies, url }, build, fallback) { + const form = await request.formData(); + const call = build(form); + if (typeof call === 'string') return fail(400, { savedViewError: call }); + try { + await apiRequest(call.path, { method: call.method, body: call.body }, { cookies }); + } catch (err) { + const status = /** @type {any} */ (err)?.status; + if (!status) throw err; + return fail(status === 404 ? 404 : 400, { + savedViewError: + status === 404 + ? 'That view no longer exists.' + : (firstSentence(/** @type {any} */ (err).body) ?? readableError(err, fallback)) + }); + } + redirect(303, backTo(url, form)); + } + + /** @param {FormData} form */ + const viewId = (form) => { + const id = String(form.get('id') ?? ''); + return UUID.test(id) ? id : null; + }; + + return { + /** @param {import('@sveltejs/kit').RequestEvent} event */ + saveView: (event) => + run( + event, + (form) => { + const name = String(form.get('name') ?? '').trim(); + if (!name) return 'Give the view a name.'; + const query = new URLSearchParams(String(form.get('query') ?? '')); + const filters = pickFilters(query, savedViewSpec(module, query)); + return { path: '/saved-views/', method: 'POST', body: { module, name, filters } }; + }, + 'Could not save the view.' + ), + + /** @param {import('@sveltejs/kit').RequestEvent} event */ + renameView: (event) => + run( + event, + (form) => { + const id = viewId(form); + const name = String(form.get('name') ?? '').trim(); + if (!id) return 'Which view?'; + if (!name) return 'Give the view a name.'; + return { path: `/saved-views/${id}/`, method: 'PATCH', body: { name } }; + }, + 'Could not rename the view.' + ), + + /** @param {import('@sveltejs/kit').RequestEvent} event */ + deleteView: (event) => + run( + event, + (form) => { + const id = viewId(form); + if (!id) return 'Which view?'; + return { path: `/saved-views/${id}/`, method: 'DELETE' }; + }, + 'Could not delete the view.' + ) + }; +} diff --git a/frontend/src/lib/server/v2/saved-views.test.js b/frontend/src/lib/server/v2/saved-views.test.js new file mode 100644 index 00000000..05b54d58 --- /dev/null +++ b/frontend/src/lib/server/v2/saved-views.test.js @@ -0,0 +1,249 @@ +/** + * Saved views, the server half: every key a page saves is one its list really + * forwards, and the three actions call the API with only what they should. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/public', () => ({ env: { PUBLIC_DJANGO_API_URL: 'http://api.test' } })); +const apiRequest = vi.fn(); +vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) })); + +const { SAVED_VIEW_KEYS, loadSavedViews, savedViewActions, savedViewSpec } = + await import('./saved-views.js'); +const queries = await import('./list-queries.js'); +const { FILTERS } = await import('$lib/v2/filters.js'); + +const UUID = '11111111-2222-3333-4444-555555555555'; +const VIEW = '99999999-8888-7777-6666-555555555555'; +const cookies = /** @type {any} */ ({ get: () => 'token' }); + +/** The filter-bar page each API list is drawn on. */ +const PAGE = { + leads: 'leads', + contacts: 'contacts', + accounts: 'accounts', + opportunities: 'pipeline', + cases: 'tickets', + invoices: 'invoices' +}; + +/** @param {string} module */ +function listQuery(module, /** @type {URL} */ url) { + if (module === 'leads') return queries.leadListQuery(url); + if (module === 'contacts') return queries.contactListQuery(url); + if (module === 'accounts') return queries.accountListQuery(url); + if (module === 'cases') return queries.ticketListQuery(url); + if (module === 'invoices') return queries.invoiceListQuery(url); + return queries.dealListQuery(url, [{ id: UUID }]).params; +} + +/** A value the page accepts for `key`, from its filter descriptor. */ +function sample(/** @type {string} */ module, /** @type {string} */ key) { + const field = FILTERS[PAGE[module]].fields.find( + (/** @type {any} */ f) => f.key === key || f.gteKey === key || f.lteKey === key + ); + if (field?.type === 'select') return field.options[0]; + if (['person', 'tag', 'account'].includes(field?.type)) return UUID; + if (field?.type === 'stage') return 'QUALIFIED'; + if (field?.type === 'date-range') return '2026-01-02'; + if (field?.type === 'number-range') return '10'; + if (field?.type === 'boolean' || key === 'open' || key === 'rotten') return 'true'; + if (key === 'pipeline') return UUID; + if (key === 'status') return 'New'; + return 'x'; +} + +describe('SAVED_VIEW_KEYS', () => { + for (const [module, keys] of Object.entries(SAVED_VIEW_KEYS)) { + it(`${module}: every saved key is forwarded to the list under its own name`, () => { + for (const key of keys) { + const value = sample(module, key); + const url = new URL(`http://app.test/x?${new URLSearchParams({ [key]: value })}`); + expect(listQuery(module, url).getAll(key), key).toContain(value); + } + }); + } + + it('every multi key is forwarded with all its values', () => { + const second = { status: { leads: 'in process', cases: 'Pending' } }; + for (const module of Object.keys(SAVED_VIEW_KEYS)) { + const spec = savedViewSpec(/** @type {any} */ (module), new URLSearchParams()); + for (const key of spec.multi ?? []) { + const values = + key === 'status' + ? [sample(module, key), second.status[/** @type {'leads'|'cases'} */ (module)]] + : [UUID, UUID.replace('1', '9')]; + const query = new URLSearchParams(); + for (const v of values) query.append(key, v); + expect( + listQuery(module, new URL(`http://app.test/x?${query}`)).getAll(key), + `${module} ${key}` + ).toEqual(values); + } + } + }); + + it('never saves paging or the page presets', () => { + for (const keys of Object.values(SAVED_VIEW_KEYS)) { + for (const key of ['limit', 'offset', 'inactive', 'all', 'view']) { + expect(keys).not.toContain(key); + } + } + }); +}); + +describe('loadSavedViews', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + const url = new URL('http://app.test/tickets'); + + it("asks for one list's views and hands back the page's spec", async () => { + apiRequest.mockResolvedValue({ saved_views: [{ id: VIEW }], limit: 25 }); + const saved = await loadSavedViews({ cookies, url }, 'cases'); + expect(apiRequest.mock.calls[0][0]).toBe('/saved-views/?module=cases'); + expect(saved).toEqual({ + spec: savedViewSpec('cases', url.searchParams), + views: [{ id: VIEW }], + limit: 25 + }); + }); + + it('costs the menu, not the page, when the fetch fails', async () => { + apiRequest.mockRejectedValue(Object.assign(new Error('down'), { status: 500 })); + expect((await loadSavedViews({ cookies, url }, 'leads')).views).toEqual([]); + }); +}); + +describe('savedViewSpec', () => { + it('reads multi off the filter descriptor', () => { + const spec = savedViewSpec('leads', new URLSearchParams()); + expect(spec.multi).toEqual(expect.arrayContaining(['assigned_to', 'status', 'tags'])); + expect(spec.multi).not.toContain('source'); + expect(savedViewSpec('invoices', new URLSearchParams()).multi).toEqual([]); + }); + + it('tickets: the open queue is explicit and All is its own flag', () => { + expect(savedViewSpec('cases', new URLSearchParams()).implicit).toEqual({ + key: 'status', + values: ['New', 'Assigned', 'Pending'], + allParam: 'all' + }); + }); + + it('the board saves and applies only what the board runs, and stays the board', () => { + const list = savedViewSpec('opportunities', new URLSearchParams()); + const board = savedViewSpec('opportunities', new URLSearchParams('view=board')); + expect(list.keys).toContain('stage'); + expect(board.keys).not.toContain('stage'); + expect(board.keys).toEqual(expect.arrayContaining(['assigned_to', 'tags', 'search'])); + expect(board.keep).toEqual(['view']); + }); +}); + +/** A form action event posting `fields` from `/leads`. */ +function post(/** @type {Record} */ fields) { + const body = new FormData(); + for (const [k, v] of Object.entries(fields)) body.set(k, v); + return /** @type {any} */ ({ + request: new Request('http://app.test/leads?/saveView', { method: 'POST', body }), + cookies, + url: new URL('http://app.test/leads?/saveView') + }); +} + +/** Run an action and return the redirect it throws, or its result. */ +async function outcome(/** @type {Promise} */ promise) { + try { + return await promise; + } catch (thrown) { + return thrown; + } +} + +describe('savedViewActions', () => { + const actions = savedViewActions('leads'); + beforeEach(() => { + apiRequest.mockReset(); + }); + + it('saves only the page filters, server-set fields never sent, then returns to the page', async () => { + apiRequest.mockResolvedValue({ id: VIEW }); + const result = await outcome( + actions.saveView( + post({ + name: ' Hot ', + query: `status=assigned&tags=${UUID}&limit=25&org=evil&next=https://evil.test` + }) + ) + ); + const [path, options] = apiRequest.mock.calls[0]; + expect(path).toBe('/saved-views/'); + expect(options).toEqual({ + method: 'POST', + body: { module: 'leads', name: 'Hot', filters: { status: ['assigned'], tags: [UUID] } } + }); + expect(result.status).toBe(303); + // Back to this page's own path, whatever the hidden query said. + expect(result.location.startsWith('/leads?')).toBe(true); + }); + + it("tickets: the queue's default is saved as its statuses, All as none", async () => { + apiRequest.mockResolvedValue({ id: VIEW }); + const tickets = savedViewActions('cases'); + await outcome(tickets.saveView(post({ name: 'Open', query: 'priority=High' }))); + await outcome(tickets.saveView(post({ name: 'All', query: 'all=1&priority=High' }))); + expect(apiRequest.mock.calls[0][1].body.filters).toEqual({ + status: ['New', 'Assigned', 'Pending'], + priority: ['High'] + }); + expect(apiRequest.mock.calls[1][1].body.filters).toEqual({ priority: ['High'] }); + }); + + it('refuses a blank name without calling the API', async () => { + const result = await actions.saveView(post({ name: ' ', query: '' })); + expect(result.status).toBe(400); + expect(result.data.savedViewError).toMatch(/name/); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it("shows the API's own sentence when it refuses", async () => { + apiRequest.mockRejectedValue( + Object.assign(new Error('name: x'), { + status: 400, + body: { name: ['You already have a view with this name for this list.'] } + }) + ); + const result = await actions.saveView(post({ name: 'Hot', query: '' })); + expect(result.status).toBe(400); + expect(result.data.savedViewError).toBe( + 'You already have a view with this name for this list.' + ); + }); + + it('renames and deletes by id, and never builds a path from a non-id', async () => { + apiRequest.mockResolvedValue(null); + await outcome(actions.renameView(post({ id: VIEW, name: 'Warm', query: '' }))); + expect(apiRequest.mock.calls[0]).toEqual([ + `/saved-views/${VIEW}/`, + { method: 'PATCH', body: { name: 'Warm' } }, + { cookies } + ]); + await outcome(actions.deleteView(post({ id: VIEW, query: 'status=assigned' }))); + expect(apiRequest.mock.calls[1][0]).toBe(`/saved-views/${VIEW}/`); + expect(apiRequest.mock.calls[1][1].method).toBe('DELETE'); + + apiRequest.mockReset(); + const bad = await actions.deleteView(post({ id: '../leads/1', query: '' })); + expect(bad.status).toBe(400); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it("answers another person's view (a 404 upstream) as gone", async () => { + apiRequest.mockRejectedValue(Object.assign(new Error('nf'), { status: 404, body: {} })); + const result = await actions.deleteView(post({ id: VIEW, query: '' })); + expect(result.status).toBe(404); + expect(result.data.savedViewError).toBe('That view no longer exists.'); + }); +}); diff --git a/frontend/src/lib/server/v2/tags.js b/frontend/src/lib/server/v2/tags.js index b64af2c4..253dba70 100644 --- a/frontend/src/lib/server/v2/tags.js +++ b/frontend/src/lib/server/v2/tags.js @@ -22,20 +22,17 @@ * `/settings` is deliberately member-readable (see the comment in * `$lib/v2/components/Sidebar.svelte`), and `TagsListView.get` has no role * check, so every member reads the full tag list. `TagsListView.post` - * (`backend/common/views/tags_views.py:143-149`) does gate: it 403s anyone - * whose `profile.role` is not `ADMIN`. `can_edit` below mirrors that split so - * the page can hide "New tag" for a member instead of letting them submit a - * form the backend was always going to refuse. It is a display hint only, - * decoded from the JWT and never verified; the backend re-derives the role - * from the same token and is the check that actually matters. `viewerRole` is - * imported from `organization.js`, where it is exported for exactly this - * reuse, rather than redefined here: a role check is exactly the kind of - * thing that should not exist in one more private copy per module. - * `goals.js` and `products.js` still carry their own pre-existing private - * copies of the same decode; not touched here, worth consolidating later. + * does gate: it 403s anyone who is not an org admin (`is_org_admin`). `can_edit` + * below mirrors that split so the page can hide "New tag" for a member instead + * of letting them submit a form the backend was always going to refuse. It is + * a display hint only, decoded from the JWT and never verified; the backend + * re-derives the fact and is the check that actually matters. `viewerIsAdmin` + * is imported from `organization.js` rather than redefined here: an admin + * check is exactly the kind of thing that should not exist in one more + * private copy per module. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * @param {{ cookies: import('@sveltejs/kit').Cookies }} event @@ -46,7 +43,7 @@ export async function getTags({ cookies }) { return { tags: resp.tags ?? [], totals: resp.totals ?? { count: 0, active: 0, unused: 0 }, - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/templates.js b/frontend/src/lib/server/v2/templates.js index 03f562a1..796c2ef0 100644 --- a/frontend/src/lib/server/v2/templates.js +++ b/frontend/src/lib/server/v2/templates.js @@ -46,7 +46,7 @@ * templates, and that ceiling is called out where it is set. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from '$lib/server/v2/organization.js'; +import { viewerIsAdmin } from '$lib/server/v2/organization.js'; const num = (/** @type {any} */ value) => { const n = Number(value); @@ -270,7 +270,7 @@ export async function createInvoiceTemplate({ cookies }, values, logoFile = null * @param {string} id */ export async function getInvoiceTemplateForEdit({ cookies }, id) { - if (viewerRole(cookies) !== 'ADMIN') return { can_edit: false }; + if (!viewerIsAdmin(cookies)) return { can_edit: false }; const t = await apiRequest(`/invoices/templates/${id}/editor/`, {}, { cookies }); return { diff --git a/frontend/src/lib/server/v2/templates.test.js b/frontend/src/lib/server/v2/templates.test.js index 761fb86a..a59975b3 100644 --- a/frontend/src/lib/server/v2/templates.test.js +++ b/frontend/src/lib/server/v2/templates.test.js @@ -121,17 +121,23 @@ describe('createInvoiceTemplate', () => { }); /** - * `getInvoiceTemplateForEdit`'s admin gate comes from `viewerRole`, which - * decodes the `role` claim straight out of the `jwt_access` cookie with no - * network call. A `{ get: () => 'token' }` stub cannot drive it: `'token'` has - * no `.` to split on, so `viewerRole` catches that and returns null and every - * test would see a non-admin. This builds a JWT-shaped string with a real - * base64url payload so the decode path actually runs. + * `getInvoiceTemplateForEdit`'s admin gate comes from `viewerIsAdmin`, which + * decodes the `is_organization_admin` claim straight out of the `jwt_access` + * cookie with no network call. A `{ get: () => 'token' }` stub cannot drive it: + * `'token'` has no `.` to split on, so `viewerIsAdmin` catches that and returns + * false and every test would see a non-admin. This builds a JWT-shaped string + * with a real base64url payload so the decode path actually runs. The claims + * are the pair the API signs: `role`, and the admin fact derived from it (or + * from a superuser's membership, `isAdmin` below). * * @param {string} role + * @param {boolean} [isAdmin] */ -function eventWithRole(role) { - const payload = Buffer.from(JSON.stringify({ role }), 'utf-8').toString('base64url'); +function eventWithRole(role, isAdmin = role === 'ADMIN') { + const payload = Buffer.from( + JSON.stringify({ role, is_organization_admin: isAdmin }), + 'utf-8' + ).toString('base64url'); const token = `h.${payload}.s`; return /** @type {any} */ ({ cookies: { get: (/** @type {string} */ name) => (name === 'jwt_access' ? token : null) } @@ -177,6 +183,11 @@ describe('getInvoiceTemplateForEdit', () => { expect(apiRequest).not.toHaveBeenCalled(); }); + it('lets a USER-role superuser in, because the admin fact says so', async () => { + const data = await getInvoiceTemplateForEdit(eventWithRole('USER', true), 't1'); + expect(data.can_edit).toBe(true); + }); + it('refuses a caller with no readable role', async () => { const data = await getInvoiceTemplateForEdit( /** @type {any} */ ({ cookies: { get: () => null } }), diff --git a/frontend/src/lib/server/v2/ticket-approvals.js b/frontend/src/lib/server/v2/ticket-approvals.js index a9891605..d9a38cad 100644 --- a/frontend/src/lib/server/v2/ticket-approvals.js +++ b/frontend/src/lib/server/v2/ticket-approvals.js @@ -19,7 +19,7 @@ * for the sentence it renders on the row. */ import { apiRequest } from '$lib/api-helpers.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** * @param {{ cookies: import('@sveltejs/kit').Cookies }} event @@ -48,7 +48,7 @@ export async function getApprovalRules({ cookies }) { // A display hint: POST/PUT/DELETE on `/cases/approval-rules/` each start // with `_is_admin(request.profile)` server-side and 403 regardless of // what this says. This only decides whether the page offers the controls. - can_edit: viewerRole(cookies) === 'ADMIN' + can_edit: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/tickets-bulk.test.js b/frontend/src/lib/server/v2/tickets-bulk.test.js index 2789e885..ad509e49 100644 --- a/frontend/src/lib/server/v2/tickets-bulk.test.js +++ b/frontend/src/lib/server/v2/tickets-bulk.test.js @@ -9,13 +9,15 @@ describe('summarizeBulk', () => { { id: '3', status: 'no_access' }, { id: '4', status: 'approval_required' }, { id: '5', status: 'closed_on_required' }, - { id: '6', status: 'invalid' } + { id: '6', status: 'invalid' }, + { id: '7', status: 'merged', detail: 'Unmerge it first.' } ]); expect(s.updated).toBe(2); expect(s.no_access).toBe(1); expect(s.approval_required).toBe(1); expect(s.closed_on_required).toBe(1); expect(s.invalid).toBe(1); + expect(s.merged).toBe(1); }); it('handles an empty list', () => { diff --git a/frontend/src/lib/server/v2/tickets-parent.test.js b/frontend/src/lib/server/v2/tickets-parent.test.js index d3a01d83..f4b6092c 100644 --- a/frontend/src/lib/server/v2/tickets-parent.test.js +++ b/frontend/src/lib/server/v2/tickets-parent.test.js @@ -58,3 +58,75 @@ describe('a ticket parent', () => { expect(RESTRICTED_TICKET_NAME).toBe('A ticket you cannot open'); }); }); + +describe('linking a ticket under a parent', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it('posts the parent id to link/', async () => { + apiRequest.mockResolvedValue({ id: 't', parent: { id: 'p' } }); + const { linkTicketParent } = await import('./tickets.js'); + await linkTicketParent(event, 't', 'p'); + + expect(apiRequest).toHaveBeenCalledWith( + '/cases/t/link/', + { method: 'POST', body: { parent_id: 'p' } }, + { cookies: event.cookies } + ); + }); + + it('detaches with an explicit null, never an absent key', async () => { + apiRequest.mockResolvedValue({ id: 't', parent: null }); + const { linkTicketParent } = await import('./tickets.js'); + await linkTicketParent(event, 't', null); + + expect(apiRequest.mock.calls[0][1]).toEqual({ method: 'POST', body: { parent_id: null } }); + }); + + it('carries is_problem onto the ticket', async () => { + apiRequest.mockResolvedValue({ + cases_obj: { ...row('a', null), is_problem: true }, + comment_permission: true + }); + const { ticket } = await getTicket(event, 'a'); + expect(ticket.is_problem).toBe(true); + }); +}); + +describe('a ticket the viewer cannot open', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it('is a not-found page, the same one a missing ticket gets', async () => { + apiRequest.mockRejectedValue( + Object.assign(new Error('No such case.'), { + status: 404, + body: { detail: 'No such case.' } + }) + ); + await expect(getTicket(event, 'hidden')).rejects.toMatchObject({ + status: 404, + body: { message: 'That ticket does not exist, or you do not have access to it.' } + }); + }); +}); + +describe('what the page may offer', () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + // `canReply` gates replying, status changes, logging time, the timer and + // linking a parent: the API's write rule, never a guess from the role. + it.each([ + [true, true], + [false, false], + [undefined, false] + ])('comment_permission %s reads as canReply %s', async (flag, expected) => { + apiRequest.mockResolvedValue({ cases_obj: row('a', null), comment_permission: flag }); + const { canReply } = await getTicket(event, 'a'); + expect(canReply).toBe(expected); + }); +}); diff --git a/frontend/src/lib/server/v2/tickets-status-choices.test.js b/frontend/src/lib/server/v2/tickets-status-choices.test.js index 50d72816..e72f1b4d 100644 --- a/frontend/src/lib/server/v2/tickets-status-choices.test.js +++ b/frontend/src/lib/server/v2/tickets-status-choices.test.js @@ -23,12 +23,15 @@ const STATUS_CHOICE = [ ['Duplicate', 'Duplicate'] ]; -/** @param {string} status */ -function answer(status) { +/** @param {string} status @param {boolean} [canWrite] */ +function answer(status, canWrite = true) { apiRequest.mockImplementation(async (/** @type {string} */ path) => { return path.startsWith('/cases/?') ? { status: STATUS_CHOICE, priority: [], type_of_case: [] } - : { cases_obj: { id: 'c1', name: 'Printer', status, priority: 'Normal' } }; + : { + cases_obj: { id: 'c1', name: 'Printer', status, priority: 'Normal' }, + comment_permission: canWrite + }; }); } @@ -52,6 +55,14 @@ describe('ticket status choices', () => { expect(values(data)).not.toContain('Duplicate'); }); + it('the edit form is refused to someone who may read but not change the ticket', async () => { + answer('New', false); + await expect(getTicketForEdit(event, 'c1')).rejects.toMatchObject({ + status: 403, + body: { message: 'You can read this ticket but not change it.' } + }); + }); + it('the edit form keeps Duplicate for a ticket that already is one', async () => { answer('Duplicate'); const data = await getTicketForEdit(event, 'c1'); diff --git a/frontend/src/lib/server/v2/tickets.js b/frontend/src/lib/server/v2/tickets.js index e075d32a..b9d692df 100644 --- a/frontend/src/lib/server/v2/tickets.js +++ b/frontend/src/lib/server/v2/tickets.js @@ -71,7 +71,7 @@ function accountLink(account) { * A parent the viewer may not open arrives as `{ id, name: null, status: null, * restricted: true }`. It keeps its id (the viewer may still detach from it, * since unlinking needs write on the child alone) and reads as - * `RESTRICTED_TICKET_NAME`, never as a link: opening it would only answer 403. + * `RESTRICTED_TICKET_NAME`, never as a link: opening it would only answer 404. * * @param {any} parent */ @@ -135,6 +135,7 @@ function toRow(row) { paused_at: row.sla_paused_at ?? null, child_count: row.child_count ?? 0, parent: parentLink(row.parent_summary), + is_problem: Boolean(row.is_problem), // Everyone's logged time on this ticket, which is not what // `/cases//time-entries/` returns to an agent: that list is narrowed // to their own rows, so a panel adding it up would tell a team of three @@ -151,7 +152,14 @@ function toRow(row) { * `filters.test.js` enforces that, because the failure mode otherwise is a chip * on screen and an unfiltered list underneath it. */ -export const FILTER_FIELDS = ['assigned_to', 'priority', 'case_type', 'sla_breached', 'tags']; +export const FILTER_FIELDS = [ + 'assigned_to', + 'status', + 'priority', + 'case_type', + 'sla_breached', + 'tags' +]; /** * The queue, newest first. @@ -470,6 +478,12 @@ export async function getTicketFormOptions({ cookies }, accountId = null) { */ export async function getTicketForEdit({ cookies }, id) { const [response, choices] = await Promise.all([fetchDetail(cookies, id), listChoices(cookies)]); + // Saving takes the ticket's write rule, which `comment_permission` reports. + // A reader (a watcher) would fill the form in and be refused on save, so + // the page says so up front. They know the ticket exists: 403, not 404. + if (response.comment_permission !== true) { + error(403, 'You can read this ticket but not change it.'); + } const raw = response.cases_obj; const ticket = toRow(raw); @@ -563,6 +577,27 @@ export async function getTicketTree({ cookies }, id) { return await apiRequest(`/cases/${id}/tree/`, {}, { cookies }); } +/** + * Put this ticket under `parentId`, or take it out from under its parent when + * `parentId` is null. + * + * The API takes the write rule on this ticket and the read rule on the new + * parent (a parent the caller cannot open is refused exactly like a missing + * one), and refuses self, cycles, merged tickets and trees deeper than three + * levels. Nothing here second-guesses it. + * + * @param {{ cookies: import('@sveltejs/kit').Cookies }} event + * @param {string} id + * @param {string|null} parentId + */ +export async function linkTicketParent({ cookies }, id, parentId) { + return await apiRequest( + `/cases/${id}/link/`, + { method: 'POST', body: { parent_id: parentId } }, + { cookies } + ); +} + /** * Close the ticket, and optionally every open descendant of it. * @@ -677,6 +712,8 @@ export function summarizeBulk(results) { no_access: 0, approval_required: 0, closed_on_required: 0, + // A merged ticket's status changes only by unmerging it. + merged: 0, invalid: 0 }; for (const r of results ?? []) { @@ -686,13 +723,15 @@ export function summarizeBulk(results) { } /** - * `CaseDetailView.get` answers 404 for another org's ticket. Deliberately not - * 403, which would confirm the id exists, and 403 for a ticket inside the org - * that this profile neither raised, nor is assigned to, nor watches. + * `CaseDetailView.get` answers 404, with one body, for a ticket that does not + * exist, one in another org, and one in this org that this profile neither + * raised, nor is assigned to, nor watches. Deliberately never 403, which would + * confirm the id exists, so the page cannot tell those apart either. * - * It has a third answer the fixtures had no equivalent for: a ticket that was + * It has a second answer the fixtures had no equivalent for: a ticket that was * merged into another one comes back as a 200 carrying `redirect_to`, so the - * duplicate's URL keeps working and lands on the survivor. + * duplicate's URL keeps working and lands on the survivor. That is also why no + * page ever offers a status change on a merged ticket: it is never rendered. * * @param {import('@sveltejs/kit').Cookies} cookies * @param {string} id @@ -705,10 +744,7 @@ async function fetchDetail(cookies, id) { } catch (/** @type {any} */ err) { // On the status, not on the wording. if (err?.status === 404) { - error(404, 'That ticket does not exist, or it belongs to another team.'); - } - if (err?.status === 403) { - error(403, 'This ticket belongs to somebody else. Ask an admin if you need it.'); + error(404, 'That ticket does not exist, or you do not have access to it.'); } throw err; } diff --git a/frontend/src/lib/server/v2/web-forms.js b/frontend/src/lib/server/v2/web-forms.js index 83cd27db..211166b9 100644 --- a/frontend/src/lib/server/v2/web-forms.js +++ b/frontend/src/lib/server/v2/web-forms.js @@ -33,7 +33,7 @@ import { apiRequest } from '$lib/api-helpers.js'; import { missingOption } from '$lib/v2/pickers.js'; import { builtinFor } from '$lib/v2/webform-fields.js'; -import { viewerRole } from './organization.js'; +import { viewerIsAdmin } from './organization.js'; /** Enough forms that no real org is truncated, small enough to stay one page. */ const LIST_LIMIT = 100; @@ -58,7 +58,7 @@ export async function getWebForms({ cookies }) { totals, truncated: (resp?.count ?? forms.length) > forms.length, // A display hint. `is_org_admin(request.profile)` is what actually decides. - canManage: viewerRole(cookies) === 'ADMIN' + canManage: viewerIsAdmin(cookies) }; } @@ -167,7 +167,7 @@ export async function getWebForm({ cookies }, id) { missingAssignee, customFields, tags, - canManage: viewerRole(cookies) === 'ADMIN' + canManage: viewerIsAdmin(cookies) }; } diff --git a/frontend/src/lib/server/v2/web-forms.test.js b/frontend/src/lib/server/v2/web-forms.test.js index 8c4ec73c..da1ba584 100644 --- a/frontend/src/lib/server/v2/web-forms.test.js +++ b/frontend/src/lib/server/v2/web-forms.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const apiRequest = vi.fn(); vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) })); -vi.mock('./organization.js', () => ({ viewerRole: () => 'ADMIN' })); +vi.mock('./organization.js', () => ({ viewerIsAdmin: () => true })); const { getWebForm, updateWebForm, createWebForm } = await import('./web-forms.js'); diff --git a/frontend/src/lib/utils/csv.js b/frontend/src/lib/utils/csv.js new file mode 100644 index 00000000..375d9f9e --- /dev/null +++ b/frontend/src/lib/utils/csv.js @@ -0,0 +1,54 @@ +/** + * CSV files the browser builds itself, such as the import drawers' errors file. + * + * The same two rules as the server's exports (`backend/common/csv_export.py`) + * and the mobile import sheet (`csvImportErrorsCsv`): + * + * - **A cell a spreadsheet would run as a formula is prefixed with `'`.** A + * cell starting with `=`, `+`, `-`, `@`, a tab or a carriage return, also + * after leading spaces, is a formula to Excel. An import error can quote the + * value it refused, so a row holding `=HYPERLINK(...)` would otherwise run on + * the machine of whoever opens the file. + * - **A cell holding a quote, comma or line break is quoted, quotes doubled**, + * so a message with a comma in it stays one cell. + */ + +const FORMULA_START = ['=', '+', '-', '@', '\t', '\r']; + +/** @param {unknown} value */ +export function csvCell(value) { + let text = value === null || value === undefined ? '' : String(value); + const trimmed = text.trimStart(); + if (FORMULA_START.some((c) => text.startsWith(c) || trimmed.startsWith(c))) { + text = `'${text}`; + } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +/** + * An import's row errors as the "Download errors" file: `row,field,message`. + * + * @param {{ row: number, field: string, message: string }[]} errors + */ +export function importErrorsCsv(errors) { + return [['row', 'field', 'message'], ...errors.map((e) => [e.row, e.field, e.message])] + .map((cells) => cells.map(csvCell).join(',')) + .join('\n'); +} + +/** + * Save `errors` as `-import-errors.csv`. Does nothing for none. + * + * @param {{ row: number, field: string, message: string }[]} errors + * @param {string} plural e.g. `leads` + */ +export function downloadImportErrors(errors, plural) { + if (!errors?.length) return; + const blob = new Blob([importErrorsCsv(errors)], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${plural}-import-errors.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/utils/csv.test.js b/frontend/src/lib/utils/csv.test.js new file mode 100644 index 00000000..3f55aa62 --- /dev/null +++ b/frontend/src/lib/utils/csv.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { csvCell, downloadImportErrors, importErrorsCsv } from './csv.js'; + +describe('csvCell', () => { + it.each([ + ['plain', 'plain'], + ['Acme = best', 'Acme = best'], + ['a, b', '"a, b"'], + ['say "hi"', '"say ""hi"""'], + ['two\nlines', '"two\nlines"'], + ['=1+1', "'=1+1"], + ['+44 20', "'+44 20"], + ['-2', "'-2"], + ['@SUM(A1)', "'@SUM(A1)"], + ['\t=cmd', "'\t=cmd"], + [' =HYPERLINK("x")', `"' =HYPERLINK(""x"")"`], + [3, '3'], + [null, ''], + [undefined, ''] + ])('%j becomes %j', (raw, cell) => { + expect(csvCell(raw)).toBe(cell); + }); +}); + +describe('importErrorsCsv', () => { + it('matches the mobile file: row,field,message, one error per line', () => { + expect( + importErrorsCsv([ + { row: 2, field: 'email', message: 'Not an email' }, + { row: 3, field: 'name', message: 'Say "hi", then\nleave' }, + { row: 4, field: 'phone', message: '=HYPERLINK("x")' }, + { row: 5, field: 'city', message: ' -1' } + ]) + ).toBe( + 'row,field,message\n' + + '2,email,Not an email\n' + + '3,name,"Say ""hi"", then\nleave"\n' + + `4,phone,"'=HYPERLINK(""x"")"\n` + + "5,city,' -1" + ); + }); +}); + +describe('downloadImportErrors', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('names the file after the module and skips an empty list', () => { + const click = vi.fn(); + /** @type {any} */ + const anchor = { click }; + vi.stubGlobal('document', { createElement: () => anchor }); + vi.stubGlobal('URL', { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }); + + downloadImportErrors([], 'leads'); + expect(click).not.toHaveBeenCalled(); + + downloadImportErrors([{ row: 1, field: 'email', message: 'x' }], 'leads'); + expect(anchor.download).toBe('leads-import-errors.csv'); + expect(click).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/lib/v2/audit-log.js b/frontend/src/lib/v2/audit-log.js new file mode 100644 index 00000000..2463b444 --- /dev/null +++ b/frontend/src/lib/v2/audit-log.js @@ -0,0 +1,48 @@ +/** + * How an entry from `GET /api/org/audit-log/` reads on the page. + * + * The API sends only allow-listed `details` keys (ids, counts, and sentences + * the server wrote), never the stored description, so every string built here + * comes from those. + */ + +/** + * Who the entry is about: a name, else an email. An entry with no person is a + * system event, or one whose user has since been deleted. + * @param {{ actor?: { name?: string, email?: string } | null }} entry + */ +export function auditActor(entry) { + const a = entry?.actor; + if (!a) return 'No user'; + return a.name || a.email || 'No user'; +} + +/** + * One line on what happened, or '' when the event label says it all. + * @param {{ event_type?: string, details?: Record }} entry + */ +export function auditDetail(entry) { + const d = entry?.details ?? {}; + if (d.pause_reason) return String(d.pause_reason); + if (entry?.event_type === 'WEBHOOK_REENABLED') { + return 'Turned back on, and now answers for the webhook.'; + } + if (entry?.event_type === 'WEBHOOK_CHANGED' && Array.isArray(d.changed)) { + return `Changed ${d.changed.join(', ')}, and now answers for the webhook.`; + } + if (d.action && d.resource) return `${d.action} on ${d.resource}`; + if (d.deleted_count !== undefined && d.deleted_count !== null) { + return `${d.deleted_count} sample leads removed`; + } + return ''; +} + +/** + * The webhook an entry is about, for a link, or null. + * @param {{ details?: Record }} entry + * @returns {string | null} + */ +export function auditWebhookId(entry) { + const id = entry?.details?.endpoint_id; + return typeof id === 'string' && id ? id : null; +} diff --git a/frontend/src/lib/v2/audit-log.test.js b/frontend/src/lib/v2/audit-log.test.js new file mode 100644 index 00000000..dd822bf0 --- /dev/null +++ b/frontend/src/lib/v2/audit-log.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { auditActor, auditDetail, auditWebhookId } from '$lib/v2/audit-log.js'; + +describe('auditActor', () => { + it('prefers the name, then the email', () => { + expect(auditActor({ actor: { name: 'Asha', email: 'a@x.com' } })).toBe('Asha'); + expect(auditActor({ actor: { name: '', email: 'a@x.com' } })).toBe('a@x.com'); + }); + + it('says so when there is no person', () => { + expect(auditActor({ actor: null })).toBe('No user'); + }); +}); + +describe('auditDetail', () => { + it('shows the reason a webhook was paused', () => { + const reason = 'Paused because the admin who created it was deactivated.'; + expect(auditDetail({ event_type: 'WEBHOOK_PAUSED', details: { pause_reason: reason } })).toBe( + reason + ); + }); + + it('describes a re-enable', () => { + expect(auditDetail({ event_type: 'WEBHOOK_REENABLED', details: {} })).toMatch(/answers for/); + }); + + it('names what a take-over changed', () => { + expect( + auditDetail({ event_type: 'WEBHOOK_CHANGED', details: { changed: ['url', 'secret'] } }) + ).toBe('Changed url, secret, and now answers for the webhook.'); + }); + + it('describes a refused action and a sample-data clear', () => { + expect( + auditDetail({ + event_type: 'PERMISSION_DENIED', + details: { action: 'ORG_SWITCH', resource: 'org:1' } + }) + ).toBe('ORG_SWITCH on org:1'); + expect(auditDetail({ event_type: 'SAMPLE_DATA_CLEARED', details: { deleted_count: 0 } })).toBe( + '0 sample leads removed' + ); + }); + + it('is empty when there is nothing to add', () => { + expect(auditDetail({ event_type: 'LOGIN_SUCCESS', details: {} })).toBe(''); + }); +}); + +describe('auditWebhookId', () => { + it('reads the endpoint id, and nothing else', () => { + expect(auditWebhookId({ details: { endpoint_id: 'e1' } })).toBe('e1'); + expect(auditWebhookId({ details: {} })).toBeNull(); + expect(auditWebhookId({ details: { endpoint_id: 5 } })).toBeNull(); + }); +}); diff --git a/frontend/src/lib/v2/components/FilterBar.svelte b/frontend/src/lib/v2/components/FilterBar.svelte index 93ba6502..33032a03 100644 --- a/frontend/src/lib/v2/components/FilterBar.svelte +++ b/frontend/src/lib/v2/components/FilterBar.svelte @@ -28,12 +28,14 @@ * meId?: string | null, * meta?: string | null, * onlyFields?: string[], - * onlyPresets?: string[] + * onlyPresets?: string[], + * saved?: any * }} */ import { X, Plus, ChevronDown } from '@lucide/svelte'; import { FILTERS, activeChips, activePresetKey, withParams } from '$lib/v2/filters.js'; import { invoiceStatusLabel } from '$lib/v2/enums.js'; + import SavedViews from '$lib/v2/components/SavedViews.svelte'; let { page, @@ -45,7 +47,10 @@ meId = null, meta = null, onlyFields = undefined, - onlyPresets = undefined + onlyPresets = undefined, + // The page's saved views (`loadSavedViews`), on the six lists that take + // them. Absent everywhere else, and then no menu is drawn. + saved = null } = $props(); let descriptor = $derived(FILTERS[page] ?? { presets: [], fields: [] }); @@ -141,6 +146,10 @@ + {#if saved} + + {/if} + {#each chips as chip (chip.key)} {chip.label} @@ -229,6 +238,16 @@ value={url.searchParams.get(field.key) ?? ''} placeholder="Any" /> + {:else if field.multi && url.searchParams.getAll(field.key).length > 1} + + {#each url.searchParams.getAll(field.key) as value (value)} + + {/each} + {url.searchParams.getAll(field.key).length} selected. Remove the chip to choose again. {:else} + + + + + + + + {:else} + + {view.name} + {#if dropped > 0} + + + {dropped === 1 ? '1 filter value' : `${dropped} filter values`} this page cannot apply + + {/if} + + + + + {/if} + + {/each} + + {/if} + + {#if full} +

    + This list keeps at most {saved.limit} saved views. Delete one to save another. +

    + {:else} +
    + + + +
    + {/if} + + + + diff --git a/frontend/src/lib/v2/components/SectionTabs.svelte b/frontend/src/lib/v2/components/SectionTabs.svelte index 97f2f293..fb49507e 100644 --- a/frontend/src/lib/v2/components/SectionTabs.svelte +++ b/frontend/src/lib/v2/components/SectionTabs.svelte @@ -3,6 +3,7 @@ import { asInternalPath } from '$lib/utils/paths.js'; import { page } from '$app/state'; import { TAB_SETS } from '$lib/v2/tabs.js'; + import { isOrgAdmin } from '$lib/admin.js'; /** * Second-level navigation within one destination. Tickets has a queue, an @@ -20,11 +21,11 @@ let { set } = $props(); let counts = $derived(page.data.counts ?? {}); - // `role` rides on the app layout data (server-derived from the JWT). Drop - // admin-only tabs for a member so the strip does not offer a page that only - // gate-cards them; the backend still enforces the gate, so this is UX only. - let role = $derived(page.data.role ?? 'USER'); - let tabs = $derived((TAB_SETS[set] ?? []).filter((tab) => role === 'ADMIN' || !tab.admin)); + // The admin fact rides on the app layout data (server-derived from the JWT). + // Drop admin-only tabs for a member so the strip does not offer a page that + // only gate-cards them; the backend still enforces the gate, so this is UX only. + let isAdmin = $derived(isOrgAdmin(page.data)); + let tabs = $derived((TAB_SETS[set] ?? []).filter((tab) => isAdmin || !tab.admin)); const isActive = (href, exact) => exact ? page.url.pathname === href : page.url.pathname.startsWith(href); diff --git a/frontend/src/lib/v2/components/Sidebar.svelte b/frontend/src/lib/v2/components/Sidebar.svelte index 7d07b948..064be859 100644 --- a/frontend/src/lib/v2/components/Sidebar.svelte +++ b/frontend/src/lib/v2/components/Sidebar.svelte @@ -35,8 +35,9 @@ * v1 had /leads listed twice, as "Pipeline" and as "Leads", and a "Deals" * entry pointing at /opportunities while /deals 404'd. * - * `role` is server-derived from the JWT (see the app layout loader). It only - * decides which destinations to *show*. Every hidden one is still enforced + * `isAdmin` is the server-derived admin fact (`isOrgAdmin` over the app + * layout data, which reads the JWT). It only decides which destinations to + * *show*. Every hidden one is still enforced * by the backend, so this is UX, not access control. An item marked `admin` * is one where a member gets nothing but a "for administrators" gate, so * showing it would only teach them to bounce off it. @@ -50,7 +51,7 @@ * @type {{ * counts?: Record, * org?: { name: string }, - * role?: string, + * isAdmin?: boolean, * terminology?: Record | null, * onsearch?: () => void * }} @@ -58,7 +59,7 @@ let { counts = {}, org = { name: 'BottleCRM' }, - role = 'USER', + isAdmin = false, terminology = undefined, onsearch = () => {} } = $props(); @@ -127,7 +128,7 @@ GROUPS.map((group) => ({ ...group, items: group.items - .filter((item) => role === 'ADMIN' || !item.admin) + .filter((item) => isAdmin || !item.admin) .map((item) => item.termKey ? { ...item, label: t(terminology, item.termKey, item.label) } : item ) diff --git a/frontend/src/lib/v2/deal-next-step.js b/frontend/src/lib/v2/deal-next-step.js new file mode 100644 index 00000000..d19ac5a3 --- /dev/null +++ b/frontend/src/lib/v2/deal-next-step.js @@ -0,0 +1,26 @@ +import { daysSince, shortDate } from '$lib/v2/format.js'; + +/** + * A deal's next open task as a phrase for the pipeline card and list row, or + * `null` to show nothing. + * + * Only a deal in an open stage is flagged: a won or lost deal has no next step + * to take, so it shows nothing, whether or not a task is still open on it. The + * decision reads `stage_kind`, which the API derives from the deal's own + * pipeline stage, never the stage code, because an admin can call a won stage + * anything. `next_activity` is `null` when the viewer has no open task on the + * deal (the API counts only tasks they can open), which is the flag, and + * `undefined` when the response did not compute it, which is not. + * + * @param {{ stage_kind?: string | null, next_activity?: any }} deal + * @param {Date} [now] + * @returns {{ text: string, late: boolean } | null} + */ +export function dealNextStep(deal, now = new Date()) { + if (deal.stage_kind !== 'open') return null; + const next = deal.next_activity; + if (next === undefined) return null; + if (next === null) return { text: 'No next step', late: true }; + const due = next.due_date ? shortDate(next.due_date, now) : 'no date'; + return { text: `${next.title} · ${due}`, late: (daysSince(next.due_date, now) ?? 0) > 0 }; +} diff --git a/frontend/src/lib/v2/deal-next-step.test.js b/frontend/src/lib/v2/deal-next-step.test.js new file mode 100644 index 00000000..fa21eed5 --- /dev/null +++ b/frontend/src/lib/v2/deal-next-step.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { dealNextStep } from '$lib/v2/deal-next-step.js'; + +const now = new Date('2026-09-26T12:00:00'); +const task = { id: 't1', title: 'Send proposal', due_date: '2026-10-01' }; + +describe('dealNextStep', () => { + it('flags an open deal with no next step', () => { + expect(dealNextStep({ stage_kind: 'open', next_activity: null }, now)).toEqual({ + text: 'No next step', + late: true + }); + }); + + it('shows the task on an open deal, late only when overdue', () => { + expect(dealNextStep({ stage_kind: 'open', next_activity: task }, now)).toEqual({ + text: 'Send proposal · 1 Oct', + late: false + }); + const overdue = { ...task, due_date: '2026-09-20' }; + expect(dealNextStep({ stage_kind: 'open', next_activity: overdue }, now)?.late).toBe(true); + const undated = { ...task, due_date: null }; + expect(dealNextStep({ stage_kind: 'open', next_activity: undated }, now)).toEqual({ + text: 'Send proposal · no date', + late: false + }); + }); + + it.each(['won', 'lost'])('shows nothing on a %s deal, flag or task', (kind) => { + expect(dealNextStep({ stage_kind: kind, next_activity: null }, now)).toBeNull(); + expect(dealNextStep({ stage_kind: kind, next_activity: task }, now)).toBeNull(); + }); + + it('reads the kind, not the code: a won stage named anything is still won', () => { + const deal = { stage: 'SIGNED', stage_kind: 'won', next_activity: null }; + expect(dealNextStep(deal, now)).toBeNull(); + }); + + it('shows nothing when the response did not compute it', () => { + expect(dealNextStep({ stage_kind: 'open' }, now)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/v2/filters.js b/frontend/src/lib/v2/filters.js index 25b6ee16..ab53628c 100644 --- a/frontend/src/lib/v2/filters.js +++ b/frontend/src/lib/v2/filters.js @@ -25,6 +25,7 @@ */ import { CASE_PRIORITIES, + CASE_STATUSES, CASE_TYPES, DOCUMENT_STATUSES, ESTIMATE_STATUSES, @@ -43,7 +44,7 @@ import { } from './enums.js'; /** @typedef {{ key: string, label: string, params: Record }} Preset */ -/** @typedef {{ key: string, label: string, type: string, options?: string[], labelFor?: (v: string) => string, gteKey?: string, lteKey?: string }} Field */ +/** @typedef {{ key: string, label: string, type: string, options?: string[], labelFor?: (v: string) => string, gteKey?: string, lteKey?: string, multi?: boolean }} Field */ /** @typedef {{ presets: Preset[], fields: Field[] }} Descriptor */ /** @type {Record} */ @@ -56,11 +57,13 @@ export const FILTERS = { { key: 'all', label: 'Everything', params: { all: '1' } } ], fields: [ - { key: 'assigned_to', label: 'Owner', type: 'person' }, + { key: 'assigned_to', label: 'Owner', type: 'person', multi: true }, + // Picking a status replaces the open-queue default (see `ticketListQuery`). + { key: 'status', label: 'Status', type: 'select', options: CASE_STATUSES, multi: true }, { key: 'priority', label: 'Priority', type: 'select', options: CASE_PRIORITIES }, { key: 'case_type', label: 'Type', type: 'select', options: CASE_TYPES }, { key: 'sla_breached', label: 'Breaching SLA', type: 'boolean' }, - { key: 'tags', label: 'Tag', type: 'tag' } + { key: 'tags', label: 'Tag', type: 'tag', multi: true } ] }, @@ -70,13 +73,14 @@ export const FILTERS = { { key: 'mine', label: 'Mine', params: { assigned_to: '@me' } } ], fields: [ - { key: 'assigned_to', label: 'Owner', type: 'person' }, + { key: 'assigned_to', label: 'Owner', type: 'person', multi: true }, { key: 'status', label: 'Status', type: 'select', options: LEAD_LIST_STATUSES, - labelFor: (v) => LEAD_STATUS_LABEL[v] ?? v + labelFor: (v) => LEAD_STATUS_LABEL[v] ?? v, + multi: true }, { key: 'source', @@ -85,7 +89,7 @@ export const FILTERS = { options: LEAD_SOURCES, labelFor: (v) => LEAD_SOURCE_LABEL[v] ?? v }, - { key: 'tags', label: 'Tag', type: 'tag' } + { key: 'tags', label: 'Tag', type: 'tag', multi: true } ] }, @@ -96,8 +100,8 @@ export const FILTERS = { { key: 'active', label: 'Active contacts', params: {} } ], fields: [ - { key: 'assigned_to', label: 'Owner', type: 'person' }, - { key: 'tags', label: 'Tag', type: 'tag' }, + { key: 'assigned_to', label: 'Owner', type: 'person', multi: true }, + { key: 'tags', label: 'Tag', type: 'tag', multi: true }, { key: 'city', label: 'City', type: 'text' } ] }, @@ -110,7 +114,7 @@ export const FILTERS = { { key: 'all', label: 'All deals', params: {} } ], fields: [ - { key: 'assigned_to', label: 'Owner', type: 'person' }, + { key: 'assigned_to', label: 'Owner', type: 'person', multi: true }, // Stages are per pipeline and configured by the org, so the options // arrive from the API (FilterBar's `stages` prop), not from an enum. { key: 'stage', label: 'Stage', type: 'stage' }, @@ -128,7 +132,7 @@ export const FILTERS = { gteKey: 'amount__gte', lteKey: 'amount__lte' }, - { key: 'tags', label: 'Tag', type: 'tag' } + { key: 'tags', label: 'Tag', type: 'tag', multi: true } ] }, @@ -158,8 +162,8 @@ export const FILTERS = { { key: 'all', label: 'All accounts', params: {} } ], fields: [ - { key: 'assigned_to', label: 'Owner', type: 'person' }, - { key: 'tags', label: 'Tag', type: 'tag' }, + { key: 'assigned_to', label: 'Owner', type: 'person', multi: true }, + { key: 'tags', label: 'Tag', type: 'tag', multi: true }, { key: 'industry', label: 'Industry', @@ -386,15 +390,21 @@ export function activeChips( continue; } - const raw = url.searchParams.get(field.key); - if (!raw) continue; - let value = raw; - if (field.type === 'person') value = nameFrom(lookups.people ?? [], raw); - else if (field.type === 'tag') value = nameFrom(lookups.tags ?? [], raw); - else if (field.type === 'account') value = nameFrom(lookups.accounts ?? [], raw); - else if (field.type === 'stage') value = nameFrom(lookups.stages ?? [], raw); - else if (field.type === 'boolean') value = raw === 'true' ? 'Yes' : 'No'; - else if (field.labelFor) value = field.labelFor(raw); + // A `multi` field names every value it holds, so a list filtered by two + // owners does not wear a chip that names one. + const raws = ( + field.multi ? url.searchParams.getAll(field.key) : [url.searchParams.get(field.key)] + ).filter((v) => Boolean(v)); + if (raws.length === 0) continue; + const show = (/** @type {string} */ raw) => { + if (field.type === 'person') return nameFrom(lookups.people ?? [], raw); + if (field.type === 'tag') return nameFrom(lookups.tags ?? [], raw); + if (field.type === 'account') return nameFrom(lookups.accounts ?? [], raw); + if (field.type === 'stage') return nameFrom(lookups.stages ?? [], raw); + if (field.type === 'boolean') return raw === 'true' ? 'Yes' : 'No'; + return field.labelFor ? field.labelFor(raw) : raw; + }; + const value = raws.map((raw) => show(/** @type {string} */ (raw))).join(', '); chips.push({ key: field.key, label: field.label, value, href: withoutParam(url, field.key) }); } diff --git a/frontend/src/lib/v2/filters.test.js b/frontend/src/lib/v2/filters.test.js index edea2f3c..d26485ae 100644 --- a/frontend/src/lib/v2/filters.test.js +++ b/frontend/src/lib/v2/filters.test.js @@ -202,6 +202,30 @@ describe('activeChips', () => { }); }); +describe('multi-value chips', () => { + it('names every value of a repeatable field in one removable chip', () => { + const chips = activeChips( + 'tickets', + new URL('http://x/tickets?assigned_to=p1&assigned_to=p2&status=New&status=Pending'), + { + people: [ + { id: 'p1', name: 'Ada' }, + { id: 'p2', name: 'Bob' } + ] + } + ); + const owner = chips.find((c) => c.key === 'assigned_to'); + expect(owner?.value).toBe('Ada, Bob'); + expect(owner?.href).toBe('/tickets?status=New&status=Pending'); + expect(chips.find((c) => c.key === 'status')?.value).toBe('New, Pending'); + }); + + it('a single-valued field still shows its first value only', () => { + const chips = activeChips('tickets', new URL('http://x/tickets?priority=High&priority=Low')); + expect(chips.find((c) => c.key === 'priority')?.value).toBe('High'); + }); +}); + describe('pipeline presets', () => { // The pipeline base queryset does not exclude closed stages // (opportunity_views.py), so the empty-params preset is "All deals", not diff --git a/frontend/src/lib/v2/line-items.js b/frontend/src/lib/v2/line-items.js index 4f8c7ea0..6f5327c8 100644 --- a/frontend/src/lib/v2/line-items.js +++ b/frontend/src/lib/v2/line-items.js @@ -112,3 +112,69 @@ export function linePayload(usable) { return row; }); } + +/** Rounded to the cent, as the server stores money, so a float sum does not + refuse a discount that exactly matches it. @param {number} v */ +const cents = (v) => Math.round(v * 100) / 100; + +/** + * The server's bounds on a discount, in its order and its words + * (`LineAmountsMixin.validate` for a line, `validate_document_discount` for a + * document): not negative, a percentage not over 100, and a flat amount not + * over what it comes off. Returns the message, or '' when the API would take + * it. A hint only: the API still refuses the rest. + * + * @param {string} type + * @param {any} value + * @param {number} cap what a flat discount comes off + * @param {string} capMessage + */ +function discountError(type, value, cap, capMessage) { + const v = num(value); + if (v < 0) return 'A discount cannot be negative.'; + if (type === 'PERCENTAGE') return v > 100 ? 'A percentage discount cannot exceed 100.' : ''; + return v > cents(cap) ? capMessage : ''; +} + +/** + * A document's own discount against its subtotal. The builders send a + * discount only once a type is chosen, so no type is nothing to check. + * + * @param {string} type + * @param {any} value + * @param {number} subtotal + */ +export const documentDiscountError = (type, value, subtotal) => + type ? discountError(type, value, subtotal, 'A discount cannot exceed the subtotal.') : ''; + +/** + * A line's own discount against its gross. A line only carries one from the + * deal it came from, and lowering its quantity or price afterwards can leave + * a flat discount bigger than the line. + * + * @param {Line} line + */ +export const lineDiscountError = (line) => + num(line.discount_value) + ? discountError( + line.discount_type ?? '', + line.discount_value, + num(line.quantity) * num(line.unit_price), + "A discount cannot exceed the line's amount." + ) + : ''; + +/** + * The server's bounds on a document's tax rate (a percentage, 0 to 100) and + * shipping (never negative), in its words. '' when the API would take it. + * + * @param {any} value + */ +export function taxRateError(value) { + const v = num(value); + if (v < 0) return 'Tax rate cannot be negative.'; + return v > 100 ? 'Tax rate cannot exceed 100.' : ''; +} + +/** @param {any} value */ +export const shippingError = (value) => (num(value) < 0 ? 'Shipping cannot be negative.' : ''); diff --git a/frontend/src/lib/v2/line-items.test.js b/frontend/src/lib/v2/line-items.test.js index 9d5b5cc2..84daee44 100644 --- a/frontend/src/lib/v2/line-items.test.js +++ b/frontend/src/lib/v2/line-items.test.js @@ -1,10 +1,14 @@ import { describe, it, expect } from 'vitest'; import { blankLine, + documentDiscountError, documentTotals, + lineDiscountError, lineAmount, lineTotals, - linePayload + linePayload, + shippingError, + taxRateError } from '$lib/v2/line-items.js'; const line = (over = {}) => ({ @@ -114,3 +118,54 @@ describe('the nested line_items body', () => { }); }); }); + +describe('the discount bounds the server enforces', () => { + it('refuses a document discount the API would refuse, with its wording', () => { + expect(documentDiscountError('FIXED', -1, 100)).toBe('A discount cannot be negative.'); + expect(documentDiscountError('PERCENTAGE', 100.01, 100)).toBe( + 'A percentage discount cannot exceed 100.' + ); + expect(documentDiscountError('FIXED', 100.01, 100)).toBe( + 'A discount cannot exceed the subtotal.' + ); + }); + + it('allows the whole subtotal off, and anything with no type chosen', () => { + expect(documentDiscountError('FIXED', 100, 100)).toBe(''); + expect(documentDiscountError('PERCENTAGE', 100, 0)).toBe(''); + // 0.1 + 0.2 is not 0.3 in floating point; the server compares cents. + expect(documentDiscountError('FIXED', 0.3, 0.1 + 0.2)).toBe(''); + expect(documentDiscountError('', 500, 100)).toBe(''); + }); + + it("refuses a line discount bigger than the line's amount", () => { + const flat = line({ discount_type: 'FIXED', discount_value: 50 }); + expect(lineDiscountError(flat)).toBe(''); + expect(lineDiscountError({ ...flat, unit_price: 20 })).toBe( + "A discount cannot exceed the line's amount." + ); + // A blank type is a flat amount on the server too. + expect(lineDiscountError(line({ discount_type: '', discount_value: 301 }))).toBe( + "A discount cannot exceed the line's amount." + ); + expect(lineDiscountError(line({ discount_type: 'PERCENTAGE', discount_value: 101 }))).toBe( + 'A percentage discount cannot exceed 100.' + ); + expect(lineDiscountError(line())).toBe(''); + }); +}); + +describe('the tax rate and shipping bounds the server enforces', () => { + it('holds the tax rate to 0 to 100, in its words', () => { + expect(taxRateError(-0.01)).toBe('Tax rate cannot be negative.'); + expect(taxRateError(100.01)).toBe('Tax rate cannot exceed 100.'); + expect(taxRateError(0)).toBe(''); + expect(taxRateError(100)).toBe(''); + }); + + it('refuses negative shipping only', () => { + expect(shippingError(-1)).toBe('Shipping cannot be negative.'); + expect(shippingError(0)).toBe(''); + expect(shippingError(12.5)).toBe(''); + }); +}); diff --git a/frontend/src/lib/v2/saved-views.js b/frontend/src/lib/v2/saved-views.js new file mode 100644 index 00000000..7a35dc3a --- /dev/null +++ b/frontend/src/lib/v2/saved-views.js @@ -0,0 +1,114 @@ +/** + * Saved views (G29): a person's named filters for one list, put back with one + * tap. The API is `/api/saved-views/`; the list pages reach it through the + * form actions in `$lib/server/v2/saved-views.js`. + * + * Client-safe on purpose: `SavedViews.svelte` imports these to build its links. + * + * A view stores the list's own API query as `{param: [value, ...]}`, with the + * API's meaning, so a view saved on the phone and one saved here are the same + * thing. Each page describes how its URL maps onto that with a `spec`: + * + * - `keys`: the URL parameters it forwards to the list under the same name. + * Only those are saved from it or put back into its URL. + * - `multi`: the keys it forwards every value of. Any other key forwards its + * first value only, so a view holding more is shown as losing the rest. + * - `implicit` (tickets only): the page's default for a key, and the URL flag + * that turns the default off. With no `status` the queue shows open tickets + * and `?all=1` shows all of them, while to the API no `status` is every + * status. The helpers translate both ways, so an open-queue view is saved as + * its three statuses and a view with no status opens as All. + * - `keep`: URL parameters that are not filters and survive opening a view, + * such as the pipeline's `view=board`. + * + * @typedef {{ keys: string[], multi?: string[], implicit?: { key: string, values: string[], allParam: string } | null, keep?: string[] }} Spec + */ + +/** + * The page's filters as a view stores them, read off its query string. + * + * @param {URLSearchParams} params + * @param {Spec} spec + * @returns {Record} + */ +export function pickFilters(params, spec) { + /** @type {Record} */ + const out = {}; + for (const key of spec.keys) { + const values = params + .getAll(key) + .map((v) => v.trim()) + .filter(Boolean); + const kept = spec.multi?.includes(key) ? values : values.slice(0, 1); + if (kept.length > 0) out[key] = kept; + } + const implicit = spec.implicit; + if (implicit && !out[implicit.key] && params.get(implicit.allParam) !== '1') { + out[implicit.key] = [...implicit.values]; + } + return out; +} + +/** + * The page URL that shows a view: its path, the view's filters this page can + * apply, and the current URL's `keep` parameters. Every other filter on the + * current URL is cleared, so opening a view is a fresh start rather than a + * merge with the chips already up. + * + * @param {URL} url the current page + * @param {Record} filters + * @param {Spec} spec + */ +export function viewHref(url, filters, spec) { + const query = new URLSearchParams(); + for (const key of spec.keep ?? []) { + for (const value of url.searchParams.getAll(key)) query.append(key, value); + } + for (const key of spec.keys) { + const values = filters?.[key] ?? []; + for (const value of spec.multi?.includes(key) ? values : values.slice(0, 1)) { + query.append(key, value); + } + } + const implicit = spec.implicit; + if (implicit && !(filters?.[implicit.key]?.length > 0)) query.set(implicit.allParam, '1'); + const qs = query.toString(); + return qs ? `${url.pathname}?${qs}` : url.pathname; +} + +/** + * How many of a view's filter values this page cannot apply: every value of a + * parameter it has no control for, and every value past the first of one it + * reads singly. + * + * @param {Record} filters + * @param {Spec} spec + */ +export function droppedValues(filters, spec) { + let dropped = 0; + for (const [key, values] of Object.entries(filters ?? {})) { + if (!spec.keys.includes(key)) dropped += values.length; + else if (!spec.multi?.includes(key)) dropped += Math.max(0, values.length - 1); + } + return dropped; +} + +/** + * Whether the page is showing exactly this view. Never for a view that loses + * values here: the list is then showing something else. Order of repeated + * values does not matter. + * + * @param {URLSearchParams} params + * @param {Record} filters + * @param {Spec} spec + */ +export function isShowing(params, filters, spec) { + if (droppedValues(filters, spec) > 0) return false; + const current = pickFilters(params, spec); + const keys = new Set([...Object.keys(current), ...Object.keys(filters ?? {})]); + return [...keys].every((key) => { + const a = [...(current[key] ?? [])].sort(); + const b = [...(filters?.[key] ?? [])].sort(); + return a.length === b.length && a.every((v, i) => v === b[i]); + }); +} diff --git a/frontend/src/lib/v2/saved-views.test.js b/frontend/src/lib/v2/saved-views.test.js new file mode 100644 index 00000000..22b30a2c --- /dev/null +++ b/frontend/src/lib/v2/saved-views.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { droppedValues, isShowing, pickFilters, viewHref } from './saved-views.js'; + +/** @type {import('./saved-views.js').Spec} */ +const LEADS = { keys: ['status', 'tags', 'search', 'rating'], multi: ['status', 'tags'] }; + +/** @type {import('./saved-views.js').Spec} */ +const TICKETS = { + keys: ['status', 'priority', 'search'], + multi: ['status'], + implicit: { key: 'status', values: ['New', 'Assigned', 'Pending'], allParam: 'all' } +}; + +/** @type {import('./saved-views.js').Spec} */ +const BOARD = { keys: ['assigned_to', 'search'], multi: ['assigned_to'], keep: ['view'] }; + +const page = (/** @type {string} */ qs) => new URL(`http://app.test/x${qs}`); + +describe('pickFilters', () => { + it('keeps the page keys, every value of a multi key, the first of a single one', () => { + const params = new URLSearchParams( + 'status=assigned&status=in+process&rating=HOT&rating=COLD&search=%20&limit=25&tags=t1' + ); + expect(pickFilters(params, LEADS)).toEqual({ + status: ['assigned', 'in process'], + rating: ['HOT'], + tags: ['t1'] + }); + }); + + it("saves the ticket queue's default as its three statuses, and All as none", () => { + expect(pickFilters(new URLSearchParams(''), TICKETS)).toEqual({ + status: ['New', 'Assigned', 'Pending'] + }); + expect(pickFilters(new URLSearchParams('all=1&priority=High'), TICKETS)).toEqual({ + priority: ['High'] + }); + expect(pickFilters(new URLSearchParams('status=Closed'), TICKETS)).toEqual({ + status: ['Closed'] + }); + }); +}); + +describe('viewHref', () => { + it('puts every value of a multi key back, and only the first of a single one', () => { + expect( + viewHref(page(''), { status: ['a', 'b'], rating: ['HOT', 'COLD'], city: ['x'] }, LEADS) + ).toBe('/x?status=a&status=b&rating=HOT'); + }); + + it('opens a ticket view with no status as All, and three statuses as the queue', () => { + expect(viewHref(page(''), { priority: ['High'] }, TICKETS)).toBe('/x?priority=High&all=1'); + expect(viewHref(page('?all=1'), { status: ['New', 'Assigned', 'Pending'] }, TICKETS)).toBe( + '/x?status=New&status=Assigned&status=Pending' + ); + }); + + it('keeps the board a board, and clears the filters already up', () => { + expect(viewHref(page('?view=board&search=old'), { assigned_to: ['u1'] }, BOARD)).toBe( + '/x?view=board&assigned_to=u1' + ); + expect(viewHref(page('?search=old'), {}, BOARD)).toBe('/x'); + }); +}); + +describe('droppedValues', () => { + it('counts values, not keys', () => { + expect(droppedValues({ status: ['a', 'b', 'c'] }, LEADS)).toBe(0); + expect(droppedValues({ rating: ['HOT', 'COLD'] }, LEADS)).toBe(1); + expect(droppedValues({ next_follow_up: ['2026-01-02'], city: ['a', 'b'] }, LEADS)).toBe(3); + expect(droppedValues({}, LEADS)).toBe(0); + }); +}); + +describe('isShowing', () => { + it('matches the same filters in any order, ignoring non-filter params', () => { + const params = new URLSearchParams('status=in+process&status=assigned&limit=50'); + expect(isShowing(params, { status: ['assigned', 'in process'] }, LEADS)).toBe(true); + }); + + it('does not match a subset or a superset', () => { + const params = new URLSearchParams('status=assigned&tags=t1'); + expect(isShowing(params, { status: ['assigned'] }, LEADS)).toBe(false); + expect(isShowing(params, { status: ['assigned'], tags: ['t1'], search: ['x'] }, LEADS)).toBe( + false + ); + }); + + it('never marks a view that loses values here as the one showing', () => { + const params = new URLSearchParams('rating=HOT'); + expect(isShowing(params, { rating: ['HOT', 'COLD'] }, LEADS)).toBe(false); + }); + + it("reads the ticket queue's default and All the way they are saved", () => { + const open = { status: ['New', 'Assigned', 'Pending'] }; + expect(isShowing(new URLSearchParams(''), open, TICKETS)).toBe(true); + expect(isShowing(new URLSearchParams('all=1'), open, TICKETS)).toBe(false); + expect(isShowing(new URLSearchParams('all=1'), {}, TICKETS)).toBe(true); + expect(isShowing(new URLSearchParams(''), {}, TICKETS)).toBe(false); + }); +}); diff --git a/frontend/src/routes/(app)/+layout.server.js b/frontend/src/routes/(app)/+layout.server.js index 2be5864a..22ab41c6 100644 --- a/frontend/src/routes/(app)/+layout.server.js +++ b/frontend/src/routes/(app)/+layout.server.js @@ -1,3 +1,4 @@ +import { isOrgAdmin } from '$lib/admin.js'; import { listLeads } from '$lib/server/v2/leads.js'; import { listDeals } from '$lib/server/v2/deals.js'; import { listTickets, OPEN_STATUSES } from '$lib/server/v2/tickets.js'; @@ -137,11 +138,12 @@ export async function load(event) { // currency comes from. currency: /** @type {any} */ (event.locals).org_settings?.default_currency || 'USD' }, - // Server-derived from the JWT (never the client). Display-only: it lets the - // shell hide destinations a member can only reach to be turned away. The - // backend still enforces every one of those gates, so this is UX, not a - // security control. Defaults to the non-admin view when the claim is absent. - role: event.locals.profile?.role ?? 'USER' + // Server-derived from the JWT (never the client), and read only through + // `isOrgAdmin` (`$lib/admin.js`). Display-only: it lets the shell hide + // destinations a member can only reach to be turned away. The backend + // still enforces every one of those gates, so this is UX, not a security + // control. The non-admin view when the claim is absent. + is_organization_admin: isOrgAdmin(event.locals.profile) }; // countKeys' fetches and the terminology fetch are pushed into ONE diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index e5aedceb..f5f1c797 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -6,10 +6,11 @@ import { page } from '$app/state'; import { afterNavigate } from '$app/navigation'; import Sidebar from '$lib/v2/components/Sidebar.svelte'; + import { isOrgAdmin } from '$lib/admin.js'; import CommandPalette from '$lib/v2/components/CommandPalette.svelte'; import { Search, Sun, Columns3, LifeBuoy, Receipt, Plus, Menu } from '@lucide/svelte'; - /** @type {{ data: { counts: Record, org: { name: string, terminology?: Record | null }, role: string }, children: import('svelte').Snippet }} */ + /** @type {{ data: { counts: Record, org: { name: string, terminology?: Record | null }, is_organization_admin: boolean }, children: import('svelte').Snippet }} */ let { data, children } = $props(); let paletteOpen = $state(false); @@ -61,7 +62,7 @@ (paletteOpen = true)} /> @@ -138,7 +139,7 @@ { menuOpen = false; diff --git a/frontend/src/routes/(app)/accounts/+page.server.js b/frontend/src/routes/(app)/accounts/+page.server.js index 9b27f646..d6034bbf 100644 --- a/frontend/src/routes/(app)/accounts/+page.server.js +++ b/frontend/src/routes/(app)/accounts/+page.server.js @@ -2,6 +2,7 @@ import { listAccounts } from '$lib/server/v2/accounts.js'; import { accountListQuery } from '$lib/server/v2/list-queries.js'; import { getOrgPeopleAndTeams, resolveMe } from '$lib/server/v2/org-people.js'; import { getTags } from '$lib/server/v2/tags.js'; +import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'; /** * Only filters the API actually applies are forwarded. A parameter that @@ -22,12 +23,13 @@ import { getTags } from '$lib/server/v2/tags.js'; export async function load({ cookies, url, locals }) { const params = accountListQuery(url); - const [{ results, totals }, orgPeople, tagList] = await Promise.all([ + const [{ results, totals }, orgPeople, tagList, savedViews] = await Promise.all([ listAccounts({ cookies }, params), getOrgPeopleAndTeams(cookies), // A failed tag fetch should cost the Tag dropdown in the filter bar, not // the whole list. Same pattern as leads.js and pipeline's deals.js. - getTags({ cookies }).catch(() => ({ tags: [] })) + getTags({ cookies }).catch(() => ({ tags: [] })), + loadSavedViews({ cookies, url }, 'accounts') ]); return { @@ -35,6 +37,10 @@ export async function load({ cookies, url, locals }) { totals, people: orgPeople.people, tags: tagList.tags ?? [], + savedViews, meId: resolveMe(orgPeople.people, /** @type {any} */ (locals).user?.email) }; } + +/** The saved-views menu's save, rename and delete. */ +export const actions = savedViewActions('accounts'); diff --git a/frontend/src/routes/(app)/accounts/+page.svelte b/frontend/src/routes/(app)/accounts/+page.svelte index 50caa7a3..71febea3 100644 --- a/frontend/src/routes/(app)/accounts/+page.svelte +++ b/frontend/src/routes/(app)/accounts/+page.svelte @@ -68,6 +68,7 @@ people={data.people} tags={data.tags} meId={data.meId} + saved={data.savedViews} meta="Sorted by revenue won" /> diff --git a/frontend/src/routes/(app)/contacts/+page.server.js b/frontend/src/routes/(app)/contacts/+page.server.js index 713f35ee..0e77e8e6 100644 --- a/frontend/src/routes/(app)/contacts/+page.server.js +++ b/frontend/src/routes/(app)/contacts/+page.server.js @@ -3,6 +3,7 @@ import { contactListQuery } from '$lib/server/v2/list-queries.js'; import { getOrgPeopleAndTeams, resolveMe } from '$lib/server/v2/org-people.js'; import { getTags } from '$lib/server/v2/tags.js'; import { forwardCsvImport } from '$lib/server/v2/csv-import.js'; +import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'; /** * Only filters the API actually applies are forwarded. A parameter that @@ -24,12 +25,13 @@ export async function load({ cookies, url, locals }) { const params = contactListQuery(url); const includeInactive = url.searchParams.get('inactive') === '1'; - const [{ results, totals }, orgPeople, tagList] = await Promise.all([ + const [{ results, totals }, orgPeople, tagList, savedViews] = await Promise.all([ listContacts({ cookies }, params), getOrgPeopleAndTeams(cookies), // A failed tag fetch should cost the Tag dropdown in the filter bar, not // the whole list. Follows the tickets.js pattern; see the note there. - getTags({ cookies }).catch(() => ({ tags: [] })) + getTags({ cookies }).catch(() => ({ tags: [] })), + loadSavedViews({ cookies, url }, 'contacts') ]); return { @@ -39,12 +41,14 @@ export async function load({ cookies, url, locals }) { search: params.get('search') ?? '', people: orgPeople.people, tags: tagList.tags ?? [], + savedViews, meId: resolveMe(orgPeople.people, /** @type {any} */ (locals).user?.email) }; } -/** The CSV import drawer's two steps; see `forwardCsvImport`. */ +/** The CSV import drawer's two steps, and the saved-views menu. */ export const actions = { + ...savedViewActions('contacts'), importPreview: (event) => forwardCsvImport(event, '/contacts/import/preview/', 'importPreview'), importCommit: (event) => forwardCsvImport(event, '/contacts/import/commit/', 'importCommit') }; diff --git a/frontend/src/routes/(app)/contacts/+page.svelte b/frontend/src/routes/(app)/contacts/+page.svelte index abd9d69c..aa47ce9d 100644 --- a/frontend/src/routes/(app)/contacts/+page.svelte +++ b/frontend/src/routes/(app)/contacts/+page.svelte @@ -54,6 +54,7 @@ people={data.people} tags={data.tags} meId={data.meId} + saved={data.savedViews} meta="Most recently added first" /> diff --git a/frontend/src/routes/(app)/documents/[id]/edit/save-action.test.js b/frontend/src/routes/(app)/documents/[id]/edit/save-action.test.js index a21606c2..b7bfdaf4 100644 --- a/frontend/src/routes/(app)/documents/[id]/edit/save-action.test.js +++ b/frontend/src/routes/(app)/documents/[id]/edit/save-action.test.js @@ -24,7 +24,9 @@ function token(claims) { const cookies = { get: (/** @type {string} */ k) => - k === 'jwt_access' ? token({ role: 'ADMIN', user_id: 'u1' }) : undefined + k === 'jwt_access' + ? token({ role: 'ADMIN', is_organization_admin: true, user_id: 'u1' }) + : undefined }; /** @param {[string, string | File][]} entries */ diff --git a/frontend/src/routes/(app)/goals/[id]/edit/+page.server.js b/frontend/src/routes/(app)/goals/[id]/edit/+page.server.js index d52930a3..7c40c6fb 100644 --- a/frontend/src/routes/(app)/goals/[id]/edit/+page.server.js +++ b/frontend/src/routes/(app)/goals/[id]/edit/+page.server.js @@ -23,7 +23,7 @@ export async function load(event) { return await getGoalForEdit(event, event.params.id); } catch (/** @type {any} */ err) { if (err?.status === 404) { - error(404, 'That goal does not exist, or it belongs to another org.'); + error(404, 'That goal does not exist, or you cannot see it.'); } throw err; } diff --git a/frontend/src/routes/(app)/invoices/+page.server.js b/frontend/src/routes/(app)/invoices/+page.server.js index 743327e6..9637f605 100644 --- a/frontend/src/routes/(app)/invoices/+page.server.js +++ b/frontend/src/routes/(app)/invoices/+page.server.js @@ -4,6 +4,7 @@ import { invoiceListQuery } from '$lib/server/v2/list-queries.js'; import { listAccountsPicker } from '$lib/server/v2/accounts.js'; import { getOrgPeopleAndTeams, resolveMe } from '$lib/server/v2/org-people.js'; import { readableError } from '$lib/server/v2/form-errors.js'; +import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'; /** * The invoice list. @@ -27,12 +28,13 @@ import { readableError } from '$lib/server/v2/form-errors.js'; export async function load({ cookies, url, locals }) { const params = invoiceListQuery(url); - const [{ invoices, totals }, orgPeople, accountList] = await Promise.all([ + const [{ invoices, totals }, orgPeople, accountList, savedViews] = await Promise.all([ listInvoices({ cookies }, params), getOrgPeopleAndTeams(cookies), // A failed account fetch should cost the Account dropdown in the filter // bar, not the whole list. Same pattern as the Tag fetch in leads.js. - listAccountsPicker({ cookies }).catch(() => ({ accounts: [] })) + listAccountsPicker({ cookies }).catch(() => ({ accounts: [] })), + loadSavedViews({ cookies, url }, 'invoices') ]); return { @@ -40,12 +42,14 @@ export async function load({ cookies, url, locals }) { totals, people: orgPeople.people, accounts: accountList.accounts ?? [], + savedViews, meId: resolveMe(orgPeople.people, /** @type {any} */ (locals).user?.email) }; } /** @type {import('./$types').Actions} */ export const actions = { + ...savedViewActions('invoices'), /** * Send a draft, or re-send an overdue invoice as a reminder, the same POST * either way. The mock's inline "Send" / "Send a reminder" buttons did @@ -60,11 +64,8 @@ export const actions = { try { await sendInvoice({ cookies }, id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'That invoice is not yours to send.' - : readableError(err, 'Could not send that invoice.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not send that invoice.') }); } return { sent: id }; diff --git a/frontend/src/routes/(app)/invoices/+page.svelte b/frontend/src/routes/(app)/invoices/+page.svelte index dfe529f9..1bf1481d 100644 --- a/frontend/src/routes/(app)/invoices/+page.svelte +++ b/frontend/src/routes/(app)/invoices/+page.svelte @@ -113,6 +113,7 @@ people={data.people} accounts={data.accounts} meId={data.meId} + saved={data.savedViews} meta="Oldest due first" /> diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.server.js b/frontend/src/routes/(app)/invoices/[id]/+page.server.js index 51892f84..da8bb650 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.server.js +++ b/frontend/src/routes/(app)/invoices/[id]/+page.server.js @@ -24,11 +24,8 @@ export const actions = { try { await sendInvoice({ cookies }, params.id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This invoice is not yours to send.' - : readableError(err, 'Could not send this invoice.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not send this invoice.') }); } return { sent: true }; @@ -49,11 +46,8 @@ export const actions = { amount ? { amount, reference_number: reference } : { reference_number: reference } ); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This invoice is not yours to settle.' - : readableError(err, 'Could not record that payment.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not record that payment.') }); } return { paid: true }; @@ -64,11 +58,8 @@ export const actions = { try { await cancelInvoice({ cookies }, params.id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This invoice is not yours to cancel.' - : readableError(err, 'Could not cancel this invoice.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not cancel this invoice.') }); } return { cancelled: true }; @@ -83,11 +74,8 @@ export const actions = { try { created = await duplicateInvoice({ cookies }, params.id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This invoice is not yours to duplicate.' - : readableError(err, 'Could not duplicate this invoice.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not duplicate this invoice.') }); } const newId = created?.invoice?.id; diff --git a/frontend/src/routes/(app)/invoices/[id]/pdf/+server.js b/frontend/src/routes/(app)/invoices/[id]/pdf/+server.js index 75004e9c..50576a78 100644 --- a/frontend/src/routes/(app)/invoices/[id]/pdf/+server.js +++ b/frontend/src/routes/(app)/invoices/[id]/pdf/+server.js @@ -25,7 +25,7 @@ export async function GET({ params, cookies }) { // 403/404 collapse to 404: do not confirm an invoice exists to someone who // may not read it. 503 means WeasyPrint is missing on the server. if (upstream.status === 403 || upstream.status === 404) { - error(404, 'That invoice does not exist, or it belongs to another team.'); + error(404, 'That invoice does not exist, or you do not have access to it.'); } error(502, 'Could not generate the PDF.'); } diff --git a/frontend/src/routes/(app)/invoices/estimates/+page.server.js b/frontend/src/routes/(app)/invoices/estimates/+page.server.js index 4fb69ef9..03f34052 100644 --- a/frontend/src/routes/(app)/invoices/estimates/+page.server.js +++ b/frontend/src/routes/(app)/invoices/estimates/+page.server.js @@ -38,7 +38,8 @@ export const actions = { /** * Raise an invoice from an accepted estimate and open the new draft. The id * comes from the row, and the API decides whether this caller may convert it - *: a member who is neither creator nor assignee gets 403, and a second + *: a member who is neither creator nor assignee gets the same 404 as a + * missing estimate, and a second * conversion gets 400. */ convert: async ({ cookies, request }) => { @@ -50,11 +51,8 @@ export const actions = { try { created = await convertEstimate({ cookies }, id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This estimate is not yours to convert.' - : readableError(err, 'Could not raise an invoice from this estimate.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not raise an invoice from this estimate.') }); } @@ -75,11 +73,8 @@ export const actions = { try { await sendEstimate({ cookies }, id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This estimate is not yours to send.' - : readableError(err, 'Could not send this estimate.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not send this estimate.') }); } return { sent: true }; diff --git a/frontend/src/routes/(app)/invoices/estimates/new/+page.svelte b/frontend/src/routes/(app)/invoices/estimates/new/+page.svelte index 5ff0ce58..661c2b17 100644 --- a/frontend/src/routes/(app)/invoices/estimates/new/+page.svelte +++ b/frontend/src/routes/(app)/invoices/estimates/new/+page.svelte @@ -26,7 +26,16 @@ import PortalLineItems from '$lib/v2/components/PortalLineItems.svelte'; import LineItemsEditor from '$lib/v2/components/LineItemsEditor.svelte'; import { CURRENCY_CODES } from '$lib/constants/filters.js'; - import { blankLine, documentTotals, lineTotals, linePayload, num } from '$lib/v2/line-items.js'; + import { + blankLine, + documentDiscountError, + documentTotals, + lineDiscountError, + lineTotals, + linePayload, + num, + taxRateError + } from '$lib/v2/line-items.js'; /** @type {{ data: any, form: any }} */ let { data, form } = $props(); @@ -97,12 +106,21 @@ Boolean(expiryDate) && Boolean(issueDate) && expiryDate < issueDate ); + /* The API's discount bounds, checked before it is asked (it refuses them too). */ + let discountError = $derived(documentDiscountError(discountType, discountValue, totals.subtotal)); + let taxError = $derived(taxRateError(taxRate)); + /* Only a line started from the deal carries a discount of its own. */ + let linesOk = $derived(!usableLines.some(lineDiscountError)); + let ready = $derived( Boolean(accountId) && Boolean(contactId) && Boolean(title.trim()) && usableLines.length > 0 && - !datesBackwards + !datesBackwards && + !discountError && + !taxError && + linesOk ); /** The builder as the API body; only what `EstimateCreateSerializer` accepts. */ @@ -244,12 +262,32 @@ {#if discountType} {/if}

    Tax applies to the subtotal after the discount.

    @@ -360,6 +398,12 @@ font-family: var(--v2-mono); font-size: 12.5px; } + .field-err { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--v2-clay); + } .hint { margin: 10px 0 0; font-size: 11.5px; diff --git a/frontend/src/routes/(app)/invoices/new/+page.svelte b/frontend/src/routes/(app)/invoices/new/+page.svelte index c46dd2e9..66056a25 100644 --- a/frontend/src/routes/(app)/invoices/new/+page.svelte +++ b/frontend/src/routes/(app)/invoices/new/+page.svelte @@ -40,7 +40,16 @@ import { PAYMENT_TERMS_LABEL } from '$lib/v2/enums.js'; import { CURRENCY_CODES } from '$lib/constants/filters.js'; import { longDate } from '$lib/v2/format.js'; - import { blankLine, documentTotals, lineTotals, linePayload, num } from '$lib/v2/line-items.js'; + import { + blankLine, + documentDiscountError, + documentTotals, + lineTotals, + linePayload, + num, + shippingError, + taxRateError + } from '$lib/v2/line-items.js'; import { Info } from '@lucide/svelte'; /** @type {{ data: { products: any[], accounts: any[], contacts: any[], org: { currency: string } }, form: any }} */ @@ -105,8 +114,19 @@ let customFallsBack = $derived(paymentTerms === 'CUSTOM' && !customDueDate); + /* The API's discount bounds, checked before it is asked (it refuses them too). */ + let discountError = $derived(documentDiscountError(discountType, discountValue, totals.subtotal)); + let taxError = $derived(taxRateError(taxRate)); + let shipError = $derived(shippingError(shipping)); + let ready = $derived( - Boolean(accountId) && Boolean(contactId) && Boolean(title.trim()) && usableLines.length > 0 + Boolean(accountId) && + Boolean(contactId) && + Boolean(title.trim()) && + usableLines.length > 0 && + !discountError && + !taxError && + !shipError ); /** @@ -263,16 +283,45 @@ {#if discountType} {/if}

    @@ -395,6 +444,12 @@ font-size: 12.5px; } + .field-err { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--v2-clay); + } .hint { margin: 10px 0 0; font-size: 11.5px; diff --git a/frontend/src/routes/(app)/invoices/recurring/+page.server.js b/frontend/src/routes/(app)/invoices/recurring/+page.server.js index b8d66f03..670b67bc 100644 --- a/frontend/src/routes/(app)/invoices/recurring/+page.server.js +++ b/frontend/src/routes/(app)/invoices/recurring/+page.server.js @@ -23,7 +23,7 @@ export const actions = { /** * Pause a live schedule or resume a paused one. The id comes from the row, and * the API decides whether this caller may toggle it. A member who is neither - * creator nor assignee gets 403. + * creator nor assignee gets the same 404 as a missing schedule. */ toggle: async ({ cookies, request }) => { const form = await request.formData(); @@ -33,11 +33,8 @@ export const actions = { try { await toggleRecurring({ cookies }, id); } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { - error: - err?.status === 403 - ? 'This schedule is not yours to pause or resume.' - : readableError(err, 'Could not change this schedule.') + return fail(err?.status === 404 ? 404 : 400, { + error: readableError(err, 'Could not change this schedule.') }); } return { toggled: true }; diff --git a/frontend/src/routes/(app)/invoices/recurring/new/+page.svelte b/frontend/src/routes/(app)/invoices/recurring/new/+page.svelte index afe2303b..48261804 100644 --- a/frontend/src/routes/(app)/invoices/recurring/new/+page.svelte +++ b/frontend/src/routes/(app)/invoices/recurring/new/+page.svelte @@ -6,12 +6,16 @@ * Same shape as the one-off invoice builder (`invoices/new`): three FK * pickers, an optional line-item list, and the whole form serialised into one * hidden `payload` field so the dynamic list survives the POST intact. Reused - * directly: the account/contact filtering rule and the line-item row markup. + * directly: the account/contact filtering rule, the line-item card + * (`LineItemsEditor`) and the totals ladder and payload (`$lib/v2/line-items.js`), + * so a schedule's lines are counted and sent exactly as an invoice's are. * * WHAT IS DIFFERENT FROM THE INVOICE BUILDER * A schedule has no line-item requirement: `RecurringInvoiceCreateSerializer` * lists `line_items` as optional, because a schedule can exist before anyone - * has priced it out, so `ready` below never checks `usableLines.length`. + * has priced it out. The one exception is auto-send: the API refuses a + * schedule that would mail the client a blank invoice, and `ready` below + * asks for a line only then. * * `org`, `created_by`, `subtotal`, `total_amount` and `invoices_generated` are * absent for the same reason they are absent from the invoice builder: they @@ -34,9 +38,17 @@ import PageHeader from '$lib/v2/components/PageHeader.svelte'; import SectionTabs from '$lib/v2/components/SectionTabs.svelte'; import PortalLineItems from '$lib/v2/components/PortalLineItems.svelte'; + import LineItemsEditor from '$lib/v2/components/LineItemsEditor.svelte'; import { RECURRING_FREQUENCY_LABEL, PAYMENT_TERMS_LABEL } from '$lib/v2/enums.js'; - import { money } from '$lib/v2/format.js'; - import { Plus, Trash2 } from '@lucide/svelte'; + import { + blankLine, + documentDiscountError, + documentTotals, + lineTotals, + linePayload, + num, + taxRateError + } from '$lib/v2/line-items.js'; /** @type {{ data: { products: any[], accounts: any[], contacts: any[] }, form: any }} */ let { data, form } = $props(); @@ -86,7 +98,7 @@ let notes = $state(''); let terms = $state(''); - let items = $state([{ name: '', description: '', quantity: 1, unit_price: 0, product: null }]); + let items = $state([blankLine()]); /** * Contacts whose primary account is this account, plus contacts with no @@ -109,35 +121,29 @@ data.contacts.filter((c) => !c.account_id || c.account_id === accountId) ); - const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); - - /** Per-line amount, matching RecurringInvoiceLineItem.net_amount: this form - sets no line discount, so it is quantity x unit_price. */ - let lines = $derived(items.map((i) => ({ ...i, amount: num(i.quantity) * num(i.unit_price) }))); - - /* The same ladder the serializer's _recalculate_totals runs, in order. There - is no shipping field on a recurring schedule, unlike the one-off invoice. */ - let subtotal = $derived(lines.reduce((a, l) => a + l.amount, 0)); - let discountAmount = $derived( - discountType === 'PERCENTAGE' - ? subtotal * (num(discountValue) / 100) - : discountType === 'FIXED' - ? num(discountValue) - : 0 + /* The invoice builder's lines and ladder, the same one the serializer's + _recalculate_totals runs. There is no shipping on a recurring schedule, + unlike the one-off invoice. */ + let usableLines = $derived(lineTotals(items).usable); + let totals = $derived( + documentTotals({ lines: usableLines, discountType, discountValue, taxRate }) ); - let taxable = $derived(subtotal - discountAmount); - let taxAmount = $derived(taxable * (num(taxRate) / 100)); - let total = $derived(taxable + taxAmount); - /** A line with a name and a positive amount is a line worth billing. */ - let usableLines = $derived(lines.filter((l) => l.name.trim() && l.amount > 0)); + /* The API's discount bounds, checked before it is asked (it refuses them too). */ + let discountError = $derived(documentDiscountError(discountType, discountValue, totals.subtotal)); + let taxError = $derived(taxRateError(taxRate)); + + /* Lines are optional, except on a schedule that mails what it raises. */ + let needsLine = $derived(autoSend && !usableLines.length); - /** Line items are optional on a schedule, so this never checks their count. */ let ready = $derived( Boolean(accountId) && Boolean(contactId) && Boolean(title.trim()) && - (frequency !== 'CUSTOM' || Boolean(customDays)) + (frequency !== 'CUSTOM' || Boolean(customDays)) && + !needsLine && + !discountError && + !taxError ); /** @@ -168,42 +174,9 @@ if (num(taxRate)) body.tax_rate = num(taxRate); if (notes.trim()) body.notes = notes.trim(); if (terms.trim()) body.terms = terms.trim(); - if (usableLines.length) { - body.line_items = usableLines.map((l) => { - /** @type {Record} */ - const row = { - name: l.name.trim(), - description: (l.description || '').trim(), - quantity: num(l.quantity), - unit_price: num(l.unit_price) - }; - if (l.product) row.product = l.product; - return row; - }); - } + if (usableLines.length) body.line_items = linePayload(usableLines); return body; }); - - function addLine() { - items.push({ name: '', description: '', quantity: 1, unit_price: 0, product: null }); - } - - function addProduct(id) { - const p = data.products.find((x) => x.id === id); - if (!p) return; - items.push({ - name: p.name, - description: p.sku, - quantity: 1, - unit_price: p.price, - product: p.id - }); - } - - function removeLine(i) { - items.splice(i, 1); - if (!items.length) addLine(); - } @@ -331,62 +304,13 @@ off leaves a draft for you to review and send + {#if needsLine} +

    + {/if} -
    -
    -
    Lines (optional)
    - -
    - - {#each items as item, i (i)} -
    -
    - - -
    - - -
    - {money(num(item.quantity) * num(item.unit_price), currency)} -
    - -
    - {/each} - - -
    + +
    Adjustments
    @@ -402,12 +326,32 @@ {#if discountType} {/if}

    @@ -436,18 +380,19 @@ {:else}

    - Lines are optional here. Add one to preview what each generated invoice will total, - or save the schedule without pricing it yet. + Lines are optional here unless each invoice is sent automatically. Add one to + preview what each generated invoice will total, or save the schedule without pricing + it yet.

    {/if} @@ -541,57 +486,12 @@ color: var(--v2-slate); } - .catalogue { - width: auto; - min-width: 0; - margin-left: auto; - font-size: 12px; - padding: 5px 8px; - } - - .line { - display: grid; - grid-template-columns: 72px 116px 1fr 26px; - gap: 9px; - align-items: end; - padding: 9px 0; - border-bottom: 1px solid var(--v2-line-soft); - } - .line-main { - grid-column: 1 / -1; - min-width: 0; - } - .line-desc { - margin-top: 5px; - font-size: 12px; - color: var(--v2-slate); - } - .line-n > span { + .field-err { display: block; - font-size: 10px; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--v2-slate); - margin-bottom: 3px; + margin-top: 4px; + font-size: 12px; + color: var(--v2-clay); } - .line-total { - font-size: 13px; - font-weight: 600; - text-align: right; - min-width: 74px; - padding-bottom: 8px; - } - .line-del { - background: none; - border: 0; - padding: 0 0 9px; - color: var(--v2-slate); - cursor: pointer; - } - .line-del:hover { - color: var(--v2-rust); - } - .hint { margin: 10px 0 0; font-size: 11.5px; @@ -635,17 +535,7 @@ margin: 0; } - @media (max-width: 768px) { - .line-del { - min-width: 40px; - min-height: 40px; - padding: 0 0 9px; - } - } @media (max-width: 560px) { - .line { - grid-template-columns: 1fr 1fr auto 40px; - } .grid2 { grid-template-columns: 1fr; } diff --git a/frontend/src/routes/(app)/invoices/templates/+page.server.js b/frontend/src/routes/(app)/invoices/templates/+page.server.js index edd7c0f6..21119447 100644 --- a/frontend/src/routes/(app)/invoices/templates/+page.server.js +++ b/frontend/src/routes/(app)/invoices/templates/+page.server.js @@ -1,6 +1,7 @@ import { fail } from '@sveltejs/kit'; import { listInvoiceTemplates, setDefaultTemplate } from '$lib/server/v2/templates.js'; import { readableError } from '$lib/server/v2/form-errors.js'; +import { isOrgAdmin } from '$lib/admin.js'; /** * The template catalogue. `load` returns `{ templates, totals }`. The names the @@ -14,7 +15,7 @@ export async function load({ cookies, locals }) { const data = await listInvoiceTemplates({ cookies }); return { ...data, - can_manage: /** @type {any} */ (locals)?.profile?.role === 'ADMIN' + can_manage: isOrgAdmin(/** @type {any} */ (locals).profile) }; } diff --git a/frontend/src/routes/(app)/invoices/templates/new/+page.server.js b/frontend/src/routes/(app)/invoices/templates/new/+page.server.js index 95fd743b..2297b64e 100644 --- a/frontend/src/routes/(app)/invoices/templates/new/+page.server.js +++ b/frontend/src/routes/(app)/invoices/templates/new/+page.server.js @@ -1,13 +1,14 @@ import { fail, redirect } from '@sveltejs/kit'; import { createInvoiceTemplate } from '$lib/server/v2/templates.js'; import { readableError } from '$lib/server/v2/form-errors.js'; +import { isOrgAdmin } from '$lib/admin.js'; /** * Creating a template. * * Admin-only: `POST /api/invoices/templates/` refuses a non-admin via * `_forbid_non_admin_template`, so this mirrors the products/new and - * goals/new pattern: `load` computes `can_manage` from `locals.profile.role` + * goals/new pattern: `load` computes `can_manage` with `isOrgAdmin(locals.profile)` * (same source the list page's `load` already uses) so a non-admin who * navigates here directly sees an "admins only" state instead of a form the * POST would refuse. The list page's own button is gated the same way. @@ -15,7 +16,7 @@ import { readableError } from '$lib/server/v2/form-errors.js'; * @type {import('./$types').PageServerLoad} */ export function load({ locals }) { - return { can_manage: /** @type {any} */ (locals)?.profile?.role === 'ADMIN' }; + return { can_manage: isOrgAdmin(/** @type {any} */ (locals).profile) }; } /** diff --git a/frontend/src/routes/(app)/leads/+page.server.js b/frontend/src/routes/(app)/leads/+page.server.js index c8ad7437..71150fc8 100644 --- a/frontend/src/routes/(app)/leads/+page.server.js +++ b/frontend/src/routes/(app)/leads/+page.server.js @@ -3,6 +3,7 @@ import { leadListQuery } from '$lib/server/v2/list-queries.js'; import { getOrgPeopleAndTeams, resolveMe } from '$lib/server/v2/org-people.js'; import { getTags } from '$lib/server/v2/tags.js'; import { forwardCsvImport } from '$lib/server/v2/csv-import.js'; +import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'; /** * Server load, not a universal one. The access token is an httpOnly cookie; @@ -21,12 +22,13 @@ import { forwardCsvImport } from '$lib/server/v2/csv-import.js'; export async function load({ cookies, url, locals }) { const params = leadListQuery(url); - const [{ results, totals }, orgPeople, tagList] = await Promise.all([ + const [{ results, totals }, orgPeople, tagList, savedViews] = await Promise.all([ listLeads({ cookies }, params), getOrgPeopleAndTeams(cookies), // A failed tag fetch should cost the Tag dropdown in the filter bar, not // the whole list. Follows the tickets.js pattern; see the note there. - getTags({ cookies }).catch(() => ({ tags: [] })) + getTags({ cookies }).catch(() => ({ tags: [] })), + loadSavedViews({ cookies, url }, 'leads') ]); return { @@ -36,12 +38,14 @@ export async function load({ cookies, url, locals }) { totals, people: orgPeople.people, tags: tagList.tags ?? [], + savedViews, meId: resolveMe(orgPeople.people, /** @type {any} */ (locals).user?.email) }; } -/** The CSV import drawer's two steps; see `forwardCsvImport`. */ +/** The CSV import drawer's two steps, and the saved-views menu. */ export const actions = { + ...savedViewActions('leads'), importPreview: (event) => forwardCsvImport(event, '/leads/import/preview/', 'importPreview'), importCommit: (event) => forwardCsvImport(event, '/leads/import/commit/', 'importCommit') }; diff --git a/frontend/src/routes/(app)/leads/+page.svelte b/frontend/src/routes/(app)/leads/+page.svelte index bbe4e171..c5f385c3 100644 --- a/frontend/src/routes/(app)/leads/+page.svelte +++ b/frontend/src/routes/(app)/leads/+page.svelte @@ -65,6 +65,7 @@ people={data.people} tags={data.tags} meId={data.meId} + saved={data.savedViews} meta="Least recently touched first" /> diff --git a/frontend/src/routes/(app)/leads/[id]/+page.server.js b/frontend/src/routes/(app)/leads/[id]/+page.server.js index 068570f3..174f1a94 100644 --- a/frontend/src/routes/(app)/leads/[id]/+page.server.js +++ b/frontend/src/routes/(app)/leads/[id]/+page.server.js @@ -60,7 +60,8 @@ export const actions = { opportunity_id: result?.opportunity_id ?? null }; } catch (/** @type {any} */ err) { - return fail(err?.status === 403 ? 403 : 400, { + // A lead this profile may not open is a 404, the same as a missing one. + return fail(err?.status === 404 ? 404 : 400, { error: readableError(err, 'Could not convert this lead.') }); } diff --git a/frontend/src/routes/(app)/pipeline/+page.server.js b/frontend/src/routes/(app)/pipeline/+page.server.js index f941bcab..46ebc1c0 100644 --- a/frontend/src/routes/(app)/pipeline/+page.server.js +++ b/frontend/src/routes/(app)/pipeline/+page.server.js @@ -11,6 +11,7 @@ import { readableError } from '$lib/server/v2/form-errors.js'; import { dealListQuery } from '$lib/server/v2/list-queries.js'; import { getOrgPeopleAndTeams, resolveMe } from '$lib/server/v2/org-people.js'; import { getTags } from '$lib/server/v2/tags.js'; +import { loadSavedViews, savedViewActions } from '$lib/server/v2/saved-views.js'; /** * The list and the board are two different queries, not two renderings of one @@ -39,12 +40,13 @@ import { getTags } from '$lib/server/v2/tags.js'; export async function load(event) { const { cookies, url, locals } = event; - const [orgPeople, tagList, pipelines] = await Promise.all([ + const [orgPeople, tagList, pipelines, savedViews] = await Promise.all([ getOrgPeopleAndTeams(cookies), // A failed tag fetch should cost the Tag dropdown in the filter bar, not // the whole pipeline. Same pattern as tickets.js and leads.js. getTags({ cookies }).catch(() => ({ tags: [] })), - listPipelines(cookies) + listPipelines(cookies), + loadSavedViews({ cookies, url }, 'opportunities') ]); // `?pipeline=` picks one pipeline, on both views. Only an id the org @@ -61,6 +63,7 @@ export async function load(event) { pipelineId: boardMode ? (boardPipeline?.id ?? null) : (picked?.id ?? null), people: orgPeople.people, tags: tagList.tags ?? [], + savedViews, meId: resolveMe(orgPeople.people, /** @type {any} */ (locals).user?.email), // Handed to the page even in list mode, so the List<->Board toggle can // build a board-safe link without a `.svelte` file importing anything @@ -108,14 +111,15 @@ export async function load(event) { } export const actions = { + ...savedViewActions('opportunities'), /** * Move a card to another lane, or to a position inside one. * * The board reorders optimistically and calls this without waiting, so the * only job here is to persist and to report a refusal in words the card can - * show while it snaps back. A 403 stays a 403: the move endpoint refuses a - * deal the caller neither created nor is assigned to, and flattening that to - * 400 would read as bad input rather than as a permission answer. + * show while it snaps back. A 404 stays a 404: the move endpoint answers a + * deal the caller neither created nor is assigned to exactly as a missing + * one, and flattening that to 400 would read as bad input. */ move: async ({ request, cookies }) => { const form = await request.formData(); @@ -128,7 +132,7 @@ export const actions = { await moveDeal({ cookies }, id, { columnId, aboveId, belowId }); return { success: true }; } catch (err) { - const status = /** @type {any} */ (err)?.status === 403 ? 403 : 400; + const status = /** @type {any} */ (err)?.status === 404 ? 404 : 400; return fail(status, { error: readableError(err, 'Could not move the deal.') }); } } diff --git a/frontend/src/routes/(app)/pipeline/+page.svelte b/frontend/src/routes/(app)/pipeline/+page.svelte index 2737f692..9419ac42 100644 --- a/frontend/src/routes/(app)/pipeline/+page.svelte +++ b/frontend/src/routes/(app)/pipeline/+page.svelte @@ -11,9 +11,10 @@ import PipelineSwitcher from '$lib/v2/components/PipelineSwitcher.svelte'; import EmptyState from '$lib/v2/components/EmptyState.svelte'; import { money, moneyEach, sumByCurrency, count, shortDate } from '$lib/v2/format.js'; + import { dealNextStep } from '$lib/v2/deal-next-step.js'; import { AGING_TONE, AGING_LABEL } from '$lib/v2/enums.js'; import { activeChips, activePresetKey, withoutParam } from '$lib/v2/filters.js'; - import { Columns3, Download, List, Plus, TriangleAlert } from '@lucide/svelte'; + import { CalendarClock, Columns3, Download, List, Plus, TriangleAlert } from '@lucide/svelte'; import { flip } from 'svelte/animate'; import { dndzone } from 'svelte-dnd-action'; import { invalidateAll } from '$app/navigation'; @@ -235,6 +236,7 @@ tags={data.tags} stages={stageOptions} meId={data.meId} + saved={data.savedViews} onlyFields={data.onlyFields} onlyPresets={data.onlyPresets} meta={view === 'board' ? 'Open stages only. Drag a card to change its stage' : 'Sorted by value'} @@ -276,6 +278,7 @@ onfinalize={(e) => onFinalize(lane, e)} > {#each lane.rows as d (d.id)} + {@const step = dealNextStep(d)} @@ -286,6 +289,12 @@ >{d.name}
    {d.account.name}
    + {#if step} +
    +
    + {/if}
    {AGING_LABEL[d.aging_status] + @@ -329,6 +338,7 @@ Deal Stage Health + Next step Value Closing In stage @@ -337,6 +347,7 @@ {#each deals as d (d.id)} + {@const step = dealNextStep(d)} @@ -355,6 +366,14 @@ {AGING_LABEL[d.aging_status]} + + {#if step} + + + {/if} + {money(d.amount, d.currency)} {shortDate(d.closed_on)} + {#if canReply && ticket.is_open}
    {#if ticket.status !== 'Pending'} @@ -281,7 +305,7 @@ {#if data.close && !closePanel} {/if} - {:else} + {:else if canReply}
    @@ -334,6 +358,12 @@

    {/if} + {#if form?.detached} +

    + {form.detached} +

    + {/if} + {#if data.merge.open}
    @@ -552,6 +582,189 @@
    {/if} + + {#if inTree || data.link.open} +
    + + + {#if ticket.parent} +
    +
    +
    Parent
    + {#if ticket.parent.restricted} + {ticket.parent.name} + {:else} + {ticket.parent.name} + {/if} +
    + {#if ticket.parent.status} + {ticket.parent.status} + {/if} + {#if canReply && !confirmDetach} + + {/if} +
    + {#if confirmDetach} +
    + async ({ update }) => { + await update(); + confirmDetach = false; + }} + class="merge-confirm" + > +

    + This ticket will no longer sit under {ticket.parent.restricted + ? 'its parent' + : `"${ticket.parent.name}"`}. Neither ticket is otherwise changed. +

    +
    + + +
    +
    + {/if} + {/if} + + {#if data.tree && data.tree.rows.length > 1} +
      + {#each data.tree.rows as row (row.id)} +
    • + {#if row.restricted || row.focus} + {row.name} + {:else} + {row.name} + {/if} + {#if row.status} + {row.status} + {/if} +
    • + {#if row.truncated} +
    • + More tickets further down are not shown. +
    • + {/if} + {/each} +
    + {:else if ticket.child_count > 0} +

    + {ticket.child_count} linked {ticket.child_count === 1 ? 'ticket' : 'tickets'} under this + one. The tree could not be loaded. +

    + {:else if !ticket.parent} +

    + Not linked to another ticket yet. +

    + {/if} + + {#if data.link.open} +
    +
    + Link {ticket.name} under a parent +
    + + + {#if data.link.error} +

    {data.link.error}

    + {:else if data.link.candidates.length === 0} +

    + {data.link.q + ? 'No ticket you could link this under matches that search.' + : 'There is no ticket you could link this under.'} +

    + {:else} +
      + {#each data.link.candidates as candidate (candidate.id)} +
    • + +
    • + {/each} +
    + {/if} + + {#if linkTarget} +
    + async ({ update }) => { + await update(); + linkTarget = null; + }} + class="merge-confirm" + > + +

    + "{ticket.name}" will sit under "{linkTarget.name}"{ticket.parent + ? ', instead of its current parent' + : ''}. A tree is at most three levels deep. +

    +
    + + +
    +
    + {/if} +
    + {/if} +
    + {/if} +
    @@ -596,60 +812,67 @@ -
    - Log time -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -

    - Counted back from now. To record a session from an earlier day, start and stop the - timer on it. -

    -
    -
    + {#if canReply} +
    + Log time +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +

    + Counted back from now. To record a session from an earlier day, start and stop + the timer on it. +

    +
    +
    + {/if}
    {#if form?.timeError} @@ -667,7 +890,9 @@

    {:else if entries.length === 0}

    - No time logged yet. Start the timer, or log a session you have already worked. + {canReply + ? 'No time logged yet. Start the timer, or log a session you have already worked.' + : 'No time logged yet.'}

    {:else}
    @@ -1187,6 +1412,63 @@ margin-top: 12px; } + /* Parent and child tickets. Rows indent by depth, which `/tree/` caps at + three levels, so the deepest row starts 42px in and still fits 390px. */ + .tree-panel { + padding: 13px 15px; + margin-bottom: 18px; + } + .tree-head { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + .tree-parent { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-top: 10px; + } + .tree-list { + list-style: none; + margin: 12px 0 0; + padding: 10px 0 0; + border-top: 1px solid var(--v2-line); + display: flex; + flex-direction: column; + gap: 4px; + } + .tree-row { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + padding-left: calc(var(--depth, 0) * 14px); + } + .tree-name { + min-width: 0; + font-size: 12.5px; + font-weight: 550; + color: inherit; + overflow-wrap: anywhere; + } + .tree-focus .tree-name { + font-weight: 700; + } + .tree-restricted { + font-style: italic; + font-weight: 450; + color: var(--v2-slate); + } + .tree-picker { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--v2-line); + } + /* Identity mark for a ticket that has no account to show a face for. */ .ticket-glyph { display: grid; @@ -1347,7 +1629,9 @@ @media (max-width: 768px) { .merge-panel .v2-btn, .merge-search .v2-input, - .merge-option { + .merge-option, + .tree-panel .v2-btn, + .tree-row { min-height: 44px; } /* One field per line, and both controls full width: two half-width diff --git a/frontend/src/routes/(app)/tickets/[id]/close.js b/frontend/src/routes/(app)/tickets/[id]/close.js index 9b9493de..9c85a46a 100644 --- a/frontend/src/routes/(app)/tickets/[id]/close.js +++ b/frontend/src/routes/(app)/tickets/[id]/close.js @@ -20,15 +20,16 @@ * [openDescendants] mirrors `_open_descendants` in `cases/parent_views.py` * exactly, including the two things that are easy to get wrong: * - * - a node counts only when it is open AND active, and + * - a node counts only when it is open (neither Closed nor Duplicate) AND + * active, since a merged ticket's status is not the cascade's to change, and * - recursion goes through closed nodes anyway, so an open grandchild under a * closed child is still cascaded. */ import { RESTRICTED_TICKET_NAME } from '$lib/v2/enums.js'; -/** A ticket closes to any status but this one. */ -const CLOSED = 'Closed'; +/** Statuses a cascading close leaves alone: already closed, or merged away. */ +const NOT_OPEN = ['Closed', 'Duplicate']; /** * What a ticket the viewer may not open is called in the list. @@ -72,9 +73,9 @@ export function openDescendants(root, id) { /** @param {any} node */ const walk = (node) => { for (const child of node.children ?? []) { - // Open AND active, matching `_open_descendants`. An inactive row is a - // merged duplicate and the backend skips it. - if (child.status !== CLOSED && child.is_active !== false) { + // Open AND active, matching `_open_descendants`: a Duplicate child was + // merged away and an inactive one deleted, and the backend skips both. + if (!NOT_OPEN.includes(child.status) && child.is_active !== false) { out.push({ id: child.id, name: child.restricted ? RESTRICTED_NAME : child.name, diff --git a/frontend/src/routes/(app)/tickets/[id]/close.test.js b/frontend/src/routes/(app)/tickets/[id]/close.test.js index b688c0f3..be92086b 100644 --- a/frontend/src/routes/(app)/tickets/[id]/close.test.js +++ b/frontend/src/routes/(app)/tickets/[id]/close.test.js @@ -85,15 +85,27 @@ describe('openDescendants', () => { }); it('leaves an inactive ticket out, matching the backend', () => { - // An inactive row is a merged duplicate; the backend's `is_active` check - // skips it. + // An inactive row was deleted; the backend's `is_active` check skips it. const tree = node({ id: 'p', - children: [node({ id: 'merged', is_active: false }), node({ id: 'real' })] + children: [node({ id: 'deleted', is_active: false }), node({ id: 'real' })] }); expect(openDescendants(tree, 'p').map((d) => d.id)).toEqual(['real']); }); + it('leaves a merged (Duplicate) ticket out, but still walks under it', () => { + // A merged ticket's status changes only by unmerging it, so the cascade + // passes over it, as `_open_descendants` does. + const tree = node({ + id: 'p', + children: [ + node({ id: 'merged', status: 'Duplicate', children: [node({ id: 'under' })] }), + node({ id: 'real' }) + ] + }); + expect(openDescendants(tree, 'p').map((d) => d.id)).toEqual(['under', 'real']); + }); + it('lists a ticket the viewer cannot open, without a name', () => { // `/tree/` redacts it to an id, a status and `restricted`. It still // counts, because the close refuses a cascade that would take it. diff --git a/frontend/src/routes/(app)/tickets/[id]/tree.js b/frontend/src/routes/(app)/tickets/[id]/tree.js new file mode 100644 index 00000000..d8940c17 --- /dev/null +++ b/frontend/src/routes/(app)/tickets/[id]/tree.js @@ -0,0 +1,112 @@ +/** + * Parent and child tickets on a ticket's page (PARITY B3). + * + * The phone has had this on `ticket_detail_screen.dart` all along: a parent + * row, the tree the ticket sits in, link under a parent, detach. The web had + * two components for it that nothing imported. This module is the part of the + * web version that is not markup, so it can be tested on its own, the way + * `close.js` is. + * + * Everything the page decides here comes from the API: + * + * - The tree is `GET /cases//tree/`, which starts at the TOP of the tree, + * not at this ticket, and names `focus_id`. A node the viewer may not open + * arrives redacted (`name: null`, `restricted: true`) and stays in place, so + * a readable grandchild under it keeps its real depth. + * - Whether link and detach are offered is `comment_permission` from the + * detail payload, the ticket's write rule, which is the rule `link/` takes. + * - What may be picked as a parent is the list endpoint's answer, which is + * already only tickets this person may open. + */ + +import { RESTRICTED_TICKET_NAME } from '$lib/v2/enums.js'; +import { findNode } from './close.js'; + +/** + * The tree as rows, top down, for an indented list. + * + * A restricted node keeps its status: `/tree/` sends it on purpose, and the + * phone shows it too. It never gets a name or a link, because opening it would + * only answer 404. + * + * @param {any} root the tree response's `root` + * @param {string} focusId the ticket this page is about + * @returns {Array<{id: string, name: string, status: string|null, depth: number, + * restricted: boolean, focus: boolean, truncated: boolean}>} + */ +export function treeRows(root, focusId) { + /** @type {ReturnType} */ + const rows = []; + /** @param {any} node @param {number} depth */ + const walk = (node, depth) => { + const restricted = Boolean(node.restricted); + rows.push({ + id: node.id, + name: restricted ? RESTRICTED_TICKET_NAME : (node.name ?? ''), + status: node.status ?? null, + depth, + restricted, + focus: node.id === focusId, + truncated: Boolean(node.truncated) + }); + for (const child of node.children ?? []) walk(child, depth + 1); + }; + if (root) walk(root, 0); + return rows; +} + +/** + * The ticket and everything under it: none of these may become its parent, + * since that would make a cycle. The API refuses one anyway; this keeps the + * picker from offering it. + * + * @param {any} root + * @param {string} id + * @returns {Set} + */ +export function subtreeIds(root, id) { + const ids = new Set([id]); + /** @param {any} node */ + const walk = (node) => { + for (const child of node.children ?? []) { + ids.add(child.id); + walk(child); + } + }; + const focus = findNode(root, id); + if (focus) walk(focus); + return ids; +} + +/** + * What the "Link under a parent" picker offers: list rows minus this ticket, + * its own subtree, the parent it already has, and anything merged away (the + * API refuses a merged parent). + * + * @param {Array<{id: string, status: string}>} rows from `listTickets` + * @param {{ exclude: Set, parentId?: string|null }} args + */ +export function parentCandidates(rows, { exclude, parentId = null }) { + return rows.filter( + (row) => !exclude.has(row.id) && row.id !== parentId && row.status !== 'Duplicate' + ); +} + +/** + * The sentence to show when `link/` refuses. + * + * Its refusals are `{"parent_id": ""}` (hidden or missing parent, + * self, cycle, depth, merged) or `{"detail": ""}`, and `apiRequest` + * would prefix the first with the key. The key means nothing to a person. + * + * @param {any} err the error thrown by `apiRequest` + * @param {string} fallback + */ +export function linkRefusal(err, fallback) { + const body = err?.body ?? {}; + for (const said of [body.parent_id, body.detail]) { + const text = Array.isArray(said) ? said[0] : said; + if (typeof text === 'string' && text.trim()) return text.trim(); + } + return fallback; +} diff --git a/frontend/src/routes/(app)/tickets/[id]/tree.test.js b/frontend/src/routes/(app)/tickets/[id]/tree.test.js new file mode 100644 index 00000000..10fda59c --- /dev/null +++ b/frontend/src/routes/(app)/tickets/[id]/tree.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { treeRows, subtreeIds, parentCandidates, linkRefusal } from './tree.js'; +import { RESTRICTED_TICKET_NAME } from '$lib/v2/enums.js'; + +/** @param {any} over */ +function node(over = {}) { + return { id: 'n', name: 'A ticket', status: 'New', is_active: true, children: [], ...over }; +} + +/** + * What `/tree/` returns for a ticket that is itself a child: the TOP of the + * tree, with a hidden sibling and a hidden node above a readable grandchild. + * + * root + * ├── hiddenSibling (restricted) + * └── focus <- this page + * └── hiddenChild (restricted) + * └── grandchild + */ +const TREE = node({ + id: 'root', + name: 'Outage', + children: [ + node({ id: 'hiddenSibling', name: null, restricted: true, status: 'Pending' }), + node({ + id: 'focus', + name: 'This one', + children: [ + node({ + id: 'hiddenChild', + name: null, + restricted: true, + children: [node({ id: 'grandchild', name: 'Mine', truncated: true })] + }) + ] + }) + ] +}); + +describe('treeRows', () => { + it('flattens the whole tree top down, with depths', () => { + expect(treeRows(TREE, 'focus').map((r) => [r.id, r.depth])).toEqual([ + ['root', 0], + ['hiddenSibling', 1], + ['focus', 1], + ['hiddenChild', 2], + ['grandchild', 3] + ]); + }); + + it('marks the ticket this page is about, and only it', () => { + const rows = treeRows(TREE, 'focus'); + expect(rows.filter((r) => r.focus).map((r) => r.id)).toEqual(['focus']); + }); + + it('names a ticket the viewer cannot open with the shared phrase, keeping its status', () => { + const hidden = treeRows(TREE, 'focus').find((r) => r.id === 'hiddenSibling'); + expect(hidden).toMatchObject({ + name: RESTRICTED_TICKET_NAME, + restricted: true, + status: 'Pending' + }); + }); + + it('keeps a readable ticket under a hidden one at its real depth', () => { + const grandchild = treeRows(TREE, 'focus').find((r) => r.id === 'grandchild'); + expect(grandchild).toMatchObject({ name: 'Mine', restricted: false, depth: 3 }); + }); + + it('carries the depth cap so the page can say more is below', () => { + const rows = treeRows(TREE, 'focus'); + expect(rows.filter((r) => r.truncated).map((r) => r.id)).toEqual(['grandchild']); + }); + + it('is empty without a tree', () => { + expect(treeRows(null, 'focus')).toEqual([]); + }); +}); + +describe('subtreeIds', () => { + it('is the ticket and everything under it, never its parent or siblings', () => { + expect([...subtreeIds(TREE, 'focus')].sort()).toEqual(['focus', 'grandchild', 'hiddenChild']); + }); + + it('is just the ticket when there is no tree', () => { + expect([...subtreeIds(null, 'focus')]).toEqual(['focus']); + }); +}); + +describe('parentCandidates', () => { + const rows = [ + { id: 'focus', status: 'New' }, + { id: 'grandchild', status: 'New' }, + { id: 'current', status: 'Assigned' }, + { id: 'merged', status: 'Duplicate' }, + { id: 'ok', status: 'Pending' }, + { id: 'closed', status: 'Closed' } + ]; + + it('drops the ticket, its subtree, its current parent and merged tickets', () => { + const picked = parentCandidates(rows, { + exclude: subtreeIds(TREE, 'focus'), + parentId: 'current' + }); + expect(picked.map((r) => r.id)).toEqual(['ok', 'closed']); + }); + + it('keeps everything else when the ticket has no parent', () => { + const picked = parentCandidates(rows, { exclude: new Set(['focus']) }); + expect(picked.map((r) => r.id)).toEqual(['grandchild', 'current', 'ok', 'closed']); + }); +}); + +describe('linkRefusal', () => { + it("reads the endpoint's parent_id sentence without the key", () => { + const err = { body: { parent_id: 'Linking would create a cycle.' } }; + expect(linkRefusal(err, 'fallback')).toBe('Linking would create a cycle.'); + }); + + it('reads a detail sentence, which is how a 403 arrives', () => { + const err = { body: { detail: 'You do not have Permission to perform this action' } }; + expect(linkRefusal(err, 'fallback')).toBe('You do not have Permission to perform this action'); + }); + + it('accepts a list-shaped message', () => { + const err = { body: { parent_id: ['Case tree is limited to 3 levels.'] } }; + expect(linkRefusal(err, 'fallback')).toBe('Case tree is limited to 3 levels.'); + }); + + it('falls back when the response said nothing usable', () => { + expect(linkRefusal({ body: {} }, 'Could not link this ticket.')).toBe( + 'Could not link this ticket.' + ); + expect(linkRefusal(undefined, 'Could not link this ticket.')).toBe( + 'Could not link this ticket.' + ); + }); +}); diff --git a/frontend/src/routes/api/cases/[id]/link/+server.js b/frontend/src/routes/api/cases/[id]/link/+server.js deleted file mode 100644 index d250e186..00000000 --- a/frontend/src/routes/api/cases/[id]/link/+server.js +++ /dev/null @@ -1,23 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { apiRequest } from '$lib/api-helpers.js'; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** Tier 3 parent/child: link/unlink a parent case. Body: { parent_id }. */ -/** @type {import('./$types').RequestHandler} */ -export async function POST({ params, request, cookies, locals }) { - if (!UUID_RE.test(params.id)) { - return json({ error: 'invalid case id' }, { status: 400 }); - } - const payload = await request.json().catch(() => ({})); - try { - const data = await apiRequest( - `/cases/${params.id}/link/`, - { method: 'POST', body: payload }, - { cookies, org: locals?.org } - ); - return json(data); - } catch (err) { - return json({ error: err?.message || 'Link failed' }, { status: 400 }); - } -} diff --git a/frontend/src/routes/api/cases/[id]/tree/+server.js b/frontend/src/routes/api/cases/[id]/tree/+server.js deleted file mode 100644 index ad5af850..00000000 --- a/frontend/src/routes/api/cases/[id]/tree/+server.js +++ /dev/null @@ -1,14 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { apiRequest } from '$lib/api-helpers.js'; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** Tier 3 parent/child: descendant tree (max depth 3). */ -/** @type {import('./$types').RequestHandler} */ -export async function GET({ params, cookies, locals }) { - if (!UUID_RE.test(params.id)) { - return json({ error: 'invalid case id' }, { status: 400 }); - } - const data = await apiRequest(`/cases/${params.id}/tree/`, {}, { cookies, org: locals?.org }); - return json(data); -} diff --git a/mkdocs.yml b/mkdocs.yml index d54e1aef..acc2b112 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,9 @@ nav: - Cases: api/cases.md - Tasks: api/tasks.md - Invoices and estimates: api/invoices.md + - CSV exports: api/exports.md + - Saved views: api/saved-views.md + - Security audit log: api/audit-log.md - Customer portal: api/customer-portal.md - Endpoint index: api/endpoint-index.md - Integrations: diff --git a/mobile/docs/PARITY.md b/mobile/docs/PARITY.md index b86d0c1c..36e80c09 100644 --- a/mobile/docs/PARITY.md +++ b/mobile/docs/PARITY.md @@ -19,10 +19,8 @@ On 2026-09-26 the counts were 90 `+page.svelte` files under `frontend/src/routes | ID | Gap | Missing on | Backend | Opened | Notes | |---|---|---|---|---|---| -| B3 | Parent and child tickets: parent banner, link parent, detach, tree | Web | `/api/cases//tree/`, `link/`, `close-with-children/` and `parent_summary` exist (authz fixed as D50 and D51) | 2026-09-26 | Mobile has all of it in `ticket_detail_screen.dart`. Web has `TicketTreePanel.svelte` and `LinkParentDialog.svelte` exported from the components index but imported nowhere; only the close-with-children cascade (`tickets/[id]/close.js`) is wired. | -| B4 | CSV import: errors download and valid-row sample | Mobile | Preview returns `errors[]` and `valid[]` | 2026-09-26 | Mobile's import sheet (`widgets/forms/csv_import_sheet.dart`) previews, commits and shows row errors inline, but cannot save the errors as a file or show the sample the web drawers show. | -| B5 | Invoice and estimate forms: document discount and tax | Mobile | Serializers accept both | 2026-09-26 | The web forms (`LineItemsEditor.svelte` plus the adjustments card) set them; `new_invoice_screen.dart` and `new_estimate_screen.dart` do not. Line discounts do reach both. | -| B6 | Invoice detail: subtotal, discount, tax and shipping rows | Mobile | Detail payload carries them | 2026-09-26 | Mobile shows Total, Paid and Due only. | + +Empty as of 2026-09-26 (django-crm 1.11.0 pass): B3 to B6 closed below. ## Web-only by design @@ -40,3 +38,7 @@ These are not gaps. Do not build them on mobile without a decision. |---|---|---|---| | B1 | CSV import for contacts, tickets and leads | 2026-09-26 | One reusable sheet (`mobile/lib/widgets/forms/csv_import_sheet.dart`, provider `csv_import_provider.dart`) on the contacts, tickets and leads lists: pick, preview, commit, created count, template help, the web's 403 wording. The errors download stayed web-only (B4). Tracked as G1 in `enterprise-crm/docs/gap-analysis/TRACKER.md`. | | B2 | Editing a converted lead | 2026-09-26 | `_buildPayload` in `lead_form_screen.dart` now sends `status` on an edit only when it changed, as the web does, so editing any other field of a converted lead is no longer refused by `LeadCreateSerializer.validate_status`. The detail screen's sheets (assignees, tags, follow-up) never sent `status`. Tests: `test/screens/leads/lead_form_status_test.dart`. | +| B3 | Parent and child tickets on the web | 2026-09-26 | Web `/tickets/[id]` has a Linked tickets panel: parent row (a redacted parent is shown, never linked), detach with an in-page confirm, the tree with the current ticket in bold, and a parent picker, gated on `comment_permission`. The unused `TicketTreePanel.svelte` and `LinkParentDialog.svelte` were deleted. Mobile's link action is gated the same way. Tests: `tickets/[id]/tree.test.js`, `ticket_detail_redirect_and_gate_test.dart`. | +| B4 | CSV import errors file and valid-row sample on mobile | 2026-09-26 | `csv_import_sheet.dart` saves `-import-errors.csv` through `file_picker` (quoted and formula-guarded like the web's shared `lib/utils/csv.js`) and shows the first 20 valid rows. | +| B5 | Document discount and tax on mobile invoice and estimate forms | 2026-09-26 | `document_adjustments.dart`: invoices set discount, tax and shipping; estimates and recurring schedules set discount and tax, in the web payload shape, with the server's bounds and messages mirrored. | +| B6 | Mobile invoice detail breakdown | 2026-09-26 | Subtotal, Discount (when set), Tax and Shipping (when set) rows, as on the web. Neither client has an estimate detail screen. | diff --git a/mobile/lib/config/api_config.dart b/mobile/lib/config/api_config.dart index 95a1b085..9f892449 100644 --- a/mobile/lib/config/api_config.dart +++ b/mobile/lib/config/api_config.dart @@ -179,6 +179,15 @@ class ApiConfig { /// the admin's org-wide revoke and refuses a member outright. static String profileToken(String id) => '$apiBaseUrl/profile/tokens/$id/'; + // ========================================================================== + // AUDIT LOG + // ========================================================================== + + /// The org's security audit log, newest first, paginated with `limit` and + /// `offset`, filtered by `event_type`, `actor`, `from` and `to`. Admin-only, + /// and refused to API tokens. Returns `event_types` for the filter too. + static String get auditLog => '$apiBaseUrl/org/audit-log/'; + // ========================================================================== // WEBHOOKS // ========================================================================== @@ -272,6 +281,10 @@ class ApiConfig { static String get ticketsExport => '${tickets}export/'; static String get invoicesExport => '${invoices}export/'; + /// The caller's own saved list views (G29), for the same six lists. + static String get savedViews => '$apiBaseUrl/saved-views/'; + static String savedView(String id) => '$apiBaseUrl/saved-views/$id/'; + /// Opportunity comment (for update/delete) static String opportunityComment(String commentId) => '$apiBaseUrl/opportunities/comment/$commentId/'; diff --git a/mobile/lib/data/models/audit_entry.dart b/mobile/lib/data/models/audit_entry.dart new file mode 100644 index 00000000..963bae6c --- /dev/null +++ b/mobile/lib/data/models/audit_entry.dart @@ -0,0 +1,101 @@ +/// Entries from `GET /api/org/audit-log/`, mirroring `/settings/audit-log` +/// on the web. +/// +/// The API sends only allow-listed `details` keys (ids, counts, and sentences +/// the server wrote) and never the stored description, so everything shown is +/// built from those. +library; + +/// One option in the event filter. +class AuditEventType { + const AuditEventType({required this.value, required this.label}); + + final String value; + final String label; + + factory AuditEventType.fromJson(Map json) => AuditEventType( + value: json['value']?.toString() ?? '', + label: json['label']?.toString() ?? '', + ); +} + +class AuditEntry { + const AuditEntry({ + required this.id, + required this.eventType, + required this.eventLabel, + this.createdAt, + this.success = true, + this.actorId, + this.actorName = '', + this.actorEmail = '', + this.ipAddress = '', + this.details = const {}, + }); + + final String id; + final String eventType; + final String eventLabel; + final DateTime? createdAt; + final bool success; + + /// Null for a system event, or once the user has been deleted. + final String? actorId; + final String actorName; + final String actorEmail; + final String ipAddress; + final Map details; + + String get actorLabel { + if (actorId == null) return 'No user'; + if (actorName.isNotEmpty) return actorName; + if (actorEmail.isNotEmpty) return actorEmail; + return 'No user'; + } + + /// One line on what happened, or '' when the label says it all. The same + /// rules as `auditDetail` in `frontend/src/lib/v2/audit-log.js`. + String get detail { + final reason = details['pause_reason']; + if (reason != null && reason.toString().isNotEmpty) return '$reason'; + if (eventType == 'WEBHOOK_REENABLED') { + return 'Turned back on, and now answers for the webhook.'; + } + final changed = details['changed']; + if (eventType == 'WEBHOOK_CHANGED' && changed is List) { + return 'Changed ${changed.join(', ')}, and now answers for the webhook.'; + } + final action = details['action']; + final resource = details['resource']; + if (action != null && resource != null) return '$action on $resource'; + final deleted = details['deleted_count']; + if (deleted != null) return '$deleted sample leads removed'; + return ''; + } + + /// The webhook the entry is about, for a link, or null. + String? get webhookId { + final id = details['endpoint_id']; + return id is String && id.isNotEmpty ? id : null; + } + + factory AuditEntry.fromJson(Map json) { + final actor = json['actor']; + final details = json['details']; + return AuditEntry( + id: json['id']?.toString() ?? '', + eventType: json['event_type']?.toString() ?? '', + eventLabel: + json['event_label']?.toString() ?? + json['event_type']?.toString() ?? + '', + createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), + success: json['success'] != false, + actorId: actor is Map ? actor['id']?.toString() : null, + actorName: actor is Map ? actor['name']?.toString() ?? '' : '', + actorEmail: actor is Map ? actor['email']?.toString() ?? '' : '', + ipAddress: json['ip_address']?.toString() ?? '', + details: details is Map ? details : const {}, + ); + } +} diff --git a/mobile/lib/data/models/auth_response.dart b/mobile/lib/data/models/auth_response.dart index 2873bd62..aaf1df49 100644 --- a/mobile/lib/data/models/auth_response.dart +++ b/mobile/lib/data/models/auth_response.dart @@ -47,7 +47,15 @@ class AuthUser { class Organization { final String id; final String name; + + /// For display. Admin gates read [isOrganizationAdmin], never this. final String? role; + + /// Whether the signed-in user administers this org: the API's + /// `is_org_admin` (the ADMIN role, or a Django superuser's membership), + /// sent as `is_organization_admin` with every org the auth endpoints list. + /// Read it through `isOrgAdminProvider`. UI affordances only. + final bool isOrganizationAdmin; final String? defaultCurrency; final String? currencySymbol; final String? defaultCountry; @@ -56,6 +64,7 @@ class Organization { required this.id, required this.name, this.role, + this.isOrganizationAdmin = false, this.defaultCurrency, this.currencySymbol, this.defaultCountry, @@ -66,6 +75,14 @@ class Organization { id: json['id'] as String, name: json['name'] as String, role: json['role'] as String?, + // Only a literal `true` counts. The key is absent only from an org + // cached by a build older than 1.11.0, which stored the role and no + // fact; for that one case the role (also server-issued) stands in until + // the next sign-in or org switch replaces it (see [withMembership]). A superuser holding the USER + // role is not recognised from such a cache, which is the old behaviour. + isOrganizationAdmin: json.containsKey('is_organization_admin') + ? json['is_organization_admin'] == true + : json['role'] == 'ADMIN', defaultCurrency: json['default_currency'] as String?, currencySymbol: json['currency_symbol'] as String?, defaultCountry: json['default_country'] as String?, @@ -77,6 +94,7 @@ class Organization { 'id': id, 'name': name, 'role': role, + 'is_organization_admin': isOrganizationAdmin, 'default_currency': defaultCurrency, 'currency_symbol': currencySymbol, 'default_country': defaultCountry, @@ -93,6 +111,26 @@ class Organization { return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase(); } + /// This org with the caller's membership facts from `profile`, the + /// `{role, is_organization_admin}` object `POST /api/auth/switch-org/` + /// returns. Both are read fresh on every switch, so a promotion or demotion + /// since sign-in (and an org built from the create response) is not left + /// stale in the cache. An absent or malformed value keeps what we had. + Organization withMembership(Object? profile) { + if (profile is! Map) return this; + final admin = profile['is_organization_admin']; + final freshRole = profile['role']; + return Organization( + id: id, + name: name, + role: freshRole is String ? freshRole : role, + isOrganizationAdmin: admin is bool ? admin : isOrganizationAdmin, + defaultCurrency: defaultCurrency, + currencySymbol: currencySymbol, + defaultCountry: defaultCountry, + ); + } + @override String toString() => 'Organization(id: $id, name: $name, role: $role)'; } diff --git a/mobile/lib/data/models/deal.dart b/mobile/lib/data/models/deal.dart index 62b4c753..659a16a9 100644 --- a/mobile/lib/data/models/deal.dart +++ b/mobile/lib/data/models/deal.dart @@ -97,6 +97,28 @@ enum Currency { } } +/// A deal's earliest open task, as the deal list sends it (`next_activity`). +/// +/// The server counts only tasks the caller can open, so a task hidden from +/// this user never reaches the phone. Undated tasks sort after dated ones. +class DealNextActivity { + final String id; + final String title; + final DateTime? dueDate; + + const DealNextActivity({required this.id, required this.title, this.dueDate}); + + static DealNextActivity? fromJson(Object? json) { + if (json is! Map) return null; + final due = json['due_date']; + return DealNextActivity( + id: json['id']?.toString() ?? '', + title: json['title'] as String? ?? '', + dueDate: due is String ? DateTime.tryParse(due) : null, + ); + } +} + /// Product in a deal class DealProduct { final String id; @@ -198,6 +220,13 @@ class Deal { final String? agingStatus; final double? lineItemsTotal; + /// The earliest open task on the deal that this user can open, or null + /// when there is none (the "no next step" flag). Only meaningful when + /// [nextActivityKnown]: the deal list computes it, the detail does not, and + /// a deal read from the detail must not be shown as having nothing planned. + final DealNextActivity? nextActivity; + final bool nextActivityKnown; + const Deal({ required this.id, required this.title, @@ -239,6 +268,8 @@ class Deal { this.daysInStageServer, this.agingStatus, this.lineItemsTotal, + this.nextActivity, + this.nextActivityKnown = false, }); /// Factory constructor to create Deal from JSON (backend API) @@ -505,6 +536,8 @@ class Deal { daysInStageServer: daysInStageServer, agingStatus: json['aging_status'] as String?, lineItemsTotal: lineItemsTotal, + nextActivity: DealNextActivity.fromJson(json['next_activity']), + nextActivityKnown: json.containsKey('next_activity'), ); } @@ -616,6 +649,8 @@ class Deal { String? agingStatus, double? lineItemsTotal, }) { + // The next activity is carried as it is: no edit made on the phone + // changes which tasks are open, and a reload replaces it. return Deal( id: id ?? this.id, title: title ?? this.title, @@ -657,6 +692,8 @@ class Deal { daysInStageServer: daysInStageServer ?? this.daysInStageServer, agingStatus: agingStatus ?? this.agingStatus, lineItemsTotal: lineItemsTotal ?? this.lineItemsTotal, + nextActivity: nextActivity, + nextActivityKnown: nextActivityKnown, ); } diff --git a/mobile/lib/data/models/invoice.dart b/mobile/lib/data/models/invoice.dart index a6a0638c..9cac8e5b 100644 --- a/mobile/lib/data/models/invoice.dart +++ b/mobile/lib/data/models/invoice.dart @@ -65,6 +65,10 @@ class Invoice { this.clientEmail = '', this.issueDate, this.dueDate, + this.subtotal = 0, + this.discountAmount = 0, + this.taxAmount = 0, + this.shippingAmount = 0, this.totalAmount = 0, this.amountPaid = 0, this.amountDue = 0, @@ -97,6 +101,15 @@ class Invoice { final DateTime? issueDate; final DateTime? dueDate; + /// The detail's breakdown of [totalAmount], as the server computed it: the + /// lines' net amounts summed, less the invoice's own discount, plus tax on + /// what is left, plus shipping. The list rows do not carry these, so they + /// read 0 there. + final double subtotal; + final double discountAmount; + final double taxAmount; + final double shippingAmount; + /// Amounts arrive as decimal strings, so they are parsed rather than cast. final double totalAmount; final double amountPaid; @@ -198,6 +211,10 @@ class Invoice { clientEmail: json['client_email'] as String? ?? '', issueDate: _date(json['issue_date']), dueDate: _date(json['due_date']), + subtotal: _amount(json['subtotal']), + discountAmount: _amount(json['discount_amount']), + taxAmount: _amount(json['tax_amount']), + shippingAmount: _amount(json['shipping_amount']), totalAmount: _amount(json['total_amount']), amountPaid: _amount(json['amount_paid']), amountDue: _amount(json['amount_due']), diff --git a/mobile/lib/data/models/webhook.dart b/mobile/lib/data/models/webhook.dart index eab4fa8c..5dba8b87 100644 --- a/mobile/lib/data/models/webhook.dart +++ b/mobile/lib/data/models/webhook.dart @@ -38,6 +38,8 @@ class WebhookEndpoint { this.isActive = true, this.disabledReason = '', this.secretHint = '', + this.createdByName = '', + this.createdByEmail = '', this.createdAt, }); @@ -51,9 +53,26 @@ class WebhookEndpoint { final String format; final bool isActive; - /// Why the server turned it off (it answered 410 Gone). Empty otherwise. + /// Why the server paused it: it answered 410 Gone, or the admin who + /// created it stopped being an admin, was deactivated, or left the org. + /// Empty otherwise. Any admin can turn it back on. final String disabledReason; + /// Who answers for it: whoever added it, or the admin who last turned it + /// back on. Both empty once that user has been deleted. + final String createdByName; + final String createdByEmail; + + /// The creator as one line: name and email, whichever are known. + String get creatorLabel { + if (createdByName.isNotEmpty && createdByEmail.isNotEmpty) { + return '$createdByName ($createdByEmail)'; + } + if (createdByName.isNotEmpty) return createdByName; + if (createdByEmail.isNotEmpty) return createdByEmail; + return 'a removed user'; + } + /// `whsec_...abcd`. The full secret is never on a list or detail response. final String secretHint; final DateTime? createdAt; @@ -64,6 +83,8 @@ class WebhookEndpoint { format == formatSlack ? 'Slack message' : 'Signed JSON'; factory WebhookEndpoint.fromJson(Map json) { + final creator = json['created_by']; + final by = creator is Map ? creator : const {}; return WebhookEndpoint( id: json['id']?.toString() ?? '', url: json['url']?.toString() ?? '', @@ -75,6 +96,8 @@ class WebhookEndpoint { isActive: json['is_active'] == true, disabledReason: json['disabled_reason']?.toString() ?? '', secretHint: json['secret_hint']?.toString() ?? '', + createdByName: by['name']?.toString() ?? '', + createdByEmail: by['email']?.toString() ?? '', createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), ); } diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index d7735443..701259a0 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -79,6 +79,14 @@ class AccountsNotifier extends AsyncNotifier { await refresh(); } + /// Both filters at once, as a saved view sets them: one fetch, so a slow + /// first answer cannot land after the second. + Future applyView({required String search, required bool closed}) async { + _search = search.trim(); + _showClosed = closed; + await refresh(); + } + /// Appends the next page. Errors here are swallowed into "no more pages" /// rather than replacing the list with an error state: the rows already on /// screen are still good, and blanking them because page three failed is a @@ -148,9 +156,14 @@ class AccountsNotifier extends AsyncNotifier { } /// One account, with the relations and rollups the list rows do not carry. - Future getAccount(String id) async { + /// The account, or null. [onNotFound] runs only on a 404, which the server + /// gives both for a missing account and one this user may not open. + Future getAccount(String id, {void Function()? onNotFound}) async { final response = await _api.get('${ApiConfig.accounts}$id/'); - if (!response.success || response.data == null) return null; + if (!response.success || response.data == null) { + if (response.statusCode == 404) onNotFound?.call(); + return null; + } final body = response.data!; // The detail view wraps the record; the key has been stable but a bare // body is accepted too so a serializer change degrades to a miss rather diff --git a/mobile/lib/providers/analytics_provider.dart b/mobile/lib/providers/analytics_provider.dart index 08ce014e..59486724 100644 --- a/mobile/lib/providers/analytics_provider.dart +++ b/mobile/lib/providers/analytics_provider.dart @@ -35,6 +35,10 @@ class AnalyticsQuery { } } +/// The dashboard's sections, one per endpoint. Each loads, fails and retries +/// on its own. +enum AnalyticsSection { frt, nrt, mttr, backlog, sla, csat, agents } + class AnalyticsDashboard { final Map? frt; final Map? mttr; @@ -49,7 +53,11 @@ class AnalyticsDashboard { /// customer answered. final Map? csat; final bool isLoading; - final String? error; + + /// Why a section has no figures, keyed by section. A section absent here + /// loaded. One failing call used to blank the whole dashboard; now only its + /// own section says so, with its own retry, and the rest still render. + final Map errors; const AnalyticsDashboard({ this.frt, @@ -60,14 +68,46 @@ class AnalyticsDashboard { this.nrt, this.csat, this.isLoading = false, - this.error, + this.errors = const {}, }); + + /// This dashboard with [section] replaced by what [response] carried: its + /// figures when it succeeded, its error when it did not. + AnalyticsDashboard withSection( + AnalyticsSection section, + ApiResponse> response, + ) { + final data = response.success ? response.data : null; + final errors = {...this.errors}..remove(section); + if (!response.success) { + errors[section] = response.message ?? 'Could not load this section.'; + } + List> agentsFrom(Map? body) => + (body?['results'] as List? ?? const []) + .whereType>() + .toList(); + return AnalyticsDashboard( + frt: section == AnalyticsSection.frt ? data : frt, + mttr: section == AnalyticsSection.mttr ? data : mttr, + backlog: section == AnalyticsSection.backlog ? data : backlog, + agents: section == AnalyticsSection.agents ? agentsFrom(data) : agents, + sla: section == AnalyticsSection.sla ? data : sla, + nrt: section == AnalyticsSection.nrt ? data : nrt, + csat: section == AnalyticsSection.csat ? data : csat, + isLoading: isLoading, + errors: errors, + ); + } } class AnalyticsNotifier extends Notifier { final ApiService _api = ApiService(); AnalyticsQuery _query = const AnalyticsQuery(); + /// Bumped by every full load, so an answer that arrives after the filters + /// changed is dropped instead of painting the old window's figures. + int _generation = 0; + @override AnalyticsDashboard build() { Future.microtask(_load); @@ -81,45 +121,47 @@ class AnalyticsNotifier extends Notifier { AnalyticsQuery get query => _query; - Future _load() async { - state = AnalyticsDashboard(isLoading: true, agents: state.agents); + /// Fetch one section again, leaving the others as they are. + Future retry(AnalyticsSection section) async { + final generation = _generation; + final response = await _fetch(section); + if (generation != _generation) return; + state = state.withSection(section, response); + } + + static String _url(AnalyticsSection section) => switch (section) { + AnalyticsSection.frt => ApiConfig.analyticsFrt, + AnalyticsSection.nrt => ApiConfig.analyticsNrt, + AnalyticsSection.mttr => ApiConfig.analyticsMttr, + AnalyticsSection.backlog => ApiConfig.analyticsBacklog, + AnalyticsSection.sla => ApiConfig.analyticsSla, + AnalyticsSection.csat => ApiConfig.csatAggregate, + AnalyticsSection.agents => ApiConfig.analyticsAgents, + }; + + Future>> _fetch(AnalyticsSection section) { final params = _query.toParams(); - String build(String url) => params.isEmpty - ? url - : Uri.parse(url).replace(queryParameters: params).toString(); - - final results = await Future.wait([ - _api.get(build(ApiConfig.analyticsFrt)), - _api.get(build(ApiConfig.analyticsMttr)), - _api.get(build(ApiConfig.analyticsBacklog)), - _api.get(build(ApiConfig.analyticsAgents)), - _api.get(build(ApiConfig.analyticsSla)), - _api.get(build(ApiConfig.analyticsNrt)), - _api.get(build(ApiConfig.csatAggregate)), - ]); - - String? error; - for (final r in results) { - if (!r.success) error = r.message ?? 'Failed to load analytics'; - } - - final agentsResp = results[3]; - final agents = (agentsResp.data?['results'] as List? ?? []) - .whereType>() - .toList(); - - state = AnalyticsDashboard( - frt: results[0].data, - mttr: results[1].data, - backlog: results[2].data, - agents: agents, - sla: results[4].data, - nrt: results[5].data, - csat: results[6].data, - isLoading: false, - error: error, + final url = _url(section); + return _api.get( + params.isEmpty + ? url + : Uri.parse(url).replace(queryParameters: params).toString(), ); } + + Future _load() async { + final generation = ++_generation; + state = AnalyticsDashboard(isLoading: true, agents: state.agents); + final sections = AnalyticsSection.values; + final results = await Future.wait([for (final s in sections) _fetch(s)]); + if (generation != _generation) return; + + var next = const AnalyticsDashboard(); + for (var i = 0; i < sections.length; i++) { + next = next.withSection(sections[i], results[i]); + } + state = next; + } } final analyticsProvider = diff --git a/mobile/lib/providers/audit_log_provider.dart b/mobile/lib/providers/audit_log_provider.dart new file mode 100644 index 00000000..36d8b5ce --- /dev/null +++ b/mobile/lib/providers/audit_log_provider.dart @@ -0,0 +1,85 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../config/api_config.dart'; +import '../data/api_envelope.dart'; +import '../data/models/audit_entry.dart'; +import '../services/api_service.dart'; + +/// The security audit log, mirroring `/settings/audit-log` on the web. +/// +/// Admin-only server-side (`common/views/audit_log_views.py`). The screen +/// gates on `isOrgAdminProvider` so a member never asks; that is a courtesy, +/// not the boundary. The server validates every filter and answers 400 on a +/// malformed one, whose sentence the screen shows. + +/// Entries per page, the same as the web. +const int auditLogPageSize = 25; + +/// What the screen is asking for. A record, so two equal queries share one +/// cached page. +typedef AuditLogQuery = ({ + String? eventType, + String? actor, + DateTime? from, + DateTime? to, + int offset, +}); + +const AuditLogQuery auditLogFirstPage = ( + eventType: null, + actor: null, + from: null, + to: null, + offset: 0, +); + +String _day(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + +/// The query parameters for [q]. Only set filters are sent. +Map auditLogParams(AuditLogQuery q) => { + 'limit': '$auditLogPageSize', + 'offset': '${q.offset}', + if (q.eventType != null && q.eventType!.isNotEmpty) + 'event_type': q.eventType!, + if (q.actor != null && q.actor!.isNotEmpty) 'actor': q.actor!, + if (q.from != null) 'from': _day(q.from!), + if (q.to != null) 'to': _day(q.to!), +}; + +class AuditLogPage { + const AuditLogPage({ + this.entries = const [], + this.count = 0, + this.eventTypes = const [], + }); + + final List entries; + final int count; + final List eventTypes; +} + +final auditLogProvider = FutureProvider.autoDispose + .family((ref, q) async { + final response = await ApiService().get( + ApiConfig.auditLog, + queryParams: auditLogParams(q), + ); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Could not load the audit log'); + } + final body = response.data!; + final count = body['count']; + final entries = listFromEnvelope(body, const [ + 'results', + ]).map(AuditEntry.fromJson).toList(growable: false); + return AuditLogPage( + entries: entries, + count: count is int ? count : entries.length, + eventTypes: listFromEnvelope(body, const [ + 'event_types', + ]).map(AuditEventType.fromJson).toList(growable: false), + ); + }); diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 704e7aaf..fce1d9de 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -213,7 +213,13 @@ class AuthNotifier extends Notifier { final success = await _authService.selectOrganization(org); if (success) { - state = state.copyWith(isLoading: false, selectedOrganization: org); + // The service's copy, not `org`: the switch re-read the membership + // facts (role, admin) and `org` may predate them. + state = state.copyWith( + isLoading: false, + organizations: _authService.organizations, + selectedOrganization: _authService.selectedOrganization, + ); _dropSessionCaches(); debugPrint('AuthNotifier: Organization switched to ${org.name}'); @@ -333,13 +339,16 @@ final selectedOrgProvider = Provider((ref) { return ref.watch(authProvider).selectedOrganization; }); -/// Role of the signed-in profile in the selected org, as an admin/not-admin -/// answer. The claim is server-issued and arrives with the org, so this reads -/// the same fact the backend's `is_org_admin` reads. UI affordances only; see +/// Whether the signed-in user administers the selected org. The app's one +/// admin rule: every admin gate reads this, never `role`. +/// +/// It reads `is_organization_admin`, which the API computes with +/// `is_org_admin` (the ADMIN role, or a Django superuser's membership) and +/// sends with the org, so a superuser holding the USER role gets the admin +/// controls the API already lets them use. UI affordances only; see /// `core/permissions.dart`. final isOrgAdminProvider = Provider((ref) { - final org = ref.watch(selectedOrgProvider); - return (org?.role ?? '').toUpperCase() == 'ADMIN'; + return ref.watch(selectedOrgProvider)?.isOrganizationAdmin ?? false; }); /// The signed-in user's email, or null. diff --git a/mobile/lib/providers/contacts_provider.dart b/mobile/lib/providers/contacts_provider.dart index 018b54e5..8c89bec1 100644 --- a/mobile/lib/providers/contacts_provider.dart +++ b/mobile/lib/providers/contacts_provider.dart @@ -110,9 +110,14 @@ class ContactsNotifier extends AsyncNotifier { ); } - Future getContact(String id) async { + /// The contact, or null. [onNotFound] runs only on a 404, which the server + /// gives both for a missing contact and one this user may not open. + Future getContact(String id, {void Function()? onNotFound}) async { final response = await _api.get('${ApiConfig.contacts}$id/'); - if (!response.success || response.data == null) return null; + if (!response.success || response.data == null) { + if (response.statusCode == 404) onNotFound?.call(); + return null; + } final raw = response.data!['contact_obj'] ?? response.data!; if (raw is! Map) return null; return Contact.fromJson(raw); diff --git a/mobile/lib/providers/csv_import_provider.dart b/mobile/lib/providers/csv_import_provider.dart index 6803e99b..2325bb65 100644 --- a/mobile/lib/providers/csv_import_provider.dart +++ b/mobile/lib/providers/csv_import_provider.dart @@ -220,6 +220,37 @@ enum CsvImportTarget { String get templateFileName => '$plural-import-template.csv'; + /// The name the web drawer gives its "Download errors" file. + String get errorsFileName => '$plural-import-errors.csv'; + + /// One valid row of a preview as the web drawer's sample table shows it: + /// the name, then the other columns it prints, blanks left out. The keys + /// are the ones the import views put in `valid[]` for this module. + ({String title, String details}) sampleLine(Map row) { + String text(String key) => row[key]?.toString().trim() ?? ''; + final List details; + final String title; + switch (this) { + case CsvImportTarget.contacts: + title = '${text('first_name')} ${text('last_name')}'.trim(); + details = [text('email'), text('phone'), text('organization')]; + case CsvImportTarget.tickets: + title = text('name'); + details = [ + text('status'), + text('priority'), + if (row['account_id'] != null) 'account linked', + ]; + case CsvImportTarget.leads: + title = '${text('first_name')} ${text('last_name')}'.trim(); + details = [text('email'), text('company_name'), text('status')]; + } + return ( + title: title.isEmpty ? 'Unnamed' : title, + details: details.where((d) => d.isNotEmpty).join(' \u00b7 '), + ); + } + /// The template as CSV text, quoting a cell only when it holds a comma, as /// the web does. String get templateCsv => [ @@ -267,6 +298,33 @@ class CsvRowError { final String message; } +/// A preview's or a commit's row errors as the CSV the web drawer's +/// "Download errors" saves: `row,field,message`, one line per error. +/// +/// The same file the web builds (`frontend/src/lib/utils/csv.js`): a message +/// holding a quote, comma or line break stays one quoted cell, and a cell that +/// a spreadsheet would read as a formula is prefixed with `'`, the guard +/// `common/csv_export.py` puts on every export, because a message can quote +/// the value that was refused. +String csvImportErrorsCsv(List errors) { + String cell(String value) { + const formula = ['=', '+', '-', '@', '\t', '\r']; + final trimmed = value.trimLeft(); + if (formula.any(value.startsWith) || formula.any(trimmed.startsWith)) { + value = "'$value"; + } + return RegExp(r'[",\r\n]').hasMatch(value) + ? '"${value.replaceAll('"', '""')}"' + : value; + } + + return [ + 'row,field,message', + for (final e in errors) + ['${e.row}', e.field, e.message].map(cell).join(','), + ].join('\n'); +} + List _rowErrors(Object? raw) => raw is List ? raw .whereType>() @@ -281,6 +339,7 @@ class CsvImportPreview { required this.valid, required this.invalid, required this.errors, + this.validRows = const [], this.headerError, }); @@ -295,6 +354,11 @@ class CsvImportPreview { valid: count('valid'), invalid: count('invalid'), errors: _rowErrors(json['errors']), + validRows: json['valid'] is List + ? (json['valid'] as List).whereType>().toList( + growable: false, + ) + : const [], headerError: (headerError == null || headerError.isEmpty) ? null : headerError, @@ -306,6 +370,10 @@ class CsvImportPreview { final int invalid; final List errors; + /// The rows that would import, each as the import view parsed it, for the + /// sample the sheet shows. + final List> validRows; + /// Set when the file as a whole could not be read: missing or unknown /// headers, too many rows, not UTF-8. No row is checked in that case. final String? headerError; diff --git a/mobile/lib/providers/deals_provider.dart b/mobile/lib/providers/deals_provider.dart index 69df2c1c..f3ce9e3e 100644 --- a/mobile/lib/providers/deals_provider.dart +++ b/mobile/lib/providers/deals_provider.dart @@ -71,6 +71,34 @@ class DealFilters { amountMax == null && !rottenOnly; + /// A saved view's filters (`{param: [value, ...]}`) as this list's state: + /// the parameters `filterQuery` sends, read back. The web searches deals + /// with `search` and this screen with `name`; on this list both match the + /// deal name, so either fills the search box. Unparseable values are + /// dropped. + factory DealFilters.fromQuery(Map> query) { + String? one(String key) { + final values = query[key]; + return values == null || values.isEmpty ? null : values.first; + } + + DateTime? day(String key) => DateTime.tryParse(one(key) ?? ''); + double? number(String key) => double.tryParse(one(key) ?? ''); + return DealFilters( + search: one('search') ?? one('name'), + stage: one('stage'), + assignedToIds: query['assigned_to'] ?? const [], + tagIds: query['tags'] ?? const [], + createdFrom: day('created_at__gte'), + createdTo: day('created_at__lte'), + closingFrom: day('closed_on__gte'), + closingTo: day('closed_on__lte'), + amountMin: number('amount__gte'), + amountMax: number('amount__lte'), + rottenOnly: one('rotten') == 'true', + ); + } + /// How many "active" filter facets to show on the filter button badge. /// Search is excluded (it has its own UI affordance). int get badgeCount { @@ -199,6 +227,15 @@ class DealsNotifier extends AsyncNotifier { await refresh(); } + /// A saved view: its pipeline, when it names one, and its filters, in one + /// fetch. A pipeline deleted since falls back to the default, as it does + /// for any stale id. + Future applyView({String? pipelineId, required DealFilters filters}) { + if (pipelineId != null) _pipelineId = pipelineId; + _filters = filters; + return refresh(); + } + /// Show another pipeline. The stage filter goes with the old one, since a /// stage code belongs to a pipeline. Future setPipeline(String id) async { @@ -343,7 +380,15 @@ class DealsNotifier extends AsyncNotifier { /// render and act on it: comments, attachments, custom-field /// definitions, contacts and the assignable user list. Keeps the lookups /// in one place rather than scattering parallel calls across the screen. - Future getDealDetail(String id) async { + /// + /// [onNotFound] runs when the server answered 404, which it does both for a + /// record that does not exist and for one this user may not open. Any other + /// failure (offline, a 500) returns null without it, so a screen can say + /// "not found" only when that is what the server said. + Future getDealDetail( + String id, { + void Function()? onNotFound, + }) async { try { final url = '${ApiConfig.opportunities}$id/'; final response = await _apiService.get(url); @@ -354,6 +399,7 @@ class DealsNotifier extends AsyncNotifier { '[deals_provider] getDealDetail($id) HTTP failed: ' 'status=${response.statusCode} message=${response.message}', ); + if (response.statusCode == 404) onNotFound?.call(); return null; } diff --git a/mobile/lib/providers/invoices_provider.dart b/mobile/lib/providers/invoices_provider.dart index 023a727f..5e744881 100644 --- a/mobile/lib/providers/invoices_provider.dart +++ b/mobile/lib/providers/invoices_provider.dart @@ -76,6 +76,16 @@ class InvoicesNotifier extends AsyncNotifier { await refresh(); } + /// Both filters at once, as a saved view sets them: one fetch. + Future applyView({ + required String search, + required InvoiceStatus? status, + }) async { + _search = search.trim(); + _status = status; + await refresh(); + } + /// Appends the next page. A failure here leaves the loaded rows alone and /// stops paging, rather than replacing a good list with an error screen /// because page three timed out. diff --git a/mobile/lib/providers/leads_provider.dart b/mobile/lib/providers/leads_provider.dart index a110bca7..62656c91 100644 --- a/mobile/lib/providers/leads_provider.dart +++ b/mobile/lib/providers/leads_provider.dart @@ -118,6 +118,45 @@ class LeadFilters { '${day.day.toString().padLeft(2, '0')}', ); + /// A saved view's filters (`{param: [value, ...]}`) as this list's state: + /// the parameters [filterQuery] sends, read back. A value this list does not + /// offer is dropped rather than guessed at. The labels are the screen's to + /// look up. + factory LeadFilters.fromQuery( + Map> query, { + String? assignedToLabel, + String? tagLabel, + }) { + String? one(String key) { + final values = query[key]; + return values == null || values.isEmpty ? null : values.first; + } + + T? match(List all, String? raw, String Function(T) value) { + for (final option in all) { + if (raw != null && value(option) == raw) return option; + } + return null; + } + + final assignee = one('assigned_to'); + final tag = one('tags'); + return LeadFilters( + search: one('search'), + statuses: { + for (final raw in query['status'] ?? const []) + ?match(LeadStatus.values, raw, (s) => s.value), + }, + source: match(LeadSource.values, one('source'), (s) => s.value), + rating: match(LeadRating.values, one('rating'), (r) => r.value), + assignedToId: assignee, + assignedToLabel: assignee == null ? null : assignedToLabel, + tagId: tag, + tagLabel: tag == null ? null : tagLabel, + nextFollowUp: one('next_follow_up'), + ); + } + bool get isEmpty => (search == null || search!.isEmpty) && statuses.isEmpty && @@ -385,7 +424,15 @@ class LeadsNotifier extends AsyncNotifier { /// and act on it: top-level comments and attachments, custom-field /// definitions, and the assignable user list. Keeps lookups in one place /// rather than scattering parallel calls across the screen. - Future getLeadDetail(String id) async { + /// + /// [onNotFound] runs when the server answered 404, which it does both for a + /// record that does not exist and for one this user may not open. Any other + /// failure (offline, a 500) returns null without it, so a screen can say + /// "not found" only when that is what the server said. + Future getLeadDetail( + String id, { + void Function()? onNotFound, + }) async { try { final url = '${ApiConfig.leads}$id/'; final response = await _apiService.get(url); @@ -396,6 +443,7 @@ class LeadsNotifier extends AsyncNotifier { '[leads_provider] getLeadDetail($id) HTTP failed: ' 'status=${response.statusCode} message=${response.message}', ); + if (response.statusCode == 404) onNotFound?.call(); return null; } diff --git a/mobile/lib/providers/saved_views_provider.dart b/mobile/lib/providers/saved_views_provider.dart new file mode 100644 index 00000000..d5d0b85d --- /dev/null +++ b/mobile/lib/providers/saved_views_provider.dart @@ -0,0 +1,201 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../config/api_config.dart'; +import '../services/api_service.dart'; + +/// The six lists a saved view can belong to, by the name the API uses. +/// +/// Mirrors `LISTS` in `backend/common/saved_views.py` and `SAVED_VIEW_KEYS` in +/// `frontend/src/lib/server/v2/saved-views.js`. +enum SavedViewList { + leads('leads'), + contacts('contacts'), + accounts('accounts'), + deals('opportunities'), + tickets('cases'), + invoices('invoices'); + + const SavedViewList(this.module); + + /// The `module` the API files a view under. + final String module; +} + +/// One saved view: a name and the list query parameters it puts back. +/// +/// Private to whoever saved it. The API answers only the caller's own views, +/// and another person's id is a 404, so nothing here needs to check an owner. +class SavedView { + const SavedView({ + required this.id, + required this.name, + required this.filters, + }); + + factory SavedView.fromJson(Map json) { + final raw = json['filters']; + final filters = >{}; + if (raw is Map) { + raw.forEach((key, value) { + final values = value is List + ? value.map((v) => '$v').toList(growable: false) + : ['$value']; + if (values.isNotEmpty) filters['$key'] = values; + }); + } + return SavedView( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + filters: filters, + ); + } + + final String id; + final String name; + + /// `{param: [value, ...]}`, the list's own query parameters. + final Map> filters; + + /// How many of this view's filter values a screen cannot apply, when it + /// shows the parameters in [keys] and takes more than one value only for + /// those in [multi]. Counted in values, not keys: three statuses on a + /// screen that reads one is two it leaves out. The web counts the same way + /// (`droppedValues` in `frontend/src/lib/v2/saved-views.js`). + int dropped(Set keys, Set multi) { + var count = 0; + filters.forEach((key, values) { + if (!keys.contains(key)) { + count += values.length; + } else if (!multi.contains(key) && values.length > 1) { + count += values.length - 1; + } + }); + return count; + } +} + +/// A list's query, as `filterQuery` builds it, in the shape a saved view +/// stores: only the parameters in [keys], every value as text, blanks gone, +/// and one value for a parameter outside [multi]. +Map> savedViewFilters( + Map query, + Set keys, { + Set multi = const {}, +}) { + final out = >{}; + query.forEach((key, value) { + if (!keys.contains(key) || value == null) return; + final values = (value is Iterable ? value : [value]) + .map((v) => '$v'.trim()) + .where((v) => v.isNotEmpty) + .toList(growable: false); + if (values.isEmpty) return; + out[key] = multi.contains(key) ? values : values.sublist(0, 1); + }); + return out; +} + +class SavedViewsState { + const SavedViewsState({this.views = const [], this.limit}); + + final List views; + + /// How many views the API keeps per list, or null if it did not say. + final int? limit; + + bool get full => limit != null && views.length >= limit!; +} + +/// The caller's views for one list, and the three writes on them. +/// +/// Each write answers `null` on success, or the sentence to show. The list is +/// read again after every write, so what is on screen is what was stored. +class SavedViewsNotifier extends AsyncNotifier { + SavedViewsNotifier(this.list); + + final SavedViewList list; + final ApiService _api = ApiService(); + + @override + Future build() => _fetch(); + + Future _fetch() async { + final url = Uri.parse( + ApiConfig.savedViews, + ).replace(queryParameters: {'module': list.module}).toString(); + final response = await _api.get(url); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Could not load saved views.'); + } + final body = response.data!; + final rows = body['saved_views']; + return SavedViewsState( + views: rows is List + ? rows + .whereType>() + .map(SavedView.fromJson) + .toList(growable: false) + : const [], + limit: (body['limit'] as num?)?.toInt(), + ); + } + + /// Read the list again. The rows already on screen stay until the answer + /// arrives, rather than blanking the sheet between a write and its reload. + Future reload() async { + state = await AsyncValue.guard(_fetch); + } + + Future save(String name, Map> filters) => + _write( + _api.post(ApiConfig.savedViews, { + 'module': list.module, + 'name': name.trim(), + 'filters': filters, + }), + 'Could not save the view.', + ); + + Future rename(String id, String name) => _write( + _api.patch(ApiConfig.savedView(id), {'name': name.trim()}), + 'Could not rename the view.', + ); + + Future remove(String id) => _write( + _api.delete(ApiConfig.savedView(id)), + 'Could not delete the view.', + ); + + Future _write( + Future>> request, + String fallback, + ) async { + final response = await request; + if (!ref.mounted) return null; + if (!response.success) { + if (response.statusCode == 404) { + await reload(); + return 'That view no longer exists.'; + } + return firstSentence(response.data) ?? response.message ?? fallback; + } + await reload(); + return null; + } +} + +/// The first message in a DRF rejection. The saved-views API writes each as a +/// whole sentence, so the field name in front of it is left off, as on the web. +String? firstSentence(Map? body) { + if (body == null || body.isEmpty) return null; + final first = body.values.first; + final text = first is List && first.isNotEmpty ? first.first : first; + return text is String && text.trim().isNotEmpty ? text : null; +} + +/// One per list while its sheet is open, so every opening reads fresh and no +/// view outlives a sign-out or an org switch. +final savedViewsProvider = AsyncNotifierProvider.autoDispose + .family( + SavedViewsNotifier.new, + ); diff --git a/mobile/lib/providers/tickets_provider.dart b/mobile/lib/providers/tickets_provider.dart index 78ee319f..c05d5d9b 100644 --- a/mobile/lib/providers/tickets_provider.dart +++ b/mobile/lib/providers/tickets_provider.dart @@ -164,19 +164,66 @@ class TicketsNotifier extends AsyncNotifier { } /// Fetch a single ticket (just the Ticket object). + /// + /// Never follows a merge: the edit form saves back to [id], so handing it + /// the ticket a merged one redirects to would write that ticket's fields + /// onto the merged one. A merged ticket is null here, as it always was. + /// + /// Null on any failure: the edit form has one error state, and it only + /// needs to know there is nothing to edit. Future getTicketById(String id) async { - final detail = await getTicketDetail(id); - return detail?.ticketObj; + try { + final detail = await _fetchTicketDetail(id, followMerge: false); + return detail?.ticketObj; + } on TicketLoadFailure { + return null; + } } /// Fetch the full ticket detail (incl. comments + activities + permissions). - Future getTicketDetail(String id) async { - try { - final response = await _apiService.get(ApiConfig.ticketDetail(id)); - if (!response.success || response.data == null) return null; + /// + /// Null only for a 404, which is also what a ticket the caller may not open + /// answers, so the two cannot be told apart. Any other failure (offline, a + /// 500, a body that will not parse) throws [TicketLoadFailure], so a screen + /// can offer a retry instead of saying the ticket does not exist. + /// + /// A merged ticket answers with `redirect_to`, the ticket it was merged + /// into, instead of itself. That one is fetched and returned in its place, + /// as the web redirects, so the result's `ticketObj.id` differs from [id] + /// and no screen ever offers a status change on the merged ticket (the API + /// refuses one until it is unmerged). + Future getTicketDetail(String id) => + _fetchTicketDetail(id, followMerge: true); + Future _fetchTicketDetail( + String id, { + required bool followMerge, + }) async { + final response = await _apiService.get(ApiConfig.ticketDetail(id)); + if (response.statusCode == 404) return null; + if (!response.success || response.data == null) { + throw TicketLoadFailure( + response.statusCode == 0 + ? (response.message ?? TicketLoadFailure.fallback) + : TicketLoadFailure.fallback, + ); + } + + // Followed once: a merge never targets a ticket that is itself merged, + // and a second hop would mean something is wrong, not a longer chain. + final redirectTo = response.data!['redirect_to']; + if (redirectTo is String && redirectTo.isNotEmpty) { + if (followMerge) { + return _fetchTicketDetail(redirectTo, followMerge: false); + } + throw const TicketLoadFailure(TicketLoadFailure.fallback); + } + + try { final ticketData = response.data!['cases_obj'] as Map?; - if (ticketData == null) return null; + if (ticketData == null) { + throw const TicketLoadFailure(TicketLoadFailure.fallback); + } var ticketObj = Ticket.fromJson(ticketData); // Public + internal comments are sent in two separate arrays. We tag @@ -253,8 +300,11 @@ class TicketsNotifier extends AsyncNotifier { .map((t) => t.comment.id) .toSet(), ); + } on TicketLoadFailure { + rethrow; } catch (_) { - return null; + // A body that does not parse is a failed load, not a missing ticket. + throw const TicketLoadFailure(TicketLoadFailure.fallback); } } @@ -640,6 +690,20 @@ final ticketsErrorProvider = Provider((ref) { return ref.watch(ticketsProvider).error?.toString(); }); +/// A ticket detail that could not be loaded for a reason other than a 404: +/// offline, a server error, a body that would not parse. [message] is what to +/// show beside a retry. A 404 is not this: it is a null result. +class TicketLoadFailure implements Exception { + final String message; + const TicketLoadFailure(this.message); + + static const fallback = + 'The server could not load this ticket. Try again in a moment.'; + + @override + String toString() => 'TicketLoadFailure: $message'; +} + /// Bundled detail-fetch result. The mobile detail screen needs more than /// just the Ticket object, also activities and the per-user permission to /// add comments. @@ -761,9 +825,10 @@ class TicketTreeNode { /// which `_open_descendants` touches. /// /// Mirrors `_open_descendants` in `cases/parent_views.py`, including the two - /// easy mistakes: a node counts only when it is open AND active, and - /// recursion goes through closed nodes anyway, so an open grandchild under a - /// closed child still cascades. + /// easy mistakes: a node counts only when it is open (neither Closed nor + /// Duplicate, since a merged ticket's status is not the cascade's to change) + /// AND active, and recursion goes through closed nodes anyway, so an open + /// grandchild under a closed child still cascades. /// /// `frontend/src/routes/(app)/tickets/[id]/close.js` carries the same rules. List openDescendantsOf(String id) { @@ -772,7 +837,8 @@ class TicketTreeNode { final out = []; void walk(TicketTreeNode node) { for (final child in node.children) { - if (child.status != 'Closed' && child.isActive) out.add(child); + final open = child.status != 'Closed' && child.status != 'Duplicate'; + if (open && child.isActive) out.add(child); walk(child); } } @@ -865,6 +931,32 @@ class TicketListFilters { this.createdBefore, }); + /// A saved view's filters (`{param: [value, ...]}`) as the queue's state: + /// what [ticketListQuery] sends, read back. One status is the single + /// `status`, several the `statusList` the quick chips use. Watching is an + /// endpoint rather than a filter, so a view never turns it on. + factory TicketListFilters.fromQuery(Map> query) { + String? one(String key) { + final values = query[key]; + return values == null || values.isEmpty ? null : values.first; + } + + final statuses = query['status'] ?? const []; + return TicketListFilters( + search: one('search') ?? '', + status: statuses.length == 1 ? statuses.first : null, + statusList: statuses.length > 1 ? statuses : const [], + priority: one('priority'), + accountId: one('account'), + caseType: one('case_type'), + assigneeIds: query['assigned_to'] ?? const [], + tagIds: query['tags'] ?? const [], + slaBreached: one('sla_breached') == 'true', + createdAfter: DateTime.tryParse(one('created_at__gte') ?? ''), + createdBefore: DateTime.tryParse(one('created_at__lte') ?? ''), + ); + } + bool get hasAny => search.isNotEmpty || status != null || diff --git a/mobile/lib/routes/app_router.dart b/mobile/lib/routes/app_router.dart index 29da8af1..02d0b98d 100644 --- a/mobile/lib/routes/app_router.dart +++ b/mobile/lib/routes/app_router.dart @@ -61,6 +61,7 @@ import '../screens/settings/settings_hub_screen.dart'; import '../screens/settings/tags_screen.dart'; import '../screens/settings/web_form_detail_screen.dart'; import '../screens/settings/web_forms_screen.dart'; +import '../screens/settings/audit_log_screen.dart'; import '../screens/settings/webhook_detail_screen.dart'; import '../screens/settings/webhooks_screen.dart'; import '../screens/settings/team_screen.dart'; @@ -185,6 +186,10 @@ class AppRoutes { static String settingsWebhook(String id) => '/more/settings/webhooks/$id'; + /// The security audit log. Admin-only to read; the screen says so to a + /// member. + static const String settingsAuditLog = '/more/settings/audit-log'; + /// Your OWN tokens, under profile rather than under settings. Settings /// holds the admin's org-wide oversight list, which 403s a member; this /// is the self-scoped half every member may use. @@ -719,6 +724,12 @@ final appRouterProvider = Provider((ref) { webhookId: state.pathParameters['webhookId'] ?? '', ), ), + GoRoute( + path: AppRoutes.settingsAuditLog, + name: 'settingsAuditLog', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const AuditLogScreen(), + ), GoRoute( path: AppRoutes.settingsRouting, name: 'settingsRouting', diff --git a/mobile/lib/screens/accounts/account_detail_screen.dart b/mobile/lib/screens/accounts/account_detail_screen.dart index 8e46e5c5..08cfedd5 100644 --- a/mobile/lib/screens/accounts/account_detail_screen.dart +++ b/mobile/lib/screens/accounts/account_detail_screen.dart @@ -41,14 +41,21 @@ class _AccountDetailScreenState extends ConsumerState { _loading = true; _error = null; }); + var notFound = false; final account = await ref .read(accountsProvider.notifier) - .getAccount(widget.accountId); + .getAccount(widget.accountId, onNotFound: () => notFound = true); if (!mounted) return; setState(() { _loading = false; _account = account; - if (account == null) _error = 'Could not load this account'; + // Only a 404 says "not found", and it covers both a deleted account + // and one this user may not open. Offline or a 500 is "could not load". + if (account == null) { + _error = notFound + ? 'This account does not exist, or you do not have access to it' + : 'Could not load this account'; + } }); } @@ -157,7 +164,11 @@ class _AccountDetailScreenState extends ConsumerState { children: [ Icon(LucideIcons.alertCircle, size: 40, color: AppColors.danger500), const SizedBox(height: 12), - Text(_error ?? 'Not found', style: AppTypography.body), + Text( + _error ?? 'Could not load this account', + textAlign: TextAlign.center, + style: AppTypography.body, + ), const SizedBox(height: 16), FilledButton(onPressed: _load, child: const Text('Retry')), ], diff --git a/mobile/lib/screens/accounts/accounts_list_screen.dart b/mobile/lib/screens/accounts/accounts_list_screen.dart index db174bc2..eb85a1b8 100644 --- a/mobile/lib/screens/accounts/accounts_list_screen.dart +++ b/mobile/lib/screens/accounts/accounts_list_screen.dart @@ -9,8 +9,10 @@ import '../../config/api_config.dart'; import '../../core/theme/theme.dart'; import '../../data/models/account.dart'; import '../../providers/accounts_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../routes/app_router.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; /// The accounts list. /// @@ -60,6 +62,29 @@ class _AccountsListScreenState extends ConsumerState { }); } + /// The search box and the Open/Closed toggle. + static const _savedViewKeys = {'search', 'is_active'}; + + /// The list's query as a view keeps it. Open accounts are what this screen + /// and the web show by default, so `is_active=true` is left out and only a + /// view of closed accounts says which half it is. + Future> _savedViewQuery() async { + final query = {...ref.read(accountsProvider.notifier).filterQuery}; + if (query['is_active'] == 'true') query.remove('is_active'); + return query; + } + + void _applySavedView(Map> filters) { + final search = filters['search']?.first ?? ''; + final closed = filters['is_active']?.first == 'false'; + _debounce?.cancel(); + _searchController.text = search; + setState(() => _showClosed = closed); + ref + .read(accountsProvider.notifier) + .applyView(search: search, closed: closed); + } + @override Widget build(BuildContext context) { final async = ref.watch(accountsProvider); @@ -72,6 +97,12 @@ class _AccountsListScreenState extends ConsumerState { elevation: 0, scrolledUnderElevation: 1, actions: [ + SavedViewsButton( + list: SavedViewList.accounts, + keys: _savedViewKeys, + currentQuery: _savedViewQuery, + onApply: _applySavedView, + ), ExportCsvButton( endpoint: ApiConfig.accountsExport, filePrefix: 'accounts', diff --git a/mobile/lib/screens/contacts/contact_detail_screen.dart b/mobile/lib/screens/contacts/contact_detail_screen.dart index 12ae5c59..4c5ae6a6 100644 --- a/mobile/lib/screens/contacts/contact_detail_screen.dart +++ b/mobile/lib/screens/contacts/contact_detail_screen.dart @@ -38,14 +38,21 @@ class _ContactDetailScreenState extends ConsumerState { _loading = true; _error = null; }); + var notFound = false; final contact = await ref .read(contactsProvider.notifier) - .getContact(widget.contactId); + .getContact(widget.contactId, onNotFound: () => notFound = true); if (!mounted) return; setState(() { _loading = false; _contact = contact; - if (contact == null) _error = 'Could not load this contact'; + // Only a 404 says "not found", and it covers both a deleted contact + // and one this user may not open. Offline or a 500 is "could not load". + if (contact == null) { + _error = notFound + ? 'This contact does not exist, or you do not have access to them' + : 'Could not load this contact'; + } }); } @@ -146,7 +153,11 @@ class _ContactDetailScreenState extends ConsumerState { children: [ Icon(LucideIcons.alertCircle, size: 40, color: AppColors.danger500), const SizedBox(height: 12), - Text(_error ?? 'Not found', style: AppTypography.body), + Text( + _error ?? 'Could not load this contact', + textAlign: TextAlign.center, + style: AppTypography.body, + ), const SizedBox(height: 16), FilledButton(onPressed: _load, child: const Text('Retry')), ], diff --git a/mobile/lib/screens/contacts/contacts_list_screen.dart b/mobile/lib/screens/contacts/contacts_list_screen.dart index 339c4e99..b1c7253c 100644 --- a/mobile/lib/screens/contacts/contacts_list_screen.dart +++ b/mobile/lib/screens/contacts/contacts_list_screen.dart @@ -10,8 +10,10 @@ import '../../core/theme/theme.dart'; import '../../data/models/contact.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/csv_import_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../routes/app_router.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; import '../../widgets/forms/csv_import_sheet.dart'; /// The contacts list. @@ -56,6 +58,17 @@ class _ContactsListScreenState extends ConsumerState { }); } + /// Search is the one filter this screen has, so it is all a view saved + /// here holds; a web view's other filters are named by the sheet instead. + static const _savedViewKeys = {'search'}; + + void _applySavedView(Map> filters) { + final search = filters['search']?.first ?? ''; + _debounce?.cancel(); + _searchController.text = search; + ref.read(contactsProvider.notifier).search(search); + } + @override Widget build(BuildContext context) { final async = ref.watch(contactsProvider); @@ -79,6 +92,13 @@ class _ContactsListScreenState extends ConsumerState { onImported: () => ref.read(contactsProvider.notifier).refresh(), ), ), + SavedViewsButton( + list: SavedViewList.contacts, + keys: _savedViewKeys, + currentQuery: () async => + ref.read(contactsProvider.notifier).filterQuery, + onApply: _applySavedView, + ), ExportCsvButton( endpoint: ApiConfig.contactsExport, filePrefix: 'contacts', diff --git a/mobile/lib/screens/deals/deal_detail_screen.dart b/mobile/lib/screens/deals/deal_detail_screen.dart index a837a225..48dda87a 100644 --- a/mobile/lib/screens/deals/deal_detail_screen.dart +++ b/mobile/lib/screens/deals/deal_detail_screen.dart @@ -43,6 +43,11 @@ class _DealDetailScreenState extends ConsumerState bool _isInvoicing = false; String? _error; + /// True when the server answered 404, which it does for a deal that does + /// not exist and for one this user may not open alike. Any other failure + /// (offline, a 500) is "could not load", never "not found". + bool _notFound = false; + @override void initState() { super.initState(); @@ -68,9 +73,10 @@ class _DealDetailScreenState extends ConsumerState _error = null; }); + var notFound = false; final detail = await ref .read(dealsProvider.notifier) - .getDealDetail(widget.dealId); + .getDealDetail(widget.dealId, onNotFound: () => notFound = true); if (mounted) { setState(() { @@ -79,7 +85,10 @@ class _DealDetailScreenState extends ConsumerState _deal = detail.deal; _customFieldDefinitions = detail.customFieldDefinitions; } else if (isInitialLoad) { - _error = 'Failed to load deal'; + _notFound = notFound; + _error = notFound + ? 'It may have been deleted, or you may not have access to it' + : 'Check your connection and try again'; } }); } @@ -133,10 +142,12 @@ class _DealDetailScreenState extends ConsumerState child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const EmptyState( + EmptyState( icon: LucideIcons.briefcase, - title: 'Deal not found', - description: 'This deal may have been deleted', + title: _notFound + ? 'Deal not found' + : 'Could not load this deal', + description: _error ?? 'Check your connection and try again', ), const SizedBox(height: 16), TextButton(onPressed: _fetchDeal, child: const Text('Retry')), @@ -941,8 +952,7 @@ class _DealDetailScreenState extends ConsumerState .watch(currentUserProvider) ?.email .toLowerCase(); - final isAdmin = - ref.watch(selectedOrgProvider)?.role == 'ADMIN'; + final isAdmin = ref.watch(isOrgAdminProvider); final isAuthor = currentEmail != null && comment.commentedByEmail?.toLowerCase() == currentEmail; diff --git a/mobile/lib/screens/deals/deals_list_screen.dart b/mobile/lib/screens/deals/deals_list_screen.dart index 42551490..1cb8e821 100644 --- a/mobile/lib/screens/deals/deals_list_screen.dart +++ b/mobile/lib/screens/deals/deals_list_screen.dart @@ -13,10 +13,12 @@ import '../../providers/auth_provider.dart'; import '../../providers/deal_pipelines_provider.dart'; import '../../providers/deals_provider.dart'; import '../../providers/lookup_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../widgets/cards/deal_card.dart'; import '../../widgets/misc/kanban_column.dart'; import '../../widgets/common/common.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; enum ViewMode { kanban, list } @@ -304,6 +306,50 @@ class _DealsListScreenState extends ConsumerState { }); } + // --------------------------------------------------------------------- + // Saved views + // --------------------------------------------------------------------- + + /// What `DealsNotifier.filterQuery` sends, with `search` for the name + /// match (see `DealFilters.fromQuery`), and so what a saved view holds. + static const _savedViewKeys = { + 'pipeline', + 'search', + 'stage', + 'assigned_to', + 'tags', + 'created_at__gte', + 'created_at__lte', + 'closed_on__gte', + 'closed_on__lte', + 'amount__gte', + 'amount__lte', + 'rotten', + }; + + /// The list's query as a view keeps it: this screen sends its search as + /// `name`, saved as `search` so the web's deal list fills its box with it. + Future> _savedViewQuery() async { + final query = { + ...await ref.read(dealsProvider.notifier).filterQuery(), + }; + final name = query.remove('name'); + if (name != null) query['search'] = name; + return query; + } + + void _applySavedView(Map> filters) { + final next = DealFilters.fromQuery(filters); + _searchDebounce?.cancel(); + setState(() { + _searchController.text = next.search ?? ''; + _showSearch = _searchController.text.isNotEmpty; + }); + ref + .read(dealsProvider.notifier) + .applyView(pipelineId: filters['pipeline']?.first, filters: next); + } + // --------------------------------------------------------------------- // Bulk select helpers // --------------------------------------------------------------------- @@ -504,6 +550,13 @@ class _DealsListScreenState extends ConsumerState { ), onPressed: _toggleViewMode, ), + SavedViewsButton( + list: SavedViewList.deals, + keys: _savedViewKeys, + multi: const {'assigned_to', 'tags'}, + currentQuery: _savedViewQuery, + onApply: _applySavedView, + ), ExportCsvButton( endpoint: ApiConfig.opportunitiesExport, filePrefix: 'deals', diff --git a/mobile/lib/screens/invoices/document_adjustments.dart b/mobile/lib/screens/invoices/document_adjustments.dart new file mode 100644 index 00000000..aa0b2b16 --- /dev/null +++ b/mobile/lib/screens/invoices/document_adjustments.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/theme/theme.dart'; + +/// The server's bounds on a discount, in its wording (`LineAmountsMixin` for a +/// line, `validate_document_discount` for a document): a percentage not over +/// 100, and a flat amount not over what it comes off. Null when the API would +/// take it. The inputs here accept digits only and a deal line's discount is +/// checked when the deal is saved, so the server's "cannot be negative" case +/// never reaches this. A hint only: the API still refuses the rest. +String? discountBoundError( + String type, + double value, + double cap, + String capMessage, +) { + if (type == 'PERCENTAGE') { + return value > 100 ? 'A percentage discount cannot exceed 100.' : null; + } + // Compared in cents, as the server stores money, so a float sum does not + // refuse a discount that exactly matches it. + return value > (cap * 100).roundToDouble() / 100 ? capMessage : null; +} + +/// A document's own discount, tax and shipping, as the web builders' adjustments +/// card sets them. The server recomputes every figure on save; [total] is the +/// same ladder in the same order (discount off the subtotal, tax on what is +/// left, shipping added untaxed), shown as a guide. +class DocumentAdjustments { + const DocumentAdjustments({ + this.discountType = '', + this.discountValue = 0, + this.taxRate = 0, + this.shipping = 0, + }); + + /// '' for none, `PERCENTAGE` or `FIXED`: `invoices.models.DISCOUNT_TYPES`. + final String discountType; + final double discountValue; + final double taxRate; + + /// Invoices only. An estimate or a schedule has no shipping. + final double shipping; + + double discountAmount(double subtotal) => switch (discountType) { + 'PERCENTAGE' => subtotal * discountValue / 100, + 'FIXED' => discountValue, + _ => 0, + }; + + double taxAmount(double subtotal) => + (subtotal - discountAmount(subtotal)) * taxRate / 100; + + double total(double subtotal) => + subtotal - discountAmount(subtotal) + taxAmount(subtotal) + shipping; + + /// The API's refusal for this discount on [subtotal], or null. + String? discountError(double subtotal) => discountType.isEmpty + ? null + : discountBoundError( + discountType, + discountValue, + subtotal, + 'A discount cannot exceed the subtotal.', + ); + + /// The API's refusal for this tax rate, or null. The field takes digits + /// only, so only the top of the 0 to 100 range can be crossed. + String? get taxError => taxRate > 100 ? 'Tax rate cannot exceed 100.' : null; + + /// Whether the API would take these adjustments on [subtotal]. Shipping + /// needs no check: its field cannot go negative. + bool isValidFor(double subtotal) => + discountError(subtotal) == null && taxError == null; + + /// The web builders' payload shape: the discount only once a type is chosen, + /// tax and shipping only when set. + Map toPayload() => { + if (discountType.isNotEmpty) ...{ + 'discount_type': discountType, + 'discount_value': discountValue.toStringAsFixed(2), + }, + if (taxRate != 0) 'tax_rate': taxRate.toStringAsFixed(2), + if (shipping != 0) 'shipping_amount': shipping.toStringAsFixed(2), + }; +} + +/// The adjustments card shared by the invoice, estimate and schedule forms: +/// discount type and amount, tax rate, and shipping where the document has it. +class AdjustmentsSection extends StatefulWidget { + const AdjustmentsSection({ + super.key, + required this.value, + required this.subtotal, + required this.symbol, + required this.onChanged, + this.showShipping = false, + }); + + final DocumentAdjustments value; + final double subtotal; + final String symbol; + final bool showShipping; + final ValueChanged onChanged; + + @override + State createState() => _AdjustmentsSectionState(); +} + +class _AdjustmentsSectionState extends State { + late final _discount = TextEditingController( + text: _text(widget.value.discountValue), + ); + late final _tax = TextEditingController(text: _text(widget.value.taxRate)); + late final _shipping = TextEditingController( + text: _text(widget.value.shipping), + ); + + static const _types = { + '': 'No discount', + 'PERCENTAGE': 'Percentage', + 'FIXED': 'Fixed amount', + }; + + static final _decimal = [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ]; + + static String _text(double value) => value == 0 ? '' : value.toString(); + + @override + void dispose() { + _discount.dispose(); + _tax.dispose(); + _shipping.dispose(); + super.dispose(); + } + + double _read(TextEditingController c) => double.tryParse(c.text.trim()) ?? 0; + + void _emit({String? discountType}) { + widget.onChanged( + DocumentAdjustments( + discountType: discountType ?? widget.value.discountType, + discountValue: _read(_discount), + taxRate: _read(_tax), + shipping: widget.showShipping ? _read(_shipping) : 0, + ), + ); + } + + Widget _number( + TextEditingController controller, + String label, { + String? prefix, + String? suffix, + String? error, + }) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: _decimal, + onChanged: (_) => _emit(), + decoration: InputDecoration( + labelText: label, + prefixText: prefix, + suffixText: suffix, + errorText: error, + errorMaxLines: 3, + border: const OutlineInputBorder(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final type = widget.value.discountType; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: type, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Discount', + border: OutlineInputBorder(), + ), + items: [ + for (final entry in _types.entries) + DropdownMenuItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: (value) => _emit(discountType: value ?? ''), + ), + if (type.isNotEmpty) ...[ + const SizedBox(height: 12), + _number( + _discount, + type == 'PERCENTAGE' ? 'Percent off' : 'Amount off', + prefix: type == 'FIXED' ? widget.symbol : null, + suffix: type == 'PERCENTAGE' ? '%' : null, + error: widget.value.discountError(widget.subtotal), + ), + ], + const SizedBox(height: 12), + _number(_tax, 'Tax rate', suffix: '%', error: widget.value.taxError), + if (widget.showShipping) ...[ + const SizedBox(height: 12), + _number(_shipping, 'Shipping', prefix: widget.symbol), + ], + const SizedBox(height: 8), + Text( + widget.showShipping + ? 'Tax applies to the subtotal after the discount. Shipping is ' + 'added afterwards and is not taxed.' + : 'Tax applies to the subtotal after the discount.', + style: AppTypography.caption.copyWith(color: AppColors.textSecondary), + ), + ], + ); + } +} diff --git a/mobile/lib/screens/invoices/invoice_detail_screen.dart b/mobile/lib/screens/invoices/invoice_detail_screen.dart index 461e7941..e958161e 100644 --- a/mobile/lib/screens/invoices/invoice_detail_screen.dart +++ b/mobile/lib/screens/invoices/invoice_detail_screen.dart @@ -218,6 +218,14 @@ class _InvoiceDetailScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), child: Column( children: [ + // The web detail's breakdown: discount and shipping only when set. + _amountRow('Subtotal', money(invoice.subtotal, symbol)), + if (invoice.discountAmount > 0) + _amountRow('Discount', '-${money(invoice.discountAmount, symbol)}'), + _amountRow('Tax', money(invoice.taxAmount, symbol)), + if (invoice.shippingAmount > 0) + _amountRow('Shipping', money(invoice.shippingAmount, symbol)), + const Divider(height: 20), _amountRow('Total', money(invoice.totalAmount, symbol)), if (invoice.amountPaid > 0) _amountRow('Paid', money(invoice.amountPaid, symbol)), diff --git a/mobile/lib/screens/invoices/invoices_list_screen.dart b/mobile/lib/screens/invoices/invoices_list_screen.dart index f19c27b8..d4f977e4 100644 --- a/mobile/lib/screens/invoices/invoices_list_screen.dart +++ b/mobile/lib/screens/invoices/invoices_list_screen.dart @@ -11,9 +11,11 @@ import '../../core/theme/theme.dart'; import '../../data/models/deal.dart' show Currency; import '../../data/models/invoice.dart'; import '../../providers/invoices_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../routes/app_router.dart'; import '../../widgets/common/badge.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; import 'invoice_format.dart'; /// The invoices list. @@ -73,6 +75,22 @@ class _InvoicesListScreenState extends ConsumerState { }); } + /// The search box and the status chips. The list's fixed order (`sort`) + /// is not a filter and is not saved. + static const _savedViewKeys = {'search', 'status'}; + + void _applySavedView(Map> filters) { + final search = filters['search']?.first ?? ''; + _debounce?.cancel(); + _searchController.text = search; + ref + .read(invoicesProvider.notifier) + .applyView( + search: search, + status: InvoiceStatus.fromString(filters['status']?.first), + ); + } + @override Widget build(BuildContext context) { final async = ref.watch(invoicesProvider); @@ -85,6 +103,13 @@ class _InvoicesListScreenState extends ConsumerState { elevation: 0, scrolledUnderElevation: 1, actions: [ + SavedViewsButton( + list: SavedViewList.invoices, + keys: _savedViewKeys, + currentQuery: () async => + ref.read(invoicesProvider.notifier).filterQuery, + onApply: _applySavedView, + ), ExportCsvButton( endpoint: ApiConfig.invoicesExport, filePrefix: 'invoices', diff --git a/mobile/lib/screens/invoices/line_item_sheet.dart b/mobile/lib/screens/invoices/line_item_sheet.dart index 7046e22a..ee30cfcb 100644 --- a/mobile/lib/screens/invoices/line_item_sheet.dart +++ b/mobile/lib/screens/invoices/line_item_sheet.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/theme/theme.dart'; import '../../data/models/product.dart'; import '../../providers/invoice_extras_provider.dart'; +import 'document_adjustments.dart'; import 'invoice_format.dart'; /// One line being written, before it is sent. @@ -52,6 +53,18 @@ class LineItemDraft { : discountValue); } + /// The API's refusal for this line's discount, or null. A flat discount + /// carried from a deal can outgrow the line once its quantity or price is + /// lowered, and the server refuses that with this message. + String? get discountError => discountValue == 0 + ? null + : discountBoundError( + discountType, + discountValue, + quantity * unitPrice, + "A discount cannot exceed the line's amount.", + ); + /// "less 10%" or "less €5.00", or null for a line with no discount. String? discountLabel(String symbol) { if (discountValue == 0) return null; @@ -185,6 +198,13 @@ class LineItemsSection extends StatelessWidget { color: AppColors.textSecondary, ), ), + if (item.discountError != null) + Text( + item.discountError!, + style: AppTypography.caption.copyWith( + color: AppColors.danger600, + ), + ), ], ), ), @@ -242,6 +262,7 @@ class _LineItemSheetState extends ConsumerState<_LineItemSheet> { String? _productId; String? _nameError; String? _quantityError; + String? _priceError; @override void initState() { @@ -295,17 +316,20 @@ class _LineItemSheetState extends ConsumerState<_LineItemSheet> { }); if (_nameError != null || _quantityError != null) return; - Navigator.of(context).pop( - LineItemDraft( - name: name, - description: _description.text.trim(), - quantity: quantity!, - unitPrice: double.tryParse(_unitPrice.text.trim()) ?? 0, - productId: _productId, - discountType: widget.existing?.discountType ?? '', - discountValue: widget.existing?.discountValue ?? 0, - ), + final line = LineItemDraft( + name: name, + description: _description.text.trim(), + quantity: quantity!, + unitPrice: double.tryParse(_unitPrice.text.trim()) ?? 0, + productId: _productId, + discountType: widget.existing?.discountType ?? '', + discountValue: widget.existing?.discountValue ?? 0, ); + // The deal's discount stays with the line, so the price has to cover it. + setState(() => _priceError = line.discountError); + if (_priceError != null) return; + + Navigator.of(context).pop(line); } @override @@ -414,6 +438,8 @@ class _LineItemSheetState extends ConsumerState<_LineItemSheet> { labelText: 'Unit price', prefixText: widget.symbol, border: const OutlineInputBorder(), + errorText: _priceError, + errorMaxLines: 3, ), ), ), diff --git a/mobile/lib/screens/invoices/new_estimate_screen.dart b/mobile/lib/screens/invoices/new_estimate_screen.dart index 797d8520..dcc60579 100644 --- a/mobile/lib/screens/invoices/new_estimate_screen.dart +++ b/mobile/lib/screens/invoices/new_estimate_screen.dart @@ -9,6 +9,7 @@ import '../../data/models/lookup_models.dart'; import '../../providers/auth_provider.dart'; import '../../providers/invoice_extras_provider.dart'; import '../../providers/lookup_provider.dart'; +import 'document_adjustments.dart'; import 'invoice_format.dart'; import 'line_item_sheet.dart'; @@ -55,6 +56,7 @@ class _NewEstimateScreenState extends ConsumerState { ).add(const Duration(days: 30)); final List _items = []; + DocumentAdjustments _adjustments = const DocumentAdjustments(); bool _saving = false; String? _error; @@ -96,8 +98,6 @@ class _NewEstimateScreenState extends ConsumerState { String get _symbol => Currency.fromString(_currency).symbol; - double get _subtotal => _items.fold(0, (sum, item) => sum + item.netAmount); - bool get _datesBackwards => _expiryDate != null && _expiryDate!.isBefore(_issueDate); @@ -138,11 +138,15 @@ class _NewEstimateScreenState extends ConsumerState { final usable = _items .where((i) => i.name.trim().isNotEmpty && i.quantity > 0) .toList(); + // What the sent lines add up to, before the estimate's own discount. + final subtotal = usable.fold(0.0, (sum, item) => sum + item.netAmount); final ready = accountId != null && contactId != null && _title.text.trim().isNotEmpty && usable.isNotEmpty && + usable.every((i) => i.discountError == null) && + _adjustments.isValidFor(subtotal) && !_datesBackwards && !_saving; @@ -280,6 +284,14 @@ class _NewEstimateScreenState extends ConsumerState { }), ), ]), + _card('Discount and tax', [ + AdjustmentsSection( + value: _adjustments, + subtotal: subtotal, + symbol: _symbol, + onChanged: (value) => setState(() => _adjustments = value), + ), + ]), _card('Anything else', [ DropdownButtonFormField( initialValue: _currency, @@ -320,6 +332,7 @@ class _NewEstimateScreenState extends ConsumerState { ), ), _totalBar( + _adjustments.total(subtotal), ready ? () => _submit( accountId: accountId, @@ -370,7 +383,7 @@ class _NewEstimateScreenState extends ConsumerState { ); } - Widget _totalBar(VoidCallback? onSubmit) { + Widget _totalBar(double total, VoidCallback? onSubmit) { return Container( decoration: BoxDecoration( color: AppColors.surface, @@ -392,15 +405,17 @@ class _NewEstimateScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - money(_subtotal, _symbol), + money(total, _symbol), style: AppTypography.h3.copyWith( fontWeight: FontWeight.w700, ), ), Text( - // A deal's line discounts are already in; tax and the - // estimate's own discount are not. - 'before tax and any overall discount', + // The server's ladder, previewed: a deal's line discounts, + // then the estimate's own discount, then tax. + 'with discounts and tax', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTypography.caption.copyWith( color: AppColors.textSecondary, ), @@ -453,6 +468,7 @@ class _NewEstimateScreenState extends ConsumerState { ], if (_notes.text.trim().isNotEmpty) 'notes': _notes.text.trim(), if (_terms.text.trim().isNotEmpty) 'terms': _terms.text.trim(), + ..._adjustments.toPayload(), }; final error = await ref.read(estimatesProvider.notifier).create(payload); diff --git a/mobile/lib/screens/invoices/new_invoice_screen.dart b/mobile/lib/screens/invoices/new_invoice_screen.dart index 6d0db7ae..3e726046 100644 --- a/mobile/lib/screens/invoices/new_invoice_screen.dart +++ b/mobile/lib/screens/invoices/new_invoice_screen.dart @@ -10,6 +10,7 @@ import '../../providers/auth_provider.dart'; import '../../providers/invoices_provider.dart'; import '../../providers/lookup_provider.dart'; import '../../routes/app_router.dart'; +import 'document_adjustments.dart'; import 'invoice_format.dart'; import 'line_item_sheet.dart'; @@ -45,6 +46,7 @@ class _NewInvoiceScreenState extends ConsumerState { DateTime? _customDueDate; final List _items = []; + DocumentAdjustments _adjustments = const DocumentAdjustments(); bool _saving = false; String? _error; @@ -74,21 +76,23 @@ class _NewInvoiceScreenState extends ConsumerState { String get _symbol => Currency.fromString(_currency).symbol; - /// Each line's `netAmount` summed, matching `InvoiceLineItem`. Shown as a - /// guide only: the server recomputes every total on save and its figure is - /// the one that ends up on the invoice. - double get _subtotal => _items.fold(0, (sum, item) => sum + item.netAmount); - /// Lines with nothing on them are dropped rather than rejected, so a half /// typed row does not block the save. List get _usableItems => _items.where((i) => i.name.trim().isNotEmpty && i.quantity > 0).toList(); + /// The sent lines' `netAmount` summed, matching `InvoiceLineItem`. Shown as + /// a guide only: the server recomputes every total on save and its figure + /// is the one that ends up on the invoice. + double get _subtotal => + _usableItems.fold(0, (sum, item) => sum + item.netAmount); + bool get _ready => _accountId != null && _contactId != null && _title.text.trim().isNotEmpty && _usableItems.isNotEmpty && + _adjustments.isValidFor(_subtotal) && !_saving; @override @@ -124,6 +128,15 @@ class _NewInvoiceScreenState extends ConsumerState { ), _whoAndWhen(accounts, contacts), _lineItems(), + _card('Discount, tax and shipping', [ + AdjustmentsSection( + value: _adjustments, + subtotal: _subtotal, + symbol: _symbol, + showShipping: true, + onChanged: (value) => setState(() => _adjustments = value), + ), + ]), _extras(), ], ), @@ -370,16 +383,17 @@ class _NewInvoiceScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - money(_subtotal, _symbol), + money(_adjustments.total(_subtotal), _symbol), style: AppTypography.h3.copyWith( fontWeight: FontWeight.w700, ), ), Text( - // Said plainly rather than labelled "Total": tax, discount - // and shipping are applied server-side and are not on this - // form, so this figure can be lower than the invoice. - 'before any tax or discount', + // The server's ladder, previewed; its figure is the one + // that ends up on the invoice. + 'with discount, tax and shipping', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTypography.caption.copyWith( color: AppColors.textSecondary, ), @@ -427,6 +441,7 @@ class _NewInvoiceScreenState extends ConsumerState { if (_paymentTerms == 'CUSTOM' && _customDueDate != null) 'due_date': DateFormat('yyyy-MM-dd').format(_customDueDate!), if (_notes.text.trim().isNotEmpty) 'notes': _notes.text.trim(), + ..._adjustments.toPayload(), }; final result = await ref diff --git a/mobile/lib/screens/invoices/new_recurring_screen.dart b/mobile/lib/screens/invoices/new_recurring_screen.dart index d250a4d7..c5e07f10 100644 --- a/mobile/lib/screens/invoices/new_recurring_screen.dart +++ b/mobile/lib/screens/invoices/new_recurring_screen.dart @@ -12,6 +12,7 @@ import '../../data/models/recurring_invoice.dart'; import '../../providers/auth_provider.dart'; import '../../providers/invoice_extras_provider.dart'; import '../../providers/lookup_provider.dart'; +import 'document_adjustments.dart'; import 'invoice_format.dart'; import 'line_item_sheet.dart'; @@ -20,8 +21,10 @@ import 'line_item_sheet.dart'; /// The same shape as the new-invoice form, since /// `RecurringInvoiceCreateSerializer` takes the same required pair /// (`account_id`, `contact_id`), the same contact-belongs-to-account rule and -/// the same nested `line_items`. It reuses the line builder rather than -/// growing a second one. +/// the same nested `line_items`. It reuses the invoice form's line list +/// ([LineItemsSection]) and adjustments card rather than growing second +/// copies, so a schedule's lines and discounts are sent exactly as an +/// invoice's are and every invoice it raises carries them. /// /// **One field the web exposes is deliberately not here.** /// `next_generation_date` and `start_date` are separate columns and the web @@ -51,6 +54,7 @@ class _NewRecurringScreenState extends ConsumerState { bool _autoSend = false; final List _items = []; + DocumentAdjustments _adjustments = const DocumentAdjustments(); bool _saving = false; String? _error; @@ -78,22 +82,29 @@ class _NewRecurringScreenState extends ConsumerState { String get _symbol => Currency.fromString(_currency).symbol; - double get _subtotal => _items.fold(0, (sum, item) => sum + item.netAmount); - List get _usableItems => _items.where((i) => i.name.trim().isNotEmpty && i.quantity > 0).toList(); + double get _subtotal => + _usableItems.fold(0, (sum, item) => sum + item.netAmount); + /// The interval, or null when it is missing or not a positive number. int? get _customDaysValue { final parsed = int.tryParse(_customDays.text.trim()); return (parsed == null || parsed < 1) ? null : parsed; } + /// Lines are optional on a schedule, as on the web and in the API, except + /// one that emails each invoice: the server refuses that with no lines, + /// since it would mail the client a blank invoice. + bool get _needsLine => _autoSend && _usableItems.isEmpty; + bool get _ready => _accountId != null && _contactId != null && _title.text.trim().isNotEmpty && - _usableItems.isNotEmpty && + !_needsLine && + _adjustments.isValidFor(_subtotal) && // A CUSTOM cadence with no interval is refused by the serializer, and // before that guard existed it was accepted and silently billed monthly. cadenceIsComplete(_frequency, _customDaysValue) && @@ -132,7 +143,28 @@ class _NewRecurringScreenState extends ConsumerState { ), _who(accounts, contacts), _cadence(), - _lineItems(), + _card('What to bill each time', [ + LineItemsSection( + items: _items, + symbol: _symbol, + emptyHint: + 'Lines are optional unless each invoice is emailed ' + 'automatically.', + onChanged: (items) => setState(() { + _items + ..clear() + ..addAll(items); + }), + ), + ]), + _card('Discount and tax', [ + AdjustmentsSection( + value: _adjustments, + subtotal: _subtotal, + symbol: _symbol, + onChanged: (value) => setState(() => _adjustments = value), + ), + ]), ], ), ), @@ -291,105 +323,14 @@ class _NewRecurringScreenState extends ConsumerState { ), ), ), + if (_needsLine) + Text( + 'Add at least one line before turning on auto-send.', + style: AppTypography.caption.copyWith(color: AppColors.danger600), + ), ]); } - Widget _lineItems() { - return _card('What to bill each time', [ - if (_items.isEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'A schedule needs at least one line.', - style: AppTypography.caption.copyWith( - color: AppColors.textSecondary, - ), - ), - ), - for (var i = 0; i < _items.length; i++) _lineRow(i), - const SizedBox(height: 4), - SizedBox( - width: double.infinity, - height: 44, - child: OutlinedButton.icon( - onPressed: () => _editLine(null), - icon: const Icon(LucideIcons.plus, size: 16), - label: const Text('Add a line'), - ), - ), - ]); - } - - Widget _lineRow(int index) { - final item = _items[index]; - return InkWell( - onTap: () => _editLine(index), - child: Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: AppColors.gray200)), - ), - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name.isEmpty ? 'Untitled line' : item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTypography.caption.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - '${item.quantityLabel} x ${money(item.unitPrice, _symbol)}', - style: AppTypography.caption.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Text( - money(item.netAmount, _symbol), - style: AppTypography.caption.copyWith( - color: AppColors.textPrimary, - ), - ), - IconButton( - icon: const Icon(LucideIcons.x, size: 16), - tooltip: 'Remove this line', - constraints: const BoxConstraints(minWidth: 44, minHeight: 44), - onPressed: () => setState(() => _items.removeAt(index)), - ), - ], - ), - ), - ); - } - - Future _editLine(int? index) async { - final result = await showLineItemSheet( - context, - existing: index == null ? null : _items[index], - symbol: _symbol, - ); - if (result == null || !mounted) return; - setState(() { - if (index == null) { - _items.add(result); - } else { - _items[index] = result; - } - }); - } - Widget _dateField({ required String label, required DateTime? value, @@ -454,15 +395,15 @@ class _NewRecurringScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - money(_subtotal, _symbol), + money(_adjustments.total(_subtotal), _symbol), style: AppTypography.h3.copyWith( fontWeight: FontWeight.w700, ), ), Text( _frequency == 'CUSTOM' && _customDaysValue != null - ? 'every $_customDaysValue days, before tax' - : '${(recurringFrequencies[_frequency] ?? '').toLowerCase()}, before tax', + ? 'every $_customDaysValue days, with discounts and tax' + : '${(recurringFrequencies[_frequency] ?? '').toLowerCase()}, with discounts and tax', maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTypography.caption.copyWith( @@ -519,6 +460,7 @@ class _NewRecurringScreenState extends ConsumerState { if (_frequency == 'CUSTOM') 'custom_days': _customDaysValue, if (_endDate != null) 'end_date': DateFormat('yyyy-MM-dd').format(_endDate!), + ..._adjustments.toPayload(), }; final error = await ref diff --git a/mobile/lib/screens/leads/lead_detail_screen.dart b/mobile/lib/screens/leads/lead_detail_screen.dart index 4e73f276..8fe6ceb3 100644 --- a/mobile/lib/screens/leads/lead_detail_screen.dart +++ b/mobile/lib/screens/leads/lead_detail_screen.dart @@ -40,6 +40,11 @@ class _LeadDetailScreenState extends ConsumerState bool _isUploadingAttachment = false; String? _error; + /// True when the server answered 404, which it does for a lead that does + /// not exist and for one this user may not open alike. Any other failure + /// (offline, a 500) is "could not load", never "not found". + bool _notFound = false; + @override void initState() { super.initState(); @@ -67,9 +72,10 @@ class _LeadDetailScreenState extends ConsumerState _error = null; }); + var notFound = false; final detail = await ref .read(leadsProvider.notifier) - .getLeadDetail(widget.leadId); + .getLeadDetail(widget.leadId, onNotFound: () => notFound = true); if (mounted) { setState(() { @@ -80,7 +86,10 @@ class _LeadDetailScreenState extends ConsumerState _assignableUsers = detail.assignableUsers; _stage = detail.stage; } else if (isInitialLoad) { - _error = 'Failed to load lead'; + _notFound = notFound; + _error = notFound + ? 'It may have been deleted, or you may not have access to it' + : 'Check your connection and try again'; } }); } @@ -116,10 +125,14 @@ class _LeadDetailScreenState extends ConsumerState children: [ Icon(LucideIcons.userX, size: 48, color: AppColors.gray400), const SizedBox(height: 16), - Text('Lead not found', style: AppTypography.h3), + Text( + _notFound ? 'Lead not found' : 'Could not load this lead', + style: AppTypography.h3, + ), const SizedBox(height: 8), Text( - _error ?? 'This lead may have been deleted', + _error ?? 'Check your connection and try again', + textAlign: TextAlign.center, style: AppTypography.body.copyWith( color: AppColors.textSecondary, ), @@ -1084,8 +1097,7 @@ class _LeadDetailScreenState extends ConsumerState .watch(currentUserProvider) ?.email .toLowerCase(); - final isAdmin = - ref.watch(selectedOrgProvider)?.role == 'ADMIN'; + final isAdmin = ref.watch(isOrgAdminProvider); final isAuthor = currentEmail != null && comment.commentedByEmail?.toLowerCase() == currentEmail; diff --git a/mobile/lib/screens/leads/leads_list_screen.dart b/mobile/lib/screens/leads/leads_list_screen.dart index 904a2d39..5907fdaf 100644 --- a/mobile/lib/screens/leads/leads_list_screen.dart +++ b/mobile/lib/screens/leads/leads_list_screen.dart @@ -9,11 +9,14 @@ import '../../core/theme/theme.dart'; import '../../data/models/models.dart'; import '../../providers/csv_import_provider.dart'; import '../../providers/leads_provider.dart'; +import '../../providers/lookup_provider.dart'; import '../../providers/profile_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../routes/app_router.dart'; import '../../widgets/cards/lead_card.dart'; import '../../widgets/common/common.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; import '../../widgets/forms/csv_import_sheet.dart'; /// Leads list screen: searchable, filterable, paginated against the server. @@ -106,6 +109,40 @@ class _LeadsListScreenState extends ConsumerState { _applyFilters(const LeadFilters()); } + /// What [LeadsNotifier.filterQuery] sends, and so what a saved view holds. + static const _savedViewKeys = { + 'search', + 'status', + 'source', + 'rating', + 'assigned_to', + 'tags', + 'next_follow_up', + }; + + /// Put a saved view's filters on the list, labels and search box included. + Future _applySavedView(Map> filters) async { + final me = ref.read(profileProvider).value?.id; + final assignee = filters['assigned_to']?.first; + final tagId = filters['tags']?.first; + String? tagName; + if (tagId != null) { + final tags = await ref + .read(tagsLookupProvider.future) + .catchError((_) => const []); + tagName = tags.where((t) => t.id == tagId).firstOrNull?.name; + } + if (!mounted) return; + final next = LeadFilters.fromQuery( + filters, + assignedToLabel: assignee != null && assignee == me ? 'Me' : null, + tagLabel: tagName ?? 'selected', + ); + _searchDebounce?.cancel(); + _searchController.text = next.search ?? ''; + _applyFilters(next); + } + @override Widget build(BuildContext context) { final leadsAsync = ref.watch(leadsProvider); @@ -142,6 +179,14 @@ class _LeadsListScreenState extends ConsumerState { onImported: () => ref.read(leadsProvider.notifier).refresh(), ), ), + SavedViewsButton( + list: SavedViewList.leads, + keys: _savedViewKeys, + multi: const {'status'}, + currentQuery: () async => + ref.read(leadsProvider.notifier).filterQuery, + onApply: _applySavedView, + ), ExportCsvButton( endpoint: ApiConfig.leadsExport, filePrefix: 'leads', diff --git a/mobile/lib/screens/settings/audit_log_screen.dart b/mobile/lib/screens/settings/audit_log_screen.dart new file mode 100644 index 00000000..59994711 --- /dev/null +++ b/mobile/lib/screens/settings/audit_log_screen.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/audit_entry.dart'; +import '../../providers/audit_log_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../routes/app_router.dart'; + +/// The security audit log, mirroring `/settings/audit-log` on the web: +/// sign-ins, org switches, refused requests, and webhooks paused or turned +/// back on. Read-only. +/// +/// **Admin-only to read.** The API answers a member 403, so the screen gates +/// on `isOrgAdminProvider` rather than issuing a request it knows will fail. +/// Tapping a person narrows the log to them, the one way to set that filter, +/// as on the web. +class AuditLogScreen extends ConsumerStatefulWidget { + const AuditLogScreen({super.key}); + + @override + ConsumerState createState() => _AuditLogScreenState(); +} + +class _AuditLogScreenState extends ConsumerState { + AuditLogQuery _query = auditLogFirstPage; + String _actorLabel = ''; + + /// A changed filter starts again from the first page. + void _filter(AuditLogQuery next) => setState( + () => _query = ( + eventType: next.eventType, + actor: next.actor, + from: next.from, + to: next.to, + offset: 0, + ), + ); + + Future _pickDay({required bool from}) async { + final now = DateTime.now(); + final current = from ? _query.from : _query.to; + final picked = await showDatePicker( + context: context, + initialDate: current ?? now, + firstDate: DateTime(now.year - 5), + lastDate: now, + ); + if (picked == null || !mounted) return; + final q = _query; + _filter(( + eventType: q.eventType, + actor: q.actor, + from: from ? picked : q.from, + to: from ? q.to : picked, + offset: 0, + )); + } + + @override + Widget build(BuildContext context) { + final isAdmin = ref.watch(isOrgAdminProvider); + return Scaffold( + backgroundColor: AppColors.surfaceDim, + appBar: AppBar( + title: const Text('Audit log'), + backgroundColor: AppColors.surface, + elevation: 0, + scrolledUnderElevation: 1, + ), + body: !isAdmin ? const _MemberNotice() : _body(), + ); + } + + Widget _body() { + final async = ref.watch(auditLogProvider(_query)); + final q = _query; + final filtered = + q.eventType != null || + q.actor != null || + q.from != null || + q.to != null; + return RefreshIndicator( + onRefresh: () => ref.refresh(auditLogProvider(_query).future), + child: ListView( + padding: const EdgeInsets.only(bottom: 48), + children: [ + _Filters( + query: q, + eventTypes: async.value?.eventTypes ?? const [], + onEventType: (value) => _filter(( + eventType: value, + actor: q.actor, + from: q.from, + to: q.to, + offset: 0, + )), + onFrom: () => _pickDay(from: true), + onTo: () => _pickDay(from: false), + onClear: filtered + ? () { + _actorLabel = ''; + _filter(auditLogFirstPage); + } + : null, + ), + if (q.actor != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text( + 'Showing $_actorLabel only.', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + TextButton( + style: TextButton.styleFrom(minimumSize: const Size(0, 44)), + onPressed: () => _filter(( + eventType: q.eventType, + actor: null, + from: q.from, + to: q.to, + offset: 0, + )), + child: const Text('Show everyone'), + ), + ], + ), + ), + ...async.when( + loading: () => const [ + Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ], + error: (error, _) => [ + _Message( + icon: LucideIcons.circleAlert, + title: 'That did not work', + body: error.toString().replaceFirst('Exception: ', ''), + onRetry: () => ref.invalidate(auditLogProvider(_query)), + ), + ], + data: (page) => [ + if (page.entries.isEmpty) + _Message( + icon: LucideIcons.scrollText, + title: filtered ? 'Nothing matches' : 'Nothing recorded yet', + body: filtered + ? 'No entry matches these filters. Widen the dates or ' + 'clear them.' + : 'Sign-ins, org switches, refused requests and ' + 'webhook pauses appear here as they happen.', + ) + else + for (final entry in page.entries) + _EntryRow( + entry: entry, + onActor: entry.actorId == null + ? null + : () { + _actorLabel = entry.actorLabel; + _filter(( + eventType: q.eventType, + actor: entry.actorId, + from: q.from, + to: q.to, + offset: 0, + )); + }, + ), + _Pager( + offset: q.offset, + count: page.count, + onPage: (offset) => setState( + () => _query = ( + eventType: q.eventType, + actor: q.actor, + from: q.from, + to: q.to, + offset: offset, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _Filters extends StatelessWidget { + const _Filters({ + required this.query, + required this.eventTypes, + required this.onEventType, + required this.onFrom, + required this.onTo, + required this.onClear, + }); + + final AuditLogQuery query; + final List eventTypes; + final ValueChanged onEventType; + final VoidCallback onFrom; + final VoidCallback onTo; + final VoidCallback? onClear; + + static final _day = DateFormat('d MMM yyyy'); + + @override + Widget build(BuildContext context) { + const size = Size(0, 48); + // The types arrive with the first page. Until then a chosen type reads as + // "All events", because a dropdown whose value is not among its items + // throws. The key rebuilds the field once they arrive. + final known = {for (final t in eventTypes) t.value}; + final selected = known.contains(query.eventType) ? query.eventType : null; + return Container( + color: AppColors.surface, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + margin: const EdgeInsets.only(bottom: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + key: ValueKey('${selected ?? ''}|${eventTypes.length}'), + initialValue: selected, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Event'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All events'), + ), + for (final t in eventTypes) + DropdownMenuItem( + value: t.value, + child: Text(t.label, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: onEventType, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: size), + onPressed: onFrom, + icon: const Icon(LucideIcons.calendar, size: 16), + label: Text( + query.from == null + ? 'From' + : 'From ${_day.format(query.from!)}', + ), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: size), + onPressed: onTo, + icon: const Icon(LucideIcons.calendar, size: 16), + label: Text( + query.to == null ? 'To' : 'To ${_day.format(query.to!)}', + ), + ), + if (onClear != null) + TextButton( + style: TextButton.styleFrom(minimumSize: size), + onPressed: onClear, + child: const Text('Clear'), + ), + ], + ), + ], + ), + ); + } +} + +class _EntryRow extends StatelessWidget { + const _EntryRow({required this.entry, required this.onActor}); + + final AuditEntry entry; + final VoidCallback? onActor; + + static final _when = DateFormat('d MMM yyyy, HH:mm'); + + @override + Widget build(BuildContext context) { + final detail = entry.detail; + final webhookId = entry.webhookId; + final (label, color) = entry.success + ? ('OK', AppColors.success600) + : ('Refused', AppColors.danger600); + final meta = [ + if (entry.createdAt != null) _when.format(entry.createdAt!.toLocal()), + if (entry.ipAddress.isNotEmpty) entry.ipAddress, + ].join(' · '); + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + decoration: BoxDecoration( + color: AppColors.surface, + border: Border(bottom: BorderSide(color: AppColors.gray100)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + entry.eventLabel, + style: AppTypography.body.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: AppTypography.caption.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (detail.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + detail, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + if (meta.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + meta, + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + Wrap( + spacing: 4, + children: [ + TextButton.icon( + style: TextButton.styleFrom( + minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + onPressed: onActor, + icon: const Icon(LucideIcons.user, size: 14), + label: Text(entry.actorLabel, overflow: TextOverflow.ellipsis), + ), + if (webhookId != null) + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + onPressed: () => + context.push(AppRoutes.settingsWebhook(webhookId)), + child: const Text('Open webhook'), + ), + ], + ), + ], + ), + ); + } +} + +class _Pager extends StatelessWidget { + const _Pager({ + required this.offset, + required this.count, + required this.onPage, + }); + + final int offset; + final int count; + final ValueChanged onPage; + + @override + Widget build(BuildContext context) { + final newer = offset > 0; + final older = offset + auditLogPageSize < count; + if (!newer && !older) return const SizedBox.shrink(); + const size = Size(0, 48); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Wrap( + spacing: 8, + children: [ + if (newer) + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: size), + onPressed: () => + onPage((offset - auditLogPageSize).clamp(0, offset)), + child: const Text('Newer'), + ), + if (older) + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: size), + onPressed: () => onPage(offset + auditLogPageSize), + child: const Text('Older'), + ), + ], + ), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({ + required this.icon, + required this.title, + required this.body, + this.onRetry, + }); + + final IconData icon; + final String title; + final String body; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + child: Column( + children: [ + Icon(icon, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text(title, style: AppTypography.h3, textAlign: TextAlign.center), + const SizedBox(height: 8), + Text( + body, + style: AppTypography.body.copyWith(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 12), + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 48)), + onPressed: onRetry, + child: const Text('Try again'), + ), + ], + ], + ), + ); + } +} + +class _MemberNotice extends StatelessWidget { + const _MemberNotice(); + + @override + Widget build(BuildContext context) { + return const _Message( + icon: LucideIcons.lock, + title: 'Administrators only', + body: + 'The audit log records who signed in, from where, and what was ' + 'refused, so only administrators can read it.', + ); + } +} diff --git a/mobile/lib/screens/settings/settings_hub_screen.dart b/mobile/lib/screens/settings/settings_hub_screen.dart index 7e06ac20..1ee6144a 100644 --- a/mobile/lib/screens/settings/settings_hub_screen.dart +++ b/mobile/lib/screens/settings/settings_hub_screen.dart @@ -137,6 +137,19 @@ class SettingsHubScreen extends StatelessWidget { ) : const SizedBox.shrink(), ), + // Admins only, as on the web: the audit log answers a member 403. + Consumer( + builder: (context, ref, _) => ref.watch(isOrgAdminProvider) + ? MenuRow( + icon: LucideIcons.scrollText, + label: 'Audit log', + description: + 'Sign-ins, org switches, refused requests and ' + 'paused webhooks', + onTap: () => context.push(AppRoutes.settingsAuditLog), + ) + : const SizedBox.shrink(), + ), // Filed with the organization cluster rather than with records, // beside API tokens. What a published form grants is the ability // for a stranger with no account to write into this org, which is diff --git a/mobile/lib/screens/settings/webhook_detail_screen.dart b/mobile/lib/screens/settings/webhook_detail_screen.dart index 18a09013..33615e09 100644 --- a/mobile/lib/screens/settings/webhook_detail_screen.dart +++ b/mobile/lib/screens/settings/webhook_detail_screen.dart @@ -258,6 +258,13 @@ class _Summary extends StatelessWidget { color: AppColors.textTertiary, ), ), + const SizedBox(height: 6), + Text( + 'Answers for it: ${endpoint.creatorLabel}', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), if (endpoint.disabledReason.isNotEmpty) ...[ const SizedBox(height: 8), Text( @@ -267,6 +274,14 @@ class _Summary extends StatelessWidget { ), ), ], + const SizedBox(height: 8), + Text( + 'Turning it on, changing its URL, events or format, or rotating ' + 'its secret makes you the admin who answers for it.', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), ], ), ); @@ -312,7 +327,13 @@ class _Actions extends StatelessWidget { OutlinedButton( style: OutlinedButton.styleFrom(minimumSize: size), onPressed: busy ? null : onToggle, - child: Text(endpoint.isActive ? 'Turn off' : 'Turn on'), + child: Text( + endpoint.isActive + ? 'Turn off' + : endpoint.disabledReason.isNotEmpty + ? 'Re-enable' + : 'Turn on', + ), ), OutlinedButton( style: OutlinedButton.styleFrom(minimumSize: size), diff --git a/mobile/lib/screens/settings/webhooks_screen.dart b/mobile/lib/screens/settings/webhooks_screen.dart index 7f81d712..ba6209c2 100644 --- a/mobile/lib/screens/settings/webhooks_screen.dart +++ b/mobile/lib/screens/settings/webhooks_screen.dart @@ -256,6 +256,15 @@ class _EndpointRow extends StatelessWidget { color: AppColors.textTertiary, ), ), + const SizedBox(height: 3), + Text( + 'By ${endpoint.creatorLabel}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), ], ), ), @@ -279,7 +288,7 @@ class WebhookStatePill extends StatelessWidget { final (label, color) = endpoint.isActive ? ('Sending', AppColors.success600) : endpoint.disabledReason.isNotEmpty - ? ('Turned off', AppColors.warning600) + ? ('Paused', AppColors.warning600) : ('Off', AppColors.textSecondary); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), @@ -339,7 +348,9 @@ class _Footnote extends StatelessWidget { 'HMAC-SHA256 of the timestamp and the raw body, keyed with the ' 'webhook\'s secret. A failed delivery is retried five times over about ' 'eight and a half hours, and an endpoint that answers 410 Gone is ' - 'turned off.', + 'paused. So is every webhook whose creator stops being an admin, is ' + 'deactivated or leaves; any admin can turn it back on and then ' + 'answers for it.', style: AppTypography.caption.copyWith(color: AppColors.textTertiary), ), ); diff --git a/mobile/lib/screens/tickets/ticket_analytics_screen.dart b/mobile/lib/screens/tickets/ticket_analytics_screen.dart index 815b7ae8..1912e030 100644 --- a/mobile/lib/screens/tickets/ticket_analytics_screen.dart +++ b/mobile/lib/screens/tickets/ticket_analytics_screen.dart @@ -143,20 +143,13 @@ class _TicketAnalyticsScreenState extends ConsumerState { if (data.isLoading && data.frt == null) { return const Center(child: CircularProgressIndicator()); } - if (data.error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(LucideIcons.alertCircle, size: 48, color: Colors.grey[400]), - const SizedBox(height: 16), - Text(data.error!, style: AppTypography.body), - const SizedBox(height: 16), - TextButton(onPressed: _apply, child: const Text('Retry')), - ], - ), - ); - } + + // Each section answers for itself: a failed call shows its own error and + // retry in its own place, and every other section still renders. + final notifier = ref.read(analyticsProvider.notifier); + String? errorOf(AnalyticsSection s) => data.errors[s]; + VoidCallback retryOf(AnalyticsSection s) => + () => notifier.retry(s); final frt = data.frt; final nrt = data.nrt; @@ -182,6 +175,8 @@ class _TicketAnalyticsScreenState extends ConsumerState { breachLabel: '${frt?['breach_count'] ?? 0} breached', icon: LucideIcons.zap, color: AppColors.primary600, + error: errorOf(AnalyticsSection.frt), + onRetry: retryOf(AnalyticsSection.frt), ), _MetricTile( title: 'Next Response', @@ -191,6 +186,8 @@ class _TicketAnalyticsScreenState extends ConsumerState { breachLabel: '${nrt?['breach_count'] ?? 0} breached', icon: LucideIcons.messageSquareReply, color: AppColors.teal600, + error: errorOf(AnalyticsSection.nrt), + onRetry: retryOf(AnalyticsSection.nrt), ), ], ), @@ -204,6 +201,8 @@ class _TicketAnalyticsScreenState extends ConsumerState { 'p90 ${_hours(mttr?['p90_hours'])} · ${mttr?['count'] ?? 0} resolved', icon: LucideIcons.checkCircle, color: AppColors.success600, + error: errorOf(AnalyticsSection.mttr), + onRetry: retryOf(AnalyticsSection.mttr), ), _MetricTile( title: 'Backlog (today)', @@ -212,6 +211,8 @@ class _TicketAnalyticsScreenState extends ConsumerState { 'Peak urgent ${_backlogPeakUrgent(backlog)} in window', icon: LucideIcons.inbox, color: AppColors.warning600, + error: errorOf(AnalyticsSection.backlog), + onRetry: retryOf(AnalyticsSection.backlog), ), ], ), @@ -226,11 +227,21 @@ class _TicketAnalyticsScreenState extends ConsumerState { '${_rate(sla?['nrt_breach_rate'])}', icon: LucideIcons.alertTriangle, color: AppColors.danger600, + error: errorOf(AnalyticsSection.sla), + onRetry: retryOf(AnalyticsSection.sla), ), const SizedBox(height: 16), - _CsatSection(csat: data.csat), + _CsatSection( + csat: data.csat, + error: errorOf(AnalyticsSection.csat), + onRetry: retryOf(AnalyticsSection.csat), + ), const SizedBox(height: 16), - _AgentsSection(agents: data.agents), + _AgentsSection( + agents: data.agents, + error: errorOf(AnalyticsSection.agents), + onRetry: retryOf(AnalyticsSection.agents), + ), ], ), ); @@ -301,6 +312,11 @@ class _MetricTile extends StatelessWidget { final IconData icon; final Color color; + /// Set when this tile's call failed: shown with [onRetry] instead of the + /// figures, which would otherwise read as zeros. + final String? error; + final VoidCallback? onRetry; + const _MetricTile({ required this.title, required this.value, @@ -308,6 +324,8 @@ class _MetricTile extends StatelessWidget { required this.icon, required this.color, this.breachLabel, + this.error, + this.onRetry, }); @override @@ -337,21 +355,25 @@ class _MetricTile extends StatelessWidget { ], ), const SizedBox(height: 8), - Text( - value, - style: AppTypography.h2.copyWith( - color: color, - fontWeight: FontWeight.w700, + if (error != null) + _SectionError(message: error!, onRetry: onRetry) + else ...[ + Text( + value, + style: AppTypography.h2.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: AppTypography.caption.copyWith( - color: AppColors.textSecondary, + const SizedBox(height: 4), + Text( + subtitle, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), ), - ), - if (breachLabel != null) ...[ + ], + if (error == null && breachLabel != null) ...[ const SizedBox(height: 4), Text( breachLabel!, @@ -371,7 +393,9 @@ class _MetricTile extends StatelessWidget { /// how the answers spread over 1 to 5. class _CsatSection extends StatelessWidget { final Map? csat; - const _CsatSection({required this.csat}); + final String? error; + final VoidCallback? onRetry; + const _CsatSection({required this.csat, this.error, this.onRetry}); @override Widget build(BuildContext context) { @@ -406,8 +430,11 @@ class _CsatSection extends StatelessWidget { ), const SizedBox(height: 8), // The backend sends a null average exactly when nobody answered, so - // both read as "no ratings" rather than as a score of nothing. - if (count == 0 || average == null) + // both read as "no ratings" rather than as a score of nothing. A + // failed call is neither, and says so. + if (error != null) + _SectionError(message: error!, onRetry: onRetry) + else if (count == 0 || average == null) Text( 'No ratings in this window. Scores appear once customers answer ' 'the satisfaction survey sent when a ticket closes.', @@ -489,11 +516,13 @@ class _CsatSection extends StatelessWidget { class _AgentsSection extends StatelessWidget { final List> agents; - const _AgentsSection({required this.agents}); + final String? error; + final VoidCallback? onRetry; + const _AgentsSection({required this.agents, this.error, this.onRetry}); @override Widget build(BuildContext context) { - if (agents.isEmpty) return const SizedBox.shrink(); + if (agents.isEmpty && error == null) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -511,7 +540,10 @@ class _AgentsSection extends StatelessWidget { ), ), const SizedBox(height: 8), - for (final a in agents.take(20)) _agentRow(a), + if (error != null) + _SectionError(message: error!, onRetry: onRetry) + else + for (final a in agents.take(20)) _agentRow(a), ], ), ); @@ -576,3 +608,35 @@ class _AgentsSection extends StatelessWidget { return '${(d * 100).toStringAsFixed(0)}%'; } } + +/// A section whose call failed: the server's reason and a retry that fetches +/// this section alone. The button keeps a 44px target at any text scale. +class _SectionError extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + const _SectionError({required this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message, + style: AppTypography.caption.copyWith(color: AppColors.danger600), + ), + if (onRetry != null) + TextButton.icon( + onPressed: onRetry, + style: TextButton.styleFrom( + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.centerLeft, + ), + icon: const Icon(LucideIcons.refreshCw, size: 14), + label: const Text('Retry'), + ), + ], + ); + } +} diff --git a/mobile/lib/screens/tickets/ticket_detail_screen.dart b/mobile/lib/screens/tickets/ticket_detail_screen.dart index 02ac5cd0..b8eb0a5b 100644 --- a/mobile/lib/screens/tickets/ticket_detail_screen.dart +++ b/mobile/lib/screens/tickets/ticket_detail_screen.dart @@ -53,6 +53,10 @@ class _TicketDetailScreenState extends ConsumerState bool _isAddingComment = false; bool _isUploadingAttachment = false; _ThreadSegment _segment = _ThreadSegment.public; + + /// Why the ticket could not be loaded, when it was not a 404 (offline, a + /// server error): shown with a retry. A 404 leaves this null and [_detail] + /// null, and reads as "not found". String? _error; @override @@ -75,19 +79,36 @@ class _TicketDetailScreenState extends ConsumerState _error = null; }); final notifier = ref.read(ticketsProvider.notifier); + String? failure; + Future loadDetail() async { + try { + return await notifier.getTicketDetail(widget.ticketId); + } on TicketLoadFailure catch (e) { + failure = e.message; + return null; + } + } + final results = await Future.wait([ - notifier.getTicketDetail(widget.ticketId), + loadDetail(), notifier.getWatchers(widget.ticketId), notifier.fetchTree(widget.ticketId), ]); if (!mounted) return; final detail = results[0] as TicketDetailResult?; + if (detail != null && detail.ticketObj.id != widget.ticketId) { + // This ticket was merged, and the API answered with the one it was + // merged into. Open that one in this screen's place, as the web + // redirects, so every action here names the surviving ticket. + context.pushReplacement('/tickets/${detail.ticketObj.id}'); + return; + } setState(() { _isLoading = false; _detail = detail; _watchers = results[1] as TicketWatchers?; _tree = results[2] as TicketTreeNode?; - if (detail == null) _error = 'Failed to load ticket'; + _error = failure; }); // Second request, after the ticket, because it needs the account id the @@ -118,7 +139,12 @@ class _TicketDetailScreenState extends ConsumerState body: const Center(child: CircularProgressIndicator()), ); } - if (_detail == null || _error != null) { + if (_detail == null) { + // A 404 is the same answer for a missing ticket and one the caller may + // not open, so "not found" says both and offers no retry: asking again + // gets the same answer. Anything else (offline, a server error) may + // work next time, so it says what went wrong and offers one. + final notFound = _error == null; return Scaffold( appBar: AppBar( leading: IconButton( @@ -127,22 +153,43 @@ class _TicketDetailScreenState extends ConsumerState ), ), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(LucideIcons.fileX, size: 48, color: AppColors.gray400), - const SizedBox(height: 16), - Text('Ticket not found', style: AppTypography.h3), - const SizedBox(height: 8), - Text( - _error ?? 'This ticket may have been deleted', - style: AppTypography.body.copyWith( - color: AppColors.textSecondary, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + notFound ? LucideIcons.fileX : LucideIcons.cloudOff, + size: 48, + color: AppColors.gray400, ), - ), - const SizedBox(height: 16), - TextButton(onPressed: _fetchDetail, child: const Text('Retry')), - ], + const SizedBox(height: 16), + Text( + notFound ? 'Ticket not found' : 'Could not load this ticket', + style: AppTypography.h3, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _error ?? + 'It does not exist, or you do not have access to it.', + style: AppTypography.body.copyWith( + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (!notFound) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: _fetchDetail, + style: TextButton.styleFrom( + minimumSize: const Size(44, 44), + ), + child: const Text('Retry'), + ), + ], + ], + ), ), ), ); @@ -181,16 +228,20 @@ class _TicketDetailScreenState extends ConsumerState ), onPressed: _toggleWatch, ), - IconButton( - tooltip: 'Edit', - icon: const Icon(LucideIcons.pencil), - onPressed: () async { - final result = await context.push( - '/tickets/${widget.ticketId}/edit', - ); - if (result == true && mounted) _fetchDetail(); - }, - ), + // Every change to the ticket takes its write rule on the server, + // which `comment_permission` reports. A reader (a watcher) is not + // offered an edit the API would answer 403. + if (_canWrite) + IconButton( + tooltip: 'Edit', + icon: const Icon(LucideIcons.pencil), + onPressed: () async { + final result = await context.push( + '/tickets/${widget.ticketId}/edit', + ); + if (result == true && mounted) _fetchDetail(); + }, + ), IconButton( tooltip: 'More', icon: const Icon(LucideIcons.moreVertical), @@ -352,15 +403,16 @@ class _TicketDetailScreenState extends ConsumerState _buildTreeCard(c), ], const SizedBox(height: 16), - TicketTimePanel(ticketId: c.id), + TicketTimePanel(ticketId: c.id, canLogTime: _canWrite), const SizedBox(height: 16), TicketSolutionsPanel( ticketId: c.id, linked: _detail?.linkedSolutions ?? const [], onChanged: _fetchDetail, + canEdit: _canWrite, ), const SizedBox(height: 16), - TicketApprovalPanel(ticketId: c.id), + TicketApprovalPanel(ticketId: c.id, canRequest: _canWrite), const SizedBox(height: 16), _card( title: 'Ticket Information', @@ -828,16 +880,20 @@ class _TicketDetailScreenState extends ConsumerState Text('No files attached', style: AppTypography.h3), const SizedBox(height: 8), Text( - 'Attach a screenshot, a log, or a document.', + _canWrite + ? 'Attach a screenshot, a log, or a document.' + : 'Nothing has been attached to this ticket.', style: AppTypography.body.copyWith( color: AppColors.textSecondary, ), ), - const SizedBox(height: 12), - AttachFileButton( - isUploading: _isUploadingAttachment, - onPressed: _pickAndUploadAttachment, - ), + if (_canWrite) ...[ + const SizedBox(height: 12), + AttachFileButton( + isUploading: _isUploadingAttachment, + onPressed: _pickAndUploadAttachment, + ), + ], ], ), ), @@ -850,7 +906,7 @@ class _TicketDetailScreenState extends ConsumerState child: ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), - itemCount: attachments.length + 1, + itemCount: attachments.length + (_canWrite ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) => index == attachments.length ? Align( @@ -1082,10 +1138,13 @@ class _TicketDetailScreenState extends ConsumerState } } - bool get _isAdmin { - final org = ref.read(selectedOrgProvider); - return (org?.role ?? '').toUpperCase() == 'ADMIN'; - } + bool get _isAdmin => ref.read(isOrgAdminProvider); + + /// The ticket's write rule, as the server answered it (`comment_permission` + /// on the detail). Gates every change this screen offers: edit, status, + /// priority, reassign, close, link, time, attachments, solutions, approval + /// requests. Watching is a read and stays open to readers. + bool get _canWrite => _detail?.commentPermission == true; void _openActionSheet() { final c = _detail?.ticketObj; @@ -1109,31 +1168,34 @@ class _TicketDetailScreenState extends ConsumerState borderRadius: BorderRadius.circular(2), ), ), - _actionRow( - icon: LucideIcons.users, - label: 'Reassign', - onTap: () { - Navigator.pop(context); - _reassign(c); - }, - ), - _actionRow( - icon: LucideIcons.circleDot, - label: 'Change status', - onTap: () { - Navigator.pop(context); - _changeStatus(c); - }, - ), - _actionRow( - icon: LucideIcons.flag, - label: 'Change priority', - onTap: () { - Navigator.pop(context); - _changePriority(c); - }, - ), - if (c.status != TicketStatus.closed) + if (_canWrite) + _actionRow( + icon: LucideIcons.users, + label: 'Reassign', + onTap: () { + Navigator.pop(context); + _reassign(c); + }, + ), + if (_canWrite) + _actionRow( + icon: LucideIcons.circleDot, + label: 'Change status', + onTap: () { + Navigator.pop(context); + _changeStatus(c); + }, + ), + if (_canWrite) + _actionRow( + icon: LucideIcons.flag, + label: 'Change priority', + onTap: () { + Navigator.pop(context); + _changePriority(c); + }, + ), + if (_canWrite && c.status != TicketStatus.closed) _actionRow( icon: LucideIcons.checkCircle, iconColor: AppColors.success600, @@ -1154,17 +1216,20 @@ class _TicketDetailScreenState extends ConsumerState _mergeInto(c); }, ), - _actionRow( - icon: LucideIcons.link, - label: c.parentSummary == null - ? 'Link to parent ticket' - : 'Change / detach parent', - onTap: () { - Navigator.pop(context); - _linkParent(c); - }, - ), - if (c.childCount > 0 && c.status != TicketStatus.closed) + if (_canWrite) + _actionRow( + icon: LucideIcons.link, + label: c.parentSummary == null + ? 'Link to parent ticket' + : 'Change / detach parent', + onTap: () { + Navigator.pop(context); + _linkParent(c); + }, + ), + if (_canWrite && + c.childCount > 0 && + c.status != TicketStatus.closed) _actionRow( icon: LucideIcons.checkCheck, iconColor: AppColors.success600, @@ -1637,7 +1702,7 @@ class _TicketDetailScreenState extends ConsumerState const SizedBox(height: 12), if (c.parentSummary != null) InkWell( - // A parent the viewer cannot open would only answer 403. + // A parent the viewer cannot open would only answer 404. onTap: c.parentSummary!.restricted ? null : () => context.push('/tickets/${c.parentSummary!.id}'), @@ -1721,7 +1786,7 @@ class _TicketDetailScreenState extends ConsumerState if (depth > 0) { rows.add( InkWell( - // A ticket the viewer cannot open would only answer 403. + // A ticket the viewer cannot open would only answer 404. onTap: node.restricted ? null : () => context.push('/tickets/${node.id}'), diff --git a/mobile/lib/screens/tickets/tickets_list_screen.dart b/mobile/lib/screens/tickets/tickets_list_screen.dart index c971ee11..f69ae4df 100644 --- a/mobile/lib/screens/tickets/tickets_list_screen.dart +++ b/mobile/lib/screens/tickets/tickets_list_screen.dart @@ -12,12 +12,14 @@ import '../../data/models/lookup_models.dart'; import '../../data/models/ticket.dart'; import '../../providers/csv_import_provider.dart'; import '../../providers/lookup_provider.dart'; +import '../../providers/saved_views_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../routes/app_router.dart'; import '../../services/api_service.dart'; import '../../widgets/cards/ticket_card.dart'; import '../../widgets/common/common.dart'; import '../../widgets/common/export_csv_button.dart'; +import '../../widgets/common/saved_views_button.dart'; import '../../widgets/forms/csv_import_sheet.dart'; import '../../widgets/forms/multi_select_sheet.dart'; @@ -135,6 +137,27 @@ class _TicketsListScreenState extends ConsumerState { _applyFilters(const TicketListFilters()); } + /// What [ticketListQuery] sends, and so what a saved view holds. + static const _savedViewKeys = { + 'search', + 'status', + 'priority', + 'account', + 'case_type', + 'assigned_to', + 'tags', + 'sla_breached', + 'created_at__gte', + 'created_at__lte', + }; + + void _applySavedView(Map> filters) { + final next = TicketListFilters.fromQuery(filters); + _searchDebounce?.cancel(); + _searchController.text = next.search; + _applyFilters(next); + } + @override Widget build(BuildContext context) { final ticketsAsync = ref.watch(ticketsProvider); @@ -201,6 +224,13 @@ class _TicketsListScreenState extends ConsumerState { .refresh(filters: _filters), ), ), + SavedViewsButton( + list: SavedViewList.tickets, + keys: _savedViewKeys, + multi: const {'status', 'assigned_to', 'tags'}, + currentQuery: () async => ticketListQuery(_filters), + onApply: _applySavedView, + ), // Not while watching: that view comes from its own endpoint, // and the export can only reproduce the queue. if (!_filters.watchingOnly) @@ -801,6 +831,7 @@ class _TicketsListScreenState extends ConsumerState { var noAccess = 0; var approvalRequired = 0; var closedOnRequired = 0; + var merged = 0; var invalid = 0; for (final row in results) { if (row is! Map) continue; @@ -815,6 +846,8 @@ class _TicketsListScreenState extends ConsumerState { approvalRequired++; case 'closed_on_required': closedOnRequired++; + case 'merged': + merged++; case 'invalid': invalid++; } @@ -823,6 +856,7 @@ class _TicketsListScreenState extends ConsumerState { if (noAccess > 0) parts.add('$noAccess skipped (no access)'); if (approvalRequired > 0) parts.add('$approvalRequired need approval'); if (closedOnRequired > 0) parts.add('$closedOnRequired missing close date'); + if (merged > 0) parts.add('$merged merged (unmerge first)'); if (invalid > 0) parts.add('$invalid invalid'); return parts.join(' · '); } @@ -1006,8 +1040,9 @@ class _TicketsListScreenState extends ConsumerState { if (after == null && before == null) return 'Date'; String fmt(DateTime d) => '${d.month.toString().padLeft(2, '0')}/${d.day.toString().padLeft(2, '0')}'; - if (after != null && before != null) + if (after != null && before != null) { return '${fmt(after)} → ${fmt(before)}'; + } if (after != null) return 'After ${fmt(after)}'; return 'Before ${fmt(before!)}'; } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index ed88fafe..ec580bef 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -381,8 +381,13 @@ class AuthService { _refreshToken = newRefresh; } - // Update selected organization - _selectedOrganization = org; + // Update selected organization, with the membership facts the switch + // just re-read (role, admin), and the cached list entry with it. + final fresh = org.withMembership(response.data!['profile']); + _selectedOrganization = fresh; + _organizations = _organizations + ?.map((o) => o.id == fresh.id ? fresh : o) + .toList(); // Sync with ApiService _apiService.setAccessToken(_accessToken); diff --git a/mobile/lib/widgets/cards/deal_card.dart b/mobile/lib/widgets/cards/deal_card.dart index 900e7a54..bb3bd264 100644 --- a/mobile/lib/widgets/cards/deal_card.dart +++ b/mobile/lib/widgets/cards/deal_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/theme/theme.dart'; import '../../data/models/models.dart'; @@ -143,6 +144,14 @@ class DealCard extends ConsumerWidget { DealAgingBadge(deal: deal), ], + // Row 2c: next step, on open deals only (a won or lost + // deal has none to take) and only where the list + // computed it. `isClosed` reads the server's stage kind. + if (deal.nextActivityKnown && !deal.isClosed) ...[ + const SizedBox(height: 6), + DealNextStep(activity: deal.nextActivity), + ], + // Row 3: Labels if (deal.labels.isNotEmpty) ...[ const SizedBox(height: 10), @@ -293,3 +302,41 @@ class DealCard extends ConsumerWidget { return AppColors.gray400; } } + +/// The deal's next open task on one line, or "No next step" when it has none. +/// +/// Both an overdue task and no task at all read in the danger colour: they +/// are the two a salesperson has to act on. The title is whatever someone +/// typed, so it truncates rather than widening the card. +class DealNextStep extends StatelessWidget { + final DealNextActivity? activity; + + const DealNextStep({super.key, required this.activity}); + + @override + Widget build(BuildContext context) { + final next = activity; + final today = DateUtils.dateOnly(DateTime.now()); + final late = + next == null || (next.dueDate != null && next.dueDate!.isBefore(today)); + final color = late ? AppColors.danger600 : AppColors.textSecondary; + final text = next == null + ? 'No next step' + : '${next.title} · ' + '${next.dueDate == null ? 'no date' : DateFormat('d MMM').format(next.dueDate!)}'; + return Row( + children: [ + Icon(LucideIcons.calendarClock, size: 14, color: color), + const SizedBox(width: 4), + Expanded( + child: Text( + text, + style: AppTypography.caption.copyWith(color: color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/common/saved_views_button.dart b/mobile/lib/widgets/common/saved_views_button.dart new file mode 100644 index 00000000..cbd5a0e1 --- /dev/null +++ b/mobile/lib/widgets/common/saved_views_button.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/theme/theme.dart'; +import '../../providers/saved_views_provider.dart'; + +/// "Saved views", in the app bar of the six record lists (G29). +/// +/// Opens a sheet listing the person's views for [list]: tap one to put its +/// filters back, rename or delete it from its menu, or save the filters the +/// list is showing now. The web's saved-views menu does the same over the +/// same API, so a view saved on one client opens on the other. +/// +/// [keys] are the query parameters this screen can show and set, and [multi] +/// the ones it takes several values of. Only those are saved from it, and a +/// view carrying more (the web offers filters this screen has no control for, +/// and several owners where this screen picks one) still opens, with the +/// sheet saying how many values it left out rather than narrowing the list +/// by something invisible. +/// +/// A view means what the API means: no ticket `status` is every status, so the +/// Open chip is saved as its three statuses, and the web opens both the same. +class SavedViewsButton extends StatelessWidget { + const SavedViewsButton({ + super.key, + required this.list, + required this.keys, + this.multi = const {}, + required this.currentQuery, + required this.onApply, + }); + + final SavedViewList list; + final Set keys; + final Set multi; + + /// The list's filters now, as it sends them (`filterQuery`), read at the + /// moment "Save" is tapped. + final Future> Function() currentQuery; + + /// Replace the screen's filters with a view's. Parameters outside [keys] + /// are ignored by the screen. + final void Function(Map> filters) onApply; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: 'Saved views', + icon: const Icon(LucideIcons.bookmark), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => SavedViewsSheet( + list: list, + keys: keys, + multi: multi, + currentQuery: currentQuery, + onApply: (view) { + onApply(view.filters); + final left = view.dropped(keys, multi); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + left == 0 + ? 'Showing ${view.name}' + : 'Showing ${view.name}. ' + '${left == 1 ? '1 filter value' : '$left filter values'} ' + 'in it cannot be shown here.', + ), + ), + ); + }, + ), + ), + ); + } +} + +class SavedViewsSheet extends ConsumerStatefulWidget { + const SavedViewsSheet({ + super.key, + required this.list, + required this.keys, + this.multi = const {}, + required this.currentQuery, + required this.onApply, + }); + + final SavedViewList list; + final Set keys; + final Set multi; + final Future> Function() currentQuery; + final void Function(SavedView view) onApply; + + @override + ConsumerState createState() => _SavedViewsSheetState(); +} + +class _SavedViewsSheetState extends ConsumerState { + bool _busy = false; + String? _message; + + SavedViewsNotifier get _notifier => + ref.read(savedViewsProvider(widget.list).notifier); + + Future _run(Future Function() write) async { + setState(() { + _busy = true; + _message = null; + }); + final error = await write(); + if (!mounted) return; + setState(() { + _busy = false; + _message = error; + }); + } + + Future _save() async { + final name = await _askName(context, title: 'Save current filters'); + if (name == null || !mounted) return; + final filters = savedViewFilters( + await widget.currentQuery(), + widget.keys, + multi: widget.multi, + ); + await _run(() => _notifier.save(name, filters)); + } + + Future _rename(SavedView view) async { + final name = await _askName( + context, + title: 'Rename view', + initial: view.name, + ); + if (name == null || name == view.name || !mounted) return; + await _run(() => _notifier.rename(view.id, name)); + } + + Future _delete(SavedView view) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete view?'), + content: Text( + '${view.name} will be removed. The records it shows are not ' + 'touched.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: AppColors.danger700), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _run(() => _notifier.remove(view.id)); + } + + @override + Widget build(BuildContext context) { + final async = ref.watch(savedViewsProvider(widget.list)); + final data = async.value; + final secondary = AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ); + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 4, 0), + child: Row( + children: [ + const Icon( + LucideIcons.bookmark, + size: 18, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Saved views', + style: AppTypography.h3.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: 'Close', + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + if (async.isLoading || _busy) + const LinearProgressIndicator(minHeight: 2), + Flexible( + child: switch (async) { + AsyncError() when data == null => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Could not load your saved views.', style: secondary), + TextButton( + onPressed: _notifier.reload, + child: const Text('Retry'), + ), + ], + ), + ), + _ when data == null => const SizedBox(height: 48), + _ when data.views.isEmpty => Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'No saved views yet. Filter the list, then save it here.', + style: secondary, + ), + ), + _ => ListView.separated( + shrinkWrap: true, + itemCount: data.views.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _ViewRow( + view: data.views[i], + dropped: data.views[i].dropped(widget.keys, widget.multi), + enabled: !_busy, + onApply: () { + Navigator.of(context).pop(); + widget.onApply(data.views[i]); + }, + onRename: () => _rename(data.views[i]), + onDelete: () => _delete(data.views[i]), + ), + ), + }, + ), + if (_message != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + _message!, + style: AppTypography.bodySmall.copyWith( + color: AppColors.danger700, + ), + ), + ), + Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: AppColors.border)), + ), + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: data != null && data.full + ? Text( + 'This list keeps at most ${data.limit} saved views. ' + 'Delete one to save another.', + style: secondary, + ) + : FilledButton.icon( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight( + AppLayout.buttonHeightLarge, + ), + shape: RoundedRectangleBorder( + borderRadius: AppLayout.borderRadiusMd, + ), + ), + onPressed: _busy || data == null ? null : _save, + icon: const Icon(LucideIcons.bookmarkPlus, size: 18), + label: const Text('Save current filters'), + ), + ), + ], + ), + ); + } +} + +class _ViewRow extends StatelessWidget { + const _ViewRow({ + required this.view, + required this.dropped, + required this.enabled, + required this.onApply, + required this.onRename, + required this.onDelete, + }); + + final SavedView view; + final int dropped; + final bool enabled; + final VoidCallback onApply; + final VoidCallback onRename; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + minTileHeight: 52, + enabled: enabled, + contentPadding: const EdgeInsets.only(left: 16, right: 4), + title: Text(view.name, style: AppTypography.body), + subtitle: dropped == 0 + ? null + : Text( + '${dropped == 1 ? '1 filter value' : '$dropped filter values'} ' + 'this screen cannot show', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + onTap: onApply, + trailing: PopupMenuButton( + tooltip: 'More for ${view.name}', + enabled: enabled, + style: IconButton.styleFrom(minimumSize: const Size(48, 48)), + icon: const Icon(LucideIcons.ellipsisVertical, size: 20), + onSelected: (choice) => choice == 'rename' ? onRename() : onDelete(), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'rename', child: Text('Rename')), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ); + } +} + +/// A name for a view: required, at most 100 characters, the API's own limits. +Future _askName( + BuildContext context, { + required String title, + String initial = '', +}) => showDialog( + context: context, + builder: (_) => _NameDialog(title: title, initial: initial), +); + +/// Owns its text controller, so the field is not disposed while the dialog +/// is still animating out. +class _NameDialog extends StatefulWidget { + const _NameDialog({required this.title, required this.initial}); + + final String title; + final String initial; + + @override + State<_NameDialog> createState() => _NameDialogState(); +} + +class _NameDialogState extends State<_NameDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initial, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text.trim(); + if (name.isNotEmpty) Navigator.pop(context, name); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + maxLength: 100, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + decoration: const InputDecoration(hintText: 'View name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton(onPressed: _submit, child: const Text('Save')), + ], + ); + } +} diff --git a/mobile/lib/widgets/forms/csv_import_sheet.dart b/mobile/lib/widgets/forms/csv_import_sheet.dart index b838502e..9f959870 100644 --- a/mobile/lib/widgets/forms/csv_import_sheet.dart +++ b/mobile/lib/widgets/forms/csv_import_sheet.dart @@ -14,24 +14,21 @@ import '../../services/attachment_upload.dart' show clearAttachmentPickerCache; /// /// On a successful import the sheet closes, a snackbar reports how many /// records were created, and [onImported] runs so the list behind it -/// refreshes. [pickFile] and [saveTemplate] replace the platform picker in -/// tests; nothing else passes them. +/// refreshes. [pickFile] and [saveFile] replace the platform pickers in tests; +/// nothing else passes them. Future showCsvImportSheet( BuildContext context, CsvImportTarget target, { required VoidCallback onImported, Future Function()? pickFile, - Future Function(String fileName, List bytes)? saveTemplate, + Future Function(String fileName, List bytes)? saveFile, }) async { final created = await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, - builder: (_) => CsvImportSheet( - target: target, - pickFile: pickFile, - saveTemplate: saveTemplate, - ), + builder: (_) => + CsvImportSheet(target: target, pickFile: pickFile, saveFile: saveFile), ); if (created == null || !context.mounted) return; onImported(); @@ -45,13 +42,12 @@ class CsvImportSheet extends ConsumerStatefulWidget { super.key, required this.target, this.pickFile, - this.saveTemplate, + this.saveFile, }); final CsvImportTarget target; final Future Function()? pickFile; - final Future Function(String fileName, List bytes)? - saveTemplate; + final Future Function(String fileName, List bytes)? saveFile; @override ConsumerState createState() => _CsvImportSheetState(); @@ -59,6 +55,7 @@ class CsvImportSheet extends ConsumerStatefulWidget { class _CsvImportSheetState extends ConsumerState { String? _templateNote; + String? _errorsNote; CsvImportTarget get _target => widget.target; CsvImportNotifier get _notifier => @@ -81,24 +78,49 @@ class _CsvImportSheetState extends ConsumerState { return result.files.first; } - Future _saveTemplate() async { - final bytes = utf8.encode(_target.templateCsv); - String? note; + /// Ask where to keep [content] as [fileName]. `null` when the person + /// closed the dialog, a sentence to show otherwise. + Future _save( + String fileName, + String content, { + required String saved, + required String failed, + }) async { try { - final saved = await (widget.saveTemplate ?? _saveWithPicker)( - _target.templateFileName, - bytes, + final path = await (widget.saveFile ?? _saveWithPicker)( + fileName, + utf8.encode(content), ); - note = saved == null ? null : 'Template saved.'; + return path == null ? null : saved; } catch (_) { - note = 'Could not save the template.'; + return failed; } + } + + Future _saveTemplate() async { + final note = await _save( + _target.templateFileName, + _target.templateCsv, + saved: 'Template saved.', + failed: 'Could not save the template.', + ); if (mounted) setState(() => _templateNote = note); } + /// The web drawer's "Download errors": the row errors as a CSV file. + Future _saveErrors(List errors) async { + final note = await _save( + _target.errorsFileName, + csvImportErrorsCsv(errors), + saved: 'Saved ${_target.errorsFileName}.', + failed: 'Could not save the errors.', + ); + if (mounted) setState(() => _errorsNote = note); + } + static Future _saveWithPicker(String fileName, List bytes) => FilePicker.saveFile( - dialogTitle: 'Save CSV template', + dialogTitle: 'Save CSV file', fileName: fileName, bytes: Uint8List.fromList(bytes), ); @@ -178,7 +200,12 @@ class _CsvImportSheetState extends ConsumerState { note: _templateNote, ) else - _PreviewSummary(target: _target, preview: preview), + _PreviewSummary( + target: _target, + preview: preview, + onSaveErrors: _saveErrors, + errorsNote: _errorsNote, + ), if (state.commitErrors.isNotEmpty) ...[ const SizedBox(height: 12), _RowErrors( @@ -188,6 +215,8 @@ class _CsvImportSheetState extends ConsumerState { 'during import. The file may have changed since ' 'preview', errors: state.commitErrors, + onSave: _saveErrors, + note: _errorsNote, ), ], if (state.error != null) ...[ @@ -344,10 +373,17 @@ class _FormatHelp extends StatelessWidget { } class _PreviewSummary extends StatelessWidget { - const _PreviewSummary({required this.target, required this.preview}); + const _PreviewSummary({ + required this.target, + required this.preview, + required this.onSaveErrors, + required this.errorsNote, + }); final CsvImportTarget target; final CsvImportPreview preview; + final void Function(List errors) onSaveErrors; + final String? errorsNote; @override Widget build(BuildContext context) { @@ -379,6 +415,10 @@ class _PreviewSummary extends StatelessWidget { ), ], ), + if (preview.validRows.isNotEmpty) ...[ + const SizedBox(height: 12), + _ValidSample(target: target, rows: preview.validRows), + ], if (errors.isNotEmpty) ...[ const SizedBox(height: 12), _RowErrors( @@ -386,6 +426,8 @@ class _PreviewSummary extends StatelessWidget { '${errors.length} error${errors.length == 1 ? '' : 's'}, ' 'fix the CSV before importing', errors: errors, + onSave: onSaveErrors, + note: errorsNote, ), ] else if (preview.valid == 0) ...[ const SizedBox(height: 12), @@ -440,10 +482,19 @@ class _Count extends StatelessWidget { /// Row number, column and problem, in a box that scrolls on its own so a file /// with hundreds of bad rows does not push the buttons off the screen. class _RowErrors extends StatelessWidget { - const _RowErrors({required this.heading, required this.errors}); + const _RowErrors({ + required this.heading, + required this.errors, + required this.onSave, + required this.note, + }); final String heading; final List errors; + final void Function(List errors) onSave; + + /// What the last save said, if anything. + final String? note; @override Widget build(BuildContext context) { @@ -457,7 +508,28 @@ class _RowErrors extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + style: TextButton.styleFrom( + minimumSize: const Size(0, AppLayout.buttonHeightLarge), + padding: EdgeInsets.zero, + ), + onPressed: () => onSave(errors), + icon: const Icon(LucideIcons.download, size: 16), + label: const Text('Save errors as CSV'), + ), + ), + if (note != null) ...[ + Text( + note!, + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + ], + const SizedBox(height: 4), Container( constraints: const BoxConstraints(maxHeight: 240), decoration: BoxDecoration( @@ -502,6 +574,89 @@ class _RowErrors extends StatelessWidget { } } +/// The web drawer's sample of the rows that would import: the first 20, in a +/// box that scrolls on its own, one line of name and one of the other columns +/// the web table prints, since five columns do not fit a phone. +class _ValidSample extends StatelessWidget { + const _ValidSample({required this.target, required this.rows}); + + static const int shown = 20; + + final CsvImportTarget target; + final List> rows; + + @override + Widget build(BuildContext context) { + final sample = rows.take(shown).toList(growable: false); + final secondary = AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ); + return Container( + constraints: const BoxConstraints(maxHeight: 192), + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: AppLayout.borderRadiusMd, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: sample.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) { + final row = sample[i]; + final line = target.sampleLine(row); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Row ${row['row'] ?? i + 1}', + style: const TextStyle( + color: AppColors.textSecondary, + ), + ), + TextSpan( + text: ' ${line.title}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + if (line.details.isNotEmpty) + TextSpan(text: '\n${line.details}', style: secondary), + ], + ), + style: AppTypography.bodySmall, + ), + ); + }, + ), + ), + if (rows.length > shown) + Container( + color: AppColors.surfaceDim, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + 'Showing first $shown of ${rows.length} valid rows', + textAlign: TextAlign.center, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + ); + } +} + class _Alert extends StatelessWidget { const _Alert({required this.message}); diff --git a/mobile/lib/widgets/tickets/ticket_approval_panel.dart b/mobile/lib/widgets/tickets/ticket_approval_panel.dart index 65245893..447d376e 100644 --- a/mobile/lib/widgets/tickets/ticket_approval_panel.dart +++ b/mobile/lib/widgets/tickets/ticket_approval_panel.dart @@ -16,7 +16,17 @@ import '../common/common.dart'; /// "Request approval" button surfaced lower on the screen). class TicketApprovalPanel extends ConsumerStatefulWidget { final String ticketId; - const TicketApprovalPanel({super.key, required this.ticketId}); + + /// Whether this person may file a request: the ticket's write rule, + /// reported as `comment_permission` (the API answers anyone else 403). + /// Approving, rejecting and cancelling are the approval's own rules. + final bool canRequest; + + const TicketApprovalPanel({ + super.key, + required this.ticketId, + required this.canRequest, + }); @override ConsumerState createState() => @@ -204,15 +214,17 @@ class _TicketApprovalPanelState extends ConsumerState { ), ), ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isBusy ? null : _request, - icon: const Icon(LucideIcons.shieldCheck, size: 16), - label: const Text('Request approval'), + if (widget.canRequest) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isBusy ? null : _request, + icon: const Icon(LucideIcons.shieldCheck, size: 16), + label: const Text('Request approval'), + ), ), - ), + ], ], ); } diff --git a/mobile/lib/widgets/tickets/ticket_solutions_panel.dart b/mobile/lib/widgets/tickets/ticket_solutions_panel.dart index 22823f9f..3a06f2ba 100644 --- a/mobile/lib/widgets/tickets/ticket_solutions_panel.dart +++ b/mobile/lib/widgets/tickets/ticket_solutions_panel.dart @@ -15,11 +15,16 @@ class TicketSolutionsPanel extends ConsumerStatefulWidget { final List linked; final VoidCallback onChanged; + /// Whether this person may link and unlink articles: the ticket's write + /// rule, reported as `comment_permission`. The API answers anyone else 403. + final bool canEdit; + const TicketSolutionsPanel({ super.key, required this.ticketId, required this.linked, required this.onChanged, + required this.canEdit, }); @override @@ -141,11 +146,12 @@ class _TicketSolutionsPanelState extends ConsumerState { ), ), const Spacer(), - IconButton( - tooltip: 'Link a solution', - icon: const Icon(LucideIcons.plus, size: 18), - onPressed: _isBusy ? null : _openPicker, - ), + if (widget.canEdit) + IconButton( + tooltip: 'Link a solution', + icon: const Icon(LucideIcons.plus, size: 18), + onPressed: _isBusy ? null : _openPicker, + ), ], ), if (widget.linked.isEmpty) @@ -162,11 +168,11 @@ class _TicketSolutionsPanelState extends ConsumerState { ...widget.linked.map( (s) => _LinkedSolutionRow( solution: s, - onUnlink: () => _unlink(s), + onUnlink: widget.canEdit ? () => _unlink(s) : null, isBusy: _isBusy, ), ), - if (_suggestions.isNotEmpty) ...[ + if (widget.canEdit && _suggestions.isNotEmpty) ...[ const Divider(height: 24), Text( 'Suggested', @@ -195,7 +201,9 @@ class _TicketSolutionsPanelState extends ConsumerState { class _LinkedSolutionRow extends StatelessWidget { final Solution solution; - final VoidCallback onUnlink; + + /// Null when this person may not change the ticket: no Unlink button. + final VoidCallback? onUnlink; final bool isBusy; const _LinkedSolutionRow({ @@ -226,12 +234,13 @@ class _LinkedSolutionRow extends StatelessWidget { label: solution.status.label, color: solution.status.color, ), - IconButton( - tooltip: 'Unlink', - icon: const Icon(LucideIcons.x, size: 16), - color: AppColors.gray500, - onPressed: isBusy ? null : onUnlink, - ), + if (onUnlink != null) + IconButton( + tooltip: 'Unlink', + icon: const Icon(LucideIcons.x, size: 16), + color: AppColors.gray500, + onPressed: isBusy ? null : onUnlink, + ), ], ), ), diff --git a/mobile/lib/widgets/tickets/ticket_time_panel.dart b/mobile/lib/widgets/tickets/ticket_time_panel.dart index 16058ee1..9abfb31c 100644 --- a/mobile/lib/widgets/tickets/ticket_time_panel.dart +++ b/mobile/lib/widgets/tickets/ticket_time_panel.dart @@ -19,9 +19,16 @@ class TicketTimePanel extends ConsumerStatefulWidget { final String ticketId; final TimeSummary? initialSummary; + /// Whether this person may log time or start a timer here: the ticket's + /// write rule, which the detail payload reports as `comment_permission`. + /// The API answers anyone else 403. Stopping your own running timer is not + /// gated on it. + final bool canLogTime; + const TicketTimePanel({ super.key, required this.ticketId, + required this.canLogTime, this.initialSummary, }); @@ -194,11 +201,12 @@ class _TicketTimePanelState extends ConsumerState { ), ), const Spacer(), - IconButton( - tooltip: 'Add manual entry', - icon: const Icon(LucideIcons.plus, size: 18), - onPressed: _isBusy ? null : _addManual, - ), + if (widget.canLogTime) + IconButton( + tooltip: 'Add manual entry', + icon: const Icon(LucideIcons.plus, size: 18), + onPressed: _isBusy ? null : _addManual, + ), ], ), if (_summary != null) ...[ @@ -225,7 +233,7 @@ class _TicketTimePanelState extends ConsumerState { isBusy: _isBusy, onStop: () => _stopTimer(running), ) - else + else if (widget.canLogTime) SizedBox( width: double.infinity, child: OutlinedButton.icon( diff --git a/mobile/test/screens/account_rollups_test.dart b/mobile/test/screens/account_rollups_test.dart index d4bb4345..51927e34 100644 --- a/mobile/test/screens/account_rollups_test.dart +++ b/mobile/test/screens/account_rollups_test.dart @@ -119,10 +119,11 @@ class _FakeAccounts extends AccountsNotifier { Future build() async => const AccountsListData(); @override - Future getAccount(String id) async => Account.fromJson({ - 'id': id, - 'name': 'Northwind', - 'currency': 'USD', - 'rollups': rollups, - }); + Future getAccount(String id, {void Function()? onNotFound}) async => + Account.fromJson({ + 'id': id, + 'name': 'Northwind', + 'currency': 'USD', + 'rollups': rollups, + }); } diff --git a/mobile/test/screens/contacts/contact_form_account_test.dart b/mobile/test/screens/contacts/contact_form_account_test.dart index be747537..16b37994 100644 --- a/mobile/test/screens/contacts/contact_form_account_test.dart +++ b/mobile/test/screens/contacts/contact_form_account_test.dart @@ -121,7 +121,8 @@ class _FakeContacts extends ContactsNotifier { Future build() async => const ContactsListData(); @override - Future getContact(String id) async => stored; + Future getContact(String id, {void Function()? onNotFound}) async => + stored; @override Future updateContact(String id, Map payload) async { diff --git a/mobile/test/screens/invoices/document_adjustments_test.dart b/mobile/test/screens/invoices/document_adjustments_test.dart new file mode 100644 index 00000000..aa82eeb6 --- /dev/null +++ b/mobile/test/screens/invoices/document_adjustments_test.dart @@ -0,0 +1,482 @@ +import 'package:bottle_crm/data/models/models.dart'; +import 'package:bottle_crm/providers/invoice_extras_provider.dart'; +import 'package:bottle_crm/providers/invoices_provider.dart'; +import 'package:bottle_crm/providers/lookup_provider.dart'; +import 'package:bottle_crm/screens/invoices/document_adjustments.dart'; +import 'package:bottle_crm/screens/invoices/invoice_detail_screen.dart'; +import 'package:bottle_crm/screens/invoices/line_item_sheet.dart'; +import 'package:bottle_crm/screens/invoices/new_estimate_screen.dart'; +import 'package:bottle_crm/screens/invoices/new_invoice_screen.dart'; +import 'package:bottle_crm/screens/invoices/new_recurring_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +/// Document discount, tax and shipping on the invoice, estimate and schedule +/// forms (PARITY B5), the server's discount bounds mirrored on the phone, and +/// the invoice detail's breakdown (PARITY B6). +/// +/// The server is the check that counts; these pin that the phone asks it the +/// same question the web does, with the same payload and the same wording. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('the adjustments ladder and payload', () { + test('discount off the subtotal, tax on the rest, shipping untaxed', () { + const flat = DocumentAdjustments( + discountType: 'FIXED', + discountValue: 20, + taxRate: 10, + shipping: 5, + ); + // 200 - 20 = 180, + 18 tax, + 5 shipping. + expect(flat.total(200), closeTo(203, 0.001)); + + const percent = DocumentAdjustments( + discountType: 'PERCENTAGE', + discountValue: 10, + ); + expect(percent.total(200), closeTo(180, 0.001)); + + // A value with no type chosen takes nothing off and is not sent. + const untyped = DocumentAdjustments(discountValue: 50); + expect(untyped.total(200), 200); + expect(untyped.toPayload(), isEmpty); + }); + + test('sends the web builders keys, and only what is set', () { + expect( + const DocumentAdjustments( + discountType: 'FIXED', + discountValue: 20, + taxRate: 7.5, + shipping: 5, + ).toPayload(), + { + 'discount_type': 'FIXED', + 'discount_value': '20.00', + 'tax_rate': '7.50', + 'shipping_amount': '5.00', + }, + ); + expect(const DocumentAdjustments().toPayload(), isEmpty); + }); + + test('refuses what the API refuses, in its words', () { + expect( + const DocumentAdjustments( + discountType: 'PERCENTAGE', + discountValue: 100.01, + ).discountError(500), + 'A percentage discount cannot exceed 100.', + ); + expect( + const DocumentAdjustments( + discountType: 'FIXED', + discountValue: 100.01, + ).discountError(100), + 'A discount cannot exceed the subtotal.', + ); + // The whole subtotal off is allowed, compared in cents. + expect( + const DocumentAdjustments( + discountType: 'FIXED', + discountValue: 0.3, + ).discountError(0.1 + 0.2), + isNull, + ); + expect( + const DocumentAdjustments( + discountType: 'PERCENTAGE', + discountValue: 100, + ).discountError(0), + isNull, + ); + }); + + test('a tax rate over 100 is refused, and blocks the form', () { + const over = DocumentAdjustments(taxRate: 100.01); + expect(over.taxError, 'Tax rate cannot exceed 100.'); + expect(over.isValidFor(100), isFalse); + expect(const DocumentAdjustments(taxRate: 100).taxError, isNull); + expect(const DocumentAdjustments(taxRate: 100).isValidFor(100), isTrue); + }); + + test('a line discount cannot outgrow its line', () { + const line = LineItemDraft( + name: 'Seats', + quantity: 2, + unitPrice: 100, + discountType: 'FIXED', + discountValue: 150, + ); + expect(line.discountError, isNull); + expect( + const LineItemDraft( + name: 'Seats', + quantity: 1, + unitPrice: 100, + discountType: 'FIXED', + discountValue: 150, + ).discountError, + "A discount cannot exceed the line's amount.", + ); + expect(const LineItemDraft(name: 'Plain').discountError, isNull); + }); + }); + + void usePhone(WidgetTester tester, double textScale) { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + + Future pump( + WidgetTester tester, + Widget screen, { + double textScale = 1.0, + List overrides = const [], + }) async { + usePhone(tester, textScale); + // A router, as in the app: the forms leave with `context.pop()`. + final router = GoRouter( + initialLocation: '/start', + routes: [ + GoRoute(path: '/start', builder: (_, _) => const Text('started')), + GoRoute(path: '/form', builder: (_, _) => screen), + ], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + accountsLookupProvider.overrideWith(_FakeAccounts.new), + contactsLookupProvider.overrideWith(_FakeContacts.new), + opportunityEntityOptionsProvider.overrideWithValue( + const AsyncValue.data([]), + ), + productsProvider.overrideWith(_FakeProducts.new), + ...overrides.cast(), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + router.push('/form'); + await tester.pumpAndSettle(); + } + + Future scrollTo(WidgetTester tester, Finder target) async { + await tester.scrollUntilVisible( + target, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + for (final scale in [1.0, 1.3]) { + testWidgets('the invoice form lays out at 390px, text x$scale', ( + tester, + ) async { + await pump(tester, const NewInvoiceScreen(), textScale: scale); + await scrollTo(tester, find.text('Shipping')); + await tester.tap(find.text('No discount')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fixed amount').last); + await tester.pumpAndSettle(); + + expect(find.text('Amount off'), findsOneWidget); + expect(find.text('Tax rate'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the schedule form lays out at 390px, text x$scale', ( + tester, + ) async { + await pump(tester, const NewRecurringScreen(), textScale: scale); + await scrollTo(tester, find.text('Tax rate')); + + // The invoice form's own line list and adjustments card, no shipping. + expect(find.text('Add a line'), findsOneWidget); + expect(find.text('Shipping'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('the invoice detail shows its breakdown at 390px, x$scale', ( + tester, + ) async { + await pump( + tester, + const InvoiceDetailScreen(invoiceId: 'inv-1'), + textScale: scale, + overrides: [invoicesProvider.overrideWith(_FakeInvoices.new)], + ); + + expect(find.text('Subtotal'), findsOneWidget); + expect(find.text(r'$200.00'), findsOneWidget); + expect(find.text('Discount'), findsOneWidget); + expect(find.text(r'-$20.00'), findsOneWidget); + expect(find.text('Tax'), findsOneWidget); + expect(find.text(r'$18.00'), findsOneWidget); + expect(find.text('Shipping'), findsOneWidget); + expect(find.text(r'$5.00'), findsOneWidget); + expect(find.text('Total'), findsOneWidget); + expect(find.text(r'$203.00'), findsWidgets); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'the detail leaves out a discount and shipping it does not have', + (tester) async { + await pump( + tester, + const InvoiceDetailScreen(invoiceId: 'inv-1'), + overrides: [ + invoicesProvider.overrideWith(() => _FakeInvoices(plain: true)), + ], + ); + + expect(find.text('Subtotal'), findsOneWidget); + expect(find.text('Tax'), findsOneWidget); + expect(find.text('Discount'), findsNothing); + expect(find.text('Shipping'), findsNothing); + }, + ); + + testWidgets('a schedule needs a line only when it emails each invoice', ( + tester, + ) async { + await pump(tester, const NewRecurringScreen(), textScale: 1.3); + FilledButton start() => tester.widget( + find.widgetWithText(FilledButton, 'Start schedule'), + ); + + await tester.tap( + find.widgetWithText(DropdownButtonFormField, 'Account'), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Northwind').last); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(DropdownButtonFormField, 'Bill to'), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('c1 Person').last); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'What it is for'), + 'Hosting', + ); + await tester.pumpAndSettle(); + + // No lines and no auto-send: the API takes it, so the form does too. + expect(start().onPressed, isNotNull); + + await scrollTo(tester, find.text('Email each invoice automatically')); + await tester.tap(find.text('Email each invoice automatically')); + await tester.pumpAndSettle(); + + expect( + find.text('Add at least one line before turning on auto-send.'), + findsOneWidget, + ); + expect(start().onPressed, isNull); + + await scrollTo(tester, find.text('Add a line')); + await tester.tap(find.text('Add a line')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Description'), + 'Plan', + ); + await tester.enterText(find.widgetWithText(TextField, 'Unit price'), '50'); + await tester.tap(find.text('Add line')); + await tester.pumpAndSettle(); + + expect( + find.text('Add at least one line before turning on auto-send.'), + findsNothing, + ); + expect(start().onPressed, isNotNull); + expect(tester.takeException(), isNull); + }); + + group('the estimate form from a deal', () { + late _FakeEstimates estimates; + + setUp(() => estimates = _FakeEstimates()); + + Future pumpEstimate(WidgetTester tester, {Deal? deal}) => pump( + tester, + NewEstimateScreen(fromDeal: deal ?? _deal()), + overrides: [estimatesProvider.overrideWith(() => estimates)], + ); + + FilledButton submit(WidgetTester tester) => tester.widget( + find.widgetWithText(FilledButton, 'Create draft'), + ); + + Future chooseFixed(WidgetTester tester, String amount) async { + await scrollTo(tester, find.text('Tax rate')); + await tester.tap(find.text('No discount')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fixed amount').last); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Amount off'), + amount, + ); + await tester.pumpAndSettle(); + } + + testWidgets('a discount over the subtotal is refused before sending', ( + tester, + ) async { + await pumpEstimate(tester); + // The deal's line: 3 x 100. + await chooseFixed(tester, '300.01'); + + expect( + find.text('A discount cannot exceed the subtotal.'), + findsOneWidget, + ); + expect(submit(tester).onPressed, isNull); + }); + + testWidgets("a deal line's discount holds the line's price up", ( + tester, + ) async { + await pumpEstimate( + tester, + deal: _deal( + lineDiscount: {'discount_type': 'FIXED', 'discount_value': '50.00'}, + ), + ); + await tester.tap(find.text('Seats')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Unit price'), + '10', + ); + await tester.tap(find.text('Save line')); + await tester.pumpAndSettle(); + + // 3 x 10 is less than the 50 the deal took off, which the API refuses. + expect( + find.text("A discount cannot exceed the line's amount."), + findsOneWidget, + ); + expect(find.text('Edit the line'), findsOneWidget); + }); + + testWidgets('discount and tax travel in the web payload shape', ( + tester, + ) async { + await pumpEstimate(tester); + await chooseFixed(tester, '100'); + await tester.enterText(find.widgetWithText(TextField, 'Tax rate'), '10'); + await tester.pumpAndSettle(); + + // (300 - 100) + 10% tax. + expect(find.text('€220.00'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Create draft')); + await tester.pumpAndSettle(); + + final sent = estimates.created.single; + expect(sent['discount_type'], 'FIXED'); + expect(sent['discount_value'], '100.00'); + expect(sent['tax_rate'], '10.00'); + // An estimate has no shipping. + expect(sent.containsKey('shipping_amount'), isFalse); + expect(find.text('started'), findsOneWidget); + }); + }); +} + +Deal _deal({Map lineDiscount = const {}}) => Deal.fromJson({ + 'id': 'd1', + 'name': 'Renewal', + 'stage': 'TALKING', + 'stage_kind': 'open', + 'currency': 'EUR', + 'account': {'id': 'acc-1', 'name': 'Northwind'}, + 'contacts': [ + {'id': 'c1', 'first_name': 'c1', 'last_name': 'Person'}, + ], + 'line_items': [ + { + 'id': 'li1', + 'name': 'Seats', + 'quantity': 3, + 'unit_price': '100.00', + ...lineDiscount, + }, + ], + 'created_at': '2026-09-01T00:00:00Z', +}); + +class _FakeAccounts extends AccountsLookupNotifier { + @override + Future> build() async => const [ + AccountLookup(id: 'acc-1', name: 'Northwind'), + ]; +} + +class _FakeContacts extends ContactsLookupNotifier { + @override + Future> build() async => const [ + ContactLookup(id: 'c1', firstName: 'c1', lastName: 'Person'), + ]; +} + +class _FakeProducts extends ProductsNotifier { + @override + Future> build() async => const []; +} + +class _FakeEstimates extends EstimatesNotifier { + final List> created = []; + + @override + Future> build() async => const []; + + @override + Future create(Map payload) async { + created.add(payload); + return null; + } +} + +class _FakeInvoices extends InvoicesNotifier { + _FakeInvoices({this.plain = false}); + + /// No discount and no shipping, so those rows have nothing to show. + final bool plain; + + @override + Future build() async => const InvoicesListData(); + + @override + Future getInvoice(String id) async => Invoice.fromJson({ + 'id': id, + 'invoice_number': 'INV-1', + 'invoice_title': 'Retainer', + 'status': 'Sent', + 'currency': 'USD', + 'subtotal': '200.00', + 'discount_amount': plain ? '0.00' : '20.00', + 'tax_amount': plain ? '20.00' : '18.00', + 'shipping_amount': plain ? '0.00' : '5.00', + 'total_amount': plain ? '220.00' : '203.00', + 'amount_paid': '0.00', + 'amount_due': plain ? '220.00' : '203.00', + 'line_items': [], + 'payments': [], + }); +} diff --git a/mobile/test/screens/invoices/estimate_and_deal_billing_test.dart b/mobile/test/screens/invoices/estimate_and_deal_billing_test.dart index eba2bfde..0ef9d325 100644 --- a/mobile/test/screens/invoices/estimate_and_deal_billing_test.dart +++ b/mobile/test/screens/invoices/estimate_and_deal_billing_test.dart @@ -402,7 +402,10 @@ class _FakeDeals extends DealsNotifier { Future build() async => const DealsListData(); @override - Future getDealDetail(String id) async => DealDetail(deal: deal); + Future getDealDetail( + String id, { + void Function()? onNotFound, + }) async => DealDetail(deal: deal); } class _FakePipelines extends DealPipelinesNotifier { diff --git a/mobile/test/screens/leads/lead_form_status_test.dart b/mobile/test/screens/leads/lead_form_status_test.dart index 1882b0b5..212dc7c2 100644 --- a/mobile/test/screens/leads/lead_form_status_test.dart +++ b/mobile/test/screens/leads/lead_form_status_test.dart @@ -132,8 +132,10 @@ class _FakeLeads extends LeadsNotifier { Future build() async => const LeadsListData(); @override - Future getLeadDetail(String id) async => - LeadDetail(lead: stored); + Future getLeadDetail( + String id, { + void Function()? onNotFound, + }) async => LeadDetail(lead: stored); @override Future>> updateLead( diff --git a/mobile/test/screens/leads/lead_form_tags_test.dart b/mobile/test/screens/leads/lead_form_tags_test.dart index 48ae398e..d00c3a31 100644 --- a/mobile/test/screens/leads/lead_form_tags_test.dart +++ b/mobile/test/screens/leads/lead_form_tags_test.dart @@ -181,8 +181,10 @@ class _FakeLeads extends LeadsNotifier { Future build() async => const LeadsListData(); @override - Future getLeadDetail(String id) async => - LeadDetail(lead: stored); + Future getLeadDetail( + String id, { + void Function()? onNotFound, + }) async => LeadDetail(lead: stored); @override Future>> updateLead( diff --git a/mobile/test/screens/phone_viewport_test.dart b/mobile/test/screens/phone_viewport_test.dart index fba8f244..cb7c0925 100644 --- a/mobile/test/screens/phone_viewport_test.dart +++ b/mobile/test/screens/phone_viewport_test.dart @@ -1060,14 +1060,14 @@ void main() { expect(find.text('Pick an account first'), findsOneWidget); }); - testWidgets('says the running total excludes tax and discount', ( + testWidgets('says the running total includes tax and discount', ( tester, ) async { await pump(tester, newInvoiceApp()); - // Tax, discount and shipping are applied server-side and are not on - // this form, so calling the figure "Total" would overstate it. - expect(find.text('before any tax or discount'), findsOneWidget); + // Discount, tax and shipping are on this form now (PARITY B5), so the + // figure is the whole ladder and says so. + expect(find.text('with discount, tax and shipping'), findsOneWidget); }); testWidgets('the create button is thumb-sized', (tester) async { @@ -1136,7 +1136,10 @@ void main() { // A figure with no cadence beside it is the one number this screen // must never show: it reads as a one-off total. - expect(find.textContaining('monthly, before tax'), findsOneWidget); + expect( + find.textContaining('monthly, with discounts and tax'), + findsOneWidget, + ); }); }); diff --git a/mobile/test/screens/saved_views_button_test.dart b/mobile/test/screens/saved_views_button_test.dart new file mode 100644 index 00000000..16e2c582 --- /dev/null +++ b/mobile/test/screens/saved_views_button_test.dart @@ -0,0 +1,439 @@ +import 'dart:convert'; + +import 'package:bottle_crm/core/theme/theme.dart'; +import 'package:bottle_crm/data/models/auth_response.dart'; +import 'package:bottle_crm/data/models/models.dart'; +import 'package:bottle_crm/providers/accounts_provider.dart'; +import 'package:bottle_crm/providers/auth_provider.dart'; +import 'package:bottle_crm/providers/contacts_provider.dart'; +import 'package:bottle_crm/providers/deal_pipelines_provider.dart'; +import 'package:bottle_crm/providers/deals_provider.dart'; +import 'package:bottle_crm/providers/invoices_provider.dart'; +import 'package:bottle_crm/providers/leads_provider.dart'; +import 'package:bottle_crm/providers/lookup_provider.dart'; +import 'package:bottle_crm/providers/saved_views_provider.dart'; +import 'package:bottle_crm/providers/tickets_provider.dart'; +import 'package:bottle_crm/screens/accounts/accounts_list_screen.dart'; +import 'package:bottle_crm/screens/contacts/contacts_list_screen.dart'; +import 'package:bottle_crm/screens/deals/deals_list_screen.dart'; +import 'package:bottle_crm/screens/invoices/invoices_list_screen.dart'; +import 'package:bottle_crm/screens/leads/leads_list_screen.dart'; +import 'package:bottle_crm/screens/tickets/tickets_list_screen.dart'; +import 'package:bottle_crm/services/api_service.dart'; +import 'package:bottle_crm/widgets/common/saved_views_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +/// Saved views (G29) on the six record lists, rendered at a real phone. +/// +/// The button has to sit on each app bar at 390px, with large text too, +/// without squeezing the title out, and be thumb-sized. The sheet has to fit +/// the same phone, and a view has to land on the screen's own filters. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _QueueClient client; + + setUp(() { + client = _QueueClient(); + ApiService().setClientForTesting(client); + }); + + void usePhone(WidgetTester tester, double textScale) { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + + Widget app(Widget screen) => ProviderScope( + overrides: [ + authProvider.overrideWith(_FakeAuth.new), + leadsProvider.overrideWith(_FakeLeads.new), + contactsProvider.overrideWith(_FakeContacts.new), + accountsProvider.overrideWith(_FakeAccounts.new), + dealsProvider.overrideWith(_FakeDeals.new), + dealPipelinesProvider.overrideWith(_FakePipelines.new), + ticketsProvider.overrideWith(_FakeTickets.new), + invoicesProvider.overrideWith(_FakeInvoices.new), + usersProvider.overrideWithValue(const []), + tagsProvider.overrideWithValue(const []), + accountOptionsProvider.overrideWithValue(const []), + ], + child: MaterialApp(theme: AppTheme.light, home: screen), + ); + + final screens = { + 'leads': ('Leads', const LeadsListScreen()), + 'contacts': ('Contacts', const ContactsListScreen()), + 'accounts': ('Accounts', const AccountsListScreen()), + 'deals': ('Deals', const DealsListScreen()), + 'tickets': ('Tickets', const TicketsListScreen()), + 'invoices': ('Invoices', const InvoicesListScreen()), + }; + + for (final textScale in [1.0, 1.3]) { + for (final entry in screens.entries) { + final (title, screen) = entry.value; + testWidgets('${entry.key}: saved views is on the app bar at 390px, ' + 'text x$textScale', (tester) async { + usePhone(tester, textScale); + await tester.pumpWidget(app(screen)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final box = tester.getRect(find.byType(SavedViewsButton)); + expect(box.width, greaterThanOrEqualTo(44)); + expect(box.height, greaterThanOrEqualTo(44)); + expect(box.right, lessThanOrEqualTo(390)); + // The title keeps room for its own word beside the icons. + final titleBox = tester.getRect(find.text(title).first); + expect(titleBox.width, greaterThan(40)); + expect(titleBox.right, lessThanOrEqualTo(box.left + 1)); + }); + } + } + + group('the sheet', () { + const view = { + 'id': '99999999-8888-7777-6666-555555555555', + 'module': 'leads', + 'name': 'Hot leads nobody has called back since the trade show', + 'filters': { + 'search': ['ada'], + 'rating': ['HOT'], + 'city': ['Austin'], + }, + }; + + Future>>> openSheet( + WidgetTester tester, { + double textScale = 1.0, + Map current = const {}, + }) async { + usePhone(tester, textScale); + final applied = >>[]; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + appBar: AppBar( + actions: [ + SavedViewsButton( + list: SavedViewList.leads, + keys: const {'search', 'rating'}, + currentQuery: () async => current, + onApply: applied.add, + ), + ], + ), + ), + ), + ), + ); + await tester.tap(find.byTooltip('Saved views')); + await tester.pumpAndSettle(); + return applied; + } + + for (final textScale in [1.0, 1.3]) { + testWidgets('lists views and fits a phone at text x$textScale', ( + tester, + ) async { + client.replies.add((200, _list([view]))); + await openSheet(tester, textScale: textScale); + + expect(tester.takeException(), isNull); + expect(find.text(view['name'] as String), findsOneWidget); + expect( + find.text('1 filter value this screen cannot show'), + findsOneWidget, + ); + final save = tester.getRect( + find.ancestor( + of: find.text('Save current filters'), + matching: find.byWidgetPredicate((w) => w is FilledButton), + ), + ); + expect(save.height, greaterThanOrEqualTo(44)); + expect(save.right, lessThanOrEqualTo(390)); + final more = tester.getRect(find.byTooltip('More for ${view['name']}')); + expect(more.height, greaterThanOrEqualTo(44)); + }); + } + + testWidgets('tapping a view applies it and says what it left out', ( + tester, + ) async { + client.replies.add((200, _list([view]))); + final applied = await openSheet(tester); + + await tester.tap(find.text(view['name'] as String)); + await tester.pumpAndSettle(); + + expect(applied.single['search'], ['ada']); + expect(applied.single['city'], ['Austin']); + expect(find.text('Saved views'), findsNothing); + expect( + find.text( + 'Showing ${view['name']}. 1 filter value in it cannot be shown here.', + ), + findsOneWidget, + ); + }); + + testWidgets('saving sends only the keys this screen has', (tester) async { + client.replies + ..add((200, _list([]))) + ..add((201, jsonEncode(view))) + ..add((200, _list([view]))); + await openSheet( + tester, + current: { + 'search': 'ada', + 'rating': 'HOT', + 'sort': 'due_date', + 'limit': '20', + }, + ); + expect( + find.text('No saved views yet. Filter the list, then save it here.'), + findsOneWidget, + ); + + await tester.tap(find.text('Save current filters')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Hot'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(client.sent[1].method, 'POST'); + expect(jsonDecode(client.bodies[1]), { + 'module': 'leads', + 'name': 'Hot', + 'filters': { + 'search': ['ada'], + 'rating': ['HOT'], + }, + }); + expect(find.text(view['name'] as String), findsOneWidget); + }); + + testWidgets("a refusal shows the API's sentence in the sheet", ( + tester, + ) async { + client.replies + ..add((200, _list([view]))) + ..add(( + 400, + '{"name": ["You already have a view with this name for this list."]}', + )); + await openSheet(tester); + + await tester.tap(find.text('Save current filters')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'dup'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + find.text('You already have a view with this name for this list.'), + findsOneWidget, + ); + }); + + testWidgets('rename and delete go through the row menu', (tester) async { + client.replies + ..add((200, _list([view]))) + ..add((200, jsonEncode({...view, 'name': 'Warm'}))) + ..add(( + 200, + _list([ + {...view, 'name': 'Warm'}, + ]), + )) + ..add((204, '')) + ..add((200, _list([]))); + await openSheet(tester); + + await tester.tap(find.byTooltip('More for ${view['name']}')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Warm'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(client.sent[1].method, 'PATCH'); + expect(find.text('Warm'), findsOneWidget); + + await tester.tap(find.byTooltip('More for Warm')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + // Nothing is sent until the confirm. + expect(client.sent, hasLength(3)); + await tester.tap(find.widgetWithText(TextButton, 'Delete')); + await tester.pumpAndSettle(); + expect(client.sent[3].method, 'DELETE'); + expect(find.text('Warm'), findsNothing); + }); + + testWidgets('a full list says so instead of offering Save', (tester) async { + client.replies.add((200, _list([view], limit: 1))); + await openSheet(tester); + + expect(find.text('Save current filters'), findsNothing); + expect( + find.text( + 'This list keeps at most 1 saved views. Delete one to save another.', + ), + findsOneWidget, + ); + }); + }); + + testWidgets('leads: a view lands on the search box and the chips', ( + tester, + ) async { + usePhone(tester, 1.0); + client.replies.add(( + 200, + _list([ + { + 'id': '99999999-8888-7777-6666-555555555555', + 'module': 'leads', + 'name': 'Hot', + 'filters': { + 'search': ['ada'], + 'rating': ['HOT'], + 'status': ['assigned', 'in process'], + }, + }, + ]), + )); + await tester.pumpWidget(app(const LeadsListScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Saved views')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Hot')); + await tester.pumpAndSettle(); + + final applied = _FakeLeads.last!; + expect(applied.search, 'ada'); + expect(applied.rating, LeadRating.hot); + expect(applied.statuses, {LeadStatus.assigned, LeadStatus.inProcess}); + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'ada', + ); + expect(find.text('2 statuses'), findsOneWidget); + }); +} + +String _list(List> views, {int limit = 25}) => + jsonEncode({'saved_views': views, 'limit': limit}); + +class _QueueClient extends http.BaseClient { + final List<(int, String)> replies = []; + final List sent = []; + final List bodies = []; + + /// Only saved-views calls are queued and kept; anything else the screens + /// ask for on the way (the profile, lookups) gets an empty answer. + @override + Future send(http.BaseRequest request) async { + final ours = request.url.path.contains('/saved-views/'); + final bytes = await request.finalize().toBytes(); + if (ours) { + sent.add(request); + bodies.add(utf8.decode(bytes)); + } + final (status, body) = !ours + ? (200, '{}') + : replies.isEmpty + ? (200, _list([])) + : replies.removeAt(0); + return http.StreamedResponse( + Stream.value(utf8.encode(body)), + status, + request: request, + ); + } +} + +class _FakeAuth extends AuthNotifier { + @override + AuthState build() { + const org = Organization(id: 'org-1', name: 'Org', currencySymbol: r'$'); + return AuthState( + user: const AuthUser(id: 'u1', email: 'me@example.com'), + organizations: [org], + selectedOrganization: org, + isAuthenticated: true, + ); + } +} + +class _FakeLeads extends LeadsNotifier { + static LeadFilters? last; + + @override + Future build() async => LeadsListData(hasMore: false); + + @override + Future setFilters(LeadFilters filters) async => last = filters; +} + +class _FakeContacts extends ContactsNotifier { + @override + Future build() async => const ContactsListData(); +} + +class _FakeAccounts extends AccountsNotifier { + @override + Future build() async => const AccountsListData(); +} + +class _FakeDeals extends DealsNotifier { + @override + Future build() async => DealsListData(); + + @override + Future refresh({String? search, String? stage}) async {} +} + +class _FakePipelines extends DealPipelinesNotifier { + @override + Future> build() async => [ + DealPipeline.fromJson({ + 'id': 'p1', + 'name': 'Sales', + 'is_default': true, + 'stages': [ + {'id': 's1', 'code': 'PROSPECTING', 'label': 'Prospecting'}, + ], + }), + ]; +} + +class _FakeTickets extends TicketsNotifier { + @override + Future build() async => + TicketsListData(tickets: const [], totalCount: 0, hasMore: false); + + @override + Future refresh({ + TicketListFilters filters = const TicketListFilters(), + }) async {} +} + +class _FakeInvoices extends InvoicesNotifier { + @override + Future build() async => const InvoicesListData(); +} diff --git a/mobile/test/screens/settings/audit_log_screen_test.dart b/mobile/test/screens/settings/audit_log_screen_test.dart new file mode 100644 index 00000000..00dc790c --- /dev/null +++ b/mobile/test/screens/settings/audit_log_screen_test.dart @@ -0,0 +1,174 @@ +import 'package:bottle_crm/data/models/audit_entry.dart'; +import 'package:bottle_crm/providers/audit_log_provider.dart'; +import 'package:bottle_crm/providers/auth_provider.dart'; +import 'package:bottle_crm/screens/settings/audit_log_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The audit log on the phone: a 390px phone at 1.0x and 1.3x text (Flutter +/// reports an overflow as an exception `takeException` returns), a tablet, +/// the member gate, tapping a person to filter, and the model and query +/// helpers the screen leans on. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + void useViewport( + WidgetTester tester, { + Size size = const Size(390, 844), + double textScale = 1.0, + }) { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = Size(size.width * 3, size.height * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + + final paused = AuditEntry.fromJson(const { + 'id': 'a1', + 'event_type': 'WEBHOOK_PAUSED', + 'event_label': 'Webhook Paused', + 'created_at': '2026-09-26T10:15:00Z', + 'success': true, + 'actor': { + 'id': 'u1', + 'name': 'Asha Raman with a rather long display name', + 'email': 'asha@example.com', + }, + 'ip_address': '2001:db8:85a3:0000:0000:8a2e:0370:7334', + 'details': { + 'endpoint_id': 'w1', + 'pause_reason': + 'Paused because the admin who created it is no longer an admin.', + }, + }); + final refused = AuditEntry.fromJson(const { + 'id': 'a2', + 'event_type': 'PERMISSION_DENIED', + 'event_label': 'Permission Denied', + 'created_at': '2026-09-25T08:00:00Z', + 'success': false, + 'actor': null, + 'details': {'action': 'ORG_SWITCH', 'resource': 'org:123'}, + }); + final page = AuditLogPage( + entries: [paused, refused], + count: 60, + eventTypes: const [ + AuditEventType(value: 'WEBHOOK_PAUSED', label: 'Webhook Paused'), + AuditEventType(value: 'PERMISSION_DENIED', label: 'Permission Denied'), + ], + ); + + late List asked; + + Future pump( + WidgetTester tester, { + bool admin = true, + double textScale = 1.0, + Size size = const Size(390, 844), + }) async { + asked = []; + useViewport(tester, size: size, textScale: textScale); + await tester.pumpWidget( + ProviderScope( + overrides: [ + isOrgAdminProvider.overrideWithValue(admin), + auditLogProvider.overrideWith((ref, q) async { + asked.add(q); + return page; + }), + ], + child: const MaterialApp(home: AuditLogScreen()), + ), + ); + await tester.pumpAndSettle(); + } + + for (final scale in [1.0, 1.3]) { + testWidgets('fits a 390px phone at ${scale}x text', (tester) async { + await pump(tester, textScale: scale); + expect(tester.takeException(), isNull); + expect(find.text('Webhook Paused'), findsWidgets); + expect(find.text(paused.detail), findsOneWidget); + expect(find.text('Refused'), findsOneWidget); + expect(find.text('Open webhook'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Older'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('holds up at tablet width', (tester) async { + await pump(tester, size: const Size(834, 1112), textScale: 1.3); + expect(tester.takeException(), isNull); + }); + + testWidgets('a member sees the notice and never asks', (tester) async { + await pump(tester, admin: false); + expect(find.text('Administrators only'), findsOneWidget); + expect(asked, isEmpty); + }); + + testWidgets('tapping a person narrows the log to them', (tester) async { + await pump(tester); + await tester.tap(find.text(paused.actorLabel)); + await tester.pumpAndSettle(); + expect(asked.last.actor, 'u1'); + expect(asked.last.offset, 0); + expect(find.text('Show everyone'), findsOneWidget); + }); + + testWidgets('a system entry has no person to tap', (tester) async { + await pump(tester); + final button = tester.widget( + find.ancestor( + of: find.text('No user'), + matching: find.byType(TextButton), + ), + ); + expect(button.onPressed, isNull); + }); + + group('models and query', () { + test('details read as one line, as on the web', () { + expect(paused.detail, startsWith('Paused because')); + expect(refused.detail, 'ORG_SWITCH on org:123'); + expect(paused.webhookId, 'w1'); + expect(refused.webhookId, isNull); + expect(refused.actorLabel, 'No user'); + final changed = AuditEntry.fromJson(const { + 'id': 'a3', + 'event_type': 'WEBHOOK_CHANGED', + 'details': { + 'changed': ['url', 'secret'], + }, + }); + expect( + changed.detail, + 'Changed url, secret, and now answers for the webhook.', + ); + }); + + test('only set filters are sent, dates as YYYY-MM-DD', () { + final params = auditLogParams(( + eventType: 'ORG_SWITCH', + actor: null, + from: DateTime(2026, 9, 1), + to: null, + offset: 25, + )); + expect(params, { + 'limit': '$auditLogPageSize', + 'offset': '25', + 'event_type': 'ORG_SWITCH', + 'from': '2026-09-01', + }); + }); + }); +} diff --git a/mobile/test/screens/settings/webhooks_screen_test.dart b/mobile/test/screens/settings/webhooks_screen_test.dart index bac946ee..c5a9001e 100644 --- a/mobile/test/screens/settings/webhooks_screen_test.dart +++ b/mobile/test/screens/settings/webhooks_screen_test.dart @@ -49,6 +49,8 @@ void main() { description: 'Zapier: new leads into the regional sales spreadsheet', events: ['lead.created', 'ticket.comment_added'], secretHint: 'whsec_...a1b2', + createdByName: 'Asha Raman', + createdByEmail: 'asha.raman.with.a.long.address@example-company.com', ); const gone = WebhookEndpoint( id: 'w2', @@ -59,8 +61,19 @@ void main() { disabledReason: 'The endpoint answered 410 Gone, so it was turned off.', secretHint: 'whsec_...zz99', ); + const paused = WebhookEndpoint( + id: 'w3', + url: 'https://hooks.example.com/in', + events: ['lead.created'], + isActive: false, + disabledReason: + 'Paused because the admin who created it is no longer an admin.', + secretHint: 'whsec_...cc33', + createdByName: 'Dev Patel', + createdByEmail: 'dev@example.com', + ); const state = WebhooksState( - endpoints: [live, gone], + endpoints: [live, gone, paused], catalogue: catalogue, limit: 10, ); @@ -121,7 +134,9 @@ void main() { expect(tester.takeException(), isNull); expect(find.text(live.description), findsOneWidget); expect(find.text('Sending'), findsOneWidget); - expect(find.text('Turned off'), findsOneWidget); + // The 410 one and the one whose creator lost admin. + expect(find.text('Paused'), findsNWidgets(2)); + expect(find.textContaining('By Asha Raman'), findsOneWidget); expect(find.text('New webhook'), findsOneWidget); }); @@ -200,11 +215,37 @@ void main() { ) async { await pump(tester, const WebhookDetailScreen(webhookId: 'w2')); expect(find.text('Send test'), findsNothing); - expect(find.text('Turn on'), findsOneWidget); + expect(find.text('Re-enable'), findsOneWidget); expect(find.textContaining('410 Gone'), findsOneWidget); expect(find.text('Redeliver'), findsNothing); }); + for (final scale in [1.0, 1.3]) { + testWidgets('a paused endpoint shows why, who, and re-enables at ' + '${scale}x text', (tester) async { + await pump( + tester, + const WebhookDetailScreen(webhookId: 'w3'), + textScale: scale, + ); + expect(tester.takeException(), isNull); + expect(find.text('Paused'), findsOneWidget); + expect(find.text(paused.disabledReason), findsOneWidget); + expect( + find.text('Answers for it: Dev Patel (dev@example.com)'), + findsOneWidget, + ); + expect( + find.textContaining('makes you the admin who answers for it'), + findsOneWidget, + ); + _FakeWebhooks.activations.clear(); + await tester.tap(find.text('Re-enable')); + await tester.pumpAndSettle(); + expect(_FakeWebhooks.activations, [('w3', true)]); + }); + } + testWidgets('the form refuses http and an empty event list', (tester) async { await pump( tester, @@ -269,6 +310,21 @@ void main() { expect(deliveries.deliveries[0].statusLabel, 'Failed'); }); + test('the creator reads as name and email, or as removed', () { + final e = WebhookEndpoint.fromJson(const { + 'id': 'x', + 'url': 'https://h.example.com/', + 'created_by': {'id': 'u1', 'name': 'Asha', 'email': 'a@x.com'}, + }); + expect(e.creatorLabel, 'Asha (a@x.com)'); + final gone = WebhookEndpoint.fromJson(const { + 'id': 'y', + 'url': 'https://h.example.com/', + 'created_by': null, + }); + expect(gone.creatorLabel, 'a removed user'); + }); + test('limit gate', () { expect(state.atLimit, isFalse); expect(const WebhooksState(endpoints: [live], limit: 1).atLimit, isTrue); @@ -281,6 +337,15 @@ class _FakeWebhooks extends WebhooksNotifier { final WebhooksState _state; + /// Every `setActive` call, so a test can see what a button asked for. + static final List<(String, bool)> activations = []; + @override Future build() async => _state; + + @override + Future setActive(String id, bool active) async { + activations.add((id, active)); + return null; + } } diff --git a/mobile/test/screens/solutions/solution_detail_screen_test.dart b/mobile/test/screens/solutions/solution_detail_screen_test.dart index 47c29f8d..4fff544d 100644 --- a/mobile/test/screens/solutions/solution_detail_screen_test.dart +++ b/mobile/test/screens/solutions/solution_detail_screen_test.dart @@ -310,7 +310,13 @@ class _FakeAuth extends AuthNotifier { @override AuthState build() { - final org = Organization(id: 'org-1', name: 'Test Org', role: role); + // The API sends the admin fact with the org; the app gates on that. + final org = Organization( + id: 'org-1', + name: 'Test Org', + role: role, + isOrganizationAdmin: role == 'ADMIN', + ); return AuthState( user: AuthUser(id: userId, email: 'user@example.com'), organizations: [org], diff --git a/mobile/test/screens/tickets/ticket_detail_redirect_and_gate_test.dart b/mobile/test/screens/tickets/ticket_detail_redirect_and_gate_test.dart new file mode 100644 index 00000000..f0a633f1 --- /dev/null +++ b/mobile/test/screens/tickets/ticket_detail_redirect_and_gate_test.dart @@ -0,0 +1,498 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bottle_crm/core/theme/theme.dart'; +import 'package:bottle_crm/data/models/ticket.dart'; +import 'package:bottle_crm/data/models/time_entry.dart'; +import 'package:bottle_crm/providers/lookup_provider.dart'; +import 'package:bottle_crm/providers/tickets_provider.dart'; +import 'package:bottle_crm/screens/tickets/ticket_detail_screen.dart'; +import 'package:bottle_crm/services/api_service.dart'; +import 'package:bottle_crm/widgets/tickets/ticket_time_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// Ticket detail, three answers the API can give besides a ticket: +/// +/// * A merged ticket answers `redirect_to`, the ticket it was merged into. The +/// phone opens that one in its place, as the web redirects, so it never +/// offers a status change on the merged ticket (the API refuses one with +/// "unmerge first"). +/// * A ticket the caller may not open answers 404, the same body a missing one +/// gets. The screen shows a not-found state and does not crash. +/// * Link and detach take the ticket's write rule on the server, and +/// `comment_permission` is its answer, so the action is offered only when it +/// is true. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('getTicketDetail', () { + late _RoutingClient client; + late ProviderContainer container; + + setUp(() async { + client = _RoutingClient(); + ApiService().setClientForTesting(client); + container = ProviderContainer(); + await container.read(ticketsProvider.future); + client.paths.clear(); + }); + + tearDown(() { + container.dispose(); + ApiService().setClientForTesting(http.Client()); + }); + + Map ticket(String id) => { + 'cases_obj': {'id': id, 'name': 'Ticket $id', 'status': 'New'}, + 'comment_permission': true, + }; + + test('a merged ticket comes back as the one it was merged into', () async { + client.routes['/cases/src/'] = (200, {'redirect_to': 'tgt'}); + client.routes['/cases/tgt/'] = (200, ticket('tgt')); + + final result = await container + .read(ticketsProvider.notifier) + .getTicketDetail('src'); + + expect(result?.ticketObj.id, 'tgt'); + }); + + test('a second redirect is not followed, and is a failed load', () async { + client.routes['/cases/src/'] = (200, {'redirect_to': 'tgt'}); + client.routes['/cases/tgt/'] = (200, {'redirect_to': 'other'}); + client.routes['/cases/other/'] = (200, ticket('other')); + + await expectLater( + container.read(ticketsProvider.notifier).getTicketDetail('src'), + throwsA(isA()), + ); + expect(client.paths, isNot(contains('/cases/other/'))); + }); + + test('a 500 is a failed load, not a missing ticket', () async { + client.routes['/cases/t1/'] = (500, {'detail': 'boom'}); + + await expectLater( + container.read(ticketsProvider.notifier).getTicketDetail('t1'), + throwsA( + isA().having( + (f) => f.message, + 'message', + TicketLoadFailure.fallback, + ), + ), + ); + }); + + test('offline is a failed load that says so', () async { + client.offline = true; + + await expectLater( + container.read(ticketsProvider.notifier).getTicketDetail('t1'), + throwsA( + isA().having( + (f) => f.message, + 'message', + contains('Cannot reach the server'), + ), + ), + ); + }); + + test('the edit form reads any failure as nothing to edit', () async { + client.routes['/cases/t1/'] = (500, {'detail': 'boom'}); + expect( + await container.read(ticketsProvider.notifier).getTicketById('t1'), + isNull, + ); + }); + + test('the edit form\'s fetch does not follow a merge', () async { + // It saves back to the id it was given, so following would write the + // surviving ticket's fields onto the merged one. + client.routes['/cases/src/'] = (200, {'redirect_to': 'tgt'}); + client.routes['/cases/tgt/'] = (200, ticket('tgt')); + + final result = await container + .read(ticketsProvider.notifier) + .getTicketById('src'); + + expect(result, isNull); + expect(client.paths, ['/cases/src/']); + }); + + test('a 404 is no ticket, not a crash', () async { + client.routes['/cases/hidden/'] = (404, {'detail': 'No such case.'}); + + final result = await container + .read(ticketsProvider.notifier) + .getTicketDetail('hidden'); + + expect(result, isNull); + }); + + test('an ordinary ticket is itself', () async { + client.routes['/cases/t1/'] = (200, ticket('t1')); + + final result = await container + .read(ticketsProvider.notifier) + .getTicketDetail('t1'); + + expect(result?.ticketObj.id, 't1'); + expect(client.paths, ['/cases/t1/']); + }); + }); + + void usePhone(WidgetTester tester, {double textScale = 1.0}) { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + + Widget app(String openedId, _FakeTicketsNotifier notifier) => ProviderScope( + overrides: [ + ticketsProvider.overrideWith(() => notifier), + usersProvider.overrideWithValue(const []), + tagsProvider.overrideWithValue(const []), + accountOptionsProvider.overrideWithValue(const []), + ], + child: MaterialApp.router( + theme: AppTheme.light, + routerConfig: GoRouter( + initialLocation: '/here', + routes: [ + GoRoute( + path: '/here', + builder: (_, _) => TicketDetailScreen(ticketId: openedId), + ), + GoRoute( + path: '/tickets/:id', + builder: (_, s) => + Scaffold(body: Text('opened ${s.pathParameters['id']}')), + ), + ], + ), + ), + ); + + Ticket ticketNamed(String id) => Ticket.fromJson({ + 'id': id, + 'name': 'Printer on fire', + 'status': 'New', + 'priority': 'Normal', + 'created_at': '2026-09-01T09:00:00Z', + }); + + group('TicketTimePanel', () { + for (final canLog in [true, false]) { + testWidgets('logging and the timer follow comment_permission ($canLog)', ( + tester, + ) async { + usePhone(tester, textScale: 1.3); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ticketsProvider.overrideWith(() => _FakeTicketsNotifier()), + ], + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: SingleChildScrollView( + child: TicketTimePanel(ticketId: 't1', canLogTime: canLog), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final shown = canLog ? findsOneWidget : findsNothing; + expect(find.text('Start timer'), shown); + expect(find.byTooltip('Add manual entry'), shown); + expect(find.text('TIME TRACKING'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + }); + + group('TicketDetailScreen at 390px', () { + testWidgets('a merged ticket opens the one it was merged into', ( + tester, + ) async { + usePhone(tester); + await tester.pumpWidget( + app('src', _FakeTicketsNotifier(detail: ticketNamed('tgt'))), + ); + await tester.pumpAndSettle(); + + expect(find.text('opened tgt'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final scale in [1.0, 1.3]) { + testWidgets('a failed load is not "not found", and retries, at $scale', ( + tester, + ) async { + usePhone(tester, textScale: scale); + final notifier = _FakeTicketsNotifier( + failure: const TicketLoadFailure( + 'Cannot reach the server. Check your connection and try again.', + ), + ); + await tester.pumpWidget(app('t1', notifier)); + await tester.pumpAndSettle(); + + expect(find.text('Could not load this ticket'), findsOneWidget); + expect(find.textContaining('Cannot reach the server'), findsOneWidget); + expect(find.text('Ticket not found'), findsNothing); + expect(tester.takeException(), isNull); + + // Back online: Retry loads the ticket. + notifier + ..failure = null + ..detail = ticketNamed('t1'); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.text('Could not load this ticket'), findsNothing); + expect(find.text('Printer on fire'), findsWidgets); + }); + + testWidgets('a hidden or missing ticket is a not-found state at $scale', ( + tester, + ) async { + usePhone(tester, textScale: scale); + await tester.pumpWidget(app('hidden', _FakeTicketsNotifier())); + await tester.pumpAndSettle(); + + expect(find.text('Ticket not found'), findsOneWidget); + expect( + find.text('It does not exist, or you do not have access to it.'), + findsOneWidget, + ); + // Asking again gets the same 404, so no retry is offered. + expect(find.text('Retry'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + Future openActions(WidgetTester tester) async { + await tester.tap(find.byIcon(LucideIcons.moreVertical)); + await tester.pumpAndSettle(); + } + + testWidgets('link to a parent is offered to someone who may change it', ( + tester, + ) async { + usePhone(tester, textScale: 1.3); + await tester.pumpWidget( + app( + 't1', + _FakeTicketsNotifier( + detail: ticketNamed('t1'), + commentPermission: true, + ), + ), + ); + await tester.pumpAndSettle(); + await openActions(tester); + + expect(find.text('Link to parent ticket'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final canWrite in [true, false]) { + testWidgets( + 'close with children follows comment_permission ($canWrite)', + (tester) async { + usePhone(tester, textScale: 1.3); + final parent = Ticket.fromJson({ + 'id': 't1', + 'name': 'Outage', + 'status': 'New', + 'priority': 'Normal', + 'created_at': '2026-09-01T09:00:00Z', + 'child_count': 2, + }); + await tester.pumpWidget( + app( + 't1', + _FakeTicketsNotifier(detail: parent, commentPermission: canWrite), + ), + ); + await tester.pumpAndSettle(); + await openActions(tester); + + expect( + find.text('Close with children'), + canWrite ? findsOneWidget : findsNothing, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final canWrite in [true, false]) { + testWidgets('every change to the ticket follows comment_permission ' + '($canWrite)', (tester) async { + usePhone(tester, textScale: 1.3); + // The approval panel fetches on its own; answer it with nothing. + final client = _RoutingClient() + ..routes['/cases/approvals/'] = (200, {'approvals': []}); + ApiService().setClientForTesting(client); + addTearDown(() => ApiService().setClientForTesting(http.Client())); + + await tester.pumpWidget( + app( + 't1', + _FakeTicketsNotifier( + detail: ticketNamed('t1'), + commentPermission: canWrite, + ), + ), + ); + await tester.pumpAndSettle(); + final shown = canWrite ? findsOneWidget : findsNothing; + + expect(find.byTooltip('Edit'), shown); + + // Overview panels: linking an article, requesting an approval. + final scrollable = find + .descendant( + of: find.byType(TabBarView), + matching: find.byType(Scrollable), + ) + .first; + await tester.scrollUntilVisible( + find.text('SOLUTIONS'), + 200, + scrollable: scrollable, + ); + expect(find.byTooltip('Link a solution'), shown); + await tester.scrollUntilVisible( + find.text('No approval requested yet.'), + 200, + scrollable: scrollable, + ); + expect(find.text('Request approval'), shown); + + // The action sheet. + await openActions(tester); + for (final label in [ + 'Reassign', + 'Change status', + 'Change priority', + 'Close ticket', + 'Link to parent ticket', + ]) { + expect(find.text(label), shown, reason: label); + } + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + // Files: attaching one is a change to the ticket. + await tester.tap(find.text('Files')); + await tester.pumpAndSettle(); + expect(find.text('Attach a file'), shown); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('link to a parent is not offered to a reader', (tester) async { + usePhone(tester); + await tester.pumpWidget( + app( + 't1', + _FakeTicketsNotifier( + detail: ticketNamed('t1'), + commentPermission: false, + ), + ), + ); + await tester.pumpAndSettle(); + await openActions(tester); + + expect(find.text('Link to parent ticket'), findsNothing); + expect(find.text('Change / detach parent'), findsNothing); + }); + }); +} + +/// Answers each path with a canned status and JSON body, and records the +/// paths asked for. +class _RoutingClient extends http.BaseClient { + final Map)> routes = {}; + final List paths = []; + + /// Throw as a dropped connection does. + bool offline = false; + + @override + Future send(http.BaseRequest request) async { + if (offline) throw const SocketException('Network is unreachable'); + final path = request.url.path.replaceFirst(RegExp(r'^/api'), ''); + paths.add(path); + final (status, body) = + routes[path] ?? (200, {'cases': [], 'cases_count': 0}); + return http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode(body))), + status, + request: request, + ); + } +} + +class _FakeTicketsNotifier extends TicketsNotifier { + _FakeTicketsNotifier({ + this.detail, + this.commentPermission = true, + this.failure, + }); + + /// Null stands for a 404. + Ticket? detail; + final bool commentPermission; + + /// Thrown instead, for a load that failed some other way. + TicketLoadFailure? failure; + + @override + Future build() async => + TicketsListData(tickets: const [], totalCount: 0, hasMore: false); + + @override + Future getTicketDetail(String id) async { + if (failure != null) throw failure!; + if (detail == null) return null; + return TicketDetailResult( + ticketObj: detail!, + activities: const [], + commentPermission: commentPermission, + internalCommentIds: const {}, + ); + } + + @override + Future getWatchers(String id) async => const TicketWatchers( + watchers: [], + count: 0, + isCurrentUserWatching: false, + ); + + @override + Future fetchTree(String id) async => null; + + @override + Future> fetchTimeEntries(String id) async => const []; + + @override + Future fetchTimeSummary(String id) async => null; +} diff --git a/mobile/test/unit/analytics_provider_sections_test.dart b/mobile/test/unit/analytics_provider_sections_test.dart new file mode 100644 index 00000000..e10df0c6 --- /dev/null +++ b/mobile/test/unit/analytics_provider_sections_test.dart @@ -0,0 +1,203 @@ +import 'dart:convert'; + +import 'package:bottle_crm/providers/analytics_provider.dart'; +import 'package:bottle_crm/screens/tickets/ticket_analytics_screen.dart'; +import 'package:bottle_crm/services/api_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +/// One failing analytics call used to blank the whole dashboard: the provider +/// kept a single `error`, and the screen showed only that. Each section now +/// loads, fails and retries on its own. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AnalyticsNotifier', () { + late _AnalyticsClient client; + late ProviderContainer container; + + setUp(() { + client = _AnalyticsClient(); + ApiService().setClientForTesting(client); + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + ApiService().setClientForTesting(http.Client()); + }); + + Future load() async { + // Let the load `build()` schedules start first; the one below then + // supersedes it, which is also the path a filter change takes. + container.read(analyticsProvider); + await Future.delayed(Duration.zero); + await container + .read(analyticsProvider.notifier) + .setQuery(const AnalyticsQuery()); + return container.read(analyticsProvider); + } + + test('one failing call is that section only', () async { + client.failing.add('/cases/analytics/frt/'); + + final data = await load(); + + expect(data.isLoading, isFalse); + expect(data.errors.keys, [AnalyticsSection.frt]); + expect(data.errors[AnalyticsSection.frt], 'Server exploded'); + expect(data.frt, isNull); + // Everything else still arrived. + expect(data.nrt?['count'], 7); + expect(data.mttr?['count'], 7); + expect(data.backlog?['series'], isNotEmpty); + expect(data.sla?['frt_breach_rate'], 0.5); + expect(data.csat?['count'], 7); + expect(data.agents.single['email'], 'a@example.com'); + }); + + test('retry fetches that section alone and clears its error', () async { + client.failing.add('/cases/csat/aggregate/'); + await load(); + expect(container.read(analyticsProvider).errors.keys, [ + AnalyticsSection.csat, + ]); + + client.failing.clear(); + client.paths.clear(); + await container + .read(analyticsProvider.notifier) + .retry(AnalyticsSection.csat); + + final data = container.read(analyticsProvider); + expect(client.paths, ['/cases/csat/aggregate/']); + expect(data.errors, isEmpty); + expect(data.csat?['count'], 7); + expect(data.frt?['count'], 7); + }); + + test('a failing agents call keeps no stale rows', () async { + client.failing.add('/cases/analytics/agents/'); + + final data = await load(); + + expect(data.agents, isEmpty); + expect(data.errors.keys, [AnalyticsSection.agents]); + }); + + test('everything answering is no error at all', () async { + final data = await load(); + expect(data.errors, isEmpty); + }); + }); + + group('TicketAnalyticsScreen at 390px', () { + void usePhone(WidgetTester tester, {double textScale = 1.0}) { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + + for (final scale in [1.0, 1.3]) { + testWidgets('a failed section says so and the rest render, at $scale', ( + tester, + ) async { + usePhone(tester, textScale: scale); + final fake = _FakeAnalytics( + const AnalyticsDashboard( + nrt: { + 'median_hours': 2.5, + 'p90_hours': 6.0, + 'count': 14, + 'breach_count': 3, + }, + errors: {AnalyticsSection.frt: 'First response did not load.'}, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [analyticsProvider.overrideWith(() => fake)], + child: const MaterialApp(home: TicketAnalyticsScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('First response did not load.'), findsOneWidget); + // The neighbouring tile still shows its figures. + expect(find.text('2.5h'), findsOneWidget); + expect(find.text('p90 6.0h · 14 answered'), findsOneWidget); + // The failed tile does not pretend to zero breaches. + expect(find.text('0 breached'), findsNothing); + + final retry = find.text('Retry'); + expect(retry, findsOneWidget); + expect(tester.getSize(find.byType(TextButton)).height, greaterThan(43)); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(fake.retried, [AnalyticsSection.frt]); + }); + } + }); +} + +/// Every analytics endpoint answers 200 with a small body, except the paths in +/// [failing], which answer 500. +class _AnalyticsClient extends http.BaseClient { + final Set failing = {}; + final List paths = []; + + static const _bodies = >{ + '/cases/analytics/frt/': {'count': 7, 'median_hours': 1.0}, + '/cases/analytics/nrt/': {'count': 7, 'median_hours': 1.0}, + '/cases/analytics/mttr/': {'count': 7, 'median_hours': 1.0}, + '/cases/analytics/backlog/': { + 'series': [ + {'open_count': 3, 'urgent_count': 1}, + ], + }, + '/cases/analytics/sla/': {'frt_breach_rate': 0.5}, + '/cases/csat/aggregate/': {'count': 7, 'average': 4.0}, + '/cases/analytics/agents/': { + 'results': [ + {'email': 'a@example.com', 'handled': 2}, + ], + }, + }; + + @override + Future send(http.BaseRequest request) async { + final path = request.url.path.replaceFirst(RegExp(r'^/api'), ''); + paths.add(path); + final fail = failing.contains(path); + final body = fail + ? const {'detail': 'Server exploded'} + : (_bodies[path] ?? const {}); + return http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode(body))), + fail ? 500 : 200, + request: request, + ); + } +} + +class _FakeAnalytics extends AnalyticsNotifier { + _FakeAnalytics(this._data); + + final AnalyticsDashboard _data; + final List retried = []; + + @override + AnalyticsDashboard build() => _data; + + @override + Future setQuery(AnalyticsQuery query) async {} + + @override + Future retry(AnalyticsSection section) async => retried.add(section); +} diff --git a/mobile/test/unit/csv_import_provider_test.dart b/mobile/test/unit/csv_import_provider_test.dart index 208d420c..80067a2f 100644 --- a/mobile/test/unit/csv_import_provider_test.dart +++ b/mobile/test/unit/csv_import_provider_test.dart @@ -354,4 +354,95 @@ void main() { } }); }); + group('errors file and valid sample (web drawer parity)', () { + test('errors are row,field,message, quoted and formula-safe', () { + final csv = csvImportErrorsCsv(const [ + CsvRowError(row: 2, field: 'email', message: 'Not an email'), + CsvRowError(row: 3, field: 'name', message: 'Say "hi", then\nleave'), + CsvRowError(row: 4, field: 'phone', message: '=HYPERLINK("x")'), + CsvRowError(row: 5, field: 'city', message: ' -1'), + ]); + expect( + csv, + 'row,field,message\n' + '2,email,Not an email\n' + '3,name,"Say ""hi"", then\nleave"\n' + '4,phone,"\'=HYPERLINK(""x"")"\n' + "5,city,' -1", + ); + }); + + test('file names match the web drawers', () { + expect( + CsvImportTarget.contacts.errorsFileName, + 'contacts-import-errors.csv', + ); + expect( + CsvImportTarget.tickets.errorsFileName, + 'tickets-import-errors.csv', + ); + expect(CsvImportTarget.leads.errorsFileName, 'leads-import-errors.csv'); + }); + + test('each module samples the columns its web table shows', () { + expect( + CsvImportTarget.leads.sampleLine({ + 'first_name': 'Ada', + 'last_name': 'Lovelace', + 'email': 'ada@x.test', + 'company_name': 'Engines', + 'status': 'assigned', + }), + ( + title: 'Ada Lovelace', + details: 'ada@x.test \u00b7 Engines \u00b7 assigned', + ), + ); + expect( + CsvImportTarget.contacts.sampleLine({ + 'first_name': 'Alan', + 'last_name': '', + 'email': '', + 'phone': '+1 555', + 'organization': 'NPL', + }), + (title: 'Alan', details: '+1 555 \u00b7 NPL'), + ); + expect( + CsvImportTarget.tickets.sampleLine({ + 'name': 'Login broken', + 'status': 'New', + 'priority': 'High', + 'account_id': 'a1', + }), + ( + title: 'Login broken', + details: 'New \u00b7 High \u00b7 account linked', + ), + ); + expect( + CsvImportTarget.tickets.sampleLine({ + 'name': 'Other', + 'status': 'New', + 'priority': 'Low', + 'account_id': null, + }).details, + 'New \u00b7 Low', + ); + }); + + test('the preview keeps the valid rows it was sent', () { + final preview = CsvImportPreview.fromJson({ + 'valid': [ + {'row': 1, 'name': 'A'}, + 'junk', + ], + 'errors': [], + 'summary': {'total': 1, 'valid': 1, 'invalid': 0}, + }); + expect(preview.validRows, [ + {'row': 1, 'name': 'A'}, + ]); + }); + }); } diff --git a/mobile/test/unit/detail_not_found_test.dart b/mobile/test/unit/detail_not_found_test.dart new file mode 100644 index 00000000..f34c2f53 --- /dev/null +++ b/mobile/test/unit/detail_not_found_test.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bottle_crm/core/theme/theme.dart'; +import 'package:bottle_crm/providers/accounts_provider.dart'; +import 'package:bottle_crm/providers/contacts_provider.dart'; +import 'package:bottle_crm/providers/deals_provider.dart'; +import 'package:bottle_crm/providers/leads_provider.dart'; +import 'package:bottle_crm/screens/accounts/account_detail_screen.dart'; +import 'package:bottle_crm/screens/contacts/contact_detail_screen.dart'; +import 'package:bottle_crm/screens/deals/deal_detail_screen.dart'; +import 'package:bottle_crm/screens/leads/lead_detail_screen.dart'; +import 'package:bottle_crm/services/api_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +/// "Not found" only when the server said 404. +/// +/// The API answers a record this user may not open exactly as one that does +/// not exist, so a 404 reads "not found, or no access". Being offline or a +/// server error says nothing about the record, so it reads "could not load", +/// with Retry, never "not found". +class _Client extends http.BaseClient { + /// An HTTP status to answer every request with, or null to fail offline. + int? status; + + @override + Future send(http.BaseRequest request) async { + await request.finalize().toBytes(); + if (status == null) throw const SocketException('offline'); + return http.StreamedResponse( + Stream.value(utf8.encode('{"detail": "x"}')), + status!, + request: request, + ); + } +} + +typedef _Fetch = + Future Function(ProviderContainer c, void Function() notFound); + +final Map _fetchers = { + 'lead': (c, nf) => + c.read(leadsProvider.notifier).getLeadDetail('x', onNotFound: nf), + 'deal': (c, nf) => + c.read(dealsProvider.notifier).getDealDetail('x', onNotFound: nf), + 'account': (c, nf) => + c.read(accountsProvider.notifier).getAccount('x', onNotFound: nf), + 'contact': (c, nf) => + c.read(contactsProvider.notifier).getContact('x', onNotFound: nf), +}; + +final Map _screens = { + 'lead': const LeadDetailScreen(leadId: 'x'), + 'deal': const DealDetailScreen(dealId: 'x'), + 'account': const AccountDetailScreen(accountId: 'x'), + 'contact': const ContactDetailScreen(contactId: 'x'), +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _Client client; + + setUp(() { + client = _Client(); + ApiService().setClientForTesting(client); + }); + + // (label, status): null is offline, which the service reports as status 0. + const outcomes = <(String, int?, bool)>[ + ('404', 404, true), + ('500', 500, false), + ('offline', null, false), + ]; + + for (final entry in _fetchers.entries) { + for (final (label, status, isNotFound) in outcomes) { + test( + '${entry.key}: $label ${isNotFound ? 'counts' : 'does not count'} as not found', + () async { + client.status = status; + final container = ProviderContainer(); + addTearDown(container.dispose); + var fired = false; + + final result = await entry.value(container, () => fired = true); + + expect(result, isNull); + expect(fired, isNotFound); + }, + ); + } + } + + for (final entry in _screens.entries) { + for (final (label, status, isNotFound) in outcomes) { + testWidgets('${entry.key} screen: $label', (tester) async { + client.status = status; + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = 1.3; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp(theme: AppTheme.light, home: entry.value), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final access = find.textContaining('have access to'); + final couldNot = find.textContaining('Could not load'); + expect(access, isNotFound ? findsWidgets : findsNothing); + expect(couldNot, isNotFound ? findsNothing : findsWidgets); + expect(find.text('Retry'), findsOneWidget); + }); + } + } +} diff --git a/mobile/test/unit/is_org_admin_test.dart b/mobile/test/unit/is_org_admin_test.dart new file mode 100644 index 00000000..5e1bd1ef --- /dev/null +++ b/mobile/test/unit/is_org_admin_test.dart @@ -0,0 +1,130 @@ +import 'package:bottle_crm/data/models/auth_response.dart'; +import 'package:bottle_crm/providers/auth_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `isOrgAdminProvider` is the app's one admin rule, and it reads the +/// server's `is_organization_admin` fact, never `role`. +/// +/// The API computes that fact with `is_org_admin`: the ADMIN role, or a +/// Django superuser's membership. Gating on `role == 'ADMIN'` showed a +/// USER-role superuser read-only settings screens the API would have let +/// them change. +bool adminFor(Organization? org) { + final container = ProviderContainer( + overrides: [selectedOrgProvider.overrideWithValue(org)], + ); + addTearDown(container.dispose); + return container.read(isOrgAdminProvider); +} + +Organization parse(Map extra) => + Organization.fromJson({'id': 'org-1', 'name': 'Acme', ...extra}); + +void main() { + group('isOrgAdminProvider', () { + test('an ADMIN is an admin', () { + expect( + adminFor(parse({'role': 'ADMIN', 'is_organization_admin': true})), + isTrue, + ); + }); + + test('a USER-role superuser is an admin, because the fact says so', () { + expect( + adminFor(parse({'role': 'USER', 'is_organization_admin': true})), + isTrue, + ); + }); + + test('a plain member is not', () { + expect( + adminFor(parse({'role': 'USER', 'is_organization_admin': false})), + isFalse, + ); + }); + + test('the fact wins over the role when both are present', () { + expect( + adminFor(parse({'role': 'ADMIN', 'is_organization_admin': false})), + isFalse, + ); + }); + + test('no selected org is not an admin', () { + expect(adminFor(null), isFalse); + }); + }); + + group('Organization.fromJson', () { + test('only a literal true counts', () { + for (final value in ['true', 1, null, 'yes']) { + expect( + parse({ + 'role': 'ADMIN', + 'is_organization_admin': value, + }).isOrganizationAdmin, + isFalse, + reason: 'is_organization_admin: $value', + ); + } + }); + + test('an org cached before 1.11.0 (no fact) falls back to its role', () { + expect(parse({'role': 'ADMIN'}).isOrganizationAdmin, isTrue); + expect(parse({'role': 'USER'}).isOrganizationAdmin, isFalse); + expect(parse({}).isOrganizationAdmin, isFalse); + }); + + test('the fact survives the storage round trip', () { + final org = parse({'role': 'USER', 'is_organization_admin': true}); + expect(Organization.fromJson(org.toJson()).isOrganizationAdmin, isTrue); + }); + }); + + group('Organization.withMembership (the switch response refreshes it)', () { + final stale = parse({ + 'role': 'USER', + 'is_organization_admin': false, + 'default_currency': 'EUR', + }); + + test('a promotion since sign-in reaches the admin gate', () { + final fresh = stale.withMembership({ + 'id': 'p-1', + 'role': 'ADMIN', + 'is_organization_admin': true, + }); + expect(fresh.isOrganizationAdmin, isTrue); + expect(fresh.role, 'ADMIN'); + expect(adminFor(fresh), isTrue); + expect(fresh.defaultCurrency, 'EUR', reason: 'org facts are kept'); + }); + + test('a demotion since sign-in removes it', () { + final admin = parse({'role': 'ADMIN', 'is_organization_admin': true}); + final fresh = admin.withMembership({ + 'role': 'USER', + 'is_organization_admin': false, + }); + expect(adminFor(fresh), isFalse); + }); + + test('an org built from the create response is admin at once', () { + final created = parse({'role': 'ADMIN', 'is_organization_admin': true}); + expect(adminFor(created), isTrue); + }); + + test('an absent or malformed profile keeps what was cached', () { + final admin = parse({'role': 'ADMIN', 'is_organization_admin': true}); + expect(admin.withMembership(null).isOrganizationAdmin, isTrue); + expect(admin.withMembership('x').isOrganizationAdmin, isTrue); + expect( + admin.withMembership({ + 'is_organization_admin': 'false', + }).isOrganizationAdmin, + isTrue, + ); + }); + }); +} diff --git a/mobile/test/unit/saved_views_provider_test.dart b/mobile/test/unit/saved_views_provider_test.dart new file mode 100644 index 00000000..e13f9a0c --- /dev/null +++ b/mobile/test/unit/saved_views_provider_test.dart @@ -0,0 +1,369 @@ +import 'dart:convert'; + +import 'package:bottle_crm/data/models/lead.dart'; +import 'package:bottle_crm/providers/deals_provider.dart'; +import 'package:bottle_crm/providers/leads_provider.dart'; +import 'package:bottle_crm/providers/saved_views_provider.dart'; +import 'package:bottle_crm/providers/tickets_provider.dart'; +import 'package:bottle_crm/services/api_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +/// Answers each request with the next queued reply and keeps what was sent. +class _QueueClient extends http.BaseClient { + final List<(int, String)> replies = []; + final List sent = []; + final List bodies = []; + + @override + Future send(http.BaseRequest request) async { + sent.add(request); + bodies.add(utf8.decode(await request.finalize().toBytes())); + final (status, body) = replies.removeAt(0); + return http.StreamedResponse( + Stream.value(utf8.encode(body)), + status, + request: request, + ); + } +} + +const _viewId = '99999999-8888-7777-6666-555555555555'; + +String _list(List> views, {int limit = 25}) => + jsonEncode({'saved_views': views, 'limit': limit}); + +final _hot = { + 'id': _viewId, + 'module': 'leads', + 'name': 'Hot', + 'filters': { + 'rating': ['HOT'], + 'status': ['assigned', 'in process'], + }, +}; + +void main() { + late _QueueClient client; + late ProviderContainer container; + + setUp(() { + client = _QueueClient(); + ApiService().setClientForTesting(client); + container = ProviderContainer(); + }); + + tearDown(() => container.dispose()); + + /// The provider is autoDispose, so a listener stands in for the open sheet. + Future open([ + SavedViewList list = SavedViewList.leads, + ]) async { + container.listen(savedViewsProvider(list), (_, _) {}); + await container.read(savedViewsProvider(list).future); + return container.read(savedViewsProvider(list).notifier); + } + + SavedViewsState stateOf([SavedViewList list = SavedViewList.leads]) => + container.read(savedViewsProvider(list)).value!; + + group('the API', () { + test("reads one list's views, asking by the API's module name", () async { + client.replies.add((200, _list([_hot]))); + await open(SavedViewList.tickets); + + final url = client.sent.single.url; + expect(url.path, endsWith('/api/saved-views/')); + expect(url.queryParameters['module'], 'cases'); + final view = stateOf(SavedViewList.tickets).views.single; + expect(view.name, 'Hot'); + expect(view.filters['status'], ['assigned', 'in process']); + }); + + test( + 'saves module, name and filters, never an owner, then rereads', + () async { + client.replies + ..add((200, _list([]))) + ..add((201, jsonEncode(_hot))) + ..add((200, _list([_hot]))); + final notifier = await open(); + + final error = await notifier.save(' Hot ', { + 'rating': ['HOT'], + }); + + expect(error, isNull); + expect(client.sent[1].method, 'POST'); + expect(jsonDecode(client.bodies[1]), { + 'module': 'leads', + 'name': 'Hot', + 'filters': { + 'rating': ['HOT'], + }, + }); + expect(stateOf().views.single.id, _viewId); + }, + ); + + test('renames and deletes by id', () async { + client.replies + ..add((200, _list([_hot]))) + ..add((200, jsonEncode(_hot))) + ..add((200, _list([_hot]))) + ..add((204, '')) + ..add((200, _list([]))); + final notifier = await open(); + + expect(await notifier.rename(_viewId, 'Warm'), isNull); + expect(client.sent[1].method, 'PATCH'); + expect(client.sent[1].url.path, endsWith('/api/saved-views/$_viewId/')); + expect(jsonDecode(client.bodies[1]), {'name': 'Warm'}); + + expect(await notifier.remove(_viewId), isNull); + expect(client.sent[3].method, 'DELETE'); + expect(stateOf().views, isEmpty); + }); + + test("a refusal reads as the API's own sentence", () async { + client.replies + ..add((200, _list([_hot]))) + ..add(( + 400, + '{"name": ["You already have a view with this name for this list."]}', + )); + final notifier = await open(); + + expect( + await notifier.save('hot', const {}), + 'You already have a view with this name for this list.', + ); + }); + + test( + "another person's id (a 404) is gone, and the list is reread", + () async { + client.replies + ..add((200, _list([_hot]))) + ..add((404, '{"detail": "No such saved view."}')) + ..add((200, _list([]))); + final notifier = await open(); + + expect(await notifier.remove(_viewId), 'That view no longer exists.'); + expect(stateOf().views, isEmpty); + }, + ); + + test('the cap comes from the API', () async { + client.replies.add((200, _list([_hot], limit: 1))); + await open(); + expect(stateOf().full, isTrue); + }); + }); + + group('shapes', () { + test('savedViewFilters keeps only the keys, as text, without blanks', () { + expect( + savedViewFilters( + { + 'search': ' ada ', + 'status': ['New', '', 'Pending'], + 'sort': 'due_date', + 'tags': [], + 'rating': null, + }, + {'search', 'status', 'tags', 'rating'}, + multi: {'status', 'tags'}, + ), + { + 'search': ['ada'], + 'status': ['New', 'Pending'], + }, + ); + }); + + test('a view counts the values a screen cannot apply, not the keys', () { + final view = SavedView.fromJson(_hot); + // rating is not on the screen (1); status is read singly (2 kept as 1). + expect(view.dropped({'status'}, const {}), 2); + expect(view.dropped({'status'}, {'status'}), 1); + expect(view.dropped({'status', 'rating'}, {'status'}), 0); + }); + + test('savedViewFilters keeps one value of a key read singly', () { + expect( + savedViewFilters( + { + 'status': ['New', 'Pending'], + 'priority': ['High', 'Low'], + }, + {'status', 'priority'}, + multi: {'status'}, + ), + { + 'status': ['New', 'Pending'], + 'priority': ['High'], + }, + ); + }); + + test('firstSentence reads a DRF body without its field name', () { + expect( + firstSentence({ + 'filters': ["assigned_to: 'x' is not a valid id."], + }), + "assigned_to: 'x' is not a valid id.", + ); + expect(firstSentence({}), isNull); + expect(firstSentence(null), isNull); + }); + }); + + group('a view round-trips through each list', () { + test('leads', () async { + final leads = ProviderContainer( + overrides: [leadsProvider.overrideWith(_Leads.new)], + ); + addTearDown(leads.dispose); + final notifier = leads.read(leadsProvider.notifier); + const filters = LeadFilters( + search: 'ada', + statuses: {LeadStatus.assigned, LeadStatus.inProcess}, + source: LeadSource.email, + rating: LeadRating.hot, + assignedToId: 'u1', + tagId: 't1', + nextFollowUp: '2026-09-26', + ); + await notifier.setFilters(filters); + final back = LeadFilters.fromQuery( + savedViewFilters( + notifier.filterQuery, + { + 'search', + 'status', + 'source', + 'rating', + 'assigned_to', + 'tags', + 'next_follow_up', + }, + multi: {'status'}, + ), + assignedToLabel: 'Me', + tagLabel: 'vip', + ); + expect(back.search, 'ada'); + expect(back.statuses, filters.statuses); + expect(back.source, LeadSource.email); + expect(back.rating, LeadRating.hot); + expect(back.assignedToId, 'u1'); + expect(back.assignedToLabel, 'Me'); + expect(back.tagId, 't1'); + expect(back.tagLabel, 'vip'); + expect(back.nextFollowUp, '2026-09-26'); + }); + + test('leads drop a value the list does not offer', () { + final back = LeadFilters.fromQuery({ + 'status': ['nope'], + 'rating': ['LUKEWARM'], + 'open': ['true'], + }); + expect(back.isEmpty, isTrue); + }); + + test('deals read the web search and every range', () { + final back = DealFilters.fromQuery({ + 'search': ['Acme'], + 'stage': ['QUALIFIED'], + 'assigned_to': ['u1', 'u2'], + 'tags': ['t1'], + 'created_at__gte': ['2026-01-02'], + 'closed_on__lte': ['2026-03-04'], + 'amount__gte': ['10.5'], + 'amount__lte': ['lots'], + 'rotten': ['true'], + }); + expect(back.search, 'Acme'); + expect(back.stage, 'QUALIFIED'); + expect(back.assignedToIds, ['u1', 'u2']); + expect(back.tagIds, ['t1']); + expect(back.createdFrom, DateTime(2026, 1, 2)); + expect(back.closingTo, DateTime(2026, 3, 4)); + expect(back.amountMin, 10.5); + expect(back.amountMax, isNull); + expect(back.rottenOnly, isTrue); + expect( + DealFilters.fromQuery({ + 'name': ['Beta'], + }).search, + 'Beta', + ); + }); + + test('tickets: one status is single, several are the chip list', () { + final filters = TicketListFilters( + search: 'login', + statusList: const ['New', 'Assigned', 'Pending'], + priority: 'High', + accountId: 'a1', + caseType: 'Incident', + assigneeIds: const ['u1'], + tagIds: const ['t1'], + slaBreached: true, + createdAfter: DateTime.utc(2026, 1, 2), + ); + final back = TicketListFilters.fromQuery( + savedViewFilters( + ticketListQuery(filters), + { + 'search', + 'status', + 'priority', + 'account', + 'case_type', + 'assigned_to', + 'tags', + 'sla_breached', + 'created_at__gte', + 'created_at__lte', + }, + multi: {'status', 'assigned_to', 'tags'}, + ), + ); + expect(ticketListQuery(back), ticketListQuery(filters)); + expect( + TicketListFilters.fromQuery({ + 'status': ['Closed'], + }).status, + 'Closed', + ); + expect(back.watchingOnly, isFalse); + }); + + test("tickets: the web's open-queue and All views mean the same here", () { + // The web saves its default queue as the three open statuses... + final open = TicketListFilters.fromQuery({ + 'status': ['New', 'Assigned', 'Pending'], + }); + expect(open.statusList, ['New', 'Assigned', 'Pending']); + expect(ticketListQuery(open)['status'], ['New', 'Assigned', 'Pending']); + // ...and its All tab as no status, which is every status here too. + final all = TicketListFilters.fromQuery({ + 'priority': ['High'], + }); + expect(ticketListQuery(all).containsKey('status'), isFalse); + }); + }); +} + +/// The real notifier, minus the network: `filterQuery` is what is under test. +class _Leads extends LeadsNotifier { + @override + Future build() async => const LeadsListData(hasMore: false); + + @override + Future refresh() async {} +} diff --git a/mobile/test/unit/ticket_tree_close_test.dart b/mobile/test/unit/ticket_tree_close_test.dart index d9cb6dc7..dc703a77 100644 --- a/mobile/test/unit/ticket_tree_close_test.dart +++ b/mobile/test/unit/ticket_tree_close_test.dart @@ -87,17 +87,36 @@ void main() { }); test('leaves an inactive ticket out, matching the backend', () { - // An inactive row is a merged duplicate; `_open_descendants` skips it. - final merged = TicketTreeNode.fromJson( + // An inactive row was deleted; `_open_descendants` skips it. + final deleted = TicketTreeNode.fromJson( treeJson( id: 'p', children: [ - treeJson(id: 'merged', isActive: false), + treeJson(id: 'deleted', isActive: false), treeJson(id: 'real'), ], ), ); - expect(merged.openDescendantsOf('p').map((n) => n.id), ['real']); + expect(deleted.openDescendantsOf('p').map((n) => n.id), ['real']); + }); + + test('leaves a merged (Duplicate) ticket out, but walks under it', () { + // A merged ticket's status changes only by unmerging it, so the cascade + // passes over it, as `_open_descendants` does. + final merged = TicketTreeNode.fromJson( + treeJson( + id: 'p', + children: [ + treeJson( + id: 'merged', + status: 'Duplicate', + children: [treeJson(id: 'under')], + ), + treeJson(id: 'real'), + ], + ), + ); + expect(merged.openDescendantsOf('p').map((n) => n.id), ['under', 'real']); }); test('lists a ticket the viewer cannot open, without a name', () { diff --git a/mobile/test/widgets/csv_import_sheet_test.dart b/mobile/test/widgets/csv_import_sheet_test.dart index 0106067b..1484270a 100644 --- a/mobile/test/widgets/csv_import_sheet_test.dart +++ b/mobile/test/widgets/csv_import_sheet_test.dart @@ -30,7 +30,15 @@ class _QueueClient extends http.BaseClient { String _previewBody({int valid = 2, int errorCount = 0}) => jsonEncode({ 'header_error': null, 'valid': [ - for (var i = 1; i <= valid; i++) {'row': i}, + for (var i = 1; i <= valid; i++) + { + 'row': i, + 'first_name': 'Person', + 'last_name': '$i', + 'email': 'person$i@acme.test', + 'phone': '', + 'organization': 'Acme Corp', + }, ], 'errors': [ for (var i = 0; i < errorCount; i++) @@ -73,6 +81,7 @@ void main() { WidgetTester tester, { CsvImportTarget target = CsvImportTarget.contacts, double textScale = 1.0, + Future Function(String fileName, List bytes)? saveFile, }) async { usePhone(tester, textScale: textScale); final bytes = Uint8List.fromList(utf8.encode('first_name,last_name\n')); @@ -92,6 +101,7 @@ void main() { size: bytes.length, bytes: bytes, ), + saveFile: saveFile, ), child: const Text('open'), ), @@ -158,8 +168,96 @@ void main() { expect(find.text('Back'), findsOneWidget); expect(tester.getBottomLeft(find.text('Back')).dy, lessThan(844)); }); + + testWidgets('the valid rows are sampled, as on the web, at ${scale}x', ( + tester, + ) async { + client.replies.add((200, _previewBody(valid: 25, errorCount: 1))); + await open(tester, textScale: scale); + + await tester.tap(find.text('Choose CSV file')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Person 1'), findsWidgets); + expect( + find.textContaining('person1@acme.test \u00b7 Acme Corp'), + findsOneWidget, + ); + expect(find.text('Showing first 20 of 25 valid rows'), findsOneWidget); + // The save button is a full-height target. + expect( + tester + .getSize( + find.ancestor( + of: find.text('Save errors as CSV'), + matching: find.byWidgetPredicate((w) => w is TextButton), + ), + ) + .height, + greaterThanOrEqualTo(44), + ); + }); } + testWidgets('the row errors save as the web drawer\'s errors file', ( + tester, + ) async { + final saved = <(String, String)>[]; + client.replies.add((200, _previewBody(valid: 1, errorCount: 2))); + await open( + tester, + target: CsvImportTarget.leads, + saveFile: (name, bytes) async { + saved.add((name, utf8.decode(bytes))); + return '/tmp/$name'; + }, + ); + + await tester.tap(find.text('Choose CSV file')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Save errors as CSV')); + await tester.tap(find.text('Save errors as CSV')); + await tester.pumpAndSettle(); + + expect(saved, hasLength(1)); + expect(saved.single.$1, 'leads-import-errors.csv'); + final lines = saved.single.$2.split('\n'); + expect(lines.first, 'row,field,message'); + expect(lines, hasLength(3)); + // The message holds a comma, so it is one quoted cell. + expect(lines[1], startsWith('3,email,"Duplicate email also used by row 1')); + expect(lines[1], endsWith('changed"')); + expect(find.text('Saved leads-import-errors.csv.'), findsOneWidget); + }); + + testWidgets('a closed save dialog says nothing, a failed one says so', ( + tester, + ) async { + var fail = false; + client.replies.add((200, _previewBody(valid: 1, errorCount: 1))); + await open( + tester, + saveFile: (name, bytes) async { + if (fail) throw Exception('disk full'); + return null; + }, + ); + await tester.tap(find.text('Choose CSV file')); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Save errors as CSV')); + await tester.tap(find.text('Save errors as CSV')); + await tester.pumpAndSettle(); + expect(find.textContaining('Saved '), findsNothing); + expect(find.text('Could not save the errors.'), findsNothing); + + fail = true; + await tester.tap(find.text('Save errors as CSV')); + await tester.pumpAndSettle(); + expect(find.text('Could not save the errors.'), findsOneWidget); + }); + testWidgets('a clean file imports, closes, refreshes and reports the count', ( tester, ) async { diff --git a/mobile/test/widgets/deal_next_step_test.dart b/mobile/test/widgets/deal_next_step_test.dart new file mode 100644 index 00000000..a0f5ad76 --- /dev/null +++ b/mobile/test/widgets/deal_next_step_test.dart @@ -0,0 +1,162 @@ +import 'package:bottle_crm/core/theme/theme.dart'; +import 'package:bottle_crm/data/models/deal.dart'; +import 'package:bottle_crm/providers/lookup_provider.dart'; +import 'package:bottle_crm/widgets/cards/deal_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// G31: a deal card shows the deal's next open task, or flags that it has +/// none. The server counts only tasks this user can open; the card renders +/// what it is sent, and nothing at all when the response never computed it +/// (the deal detail), so a deal is never flagged on a guess. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Deal deal( + String id, { + Object? next = const _Absent(), + String kind = 'open', + }) => Deal.fromJson({ + 'id': id, + 'name': 'Multi-site renewal for the regional hospital group, phase two', + 'stage': kind == 'open' ? 'DEMO_BOOKED' : 'SIGNED', + 'stage_label': 'Demo booked', + 'stage_kind': kind, + 'amount': '125000', + 'aging_status': 'green', + 'account': {'id': 'a1', 'name': 'Northern Regional Hospitals Trust'}, + if (next is! _Absent) 'next_activity': next, + }); + + final longTask = { + 'id': 't1', + 'title': + 'Send the revised proposal with the three-year pricing option and ' + 'the procurement pack the trust asked for', + 'due_date': '2099-10-01', + }; + + Future pump(WidgetTester tester, double textScale) async { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget( + ProviderScope( + overrides: [usersProvider.overrideWithValue(const [])], + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + DealCard(deal: deal('scheduled', next: longTask)), + DealCard(deal: deal('none', next: null)), + DealCard( + deal: deal( + 'late', + next: { + 'id': 't2', + 'title': 'Chase', + 'due_date': '2000-01-01', + }, + ), + ), + DealCard( + deal: deal( + 'undated', + next: {'id': 't3', 'title': 'Call', 'due_date': null}, + ), + ), + DealCard(deal: deal('unknown')), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + for (final scale in [1.0, 1.3]) { + testWidgets('fits 390px at $scale with a long task title', (tester) async { + await pump(tester, scale); + + expect(tester.takeException(), isNull); + expect(find.byType(DealNextStep), findsNWidgets(4)); + expect(find.textContaining('Send the revised proposal'), findsOneWidget); + expect(find.text('No next step'), findsOneWidget); + expect(find.text('Call · no date'), findsOneWidget); + }); + } + + testWidgets('no task and an overdue task read in danger, a plan does not', ( + tester, + ) async { + await pump(tester, 1.0); + + Color colorOf(Finder f) => tester.widget(f).style!.color!; + expect(colorOf(find.text('No next step')), AppColors.danger600); + expect(colorOf(find.text('Chase · 1 Jan')), AppColors.danger600); + expect( + colorOf(find.textContaining('Send the revised proposal')), + AppColors.textSecondary, + ); + }); + + for (final kind in ['won', 'lost']) { + testWidgets('a $kind deal shows no next step, flag or task', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [usersProvider.overrideWithValue(const [])], + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: ListView( + children: [ + // The stage code is not a won/lost code: the kind decides. + DealCard(deal: deal('none', next: null, kind: kind)), + DealCard( + deal: deal('task', next: longTask, kind: kind), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DealNextStep), findsNothing); + expect(find.text('No next step'), findsNothing); + }); + } + + test('parses the list payload, and knows when it was not sent', () { + final scheduled = deal('a', next: longTask); + expect(scheduled.nextActivityKnown, isTrue); + expect(scheduled.nextActivity!.id, 't1'); + expect(scheduled.nextActivity!.dueDate, DateTime(2099, 10, 1)); + + final none = deal('b', next: null); + expect(none.nextActivityKnown, isTrue); + expect(none.nextActivity, isNull); + + final unknown = deal('c'); + expect(unknown.nextActivityKnown, isFalse); + + // A board move keeps it: moving a deal does not change its tasks. + final moved = scheduled.copyWith(stage: 'SIGNED'); + expect(moved.nextActivityKnown, isTrue); + expect(moved.nextActivity!.title, scheduled.nextActivity!.title); + }); +} + +class _Absent { + const _Absent(); +}