slack-objects 0.0.8__tar.gz → 0.1.0__tar.gz

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.
Files changed (73) hide show
  1. {slack_objects-0.0.8 → slack_objects-0.1.0}/.gitignore +4 -1
  2. {slack_objects-0.0.8 → slack_objects-0.1.0}/PKG-INFO +1 -1
  3. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/_version.py +3 -3
  4. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/users.py +72 -37
  5. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/PKG-INFO +1 -1
  6. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/SOURCES.txt +1 -2
  7. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/conftest_live.py +5 -5
  8. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/run_all_scim_users_live_tests.py +1 -0
  9. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_input_validation_live.py +14 -0
  10. slack_objects-0.1.0/tests/SCIM/test_scim_users_update_email_live.py +160 -0
  11. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/users_smoke_test.py +10 -0
  12. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/users_unit_test.py +139 -2
  13. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/conftest_live.py +5 -5
  14. slack_objects-0.0.8/.vs/VSWorkspaceState.json +0 -8
  15. slack_objects-0.0.8/.vs/slnx.sqlite +0 -0
  16. {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/copilot-instructions.md +0 -0
  17. {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/ci.yml +0 -0
  18. {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/publish-pypi.yml +0 -0
  19. {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/publish-testpypi.yml +0 -0
  20. {slack_objects-0.0.8 → slack_objects-0.1.0}/LICENSE +0 -0
  21. {slack_objects-0.0.8 → slack_objects-0.1.0}/README.md +0 -0
  22. {slack_objects-0.0.8 → slack_objects-0.1.0}/pyproject.toml +0 -0
  23. {slack_objects-0.0.8 → slack_objects-0.1.0}/pytest.ini +0 -0
  24. {slack_objects-0.0.8 → slack_objects-0.1.0}/setup.cfg +0 -0
  25. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/__init__.py +0 -0
  26. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/api_caller.py +0 -0
  27. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/base.py +0 -0
  28. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/client.py +0 -0
  29. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/config.py +0 -0
  30. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/conversations.py +0 -0
  31. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/files.py +0 -0
  32. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/idp_groups.py +0 -0
  33. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/messages.py +0 -0
  34. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/rate_limits.py +0 -0
  35. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/scim_base.py +0 -0
  36. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/usergroups.py +0 -0
  37. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/workspaces.py +0 -0
  38. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  39. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/requires.txt +0 -0
  40. {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/top_level.txt +0 -0
  41. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/examples_config.example.json +0 -0
  42. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/users_search_by_email_example.py +0 -0
  43. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  44. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  45. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
  46. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  47. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  48. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  49. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
  50. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/_smoke_harness.py +0 -0
  51. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
  52. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/conversations_smoke_test.py +0 -0
  53. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/files_smoke_test.py +0 -0
  54. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  55. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/messages_smoke_test.py +0 -0
  56. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/run_all_smoke.py +0 -0
  57. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/security_smoke_test.py +0 -0
  58. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
  59. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
  60. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/config_unit_test.py +0 -0
  61. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/conversations_unit_test.py +0 -0
  62. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/files_unit_test.py +0 -0
  63. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/idp_groups_unit_test.py +0 -0
  64. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/messages_unit_test.py +0 -0
  65. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  66. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/usergroups_test.py +0 -0
  67. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
  68. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/run_all_users_live_tests.py +0 -0
  69. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_admin_live.py +0 -0
  70. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_classification_live.py +0 -0
  71. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_input_validation_live.py +0 -0
  72. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_read_live.py +0 -0
  73. {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/live_test_config.example.json +0 -0
@@ -29,4 +29,7 @@ src/slack_objects/_version.py
29
29
  /tests/Examples/__pycache__
30
30
 
31
31
  # Examples config (contains Key Vault / secret names)
32
- /tests/Examples/examples_config.json
32
+ /tests/Examples/examples_config.json
33
+ /.vs/slnx.sqlite
34
+ /.vs/VSWorkspaceState.json
35
+ /src/slack_objects/.vs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.0.8
3
+ Version: 0.1.0
4
4
  Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
5
5
  Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
6
6
  Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.8'
32
- __version_tuple__ = version_tuple = (0, 0, 8)
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
33
 
34
- __commit_id__ = commit_id = 'g41dd54231'
34
+ __commit_id__ = commit_id = 'gf712fe717'
@@ -268,7 +268,11 @@ class Users(ScimMixin, SlackObjectBase):
268
268
  if not uid:
269
269
  raise ValueError("is_active_scim requires user_id (passed or bound)")
270
270
 
271
- scim_resp = self._scim_request(path=f"Users/{uid}", method="GET")
271
+ scim_resp = self._scim_request(
272
+ path=f"Users/{uid}",
273
+ method="GET",
274
+ raise_for_status=False,
275
+ )
272
276
  if not scim_resp.ok:
273
277
  return False
274
278
  return bool(scim_resp.data.get("active", False))
@@ -511,15 +515,27 @@ class Users(ScimMixin, SlackObjectBase):
511
515
 
512
516
  identifier = identifier.strip()
513
517
 
518
+ # Normalize: strip leading '@' so "@U1234" falls into the user-ID
519
+ # path and "@alice" falls into the username path — no special stage needed.
520
+ if identifier.startswith("@"):
521
+ identifier = identifier.removeprefix("@")
522
+
514
523
  # ── 1. Slack user ID ──────────────────────────────────────
515
524
  if self._looks_like_user_id(identifier):
516
- resp = self.get_user_info(identifier)
517
- if resp.get("ok"):
518
- return identifier
525
+ try:
526
+ resp = self.get_user_info(identifier)
527
+ if resp.get("ok"):
528
+ return identifier
529
+ except SlackApiError:
530
+ pass # expected for deactivated / not-found users
519
531
 
520
532
  # Slow path: SCIM (active + deactivated)
521
533
  self.logger.info("Web API miss for %s — falling back to SCIM lookup", identifier)
522
- scim_resp = self._scim_request(path=f"Users/{identifier}", method="GET")
534
+ scim_resp = self._scim_request(
535
+ path=f"Users/{identifier}",
536
+ method="GET",
537
+ raise_for_status=False,
538
+ )
523
539
  if scim_resp.ok and scim_resp.data.get("id"):
524
540
  return scim_resp.data["id"]
525
541
 
@@ -528,12 +544,9 @@ class Users(ScimMixin, SlackObjectBase):
528
544
  # ── 2. Email address ──────────────────────────────────────
529
545
  if EMAIL_RE.match(identifier):
530
546
  # Fast path: Web API (active users only)
531
- try:
532
- uid = self.get_user_id_from_email(identifier)
533
- if uid:
534
- return uid
535
- except SlackApiError:
536
- pass # expected for deactivated / not-found users
547
+ uid = self.get_user_id_from_email(identifier)
548
+ if uid:
549
+ return uid
537
550
 
538
551
  # Slow path: SCIM (active + deactivated)
539
552
  self.logger.info("Web API miss for %s — falling back to SCIM search", identifier)
@@ -543,32 +556,9 @@ class Users(ScimMixin, SlackObjectBase):
543
556
 
544
557
  raise LookupError(f"No user found for email: {identifier}")
545
558
 
546
- # ── 3. @username handle ───────────────────────────────────
547
- if identifier.startswith("@"):
548
- stripped = identifier.removeprefix("@")
549
-
550
- # The caller may have passed "@U1234" — recognise the ID
551
- # and verify via the cheaper Web API instead of SCIM.
552
- if self._looks_like_user_id(stripped):
553
- resp = self.get_user_info(stripped)
554
- if resp.get("ok"):
555
- return stripped
556
-
557
- # Slow path: SCIM (active + deactivated)
558
- self.logger.info("Web API miss for %s — falling back to SCIM lookup", stripped)
559
- scim_resp = self._scim_request(path=f"Users/{stripped}", method="GET")
560
- if scim_resp.ok and scim_resp.data.get("id"):
561
- return scim_resp.data["id"]
562
-
563
- raise LookupError(f"No user found for user ID: {stripped}")
564
-
565
- uid = self._first_scim_user_id(self.scim_search_user_by_username(stripped))
566
- if uid:
567
- return uid
568
-
569
- raise LookupError(f"No user found for username: @{stripped}")
570
-
571
- # ── 4. Bare string — try as username via SCIM ─────────────
559
+ # ── 3. Bare string / username — SCIM only ─────────────────
560
+ # No Web API endpoint exists for username lookup; SCIM is the
561
+ # only option. This also handles "@alice" (already stripped above).
572
562
  uid = self._first_scim_user_id(self.scim_search_user_by_username(identifier))
573
563
  if uid:
574
564
  return uid
@@ -656,6 +646,51 @@ class Users(ScimMixin, SlackObjectBase):
656
646
 
657
647
  return self._scim_request(path=f"Users/{user_id}", method="PATCH", payload=payload)
658
648
 
649
+ def scim_update_email(
650
+ self,
651
+ *,
652
+ user_id: Optional[str] = None,
653
+ new_email: str,
654
+ ) -> ScimResponse:
655
+ """Update a user's primary email address via SCIM PATCH.
656
+
657
+ This is intentionally separate from :meth:`scim_update_user_attribute`
658
+ because email updates require a structurally different payload in both
659
+ SCIM versions (value-filter path in v2, typed email array in v1).
660
+
661
+ Accepts a plain email-address string and builds the version-correct
662
+ payload automatically:
663
+ - v2: value-filter path ``emails[primary eq true].value`` to avoid
664
+ the "more than one primary element" conflict.
665
+ - v1: wraps the address in the standard SCIM email array.
666
+ """
667
+ uid = user_id or self.user_id
668
+ if not uid:
669
+ raise ValueError("scim_update_email requires user_id (passed or bound)")
670
+ validate_scim_id(uid, "user_id")
671
+
672
+ scim_version = self.cfg.scim_version
673
+ if scim_version == "v2":
674
+ payload = {
675
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
676
+ "Operations": [
677
+ {
678
+ "op": "replace",
679
+ "path": "emails[primary eq true].value",
680
+ "value": new_email,
681
+ }
682
+ ],
683
+ }
684
+ elif scim_version == "v1":
685
+ payload = {
686
+ "schemas": ["urn:scim:schemas:core:1.0"],
687
+ "emails": [{"value": new_email, "type": "work", "primary": True}],
688
+ }
689
+ else:
690
+ raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
691
+
692
+ return self._scim_request(path=f"Users/{uid}", method="PATCH", payload=payload)
693
+
659
694
  def make_multi_channel_guest(self, user_id: Optional[str] = None) -> ScimResponse:
660
695
  """Convert a user to a multi-channel guest via SCIM PATCH."""
661
696
  uid = user_id or self.user_id
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.0.8
3
+ Version: 0.1.0
4
4
  Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
5
5
  Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
6
6
  Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
@@ -7,8 +7,6 @@ pytest.ini
7
7
  .github/workflows/ci.yml
8
8
  .github/workflows/publish-pypi.yml
9
9
  .github/workflows/publish-testpypi.yml
10
- .vs/VSWorkspaceState.json
11
- .vs/slnx.sqlite
12
10
  src/slack_objects/__init__.py
13
11
  src/slack_objects/_version.py
14
12
  src/slack_objects/api_caller.py
@@ -42,6 +40,7 @@ tests/SCIM/test_scim_users_input_validation_live.py
42
40
  tests/SCIM/test_scim_users_make_guest_live.py
43
41
  tests/SCIM/test_scim_users_reactivate_live.py
44
42
  tests/SCIM/test_scim_users_update_attribute_live.py
43
+ tests/SCIM/test_scim_users_update_email_live.py
45
44
  tests/Smoke/_smoke_harness.py
46
45
  tests/Smoke/api_caller_smoke_test.py
47
46
  tests/Smoke/conversations_smoke_test.py
@@ -7,8 +7,8 @@ Test-user IDs and other identifiers are loaded from a JSON file.
7
7
  Setup
8
8
  -----
9
9
  1. Store your Slack tokens in Azure Key Vault:
10
- Bot-token-SB – xoxb-... bot token with users:read and users:read.email scopes
11
- User-token-SB – xoxp-... user token with admin scope (org-level admin + SCIM provisioning)
10
+ Bot-token-TEST – xoxb-... bot token with users:read and users:read.email scopes
11
+ User-token-TEST – xoxp-... user token with admin scope (org-level admin + SCIM provisioning)
12
12
 
13
13
  2. Set the ``_KEYVAULT_NAME`` constant in this file to your Azure Key Vault name.
14
14
 
@@ -51,9 +51,9 @@ _DEFAULT_CONFIG_PATH = _HERE.parent / "live_test_config.json"
51
51
  _KEYVAULT_NAME = "Slack-AI-assistant-KV"
52
52
 
53
53
  # Key Vault secret names
54
- _SECRET_BOT_TOKEN = "Bot-token-SB"
55
- _SECRET_USER_TOKEN = "User-token-SB"
56
- _SECRET_SCIM_TOKEN = "User-token-SB"
54
+ _SECRET_BOT_TOKEN = "Bot-token-TEST"
55
+ _SECRET_USER_TOKEN = "User-token-TEST"
56
+ _SECRET_SCIM_TOKEN = "User-token-TEST"
57
57
 
58
58
 
59
59
  # ---------------------------------------------------------------------------
@@ -25,6 +25,7 @@ _TEST_FILES = [
25
25
  "test_scim_users_input_validation_live.py", # no API calls
26
26
  "test_scim_users_reactivate_live.py", # read-mostly (no-ops on active users)
27
27
  "test_scim_users_update_attribute_live.py", # mutating, reversible
28
+ "test_scim_users_update_email_live.py", # mutating, reversible
28
29
  "test_scim_users_create_live.py", # self-contained (create + delete)
29
30
  "test_scim_users_deactivate_live.py", # mutating, reversible (strips workspaces)
30
31
  "test_scim_users_make_guest_live.py", # DESTRUCTIVE (disposable users only)
@@ -99,4 +99,18 @@ class TestScimInputValidation:
99
99
  with patch.object(users, "_scim_request", wraps=users._scim_request) as spy:
100
100
  with pytest.raises(ValueError):
101
101
  users.make_multi_channel_guest(bad_id)
102
+ spy.assert_not_called()
103
+
104
+ @pytest.mark.parametrize("bad_id", [
105
+ "../traversal",
106
+ "",
107
+ "id with spaces",
108
+ ])
109
+ def test_update_email_rejects_bad_ids(self, users, bad_id):
110
+ with patch.object(users, "_scim_request", wraps=users._scim_request) as spy:
111
+ with pytest.raises(ValueError):
112
+ users.scim_update_email(
113
+ user_id=bad_id,
114
+ new_email="test@example.com",
115
+ )
102
116
  spy.assert_not_called()
@@ -0,0 +1,160 @@
1
+ """
2
+ Live integration tests for scim_update_email.
3
+
4
+ MUTATING but REVERSIBLE — every email change is followed by a restore
5
+ to the original email in teardown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Dict, Optional
12
+
13
+ import pytest
14
+ import requests
15
+
16
+ from slack_objects.users import Users
17
+
18
+ from conftest_live import LiveTestContext, build_live_context
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Fixtures
22
+ # ---------------------------------------------------------------------------
23
+
24
+ _ctx: Optional[LiveTestContext] = None
25
+
26
+
27
+ def _get_ctx() -> LiveTestContext:
28
+ global _ctx
29
+ if _ctx is None:
30
+ _ctx = build_live_context()
31
+ return _ctx
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def ctx() -> LiveTestContext:
36
+ return _get_ctx()
37
+
38
+
39
+ @pytest.fixture(scope="module")
40
+ def users(ctx: LiveTestContext) -> Users:
41
+ return ctx.slack.users()
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Helpers
46
+ # ---------------------------------------------------------------------------
47
+
48
+ _SCIM_PAUSE = 4.0
49
+
50
+
51
+ def _pause():
52
+ time.sleep(_SCIM_PAUSE)
53
+
54
+
55
+ def _scim_get_user(users: Users, user_id: str) -> Dict[str, Any]:
56
+ resp = users._scim_request(path=f"Users/{user_id}", method="GET")
57
+ assert resp.ok, f"SCIM GET Users/{user_id} failed: {resp.data}"
58
+ return resp.data
59
+
60
+
61
+ def _get_primary_email(scim_data: Dict[str, Any]) -> str:
62
+ """Extract the primary email from a SCIM user resource."""
63
+ for email in scim_data.get("emails", []):
64
+ if email.get("primary", False):
65
+ return email.get("value", "")
66
+ return ""
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Tests
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class TestScimUpdateEmail:
74
+ """scim_update_email — identifier × type matrix."""
75
+
76
+ # ----- active member -----
77
+
78
+ def test_update_email_active_member(self, ctx, users):
79
+ """Update primary email on an active member, verify, then restore."""
80
+ original_data = _scim_get_user(users, ctx.active_member_id)
81
+ original_email = _get_primary_email(original_data)
82
+ assert original_email, "Could not determine original email"
83
+ _pause()
84
+
85
+ new_email = f"scim-test-{int(time.time())}@example.com"
86
+ resp = users.scim_update_email(
87
+ user_id=ctx.active_member_id,
88
+ new_email=new_email,
89
+ )
90
+ assert resp.ok, f"Expected ok: {resp.data}"
91
+ _pause()
92
+
93
+ updated = _scim_get_user(users, ctx.active_member_id)
94
+ assert _get_primary_email(updated) == new_email
95
+ _pause()
96
+
97
+ # Teardown: restore original email
98
+ users.scim_update_email(
99
+ user_id=ctx.active_member_id,
100
+ new_email=original_email,
101
+ )
102
+ _pause()
103
+
104
+ # ----- active admin -----
105
+
106
+ def test_update_email_active_admin(self, ctx, users):
107
+ """Update primary email on an admin, verify, then restore."""
108
+ original_data = _scim_get_user(users, ctx.active_admin_id)
109
+ original_email = _get_primary_email(original_data)
110
+ assert original_email, "Could not determine original email"
111
+ _pause()
112
+
113
+ new_email = f"scim-admin-test-{int(time.time())}@example.com"
114
+ resp = users.scim_update_email(
115
+ user_id=ctx.active_admin_id,
116
+ new_email=new_email,
117
+ )
118
+ assert resp.ok, f"Expected ok for admin: {resp.data}"
119
+ _pause()
120
+
121
+ # Teardown: restore
122
+ users.scim_update_email(
123
+ user_id=ctx.active_admin_id,
124
+ new_email=original_email,
125
+ )
126
+ _pause()
127
+
128
+ # ----- bound user_id (no explicit user_id) -----
129
+
130
+ def test_update_email_bound_user(self, ctx, users):
131
+ """Update email using bound user_id (no explicit user_id arg)."""
132
+ bound = users.with_user(ctx.active_member_id)
133
+ original_data = _scim_get_user(bound, ctx.active_member_id)
134
+ original_email = _get_primary_email(original_data)
135
+ assert original_email, "Could not determine original email"
136
+ _pause()
137
+
138
+ new_email = f"scim-bound-test-{int(time.time())}@example.com"
139
+ resp = bound.scim_update_email(new_email=new_email)
140
+ assert resp.ok, f"Expected ok for bound user: {resp.data}"
141
+ _pause()
142
+
143
+ # Teardown: restore
144
+ bound.scim_update_email(new_email=original_email)
145
+ _pause()
146
+
147
+ # ----- deactivated user -----
148
+
149
+ def test_update_email_deactivated_user(self, ctx, users):
150
+ """Updating email on a deactivated user should fail or be a no-op."""
151
+ try:
152
+ resp = users.scim_update_email(
153
+ user_id=ctx.deactivated_user_id,
154
+ new_email="should-not-apply@example.com",
155
+ )
156
+ except requests.HTTPError as exc:
157
+ assert exc.response.status_code in (400, 403, 404), (
158
+ f"Unexpected status for deactivated user: {exc.response.status_code}"
159
+ )
160
+ _pause()
@@ -161,6 +161,14 @@ def main() -> None:
161
161
  "scim_update_user_attribute()",
162
162
  lambda: bound.scim_update_user_attribute(user_id="U1", attribute="active", new_value=False),
163
163
  ),
164
+ CallSpec(
165
+ "scim_update_email(by id)",
166
+ lambda: bound.scim_update_email(user_id="U1", new_email="newemail@example.com"),
167
+ ),
168
+ CallSpec(
169
+ "scim_update_email(bound)",
170
+ lambda: bound.scim_update_email(new_email="newemail@example.com"),
171
+ ),
164
172
  CallSpec("make_multi_channel_guest()", lambda: bound.make_multi_channel_guest()),
165
173
 
166
174
  # SCIM search primitives
@@ -169,6 +177,8 @@ def main() -> None:
169
177
 
170
178
  # Identifier resolution
171
179
  CallSpec("resolve_user_id(user_id)", lambda: bound.resolve_user_id("U1")),
180
+ CallSpec("resolve_user_id(email)", lambda: bound.resolve_user_id("found@example.com")),
181
+ CallSpec("resolve_user_id(@username)", lambda: bound.resolve_user_id("@testuser")),
172
182
  ]
173
183
 
174
184
  run_smoke("Users smoke (all public methods)", specs)
@@ -3,6 +3,7 @@ from typing import Any, Dict, Optional
3
3
  from unittest.mock import MagicMock
4
4
 
5
5
  import pytest
6
+ from slack_sdk.errors import SlackApiError
6
7
 
7
8
  from slack_objects.client import SlackObjectsClient
8
9
  from slack_objects.config import SlackObjectsConfig, RateTier, USER_ID_RE
@@ -182,6 +183,9 @@ class TestResolveUserId:
182
183
  )
183
184
  with pytest.raises(LookupError, match="No user found for user ID"):
184
185
  users.resolve_user_id("U00GHOST")
186
+ users._scim_request.assert_called_once_with(
187
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
188
+ )
185
189
 
186
190
  def test_email_active_user(self):
187
191
  """Active user email resolves via Web API (fast path)."""
@@ -221,7 +225,7 @@ class TestResolveUserId:
221
225
  scim_resp = ScimResponse(ok=True, status_code=200, data={"Resources": []}, text="")
222
226
  users.scim_search_user_by_username = MagicMock(return_value=scim_resp)
223
227
 
224
- with pytest.raises(LookupError, match="No user found for username"):
228
+ with pytest.raises(LookupError, match="No user found for identifier"):
225
229
  users.resolve_user_id("@ghost")
226
230
 
227
231
  def test_bare_username(self):
@@ -258,6 +262,43 @@ class TestResolveUserId:
258
262
  )
259
263
  with pytest.raises(LookupError, match="No user found for user ID"):
260
264
  users.resolve_user_id("@U00GHOST")
265
+ users._scim_request.assert_called_once_with(
266
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
267
+ )
268
+
269
+ def test_user_id_slack_api_error_falls_back_to_scim(self):
270
+ """SlackApiError from users.info falls through to SCIM lookup."""
271
+ users = _make_users()
272
+ users.get_user_info = MagicMock(
273
+ side_effect=SlackApiError("users_not_found", response=MagicMock())
274
+ )
275
+ scim_resp = ScimResponse(
276
+ ok=True, status_code=200,
277
+ data={"id": "USCIM", "userName": "scim-user", "active": True},
278
+ text="",
279
+ )
280
+ users._scim_request = MagicMock(return_value=scim_resp)
281
+
282
+ assert users.resolve_user_id("USCIM") == "USCIM"
283
+ users._scim_request.assert_called_once_with(
284
+ path="Users/USCIM", method="GET", raise_for_status=False,
285
+ )
286
+
287
+ def test_user_id_slack_api_error_and_scim_miss_raises(self):
288
+ """SlackApiError + SCIM miss still raises LookupError."""
289
+ users = _make_users()
290
+ users.get_user_info = MagicMock(
291
+ side_effect=SlackApiError("users_not_found", response=MagicMock())
292
+ )
293
+ users._scim_request = MagicMock(
294
+ return_value=ScimResponse(ok=False, status_code=404, data={}, text="")
295
+ )
296
+ with pytest.raises(LookupError, match="No user found for user ID"):
297
+ users.resolve_user_id("U00GHOST")
298
+ users._scim_request.assert_called_once_with(
299
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
300
+ )
301
+
261
302
 
262
303
  # ═══════════════════════════════════════════════════════════════════════════
263
304
  # is_user_authorized
@@ -373,7 +414,9 @@ class TestIsActiveScim:
373
414
  return_value=ScimResponse(ok=True, status_code=200, data={"id": "U_OTHER", "active": True}, text=""),
374
415
  )
375
416
  assert bound.is_active_scim(user_id="U_OTHER") is True
376
- bound._scim_request.assert_called_once_with(path="Users/U_OTHER", method="GET")
417
+ bound._scim_request.assert_called_once_with(
418
+ path="Users/U_OTHER", method="GET", raise_for_status=False,
419
+ )
377
420
 
378
421
  def test_no_user_id_raises(self):
379
422
  """Unbound instance with no user_id raises ValueError."""
@@ -436,3 +479,97 @@ class TestIsActiveWithUserId:
436
479
  bound.get_user_info = MagicMock()
437
480
  assert bound.is_active(user_id="U123") is True
438
481
  bound.get_user_info.assert_not_called()
482
+
483
+
484
+ # ═══════════════════════════════════════════════════════════════════════════
485
+ # scim_update_email
486
+ # ═══════════════════════════════════════════════════════════════════════════
487
+
488
+ class TestScimUpdateEmail:
489
+ """scim_update_email — version-aware SCIM PATCH for primary email."""
490
+
491
+ def test_v2_payload_uses_value_filter_path(self):
492
+ """v2 uses emails[primary eq true].value to avoid multi-primary conflict."""
493
+ users = _make_users()
494
+ bound = users.with_user("U1")
495
+ bound._scim_request = MagicMock(
496
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
497
+ )
498
+ bound.scim_update_email(user_id="U1", new_email="new@example.com")
499
+
500
+ bound._scim_request.assert_called_once()
501
+ _, kwargs = bound._scim_request.call_args
502
+ op = kwargs["payload"]["Operations"][0]
503
+ assert op["op"] == "replace"
504
+ assert op["path"] == "emails[primary eq true].value"
505
+ assert op["value"] == "new@example.com"
506
+
507
+ def test_v1_payload_uses_email_array(self):
508
+ """v1 wraps address in standard SCIM email array with primary+type."""
509
+ users = _make_users()
510
+ bound = users.with_user("U1")
511
+ object.__setattr__(bound.cfg, "scim_version", "v1")
512
+ bound._scim_request = MagicMock(
513
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
514
+ )
515
+ bound.scim_update_email(user_id="U1", new_email="new@example.com")
516
+
517
+ bound._scim_request.assert_called_once()
518
+ _, kwargs = bound._scim_request.call_args
519
+ emails = kwargs["payload"]["emails"]
520
+ assert len(emails) == 1
521
+ assert emails[0]["value"] == "new@example.com"
522
+ assert emails[0]["primary"] is True
523
+ assert emails[0]["type"] == "work"
524
+
525
+ def test_falls_back_to_bound_user_id(self):
526
+ """Omitting user_id uses self.user_id."""
527
+ users = _make_users()
528
+ bound = users.with_user("U1")
529
+ bound._scim_request = MagicMock(
530
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
531
+ )
532
+ bound.scim_update_email(new_email="new@example.com")
533
+
534
+ bound._scim_request.assert_called_once()
535
+ _, kwargs = bound._scim_request.call_args
536
+ assert kwargs["path"] == "Users/U1"
537
+
538
+ def test_explicit_user_id_overrides_bound(self):
539
+ """Passing user_id explicitly takes precedence over bound user_id."""
540
+ users = _make_users()
541
+ bound = users.with_user("U_IGNORED")
542
+ bound._scim_request = MagicMock(
543
+ return_value=ScimResponse(ok=True, status_code=200, data={}, text=""),
544
+ )
545
+ bound.scim_update_email(user_id="U_OTHER", new_email="new@example.com")
546
+
547
+ bound._scim_request.assert_called_once()
548
+ _, kwargs = bound._scim_request.call_args
549
+ assert kwargs["path"] == "Users/U_OTHER"
550
+
551
+ def test_no_user_id_raises(self):
552
+ """Unbound instance with no user_id raises ValueError."""
553
+ users = _make_users()
554
+ with pytest.raises(ValueError, match="requires user_id"):
555
+ users.scim_update_email(new_email="x@example.com")
556
+
557
+ def test_invalid_scim_version_raises(self):
558
+ """Unsupported scim_version raises NotImplementedError."""
559
+ users = _make_users()
560
+ bound = users.with_user("U1")
561
+ object.__setattr__(bound.cfg, "scim_version", "v99")
562
+ with pytest.raises(NotImplementedError, match="Invalid SCIM version"):
563
+ bound.scim_update_email(new_email="x@example.com")
564
+
565
+ def test_invalid_user_id_raises(self):
566
+ """Path-traversal user_id is rejected by validate_scim_id."""
567
+ users = _make_users()
568
+ with pytest.raises(ValueError):
569
+ users.scim_update_email(user_id="../../bad", new_email="x@example.com")
570
+
571
+ def test_empty_user_id_raises(self):
572
+ """Empty user_id is rejected by validate_scim_id."""
573
+ users = _make_users()
574
+ with pytest.raises(ValueError):
575
+ users.scim_update_email(user_id="", new_email="x@example.com")
@@ -7,8 +7,8 @@ Test-user IDs and other identifiers are loaded from a JSON file.
7
7
  Setup
8
8
  -----
9
9
  1. Store your Slack tokens in Azure Key Vault:
10
- Bot-token-SB – xoxb-... bot token with users:read and users:read.email scopes
11
- User-token-SB – xoxp-... user token with admin scope
10
+ Bot-token-TEST – xoxb-... bot token with users:read and users:read.email scopes
11
+ User-token-TEST – xoxp-... user token with admin scope
12
12
 
13
13
  2. Set the ``_KEYVAULT_NAME`` constant in this file to your Azure Key Vault name.
14
14
 
@@ -48,9 +48,9 @@ _DEFAULT_CONFIG_PATH = _HERE.parent / "live_test_config.json"
48
48
  _KEYVAULT_NAME = "Slack-AI-assistant-KV"
49
49
 
50
50
  # Key Vault secret names
51
- _SECRET_BOT_TOKEN = "Bot-token-SB"
52
- _SECRET_USER_TOKEN = "User-token-SB"
53
- _SECRET_SCIM_TOKEN = "User-token-SB" # There's no separate SCIM token at the moment
51
+ _SECRET_BOT_TOKEN = "Bot-token-TEST"
52
+ _SECRET_USER_TOKEN = "User-token-TEST"
53
+ _SECRET_SCIM_TOKEN = "User-token-TEST" # There's no separate SCIM token at the moment
54
54
 
55
55
 
56
56
  # ---------------------------------------------------------------------------
@@ -1,8 +0,0 @@
1
- {
2
- "ExpandedNodes": [
3
- "",
4
- "\\src",
5
- "\\src\\slack-objects"
6
- ],
7
- "PreviewInSolutionExplorer": false
8
- }
Binary file
File without changes
File without changes
File without changes
File without changes