@tellescope/sdk 1.256.16 → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -0
- package/env.example +39 -0
- package/lib/cjs/enduser.d.ts +1 -0
- package/lib/cjs/enduser.d.ts.map +1 -1
- package/lib/cjs/sdk.d.ts +6 -1
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +2 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/session.d.ts +1 -0
- package/lib/cjs/session.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/active_patient_count.test.js +1 -1
- package/lib/cjs/tests/api_tests/active_patient_count.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/appointment_rescheduled_trigger.test.js +1 -1
- package/lib/cjs/tests/api_tests/appointment_rescheduled_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_limits.test.js +5 -5
- package/lib/cjs/tests/api_tests/calendar_event_limits.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_references_update.test.js +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_references_update.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +1 -1
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_fields_sync.test.d.ts +21 -0
- package/lib/cjs/tests/api_tests/enduser_fields_sync.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_fields_sync.test.js +215 -0
- package/lib/cjs/tests/api_tests/enduser_fields_sync.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js +6 -6
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.js +3 -3
- package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/load_threads_care_team.test.d.ts +32 -0
- package/lib/cjs/tests/api_tests/load_threads_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/load_threads_care_team.test.js +257 -0
- package/lib/cjs/tests/api_tests/load_threads_care_team.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/managed_content_enduser_access.test.js +1 -1
- package/lib/cjs/tests/api_tests/managed_content_enduser_access.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/managed_content_file_access.test.js +1 -1
- package/lib/cjs/tests/api_tests/managed_content_file_access.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +139 -9
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_webhooks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_webhooks.test.js +21 -8
- package/lib/cjs/tests/api_tests/mdi_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.js +1 -1
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/programs.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/programs.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/programs.test.js +370 -0
- package/lib/cjs/tests/api_tests/programs.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/programs_trigger_filters.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/programs_trigger_filters.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/programs_trigger_filters.test.js +295 -0
- package/lib/cjs/tests/api_tests/programs_trigger_filters.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/allowed-paths-confinement.test.js +1 -1
- package/lib/cjs/tests/api_tests/security/allowed-paths-confinement.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/appointment-reschedule-ownership.test.d.ts +42 -0
- package/lib/cjs/tests/api_tests/security/appointment-reschedule-ownership.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/appointment-reschedule-ownership.test.js +484 -0
- package/lib/cjs/tests/api_tests/security/appointment-reschedule-ownership.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/booking-info-idor-token-mint.test.js +1 -1
- package/lib/cjs/tests/api_tests/security/booking-info-idor-token-mint.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-cross-create.test.js +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-cross-create.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-write-restrictions.test.js +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/invite-user-enumeration.test.js +2 -2
- package/lib/cjs/tests/api_tests/security/invite-user-enumeration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.d.ts +32 -0
- package/lib/cjs/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.js +297 -0
- package/lib/cjs/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/sanitize-user-html-with-iframes.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/sanitize-user-html-with-iframes.test.js +4 -1
- package/lib/cjs/tests/api_tests/security/sanitize-user-html-with-iframes.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/templated_message_date_fields.test.d.ts +3 -0
- package/lib/cjs/tests/api_tests/templated_message_date_fields.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/templated_message_date_fields.test.js +202 -0
- package/lib/cjs/tests/api_tests/templated_message_date_fields.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/templated_message_link_in_href.test.d.ts +3 -0
- package/lib/cjs/tests/api_tests/templated_message_link_in_href.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/templated_message_link_in_href.test.js +233 -0
- package/lib/cjs/tests/api_tests/templated_message_link_in_href.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/templated_message_links.test.d.ts +3 -0
- package/lib/cjs/tests/api_tests/templated_message_links.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/templated_message_links.test.js +235 -0
- package/lib/cjs/tests/api_tests/templated_message_links.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/time_tracks.test.js +1 -1
- package/lib/cjs/tests/api_tests/time_tracks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/trigger_contains_matching.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/trigger_contains_matching.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/trigger_contains_matching.test.js +395 -0
- package/lib/cjs/tests/api_tests/trigger_contains_matching.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/user_logs_restore_deleted.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/user_logs_restore_deleted.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/user_logs_restore_deleted.test.js +471 -0
- package/lib/cjs/tests/api_tests/user_logs_restore_deleted.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js +1 -1
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/validation_behavior_locks.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/validation_behavior_locks.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/validation_behavior_locks.test.js +233 -0
- package/lib/cjs/tests/api_tests/validation_behavior_locks.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/vital_review_trigger_action.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/vital_review_trigger_action.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/vital_review_trigger_action.test.js +569 -0
- package/lib/cjs/tests/api_tests/vital_review_trigger_action.test.js.map +1 -0
- package/lib/cjs/tests/public_endpoint_tests.js +3 -2
- package/lib/cjs/tests/public_endpoint_tests.js.map +1 -1
- package/lib/cjs/tests/setup.d.ts +16 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +55 -8
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/setup_isolated.d.ts +9 -0
- package/lib/cjs/tests/setup_isolated.d.ts.map +1 -1
- package/lib/cjs/tests/setup_isolated.js +23 -2
- package/lib/cjs/tests/setup_isolated.js.map +1 -1
- package/lib/cjs/tests/socket_tests.d.ts.map +1 -1
- package/lib/cjs/tests/socket_tests.js +3 -2
- package/lib/cjs/tests/socket_tests.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +244 -216
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/trigger_title_partials.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/trigger_title_partials.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/trigger_title_partials.test.js +118 -0
- package/lib/cjs/tests/unit_tests/trigger_title_partials.test.js.map +1 -0
- package/lib/cjs/tests/webhooks_tests.js +3 -2
- package/lib/cjs/tests/webhooks_tests.js.map +1 -1
- package/lib/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/esm/enduser.d.ts +2 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +9 -3
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +2 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +2 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/active_patient_count.test.js +1 -1
- package/lib/esm/tests/api_tests/active_patient_count.test.js.map +1 -1
- package/lib/esm/tests/api_tests/appointment_rescheduled_trigger.test.js +1 -1
- package/lib/esm/tests/api_tests/appointment_rescheduled_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_event_limits.test.js +5 -5
- package/lib/esm/tests/api_tests/calendar_event_limits.test.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_event_references_update.test.js +1 -1
- package/lib/esm/tests/api_tests/calendar_event_references_update.test.js.map +1 -1
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +1 -1
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
- package/lib/esm/tests/api_tests/enduser_fields_sync.test.d.ts +21 -0
- package/lib/esm/tests/api_tests/enduser_fields_sync.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_fields_sync.test.js +210 -0
- package/lib/esm/tests/api_tests/enduser_fields_sync.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js +7 -7
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -1
- package/lib/esm/tests/api_tests/enduser_session_invalidation.test.js +4 -4
- package/lib/esm/tests/api_tests/enduser_session_invalidation.test.js.map +1 -1
- package/lib/esm/tests/api_tests/load_threads_care_team.test.d.ts +32 -0
- package/lib/esm/tests/api_tests/load_threads_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/load_threads_care_team.test.js +252 -0
- package/lib/esm/tests/api_tests/load_threads_care_team.test.js.map +1 -0
- package/lib/esm/tests/api_tests/managed_content_enduser_access.test.js +1 -1
- package/lib/esm/tests/api_tests/managed_content_enduser_access.test.js.map +1 -1
- package/lib/esm/tests/api_tests/managed_content_file_access.test.js +1 -1
- package/lib/esm/tests/api_tests/managed_content_file_access.test.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +139 -9
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_webhooks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/mdi_webhooks.test.js +21 -8
- package/lib/esm/tests/api_tests/mdi_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/nutrition_tracking.test.js +1 -1
- package/lib/esm/tests/api_tests/nutrition_tracking.test.js.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/programs.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/programs.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/programs.test.js +365 -0
- package/lib/esm/tests/api_tests/programs.test.js.map +1 -0
- package/lib/esm/tests/api_tests/programs_trigger_filters.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/programs_trigger_filters.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/programs_trigger_filters.test.js +290 -0
- package/lib/esm/tests/api_tests/programs_trigger_filters.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/allowed-paths-confinement.test.js +1 -1
- package/lib/esm/tests/api_tests/security/allowed-paths-confinement.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/appointment-reschedule-ownership.test.d.ts +42 -0
- package/lib/esm/tests/api_tests/security/appointment-reschedule-ownership.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/appointment-reschedule-ownership.test.js +479 -0
- package/lib/esm/tests/api_tests/security/appointment-reschedule-ownership.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/booking-info-idor-token-mint.test.js +1 -1
- package/lib/esm/tests/api_tests/security/booking-info-idor-token-mint.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +1 -1
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/enduser-cross-create.test.js +1 -1
- package/lib/esm/tests/api_tests/security/enduser-cross-create.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/enduser-write-restrictions.test.js +1 -1
- package/lib/esm/tests/api_tests/security/enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/invite-user-enumeration.test.js +3 -3
- package/lib/esm/tests/api_tests/security/invite-user-enumeration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.d.ts +32 -0
- package/lib/esm/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.js +292 -0
- package/lib/esm/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/sanitize-user-html-with-iframes.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/sanitize-user-html-with-iframes.test.js +4 -1
- package/lib/esm/tests/api_tests/security/sanitize-user-html-with-iframes.test.js.map +1 -1
- package/lib/esm/tests/api_tests/templated_message_date_fields.test.d.ts +3 -0
- package/lib/esm/tests/api_tests/templated_message_date_fields.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/templated_message_date_fields.test.js +198 -0
- package/lib/esm/tests/api_tests/templated_message_date_fields.test.js.map +1 -0
- package/lib/esm/tests/api_tests/templated_message_link_in_href.test.d.ts +3 -0
- package/lib/esm/tests/api_tests/templated_message_link_in_href.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/templated_message_link_in_href.test.js +229 -0
- package/lib/esm/tests/api_tests/templated_message_link_in_href.test.js.map +1 -0
- package/lib/esm/tests/api_tests/templated_message_links.test.d.ts +3 -0
- package/lib/esm/tests/api_tests/templated_message_links.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/templated_message_links.test.js +231 -0
- package/lib/esm/tests/api_tests/templated_message_links.test.js.map +1 -0
- package/lib/esm/tests/api_tests/time_tracks.test.js +1 -1
- package/lib/esm/tests/api_tests/time_tracks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/trigger_contains_matching.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/trigger_contains_matching.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/trigger_contains_matching.test.js +390 -0
- package/lib/esm/tests/api_tests/trigger_contains_matching.test.js.map +1 -0
- package/lib/esm/tests/api_tests/user_logs_restore_deleted.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/user_logs_restore_deleted.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/user_logs_restore_deleted.test.js +466 -0
- package/lib/esm/tests/api_tests/user_logs_restore_deleted.test.js.map +1 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.js +1 -1
- package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/esm/tests/api_tests/validation_behavior_locks.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/validation_behavior_locks.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/validation_behavior_locks.test.js +229 -0
- package/lib/esm/tests/api_tests/validation_behavior_locks.test.js.map +1 -0
- package/lib/esm/tests/api_tests/vital_review_trigger_action.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/vital_review_trigger_action.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/vital_review_trigger_action.test.js +564 -0
- package/lib/esm/tests/api_tests/vital_review_trigger_action.test.js.map +1 -0
- package/lib/esm/tests/public_endpoint_tests.js +3 -2
- package/lib/esm/tests/public_endpoint_tests.js.map +1 -1
- package/lib/esm/tests/setup.d.ts +16 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +53 -7
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/setup_isolated.d.ts +9 -0
- package/lib/esm/tests/setup_isolated.d.ts.map +1 -1
- package/lib/esm/tests/setup_isolated.js +21 -1
- package/lib/esm/tests/setup_isolated.js.map +1 -1
- package/lib/esm/tests/socket_tests.d.ts.map +1 -1
- package/lib/esm/tests/socket_tests.js +3 -2
- package/lib/esm/tests/socket_tests.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +244 -216
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/trigger_title_partials.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/trigger_title_partials.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/trigger_title_partials.test.js +115 -0
- package/lib/esm/tests/unit_tests/trigger_title_partials.test.js.map +1 -0
- package/lib/esm/tests/webhooks_tests.js +3 -2
- package/lib/esm/tests/webhooks_tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -19
- package/src/sdk.ts +11 -1
- package/src/tests/api_tests/active_patient_count.test.ts +1 -1
- package/src/tests/api_tests/appointment_rescheduled_trigger.test.ts +1 -1
- package/src/tests/api_tests/calendar_event_references_update.test.ts +1 -1
- package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +1 -1
- package/src/tests/api_tests/enduser_fields_sync.test.ts +150 -0
- package/src/tests/api_tests/enduser_login_rate_limits.test.ts +7 -7
- package/src/tests/api_tests/enduser_session_invalidation.test.ts +4 -4
- package/src/tests/api_tests/load_threads_care_team.test.ts +210 -0
- package/src/tests/api_tests/managed_content_enduser_access.test.ts +1 -1
- package/src/tests/api_tests/managed_content_file_access.test.ts +1 -1
- package/src/tests/api_tests/mdi_case_offerings.test.ts +138 -0
- package/src/tests/api_tests/mdi_webhooks.test.ts +9 -0
- package/src/tests/api_tests/nutrition_tracking.test.ts +1 -1
- package/src/tests/api_tests/openloop_webhooks.test.ts +1 -1
- package/src/tests/api_tests/security/add-to-journey-foreign-enduserid.test.ts +33 -26
- package/src/tests/api_tests/security/allowed-paths-confinement.test.ts +1 -1
- package/src/tests/api_tests/security/appointment-reschedule-ownership.test.ts +432 -0
- package/src/tests/api_tests/security/booking-info-idor-token-mint.test.ts +1 -1
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +1 -1
- package/src/tests/api_tests/security/enduser-cross-create.test.ts +1 -1
- package/src/tests/api_tests/security/enduser-write-restrictions.test.ts +1 -1
- package/src/tests/api_tests/security/invite-user-enumeration.test.ts +3 -3
- package/src/tests/api_tests/security/rich-text-answer-sanitized-on-write.test.ts +197 -0
- package/src/tests/api_tests/security/sanitize-user-html-with-iframes.test.ts +4 -1
- package/src/tests/api_tests/templated_message_date_fields.test.ts +146 -0
- package/src/tests/api_tests/templated_message_links.test.ts +192 -0
- package/src/tests/api_tests/time_tracks.test.ts +1 -1
- package/src/tests/api_tests/trigger_contains_matching.test.ts +345 -0
- package/src/tests/api_tests/user_logs_restore_deleted.test.ts +415 -0
- package/src/tests/api_tests/user_portal_settings.test.ts +1 -1
- package/src/tests/api_tests/validation_behavior_locks.test.ts +204 -0
- package/src/tests/public_endpoint_tests.ts +3 -2
- package/src/tests/setup.ts +43 -7
- package/src/tests/setup_isolated.ts +10 -0
- package/src/tests/socket_tests.ts +3 -2
- package/src/tests/tests.ts +22 -2
- package/src/tests/unit_tests/trigger_title_partials.test.ts +174 -0
- package/src/tests/webhooks_tests.ts +4 -2
- package/test_generated.pdf +0 -0
- package/tsconfig.cjs.json +17 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { AUTOMATED_ACTION_CANCEL_REASONS } from "@tellescope/constants"
|
|
4
|
+
import {
|
|
5
|
+
assert_checks,
|
|
6
|
+
Check,
|
|
7
|
+
setup_isolated_tenant,
|
|
8
|
+
} from "../setup_isolated"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Coverage for POST /v1/user-logs/restore-deleted — re-creating a deleted contact, and the records
|
|
16
|
+
* cascade-deleted with it, by replaying user_logs rows.
|
|
17
|
+
*
|
|
18
|
+
* The check that most justifies the handler's shape is B. handleDeletionForDependencies runs BEFORE
|
|
19
|
+
* the parent `delete` row is inserted, so cascade rows have SMALLER _ids than the parent row. If the
|
|
20
|
+
* `_id` range is ever written the intuitive way round (forwards from the parent), the contact still
|
|
21
|
+
* comes back and every "contact restored" check still passes — only the dependent counts go to zero.
|
|
22
|
+
* B is what fails in that case.
|
|
23
|
+
*
|
|
24
|
+
* H is the other half of that: the window is scoped by enduserId, which the delete path stamps on
|
|
25
|
+
* every cascade row for every model. Deleting contact B's ticket inside the same five-minute window
|
|
26
|
+
* must not bring it back when contact A is restored.
|
|
27
|
+
*
|
|
28
|
+
* The suite MUST poll for audit rows: the delete endpoint responds 204 *before* writing them, so
|
|
29
|
+
* reading immediately after a delete is a race.
|
|
30
|
+
*
|
|
31
|
+
* Restores are deliberately not idempotent in the "refuse if present" sense — they RESUME. D and E
|
|
32
|
+
* pin that: a second press duplicates nothing, and a dependent deleted since comes back.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const poll_for = async <T,>(get: () => Promise<T>, done: (v: T) => boolean, attempts = 40): Promise<T> => {
|
|
36
|
+
let value = await get()
|
|
37
|
+
for (let i = 0; i < attempts && !done(value); i++) {
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
39
|
+
value = await get()
|
|
40
|
+
}
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The delete endpoint returns before writing its audit rows, so every read of one polls. */
|
|
45
|
+
const poll_for_delete_log = async (sdk: any, resource: string, resourceId: string) => {
|
|
46
|
+
const log = await poll_for(
|
|
47
|
+
async () => (
|
|
48
|
+
await sdk.api.user_logs.getSome({ filter: { resourceId } })
|
|
49
|
+
).find((l: any) => l.action === 'delete' && l.resource === resource),
|
|
50
|
+
l => !!l,
|
|
51
|
+
)
|
|
52
|
+
if (!log) throw new Error(`no delete user_log appeared for ${resource} ${resourceId}`)
|
|
53
|
+
return log
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sum = (counts: Record<string, number> = {}) => Object.values(counts).reduce((a, b) => a + b, 0)
|
|
57
|
+
|
|
58
|
+
const rejection_of = async (run: () => Promise<any>) => {
|
|
59
|
+
try {
|
|
60
|
+
await run()
|
|
61
|
+
return undefined
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
return err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const user_logs_restore_deleted_checks = async (): Promise<Check[]> => {
|
|
68
|
+
const checks: Check[] = []
|
|
69
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
70
|
+
|
|
71
|
+
let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
72
|
+
let otherTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
;[tenant, otherTenant] = await Promise.all([
|
|
76
|
+
setup_isolated_tenant({
|
|
77
|
+
name: `RestoreDeleted ${RAND()}`,
|
|
78
|
+
// A non-admin is provisioned deliberately: without one, sdkNonAdmin is an unauthenticated
|
|
79
|
+
// stub and the permission check (G) would pass for the wrong reason.
|
|
80
|
+
users: [{ isAdmin: true }, { roles: ['Non-Admin'] }],
|
|
81
|
+
apiKeys: 0,
|
|
82
|
+
}),
|
|
83
|
+
setup_isolated_tenant({ name: `RestoreDeletedOther ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
const { sdk, sdkNonAdmin } = tenant
|
|
87
|
+
|
|
88
|
+
/* ============================== fixtures ============================== */
|
|
89
|
+
|
|
90
|
+
const queue = await sdk.api.ticket_queues.createOne({
|
|
91
|
+
title: `Restore Queue ${RAND()}`,
|
|
92
|
+
userIds: [sdk.userInfo.id],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const journey = await sdk.api.journeys.createOne({ title: `Restore Journey ${RAND()}` })
|
|
96
|
+
const step = await sdk.api.automation_steps.createOne({
|
|
97
|
+
journeyId: journey.id,
|
|
98
|
+
action: { type: 'setEnduserStatus', info: { status: 'Restored' } },
|
|
99
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const emailA = `restore-a-${RAND()}@example.com`
|
|
103
|
+
const externalIdA = `restore-ext-${RAND()}`
|
|
104
|
+
|
|
105
|
+
const contactA = await sdk.api.endusers.createOne({
|
|
106
|
+
fname: 'Restore', lname: 'Target', email: emailA, externalId: externalIdA,
|
|
107
|
+
})
|
|
108
|
+
const contactB = await sdk.api.endusers.createOne({
|
|
109
|
+
fname: 'Bystander', lname: 'Contact', email: `restore-b-${RAND()}@example.com`,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const noteA = await sdk.api.notes.createOne({ enduserId: contactA.id, text: 'A note that must come back' })
|
|
113
|
+
const ticketA = await sdk.api.tickets.createOne({
|
|
114
|
+
title: `Ticket for A ${RAND()}`, enduserId: contactA.id, queueId: queue.id,
|
|
115
|
+
})
|
|
116
|
+
const actionA = await sdk.api.automated_actions.createOne({
|
|
117
|
+
journeyId: journey.id,
|
|
118
|
+
automationStepId: step.id,
|
|
119
|
+
enduserId: contactA.id,
|
|
120
|
+
processAfter: Date.now() + 100000000, // far future: the worker must not process it during the test
|
|
121
|
+
status: 'active',
|
|
122
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
123
|
+
action: { type: 'setEnduserStatus', info: { status: 'Restored' } },
|
|
124
|
+
})
|
|
125
|
+
const ticketB = await sdk.api.tickets.createOne({
|
|
126
|
+
title: `Ticket for B ${RAND()}`, enduserId: contactB.id,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ticket_queues.count is maintained by asynchronous side effects, so every read of it polls
|
|
130
|
+
const queueCountAfterCreate = (await poll_for(
|
|
131
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
132
|
+
q => (q.count ?? 0) >= 1,
|
|
133
|
+
)).count ?? 0
|
|
134
|
+
|
|
135
|
+
/* ====================== delete A (and one of B's tickets) ====================== */
|
|
136
|
+
|
|
137
|
+
// H: deleted INSIDE the same window as A's delete, to prove enduserId scoping
|
|
138
|
+
await sdk.api.tickets.deleteOne(ticketB.id)
|
|
139
|
+
await poll_for_delete_log(sdk, 'tickets', ticketB.id)
|
|
140
|
+
|
|
141
|
+
await sdk.api.endusers.deleteOne(contactA.id)
|
|
142
|
+
const deleteLogA = await poll_for_delete_log(sdk, 'endusers', contactA.id)
|
|
143
|
+
|
|
144
|
+
const queueCountAfterDelete = (await poll_for(
|
|
145
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
146
|
+
q => (q.count ?? 0) < queueCountAfterCreate,
|
|
147
|
+
)).count ?? 0
|
|
148
|
+
|
|
149
|
+
record(
|
|
150
|
+
'delete removes the contact and decrements the queue',
|
|
151
|
+
!(await sdk.api.endusers.getSome({ filter: { } })).some(e => e.id === contactA.id)
|
|
152
|
+
&& queueCountAfterDelete === queueCountAfterCreate - 1,
|
|
153
|
+
`queue ${queueCountAfterCreate} -> ${queueCountAfterDelete}`,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const auditRowsBefore = (await sdk.api.user_logs.getSome({ filter: { resourceId: contactA.id } })).length
|
|
157
|
+
|
|
158
|
+
/* ============================== A: the restore ============================== */
|
|
159
|
+
|
|
160
|
+
const result = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
161
|
+
|
|
162
|
+
record(
|
|
163
|
+
'A1: restore returns the contact resource and its original id',
|
|
164
|
+
result.resource === 'endusers' && result.resourceId === contactA.id,
|
|
165
|
+
`resource=${result.resource} resourceId=${result.resourceId} (expected ${contactA.id})`,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const restoredContact = await sdk.api.endusers.getOne(contactA.id)
|
|
169
|
+
record(
|
|
170
|
+
'A2: the contact is back at its original id with its fields intact',
|
|
171
|
+
restoredContact.id === contactA.id
|
|
172
|
+
&& restoredContact.fname === 'Restore'
|
|
173
|
+
&& restoredContact.lname === 'Target'
|
|
174
|
+
&& restoredContact.email === emailA
|
|
175
|
+
&& restoredContact.externalId === externalIdA,
|
|
176
|
+
`id=${restoredContact.id} fname=${restoredContact.fname} email=${restoredContact.email} externalId=${restoredContact.externalId}`,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
record(
|
|
180
|
+
'A3: the contact keeps its original creation timestamp',
|
|
181
|
+
new Date(restoredContact.createdAt).getTime() === new Date(contactA.createdAt).getTime(),
|
|
182
|
+
`restored=${restoredContact.createdAt} original=${contactA.createdAt}`,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
/* ---- B: the cascade. This is what fails if the _id range runs the wrong way. ---- */
|
|
186
|
+
|
|
187
|
+
const dependentModels = Object.keys(result.restoredCounts ?? {}).filter(m => m !== 'endusers')
|
|
188
|
+
record(
|
|
189
|
+
'B1: restoredCounts covers at least two dependent models',
|
|
190
|
+
dependentModels.length >= 2,
|
|
191
|
+
`restoredCounts=${JSON.stringify(result.restoredCounts)} (the _id range runs BACKWARDS from the parent row)`,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const restoredNote = await sdk.api.notes.getOne(noteA.id)
|
|
195
|
+
const restoredTicket = await sdk.api.tickets.getOne(ticketA.id)
|
|
196
|
+
record(
|
|
197
|
+
'B2: dependents come back at their original ids',
|
|
198
|
+
restoredNote?.id === noteA.id && restoredTicket?.id === ticketA.id,
|
|
199
|
+
`note=${restoredNote?.id} ticket=${restoredTicket?.id}`,
|
|
200
|
+
)
|
|
201
|
+
record(
|
|
202
|
+
'B3: dependents keep their original authorship and content',
|
|
203
|
+
restoredNote.creator === noteA.creator
|
|
204
|
+
&& restoredNote.text === 'A note that must come back'
|
|
205
|
+
&& restoredTicket.creator === ticketA.creator
|
|
206
|
+
&& restoredTicket.title === ticketA.title,
|
|
207
|
+
`note.creator=${restoredNote.creator} (orig ${noteA.creator}); ticket.creator=${restoredTicket.creator} (orig ${ticketA.creator})`,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
/* ---- C: journey actions come back CANCELLED, never active ---- */
|
|
211
|
+
|
|
212
|
+
const expectedCancelReason = AUTOMATED_ACTION_CANCEL_REASONS.indexOf('Restored from Deletion')
|
|
213
|
+
const restoredAction = await sdk.api.automated_actions.getOne(actionA.id)
|
|
214
|
+
record(
|
|
215
|
+
'C: the pending journey action is restored cancelled with the new reason',
|
|
216
|
+
restoredAction?.status === 'cancelled' && restoredAction?.cancelReason === expectedCancelReason,
|
|
217
|
+
`status=${restoredAction?.status} cancelReason=${restoredAction?.cancelReason} (expected ${expectedCancelReason})`,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
/* ---- H: cross-contact isolation ---- */
|
|
221
|
+
|
|
222
|
+
const bTicketBack = await rejection_of(() => sdk.api.tickets.getOne(ticketB.id))
|
|
223
|
+
record(
|
|
224
|
+
"H: restoring A does not bring back contact B's separately-deleted ticket",
|
|
225
|
+
bTicketBack !== undefined,
|
|
226
|
+
`enduserId scoping; getOne(ticketB) ${bTicketBack === undefined ? 'UNEXPECTEDLY SUCCEEDED' : 'rejected as expected'}`,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
/* ---- queue count round-trips ---- */
|
|
230
|
+
|
|
231
|
+
const queueCountAfterRestore = (await poll_for(
|
|
232
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
233
|
+
q => (q.count ?? 0) > queueCountAfterDelete,
|
|
234
|
+
)).count ?? 0
|
|
235
|
+
record(
|
|
236
|
+
'F1: the ticket queue count round-trips on restore',
|
|
237
|
+
queueCountAfterRestore === queueCountAfterCreate,
|
|
238
|
+
`${queueCountAfterCreate} -> ${queueCountAfterDelete} -> ${queueCountAfterRestore}`,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
/* ---- audit: exactly one new row per restore ---- */
|
|
242
|
+
|
|
243
|
+
const auditRowsAfter = await poll_for(
|
|
244
|
+
async () => await sdk.api.user_logs.getSome({ filter: { resourceId: contactA.id } }),
|
|
245
|
+
rows => rows.length > auditRowsBefore,
|
|
246
|
+
)
|
|
247
|
+
const restoreRows = auditRowsAfter.filter(r => r.action === 'create' && r.note === 'Restored from deletion')
|
|
248
|
+
record(
|
|
249
|
+
'I1: the restore writes exactly one audit row',
|
|
250
|
+
auditRowsAfter.length === auditRowsBefore + 1 && restoreRows.length === 1,
|
|
251
|
+
`rows ${auditRowsBefore} -> ${auditRowsAfter.length}; restore rows=${restoreRows.length}`,
|
|
252
|
+
)
|
|
253
|
+
record(
|
|
254
|
+
'I2: the restore audit row records the source log id and the counts',
|
|
255
|
+
(restoreRows[0]?.info as any)?.sourceUserLogId === deleteLogA.id
|
|
256
|
+
&& !!(restoreRows[0]?.info as any)?.restoredCounts,
|
|
257
|
+
`info=${JSON.stringify(restoreRows[0]?.info)?.slice(0, 200)}`,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
/* ---- D: a second restore resumes, and duplicates nothing ---- */
|
|
261
|
+
|
|
262
|
+
const second = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
263
|
+
record(
|
|
264
|
+
'D1: a second restore restores nothing and skips everything',
|
|
265
|
+
sum(second.restoredCounts) === 0 && sum(second.skippedCounts) > 0,
|
|
266
|
+
`restored=${JSON.stringify(second.restoredCounts)} skipped=${JSON.stringify(second.skippedCounts)}`,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const allContacts = await sdk.api.endusers.getSome({ filter: { } })
|
|
270
|
+
const allNotes = await sdk.api.notes.getSome({ filter: { enduserId: contactA.id } })
|
|
271
|
+
record(
|
|
272
|
+
'D2: a second restore duplicates neither the contact nor its records',
|
|
273
|
+
allContacts.filter(e => e.id === contactA.id).length === 1 && allNotes.length === 1,
|
|
274
|
+
`contacts with that id=${allContacts.filter(e => e.id === contactA.id).length} notes=${allNotes.length}`,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const queueCountAfterSecond = (await sdk.api.ticket_queues.getOne(queue.id)).count ?? 0
|
|
278
|
+
record(
|
|
279
|
+
'F2: a second restore does not inflate the queue count',
|
|
280
|
+
queueCountAfterSecond === queueCountAfterCreate,
|
|
281
|
+
`expected ${queueCountAfterCreate}, got ${queueCountAfterSecond}`,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
/* ---- E: resume brings back a dependent deleted since ---- */
|
|
285
|
+
|
|
286
|
+
await sdk.api.notes.deleteOne(noteA.id)
|
|
287
|
+
await poll_for_delete_log(sdk, 'notes', noteA.id)
|
|
288
|
+
|
|
289
|
+
const third = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
290
|
+
const noteBackAgain = await sdk.api.notes.getOne(noteA.id)
|
|
291
|
+
record(
|
|
292
|
+
'E: a repeat restore brings back exactly the record deleted since',
|
|
293
|
+
(third.restoredCounts as any)?.notes === 1
|
|
294
|
+
&& sum(third.restoredCounts) === 1
|
|
295
|
+
&& noteBackAgain?.id === noteA.id,
|
|
296
|
+
`restored=${JSON.stringify(third.restoredCounts)} note back=${noteBackAgain?.id}`,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
/* ============================== error cases ============================== */
|
|
300
|
+
|
|
301
|
+
// G: a non-admin is refused. NOTE: for the default Non-Admin role the role-permission layer fires
|
|
302
|
+
// FIRST with 403 "Inaccessible" (the role has no user_logs access at all), so the schema's
|
|
303
|
+
// adminOnly gate — 401 "Admin access only" — is never reached. Both refuse; this asserts only
|
|
304
|
+
// that the call is rejected, so it stays green whichever of the two gates fires.
|
|
305
|
+
const nonAdminErr = await rejection_of(() => sdkNonAdmin.api.user_logs.restore_deleted({ userLogId: deleteLogA.id }))
|
|
306
|
+
record(
|
|
307
|
+
'G: a non-admin is refused',
|
|
308
|
+
nonAdminErr !== undefined,
|
|
309
|
+
`error=${JSON.stringify(nonAdminErr)?.slice(0, 200)}`,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// another tenant's log id is not found (indistinguishable from nonexistent)
|
|
313
|
+
const otherContact = await otherTenant.sdk.api.endusers.createOne({ fname: 'Other', lname: 'Tenant' })
|
|
314
|
+
await otherTenant.sdk.api.endusers.deleteOne(otherContact.id)
|
|
315
|
+
const otherLog = await poll_for_delete_log(otherTenant.sdk, 'endusers', otherContact.id)
|
|
316
|
+
|
|
317
|
+
const crossTenantErr = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: otherLog.id }))
|
|
318
|
+
record(
|
|
319
|
+
"J1: another tenant's log id is not found",
|
|
320
|
+
crossTenantErr !== undefined,
|
|
321
|
+
`error=${JSON.stringify(crossTenantErr)?.slice(0, 200)}`,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// a non-delete row is not found
|
|
325
|
+
const anyCreateRow = (await sdk.api.user_logs.getSome({ filter: { } })).find(l => l.action !== 'delete')
|
|
326
|
+
const nonDeleteErr = anyCreateRow
|
|
327
|
+
? await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: anyCreateRow.id }))
|
|
328
|
+
: undefined
|
|
329
|
+
record(
|
|
330
|
+
'J2: a non-delete audit row is not found',
|
|
331
|
+
!anyCreateRow || nonDeleteErr !== undefined,
|
|
332
|
+
anyCreateRow ? `error=${JSON.stringify(nonDeleteErr)?.slice(0, 200)}` : 'no non-delete row available to test',
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// K: a standalone ticket's delete row is refused as unsupported_resource, a 400 (not a 404)
|
|
336
|
+
const standaloneTicket = await sdk.api.tickets.createOne({ title: `Standalone ${RAND()}` })
|
|
337
|
+
await sdk.api.tickets.deleteOne(standaloneTicket.id)
|
|
338
|
+
const ticketLog = await poll_for_delete_log(sdk, 'tickets', standaloneTicket.id)
|
|
339
|
+
|
|
340
|
+
const unsupportedErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: ticketLog.id }))
|
|
341
|
+
record(
|
|
342
|
+
'K: a non-contact delete row is refused with unsupported_resource',
|
|
343
|
+
unsupportedErr?.info?.reason === 'unsupported_resource',
|
|
344
|
+
`error=${JSON.stringify(unsupportedErr)?.slice(0, 300)}`,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
// L: a merge's delete row carries the Profile merge marker and is refused
|
|
348
|
+
const mergeSource = await sdk.api.endusers.createOne({ fname: 'Merge', lname: 'Source', email: `merge-src-${RAND()}@example.com` })
|
|
349
|
+
const mergeDest = await sdk.api.endusers.createOne({ fname: 'Merge', lname: 'Dest', email: `merge-dst-${RAND()}@example.com` })
|
|
350
|
+
await sdk.api.endusers.merge({ sourceEnduserId: mergeSource.id, destinationEnduserId: mergeDest.id })
|
|
351
|
+
const mergeLog = await poll_for_delete_log(sdk, 'endusers', mergeSource.id)
|
|
352
|
+
|
|
353
|
+
record(
|
|
354
|
+
"L1: the merge's delete row is marked 'Profile merge'",
|
|
355
|
+
mergeLog.note === 'Profile merge',
|
|
356
|
+
`note=${mergeLog.note}`,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const mergeErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: mergeLog.id }))
|
|
360
|
+
record(
|
|
361
|
+
'L2: a merge delete row is refused with merge_row',
|
|
362
|
+
mergeErr?.info?.reason === 'merge_row',
|
|
363
|
+
`error=${JSON.stringify(mergeErr)?.slice(0, 300)}`,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// M: reusing a deleted contact's email yields unique_conflict
|
|
367
|
+
const conflictEmail = `restore-conflict-${RAND()}@example.com`
|
|
368
|
+
const conflictContact = await sdk.api.endusers.createOne({ fname: 'Conflict', lname: 'Target', email: conflictEmail })
|
|
369
|
+
await sdk.api.endusers.deleteOne(conflictContact.id)
|
|
370
|
+
const conflictLog = await poll_for_delete_log(sdk, 'endusers', conflictContact.id)
|
|
371
|
+
|
|
372
|
+
const replacement = await sdk.api.endusers.createOne({ fname: 'Replacement', lname: 'Contact', email: conflictEmail })
|
|
373
|
+
const conflictErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: conflictLog.id }))
|
|
374
|
+
|
|
375
|
+
record(
|
|
376
|
+
'M1: restoring into a reused email yields unique_conflict',
|
|
377
|
+
conflictErr?.info?.reason === 'unique_conflict',
|
|
378
|
+
`error=${JSON.stringify(conflictErr)?.slice(0, 300)}`,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const conflictStillGone = await rejection_of(() => sdk.api.endusers.getOne(conflictContact.id))
|
|
382
|
+
const replacementUntouched = await sdk.api.endusers.getOne(replacement.id)
|
|
383
|
+
record(
|
|
384
|
+
'M2: a unique conflict restores nothing and leaves the new contact untouched',
|
|
385
|
+
conflictStillGone !== undefined && replacementUntouched?.email === conflictEmail,
|
|
386
|
+
`deleted contact still absent=${conflictStillGone !== undefined}; replacement email=${replacementUntouched?.email}`,
|
|
387
|
+
)
|
|
388
|
+
} finally {
|
|
389
|
+
// Teardown BEFORE anything asserts — assert calls process.exit, which would strand these tenants.
|
|
390
|
+
for (const t of [tenant, otherTenant]) {
|
|
391
|
+
if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return checks
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Asserting wrapper, for standalone runs and for sequential use in the suite. */
|
|
399
|
+
export const user_logs_restore_deleted_tests = async () => {
|
|
400
|
+
assert_checks('user_logs restore_deleted', await user_logs_restore_deleted_checks())
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Allow running this test file independently
|
|
404
|
+
if (require.main === module) {
|
|
405
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
406
|
+
user_logs_restore_deleted_tests()
|
|
407
|
+
.then(() => {
|
|
408
|
+
console.log("✅ user_logs restore_deleted test suite completed successfully")
|
|
409
|
+
process.exit(0)
|
|
410
|
+
})
|
|
411
|
+
.catch((error) => {
|
|
412
|
+
console.error("❌ user_logs restore_deleted test suite failed:", error)
|
|
413
|
+
process.exit(1)
|
|
414
|
+
})
|
|
415
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { setup_tests } from "../setup"
|
|
11
11
|
|
|
12
12
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
-
const businessId = '60398b1131a295e64f084ff6'
|
|
13
|
+
const businessId = process.env.TEST_BUSINESS_ID || '60398b1131a295e64f084ff6'
|
|
14
14
|
|
|
15
15
|
// Main test function that can be called independently
|
|
16
16
|
export const user_portal_settings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { dateValidator, dateOptionalOrEmptyStringValidator, dateValidatorOptional, slugValidator, urlValidator } from "@tellescope/validation"
|
|
2
|
+
|
|
3
|
+
// Pins the accept-sets of the validators whose behavior is delegated to the `validator` package, so
|
|
4
|
+
// a dependency upgrade cannot move what the API accepts without a test failing.
|
|
5
|
+
//
|
|
6
|
+
// These assertions describe PRODUCTION — deliberately, including the parts that are wrong. Two of
|
|
7
|
+
// them are: a bare `YYYY-MM-DD` is rejected while an ISO datetime is accepted, and an unparseable
|
|
8
|
+
// string is accepted and persisted as an `Invalid Date`. Both predate this work and both are
|
|
9
|
+
// API-visible across ~135 schema fields, so changing them belongs in its own PR. Pinning them is
|
|
10
|
+
// what makes this file useful: it is proof of how the deployed API behaves, not an aspiration.
|
|
11
|
+
//
|
|
12
|
+
// Why it was needed. Validator 13.11.0's `isDate` was timezone-dependent — its last step compared a
|
|
13
|
+
// UTC-parsed date against a local-time `.getDate()` — so it answered false west of Greenwich and
|
|
14
|
+
// true in UTC. The servers run UTC, so the behavior pinned below is what production has been doing,
|
|
15
|
+
// while the same code on a US developer's machine did the opposite. 13.12.0 fixed that (#2257), and
|
|
16
|
+
// 13.15.35 was verified to return exactly what 13.11.0 returned under UTC, across 26 inputs and four
|
|
17
|
+
// timezones. So every assertion here holds in EVERY timezone — that is the point of the file, and
|
|
18
|
+
// the reason it can be trusted as a statement about production while running on a laptop.
|
|
19
|
+
//
|
|
20
|
+
// The `isSlug` half is different: 13.15.35 genuinely narrowed it (lowercase-only, alphanumeric ends,
|
|
21
|
+
// 1-character minimum), which would have moved the subdomain accept-set. `validation.ts` pins
|
|
22
|
+
// 13.11.0's pattern inline instead, and the checks below are that pattern.
|
|
23
|
+
//
|
|
24
|
+
// The `isURL` half (section 5) is neither: 13.11.0 and 13.15.35 were compared across 37 URL inputs
|
|
25
|
+
// and agreed on every one, so nothing was at risk in that upgrade and nothing needed pinning inline.
|
|
26
|
+
// It is here as a forward guard — webhook destinations are customer-configured and an accept-set that
|
|
27
|
+
// narrows silently breaks live integrations — and because the staging QA asserted this behavior with
|
|
28
|
+
// no test behind it. `isURL` reads no clock, so that section has no timezone caveat.
|
|
29
|
+
//
|
|
30
|
+
// Pure-function test — no Session needed. Runs as part of the main suite and standalone:
|
|
31
|
+
// npm run test:modular -- api_tests/validation_behavior_locks
|
|
32
|
+
// TZ=UTC npm run test:modular -- api_tests/validation_behavior_locks (must give the same result)
|
|
33
|
+
|
|
34
|
+
// Every assertion runs before anything throws, so one run reports the complete diff rather than the
|
|
35
|
+
// first case that happened to trip.
|
|
36
|
+
const failures: string[] = []
|
|
37
|
+
|
|
38
|
+
const accepts = (label: string, validate: (v: any) => any, value: any) => {
|
|
39
|
+
try { validate(value) }
|
|
40
|
+
catch (err: any) { failures.push(`${label}: expected to ACCEPT ${JSON.stringify(value)}, but it was rejected (${err?.message || err})`) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rejects = (label: string, validate: (v: any) => any, value: any) => {
|
|
44
|
+
let accepted = false
|
|
45
|
+
try { validate(value); accepted = true }
|
|
46
|
+
catch { /* expected */ }
|
|
47
|
+
if (accepted) failures.push(`${label}: expected to REJECT ${JSON.stringify(value)}, but it was accepted`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const validation_behavior_lock_tests = async () => {
|
|
51
|
+
console.log(`Running validation behavior lock tests (TZ=${process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone})`)
|
|
52
|
+
|
|
53
|
+
const date = dateValidator.validate()
|
|
54
|
+
const dateOptional = dateValidatorOptional.validate()
|
|
55
|
+
const dateOrEmpty = dateOptionalOrEmptyStringValidator.validate()
|
|
56
|
+
|
|
57
|
+
// -----------------------------------------------------------------------------------------------
|
|
58
|
+
// 1. Date fields: ISO datetimes are accepted
|
|
59
|
+
// -----------------------------------------------------------------------------------------------
|
|
60
|
+
// What the SDK sends, since JSON.stringify turns a Date into one of these.
|
|
61
|
+
for (const value of [
|
|
62
|
+
'2024-06-15T14:30:00Z',
|
|
63
|
+
'2024-06-15T00:00:00.000Z',
|
|
64
|
+
'2024-06-15T14:30:00+05:00',
|
|
65
|
+
]) {
|
|
66
|
+
accepts('dateValidator', date, value)
|
|
67
|
+
accepts('dateValidatorOptional', dateOptional, value)
|
|
68
|
+
accepts('dateOptionalOrEmptyStringValidator', dateOrEmpty, value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Epoch milliseconds round-trip through `new Date()`.
|
|
72
|
+
accepts('dateValidator', date, 1718400000000)
|
|
73
|
+
|
|
74
|
+
// The empty string is the "cleared" value for the two lenient variants only.
|
|
75
|
+
accepts('dateOptionalOrEmptyStringValidator', dateOrEmpty, '')
|
|
76
|
+
rejects('dateValidator', date, '')
|
|
77
|
+
|
|
78
|
+
// -----------------------------------------------------------------------------------------------
|
|
79
|
+
// 2. Date fields: a bare calendar date is REJECTED
|
|
80
|
+
// -----------------------------------------------------------------------------------------------
|
|
81
|
+
// Surprising, and not a typo. `isDate` returns true for these and the check is inverted, so they
|
|
82
|
+
// 400. This is long-standing production behavior; it is pinned, not endorsed. A client sending
|
|
83
|
+
// `"1985-03-12"` to a date field gets an error and must send an ISO datetime instead.
|
|
84
|
+
for (const value of ['1985-03-12', '2024-01-01', '2026-10-01', '2000-02-29', '1999/12/31']) {
|
|
85
|
+
rejects('dateValidator', date, value)
|
|
86
|
+
rejects('dateValidatorOptional', dateOptional, value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A real Date object is rejected by the same mechanism (isDate is true for one). Nothing sends a
|
|
90
|
+
// Date over HTTP, so this only reaches internal callers — but the three validators disagree here:
|
|
91
|
+
// the `dateOrEmpty` variant coerces with `.toString()` first, producing "Sat Jun 15 2024 ...",
|
|
92
|
+
// which isDate calls false, so it accepts. Pinned because it is a real inconsistency.
|
|
93
|
+
rejects('dateValidator', date, new Date('2024-06-15T14:30:00Z'))
|
|
94
|
+
rejects('dateValidatorOptional', dateOptional, new Date('2024-06-15T14:30:00Z'))
|
|
95
|
+
accepts('dateOptionalOrEmptyStringValidator', dateOrEmpty, new Date('2024-06-15T14:30:00Z'))
|
|
96
|
+
|
|
97
|
+
// -----------------------------------------------------------------------------------------------
|
|
98
|
+
// 3. Date fields: an unparseable string is ACCEPTED and stored as an Invalid Date
|
|
99
|
+
// -----------------------------------------------------------------------------------------------
|
|
100
|
+
// A data-corruption path, pinned so it is documented and so a future fix is a deliberate, visible
|
|
101
|
+
// change rather than a silent one. `new Date('not a date')` is an Invalid Date, and it is what
|
|
102
|
+
// gets written.
|
|
103
|
+
for (const value of ['not a date', 'hello world', 'tomorrow', '??']) {
|
|
104
|
+
accepts('dateValidator', date, value)
|
|
105
|
+
accepts('dateValidatorOptional', dateOptional, value)
|
|
106
|
+
accepts('dateOptionalOrEmptyStringValidator', dateOrEmpty, value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -----------------------------------------------------------------------------------------------
|
|
110
|
+
// 4. Organization subdomain
|
|
111
|
+
// -----------------------------------------------------------------------------------------------
|
|
112
|
+
const slug = slugValidator.validate()
|
|
113
|
+
|
|
114
|
+
// Valid, and must stay valid — 13.15.35's isSlug would reject the mixed-case ones.
|
|
115
|
+
for (const value of ['acme', 'acme-health', 'tellescope-demo', 'demo_org_1', 'Acme', 'My-Org', 'TellescopeDemo', 'abc']) {
|
|
116
|
+
accepts('slugValidator', slug, value)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Also accepted today, though none of these are valid DNS labels. Pinned so the upgrade does not
|
|
120
|
+
// quietly change them; tightening is a separate decision.
|
|
121
|
+
for (const value of ['acme.health', 'org/sub', 'my@org', 'org?x=1']) {
|
|
122
|
+
accepts('slugValidator', slug, value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Rejected today — note 13.15.35's isSlug would ACCEPT the 1- and 2-character cases.
|
|
126
|
+
for (const value of ['a', 'ab', 'my--org', 'my__org', '-acme', 'acme-', 'org name']) {
|
|
127
|
+
rejects('slugValidator', slug, value)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -----------------------------------------------------------------------------------------------
|
|
131
|
+
// 5. Webhook and other URL fields
|
|
132
|
+
// -----------------------------------------------------------------------------------------------
|
|
133
|
+
// `urlValidator` is a thin `isURL` passthrough behind `build_validator`, used for webhook
|
|
134
|
+
// subscriptions (schema `webhooks.url`) among others. It is a SYNTAX check only — refusing internal
|
|
135
|
+
// destinations is a separate layer (`api/modules/outbound_url.ts`), which is why several things
|
|
136
|
+
// accepted here are refused at delivery time.
|
|
137
|
+
const url = urlValidator.validate()
|
|
138
|
+
|
|
139
|
+
// Ports and query strings, the cases the staging QA calls out.
|
|
140
|
+
for (const value of [
|
|
141
|
+
'https://example.com:8443/hook',
|
|
142
|
+
'https://example.com/hook?a=1&b=two',
|
|
143
|
+
'https://example.com:8443/hook?a=1',
|
|
144
|
+
'https://example.com/a?b=c&d=%7B%22x%22%3A1%7D', // percent-encoded JSON in the query
|
|
145
|
+
'https://example.com:443/hook',
|
|
146
|
+
'https://example.com:8443', // port, no path
|
|
147
|
+
'https://example.com/hook#frag',
|
|
148
|
+
'HTTPS://EXAMPLE.COM/Hook',
|
|
149
|
+
'https://[::1]:8443/hook',
|
|
150
|
+
]) {
|
|
151
|
+
accepts('urlValidator', url, value)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Accepted, but quirks rather than intent — pinned like the date quirks above so that tightening any
|
|
155
|
+
// of them is a deliberate, visible change.
|
|
156
|
+
// - schemeless: values of this shape exist in the webhooks collection today (outbound_url.ts:213)
|
|
157
|
+
// - ftp: isURL's default protocol list is http/https/ftp
|
|
158
|
+
// - the last two are refused by assertPublicHttpUrl at delivery, not here
|
|
159
|
+
for (const value of [
|
|
160
|
+
'api.customer.com/hook',
|
|
161
|
+
'ftp://example.com/x',
|
|
162
|
+
'http://10.0.0.1/hook',
|
|
163
|
+
'https://user:pass@example.com/hook',
|
|
164
|
+
]) {
|
|
165
|
+
accepts('urlValidator', url, value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Rejected. `localhost` fails isURL's require_tld, which defaults on — a local webhook receiver has
|
|
169
|
+
// to be configured as 127.0.0.1, and that is why the SSRF suite binds loopback by address.
|
|
170
|
+
for (const value of [
|
|
171
|
+
'http://localhost:8080/hook',
|
|
172
|
+
'localhost:8080/hook',
|
|
173
|
+
'https://example.com:65536/hook',
|
|
174
|
+
'https://example.com:0/hook',
|
|
175
|
+
'https://example.com:abc/x',
|
|
176
|
+
'javascript:alert(1)',
|
|
177
|
+
'file:///etc/passwd',
|
|
178
|
+
'mailto:a@b.com',
|
|
179
|
+
'//example.com/hook',
|
|
180
|
+
'not a url',
|
|
181
|
+
'https://example.com/hook with space',
|
|
182
|
+
'https://example.com./hook', // trailing dot
|
|
183
|
+
]) {
|
|
184
|
+
rejects('urlValidator', url, value)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Non-string and absent values. The empty string is rejected by build_validator's non-empty rule
|
|
188
|
+
// rather than by isURL, so it is pinned separately from the malformed strings above.
|
|
189
|
+
rejects('urlValidator', url, '')
|
|
190
|
+
rejects('urlValidator', url, 123)
|
|
191
|
+
rejects('urlValidator', url, null)
|
|
192
|
+
rejects('urlValidator', url, undefined)
|
|
193
|
+
|
|
194
|
+
if (failures.length) {
|
|
195
|
+
throw new Error(`${failures.length} validation behavior lock(s) broken:\n - ` + failures.join('\n - '))
|
|
196
|
+
}
|
|
197
|
+
console.log("✅ validation behavior lock tests passed")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (require.main === module) {
|
|
201
|
+
validation_behavior_lock_tests()
|
|
202
|
+
.then(() => { console.log("✅ suite completed"); process.exit(0) })
|
|
203
|
+
.catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
|
|
204
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@tellescope/utilities"
|
|
10
10
|
import { Session, /* APIQuery */ } from "../sdk"
|
|
11
11
|
import { PublicEndpoints } from "../public"
|
|
12
|
+
import { reset_test_tenant } from "./setup"
|
|
12
13
|
|
|
13
14
|
const host = process.env.API_URL || 'http://localhost:8080'
|
|
14
15
|
const [email, password] = [process.env.TEST_EMAIL, process.env.TEST_PASSWORD]
|
|
@@ -18,7 +19,7 @@ const sdkPub = PublicEndpoints({ host })
|
|
|
18
19
|
|
|
19
20
|
if (!(email && password)) {
|
|
20
21
|
console.error("Set TEST_EMAIL and TEST_PASSWORD")
|
|
21
|
-
process.exit()
|
|
22
|
+
process.exit(1) // 1, not 0: an unconfigured run must not report success
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const enduser_login_tests = async () => {
|
|
@@ -59,7 +60,7 @@ const enduser_login_tests = async () => {
|
|
|
59
60
|
|
|
60
61
|
try {
|
|
61
62
|
await sdk.authenticate(email, password)
|
|
62
|
-
await sdk
|
|
63
|
+
await reset_test_tenant(sdk)
|
|
63
64
|
|
|
64
65
|
await enduser_login_tests()
|
|
65
66
|
} catch(err) {
|